Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b583d5a365 | |||
| 32fa01cc12 | |||
| ac703eecd2 | |||
| d05de0aacd | |||
| 09b9c45665 | |||
| 32516f6e3b | |||
| 30f1b6e6db | |||
| 7808822a55 | |||
| e805c624b2 | |||
| f7b63fead5 | |||
| 2eb649eceb | |||
| 2076ab76fa | |||
| 2e3e7b3850 | |||
| c447dfe68d | |||
| 688a18af22 | |||
| 18ee77a4e4 | |||
| 3d331e4c72 | |||
| eebc86a52b | |||
| 56b919ea1f | |||
| 648320abfd | |||
| 7d252489de | |||
| 04319025de | |||
| 8bc209b27e | |||
| cbc2740e37 | |||
| 6919158012 | |||
| fd12e921fd | |||
| 7107815a5c | |||
| 38b2de54ff | |||
| c1610c93a1 | |||
| b1a66a1ab7 | |||
| b200025daa | |||
| 0c5698c903 | |||
| 14771ae990 | |||
| 7d49df3e7d | |||
| c13e274ca4 | |||
| d4f95b64d4 | |||
| 4d520ab0e3 | |||
| ca8927834e | |||
| a39dbf272c | |||
| 50eb76a10d | |||
| d7ad933b2a | |||
| 8c69911252 | |||
| 7b3e2ebace | |||
| cfb9977532 | |||
| 802a9202e9 | |||
| 7ab9b1ac59 | |||
| cbb703341e | |||
| a13f560df2 | |||
| 5eb49be697 | |||
| 8ff735d644 | |||
| d998e6ab0c | |||
| 9a6cbfae68 | |||
| c9c72be0b6 | |||
| 5ec35b4849 | |||
| 739ad38e29 | |||
| 1da67f38c7 | |||
| 41dddbccc0 | |||
| f6a86310cc | |||
| 10fd4ead4a | |||
| 2452e2e1e4 | |||
| fd534a816b | |||
| 8cdeadd6dd | |||
| d1819c0186 | |||
| 9459de5c07 | |||
| 9782280a03 | |||
| 0ad6a04053 | |||
| 75636c00d4 | |||
| f4b16b5844 | |||
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 |
@@ -0,0 +1,20 @@
|
|||||||
|
# .claude (index)
|
||||||
|
dir: .claude
|
||||||
|
|
||||||
|
## role
|
||||||
|
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
|
||||||
|
## parent
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## children
|
||||||
|
- .claude/skills
|
||||||
|
index: .claude/skills/.pi-map.index.md
|
||||||
|
map: .claude/skills/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: .claude/.pi-map.index.md
|
||||||
|
map: .claude/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# .claude
|
||||||
|
dir: .claude
|
||||||
|
|
||||||
|
index: .claude/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Flat configuration structure containing markdown/YAML files that define custom commands, project context, and operational rules for Claude's interactions with the codebase.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# .claude/skills (index)
|
||||||
|
dir: .claude/skills
|
||||||
|
|
||||||
|
## role
|
||||||
|
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
|
||||||
|
## parent
|
||||||
|
index: .claude/.pi-map.index.md
|
||||||
|
map: .claude/.pi-map.md
|
||||||
|
## children
|
||||||
|
- .claude/skills/sift-backlog
|
||||||
|
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||||
|
map: .claude/skills/sift-backlog/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: .claude/skills/.pi-map.index.md
|
||||||
|
map: .claude/skills/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# .claude/skills
|
||||||
|
dir: .claude/skills
|
||||||
|
|
||||||
|
index: .claude/skills/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Flat configuration file structure defining modular skill behaviors and prompts used to extend Claude's domain-specific abilities.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .claude/skills/sift-backlog (index)
|
||||||
|
dir: .claude/skills/sift-backlog
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
|
||||||
|
## parent
|
||||||
|
index: .claude/skills/.pi-map.index.md
|
||||||
|
map: .claude/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||||
|
map: .claude/skills/sift-backlog/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .claude/skills/sift-backlog
|
||||||
|
dir: .claude/skills/sift-backlog
|
||||||
|
|
||||||
|
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool. | dep: sf CLI (task, plan, dependency, update subcommands)
|
||||||
|
## arch
|
||||||
|
Single-file declarative skill definition following a prompt-driven workflow pattern with structured triage and activation instructions for Claude to execute.
|
||||||
|
## tags
|
||||||
|
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+8
-2
@@ -1,3 +1,7 @@
|
|||||||
|
# Manage environment template
|
||||||
|
# Copy this file to .env, fill in the required values, and export them in your shell
|
||||||
|
# before running docker compose. Compose files use interpolation, not env_file.
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_VERSION=0.1.0
|
APP_VERSION=0.1.0
|
||||||
APP_BUILD_INFO=dev
|
APP_BUILD_INFO=dev
|
||||||
@@ -23,8 +27,9 @@ PROMETHEUS_ENABLED=true
|
|||||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||||
ALERTMANAGER_URL=http://alertmanager:9093
|
ALERTMANAGER_URL=http://alertmanager:9093
|
||||||
ALERTMANAGER_WEBHOOK_URL=
|
ALERTMANAGER_WEBHOOK_URL=
|
||||||
GRAFANA_URL=http://grafana:3000
|
# Required: master key for encrypting service secrets (API keys/tokens) at rest.
|
||||||
PROMETHEUS_URL=http://prometheus:9090
|
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||||
BACKEND_CACHE_DIR=./backend-cache
|
BACKEND_CACHE_DIR=./backend-cache
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -44,6 +49,7 @@ VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
|||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
VITE_GRAFANA_URL=https://grafana.example.com
|
VITE_GRAFANA_URL=https://grafana.example.com
|
||||||
|
VITE_PROMETHEUS_URL=https://prometheus.example.com
|
||||||
|
|
||||||
# SMTP
|
# SMTP
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
@@ -55,5 +55,3 @@ frontend/dist/
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
# Local Pi runtime state
|
# Local Pi runtime state
|
||||||
.atl/
|
.atl/
|
||||||
.pi-map.md
|
|
||||||
.pi-map.index.md
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# .opencode (index)
|
||||||
|
dir: .opencode
|
||||||
|
|
||||||
|
## role
|
||||||
|
Configuration directory for the opencode tool, managing project-specific settings and preferences.
|
||||||
|
## parent
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## children
|
||||||
|
- .opencode/commands
|
||||||
|
index: .opencode/commands/.pi-map.index.md
|
||||||
|
map: .opencode/commands/.pi-map.md
|
||||||
|
- .opencode/skills
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: .opencode/.pi-map.index.md
|
||||||
|
map: .opencode/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# .opencode
|
||||||
|
dir: .opencode
|
||||||
|
|
||||||
|
index: .opencode/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Configuration directory for the opencode tool, managing project-specific settings and preferences.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Flat directory structure containing configuration files that define opencode behavior for the associated project.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# .opencode/commands (index)
|
||||||
|
dir: .opencode/commands
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive).
|
||||||
|
## parent
|
||||||
|
index: .opencode/.pi-map.index.md
|
||||||
|
map: .opencode/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- opsx-apply.md
|
||||||
|
- opsx-archive.md
|
||||||
|
- opsx-explore.md
|
||||||
|
- opsx-propose.md
|
||||||
|
## links
|
||||||
|
index: .opencode/commands/.pi-map.index.md
|
||||||
|
map: .opencode/commands/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# .opencode/commands
|
||||||
|
dir: .opencode/commands
|
||||||
|
|
||||||
|
index: .opencode/commands/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive).
|
||||||
|
## files
|
||||||
|
- opsx-apply.md | Defines a workflow for implementing tasks from an OpenSpec change in a structured, iterative manner with pause points for blockers and ambiguity. | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||||
|
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development process, including validation, spec sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem (mkdir, mv), tasks.md
|
||||||
|
- opsx-explore.md | Defines the explore mode stance for a thinking/discussion assistant that investigates problems and clarifies requirements without implementing code | dep: OpenSpec system (openspec CLI, change artifacts like proposal.md/design.md/tasks.md/spec.md)
|
||||||
|
- opsx-propose.md | Defines a workflow for creating a new change with all required planning artifacts (proposal, design, tasks) in a single step using the openspec CLI tool. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool, file system
|
||||||
|
## arch
|
||||||
|
Markdown-based declarative templates serving as structured prompts/playbooks that guide an AI assistant through specific operational phases of a spec-driven lifecycle.
|
||||||
|
## tags
|
||||||
|
opsx, defines, tasks, md, workflow, openspec, openspec cli, askuserquestion tool
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# .opencode/skills (index)
|
||||||
|
dir: .opencode/skills
|
||||||
|
|
||||||
|
## role
|
||||||
|
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
|
||||||
|
## parent
|
||||||
|
index: .opencode/.pi-map.index.md
|
||||||
|
map: .opencode/.pi-map.md
|
||||||
|
## children
|
||||||
|
- .opencode/skills/openspec-apply-change
|
||||||
|
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-apply-change/.pi-map.md
|
||||||
|
- .opencode/skills/openspec-archive-change
|
||||||
|
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-archive-change/.pi-map.md
|
||||||
|
- .opencode/skills/openspec-explore
|
||||||
|
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||||
|
- .opencode/skills/openspec-propose
|
||||||
|
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# .opencode/skills
|
||||||
|
dir: .opencode/skills
|
||||||
|
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Configuration-based skill definition directory; skills are declared as individual files consumed by the opencode agent runtime to extend or specialize assistant behavior.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-apply-change (index)
|
||||||
|
dir: .opencode/skills/openspec-apply-change
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking.
|
||||||
|
## parent
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-apply-change/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-apply-change
|
||||||
|
dir: .opencode/skills/openspec-apply-change
|
||||||
|
|
||||||
|
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a skill for implementing tasks from an OpenSpec change using a schema-driven workflow with progress tracking and contextual file reading. | dep: openspec CLI, AskUserQuestion tool
|
||||||
|
## arch
|
||||||
|
Documentation-based skill specification using markdown with defined workflow steps, schema references, and contextual file reading rules.
|
||||||
|
## tags
|
||||||
|
skill, defines, implementing, tasks, openspec, change, schema, driven
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-archive-change (index)
|
||||||
|
dir: .opencode/skills/openspec-archive-change
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
|
||||||
|
## parent
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-archive-change/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-archive-change
|
||||||
|
dir: .opencode/skills/openspec-archive-change
|
||||||
|
|
||||||
|
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a skill for archiving a completed change in the openspec experimental workflow, including validation, sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool (subagent_type: general-purpose), openspec-sync-specs skill
|
||||||
|
## arch
|
||||||
|
Single-document declarative skill specification following a procedural checklist pattern (validate, assess sync, confirm) designed for an AI agent to execute.
|
||||||
|
## tags
|
||||||
|
skill, openspec, sync, defines, archiving, completed, change, experimental
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-explore (index)
|
||||||
|
dir: .opencode/skills/openspec-explore
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
|
||||||
|
## parent
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-explore
|
||||||
|
dir: .opencode/skills/openspec-explore
|
||||||
|
|
||||||
|
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines a conversational "explore mode" skill for the OpenSpec CLI that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
|
||||||
|
## arch
|
||||||
|
Skill-definition pattern using a single Markdown file (SKILL.md) that declaratively specifies the assistant's behavioral constraints, workflow, and operational guidelines.
|
||||||
|
## tags
|
||||||
|
skill, defines, conversational, explore, mode, openspec, cli, acts
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-propose (index)
|
||||||
|
dir: .opencode/skills/openspec-propose
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
|
||||||
|
## parent
|
||||||
|
index: .opencode/skills/.pi-map.index.md
|
||||||
|
map: .opencode/skills/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- SKILL.md
|
||||||
|
## links
|
||||||
|
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||||
|
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .opencode/skills/openspec-propose
|
||||||
|
dir: .opencode/skills/openspec-propose
|
||||||
|
|
||||||
|
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
|
||||||
|
## files
|
||||||
|
- SKILL.md | Defines an AI assistant skill that automates proposing new changes by scaffolding a directory, generating dependent artifacts (proposal, design, tasks), and tracking progress through a structured workflow using the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||||
|
## arch
|
||||||
|
Skill-definition pattern using a declarative markdown document (SKILL.md) that encodes a step-by-step procedural workflow with CLI integration conventions.
|
||||||
|
## tags
|
||||||
|
skill, defines, assistant, automates, proposing, new, changes, scaffolding
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# . (index)
|
||||||
|
dir: .
|
||||||
|
|
||||||
|
## Project Map Protocol
|
||||||
|
|
||||||
|
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||||
|
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||||
|
3. Load indexes before rich maps during task-start navigation.
|
||||||
|
4. Read the local rich map and actual source before editing.
|
||||||
|
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||||
|
6. If source and generated artifacts disagree, trust source.
|
||||||
|
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
8. After editing source, run `project_map_patch` for each changed file.
|
||||||
|
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||||
|
|
||||||
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
|
## role
|
||||||
|
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||||
|
## parent
|
||||||
|
-
|
||||||
|
## children
|
||||||
|
- .atl
|
||||||
|
index: .atl/.pi-map.index.md
|
||||||
|
map: .atl/.pi-map.md
|
||||||
|
- .claude
|
||||||
|
index: .claude/.pi-map.index.md
|
||||||
|
map: .claude/.pi-map.md
|
||||||
|
- .opencode
|
||||||
|
index: .opencode/.pi-map.index.md
|
||||||
|
map: .opencode/.pi-map.md
|
||||||
|
- .pi
|
||||||
|
index: .pi/.pi-map.index.md
|
||||||
|
map: .pi/.pi-map.md
|
||||||
|
- .ruff_cache
|
||||||
|
index: .ruff_cache/.pi-map.index.md
|
||||||
|
map: .ruff_cache/.pi-map.md
|
||||||
|
- archive
|
||||||
|
index: archive/.pi-map.index.md
|
||||||
|
map: archive/.pi-map.md
|
||||||
|
- backend
|
||||||
|
index: backend/.pi-map.index.md
|
||||||
|
map: backend/.pi-map.md
|
||||||
|
- docs
|
||||||
|
index: docs/.pi-map.index.md
|
||||||
|
map: docs/.pi-map.md
|
||||||
|
- frontend
|
||||||
|
index: frontend/.pi-map.index.md
|
||||||
|
map: frontend/.pi-map.md
|
||||||
|
- monitoring
|
||||||
|
index: monitoring/.pi-map.index.md
|
||||||
|
map: monitoring/.pi-map.md
|
||||||
|
- openspec
|
||||||
|
index: openspec/.pi-map.index.md
|
||||||
|
map: openspec/.pi-map.md
|
||||||
|
## files
|
||||||
|
- .dockerignore
|
||||||
|
- .env.example
|
||||||
|
- .gitignore
|
||||||
|
- AGENTS.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
- CONTRIBUTING.md
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
- context.md
|
||||||
|
- docker-compose.dev.yml
|
||||||
|
- docker-compose.observability.yml
|
||||||
|
- docker-compose.yml
|
||||||
|
- swap-pane
|
||||||
|
- token-usage-output.txt
|
||||||
|
## links
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# .
|
||||||
|
dir: .
|
||||||
|
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
|
||||||
|
## Project Map Protocol
|
||||||
|
|
||||||
|
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||||
|
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||||
|
3. Load indexes before rich maps during task-start navigation.
|
||||||
|
4. Read the local rich map and actual source before editing.
|
||||||
|
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||||
|
6. If source and generated artifacts disagree, trust source.
|
||||||
|
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
8. After editing source, run `project_map_patch` for each changed file.
|
||||||
|
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||||
|
|
||||||
|
Trust boundary: index routes, map orients, source decides.
|
||||||
|
|
||||||
|
## role
|
||||||
|
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||||
|
## files
|
||||||
|
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
|
||||||
|
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
|
||||||
|
- .gitignore | Configures Git to ignore Python artifacts, virtual environments, secrets, editor files, frontend builds, and tool-specific metadata from version control.
|
||||||
|
- AGENTS.md | Provides project-specific guidance for AI agents working on a media library viewer application with FastAPI backend and Vite React frontend | dep: FastAPI, Vite, React, Docker Compose, uvicorn, pytest, Ruff, TypeScript, Python 3.11
|
||||||
|
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across recent versions.
|
||||||
|
- CONTRIBUTING.md | Provides contribution guidelines and setup instructions for the Manage project's backend (FastAPI) and frontend (React) codebases. | dep: FastAPI, React, Vite, TypeScript, Ruff, pytest, Docker Compose, Tailwind CSS, TanStack Query
|
||||||
|
- LICENSE | Provides the MIT open-source software license terms for the project
|
||||||
|
- README.md | Project README documenting a media and server operations tool with Jellyfin integration, SSH file inspection, and server monitoring capabilities. | dep: FastAPI, React, TypeScript, Docker Compose, SQLite, Traefik, OIDC/Authentik, Jellyfin, Prometheus, Grafana, Alertmanager
|
||||||
|
- context.md | Documentation file providing a historical and architectural overview of an observability stack (Prometheus, Grafana, Loki, Alertmanager) for a containerized media management application. | dep: Prometheus, Grafana, Loki, Alertmanager, Grafana Alloy, Node Exporter, Docker Compose, FastAPI
|
||||||
|
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
|
||||||
|
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
|
||||||
|
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
|
||||||
|
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
|
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
|
||||||
|
## arch
|
||||||
|
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager).
|
||||||
|
## tags
|
||||||
|
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added — Observability service registry
|
||||||
|
|
||||||
|
- **Alertmanager is now a service type.** Configure Alertmanager, Grafana, and
|
||||||
|
Prometheus instances in the UI on the Services page; all three are first-class
|
||||||
|
service-registry entries with dashboard widgets (`active_alerts`, Grafana link,
|
||||||
|
Prometheus metric).
|
||||||
|
- New monitoring endpoints resolve the configured service instance and probe its
|
||||||
|
health: `GET /api/monitoring/grafana-status`, `/prometheus-status`. The
|
||||||
|
`/alerts` and `/alertmanager-status` endpoints now take an optional
|
||||||
|
`service_id` and pick the first enabled alertmanager instance by default.
|
||||||
|
- The Observability page discovers Grafana/Prometheus/Alertmanager from the
|
||||||
|
registry and renders health cards; the dashboard `active_alerts` widget sums
|
||||||
|
firing alerts by severity.
|
||||||
|
|
||||||
|
### Changed — Observability is now external only
|
||||||
|
|
||||||
|
- **Removed** all observability services from `docker-compose.yml` and
|
||||||
|
`docker-compose.dev.yml`. They now deploy **only** the backend and frontend.
|
||||||
|
The `monitoring` network and the `prometheus`/`loki`/`alloy`/`grafana`/
|
||||||
|
`alertmanager`/`node-exporter` services and their named volumes were deleted,
|
||||||
|
and the `GRAFANA_APP_HOST` Traefik rule was removed.
|
||||||
|
- Manage now connects to **existing** Grafana/Prometheus/Alertmanager instances
|
||||||
|
and never ships its own stack. The previous in-compose stack is preserved as
|
||||||
|
an optional, deploy-it-yourself example in `docker-compose.observability.yml`
|
||||||
|
(config under `monitoring/`, documented in `docs/observability-runbooks.md`).
|
||||||
|
- Removed the now-orphaned combined `monitoring/prometheus/prometheus.yml`; the
|
||||||
|
standalone stack uses `monitoring/prometheus/prometheus.standalone.yml`.
|
||||||
|
- Removed the Prometheus file-SD bridge (`PROMETHEUS_FILE_SD_DIR` + the
|
||||||
|
`write_prometheus_targets` file writer). External Prometheus instances now
|
||||||
|
consume node-exporter targets via `http_sd_configs` against
|
||||||
|
`GET /api/monitoring/prometheus-targets`. The webhook receiver is log-only.
|
||||||
|
|
||||||
|
### **BREAKING**
|
||||||
|
|
||||||
|
- Observability is configured entirely via the service registry; the backend
|
||||||
|
`alertmanager_url`/`alertmanager_webhook_url` and frontend
|
||||||
|
`VITE_GRAFANA_URL`/`VITE_PROMETHEUS_URL` environment variables, plus
|
||||||
|
`PROMETHEUS_FILE_SD_DIR`, were **removed**. Re-create your Alertmanager /
|
||||||
|
Grafana / Prometheus instances on the Services page after upgrading. The only
|
||||||
|
observability env var remaining is `PROMETHEUS_ENABLED` (toggles Manage's own
|
||||||
|
`/metrics` endpoint).
|
||||||
|
|
||||||
|
### Added — Service registry
|
||||||
|
|
||||||
|
- Runtime **service registry** persisted in the backend SQLite database. External
|
||||||
|
services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are now
|
||||||
|
configured in the app instead of via environment variables.
|
||||||
|
- Services page (`/services`) to create, list, and delete service instances.
|
||||||
|
- Service detail pages (`/services/:serviceType/:serviceId`) to edit name/enabled
|
||||||
|
state, rotate secrets, and view the widgets a service provides.
|
||||||
|
- Service definitions live as Pydantic modules in `backend/.../integrations/`,
|
||||||
|
each declaring its config schema, secret fields, and widget kinds.
|
||||||
|
- Multi-instance support: multiple Grafana/Jellyfin/etc. instances per type.
|
||||||
|
- SSH task runner service records run history in a new `service_task_runs`
|
||||||
|
table, shown on the runner's service page.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Dashboard widgets are now **service-bound** (reference a service instance +
|
||||||
|
widget kind) or **built-in** (backups, static text). The "Add widget" flow is
|
||||||
|
pick-service → pick-widget-kind → configure.
|
||||||
|
- Deleting a service cascade-deletes widgets that reference it.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
|
||||||
|
Fernet.
|
||||||
|
|
||||||
|
### **BREAKING**
|
||||||
|
|
||||||
|
- Saved Actions (server tasks) now target `ssh_tasks` service instances instead
|
||||||
|
of monitoring machines. The `default_machine_id` field on saved tasks was
|
||||||
|
replaced with `default_service_id`; the legacy `saved_task_runs` table was
|
||||||
|
dropped and run history now lives in `service_task_runs`. Re-create SSH task
|
||||||
|
runner services on the Services page and re-link saved actions after
|
||||||
|
upgrading.
|
||||||
|
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
|
||||||
|
with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `GRAFANA_URL` and `PROMETHEUS_URL` backend environment variables were
|
||||||
|
removed; Grafana/Prometheus URLs now live on service records configured in the
|
||||||
|
UI. Re-create them on the Services page after upgrading.
|
||||||
|
- The legacy widget/addon-pages model (`/addons/:addonId`,
|
||||||
|
`/api/widgets/types`, `/api/widgets/sources`) was removed in favor of the
|
||||||
|
service registry.
|
||||||
|
- Default dashboard widget seeding was removed; a fresh install starts with an
|
||||||
|
empty dashboard. Add widgets from the dashboard's edit dialog after
|
||||||
|
configuring services.
|
||||||
|
|
||||||
|
### Notes / follow-ups
|
||||||
|
|
||||||
|
- ~~Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
|
||||||
|
pages. Migrating those onto the service registry is a separate follow-up change.~~
|
||||||
|
**Done (2026-06-23):** Jellyfin is no longer a machine service, and the dead
|
||||||
|
machine-level `media_root`/`path_prefix` fields were removed. See the
|
||||||
|
Jellyfin migration entry in `docs/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
## Follow-up #2 — remove dead machine `media_root`/`path_prefix` + Jellyfin service
|
||||||
|
|
||||||
|
Completes the Jellyfin migration onto the service registry. Jellyfin is no
|
||||||
|
longer a machine `services` tag (`DEFAULT_SERVICES` is now `["monitoring",
|
||||||
|
"files"]`), and the dead machine-level `media_root`/`path_prefix` fields were
|
||||||
|
removed from the settings store, `MonitoringMachineInput`, frontend types, and
|
||||||
|
the Settings UI. Jellyfin is configured exclusively as a service-registry
|
||||||
|
instance. The global `REMOTE_MEDIA_ROOT`/`REMOTE_PATH_PREFIX` config properties
|
||||||
|
and `path_utils.py` remain (files/media-index still use them for Jellyfin→SSH
|
||||||
|
path resolution). Existing DB rows may still carry these keys in `config_json`;
|
||||||
|
they are inert and get dropped on the next machine save.
|
||||||
|
|
||||||
|
## Follow-up #1 — remove dead machine Jellyfin/Jellyseerr fields
|
||||||
|
|
||||||
|
With Jellyfin/Jellyseerr now resolved from the service registry, the machine-level
|
||||||
|
Jellyfin/Jellyseerr fields are dead config. Removed from `dependencies.py` (dead
|
||||||
|
`_jellyseerr_client_for`; `_resolve_machine` simplified to SSH-only),
|
||||||
|
`services/settings_store.py`, `routers/settings.py` (`MachineInput`), frontend
|
||||||
|
types, the `Settings.tsx` form, and frontend test fixtures. Existing DB rows may
|
||||||
|
still carry these keys in `config_json`; they are inert and get dropped on the
|
||||||
|
next machine save. No data migration required.
|
||||||
+77
-20
@@ -1,55 +1,114 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
Thanks for considering a contribution.
|
Thanks for considering a contribution to Manage.
|
||||||
|
|
||||||
|
Manage is a media and server-operations dashboard built from two subprojects:
|
||||||
|
|
||||||
|
- **`backend/`** — FastAPI (Python 3.11) REST API using a `src/` layout.
|
||||||
|
- **`frontend/`** — Vite + React + TypeScript SPA.
|
||||||
|
- **`archive/`** — the original Streamlit prototype, preserved for reference only. Do **not** use it as a guide; the app is FastAPI + React now.
|
||||||
|
|
||||||
|
The authoritative contributor quick-reference is [`AGENTS.md`](./AGENTS.md). This document mirrors it for human contributors.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd backend
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -e '.[dev]'
|
pip install -e '.[dev]'
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy env template:
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cd frontend
|
||||||
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Then set real values in `.env` and run:
|
### Local stack (optional)
|
||||||
|
|
||||||
|
For a full local dev stack with hot reload (auth disabled):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
streamlit run app.py
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development guidelines
|
The dev compose deploys only the backend and frontend; Manage never deploys an
|
||||||
|
observability stack. For the optional standalone observability example, see
|
||||||
|
`docker-compose.observability.yml` and `docs/observability-runbooks.md`.
|
||||||
|
|
||||||
|
## Development commands
|
||||||
|
|
||||||
|
Run backend checks from `backend/` and frontend checks from `frontend/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend: lint + tests
|
||||||
|
cd backend && ruff check . && python -m pytest
|
||||||
|
|
||||||
|
# Run the API locally (if the package is installed as above)
|
||||||
|
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||||
|
# Otherwise, without installing: PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# Focused backend tests
|
||||||
|
pytest tests/test_api.py
|
||||||
|
pytest -k <expr>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend: dev server (proxies /api to http://localhost:8000)
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# Frontend: lint + typecheck/build (build runs tsc -b + vite build) + tests
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
- Keep architecture boundaries clear:
|
- Keep architecture boundaries clear:
|
||||||
- `clients/` for external integrations
|
- `clients/` for external service transports (Jellyfin, Jellyseerr, SSH, local shell).
|
||||||
- `domain/` for normalization/business logic
|
- `integrations/` for service-registry definitions (config schema, secrets, widget kinds).
|
||||||
- `services/` for app services/indexing
|
- `domain/` for normalization/business logic.
|
||||||
- `ui/` for Streamlit rendering
|
- `services/` for app services, indexing, persistence, and background workers.
|
||||||
|
- `routers/` for FastAPI route handlers.
|
||||||
|
- `models/` for Pydantic request/response schemas.
|
||||||
|
- Prefer small, focused functions and explicit names.
|
||||||
|
- Preserve safe SSH behavior and shell quoting — job templates must quote all interpolated values.
|
||||||
|
- External services (Jellyfin, Grafana, Prometheus, Alertmanager, …) are configured at runtime via the **service registry** in the UI, not environment variables. The only observability env var is `PROMETHEUS_ENABLED` (Manage's own `/metrics` toggle).
|
||||||
- Avoid introducing optional fallback paths unless required.
|
- Avoid introducing optional fallback paths unless required.
|
||||||
- Prefer small, focused functions and explicit session-state keys.
|
|
||||||
- Preserve safe SSH behavior and path quoting.
|
|
||||||
|
|
||||||
## Validation
|
### Backend style
|
||||||
|
|
||||||
Before opening a merge request, run:
|
Backend linting/format is Ruff (line length 120, Python 3.11); config lives in `backend/pyproject.toml`.
|
||||||
|
|
||||||
|
### Frontend style
|
||||||
|
|
||||||
|
The frontend uses **shadcn/ui + Tailwind CSS v4 + lucide-react + TanStack Query + TanStack Table**. Do not introduce MUI, Emotion, recharts, d3, or AG Grid — those were removed and are not coming back.
|
||||||
|
|
||||||
|
## Validation before opening a merge request
|
||||||
|
|
||||||
|
Before opening a merge request, run and ensure green:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=src python -m py_compile app.py src/media_library_viewer/*.py src/media_library_viewer/clients/*.py src/media_library_viewer/domain/*.py src/media_library_viewer/services/*.py src/media_library_viewer/ui/*.py
|
cd backend && ruff check . && python -m pytest
|
||||||
|
cd frontend && npm run lint && npm run build && npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If behavior, UX, or architecture changed, also update `docs/REQUIREMENTS.md`.
|
||||||
|
|
||||||
## Security / secrets
|
## Security / secrets
|
||||||
|
|
||||||
Never commit:
|
Never commit:
|
||||||
|
|
||||||
- `.env`
|
- `.env`
|
||||||
- `.streamlit/secrets.toml`
|
|
||||||
- private keys or API tokens
|
- private keys or API tokens
|
||||||
|
- service secrets
|
||||||
|
|
||||||
Use `.env.example` for documented placeholders only.
|
Service secrets are encrypted at rest with `MANAGE_ENCRYPTION_KEY` (required to start the backend). Use `.env.example` for documented placeholders only.
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
@@ -57,6 +116,4 @@ Please include:
|
|||||||
|
|
||||||
- what changed
|
- what changed
|
||||||
- why it changed
|
- why it changed
|
||||||
- how it was tested
|
- how it was tested (commands run / tests added)
|
||||||
|
|
||||||
If behavior/requirements changed, also update `docs/REQUIREMENTS.md`.
|
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ The project consists of two subprojects:
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
|
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, Alertmanager alerts, SSH task output, static text) and shortcuts
|
||||||
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines
|
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
||||||
- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine
|
- Service registry: configure Jellyfin, Jellyseerr, Alertmanager, Grafana, Prometheus, Nextcloud, and SSH task runner instances in the UI
|
||||||
|
- Per-machine settings for SSH, monitoring targets, and file browsing
|
||||||
- SQLite-indexed media table with full-library sort/filter
|
- SQLite-indexed media table with full-library sort/filter
|
||||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
||||||
- Remote file browser with ffprobe preview and job execution
|
- Remote file browser with ffprobe preview and job execution
|
||||||
- Jellyfin API integration for library metadata and user identity data
|
- Jellyfin API integration for library metadata and user identity data
|
||||||
- SSH-based file inspection and remote job templates
|
- SSH-based file inspection and safe remote job templates
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -39,7 +40,11 @@ Production-style deployment with the frontend serving the SPA and proxying `/api
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the app at http://localhost:8080.
|
Open the app at <http://localhost:8080>.
|
||||||
|
|
||||||
|
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
|
||||||
|
|
||||||
|
> **Observability is external.** Manage only ships its **backend** and **frontend**. It does **not** deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter. The backend exposes a `/metrics` endpoint and optional Alertmanager proxy endpoints so an *existing* observability deployment can scrape and consume them. For a ready-to-run example stack you can deploy alongside Manage, see [`docker-compose.observability.yml`](docker-compose.observability.yml) and [`docs/observability-runbooks.md`](docs/observability-runbooks.md).
|
||||||
|
|
||||||
Local development with hot reload:
|
Local development with hot reload:
|
||||||
|
|
||||||
@@ -47,9 +52,9 @@ Local development with hot reload:
|
|||||||
docker compose -f docker-compose.dev.yml up --build
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend runs on http://localhost:5173 and the backend on http://localhost:8000.
|
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
|
||||||
The backend media index is persisted in a Docker volume (`backend_cache`) so rebuilds and container restarts do not force a full re-index.
|
|
||||||
Monitoring machine definitions and recent machine activity are stored in the backend so the UI can show one section per configured machine and preserve history across restarts.
|
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
|
||||||
|
|
||||||
### Manual backend/frontend development
|
### Manual backend/frontend development
|
||||||
|
|
||||||
@@ -76,21 +81,25 @@ The Compose files use environment-variable interpolation. Export the required va
|
|||||||
Production-style example with shell exports:
|
Production-style example with shell exports:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export BACKEND_APP_HOST=manage.example.com
|
export BACKEND_APP_HOST=api.manage.example.com
|
||||||
export FRONTEND_APP_HOST=manage.example.com
|
export FRONTEND_APP_HOST=manage.example.com
|
||||||
export CERT_RESOLVER=letsencrypt
|
export CERT_RESOLVER=letsencrypt
|
||||||
export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/
|
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||||
export VITE_OIDC_CLIENT_ID=manage
|
export VITE_OIDC_CLIENT_ID=manage
|
||||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/
|
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||||
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
|
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||||
|
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
||||||
|
> the app on the **Services** page — no env vars for them.
|
||||||
|
|
||||||
Inline one-liner example:
|
Inline one-liner example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
||||||
@@ -124,27 +133,35 @@ SMTP_TIMEOUT=30
|
|||||||
|
|
||||||
# Authentik / OIDC
|
# Authentik / OIDC
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
||||||
OIDC_AUDIENCE=media-library-viewer
|
OIDC_AUDIENCE=manage
|
||||||
OIDC_JWKS_URL=
|
OIDC_JWKS_URL=
|
||||||
OIDC_CLOCK_SKEW_SECONDS=30
|
OIDC_CLOCK_SKEW_SECONDS=30
|
||||||
|
|
||||||
# Frontend OIDC settings
|
# Frontend OIDC settings
|
||||||
VITE_OIDC_ENABLED=true
|
VITE_OIDC_ENABLED=true
|
||||||
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
|
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||||
VITE_OIDC_CLIENT_ID=media-library-viewer
|
VITE_OIDC_CLIENT_ID=manage
|
||||||
VITE_OIDC_SCOPE=openid profile email
|
VITE_OIDC_SCOPE=openid profile email
|
||||||
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
|
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
|
|
||||||
|
# Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
||||||
|
# the app on the Services page. The only observability env var is the optional
|
||||||
|
# PROMETHEUS_ENABLED toggle (defaults on) for Manage's own /metrics endpoint.
|
||||||
|
|
||||||
|
# Required: master key encrypting service secrets (API keys/tokens) at rest.
|
||||||
|
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||||
```
|
```
|
||||||
|
|
||||||
## Remote server requirements
|
## Remote server requirements
|
||||||
|
|
||||||
The remote server needs:
|
The remote server needs:
|
||||||
|
|
||||||
- Linux `/proc` and `/sys/block` for monitoring
|
|
||||||
- `/bin/sh` (POSIX shell)
|
- `/bin/sh` (POSIX shell)
|
||||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk`
|
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
|
||||||
|
- SSH access with a key configured in the app's Settings tab
|
||||||
|
|
||||||
The SSH client rejects unknown host keys. Connect manually once first:
|
The SSH client rejects unknown host keys. Connect manually once first:
|
||||||
|
|
||||||
@@ -155,17 +172,20 @@ ssh user@host
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend (lint + tests)
|
||||||
cd backend && PYTHONPATH=src python -m py_compile src/media_library_viewer_api/main.py
|
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest
|
||||||
|
|
||||||
# Frontend
|
# Frontend (lint + typecheck/build + tests)
|
||||||
cd frontend && npx tsc --noEmit && npm run build
|
cd frontend && npm run lint && npm run build && npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Focused frontend typecheck: `npx tsc --noEmit`.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
||||||
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
||||||
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
||||||
- Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
|
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries. They deploy **only** the backend and frontend; Manage never deploys its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
|
||||||
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
|
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
|
||||||
|
- Grafana, Prometheus, and Alertmanager are configured as **service instances** in the app (Services page); their widget adapters resolve URLs from service records, and no observability URLs/credentials live in env vars. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`. When no alertmanager service is configured, the alert proxy endpoints return graceful "not configured" responses.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# archive (index)
|
||||||
|
dir: archive
|
||||||
|
|
||||||
|
## role
|
||||||
|
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||||
|
## parent
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## children
|
||||||
|
- archive/src
|
||||||
|
index: archive/src/.pi-map.index.md
|
||||||
|
map: archive/src/.pi-map.md
|
||||||
|
- archive/tests
|
||||||
|
index: archive/tests/.pi-map.index.md
|
||||||
|
map: archive/tests/.pi-map.md
|
||||||
|
## files
|
||||||
|
- app.py
|
||||||
|
- pyproject.toml
|
||||||
|
- requirements.txt
|
||||||
|
## links
|
||||||
|
index: archive/.pi-map.index.md
|
||||||
|
map: archive/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change archive behavior
|
||||||
|
read: app.py, pyproject.toml, requirements.txt
|
||||||
|
- change archive config
|
||||||
|
read: pyproject.toml
|
||||||
|
- explore archive subdirectories
|
||||||
|
index: archive/src/.pi-map.index.md, archive/tests/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# archive
|
||||||
|
dir: archive
|
||||||
|
|
||||||
|
index: archive/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||||
|
## files
|
||||||
|
- app.py | Provides a minimal Streamlit entrypoint that adds the src directory to Python's path and delegates to the actual application in media_library_viewer.app. | dep: sys, pathlib, media_library_viewer.app
|
||||||
|
- pyproject.toml | Defines Python package metadata, dependencies, and tool configurations for a Streamlit-based Jellyfin media library browser with SSH remote file inspection. | dep: hatchling, streamlit, streamlit-aggrid, requests, paramiko, python-dotenv, pandas, ruff, pytest
|
||||||
|
- requirements.txt | Installs the current package in editable/development mode using pip | dep: pip, setuptools
|
||||||
|
## arch
|
||||||
|
Thin entrypoint pattern using a bootstrap app.py that manipulates sys.path to delegate execution to a nested media_library_viewer package, managed via standard Python packaging (pyproject.toml).
|
||||||
|
## tags
|
||||||
|
streamlit, app, python, media, library, package, pyproject, pip
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
- change archive behavior
|
||||||
|
read: app.py, pyproject.toml, requirements.txt
|
||||||
|
- change archive config
|
||||||
|
read: pyproject.toml
|
||||||
|
- explore archive subdirectories
|
||||||
|
index: archive/src/.pi-map.index.md, archive/tests/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# archive/src (index)
|
||||||
|
dir: archive/src
|
||||||
|
|
||||||
|
## role
|
||||||
|
No files provided — directory appears to be empty or contents were not included, so the package's role cannot be determined.
|
||||||
|
## parent
|
||||||
|
index: archive/.pi-map.index.md
|
||||||
|
map: archive/.pi-map.md
|
||||||
|
## children
|
||||||
|
- archive/src/media_library_viewer
|
||||||
|
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: archive/src/.pi-map.index.md
|
||||||
|
map: archive/src/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# archive/src
|
||||||
|
dir: archive/src
|
||||||
|
|
||||||
|
index: archive/src/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
No files provided — directory appears to be empty or contents were not included, so the package's role cannot be determined.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Cannot be assessed due to missing file contents; please provide the file listing for analysis.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# archive/src/media_library_viewer (index)
|
||||||
|
dir: archive/src/media_library_viewer
|
||||||
|
|
||||||
|
## role
|
||||||
|
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
|
||||||
|
## parent
|
||||||
|
index: archive/src/.pi-map.index.md
|
||||||
|
map: archive/src/.pi-map.md
|
||||||
|
## children
|
||||||
|
- archive/src/media_library_viewer/clients
|
||||||
|
index: archive/src/media_library_viewer/clients/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/clients/.pi-map.md
|
||||||
|
- archive/src/media_library_viewer/domain
|
||||||
|
index: archive/src/media_library_viewer/domain/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/domain/.pi-map.md
|
||||||
|
- archive/src/media_library_viewer/services
|
||||||
|
index: archive/src/media_library_viewer/services/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/services/.pi-map.md
|
||||||
|
- archive/src/media_library_viewer/ui
|
||||||
|
index: archive/src/media_library_viewer/ui/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/ui/.pi-map.md
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- app.py
|
||||||
|
- config.py
|
||||||
|
- jobs.py
|
||||||
|
- utils.py
|
||||||
|
## links
|
||||||
|
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change media_library_viewer behavior
|
||||||
|
read: __init__.py, app.py, config.py
|
||||||
|
- change media_library_viewer config
|
||||||
|
read: config.py
|
||||||
|
- explore media_library_viewer subdirectories
|
||||||
|
index: archive/src/media_library_viewer/clients/.pi-map.index.md, archive/src/media_library_viewer/domain/.pi-map.index.md, archive/src/media_library_viewer/services/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# archive/src/media_library_viewer
|
||||||
|
dir: archive/src/media_library_viewer
|
||||||
|
|
||||||
|
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
|
||||||
|
## files
|
||||||
|
- __init__.py | Package initialization file that defines the Media Library Viewer package metadata and exports the version string.
|
||||||
|
- app.py | Streamlit UI entrypoint for a Media Library Viewer that connects to Jellyfin and SSH backends, providing dashboard, monitoring, media browsing, and file browser tabs with cached data and path resolution between systems. | exp: func:get_jellyfin_client(base_url: str, api_key: str) → JellyfinClient, call:JellyfinClient, func:cached_users(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).users, func:get_ssh_client(host: str, username: str, port: int, key_filename: str, password: str) → RemoteSSHClient, call:RemoteSSHClient, call:client.connect, func:cached_libraries(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).libraries, func:cached_media_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).media_counts, func:cached_library_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client, call:client.libraries, call:client.library_item_counts, func:cached_active_sessions(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).active_sessions, func:cached_dir_listing(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.list_dir, call:json.loads, raise:RuntimeError, func:cached_ffprobe_preview(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.ffprobe_json, func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:apply_remote_path_prefix, func:credentials_panel(), call:load_config, call:st.header, call:st.expander, call:st.text_input, call:st.number_input, call:int, func:main(), call:st.set_page_config, call:st.title, call:st.caption, call:credentials_panel, call:st.info, call:get_jellyfin_client, call:cached_users, call:st.error, call:user.get, call:st.selectbox, call:list, call:user_options.keys, call:st.tabs, call:render_now_playing, call:st.divider, call:render_resource_dashboard, call:render_media_overview, call:cached_libraries, call:set_file_browser_path, call:resolve_remote_media_path, call:render_media_tab, call:render_file_browser, call:get_ssh_client, call:render_ssh_tools, func:set_prefixed_file_browser_path(path: str, selected_path, reset_filters) → None, call:set_file_browser_path, call:resolve_remote_media_path | dep: json, posixpath, typing, media_library_viewer.clients.jellyfin, media_library_viewer.clients.ssh, media_library_viewer.config, media_library_viewer.ui.dashboard, media_library_viewer.ui.file_browser, media_library_viewer.ui.media, media_library_viewer.ui.preview, streamlit
|
||||||
|
- config.py | Loads application configuration from environment variables and .env files using immutable dataclasses for Jellyfin and SSH settings. | exp: class:JellyfinConfig, class:SSHConfig, class:AppConfig, func:load_config() → AppConfig, call:AppConfig | dep: os, dataclasses, pathlib, dotenv
|
||||||
|
- jobs.py | Defines safe, template-based remote SSH jobs with shell-quoted parameter rendering. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:ssh.run | dep: shlex, dataclasses, typing, media_library_viewer.clients.ssh, typing.Mapping
|
||||||
|
- utils.py | Provides UI-independent formatting helpers and ffprobe output summarizers for video/audio/subtitle stream metadata. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||||
|
## arch
|
||||||
|
Layered Streamlit application using immutable dataclass configuration, template-based remote job execution, cached data access, and separated utility functions following a tab-based modular UI pattern.
|
||||||
|
## tags
|
||||||
|
client, path, media, call:, jellyfin, call:get, ssh, cached
|
||||||
|
## symbols
|
||||||
|
- JellyfinConfig
|
||||||
|
- SSHConfig
|
||||||
|
- AppConfig
|
||||||
|
- JobTemplate
|
||||||
|
- get_jellyfin_client
|
||||||
|
- cached_users
|
||||||
|
- get_ssh_client
|
||||||
|
- cached_libraries
|
||||||
|
## workflows
|
||||||
|
- change media_library_viewer behavior
|
||||||
|
read: __init__.py, app.py, config.py
|
||||||
|
- change media_library_viewer config
|
||||||
|
read: config.py
|
||||||
|
- explore media_library_viewer subdirectories
|
||||||
|
index: archive/src/media_library_viewer/clients/.pi-map.index.md, archive/src/media_library_viewer/domain/.pi-map.index.md, archive/src/media_library_viewer/services/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# archive/src/media_library_viewer/clients (index)
|
||||||
|
dir: archive/src/media_library_viewer/clients
|
||||||
|
|
||||||
|
## role
|
||||||
|
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
|
||||||
|
## parent
|
||||||
|
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- jellyfin.py
|
||||||
|
- resources.py
|
||||||
|
- ssh.py
|
||||||
|
## links
|
||||||
|
index: archive/src/media_library_viewer/clients/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/clients/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change clients behavior
|
||||||
|
read: __init__.py, jellyfin.py, resources.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# archive/src/media_library_viewer/clients
|
||||||
|
dir: archive/src/media_library_viewer/clients
|
||||||
|
|
||||||
|
index: archive/src/media_library_viewer/clients/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
|
||||||
|
## files
|
||||||
|
- __init__.py | Package initialization file that defines external service clients module boundaries and constraints
|
||||||
|
- jellyfin.py | HTTP API client for Jellyfin/Emby media servers providing user, library, item, and session management with plain Python return types for frontend agnosticism. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → dict[str, Any], call:params.items, call:self.session.get, call:response.raise_for_status, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:isinstance, call:session.get, method:image_url(self, item_id: str, image_type) → str | dep: typing, requests
|
||||||
|
- resources.py | Manages a lightweight POSIX shell-based remote system metrics collector that samples CPU, memory, network, and disk statistics via SSH and reads the resulting JSONL data. | exp: class:ResourceMonitorPaths, func:start_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:shlex.quote, call:int, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:stop_resource_collector(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:restart_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:stop_resource_collector, call:start_resource_collector, func:resource_collector_status(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:resource_collector_debug_info(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, func:read_resource_metrics(ssh: RemoteSSHClient, max_lines, paths) → list[dict[str, Any]], call:shlex.quote, call:int, call:ssh.run, call:result.stdout.splitlines, call:line.strip, call:rows.append, call:json.loads, raise:RuntimeError, func:disk_space(ssh: RemoteSSHClient, path) → dict[str, Any], call:shlex.quote, call:ssh.run, call:result.stdout.strip, call:json.loads, raise:RuntimeError | dep: json, shlex, dataclasses, typing, media_library_viewer.clients.ssh
|
||||||
|
- ssh.py | Provides an SSH client wrapper around Paramiko for remote filesystem inspection and media analysis, ensuring POSIX shell compatibility regardless of the user's login shell. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, password, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:client.connect, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, posixpath, shlex, dataclasses, typing, paramiko
|
||||||
|
## arch
|
||||||
|
Client-wrapper pattern with each module encapsulating a specific integration concern (Jellyfin HTTP API, SSH filesystem access, remote resource monitoring), returning plain Python types for frontend agnosticism.
|
||||||
|
## tags
|
||||||
|
call:shlex.quote, resource, error, collector, call:ssh.run, raise:runtime, call:self.get, call:result.stdout.strip
|
||||||
|
## symbols
|
||||||
|
- JellyfinClient
|
||||||
|
- ResourceMonitorPaths
|
||||||
|
- CommandResult
|
||||||
|
- RemoteSSHClient
|
||||||
|
- __init__
|
||||||
|
- get
|
||||||
|
- users
|
||||||
|
- libraries
|
||||||
|
## workflows
|
||||||
|
- change clients behavior
|
||||||
|
read: __init__.py, jellyfin.py, resources.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# archive/src/media_library_viewer/domain (index)
|
||||||
|
dir: archive/src/media_library_viewer/domain
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display.
|
||||||
|
## parent
|
||||||
|
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- media.py
|
||||||
|
## links
|
||||||
|
index: archive/src/media_library_viewer/domain/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/domain/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change domain behavior
|
||||||
|
read: __init__.py, media.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# archive/src/media_library_viewer/domain
|
||||||
|
dir: archive/src/media_library_viewer/domain
|
||||||
|
|
||||||
|
index: archive/src/media_library_viewer/domain/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display.
|
||||||
|
## files
|
||||||
|
- __init__.py | Serves as the package docstring for a domain-level helpers/normalization module that converts external data into stable app concepts.
|
||||||
|
- media.py | Flattens inconsistent Jellyfin API item JSON into stable, normalized dictionaries for SQLite storage and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer.utils, pandas, media_library_viewer.utils (human_size, ticks_to_minutes)
|
||||||
|
## arch
|
||||||
|
Functional transformation layer pattern mapping raw external API JSON directly into normalized flat dictionaries without intermediate ORM or complex object hierarchies.
|
||||||
|
## tags
|
||||||
|
media, call:str, date, added, item, call:item.get, streams, call:stream
|
||||||
|
## symbols
|
||||||
|
- first_media_source
|
||||||
|
- media_streams
|
||||||
|
- stream_value
|
||||||
|
- is_hdr_item
|
||||||
|
- format_date_added
|
||||||
|
- timestamp_date_added
|
||||||
|
- format_rate_bits_decimal
|
||||||
|
- normalize_media_item
|
||||||
|
## workflows
|
||||||
|
- change domain behavior
|
||||||
|
read: __init__.py, media.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# archive/src/media_library_viewer/services (index)
|
||||||
|
dir: archive/src/media_library_viewer/services
|
||||||
|
|
||||||
|
## role
|
||||||
|
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
|
||||||
|
## parent
|
||||||
|
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- media_index.py
|
||||||
|
## links
|
||||||
|
index: archive/src/media_library_viewer/services/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/services/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change services behavior
|
||||||
|
read: __init__.py, media_index.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# archive/src/media_library_viewer/services
|
||||||
|
dir: archive/src/media_library_viewer/services
|
||||||
|
|
||||||
|
index: archive/src/media_library_viewer/services/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
|
||||||
|
## files
|
||||||
|
- __init__.py | Marks the directory as a Python package and documents it as the application services layer for coordinating clients/domain logic into reusable operations.
|
||||||
|
- media_index.py | Provides a UI-agnostic SQLite-backed media inventory service that indexes, queries, and manages Jellyfin media metadata with filtering, sorting, and pagination capabilities. | exp: class:MediaIndexStatus, class:MediaIndex, method:__init__(self, db_path), call:Path, call:self.db_path.parent.mkdir, method:connect(self) → sqlite3.Connection, call:sqlite3.connect, method:init_schema(self) → None, call:self.connect, call:conn.executescript, method:set_metadata(self, key: str, value: str | int | float) → None, call:self.init_schema, call:self.connect, call:conn.execute, call:str, method:replace_items(self, rows: Iterable[dict[str, Any]]) → int, call:self.init_schema, call:list, call:",".join, call:len, call:self.connect, call:conn.execute, call:conn.executemany, call:','.join, call:row.get, call:str, call:int, call:time.time, method:status(self) → MediaIndexStatus, call:self.db_path.exists, call:MediaIndexStatus, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone, call:str(updated_row[0]).isdigit, call:time.strftime, call:time.localtime, call:float, method:query(self, library_id, library_ids, media_types, search, hdr_filter, sort_key, sort_order, limit, offset) → tuple[list[dict[str, Any]], int], call:self.init_schema, call:where.append, call:",".join, call:len, call:params.extend, call:params.append, call:search.lower, call:" AND ".join, call:SORT_COLUMNS.get, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone, call:conn.execute( "SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?", [*params, int(limit), int(offset)], ).fetchall, call:display_media_row, call:dict, func:build_media_index(client: JellyfinClient, user_id: str, libraries: list[dict[str, Any]], index, page_size) → int, call:MediaIndex, call:time.perf_counter, call:library.get, call:client.items, call:response.get, call:normalized_rows.extend, call:normalize_media_item, call:len, call:int, call:index.replace_items, call:index.set_metadata | dep: sqlite3, time, dataclasses, pathlib, typing, media_library_viewer.clients.jellyfin, media_library_viewer.domain.media, media_library_viewer.clients.jellyfin.JellyfinClient, media_library_viewer.domain.media.display_media_row, media_library_viewer.domain.media.normalize_media_item
|
||||||
|
## arch
|
||||||
|
Service-oriented pattern with SQLite-backed indexing, query filtering, and pagination encapsulated behind a single cohesive media index service module.
|
||||||
|
## tags
|
||||||
|
media, call:conn.execute, index, call:self.connect, schema, call:int, init, status
|
||||||
|
## symbols
|
||||||
|
- MediaIndexStatus
|
||||||
|
- MediaIndex
|
||||||
|
- __init__
|
||||||
|
- connect
|
||||||
|
- init_schema
|
||||||
|
- set_metadata
|
||||||
|
- replace_items
|
||||||
|
- status
|
||||||
|
## workflows
|
||||||
|
- change services behavior
|
||||||
|
read: __init__.py, media_index.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# archive/src/media_library_viewer/ui (index)
|
||||||
|
dir: archive/src/media_library_viewer/ui
|
||||||
|
|
||||||
|
## role
|
||||||
|
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
|
||||||
|
## parent
|
||||||
|
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- dashboard.py
|
||||||
|
- file_browser.py
|
||||||
|
- media.py
|
||||||
|
- preview.py
|
||||||
|
## links
|
||||||
|
index: archive/src/media_library_viewer/ui/.pi-map.index.md
|
||||||
|
map: archive/src/media_library_viewer/ui/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change ui behavior
|
||||||
|
read: __init__.py, dashboard.py, file_browser.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# archive/src/media_library_viewer/ui
|
||||||
|
dir: archive/src/media_library_viewer/ui
|
||||||
|
|
||||||
|
index: archive/src/media_library_viewer/ui/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
|
||||||
|
## files
|
||||||
|
- __init__.py | Package initialization file for Streamlit UI modules that documents the architectural pattern of splitting the application into separate render modules.
|
||||||
|
- dashboard.py | Implements a Streamlit dashboard for monitoring a Jellyfin media server, displaying media library statistics, active playback sessions, and server resource metrics via SSH. | exp: func:format_rate_bytes(bytes_per_second: float | int | None) → str, call:human_size, func:rate_scale(max_value: float | int | None) → tuple[float, str], call:abs, call:float, func:scaled_rate_chart_df(chart_df: pd.DataFrame, columns: list[str], labels: list[str]) → tuple[pd.DataFrame, str], call:chart_df[columns].max(numeric_only=True).max, call:rate_scale, call:chart_df[columns].copy, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str, user_id: str) → None, call:st.subheader, call:cached_media_counts, call:st.warning, call:counts.get, call:st.columns, call:top_cols[0].metric, call:top_cols[1].metric, call:top_cols[2].metric, call:top_cols[3].metric, call:cached_library_counts, call:st.caption, call:st.markdown, call:e.get, call:st.container, call:m_cols[0].metric, call:m_cols[1].metric, func:render_now_playing(cached_active_sessions, base_url: str, api_key: str) → None, call:st.subheader, call:cached_active_sessions, call:st.warning, call:st.caption, call:session.get, call:bool, call:play_state.get, call:item.get, call:transcoding.get, call:transcode_type.append, call:rows.append, call:", ".join, call:st.dataframe, call:pd.DataFrame, func:render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed) → None, call:st.subheader, call:get_ssh_client, call:resource_collector_status, call:st.error, call:st.columns, call:control_col.caption, call:start_col.button, call:st.success, call:start_resource_collector, call:restart_col.button, call:restart_resource_collector, call:stop_col.button, call:st.info, call:stop_resource_collector, call:refresh_col.button, call:st.rerun, call:st.caption, call:read_resource_metrics, call:disk_space, call:float, call:str(space.get("used_pct", "0")).rstrip, call:space.get, call:disk_cols[0].metric, call:human_size, call:disk_cols[1].metric, call:disk_cols[2].metric, call:disk_cols[3].metric, call:st.progress, call:min, call:max, call:st.warning, call:st.expander, call:st.code, call:resource_collector_debug_info, call:pd.DataFrame, call:pd.to_numeric, call:df.dropna, call:pd.to_datetime(df["ts"], unit="s", utc=True).dt.tz_convert, call:time.time, call:len, call:st.write, call:raw_df['ts'].astype(float).max, call:st.dataframe, call:raw_df.tail, call:df.sort_values, call:df["cpu_pct"].mean, call:df["cpu_pct"].max, call:df["iowait_pct"].mean, call:df["iowait_pct"].max, call:df["mem_pct"].mean, call:df["mem_pct"].max, call:df["net_rx_bytes_per_sec"].mean, call:df["net_rx_bytes_per_sec"].max, call:df["net_tx_bytes_per_sec"].mean, call:df["net_tx_bytes_per_sec"].max, call:df["disk_read_bps"].mean, call:df["disk_read_bps"].max, call:df["disk_write_bps"].mean, call:df["disk_write_bps"].max, call:metric_cols[0].metric, call:metric_cols[0].caption, call:metric_cols[1].metric, call:latest.get, call:metric_cols[1].caption, call:metric_cols[2].metric, call:metric_cols[2].caption, call:metric_cols[3].metric, call:format_rate_bytes, call:metric_cols[3].caption, call:metric_cols[4].metric, call:metric_cols[4].caption, call:metric_cols[5].metric, call:metric_cols[5].caption, call:metric_cols[6].metric, call:metric_cols[6].caption, call:df.set_index, call:st.markdown, call:st.line_chart, call:scaled_rate_chart_df | dep: time, typing, media_library_viewer.clients.resources, media_library_viewer.utils, pandas, streamlit
|
||||||
|
- file_browser.py | Renders an interactive SSH remote file browser UI in Streamlit with filtering, sorting, pagination, and directory navigation using ag-grid. | exp: func:reset_file_browser_filters() → None, call:st.session_state.pop, func:set_file_browser_path(path: str, selected_path, reset_filters) → None, func:aggrid_selected_rows(response: dict) → list[dict], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:render_file_browser(cached_dir_listing: Callable[..., list[dict]], ssh_args: tuple, initial_path: str) → str, call:st.subheader, call:st.session_state.pop, call:reset_file_browser_filters, call:st.session_state.get, call:st.columns, call:status_col.caption, call:selected_col.caption, call:path_col.text_input, call:set_file_browser_path, call:st.rerun, call:refresh_col.button, call:cached_dir_listing.clear, call:st.error, call:PurePosixPath(name).suffix.lower, call:str, call:display_rows.append, call:int, call:human_size, call:float, call:timestamp_to_local, call:len, call:sum, call:st.caption, call:st.container, call:filter_col.selectbox, call:search_col.text_input, call:sorted, call:ext_col.selectbox, call:sort_col.selectbox, call:order_col.toggle, call:page_size_col.selectbox, call:search_term.lower, call:r["name"].lower, call:filtered_rows.sort, call:max, call:page_col.number_input, call:summary_col.caption, call:min, call:visible_rows.append, call:visible_rows.extend, call:st.info, call:st.expander, call:st.write, call:pd.DataFrame, call:GridOptionsBuilder.from_dataframe, call:grid_builder.configure_default_column, call:grid_builder.configure_column, call:grid_builder.configure_selection, call:grid_builder.build, call:JsCode, call:AgGrid, call:aggrid_selected_rows, call:picked_row.get | dep: json, pathlib, typing, st_aggrid, media_library_viewer.utils, streamlit, pandas
|
||||||
|
- media.py | Renders a Streamlit UI tab for browsing and filtering a local SQLite-backed media index with ag-grid table display and automatic file browser synchronization. | exp: func:aggrid_selected_rows(response: dict[str, Any]) → list[dict[str, Any]], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_tab(client, user_id: str, libraries: list[dict[str, Any]], set_file_browser_path: Callable[[str, str | None, bool], None]) → None, call:st.subheader, call:st.caption, call:MediaIndex, call:index.status, call:st.columns, call:status_parts.append, call:format_elapsed, call:status_col.caption, call:" | ".join, call:status_col.warning, call:build_col.button, call:st.spinner, call:build_media_index, call:st.success, call:st.rerun, call:refresh_col.button, call:st.info, call:filter_col.multiselect, call:list, call:library_options.keys, call:type_col.multiselect, call:search_col.text_input, call:page_size_col.selectbox, call:page_col.number_input, call:sort_col.selectbox, call:sort_options.keys, call:order_col.selectbox, call:hdr_col.selectbox, call:index.query, call:int, call:len, call:pd.DataFrame(rows)[columns].fillna, call:st.session_state.get, call:GridOptionsBuilder.from_dataframe, call:grid_builder.configure_default_column, call:grid_builder.configure_column, call:grid_builder.configure_selection, call:grid_builder.build, call:JsCode, call:AgGrid, call:min, call:aggrid_selected_rows, call:selected_rows[0].get, call:set_file_browser_path, call:str, call:PurePosixPath, call:st.expander, call:st.write | dep: pathlib, typing, st_aggrid, media_library_viewer.services.media_index, pandas, streamlit
|
||||||
|
- preview.py | Renders a Streamlit UI for previewing selected media file metadata via ffprobe and executing remote SSH diagnostic tools/jobs. | exp: func:render_ffprobe_sections(ffprobe_data: dict[str, Any]) → None, call:ffprobe_format_summary, call:summarize_video_streams, call:summarize_audio_streams, call:summarize_subtitle_streams, call:st.markdown, call:st.dataframe, call:pd.DataFrame, call:st.caption, func:render_selected_file_preview(ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:st.container, call:st.markdown, call:st.caption, call:is_known_video_file, call:st.columns, call:refresh_col.button, call:cached_ffprobe_preview.clear, call:st.rerun, call:st.spinner, call:status_col.error, call:status_col.success, call:render_ffprobe_sections, call:st.expander, call:st.json, func:render_ssh_tools(ssh, ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:render_selected_file_preview, call:st.subheader, call:st.tabs, call:st.button, call:ssh.ffprobe_json, call:render_ffprobe_sections, call:st.expander, call:st.dataframe, call:pd.DataFrame, call:summarize_streams, call:st.json, call:st.error, call:str, call:ssh.stat_path, call:st.code, call:st.warning, call:st.selectbox, call:list, call:JOB_TEMPLATES.keys, call:st.caption, call:JOB_TEMPLATES[job_key].render, call:run_job, call:st.write | dep: typing, media_library_viewer.jobs, media_library_viewer.utils, pandas, streamlit
|
||||||
|
## arch
|
||||||
|
Module-based render pattern where each UI tab/view is isolated in its own module, sharing session state for cross-component synchronization (e.g., file browser auto-sync) and leveraging ag-grid for interactive data tables.
|
||||||
|
## tags
|
||||||
|
call:metric, call:grid, render, call:st.caption, col.button, browser, col.selectbox, media
|
||||||
|
## symbols
|
||||||
|
- format_rate_bytes
|
||||||
|
- rate_scale
|
||||||
|
- scaled_rate_chart_df
|
||||||
|
- format_elapsed
|
||||||
|
- render_media_overview
|
||||||
|
- render_now_playing
|
||||||
|
- render_resource_dashboard
|
||||||
|
- reset_file_browser_filters
|
||||||
|
## workflows
|
||||||
|
- change ui behavior
|
||||||
|
read: __init__.py, dashboard.py, file_browser.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# archive/tests (index)
|
||||||
|
dir: archive/tests
|
||||||
|
|
||||||
|
## role
|
||||||
|
Legacy or archived test directory currently containing only a placeholder file with no active test code.
|
||||||
|
## parent
|
||||||
|
index: archive/.pi-map.index.md
|
||||||
|
map: archive/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- .gitkeep
|
||||||
|
## links
|
||||||
|
index: archive/tests/.pi-map.index.md
|
||||||
|
map: archive/tests/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# archive/tests
|
||||||
|
dir: archive/tests
|
||||||
|
|
||||||
|
index: archive/tests/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Legacy or archived test directory currently containing only a placeholder file with no active test code.
|
||||||
|
## files
|
||||||
|
- .gitkeep | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
|
## arch
|
||||||
|
Empty placeholder structure using a `.gitkeep` file to preserve the directory in version control for potential future use.
|
||||||
|
## tags
|
||||||
|
tmux, swaps, position, two, panes, within, window, windows
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# backend (index)
|
||||||
|
dir: backend
|
||||||
|
|
||||||
|
## role
|
||||||
|
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||||
|
## parent
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## children
|
||||||
|
- backend/.pytest_cache
|
||||||
|
index: backend/.pytest_cache/.pi-map.index.md
|
||||||
|
map: backend/.pytest_cache/.pi-map.md
|
||||||
|
- backend/.ruff_cache
|
||||||
|
index: backend/.ruff_cache/.pi-map.index.md
|
||||||
|
map: backend/.ruff_cache/.pi-map.md
|
||||||
|
- backend/src
|
||||||
|
index: backend/src/.pi-map.index.md
|
||||||
|
map: backend/src/.pi-map.md
|
||||||
|
- backend/tests
|
||||||
|
index: backend/tests/.pi-map.index.md
|
||||||
|
map: backend/tests/.pi-map.md
|
||||||
|
## files
|
||||||
|
- Dockerfile
|
||||||
|
- README.md
|
||||||
|
- pyproject.toml
|
||||||
|
## links
|
||||||
|
index: backend/.pi-map.index.md
|
||||||
|
map: backend/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# backend
|
||||||
|
dir: backend
|
||||||
|
|
||||||
|
index: backend/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||||
|
## files
|
||||||
|
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
|
||||||
|
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
|
||||||
|
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
|
||||||
|
## arch
|
||||||
|
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support.
|
||||||
|
## tags
|
||||||
|
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
+36
-21
@@ -13,29 +13,46 @@ backend/
|
|||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── main.py # FastAPI app entrypoint
|
│ ├── main.py # FastAPI app entrypoint
|
||||||
│ ├── config.py # pydantic-settings config
|
│ ├── config.py # pydantic-settings config
|
||||||
|
│ ├── auth.py # OIDC/JWT + API key auth
|
||||||
│ ├── dependencies.py # Dependency injection
|
│ ├── dependencies.py # Dependency injection
|
||||||
|
│ ├── observability.py # Prometheus metrics + request IDs
|
||||||
|
│ ├── logging_utils.py # Structured JSON/text logging
|
||||||
│ ├── path_utils.py # Jellyfin→SSH path resolution
|
│ ├── path_utils.py # Jellyfin→SSH path resolution
|
||||||
│ ├── jobs.py # Job templates
|
│ ├── jobs.py # Job templates
|
||||||
│ ├── utils.py # Formatting helpers
|
│ ├── utils.py # Formatting helpers
|
||||||
│ ├── routers/
|
│ ├── routers/
|
||||||
|
│ │ ├── backups.py
|
||||||
│ │ ├── dashboard.py
|
│ │ ├── dashboard.py
|
||||||
│ │ ├── monitoring.py
|
|
||||||
│ │ ├── media.py
|
|
||||||
│ │ ├── users.py
|
|
||||||
│ │ ├── settings.py
|
|
||||||
│ │ ├── files.py
|
│ │ ├── files.py
|
||||||
│ │ └── jobs.py
|
│ │ ├── jobs.py
|
||||||
|
│ │ ├── media.py
|
||||||
|
│ │ ├── monitoring.py
|
||||||
|
│ │ ├── services.py
|
||||||
|
│ │ ├── settings.py
|
||||||
|
│ │ ├── tasks.py
|
||||||
|
│ │ ├── users.py (+ users_impl.py)
|
||||||
|
│ │ └── widgets.py
|
||||||
│ ├── clients/
|
│ ├── clients/
|
||||||
│ │ ├── jellyfin.py
|
│ │ ├── jellyfin.py
|
||||||
│ │ ├── jellyseerr.py
|
│ │ ├── jellyseerr.py
|
||||||
│ │ ├── local.py
|
│ │ ├── local.py
|
||||||
│ │ ├── resources.py
|
|
||||||
│ │ └── ssh.py
|
│ │ └── ssh.py
|
||||||
|
│ ├── integrations/ # Service-registry definitions
|
||||||
│ ├── domain/
|
│ ├── domain/
|
||||||
│ │ └── media.py
|
│ │ └── media.py
|
||||||
│ └── services/
|
│ ├── models/ # Pydantic request/response models
|
||||||
│ ├── media_index.py
|
│ ├── services/
|
||||||
│ └── settings_store.py
|
│ │ ├── media_index.py (+ _impl.py)
|
||||||
|
│ │ ├── settings_store.py
|
||||||
|
│ │ ├── secrets.py # Fernet encryption at rest
|
||||||
|
│ │ ├── targets.py # Node Exporter target discovery
|
||||||
|
│ │ ├── task_runner.py
|
||||||
|
│ │ ├── mail_queue.py (+ mailer.py/_impl.py)
|
||||||
|
│ │ ├── backup_alert_engine.py (+ backup_poller.py)
|
||||||
|
│ │ ├── known_hosts.py
|
||||||
|
│ │ └── db_maintenance.py
|
||||||
|
│ ├── widgets/ # Widget sources (dashboard data adapters)
|
||||||
|
│ └── workers/ # Background workers (media index)
|
||||||
└── tests/
|
└── tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -99,7 +116,7 @@ Or with PYTHONPATH if not installed:
|
|||||||
PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
|
PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
API docs available at: http://localhost:8000/docs
|
API docs available at: <http://localhost:8000/docs>
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -123,16 +140,16 @@ export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
2. After the API is running, open the app, go to **Settings**, and add machine entries:
|
1. After the API is running, open the app, go to **Settings**, and add machine entries:
|
||||||
- **Local**: monitors the API host itself without SSH.
|
- **Local**: monitors the API host itself without SSH.
|
||||||
- **SSH**: monitors another machine using a host, username, and a private key pasted directly into the machine settings, with an optional passphrase.
|
- **SSH**: monitors another machine using a host, username, and a private key pasted directly into the machine settings, with an optional passphrase.
|
||||||
- The machine editor groups Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes under separate headings so each service area is easier to scan.
|
- The machine editor groups Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes under separate headings so each service area is easier to scan.
|
||||||
- Saving a monitoring machine now validates the banner/auth flow, records the first trusted host key into the backend-managed `known_hosts` file, and starts the collector so charts populate without a separate manual step.
|
- Saving a monitoring machine validates the banner/auth flow and records the first trusted host key into the backend-managed `known_hosts` file.
|
||||||
- If the host cannot be reached or authenticated, the save flow surfaces the SSH error directly in the dialog.
|
- If the host cannot be reached or authenticated, the save flow surfaces the SSH error directly in the dialog.
|
||||||
- Use **Validate SSH + trust host** in the machine editor before saving if you want to test the banner/auth flow explicitly.
|
- Use **Validate SSH + trust host** in the machine editor before saving if you want to test the banner/auth flow explicitly.
|
||||||
- The first successful SSH connection uses trust-on-first-use: the backend records that machine's host key into its managed `known_hosts` file automatically, then continues verifying it strictly on later connects.
|
- The first successful SSH connection uses trust-on-first-use: the backend records that machine's host key into its managed `known_hosts` file automatically, then continues verifying it strictly on later connects.
|
||||||
|
|
||||||
3. Open **Monitoring** to see one section per configured machine. Each section uses its own collector state, disk path, metrics queries, and recent action history, which are populated automatically by the backend poller.
|
2. Open **Observability** to see Alertmanager alerts, Prometheus scrape targets, and Grafana deep-links for configured machines. Alertmanager, Grafana, and Prometheus are configured as service instances on the **Services** page; system metrics (disk, CPU, memory) are owned by the external observability stack (Prometheus + node_exporter + Grafana), not by the Manage backend.
|
||||||
|
|
||||||
For local development, `docker compose -f docker-compose.dev.yml up --build` does not require an SSH key unless you configure remote SSH machines in the Settings tab.
|
For local development, `docker compose -f docker-compose.dev.yml up --build` does not require an SSH key unless you configure remote SSH machines in the Settings tab.
|
||||||
|
|
||||||
@@ -142,14 +159,12 @@ For local development, `docker compose -f docker-compose.dev.yml up --build` doe
|
|||||||
- `GET /api/dashboard/libraries` — Per-library breakdown
|
- `GET /api/dashboard/libraries` — Per-library breakdown
|
||||||
- `GET /api/dashboard/now-playing` — Active playback sessions
|
- `GET /api/dashboard/now-playing` — Active playback sessions
|
||||||
- `GET /api/monitoring/machines` — Persistent monitoring machine definitions
|
- `GET /api/monitoring/machines` — Persistent monitoring machine definitions
|
||||||
- `GET /api/monitoring/status?machine_id=` — Collector status for a machine
|
- `GET /api/monitoring/prometheus-targets` — Prometheus scrape targets for remote Node Exporters (consumed by external Prometheus via `http_sd_configs`)
|
||||||
- `GET /api/monitoring/metrics?machine_id=` — Resource samples (last hour)
|
- `GET /api/monitoring/alerts` — Active Alertmanager alerts summary (resolves the configured alertmanager service)
|
||||||
- `GET /api/monitoring/disk?machine_id=` — Disk space
|
- `GET /api/monitoring/alertmanager-status` — Alertmanager cluster/status
|
||||||
- `POST /api/monitoring/start|stop|restart?machine_id=` — Collector controls
|
- `GET /api/monitoring/grafana-status` — Grafana service health
|
||||||
- `GET /api/monitoring/diagnostics?machine_id=` — Collector debug info
|
- `GET /api/monitoring/prometheus-status` — Prometheus service health
|
||||||
- `GET /api/monitoring/poller` — Backend poller status and configuration
|
- `POST /api/monitoring/alertmanager-webhook` — Receive Alertmanager webhooks (log-only)
|
||||||
- `GET /api/monitoring/machines/{machine_id}/actions` — Recent machine action history
|
|
||||||
- `GET /api/dashboard/monitoring` — Dashboard-wide per-machine monitoring summary table with 10-minute averages and min/max subtext
|
|
||||||
- `GET /api/settings/machines` — Manage machine definitions
|
- `GET /api/settings/machines` — Manage machine definitions
|
||||||
- `GET /api/media/status` — Index status
|
- `GET /api/media/status` — Index status
|
||||||
- `POST /api/media/build` — Rebuild index
|
- `POST /api/media/build` — Rebuild index
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
"prometheus-client>=0.21",
|
"prometheus-client>=0.21",
|
||||||
"python-json-logger>=2.0",
|
"python-json-logger>=2.0",
|
||||||
|
"cryptography>=42.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# backend/src (index)
|
||||||
|
dir: backend/src
|
||||||
|
|
||||||
|
## role
|
||||||
|
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||||
|
## parent
|
||||||
|
index: backend/.pi-map.index.md
|
||||||
|
map: backend/.pi-map.md
|
||||||
|
## children
|
||||||
|
- backend/src/media_library_viewer_api
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## files
|
||||||
|
## links
|
||||||
|
index: backend/src/.pi-map.index.md
|
||||||
|
map: backend/src/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# backend/src
|
||||||
|
dir: backend/src
|
||||||
|
|
||||||
|
index: backend/src/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||||
|
## files
|
||||||
|
## arch
|
||||||
|
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture).
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# backend/src/media_library_viewer_api (index)
|
||||||
|
dir: backend/src/media_library_viewer_api
|
||||||
|
|
||||||
|
## role
|
||||||
|
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||||
|
## parent
|
||||||
|
index: backend/src/.pi-map.index.md
|
||||||
|
map: backend/src/.pi-map.md
|
||||||
|
## children
|
||||||
|
- backend/src/media_library_viewer_api/clients
|
||||||
|
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
||||||
|
- backend/src/media_library_viewer_api/domain
|
||||||
|
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/domain/.pi-map.md
|
||||||
|
- backend/src/media_library_viewer_api/integrations
|
||||||
|
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
|
||||||
|
- backend/src/media_library_viewer_api/models
|
||||||
|
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/models/.pi-map.md
|
||||||
|
- backend/src/media_library_viewer_api/routers
|
||||||
|
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||||
|
- backend/src/media_library_viewer_api/services
|
||||||
|
index: backend/src/media_library_viewer_api/services/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/services/.pi-map.md
|
||||||
|
- backend/src/media_library_viewer_api/widgets
|
||||||
|
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||||
|
- backend/src/media_library_viewer_api/workers
|
||||||
|
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/workers/.pi-map.md
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- auth.py
|
||||||
|
- config.py
|
||||||
|
- dependencies.py
|
||||||
|
- jobs.py
|
||||||
|
- logging_utils.py
|
||||||
|
- main.py
|
||||||
|
- observability.py
|
||||||
|
- path_utils.py
|
||||||
|
- utils.py
|
||||||
|
- version.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change media_library_viewer_api behavior
|
||||||
|
read: __init__.py, auth.py, config.py
|
||||||
|
- change media_library_viewer_api config
|
||||||
|
read: config.py
|
||||||
|
- explore media_library_viewer_api subdirectories
|
||||||
|
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md, backend/src/media_library_viewer_api/domain/.pi-map.index.md, backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# backend/src/media_library_viewer_api
|
||||||
|
dir: backend/src/media_library_viewer_api
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||||
|
## files
|
||||||
|
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
|
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
||||||
|
- config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
|
||||||
|
- dependencies.py | Provides FastAPI dependency injection functions for resolving and caching service clients (Jellyfin, Jellyseerr, SSH/Local) and settings based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:get_jellyseerr_client(request) → JellyseerrClient | None, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:logger.info, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:JellyseerrClient, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||||
|
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
|
||||||
|
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
|
||||||
|
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
|
||||||
|
- observability.py | Provides Prometheus metrics collection, request ID generation/correlation, and structured logging helpers for application observability. | exp: func:set_current_request_id(request_id: str | None) → None, call:_current_request_id.set, func:get_current_request_id() → str | None, call:_current_request_id.get, func:generate_request_id() → str, call:uuid.uuid4, func:get_request_id(request) → str, call:request.headers.get, call:header.strip, call:_current_request_id.get, call:generate_request_id, call:_current_request_id.set, func:metrics_payload() → tuple[bytes, str], call:generate_latest, func:record_request(request: Request, response: Response, duration_seconds: float) → None, call:str, call:REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc, call:REQUEST_DURATION.labels(method=method, path=path).observe, func:record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) → None, call:SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc, call:SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe, func:record_media_index_build(status: str, duration_seconds) → None, call:MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc, call:MEDIA_INDEX_BUILD_DURATION.observe, func:record_backup_run(job_name: str, status: str, success) → None, call:BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc, call:BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time, func:record_mail_queue(status: str) → None, call:MAIL_QUEUE_SIZE.labels(status=status).inc, func:log_extra(request, **kwargs: Any) → dict[str, Any], call:get_request_id, call:extra.update | dep: uuid, contextvars, typing, fastapi, prometheus_client
|
||||||
|
- path_utils.py | Maps Jellyfin media paths to SSH-accessible paths using media root anchoring or fallback prefixing. | exp: func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:logger.debug, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:logger.debug, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:logger.debug, call:apply_remote_path_prefix | dep: logging, posixpath
|
||||||
|
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||||
|
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
||||||
|
## arch
|
||||||
|
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution.
|
||||||
|
## tags
|
||||||
|
call:, settings, call:get, request, get, client, call:str, id
|
||||||
|
## symbols
|
||||||
|
- Settings
|
||||||
|
- JobTemplate
|
||||||
|
- _normalize_issuer_url
|
||||||
|
- get_oidc_metadata
|
||||||
|
- get_jwk_client
|
||||||
|
- _split_audience
|
||||||
|
- validate_auth_settings
|
||||||
|
- validate_bearer_jwt
|
||||||
|
## workflows
|
||||||
|
- change media_library_viewer_api behavior
|
||||||
|
read: __init__.py, auth.py, config.py
|
||||||
|
- change media_library_viewer_api config
|
||||||
|
read: config.py
|
||||||
|
- explore media_library_viewer_api subdirectories
|
||||||
|
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md, backend/src/media_library_viewer_api/domain/.pi-map.index.md, backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# backend/src/media_library_viewer_api/clients (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/clients
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- jellyfin.py
|
||||||
|
- jellyseerr.py
|
||||||
|
- local.py
|
||||||
|
- ssh.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change clients behavior
|
||||||
|
read: __init__.py, jellyfin.py, jellyseerr.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# backend/src/media_library_viewer_api/clients
|
||||||
|
dir: backend/src/media_library_viewer_api/clients
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
|
||||||
|
## files
|
||||||
|
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
|
- jellyfin.py | Provides a reusable, framework-agnostic HTTP client wrapper for the Jellyfin/Emby API with methods for browsing users, libraries, media items, and sessions. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests
|
||||||
|
- jellyseerr.py | HTTP client wrapper for the Jellyseerr REST API to fetch user data and enrich Jellyfin user information | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests
|
||||||
|
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
|
||||||
|
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
||||||
|
## arch
|
||||||
|
Client-wrapper pattern with framework-agnostic abstractions; parallel local/remote execution strategies via paramiko SSH and local subprocess; centralized REST API communication modules.
|
||||||
|
## tags
|
||||||
|
call:logger.info, call:logger.debug, call:self.get, call:shlex.quote, error, host, call:self.run, init
|
||||||
|
## symbols
|
||||||
|
- JellyfinClient
|
||||||
|
- JellyseerrClient
|
||||||
|
- CommandResult
|
||||||
|
- LocalCommandClient
|
||||||
|
- RemoteSSHClient
|
||||||
|
- __init__
|
||||||
|
- get
|
||||||
|
- users
|
||||||
|
## workflows
|
||||||
|
- change clients behavior
|
||||||
|
read: __init__.py, jellyfin.py, jellyseerr.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -54,11 +54,6 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Observability
|
# Observability
|
||||||
prometheus_enabled: bool = True
|
prometheus_enabled: bool = True
|
||||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
|
||||||
alertmanager_url: str = "http://alertmanager:9093"
|
|
||||||
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
|
||||||
grafana_url: str = "http://grafana:3000"
|
|
||||||
prometheus_url: str = "http://prometheus:9090"
|
|
||||||
|
|
||||||
# Remote paths
|
# Remote paths
|
||||||
remote_media_root: str = ""
|
remote_media_root: str = ""
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Dependency injection for FastAPI.
|
"""Dependency injection for FastAPI.
|
||||||
|
|
||||||
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request
|
Provides access to service-specific Jellyfin/Jellyseerr clients and
|
||||||
context. The selected machine can be chosen with a ``machine_id`` query
|
machine-specific SSH clients via FastAPI's request context.
|
||||||
parameter; otherwise the backend falls back to the first enabled machine that
|
|
||||||
matches the requested service.
|
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
|
||||||
|
parameter (resolved against the service registry); the backend falls back to
|
||||||
|
the first enabled ``jellyfin``/``jellyseerr`` service instance.
|
||||||
|
- SSH/Files transport is selected with ``machine_id`` as before.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None:
|
|||||||
return machine_id or None
|
return machine_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def _request_jellyfin_service_id(request: Request | None) -> str | None:
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
service_id = request.query_params.get("jellyfin_service_id")
|
||||||
|
return service_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def _service_record(store: SettingsStore, service_type: str, service_id: str | None) -> dict[str, Any] | None:
|
||||||
|
"""Return a service row for a type, preferring the requested id.
|
||||||
|
|
||||||
|
The row carries an in-memory decrypted ``secrets`` dict. Returns None if no
|
||||||
|
enabled instance of the type exists.
|
||||||
|
"""
|
||||||
|
from media_library_viewer_api.services.secrets import decrypt_secrets
|
||||||
|
|
||||||
|
row = None
|
||||||
|
if service_id:
|
||||||
|
candidate = store.get_service(service_id)
|
||||||
|
if candidate and candidate.get("service_type") == service_type and candidate.get("enabled", True):
|
||||||
|
row = candidate
|
||||||
|
if row is None:
|
||||||
|
instances = [s for s in store.list_services(service_type) if s.get("enabled", True)]
|
||||||
|
row = instances[0] if instances else None
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
decrypted = {}
|
||||||
|
blob = row.get("secrets") or {}
|
||||||
|
if blob:
|
||||||
|
try:
|
||||||
|
decrypted = decrypt_secrets(blob)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to decrypt service secrets service_id=%s", row.get("id"))
|
||||||
|
return {**row, "secrets": decrypted}
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||||
machine_id, url, api_key = cache_key
|
machine_id, url, api_key = cache_key
|
||||||
@@ -43,21 +81,6 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
|||||||
return JellyfinClient(url, api_key)
|
return JellyfinClient(url, api_key)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
|
||||||
def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None:
|
|
||||||
machine_id, url = cache_key
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
settings = get_settings_store().get_machine_config(machine_id) if machine_id else None
|
|
||||||
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
|
|
||||||
if not api_key:
|
|
||||||
return None
|
|
||||||
logger.info(
|
|
||||||
"Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
|
|
||||||
)
|
|
||||||
return JellyseerrClient(url, api_key)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
def _ssh_client_for(
|
def _ssh_client_for(
|
||||||
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
||||||
@@ -116,6 +139,10 @@ def _ssh_client_for(
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
||||||
|
"""Resolve an SSH/Files machine for the given transport service.
|
||||||
|
|
||||||
|
Jellyfin/Jellyseerr are resolved against the service registry, not here.
|
||||||
|
"""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
machine_id = _request_machine_id(request)
|
||||||
if machine_id:
|
if machine_id:
|
||||||
@@ -123,11 +150,7 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
|||||||
if machine and (service in machine.get("services", []) or service == "ssh"):
|
if machine and (service in machine.get("services", []) or service == "ssh"):
|
||||||
return machine
|
return machine
|
||||||
return machine
|
return machine
|
||||||
if service == "jellyfin":
|
if service == "ssh":
|
||||||
machines = store.list_machines_for_service("jellyfin")
|
|
||||||
elif service == "jellyseerr":
|
|
||||||
machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")]
|
|
||||||
elif service == "ssh":
|
|
||||||
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
||||||
else:
|
else:
|
||||||
machines = store.list_machines_for_service(service)
|
machines = store.list_machines_for_service(service)
|
||||||
@@ -135,37 +158,40 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
|||||||
|
|
||||||
|
|
||||||
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||||
"""Return a Jellyfin client for the selected machine."""
|
"""Return a Jellyfin client for the selected Jellyfin service instance."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyfin", service_id)
|
||||||
if machine is None:
|
if service is None:
|
||||||
resolved = _resolve_machine("jellyfin", request)
|
raise HTTPException(
|
||||||
if resolved:
|
status_code=503,
|
||||||
machine = store.get_machine_config(resolved["id"])
|
detail="No Jellyfin service is configured. Add a Jellyfin service on the Services page.",
|
||||||
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"):
|
)
|
||||||
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "")
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
return _jellyfin_client_for(cache_key)
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
|
if not base_url or not api_key:
|
||||||
raise RuntimeError(
|
raise HTTPException(
|
||||||
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings."
|
status_code=503,
|
||||||
)
|
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
|
||||||
|
)
|
||||||
|
cache_key = (service["id"], base_url, api_key)
|
||||||
|
return _jellyfin_client_for(cache_key)
|
||||||
|
|
||||||
|
|
||||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||||
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyseerr", service_id)
|
||||||
if machine is None:
|
if service is None:
|
||||||
resolved = _resolve_machine("jellyseerr", request)
|
logger.info("Jellyseerr client not configured (no jellyseerr service)")
|
||||||
if resolved:
|
return None
|
||||||
machine = store.get_machine_config(resolved["id"])
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"):
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "")
|
if not base_url or not api_key:
|
||||||
|
logger.info("Jellyseerr service is missing base_url or api_key")
|
||||||
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)")
|
return None
|
||||||
return None
|
return JellyseerrClient(base_url, api_key)
|
||||||
|
|
||||||
|
|
||||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||||
@@ -224,7 +250,10 @@ def get_ssh_client(request: Request = None):
|
|||||||
"set" if settings.ssh_password else "missing",
|
"set" if settings.ssh_password else "missing",
|
||||||
)
|
)
|
||||||
if not settings.ssh_key_path:
|
if not settings.ssh_key_path:
|
||||||
raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="No SSH machine is configured and SSH key settings must be configured",
|
||||||
|
)
|
||||||
return _ssh_client_for(
|
return _ssh_client_for(
|
||||||
(
|
(
|
||||||
"legacy",
|
"legacy",
|
||||||
@@ -253,16 +282,15 @@ def get_settings_store() -> SettingsStore:
|
|||||||
def get_user_id(request: Request = None) -> str:
|
def get_user_id(request: Request = None) -> str:
|
||||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
"""Return the configured Jellyfin user ID or discover the first available one."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyfin", service_id)
|
||||||
if machine is None:
|
if service and service.get("config", {}).get("user_id"):
|
||||||
resolved = _resolve_machine("jellyfin", request)
|
return str(service["config"]["user_id"])
|
||||||
if resolved:
|
|
||||||
machine = store.get_machine_config(resolved["id"])
|
|
||||||
if machine and machine.get("jellyfin_user_id"):
|
|
||||||
return str(machine["jellyfin_user_id"])
|
|
||||||
client = get_jellyfin_client(request)
|
client = get_jellyfin_client(request)
|
||||||
users = client.users()
|
users = client.users()
|
||||||
if not users:
|
if not users:
|
||||||
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="No Jellyfin users found and no user_id configured on the service",
|
||||||
|
)
|
||||||
return users[0]["Id"]
|
return users[0]["Id"]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# backend/src/media_library_viewer_api/domain (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/domain
|
||||||
|
|
||||||
|
## role
|
||||||
|
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- dashboard.py
|
||||||
|
- media.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/domain/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change domain behavior
|
||||||
|
read: __init__.py, dashboard.py, media.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# backend/src/media_library_viewer_api/domain
|
||||||
|
dir: backend/src/media_library_viewer_api/domain
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
|
||||||
|
## files
|
||||||
|
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||||
|
- dashboard.py | Provides domain helper functions for building dashboard data, specifically normalizing Jellyfin session activity rows and computing backup job summaries. | exp: func:_map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) → list[dict[str, Any]], call:session.get, call:bool, call:item.get, call:play_state.get, call:transcoding.get, call:transcode_type.append, call:results.append, call:", ".join, func:build_backup_dashboard_summary(store: SettingsStore) → BackupDashboardSummary, call:store.list_backup_jobs, call:len, call:int, call:time.time, call:store.list_backup_runs, call:recent_runs.append, call:sum, call:store.list_backup_alerts, call:failed_runs.append, call:max, call:BackupDashboardSummary, call:round | dep: time, typing, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||||
|
- media.py | Flattens inconsistent Jellyfin API JSON into normalized dictionaries for SQLite indexing and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer_api.utils, pandas
|
||||||
|
## arch
|
||||||
|
Stateless functional modules that transform inconsistent upstream API JSON into normalized dictionaries for persistence and display.
|
||||||
|
## tags
|
||||||
|
media, backup, call:item.get, call:str, date, added, dashboard, call:store.list
|
||||||
|
## symbols
|
||||||
|
- _map_sessions_to_activity_rows
|
||||||
|
- build_backup_dashboard_summary
|
||||||
|
- first_media_source
|
||||||
|
- media_streams
|
||||||
|
- stream_value
|
||||||
|
- is_hdr_item
|
||||||
|
- format_date_added
|
||||||
|
- timestamp_date_added
|
||||||
|
## workflows
|
||||||
|
- change domain behavior
|
||||||
|
read: __init__.py, dashboard.py, media.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# backend/src/media_library_viewer_api/integrations (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/integrations
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- alertmanager.py
|
||||||
|
- base.py
|
||||||
|
- grafana.py
|
||||||
|
- jellyfin.py
|
||||||
|
- jellyseerr.py
|
||||||
|
- nextcloud.py
|
||||||
|
- prometheus.py
|
||||||
|
- registry.py
|
||||||
|
- ssh_tasks.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change integrations behavior
|
||||||
|
read: __init__.py, alertmanager.py, base.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# backend/src/media_library_viewer_api/integrations
|
||||||
|
dir: backend/src/media_library_viewer_api/integrations
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||||
|
## files
|
||||||
|
- __init__.py | Defines a closed registry module for service integrations.
|
||||||
|
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
||||||
|
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
||||||
|
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
|
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
|
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
|
||||||
|
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
|
||||||
|
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
|
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
|
||||||
|
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||||
|
## arch
|
||||||
|
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions.
|
||||||
|
## tags
|
||||||
|
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
|
||||||
|
## symbols
|
||||||
|
- AlertmanagerConfig
|
||||||
|
- AlertmanagerAlertsWidgetConfig
|
||||||
|
- ServiceConfigBase
|
||||||
|
- WidgetConfigBase
|
||||||
|
- SecretField
|
||||||
|
- WidgetKind
|
||||||
|
- ServiceDefinition
|
||||||
|
- GrafanaConfig
|
||||||
|
## workflows
|
||||||
|
- change integrations behavior
|
||||||
|
read: __init__.py, alertmanager.py, base.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Closed registry of service integrations."""
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Alertmanager service definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertmanagerConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Alertmanager connection config."""
|
||||||
|
|
||||||
|
base_url: ServiceBaseUrl
|
||||||
|
timeout_seconds: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Active-alerts summary for an Alertmanager instance."""
|
||||||
|
|
||||||
|
severity_filter: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_alerts(
|
||||||
|
alerts: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
severity_filter: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a UI-friendly summary from an Alertmanager ``/api/v1/alerts`` list.
|
||||||
|
|
||||||
|
Reshapes the raw alert objects into a stable summary (``total``,
|
||||||
|
``by_severity``, top-50 ``alerts``). When ``severity_filter`` is given, only
|
||||||
|
alerts whose ``labels.severity`` matches are counted.
|
||||||
|
"""
|
||||||
|
by_severity: dict[str, int] = {}
|
||||||
|
open_alerts: list[dict[str, Any]] = []
|
||||||
|
for alert in alerts:
|
||||||
|
labels = alert.get("labels") or {}
|
||||||
|
annotations = alert.get("annotations") or {}
|
||||||
|
severity = labels.get("severity", "unknown")
|
||||||
|
if severity_filter and severity != severity_filter:
|
||||||
|
continue
|
||||||
|
by_severity[severity] = by_severity.get(severity, 0) + 1
|
||||||
|
open_alerts.append(
|
||||||
|
{
|
||||||
|
"name": labels.get("alertname", "unknown"),
|
||||||
|
"severity": severity,
|
||||||
|
"category": labels.get("category", ""),
|
||||||
|
"job_name": labels.get("job_name", labels.get("job", "")),
|
||||||
|
"summary": annotations.get("summary", ""),
|
||||||
|
"description": annotations.get("description", ""),
|
||||||
|
"active_since": alert.get("startsAt"),
|
||||||
|
"state": alert.get("status", "firing"),
|
||||||
|
"labels": labels,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
||||||
|
return {
|
||||||
|
"total": len(open_alerts),
|
||||||
|
"by_severity": by_severity,
|
||||||
|
"alerts": open_alerts[:50],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="alertmanager",
|
||||||
|
name="Alertmanager",
|
||||||
|
description="Alertmanager alerts and status.",
|
||||||
|
config_model=AlertmanagerConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="active_alerts",
|
||||||
|
name="Active alerts",
|
||||||
|
description="Firing alerts summary from Alertmanager.",
|
||||||
|
model_cls=AlertmanagerAlertsWidgetConfig,
|
||||||
|
default_config={},
|
||||||
|
refresh_interval_ms=30_000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Base classes for service integrations.
|
||||||
|
|
||||||
|
A *service definition* is a closed, compile-time description of an external service
|
||||||
|
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||||
|
|
||||||
|
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||||
|
* the secret fields it accepts (API keys / tokens),
|
||||||
|
* the widget kinds it can contribute to the dashboard (each with its own
|
||||||
|
Pydantic-derived config schema).
|
||||||
|
|
||||||
|
Definitions live in :mod:`media_library_viewer_api.integrations` modules and are
|
||||||
|
assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS`
|
||||||
|
map. There is no runtime plugin loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, BeforeValidator, Field
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_service_base_url(value: Any) -> str:
|
||||||
|
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||||
|
|
||||||
|
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||||
|
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||||
|
clear error instead of letting the call silently malfunction.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("base_url must be a string starting with http:// or https://")
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("base_url must not be empty")
|
||||||
|
lowered = text.lower()
|
||||||
|
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
||||||
|
raise ValueError("base_url must start with http:// or https:// (include the schema)")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
#: Shared annotated type for service ``base_url`` fields. applying the validator
|
||||||
|
#: uniformly across every integration so missing schemas are rejected at the
|
||||||
|
#: config boundary with a helpful message.
|
||||||
|
ServiceBaseUrl = Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Absolute URL including the http:// or https:// schema."),
|
||||||
|
BeforeValidator(_validate_service_base_url),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceConfigBase(BaseModel):
|
||||||
|
"""Base for per-service non-secret config models.
|
||||||
|
|
||||||
|
Subclass this in each integration module and declare the connection fields.
|
||||||
|
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
||||||
|
|
||||||
|
Connection URLs should use the :data:`ServiceBaseUrl` type so the
|
||||||
|
``http(s)://`` schema is enforced consistently across integrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetConfigBase(BaseModel):
|
||||||
|
"""Base for per-widget config models.
|
||||||
|
|
||||||
|
Subclass this for each widget kind a service provides. Widget configs never
|
||||||
|
hold secrets; credentials live on the parent service record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SecretField:
|
||||||
|
"""A secret field stored encrypted on the service record."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
required: bool = False
|
||||||
|
helper: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WidgetKind:
|
||||||
|
"""A widget kind contributed by a service definition."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_schema: dict[str, Any]
|
||||||
|
default_config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
refresh_interval_ms: int = 0
|
||||||
|
config_model: type[WidgetConfigBase] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ServiceDefinition:
|
||||||
|
"""Closed description of an external service type."""
|
||||||
|
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_model: type[ServiceConfigBase]
|
||||||
|
secret_fields: list[SecretField]
|
||||||
|
widget_kinds: list[WidgetKind]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_schema(self) -> dict[str, Any]:
|
||||||
|
"""JSON schema for the service's non-secret config."""
|
||||||
|
return self.config_model.model_json_schema()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secret_keys(self) -> set[str]:
|
||||||
|
return {sf.key for sf in self.secret_fields}
|
||||||
|
|
||||||
|
def widget_kind(self, kind: str) -> WidgetKind | None:
|
||||||
|
for wk in self.widget_kinds:
|
||||||
|
if wk.kind == kind:
|
||||||
|
return wk
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def widget_kind(
|
||||||
|
kind: str,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
model_cls: type[WidgetConfigBase],
|
||||||
|
*,
|
||||||
|
default_config: dict[str, Any] | None = None,
|
||||||
|
refresh_interval_ms: int = 0,
|
||||||
|
) -> WidgetKind:
|
||||||
|
"""Build a :class:`WidgetKind` from a Pydantic widget-config model."""
|
||||||
|
schema = model_cls.model_json_schema()
|
||||||
|
# Strip Pydantic's title noise so the exposed schema stays clean.
|
||||||
|
schema.pop("title", None)
|
||||||
|
return WidgetKind(
|
||||||
|
kind=kind,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
config_schema=schema,
|
||||||
|
default_config=dict(default_config or {}),
|
||||||
|
refresh_interval_ms=refresh_interval_ms,
|
||||||
|
config_model=model_cls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
||||||
|
instance = model_cls.model_validate(config or {})
|
||||||
|
return instance.model_dump(exclude_none=True)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Grafana service definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GrafanaConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Grafana connection config."""
|
||||||
|
|
||||||
|
base_url: ServiceBaseUrl
|
||||||
|
timeout_seconds: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Deep-link to a Grafana dashboard or panel."""
|
||||||
|
|
||||||
|
dashboard_uid: str
|
||||||
|
panel_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="grafana",
|
||||||
|
name="Grafana",
|
||||||
|
description="Dashboards, metrics, and logs.",
|
||||||
|
config_model=GrafanaConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="link",
|
||||||
|
name="Dashboard link",
|
||||||
|
description="Deep-link to a Grafana dashboard or panel.",
|
||||||
|
model_cls=GrafanaLinkWidgetConfig,
|
||||||
|
default_config={"dashboard_uid": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Jellyfin service definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Jellyfin connection config."""
|
||||||
|
|
||||||
|
base_url: ServiceBaseUrl
|
||||||
|
user_id: str = ""
|
||||||
|
timeout_seconds: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Live Jellyfin session activity."""
|
||||||
|
|
||||||
|
# No user-overridable fields; the service record carries user_id.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="jellyfin",
|
||||||
|
name="Jellyfin",
|
||||||
|
description="Media server with live session activity.",
|
||||||
|
config_model=JellyfinConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", required=True),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="activity",
|
||||||
|
name="Activity",
|
||||||
|
description="Live sessions and idle users.",
|
||||||
|
model_cls=JellyfinActivityWidgetConfig,
|
||||||
|
default_config={},
|
||||||
|
refresh_interval_ms=30_000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Jellyseerr service definition.
|
||||||
|
|
||||||
|
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
|
||||||
|
own service type so multiple Jellyseerr instances are supported independently of
|
||||||
|
Jellyfin. It provides no dashboard widgets today.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyseerrConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Jellyseerr connection config."""
|
||||||
|
|
||||||
|
base_url: ServiceBaseUrl
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="jellyseerr",
|
||||||
|
name="Jellyseerr",
|
||||||
|
description="Request management companion to Jellyfin.",
|
||||||
|
config_model=JellyseerrConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", required=True),
|
||||||
|
],
|
||||||
|
widget_kinds=[],
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Nextcloud service definition.
|
||||||
|
|
||||||
|
Nextcloud is included as a proof-of-concept third-party service. It has no
|
||||||
|
dashboard widgets yet; its service page holds connection config only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Nextcloud connection config."""
|
||||||
|
|
||||||
|
base_url: ServiceBaseUrl
|
||||||
|
username: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="nextcloud",
|
||||||
|
name="Nextcloud",
|
||||||
|
description="Self-hosted files and collaboration.",
|
||||||
|
config_model=NextcloudConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="app_password", label="App password", required=True),
|
||||||
|
],
|
||||||
|
widget_kinds=[],
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Prometheus service definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Prometheus connection config."""
|
||||||
|
|
||||||
|
base_url: ServiceBaseUrl
|
||||||
|
timeout_seconds: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||||
|
"""A PromQL instant query rendered as a metric."""
|
||||||
|
|
||||||
|
promql: str
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="prometheus",
|
||||||
|
name="Prometheus",
|
||||||
|
description="Metrics storage and PromQL queries.",
|
||||||
|
config_model=PrometheusConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="metric",
|
||||||
|
name="Metric",
|
||||||
|
description="Instant query result rendered as a metric.",
|
||||||
|
model_cls=PrometheusMetricWidgetConfig,
|
||||||
|
default_config={"promql": ""},
|
||||||
|
refresh_interval_ms=30_000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Closed registry of service definitions.
|
||||||
|
|
||||||
|
Adding a brand-new service still requires a backend deploy and a module here.
|
||||||
|
There is no runtime plugin loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
|
||||||
|
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||||
|
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||||
|
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||||
|
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
|
||||||
|
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||||
|
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||||
|
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||||
|
|
||||||
|
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||||
|
GRAFANA.service_type: GRAFANA,
|
||||||
|
PROMETHEUS.service_type: PROMETHEUS,
|
||||||
|
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||||
|
JELLYFIN.service_type: JELLYFIN,
|
||||||
|
JELLYSEERR.service_type: JELLYSEERR,
|
||||||
|
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||||
|
SSH_TASKS.service_type: SSH_TASKS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_service_types() -> list[str]:
|
||||||
|
"""Return all registered service type names (sorted for stable output)."""
|
||||||
|
return sorted(SERVICE_DEFINITIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_definition(service_type: str) -> ServiceDefinition | None:
|
||||||
|
"""Return the definition for a service type, or ``None`` if unknown."""
|
||||||
|
return SERVICE_DEFINITIONS.get(service_type)
|
||||||
|
|
||||||
|
|
||||||
|
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None:
|
||||||
|
"""Return a widget kind declared by a service definition, or ``None``."""
|
||||||
|
definition = get_service_definition(service_type)
|
||||||
|
if definition is None:
|
||||||
|
return None
|
||||||
|
return definition.widget_kind(widget_kind)
|
||||||
|
|
||||||
|
|
||||||
|
def require_service_definition(service_type: str) -> ServiceDefinition:
|
||||||
|
"""Return the definition or raise ``ValueError`` for an unknown type."""
|
||||||
|
definition = get_service_definition(service_type)
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(f"Unknown service type: {service_type}")
|
||||||
|
return definition
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""SSH task runner service definition.
|
||||||
|
|
||||||
|
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
|
||||||
|
Tasks themselves stay in the global saved-task registry; the instance only owns
|
||||||
|
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
||||||
|
and shown as history on the instance's service page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SshTasksConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret SSH task runner config.
|
||||||
|
|
||||||
|
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
||||||
|
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
port: int = 22
|
||||||
|
username: str = ""
|
||||||
|
ssh_key_id: str = ""
|
||||||
|
timeout_seconds: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class SshTaskOutputWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Output of a saved task run on this instance."""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
# service_id is implicit (the widget's service); allow overriding per-widget.
|
||||||
|
service_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="ssh_tasks",
|
||||||
|
name="SSH task runner",
|
||||||
|
description="Run reusable saved tasks over SSH and keep run history.",
|
||||||
|
config_model=SshTasksConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="task_output",
|
||||||
|
name="Task output",
|
||||||
|
description="Output of a saved task run.",
|
||||||
|
model_cls=SshTaskOutputWidgetConfig,
|
||||||
|
default_config={"task_id": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -23,6 +23,7 @@ from media_library_viewer_api.observability import (
|
|||||||
)
|
)
|
||||||
from media_library_viewer_api.routers import backups as backups_router
|
from media_library_viewer_api.routers import backups as backups_router
|
||||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||||
|
from media_library_viewer_api.routers import services as services_router
|
||||||
from media_library_viewer_api.routers import widgets as widgets_router
|
from media_library_viewer_api.routers import widgets as widgets_router
|
||||||
from media_library_viewer_api.routers.settings import router as settings_router
|
from media_library_viewer_api.routers.settings import router as settings_router
|
||||||
|
|
||||||
@@ -38,14 +39,11 @@ async def lifespan(app: FastAPI):
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
configure_logging(settings.log_level, settings.log_format)
|
configure_logging(settings.log_level, settings.log_format)
|
||||||
validate_auth_settings(settings)
|
validate_auth_settings(settings)
|
||||||
|
from media_library_viewer_api.services.secrets import validate_encryption_key
|
||||||
|
|
||||||
|
validate_encryption_key()
|
||||||
logger.info("Backend startup complete: %s", describe_settings(settings))
|
logger.info("Backend startup complete: %s", describe_settings(settings))
|
||||||
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
||||||
try:
|
|
||||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
|
||||||
|
|
||||||
write_prometheus_targets(get_settings_store())
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
|
||||||
try:
|
try:
|
||||||
get_settings_store().ensure_defaults()
|
get_settings_store().ensure_defaults()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -143,6 +141,7 @@ app.include_router(tasks.router)
|
|||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(backups_router.router)
|
app.include_router(backups_router.router)
|
||||||
app.include_router(widgets_router.router)
|
app.include_router(widgets_router.router)
|
||||||
|
app.include_router(services_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# backend/src/media_library_viewer_api/models (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/models
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- backups.py
|
||||||
|
- services.py
|
||||||
|
- widgets.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/models/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change models behavior
|
||||||
|
read: backups.py, services.py, widgets.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# backend/src/media_library_viewer_api/models
|
||||||
|
dir: backend/src/media_library_viewer_api/models
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||||
|
## files
|
||||||
|
- backups.py | Defines Pydantic data models for backup system API requests and responses including reports, jobs, runs, alerts, and dashboard summaries. | exp: class:BackupReportRequest, class:BackupJobResponse, class:BackupRunResponse, class:BackupAlertResponse, class:BackupDashboardSummary | dep: datetime, typing, pydantic
|
||||||
|
- services.py | Defines Pydantic models for a service registry API, including validation to prevent credential keys in non-secret configuration. | exp: class:ServiceInstanceInput, class:ServiceInstance, class:SecretFieldInfo, class:WidgetKindInfo, class:ServiceTypeInfo, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError, func:_check(value: Any) → None, call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError | dep: typing, pydantic
|
||||||
|
- widgets.py | Defines Pydantic models for a dashboard widget system with validation to prevent secrets/credentials in widget configuration. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
|
||||||
|
## arch
|
||||||
|
Pydantic-based model layer implementing data validation, serialization contracts, and custom validators enforcing security constraints (e.g., blocking credentials in non-secret configs).
|
||||||
|
## tags
|
||||||
|
widget, backup, instance, response, info, call:isinstance, call:, service
|
||||||
|
## symbols
|
||||||
|
- BackupReportRequest
|
||||||
|
- BackupJobResponse
|
||||||
|
- BackupRunResponse
|
||||||
|
- BackupAlertResponse
|
||||||
|
- BackupDashboardSummary
|
||||||
|
- ServiceInstanceInput
|
||||||
|
- ServiceInstance
|
||||||
|
- SecretFieldInfo
|
||||||
|
## workflows
|
||||||
|
- change models behavior
|
||||||
|
read: backups.py, services.py, widgets.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Pydantic models for the service registry API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Reject credential keys in non-secret service config.
|
||||||
|
|
||||||
|
Secrets are sent in the separate ``secrets`` mapping; the plain ``config``
|
||||||
|
object must never hold them.
|
||||||
|
"""
|
||||||
|
forbidden = {
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"private_key",
|
||||||
|
"passphrase",
|
||||||
|
"credential",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _check(value: Any) -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
if key.lower() in forbidden:
|
||||||
|
raise ValueError(f"Credential key '{key}' is not allowed in service config")
|
||||||
|
_check(child)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
_check(item)
|
||||||
|
|
||||||
|
_check(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstanceInput(BaseModel):
|
||||||
|
"""Payload for creating or updating a service instance."""
|
||||||
|
|
||||||
|
id: str | None = None
|
||||||
|
service_type: str = Field(..., min_length=1)
|
||||||
|
name: str = Field(..., min_length=1)
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
secrets: dict[str, str] = Field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@field_validator("config")
|
||||||
|
@classmethod
|
||||||
|
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return _validate_config_keys(value or {})
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstance(BaseModel):
|
||||||
|
"""Persisted service instance returned by the API (no plaintext secrets)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
secrets_set: dict[str, bool]
|
||||||
|
enabled: bool
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class SecretFieldInfo(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
required: bool = False
|
||||||
|
helper: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetKindInfo(BaseModel):
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_schema: dict[str, Any]
|
||||||
|
default_config: dict[str, Any]
|
||||||
|
refresh_interval_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTypeInfo(BaseModel):
|
||||||
|
"""Metadata about a registered service type."""
|
||||||
|
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_schema: dict[str, Any]
|
||||||
|
secret_fields: list[SecretFieldInfo]
|
||||||
|
widget_kinds: list[WidgetKindInfo]
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
"""Pydantic models for the dashboard widget system."""
|
"""Pydantic models for the dashboard widget system.
|
||||||
|
|
||||||
|
Widgets are either:
|
||||||
|
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||||
|
by that service's definition (Grafana link, Prometheus metric, Jellyfin
|
||||||
|
activity, SSH task output); or
|
||||||
|
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
|
||||||
|
service-less kinds (backups, static).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
FORBIDDEN_CONFIG_KEYS = {
|
FORBIDDEN_CONFIG_KEYS = {
|
||||||
"password",
|
"password",
|
||||||
@@ -47,8 +57,8 @@ def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
|||||||
class _WidgetInstanceBase(BaseModel):
|
class _WidgetInstanceBase(BaseModel):
|
||||||
"""Shared fields between input and output widget models."""
|
"""Shared fields between input and output widget models."""
|
||||||
|
|
||||||
addon_id: str
|
service_id: str | None = None
|
||||||
widget_type: str
|
widget_kind: str = Field(..., min_length=1)
|
||||||
title: str = Field(..., min_length=1)
|
title: str = Field(..., min_length=1)
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@@ -59,6 +69,13 @@ class _WidgetInstanceBase(BaseModel):
|
|||||||
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||||
return _validate_config_keys(value or {})
|
return _validate_config_keys(value or {})
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_kind(self) -> "_WidgetInstanceBase":
|
||||||
|
# The kind must be non-empty (Field enforces it); service_id may be None
|
||||||
|
# for built-ins. Deeper validation happens in the router against the
|
||||||
|
# service definition / built-in registry.
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class WidgetInstanceInput(_WidgetInstanceBase):
|
class WidgetInstanceInput(_WidgetInstanceBase):
|
||||||
"""Payload for creating or updating a widget instance."""
|
"""Payload for creating or updating a widget instance."""
|
||||||
@@ -74,22 +91,21 @@ class WidgetInstance(_WidgetInstanceBase):
|
|||||||
updated_at: int
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
class WidgetTypeInfo(BaseModel):
|
class BuiltinWidgetKindInfo(BaseModel):
|
||||||
"""Metadata about a built-in widget type."""
|
"""Metadata about a built-in (service-less) widget kind."""
|
||||||
|
|
||||||
addon_id: str
|
kind: str
|
||||||
widget_type: str
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
source_type: str
|
|
||||||
config_schema: dict[str, Any]
|
config_schema: dict[str, Any]
|
||||||
|
default_config: dict[str, Any]
|
||||||
|
refresh_interval_ms: int
|
||||||
|
|
||||||
|
|
||||||
class WidgetDataResponse(BaseModel):
|
class WidgetDataResponse(BaseModel):
|
||||||
"""Response from the per-widget data endpoint."""
|
"""Response from the per-widget data endpoint."""
|
||||||
|
|
||||||
widget_id: str
|
widget_id: str
|
||||||
widget_type: str
|
|
||||||
data: dict[str, Any] | None = None
|
data: dict[str, Any] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
fetched_at: int
|
fetched_at: int
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# backend/src/media_library_viewer_api/routers (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/routers
|
||||||
|
|
||||||
|
## role
|
||||||
|
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- backups.py
|
||||||
|
- dashboard.py
|
||||||
|
- files.py
|
||||||
|
- jobs.py
|
||||||
|
- media.py
|
||||||
|
- monitoring.py
|
||||||
|
- services.py
|
||||||
|
- settings.py
|
||||||
|
- tasks.py
|
||||||
|
- users.py
|
||||||
|
- users_impl.py
|
||||||
|
- widgets.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change routers behavior
|
||||||
|
read: __init__.py, backups.py, dashboard.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# backend/src/media_library_viewer_api/routers
|
||||||
|
dir: backend/src/media_library_viewer_api/routers
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||||
|
## files
|
||||||
|
- __init__.py | Marks the directory as a Python package for routers.
|
||||||
|
- backups.py | FastAPI router that provides REST endpoints for reporting, tracking, and alerting on backup jobs and runs. | exp: func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||||
|
- dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||||
|
- files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils
|
||||||
|
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
|
||||||
|
- media.py | FastAPI router that manages media index lifecycle (status, build, stop, query) with subprocess worker orchestration and cooperative/force cancellation. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path) → list[str], call:str, func:_start_worker(index: MediaIndex) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(client, user_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:client.libraries, call:logger.info, call:len, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index, media_library_viewer_api.workers.media_index_worker
|
||||||
|
- monitoring.py | FastAPI router providing endpoints to check the health/status of Alertmanager, Grafana, and Prometheus services and expose Prometheus scrape targets. | exp: func:_resolve_service_record(store: SettingsStore, service_type: str, service_id) → ServiceRecord | None, call:store.get_service, call:row.get, call:build_service_record, call:store.list_services, func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_grafana_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
|
||||||
|
- services.py | Provides REST API endpoints for managing service instances in a service registry, including listing service types and CRUD operations for instances while ensuring plaintext secrets are never exposed. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store
|
||||||
|
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
|
||||||
|
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||||
|
- users.py | Re-exports all public names from the users_impl module to provide a unified public API for user-related functionality. | dep: .users_impl, users_impl
|
||||||
|
- users_impl.py | FastAPI router that fetches and merges Jellyfin users with optional Jellyseerr enrichment, plus endpoints for queueing user emails via a background mail worker. | exp: func:_safe_int(value: Any) → int, call:int, func:_permission_labels(permissions: int) → list[str], func:_role_label(permissions: int) → str, func:_account_type(user_type: Any) → str, call:_USER_TYPES.get, call:_safe_int, func:_merge_users(jellyfin_users: list[dict[str, Any]], jellyseerr_jellyfin_users: list[dict[str, Any]] | None, jellyseerr_users: list[dict[str, Any]] | None, jellyseerr_client: JellyseerrClient | None) → dict[str, Any], call:str(value or "").strip().lower, call:bool, call:_looks_like_email, call:str(value).strip, call:", ".join, call:_normalize, call:item.get, call:_lookup_keys, call:user.get, call:linked_by_jellyfin_id.get, call:(jf_link or {}).get, call:seerr_by_key.get, call:_pick_source_and_value, call:(seerr_user or {}).get, call:_first_value, call:jellyseerr_client.absolute_url, call:_safe_int, call:_role_label, call:_source_summary, call:items.append, call:_account_type, call:_permission_labels, call:logger.info, call:len, func:_normalize(value: Any) → str, call:str(value or "").strip().lower, func:_looks_like_email(value: Any) → bool, call:str(value or "").strip, call:bool, func:_pick_source_and_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:_looks_like_email, call:str(value).strip, func:_first_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:str(value or "").strip, func:_source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) → str, call:", ".join, func:_lookup_keys(item: dict[str, Any]) → list[str], call:_normalize, call:item.get, func:get_users(jellyfin, jellyseerr) → dict[str, Any], call:jellyfin.users, call:logger.info, call:len, call:jellyseerr.jellyfin_users, call:logger.exception, call:jellyseerr.users, call:_merge_users, call:bool, func:get_user_message_status() → dict[str, Any], call:mail_queue.status, func:post_user_message(recipient_ids, subject, html_body, text_body, attachments, jellyfin, jellyseerr) → dict[str, Any], call:json.loads, call:isinstance, call:str(item).strip, call:subject.strip, call:get_users, call:item.get, call:directory.get, call:users_by_id.get, call:skipped.append, call:str(item.get("email") or "").strip, call:recipients.append, call:recipient_labels.append, call:get_settings, call:validate_smtp_settings, call:mail_queue.status, call:upload.read, call:attachment_payloads.append, call:EmailAttachment, call:mail_queue.enqueue, call:str(getattr(settings, "smtp_from_address", "") or "").strip, call:getattr, call:str(getattr(settings, "smtp_username", "") or "").strip, call:logger.info, call:len, raise:HTTPException | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mailer
|
||||||
|
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances, including CRUD operations, built-in widget discovery, and data fetching through service or built-in adapters. | exp: func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException | dep: logging, time, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||||
|
## arch
|
||||||
|
Modular router-per-domain pattern where each file defines an isolated FastAPI APIRouter; routers are registered by the parent application and share common dependencies for SSH orchestration, database access, and service resolution.
|
||||||
|
## tags
|
||||||
|
call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh, call:logger.info
|
||||||
|
## symbols
|
||||||
|
- RunJobRequest
|
||||||
|
- MonitoringMachineInput
|
||||||
|
- SSHKeyInput
|
||||||
|
- SSHKeyGenerateInput
|
||||||
|
- ResetLocalDatabaseInput
|
||||||
|
- TaskInput
|
||||||
|
- RunTaskRequest
|
||||||
|
- _get_or_create_job
|
||||||
|
## workflows
|
||||||
|
- change routers behavior
|
||||||
|
read: __init__.py, backups.py, dashboard.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -1,64 +1,76 @@
|
|||||||
"""Monitoring router — observability stack status (Alertmanager + Prometheus)."""
|
"""Monitoring router — observability service status.
|
||||||
|
|
||||||
|
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
||||||
|
the service registry, not environment variables. The endpoints pick the first
|
||||||
|
enabled instance of a type when no ``service_id`` is given, and return graceful
|
||||||
|
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
from fastapi import APIRouter, Body, Depends
|
from fastapi import APIRouter, Body, Depends
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _alertmanager_client() -> Any:
|
def _resolve_service_record(
|
||||||
"""Return a simple HTTP client for the configured Alertmanager URL."""
|
store: SettingsStore, service_type: str, service_id: str | None = None
|
||||||
import requests
|
) -> ServiceRecord | None:
|
||||||
|
"""Return the requested service instance, else the first enabled one.
|
||||||
|
|
||||||
settings = get_settings()
|
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||||
return requests.Session(), settings.alertmanager_url
|
when no enabled instance of ``service_type`` is configured.
|
||||||
|
"""
|
||||||
|
if service_id:
|
||||||
|
row = store.get_service(service_id)
|
||||||
|
if not row or row.get("service_type") != service_type:
|
||||||
|
return None
|
||||||
|
if not row.get("enabled", True):
|
||||||
|
return None
|
||||||
|
return build_service_record(store, row)
|
||||||
|
for row in store.list_services(service_type):
|
||||||
|
if row.get("enabled", True):
|
||||||
|
return build_service_record(store, row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _webhook_client() -> Any:
|
def _base_url(service: ServiceRecord) -> str:
|
||||||
"""Return a simple HTTP client for the optional webhook receiver URL."""
|
return str(service.config.get("base_url") or "").rstrip("/")
|
||||||
import requests
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
return requests.Session(), settings.alertmanager_webhook_url
|
def _timeout(service: ServiceRecord, default: int) -> int:
|
||||||
|
return int(service.config.get("timeout_seconds") or default)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(service: ServiceRecord) -> dict[str, str]:
|
||||||
|
api_key = str(service.secrets.get("api_key") or "")
|
||||||
|
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _status_response(service: ServiceRecord | None, *, version: str = "", error: str | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"up": error is None,
|
||||||
|
"version": version or "",
|
||||||
|
"service_id": service.id if service else "",
|
||||||
|
"name": service.name if service else "",
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""Build a UI-friendly summary from Alertmanager /api/v1/alerts payload."""
|
"""Build a UI-friendly summary from Alertmanager /api/v1/alerts payload."""
|
||||||
by_severity: dict[str, int] = {}
|
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||||
open_alerts: list[dict[str, Any]] = []
|
|
||||||
for alert in alerts:
|
return summarize_alerts(alerts)
|
||||||
labels = alert.get("labels") or {}
|
|
||||||
annotations = alert.get("annotations") or {}
|
|
||||||
severity = labels.get("severity", "unknown")
|
|
||||||
by_severity[severity] = by_severity.get(severity, 0) + 1
|
|
||||||
open_alerts.append(
|
|
||||||
{
|
|
||||||
"name": labels.get("alertname", "unknown"),
|
|
||||||
"severity": severity,
|
|
||||||
"category": labels.get("category", ""),
|
|
||||||
"job_name": labels.get("job_name", labels.get("job", "")),
|
|
||||||
"summary": annotations.get("summary", ""),
|
|
||||||
"description": annotations.get("description", ""),
|
|
||||||
"active_since": alert.get("startsAt"),
|
|
||||||
"state": alert.get("status", "firing"),
|
|
||||||
"labels": labels,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
|
||||||
return {
|
|
||||||
"total": len(open_alerts),
|
|
||||||
"by_severity": by_severity,
|
|
||||||
"alerts": open_alerts[:50],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||||
@@ -72,11 +84,9 @@ def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dic
|
|||||||
|
|
||||||
@router.get("/prometheus-targets")
|
@router.get("/prometheus-targets")
|
||||||
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||||
"""Return Prometheus file-SD targets for remote Node Exporters.
|
"""Return Prometheus scrape targets for remote Node Exporters.
|
||||||
|
|
||||||
The backend writes these targets to a JSON file that Prometheus reads via
|
External Prometheus instances consume this list via ``http_sd_configs``.
|
||||||
file_sd_configs. This endpoint returns the same list live from the store so
|
|
||||||
the UI can preview which machines will be scraped.
|
|
||||||
"""
|
"""
|
||||||
targets = build_node_exporter_targets(store)
|
targets = build_node_exporter_targets(store)
|
||||||
logger.info("Prometheus targets requested count=%s", len(targets))
|
logger.info("Prometheus targets requested count=%s", len(targets))
|
||||||
@@ -84,47 +94,90 @@ def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/alerts")
|
@router.get("/alerts")
|
||||||
def get_alertmanager_alerts() -> dict[str, Any]:
|
def get_alertmanager_alerts(
|
||||||
|
service_id: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Return a summary of active Alertmanager alerts for the UI.
|
"""Return a summary of active Alertmanager alerts for the UI.
|
||||||
|
|
||||||
Proxies the Alertmanager `/api/v1/alerts` endpoint and reshapes the payload
|
Resolves an ``alertmanager`` service instance from the registry. When none
|
||||||
into a stable, UI-friendly format. If Alertmanager is unreachable, the
|
is configured the endpoint returns an empty summary with an
|
||||||
endpoint returns an empty summary and logs the failure so the UI can still
|
``alertmanager_not_configured`` error so the UI can render a health card.
|
||||||
render a health card instead of an error page.
|
|
||||||
"""
|
"""
|
||||||
session, base_url = _alertmanager_client()
|
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||||
|
if service is None:
|
||||||
|
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
|
||||||
try:
|
try:
|
||||||
response = session.get(f"{base_url}/api/v1/alerts", timeout=5)
|
response = requests.get(
|
||||||
|
f"{_base_url(service)}/api/v1/alerts",
|
||||||
|
headers=_auth_headers(service),
|
||||||
|
timeout=_timeout(service, 5),
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch Alertmanager alerts from %s", base_url)
|
logger.exception("Failed to fetch Alertmanager alerts")
|
||||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_unreachable"}
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"by_severity": {},
|
||||||
|
"alerts": [],
|
||||||
|
"error": "alertmanager_unreachable",
|
||||||
|
"service_id": service.id,
|
||||||
|
"name": service.name,
|
||||||
|
}
|
||||||
|
|
||||||
if data.get("status") != "success":
|
if data.get("status") != "success":
|
||||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": data.get("error", "unknown")}
|
return {
|
||||||
|
"total": 0,
|
||||||
|
"by_severity": {},
|
||||||
|
"alerts": [],
|
||||||
|
"error": data.get("error", "unknown"),
|
||||||
|
"service_id": service.id,
|
||||||
|
"name": service.name,
|
||||||
|
}
|
||||||
|
|
||||||
summary = _summary_from_alerts(data.get("data", []))
|
summary = _summary_from_alerts(data.get("data", []))
|
||||||
|
summary["service_id"] = service.id
|
||||||
|
summary["name"] = service.name
|
||||||
logger.info("Alertmanager alerts requested total=%s", summary["total"])
|
logger.info("Alertmanager alerts requested total=%s", summary["total"])
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
@router.get("/alertmanager-status")
|
@router.get("/alertmanager-status")
|
||||||
def get_alertmanager_status() -> dict[str, Any]:
|
def get_alertmanager_status(
|
||||||
"""Return Alertmanager cluster/status for the UI health card.
|
service_id: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
Uses the Alertmanager `/api/v2/status` endpoint and exposes only the high-
|
) -> dict[str, Any]:
|
||||||
level fields the UI needs: uptime, version, and whether the cluster is
|
"""Return Alertmanager cluster/status for the UI health card."""
|
||||||
healthy.
|
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||||
"""
|
if service is None:
|
||||||
session, base_url = _alertmanager_client()
|
return {
|
||||||
|
"up": False,
|
||||||
|
"version": "",
|
||||||
|
"uptime": "",
|
||||||
|
"name": "",
|
||||||
|
"peers": [],
|
||||||
|
"error": "alertmanager_not_configured",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
response = session.get(f"{base_url}/api/v2/status", timeout=5)
|
response = requests.get(
|
||||||
|
f"{_base_url(service)}/api/v2/status",
|
||||||
|
headers=_auth_headers(service),
|
||||||
|
timeout=_timeout(service, 5),
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch Alertmanager status from %s", base_url)
|
logger.exception("Failed to fetch Alertmanager status")
|
||||||
return {"up": False, "version": "", "uptime": ""}
|
return {
|
||||||
|
"up": False,
|
||||||
|
"version": "",
|
||||||
|
"uptime": "",
|
||||||
|
"name": service.name,
|
||||||
|
"peers": [],
|
||||||
|
"service_id": service.id,
|
||||||
|
"error": "alertmanager_unreachable",
|
||||||
|
}
|
||||||
|
|
||||||
cluster = data.get("cluster") or {}
|
cluster = data.get("cluster") or {}
|
||||||
status = data.get("clusterStatus") or {}
|
status = data.get("clusterStatus") or {}
|
||||||
@@ -132,20 +185,68 @@ def get_alertmanager_status() -> dict[str, Any]:
|
|||||||
"up": True,
|
"up": True,
|
||||||
"version": data.get("versionInfo", {}).get("version", ""),
|
"version": data.get("versionInfo", {}).get("version", ""),
|
||||||
"uptime": status.get("createdAt", ""),
|
"uptime": status.get("createdAt", ""),
|
||||||
"name": "",
|
"name": service.name,
|
||||||
"peers": [p.get("name", "") for p in cluster.get("peers", [])],
|
"peers": [p.get("name", "") for p in cluster.get("peers", [])],
|
||||||
|
"service_id": service.id,
|
||||||
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/grafana-status")
|
||||||
|
def get_grafana_status(
|
||||||
|
service_id: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||||
|
service = _resolve_service_record(store, "grafana", service_id)
|
||||||
|
if service is None:
|
||||||
|
return _status_response(None, error="no_service_configured")
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{_base_url(service)}/api/health",
|
||||||
|
headers=_auth_headers(service),
|
||||||
|
timeout=_timeout(service, 5),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch Grafana status")
|
||||||
|
return _status_response(service, error="grafana_unreachable")
|
||||||
|
return _status_response(service, version=data.get("version", ""))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prometheus-status")
|
||||||
|
def get_prometheus_status(
|
||||||
|
service_id: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Probe a Prometheus service instance's health and build info."""
|
||||||
|
service = _resolve_service_record(store, "prometheus", service_id)
|
||||||
|
if service is None:
|
||||||
|
return _status_response(None, error="no_service_configured")
|
||||||
|
base = _base_url(service)
|
||||||
|
timeout = _timeout(service, 10)
|
||||||
|
headers = _auth_headers(service)
|
||||||
|
try:
|
||||||
|
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
||||||
|
health.raise_for_status()
|
||||||
|
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
|
||||||
|
build_info.raise_for_status()
|
||||||
|
version = build_info.json().get("data", {}).get("version", "")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch Prometheus status")
|
||||||
|
return _status_response(service, error="prometheus_unreachable")
|
||||||
|
return _status_response(service, version=version)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alertmanager-webhook")
|
@router.post("/alertmanager-webhook")
|
||||||
def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[str, str]:
|
def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[str, str]:
|
||||||
"""Receive alerts from Alertmanager and optionally forward to a webhook URL.
|
"""Receive alerts from Alertmanager and log them for audit/debug.
|
||||||
|
|
||||||
This endpoint is the receiver referenced by the optional `webhook_configs`
|
This endpoint is the receiver referenced by the optional ``webhook_configs``
|
||||||
block in Alertmanager. It logs the payload for audit/debug purposes and, if
|
block in Alertmanager. It is log-only: received payloads are recorded but not
|
||||||
`ALERTMANAGER_WEBHOOK_URL` is configured, forwards the alert JSON verbatim.
|
forwarded anywhere. (The previous outbound relay to ``ALERTMANAGER_WEBHOOK_URL``
|
||||||
Forwarding is best-effort: a failure to reach the downstream webhook does
|
was removed when observability became service-registry configured.)
|
||||||
not fail this endpoint, so Alertmanager sees a successful delivery.
|
|
||||||
"""
|
"""
|
||||||
alerts = payload.get("alerts", [])
|
alerts = payload.get("alerts", [])
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -153,16 +254,4 @@ def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[st
|
|||||||
len(alerts),
|
len(alerts),
|
||||||
payload.get("status", "unknown"),
|
payload.get("status", "unknown"),
|
||||||
)
|
)
|
||||||
|
|
||||||
session, webhook_url = _webhook_client()
|
|
||||||
if webhook_url:
|
|
||||||
try:
|
|
||||||
response = session.post(webhook_url, json=payload, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
logger.info("Forwarded Alertmanager webhook to %s", webhook_url)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to forward Alertmanager webhook to %s", webhook_url)
|
|
||||||
else:
|
|
||||||
logger.debug("No ALERTMANAGER_WEBHOOK_URL configured; webhook stored in logs only")
|
|
||||||
|
|
||||||
return {"status": "received"}
|
return {"status": "received"}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""REST API for the service registry.
|
||||||
|
|
||||||
|
Service instances hold non-secret config and encrypted secrets. Plaintext
|
||||||
|
secrets are never returned; only the boolean ``secrets_set`` map is exposed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.integrations.base import validate_config
|
||||||
|
from media_library_viewer_api.integrations.registry import (
|
||||||
|
SERVICE_DEFINITIONS,
|
||||||
|
get_service_definition,
|
||||||
|
require_service_definition,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.models.services import (
|
||||||
|
SecretFieldInfo,
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
WidgetKindInfo,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/services", tags=["services"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_type_info(service_type: str) -> ServiceTypeInfo:
|
||||||
|
definition = require_service_definition(service_type)
|
||||||
|
return ServiceTypeInfo(
|
||||||
|
service_type=definition.service_type,
|
||||||
|
name=definition.name,
|
||||||
|
description=definition.description,
|
||||||
|
config_schema=definition.config_schema,
|
||||||
|
secret_fields=[
|
||||||
|
SecretFieldInfo(
|
||||||
|
key=sf.key,
|
||||||
|
label=sf.label,
|
||||||
|
required=sf.required,
|
||||||
|
helper=sf.helper,
|
||||||
|
)
|
||||||
|
for sf in definition.secret_fields
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
WidgetKindInfo(
|
||||||
|
kind=wk.kind,
|
||||||
|
name=wk.name,
|
||||||
|
description=wk.description,
|
||||||
|
config_schema=wk.config_schema,
|
||||||
|
default_config=wk.default_config,
|
||||||
|
refresh_interval_ms=wk.refresh_interval_ms,
|
||||||
|
)
|
||||||
|
for wk in definition.widget_kinds
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_instance(row: dict[str, Any]) -> ServiceInstance:
|
||||||
|
"""Build an API response model, surfacing only secret 'set' flags."""
|
||||||
|
definition = get_service_definition(row["service_type"])
|
||||||
|
known_secrets = definition.secret_keys if definition else set()
|
||||||
|
secrets_blob = row.get("secrets") or {}
|
||||||
|
secrets_set = {key: (key in secrets_blob and bool(secrets_blob[key])) for key in known_secrets}
|
||||||
|
return ServiceInstance(
|
||||||
|
id=row["id"],
|
||||||
|
service_type=row["service_type"],
|
||||||
|
name=row["name"],
|
||||||
|
config=row.get("config") or {},
|
||||||
|
secrets_set=secrets_set,
|
||||||
|
enabled=row["enabled"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_input(body: ServiceInstanceInput) -> None:
|
||||||
|
"""Validate service_type, config, and secret keys against the definition."""
|
||||||
|
definition = get_service_definition(body.service_type)
|
||||||
|
if definition is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unknown service type: {body.service_type}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validate_config(definition.config_model, body.config)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid service config: {exc}",
|
||||||
|
) from exc
|
||||||
|
unknown_secrets = set(body.secrets) - definition.secret_keys
|
||||||
|
if unknown_secrets:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unknown secret fields for {body.service_type}: {sorted(unknown_secrets)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/types")
|
||||||
|
def list_types() -> list[ServiceTypeInfo]:
|
||||||
|
"""Return metadata for every registered service type."""
|
||||||
|
return [_to_type_info(service_type) for service_type in sorted(SERVICE_DEFINITIONS)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances")
|
||||||
|
def list_instances(
|
||||||
|
service_type: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> list[ServiceInstance]:
|
||||||
|
"""Return all persisted service instances (no plaintext secrets)."""
|
||||||
|
rows = store.list_services(service_type)
|
||||||
|
return [_to_instance(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_instance(
|
||||||
|
body: ServiceInstanceInput,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> ServiceInstance:
|
||||||
|
"""Create a new service instance."""
|
||||||
|
_validate_input(body)
|
||||||
|
row = store.upsert_service(
|
||||||
|
{
|
||||||
|
"id": body.id,
|
||||||
|
"service_type": body.service_type,
|
||||||
|
"name": body.name,
|
||||||
|
"config": body.config,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
secret_values=body.secrets,
|
||||||
|
)
|
||||||
|
return _to_instance(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/instances/{service_id}")
|
||||||
|
def update_instance(
|
||||||
|
service_id: str,
|
||||||
|
body: ServiceInstanceInput,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> ServiceInstance:
|
||||||
|
"""Update an existing service instance."""
|
||||||
|
existing = store.get_service(service_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||||
|
if body.id is not None and body.id != service_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="ID in path does not match ID in body",
|
||||||
|
)
|
||||||
|
_validate_input(body)
|
||||||
|
row = store.upsert_service(
|
||||||
|
{
|
||||||
|
"id": service_id,
|
||||||
|
"service_type": body.service_type,
|
||||||
|
"name": body.name,
|
||||||
|
"config": body.config,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
secret_values=body.secrets,
|
||||||
|
service_id=service_id,
|
||||||
|
)
|
||||||
|
return _to_instance(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/instances/{service_id}")
|
||||||
|
def delete_instance(
|
||||||
|
service_id: str,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Delete a service instance (cascade-deletes widgets referencing it)."""
|
||||||
|
existing = store.get_service(service_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||||
|
store.delete_service(service_id)
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -17,7 +17,6 @@ from media_library_viewer_api.services.db_maintenance import remove_sqlite_datab
|
|||||||
from media_library_viewer_api.services.known_hosts import has_known_host
|
from media_library_viewer_api.services.known_hosts import has_known_host
|
||||||
from media_library_viewer_api.services.media_index import MediaIndex
|
from media_library_viewer_api.services.media_index import MediaIndex
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,13 +40,6 @@ class MonitoringMachineInput(BaseModel):
|
|||||||
ssh_private_key: str = ""
|
ssh_private_key: str = ""
|
||||||
ssh_private_key_passphrase: str = ""
|
ssh_private_key_passphrase: str = ""
|
||||||
password: str = ""
|
password: str = ""
|
||||||
media_root: str = ""
|
|
||||||
path_prefix: str = ""
|
|
||||||
jellyfin_url: str = ""
|
|
||||||
jellyfin_user_id: str = ""
|
|
||||||
jellyfin_api_key: str = ""
|
|
||||||
jellyseerr_url: str = ""
|
|
||||||
jellyseerr_api_key: str = ""
|
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -126,14 +118,6 @@ def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: Settings
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def _write_prometheus_targets(store: SettingsStore) -> None:
|
|
||||||
"""Regenerate Prometheus file-SD targets after machine changes."""
|
|
||||||
try:
|
|
||||||
write_prometheus_targets(store)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to write Prometheus file-SD targets")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/machines/test-ssh")
|
@router.post("/machines/test-ssh")
|
||||||
def test_machine_ssh(
|
def test_machine_ssh(
|
||||||
machine: MonitoringMachineInput,
|
machine: MonitoringMachineInput,
|
||||||
@@ -194,7 +178,6 @@ def post_machine(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||||
_write_prometheus_targets(store)
|
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
_validate_saved_machine_ssh(saved_machine, store)
|
||||||
return saved
|
return saved
|
||||||
@@ -209,7 +192,6 @@ def put_machine(
|
|||||||
if not store.get_machine(machine_id):
|
if not store.get_machine(machine_id):
|
||||||
raise HTTPException(status_code=404, detail="Machine not found")
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
||||||
_write_prometheus_targets(store)
|
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
_validate_saved_machine_ssh(saved_machine, store)
|
||||||
return saved
|
return saved
|
||||||
@@ -220,7 +202,6 @@ def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_
|
|||||||
if not store.get_machine(machine_id):
|
if not store.get_machine(machine_id):
|
||||||
raise HTTPException(status_code=404, detail="Machine not found")
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
store.delete_machine(machine_id)
|
store.delete_machine(machine_id)
|
||||||
_write_prometheus_targets(store)
|
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
|
||||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
|
||||||
from media_library_viewer_api.config import get_settings
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,7 +24,7 @@ class TaskInput(BaseModel):
|
|||||||
task_type: str = Field(default="shell", description="shell or python")
|
task_type: str = Field(default="shell", description="shell or python")
|
||||||
content: str = Field(default="")
|
content: str = Field(default="")
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
default_machine_id: str = ""
|
default_service_id: str = ""
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -35,61 +32,31 @@ class RunTaskRequest(BaseModel):
|
|||||||
task_id: str
|
task_id: str
|
||||||
|
|
||||||
|
|
||||||
def _machine_label(machine: dict[str, Any] | None) -> str:
|
def _service_label(service: dict[str, Any] | None) -> str:
|
||||||
if not machine:
|
if not service:
|
||||||
return ""
|
return ""
|
||||||
return str(machine.get("name") or machine.get("host") or machine.get("id") or "")
|
return str(service.get("name") or service.get("id") or "")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_machine_for_task(
|
def _resolve_service_for_task(
|
||||||
store: SettingsStore,
|
store: SettingsStore,
|
||||||
task: dict[str, Any],
|
task: dict[str, Any],
|
||||||
machine_id: str | None,
|
service_id: str | None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
if machine_id:
|
if service_id:
|
||||||
return store.get_machine_config(machine_id) or store.get_machine(machine_id)
|
return store.get_service(service_id)
|
||||||
default_machine_id = str(task.get("default_machine_id") or "").strip()
|
default_service_id = str(task.get("default_service_id") or "").strip()
|
||||||
if default_machine_id:
|
if default_service_id:
|
||||||
return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id)
|
return store.get_service(default_service_id)
|
||||||
machines = [machine for machine in store.list_machines() if machine.get("enabled")]
|
services = [svc for svc in store.list_services("ssh_tasks") if svc.get("enabled")]
|
||||||
return machines[0] if machines else None
|
return services[0] if services else None
|
||||||
|
|
||||||
|
|
||||||
def _client_for_machine(store: SettingsStore, machine: dict[str, Any]):
|
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
|
||||||
mode = str(machine.get("mode") or "local").lower()
|
"""Build a ServiceRecord from a raw settings_store service row."""
|
||||||
if mode == "local":
|
from media_library_viewer_api.services.settings_store import get_settings_store
|
||||||
return LocalCommandClient()
|
|
||||||
|
|
||||||
host = str(machine.get("host") or "").strip()
|
return build_service_record(get_settings_store(), service_row)
|
||||||
username = str(machine.get("username") or "").strip()
|
|
||||||
if not host or not username:
|
|
||||||
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
private_key = str(machine.get("ssh_private_key") or "")
|
|
||||||
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
|
|
||||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
|
||||||
if ssh_key_id:
|
|
||||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
|
||||||
if ssh_key:
|
|
||||||
private_key = str(ssh_key.get("private_key") or private_key)
|
|
||||||
passphrase = str(ssh_key.get("passphrase") or passphrase)
|
|
||||||
|
|
||||||
key_filename = ""
|
|
||||||
if machine.get("key_directory") and machine.get("key_name"):
|
|
||||||
key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}"
|
|
||||||
|
|
||||||
return RemoteSSHClient(
|
|
||||||
host=host,
|
|
||||||
username=username,
|
|
||||||
port=int(machine.get("port") or 22),
|
|
||||||
key_filename=key_filename or None,
|
|
||||||
private_key=private_key or None,
|
|
||||||
private_key_passphrase=passphrase or None,
|
|
||||||
password=str(machine.get("password") or "") or None,
|
|
||||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -125,14 +92,14 @@ def list_task_runs(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not store.get_task(task_id):
|
if not store.get_task(task_id):
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
runs = store.list_task_runs(task_id, limit=limit)
|
runs = store.list_service_task_runs(task_id=task_id, limit=limit)
|
||||||
return {"items": runs, "total": len(runs)}
|
return {"items": runs, "total": len(runs)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run")
|
@router.post("/run")
|
||||||
def run_task(
|
def run_task(
|
||||||
request: RunTaskRequest,
|
request: RunTaskRequest,
|
||||||
machine_id: str | None = Query(default=None),
|
service_id: str | None = Query(default=None),
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
task = store.get_task(request.task_id)
|
task = store.get_task(request.task_id)
|
||||||
@@ -141,68 +108,22 @@ def run_task(
|
|||||||
if not task.get("enabled", True):
|
if not task.get("enabled", True):
|
||||||
raise HTTPException(status_code=400, detail="Task is disabled")
|
raise HTTPException(status_code=400, detail="Task is disabled")
|
||||||
|
|
||||||
machine = _resolve_machine_for_task(store, task, machine_id)
|
service_row = _resolve_service_for_task(store, task, service_id)
|
||||||
if not machine:
|
if not service_row:
|
||||||
raise HTTPException(status_code=400, detail="No machine is available for this action")
|
raise HTTPException(status_code=400, detail="No SSH task service is available for this action")
|
||||||
|
if not service_row.get("enabled", True):
|
||||||
|
raise HTTPException(status_code=400, detail="Selected SSH task service is disabled")
|
||||||
|
|
||||||
task_type = str(task.get("task_type") or "shell").lower()
|
service = _service_row_to_record(service_row)
|
||||||
command = str(task.get("content") or "")
|
result = run_saved_task(store, task, service)
|
||||||
if task_type == "python":
|
|
||||||
command = f"python3 -c {shlex.quote(command)}"
|
|
||||||
elif task_type != "shell":
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
|
|
||||||
|
|
||||||
client = _client_for_machine(store, machine)
|
return {
|
||||||
start = time.perf_counter()
|
"task_id": task["id"],
|
||||||
machine_name = _machine_label(machine)
|
"task_name": task["name"],
|
||||||
try:
|
"service_id": service.id,
|
||||||
result = client.run(command, timeout=1200)
|
"service_name": _service_label(service_row),
|
||||||
stdout = result.stdout or ""
|
"task_type": task.get("task_type", "shell"),
|
||||||
stderr = result.stderr or ""
|
"exit_status": result.exit_status,
|
||||||
status_text = "success" if result.exit_status == 0 else "error"
|
"stdout": result.stdout,
|
||||||
store.record_task_run(
|
"stderr": result.stderr,
|
||||||
task,
|
}
|
||||||
status_text,
|
|
||||||
machine_id=str(machine.get("id") or ""),
|
|
||||||
machine_name=machine_name,
|
|
||||||
task_type=task_type,
|
|
||||||
duration_ms=int((time.perf_counter() - start) * 1000),
|
|
||||||
stdout_tail=stdout[-4000:],
|
|
||||||
stderr_tail=stderr[-4000:],
|
|
||||||
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"task_id": task["id"],
|
|
||||||
"task_name": task["name"],
|
|
||||||
"machine_id": str(machine.get("id") or ""),
|
|
||||||
"machine_name": machine_name,
|
|
||||||
"task_type": task_type,
|
|
||||||
"exit_status": result.exit_status,
|
|
||||||
"stdout": stdout,
|
|
||||||
"stderr": stderr,
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
||||||
error_text = str(exc)
|
|
||||||
store.record_task_run(
|
|
||||||
task,
|
|
||||||
"error",
|
|
||||||
machine_id=str(machine.get("id") or ""),
|
|
||||||
machine_name=machine_name,
|
|
||||||
task_type=task_type,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
stdout_tail="",
|
|
||||||
stderr_tail=error_text[-4000:],
|
|
||||||
error=error_text,
|
|
||||||
)
|
|
||||||
logger.exception("Task execution failed task_id=%s", task["id"])
|
|
||||||
return {
|
|
||||||
"task_id": task["id"],
|
|
||||||
"task_name": task["name"],
|
|
||||||
"machine_id": str(machine.get("id") or ""),
|
|
||||||
"machine_name": machine_name,
|
|
||||||
"task_type": task_type,
|
|
||||||
"exit_status": 1,
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": error_text,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""REST API for dashboard widget instances and registry metadata."""
|
"""REST API for dashboard widget instances.
|
||||||
|
|
||||||
|
Widgets are either service-bound (``service_id`` + ``widget_kind`` from the
|
||||||
|
service definition) or built-in (``service_id`` is null; ``widget_kind`` is one
|
||||||
|
of the service-less kinds exposed by ``GET /api/widgets/builtin``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -7,68 +14,91 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.integrations.base import validate_config
|
||||||
|
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||||
from media_library_viewer_api.models.widgets import (
|
from media_library_viewer_api.models.widgets import (
|
||||||
|
BuiltinWidgetKindInfo,
|
||||||
WidgetDataResponse,
|
WidgetDataResponse,
|
||||||
WidgetInstance,
|
WidgetInstance,
|
||||||
WidgetInstanceInput,
|
WidgetInstanceInput,
|
||||||
)
|
)
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.widgets.registry import (
|
from media_library_viewer_api.widgets.builtin import (
|
||||||
get_widget_info,
|
BUILTIN_WIDGET_KINDS,
|
||||||
list_source_types,
|
is_builtin_kind,
|
||||||
list_widget_types,
|
validate_builtin_config,
|
||||||
validate_config,
|
)
|
||||||
|
from media_library_viewer_api.widgets.sources import (
|
||||||
|
build_service_record,
|
||||||
|
get_builtin_adapter,
|
||||||
|
get_service_adapter,
|
||||||
)
|
)
|
||||||
from media_library_viewer_api.widgets.sources import get_source_adapter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
|
||||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
"""Validate widget_kind + config against the service definition or built-ins."""
|
||||||
|
if body.service_id:
|
||||||
|
service = store.get_service(body.service_id)
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Service {body.service_id} not found",
|
||||||
|
)
|
||||||
|
definition = get_service_definition(service["service_type"])
|
||||||
|
if definition is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unknown service type: {service['service_type']}",
|
||||||
|
)
|
||||||
|
widget_kind = definition.widget_kind(body.widget_kind)
|
||||||
|
if widget_kind is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(f"Service type '{service['service_type']}' does not provide widget kind '{body.widget_kind}'"),
|
||||||
|
)
|
||||||
|
if widget_kind.config_model is not None:
|
||||||
|
try:
|
||||||
|
validate_config(widget_kind.config_model, body.config)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid widget config: {exc}",
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
if not is_builtin_kind(body.widget_kind):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(
|
||||||
|
f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validate_builtin_config(body.widget_kind, body.config)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid widget config: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
info = WIDGET_REGISTRY.get(widget_type)
|
|
||||||
if not info:
|
@router.get("/builtin")
|
||||||
raise HTTPException(
|
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
"""Return metadata for service-less built-in widget kinds."""
|
||||||
detail=f"Unknown widget type: {widget_type}",
|
return [
|
||||||
|
BuiltinWidgetKindInfo(
|
||||||
|
kind=wk.kind,
|
||||||
|
name=wk.name,
|
||||||
|
description=wk.description,
|
||||||
|
config_schema=wk.config_schema,
|
||||||
|
default_config=wk.default_config,
|
||||||
|
refresh_interval_ms=wk.refresh_interval_ms,
|
||||||
)
|
)
|
||||||
return info
|
for wk in BUILTIN_WIDGET_KINDS.values()
|
||||||
|
]
|
||||||
|
|
||||||
def _validate_widget_input(body: WidgetInstanceInput) -> None:
|
|
||||||
"""Validate widget_type/addon_id match and config schema."""
|
|
||||||
info = _registry_for_type(body.widget_type)
|
|
||||||
expected_addon = info["addon_id"]
|
|
||||||
if body.addon_id != expected_addon:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=(
|
|
||||||
f"Widget type '{body.widget_type}' belongs to addon "
|
|
||||||
f"'{expected_addon}', not '{body.addon_id}'"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
validate_config(body.widget_type, body.config)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources")
|
|
||||||
def list_sources() -> list[str]:
|
|
||||||
"""Return all registered widget source types."""
|
|
||||||
return list_source_types()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/types")
|
|
||||||
def list_types() -> list[dict[str, Any]]:
|
|
||||||
"""Return metadata for all registered widget types."""
|
|
||||||
return [info.model_dump() for info in list_widget_types()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/instances")
|
@router.get("/instances")
|
||||||
@@ -85,7 +115,7 @@ def create_instance(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new widget instance."""
|
"""Create a new widget instance."""
|
||||||
_validate_widget_input(body)
|
_validate_widget_input(body, store)
|
||||||
widget = store.upsert_widget(body.model_dump())
|
widget = store.upsert_widget(body.model_dump())
|
||||||
return WidgetInstance(**widget).model_dump()
|
return WidgetInstance(**widget).model_dump()
|
||||||
|
|
||||||
@@ -105,7 +135,7 @@ def update_instance(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="ID in path does not match ID in body",
|
detail="ID in path does not match ID in body",
|
||||||
)
|
)
|
||||||
_validate_widget_input(body)
|
_validate_widget_input(body, store)
|
||||||
widget = store.upsert_widget(body.model_dump(), widget_id)
|
widget = store.upsert_widget(body.model_dump(), widget_id)
|
||||||
return WidgetInstance(**widget).model_dump()
|
return WidgetInstance(**widget).model_dump()
|
||||||
|
|
||||||
@@ -133,31 +163,44 @@ async def fetch_data(
|
|||||||
if not widget:
|
if not widget:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||||
|
|
||||||
widget_type = widget["widget_type"]
|
service_id = widget.get("service_id")
|
||||||
info = get_widget_info(widget_type)
|
widget_kind = widget.get("widget_kind") or ""
|
||||||
if info is None:
|
|
||||||
return WidgetDataResponse(
|
|
||||||
widget_id=widget_id,
|
|
||||||
widget_type=widget_type,
|
|
||||||
data=None,
|
|
||||||
error=f"Unknown widget type: {widget_type}",
|
|
||||||
fetched_at=int(time.time()),
|
|
||||||
).model_dump()
|
|
||||||
|
|
||||||
adapter = get_source_adapter(info.source_type)
|
service: Any = None
|
||||||
if adapter is None:
|
if service_id:
|
||||||
# Defensive: registry should prevent this, but return a safe error.
|
service_row = store.get_service(service_id)
|
||||||
return WidgetDataResponse(
|
if not service_row:
|
||||||
widget_id=widget_id,
|
return WidgetDataResponse(
|
||||||
widget_type=widget_type,
|
widget_id=widget_id,
|
||||||
data=None,
|
error=f"Service {service_id} not found",
|
||||||
error=f"No adapter registered for source type: {info.source_type}",
|
fetched_at=int(time.time()),
|
||||||
fetched_at=int(time.time()),
|
).model_dump()
|
||||||
).model_dump()
|
if not service_row.get("enabled", True):
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
error="Service is disabled",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
adapter = get_service_adapter(service_row["service_type"])
|
||||||
|
if adapter is None:
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
error=f"No adapter for service type {service_row['service_type']}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
service = build_service_record(store, service_row)
|
||||||
|
else:
|
||||||
|
adapter = get_builtin_adapter(widget_kind)
|
||||||
|
if adapter is None:
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
error=f"Unknown built-in widget kind: {widget_kind}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await adapter.fetch(widget["config"])
|
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
|
||||||
except Exception as exc:
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
@@ -166,7 +209,6 @@ async def fetch_data(
|
|||||||
|
|
||||||
return WidgetDataResponse(
|
return WidgetDataResponse(
|
||||||
widget_id=widget_id,
|
widget_id=widget_id,
|
||||||
widget_type=widget_type,
|
|
||||||
data=data if "error" not in data else None,
|
data=data if "error" not in data else None,
|
||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
fetched_at=int(time.time()),
|
fetched_at=int(time.time()),
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# backend/src/media_library_viewer_api/services (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/services
|
||||||
|
|
||||||
|
## role
|
||||||
|
Backend service layer providing business logic for media library management, backup monitoring, email notifications, SSH task execution, encryption, and persistent settings storage.
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- backup_alert_engine.py
|
||||||
|
- backup_poller.py
|
||||||
|
- db_maintenance.py
|
||||||
|
- known_hosts.py
|
||||||
|
- mail_queue.py
|
||||||
|
- mailer.py
|
||||||
|
- mailer_impl.py
|
||||||
|
- media_index.py
|
||||||
|
- media_index_impl.py
|
||||||
|
- secrets.py
|
||||||
|
- settings_store.py
|
||||||
|
- targets.py
|
||||||
|
- task_runner.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/services/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/services/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change services behavior
|
||||||
|
read: __init__.py, backup_alert_engine.py, backup_poller.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
|||||||
|
"""Encryption-at-rest for service secrets.
|
||||||
|
|
||||||
|
Service API keys / tokens are stored encrypted in the ``services.secrets_json``
|
||||||
|
column. Encryption uses Fernet (symmetric authenticated encryption) with a single
|
||||||
|
master key provided via the ``MANAGE_ENCRYPTION_KEY`` environment variable.
|
||||||
|
|
||||||
|
* The key **must** be a urlsafe base64-encoded 32-byte value (Fernet format).
|
||||||
|
* The key is **always required** — there is no development fallback, so secrets
|
||||||
|
are never accidentally stored in plaintext.
|
||||||
|
* Secrets are encrypted field-by-field; the ``"which secrets are set"`` metadata
|
||||||
|
can be derived from the ciphertext blob without decrypting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
ENCRYPTION_KEY_ENV = "MANAGE_ENCRYPTION_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionKeyError(RuntimeError):
|
||||||
|
"""Raised when the encryption key is missing or invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_encryption_key() -> bytes:
|
||||||
|
"""Return the raw Fernet key, or raise if missing/invalid.
|
||||||
|
|
||||||
|
The result is cached for the process lifetime. Tests should call
|
||||||
|
:func:`reset_encryption_key_cache` after changing the environment.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get(ENCRYPTION_KEY_ENV)
|
||||||
|
if not raw:
|
||||||
|
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} is required to store service secrets")
|
||||||
|
key = raw.strip().encode()
|
||||||
|
try:
|
||||||
|
Fernet(key)
|
||||||
|
except (ValueError, TypeError) as exc: # pragma: no cover - validated by tests
|
||||||
|
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key") from exc
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def reset_encryption_key_cache() -> None:
|
||||||
|
"""Drop the cached encryption key (used by tests that swap keys)."""
|
||||||
|
get_encryption_key.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
return Fernet(get_encryption_key())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(plaintext: str) -> str:
|
||||||
|
"""Encrypt a single secret value and return the ciphertext string."""
|
||||||
|
return _fernet().encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(ciphertext: str) -> str:
|
||||||
|
"""Decrypt a single ciphertext value."""
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise EncryptionKeyError("Service secret could not be decrypted") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Encrypt every provided secret value."""
|
||||||
|
fernet = _fernet()
|
||||||
|
return {key: fernet.encrypt(value.encode()).decode() for key, value in values.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Decrypt every secret value in a blob."""
|
||||||
|
fernet = _fernet()
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for key, ciphertext in blob.items():
|
||||||
|
try:
|
||||||
|
result[key] = fernet.decrypt(ciphertext.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise EncryptionKeyError(f"Service secret '{key}' could not be decrypted") from exc
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def generate_development_key() -> str:
|
||||||
|
"""Return a freshly generated Fernet key (helper for operators/docs)."""
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_encryption_key() -> None:
|
||||||
|
"""Eagerly validate that the encryption key is present and well-formed."""
|
||||||
|
get_encryption_key() # raises EncryptionKeyError on failure
|
||||||
@@ -17,16 +17,14 @@ from typing import Any
|
|||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
|
||||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||||
|
|
||||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||||
LOCAL_MACHINE_ID = "local"
|
LOCAL_MACHINE_ID = "local"
|
||||||
DEFAULT_SERVICES = ["monitoring", "files", "jellyfin"]
|
DEFAULT_SERVICES = ["monitoring", "files"]
|
||||||
|
|
||||||
|
|
||||||
def _default_local_machine() -> dict[str, Any]:
|
def _default_local_machine() -> dict[str, Any]:
|
||||||
settings = get_settings()
|
|
||||||
return {
|
return {
|
||||||
"id": LOCAL_MACHINE_ID,
|
"id": LOCAL_MACHINE_ID,
|
||||||
"name": "This machine",
|
"name": "This machine",
|
||||||
@@ -42,13 +40,6 @@ def _default_local_machine() -> dict[str, Any]:
|
|||||||
"ssh_private_key": "",
|
"ssh_private_key": "",
|
||||||
"ssh_private_key_passphrase": "",
|
"ssh_private_key_passphrase": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
"media_root": settings.media_root,
|
|
||||||
"path_prefix": settings.path_prefix,
|
|
||||||
"jellyfin_url": "",
|
|
||||||
"jellyfin_user_id": "",
|
|
||||||
"jellyfin_api_key": "",
|
|
||||||
"jellyseerr_url": "",
|
|
||||||
"jellyseerr_api_key": "",
|
|
||||||
"node_exporter_enabled": False,
|
"node_exporter_enabled": False,
|
||||||
"node_exporter_port": 9100,
|
"node_exporter_port": 9100,
|
||||||
"node_exporter_scrape_host": "",
|
"node_exporter_scrape_host": "",
|
||||||
@@ -118,7 +109,7 @@ class SettingsStore:
|
|||||||
task_type TEXT NOT NULL,
|
task_type TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
enabled INTEGER NOT NULL,
|
enabled INTEGER NOT NULL,
|
||||||
default_machine_id TEXT NOT NULL,
|
default_service_id TEXT NOT NULL,
|
||||||
notes TEXT NOT NULL,
|
notes TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
@@ -126,28 +117,14 @@ class SettingsStore:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
|
||||||
conn.execute(
|
# saved_tasks.default_machine_id → default_service_id (saved tasks now
|
||||||
"""
|
# target ssh_tasks service instances). Migrate existing columns.
|
||||||
CREATE TABLE IF NOT EXISTS saved_task_runs (
|
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
|
||||||
id TEXT PRIMARY KEY,
|
if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
|
||||||
task_id TEXT NOT NULL,
|
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
|
||||||
task_name TEXT NOT NULL,
|
# Run history for saved tasks now lives in service_task_runs; the
|
||||||
machine_id TEXT NOT NULL,
|
# legacy machine-based table is dropped.
|
||||||
machine_name TEXT NOT NULL,
|
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
|
||||||
task_type TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
duration_ms INTEGER NOT NULL,
|
|
||||||
request_id TEXT NOT NULL,
|
|
||||||
stdout_tail TEXT NOT NULL,
|
|
||||||
stderr_tail TEXT NOT NULL,
|
|
||||||
error TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)"
|
|
||||||
)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS dashboard_shortcuts (
|
CREATE TABLE IF NOT EXISTS dashboard_shortcuts (
|
||||||
@@ -179,9 +156,12 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)")
|
||||||
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
|
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||||
)
|
if "service_id" not in widget_cols:
|
||||||
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||||
|
if "widget_kind" not in widget_cols:
|
||||||
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -226,6 +206,44 @@ class SettingsStore:
|
|||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
service_type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
secrets_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type)")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_task_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
exit_status INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
stdout_tail TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr_tail TEXT NOT NULL DEFAULT '',
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_service "
|
||||||
|
"ON service_task_runs(service_id, created_at DESC)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||||
@@ -263,13 +281,6 @@ class SettingsStore:
|
|||||||
"ssh_private_key_set": bool(data.get("ssh_private_key")),
|
"ssh_private_key_set": bool(data.get("ssh_private_key")),
|
||||||
"ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")),
|
"ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")),
|
||||||
"password_set": bool(data.get("password")),
|
"password_set": bool(data.get("password")),
|
||||||
"media_root": data.get("media_root", ""),
|
|
||||||
"path_prefix": data.get("path_prefix", ""),
|
|
||||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
|
||||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
|
||||||
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
|
|
||||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
|
||||||
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
|
|
||||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||||
@@ -317,19 +328,6 @@ class SettingsStore:
|
|||||||
if password in (None, ""):
|
if password in (None, ""):
|
||||||
password = (current or {}).get("password", "")
|
password = (current or {}).get("password", "")
|
||||||
password = str(password or "")
|
password = str(password or "")
|
||||||
media_root = _current_str("media_root")
|
|
||||||
path_prefix = _current_str("path_prefix")
|
|
||||||
jellyfin_url = _current_str("jellyfin_url")
|
|
||||||
jellyfin_user_id = _current_str("jellyfin_user_id")
|
|
||||||
jellyfin_api_key = payload.get("jellyfin_api_key")
|
|
||||||
if jellyfin_api_key in (None, ""):
|
|
||||||
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
|
|
||||||
jellyfin_api_key = str(jellyfin_api_key or "")
|
|
||||||
jellyseerr_url = _current_str("jellyseerr_url")
|
|
||||||
jellyseerr_api_key = payload.get("jellyseerr_api_key")
|
|
||||||
if jellyseerr_api_key in (None, ""):
|
|
||||||
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
|
|
||||||
jellyseerr_api_key = str(jellyseerr_api_key or "")
|
|
||||||
node_exporter_enabled = bool(
|
node_exporter_enabled = bool(
|
||||||
payload.get("node_exporter_enabled")
|
payload.get("node_exporter_enabled")
|
||||||
if payload.get("node_exporter_enabled") is not None
|
if payload.get("node_exporter_enabled") is not None
|
||||||
@@ -359,13 +357,6 @@ class SettingsStore:
|
|||||||
"ssh_private_key": ssh_private_key,
|
"ssh_private_key": ssh_private_key,
|
||||||
"ssh_private_key_passphrase": ssh_private_key_passphrase,
|
"ssh_private_key_passphrase": ssh_private_key_passphrase,
|
||||||
"password": password,
|
"password": password,
|
||||||
"media_root": media_root,
|
|
||||||
"path_prefix": path_prefix,
|
|
||||||
"jellyfin_url": jellyfin_url,
|
|
||||||
"jellyfin_user_id": jellyfin_user_id,
|
|
||||||
"jellyfin_api_key": jellyfin_api_key,
|
|
||||||
"jellyseerr_url": jellyseerr_url,
|
|
||||||
"jellyseerr_api_key": jellyseerr_api_key,
|
|
||||||
"node_exporter_enabled": node_exporter_enabled,
|
"node_exporter_enabled": node_exporter_enabled,
|
||||||
"node_exporter_port": node_exporter_port,
|
"node_exporter_port": node_exporter_port,
|
||||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
"node_exporter_scrape_host": node_exporter_scrape_host,
|
||||||
@@ -387,13 +378,6 @@ class SettingsStore:
|
|||||||
"ssh_private_key": "",
|
"ssh_private_key": "",
|
||||||
"ssh_private_key_passphrase": "",
|
"ssh_private_key_passphrase": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
"media_root": machine["media_root"],
|
|
||||||
"path_prefix": machine["path_prefix"],
|
|
||||||
"jellyfin_url": machine["jellyfin_url"],
|
|
||||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
|
||||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
|
||||||
"jellyseerr_url": machine["jellyseerr_url"],
|
|
||||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
|
||||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||||
"node_exporter_port": machine["node_exporter_port"],
|
"node_exporter_port": machine["node_exporter_port"],
|
||||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||||
@@ -417,39 +401,13 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _seed_dashboard_widgets(self) -> None:
|
def _seed_dashboard_widgets(self) -> None:
|
||||||
"""Seed default dashboard widgets only when the table is empty."""
|
"""Default widget seeding was removed.
|
||||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
|
||||||
|
|
||||||
self.init_schema()
|
Widgets are now service-bound (or built-in). A fresh install starts with
|
||||||
with self.connect() as conn:
|
no widgets; the user configures services and adds widgets from the UI.
|
||||||
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
|
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
|
||||||
if row and int(row[0]) > 0:
|
"""
|
||||||
return
|
return None
|
||||||
defaults = [
|
|
||||||
{
|
|
||||||
"id": "jellyfin-activity-default",
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "jellyfin",
|
|
||||||
"title": "Jellyfin activity",
|
|
||||||
"config": {"machine_id": ""},
|
|
||||||
"enabled": True,
|
|
||||||
"sort_order": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "backups-summary-default",
|
|
||||||
"addon_id": "backups",
|
|
||||||
"widget_type": "backups",
|
|
||||||
"title": "Backups",
|
|
||||||
"config": {},
|
|
||||||
"enabled": True,
|
|
||||||
"sort_order": 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
for widget in defaults:
|
|
||||||
info = WIDGET_REGISTRY.get(widget["widget_type"])
|
|
||||||
if not info or info["addon_id"] != widget["addon_id"]:
|
|
||||||
continue
|
|
||||||
self.upsert_widget(widget)
|
|
||||||
|
|
||||||
def ensure_defaults(self) -> None:
|
def ensure_defaults(self) -> None:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
@@ -457,7 +415,6 @@ class SettingsStore:
|
|||||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||||
if not row or int(row[0]) == 0:
|
if not row or int(row[0]) == 0:
|
||||||
self._seed_local_machine()
|
self._seed_local_machine()
|
||||||
self._seed_dashboard_widgets()
|
|
||||||
|
|
||||||
def list_machines(self) -> list[dict[str, Any]]:
|
def list_machines(self) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
@@ -504,13 +461,6 @@ class SettingsStore:
|
|||||||
"ssh_private_key": data.get("ssh_private_key", ""),
|
"ssh_private_key": data.get("ssh_private_key", ""),
|
||||||
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
|
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
|
||||||
"password": data.get("password", ""),
|
"password": data.get("password", ""),
|
||||||
"media_root": data.get("media_root", ""),
|
|
||||||
"path_prefix": data.get("path_prefix", ""),
|
|
||||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
|
||||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
|
||||||
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
|
|
||||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
|
||||||
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
|
|
||||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||||
@@ -548,13 +498,6 @@ class SettingsStore:
|
|||||||
"ssh_private_key": machine["ssh_private_key"],
|
"ssh_private_key": machine["ssh_private_key"],
|
||||||
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
|
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
|
||||||
"password": machine["password"],
|
"password": machine["password"],
|
||||||
"media_root": machine["media_root"],
|
|
||||||
"path_prefix": machine["path_prefix"],
|
|
||||||
"jellyfin_url": machine["jellyfin_url"],
|
|
||||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
|
||||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
|
||||||
"jellyseerr_url": machine["jellyseerr_url"],
|
|
||||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
|
||||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||||
"node_exporter_port": machine["node_exporter_port"],
|
"node_exporter_port": machine["node_exporter_port"],
|
||||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||||
@@ -741,7 +684,7 @@ class SettingsStore:
|
|||||||
"task_type": row["task_type"],
|
"task_type": row["task_type"],
|
||||||
"content": row["content"],
|
"content": row["content"],
|
||||||
"enabled": bool(row["enabled"]),
|
"enabled": bool(row["enabled"]),
|
||||||
"default_machine_id": row["default_machine_id"],
|
"default_service_id": row["default_service_id"],
|
||||||
"notes": row["notes"],
|
"notes": row["notes"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
"updated_at": row["updated_at"],
|
"updated_at": row["updated_at"],
|
||||||
@@ -758,10 +701,10 @@ class SettingsStore:
|
|||||||
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
|
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
|
||||||
)
|
)
|
||||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||||
default_machine_id = str(
|
default_service_id = str(
|
||||||
payload.get("default_machine_id")
|
payload.get("default_service_id")
|
||||||
if payload.get("default_machine_id") is not None
|
if payload.get("default_service_id") is not None
|
||||||
else (current or {}).get("default_machine_id", "") or ""
|
else (current or {}).get("default_service_id", "") or ""
|
||||||
).strip()
|
).strip()
|
||||||
notes = str(
|
notes = str(
|
||||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||||
@@ -772,7 +715,7 @@ class SettingsStore:
|
|||||||
"task_type": task_type,
|
"task_type": task_type,
|
||||||
"content": content,
|
"content": content,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"default_machine_id": default_machine_id,
|
"default_service_id": default_service_id,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +743,7 @@ class SettingsStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO saved_tasks (
|
INSERT INTO saved_tasks (
|
||||||
id, name, task_type, content, enabled, default_machine_id,
|
id, name, task_type, content, enabled, default_service_id,
|
||||||
notes, created_at, updated_at
|
notes, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -809,7 +752,7 @@ class SettingsStore:
|
|||||||
task_type = excluded.task_type,
|
task_type = excluded.task_type,
|
||||||
content = excluded.content,
|
content = excluded.content,
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
default_machine_id = excluded.default_machine_id,
|
default_service_id = excluded.default_service_id,
|
||||||
notes = excluded.notes,
|
notes = excluded.notes,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
""",
|
""",
|
||||||
@@ -819,7 +762,7 @@ class SettingsStore:
|
|||||||
task["task_type"],
|
task["task_type"],
|
||||||
task["content"],
|
task["content"],
|
||||||
1 if task["enabled"] else 0,
|
1 if task["enabled"] else 0,
|
||||||
task["default_machine_id"],
|
task["default_service_id"],
|
||||||
task["notes"],
|
task["notes"],
|
||||||
created_at,
|
created_at,
|
||||||
now,
|
now,
|
||||||
@@ -832,58 +775,6 @@ class SettingsStore:
|
|||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,))
|
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,))
|
||||||
|
|
||||||
def list_task_runs(self, task_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
self.init_schema()
|
|
||||||
with self.connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT * FROM saved_task_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
||||||
(task_id, max(1, min(int(limit), 50))),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def record_task_run(
|
|
||||||
self,
|
|
||||||
task: dict[str, Any],
|
|
||||||
status: str,
|
|
||||||
*,
|
|
||||||
machine_id: str,
|
|
||||||
machine_name: str,
|
|
||||||
task_type: str,
|
|
||||||
duration_ms: int,
|
|
||||||
request_id: str = "",
|
|
||||||
stdout_tail: str = "",
|
|
||||||
stderr_tail: str = "",
|
|
||||||
error: str = "",
|
|
||||||
) -> None:
|
|
||||||
self.init_schema()
|
|
||||||
now = int(time.time())
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO saved_task_runs (
|
|
||||||
id, task_id, task_name, machine_id, machine_name, task_type,
|
|
||||||
status, created_at, duration_ms, request_id, stdout_tail,
|
|
||||||
stderr_tail, error
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
uuid.uuid4().hex,
|
|
||||||
str(task.get("id") or ""),
|
|
||||||
str(task.get("name") or ""),
|
|
||||||
machine_id,
|
|
||||||
machine_name,
|
|
||||||
task_type,
|
|
||||||
status,
|
|
||||||
now,
|
|
||||||
duration_ms,
|
|
||||||
request_id,
|
|
||||||
stdout_tail,
|
|
||||||
stderr_tail,
|
|
||||||
error,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
|
def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
target = json.loads(row["target_json"] or "{}")
|
target = json.loads(row["target_json"] or "{}")
|
||||||
return {
|
return {
|
||||||
@@ -1363,16 +1254,18 @@ class SettingsStore:
|
|||||||
(key, value, now),
|
(key, value, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Dashboard widgets
|
# Dashboard widgets
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
keys = row.keys()
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"addon_id": row["addon_id"],
|
"addon_id": row["addon_id"],
|
||||||
"widget_type": row["widget_type"],
|
"widget_type": row["widget_type"],
|
||||||
|
"service_id": row["service_id"] if "service_id" in keys else None,
|
||||||
|
"widget_kind": row["widget_kind"] if "widget_kind" in keys else None,
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
"config": json.loads(row["config_json"] or "{}"),
|
"config": json.loads(row["config_json"] or "{}"),
|
||||||
"enabled": bool(row["enabled"]),
|
"enabled": bool(row["enabled"]),
|
||||||
@@ -1387,14 +1280,9 @@ class SettingsStore:
|
|||||||
widget_id: str | None = None,
|
widget_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
current = self.get_widget(widget_id) if widget_id else None
|
current = self.get_widget(widget_id) if widget_id else None
|
||||||
widget_id = (
|
widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||||
str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip()
|
service_id = str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() or None
|
||||||
or uuid.uuid4().hex[:12]
|
widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip()
|
||||||
)
|
|
||||||
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
|
|
||||||
widget_type = str(
|
|
||||||
payload.get("widget_type") or (current or {}).get("widget_type", "")
|
|
||||||
).strip()
|
|
||||||
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
||||||
config = payload.get("config", (current or {}).get("config", {}))
|
config = payload.get("config", (current or {}).get("config", {}))
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
@@ -1403,10 +1291,14 @@ class SettingsStore:
|
|||||||
_validate_config_keys(config)
|
_validate_config_keys(config)
|
||||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||||
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
|
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
|
||||||
|
# Legacy label kept for diagnostics; new code uses service_id + widget_kind.
|
||||||
|
widget_type = f"{service_id}:{widget_kind}" if widget_kind else ""
|
||||||
return {
|
return {
|
||||||
"id": widget_id,
|
"id": widget_id,
|
||||||
"addon_id": addon_id,
|
"addon_id": "",
|
||||||
"widget_type": widget_type,
|
"widget_type": widget_type,
|
||||||
|
"service_id": service_id,
|
||||||
|
"widget_kind": widget_kind,
|
||||||
"title": title,
|
"title": title,
|
||||||
"config": config,
|
"config": config,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
@@ -1416,9 +1308,7 @@ class SettingsStore:
|
|||||||
def list_widgets(self) -> list[dict[str, Any]]:
|
def list_widgets(self) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
|
||||||
"SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC"
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_widget(row) for row in rows]
|
return [self._row_to_widget(row) for row in rows]
|
||||||
|
|
||||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||||
@@ -1426,9 +1316,7 @@ class SettingsStore:
|
|||||||
return None
|
return None
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute("SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)).fetchone()
|
||||||
"SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)
|
|
||||||
).fetchone()
|
|
||||||
return self._row_to_widget(row) if row else None
|
return self._row_to_widget(row) if row else None
|
||||||
|
|
||||||
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
|
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
|
||||||
@@ -1444,13 +1332,15 @@ class SettingsStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO dashboard_widgets (
|
INSERT INTO dashboard_widgets (
|
||||||
id, addon_id, widget_type, title, config_json, enabled,
|
id, addon_id, widget_type, service_id, widget_kind, title,
|
||||||
sort_order, created_at, updated_at
|
config_json, enabled, sort_order, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
addon_id = excluded.addon_id,
|
addon_id = excluded.addon_id,
|
||||||
widget_type = excluded.widget_type,
|
widget_type = excluded.widget_type,
|
||||||
|
service_id = excluded.service_id,
|
||||||
|
widget_kind = excluded.widget_kind,
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
config_json = excluded.config_json,
|
config_json = excluded.config_json,
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
@@ -1461,6 +1351,8 @@ class SettingsStore:
|
|||||||
widget["id"],
|
widget["id"],
|
||||||
widget["addon_id"],
|
widget["addon_id"],
|
||||||
widget["widget_type"],
|
widget["widget_type"],
|
||||||
|
widget["service_id"],
|
||||||
|
widget["widget_kind"],
|
||||||
widget["title"],
|
widget["title"],
|
||||||
json.dumps(widget["config"]),
|
json.dumps(widget["config"]),
|
||||||
1 if widget["enabled"] else 0,
|
1 if widget["enabled"] else 0,
|
||||||
@@ -1476,6 +1368,204 @@ class SettingsStore:
|
|||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Service registry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _row_to_service(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
secrets_blob = json.loads(row["secrets_json"] or "{}")
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"service_type": row["service_type"],
|
||||||
|
"name": row["name"],
|
||||||
|
"config": json.loads(row["config_json"] or "{}"),
|
||||||
|
"secrets": secrets_blob,
|
||||||
|
"enabled": bool(row["enabled"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_services(self, service_type: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
if service_type:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM services WHERE service_type = ? ORDER BY name ASC",
|
||||||
|
(service_type,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute("SELECT * FROM services ORDER BY name ASC").fetchall()
|
||||||
|
return [self._row_to_service(row) for row in rows]
|
||||||
|
|
||||||
|
def get_service(self, service_id: str) -> dict[str, Any] | None:
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone()
|
||||||
|
return self._row_to_service(row) if row else None
|
||||||
|
|
||||||
|
def _normalize_service_payload(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
service_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
current = self.get_service(service_id) if service_id else None
|
||||||
|
service_id = str(payload.get("id") or service_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||||
|
service_type = str(payload.get("service_type") or (current or {}).get("service_type", "")).strip()
|
||||||
|
name = str(payload.get("name") or (current or {}).get("name", "") or "").strip()
|
||||||
|
config = payload.get("config", (current or {}).get("config", {}))
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
config = {}
|
||||||
|
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||||
|
return {
|
||||||
|
"id": service_id,
|
||||||
|
"service_type": service_type,
|
||||||
|
"name": name,
|
||||||
|
"config": config,
|
||||||
|
"enabled": enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
def upsert_service(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
secret_values: dict[str, str] | None = None,
|
||||||
|
service_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Insert or update a service instance.
|
||||||
|
|
||||||
|
``secret_values`` carries plaintext secrets to encrypt and store. A key
|
||||||
|
absent from ``secret_values`` preserves the existing ciphertext; a key
|
||||||
|
mapped to an empty string clears it.
|
||||||
|
"""
|
||||||
|
self.init_schema()
|
||||||
|
service = self._normalize_service_payload(payload, service_id)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
existing = self.get_service(service["id"])
|
||||||
|
secrets_blob: dict[str, str]
|
||||||
|
if existing is not None:
|
||||||
|
secrets_blob = dict(existing["secrets"])
|
||||||
|
else:
|
||||||
|
secrets_blob = {}
|
||||||
|
if secret_values:
|
||||||
|
from media_library_viewer_api.services.secrets import encrypt_value
|
||||||
|
|
||||||
|
for key, value in secret_values.items():
|
||||||
|
if value == "":
|
||||||
|
secrets_blob.pop(key, None)
|
||||||
|
else:
|
||||||
|
secrets_blob[key] = encrypt_value(value)
|
||||||
|
|
||||||
|
with self.connect() as conn:
|
||||||
|
created_at = int(existing["created_at"]) if existing else now
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO services (
|
||||||
|
id, service_type, name, config_json, secrets_json,
|
||||||
|
enabled, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
service_type = excluded.service_type,
|
||||||
|
name = excluded.name,
|
||||||
|
config_json = excluded.config_json,
|
||||||
|
secrets_json = excluded.secrets_json,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
service["id"],
|
||||||
|
service["service_type"],
|
||||||
|
service["name"],
|
||||||
|
json.dumps(service["config"]),
|
||||||
|
json.dumps(secrets_blob),
|
||||||
|
1 if service["enabled"] else 0,
|
||||||
|
created_at,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.get_service(service["id"]) or service
|
||||||
|
|
||||||
|
def delete_service(self, service_id: str) -> None:
|
||||||
|
"""Delete a service and cascade-delete widgets referencing it."""
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
# The service_id column on dashboard_widgets is added in a later
|
||||||
|
# slice; only cascade when it is present.
|
||||||
|
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||||
|
if "service_id" in widget_cols:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM dashboard_widgets WHERE service_id = ?",
|
||||||
|
(service_id,),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||||
|
|
||||||
|
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Append a service task run history row."""
|
||||||
|
self.init_schema()
|
||||||
|
run_id = str(payload.get("id") or uuid.uuid4().hex[:12])
|
||||||
|
now = int(time.time())
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_task_runs (
|
||||||
|
id, task_id, service_id, status, exit_status, duration_ms,
|
||||||
|
stdout_tail, stderr_tail, error, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
str(payload.get("task_id") or ""),
|
||||||
|
str(payload.get("service_id") or ""),
|
||||||
|
str(payload.get("status") or "error"),
|
||||||
|
payload.get("exit_status"),
|
||||||
|
payload.get("duration_ms"),
|
||||||
|
str(payload.get("stdout_tail") or "")[:8000],
|
||||||
|
str(payload.get("stderr_tail") or "")[:8000],
|
||||||
|
str(payload.get("error") or "")[:1000],
|
||||||
|
int(payload.get("created_at") or now),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"id": run_id}
|
||||||
|
|
||||||
|
def list_service_task_runs(
|
||||||
|
self,
|
||||||
|
service_id: str | None = None,
|
||||||
|
task_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
self.init_schema()
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if service_id:
|
||||||
|
clauses.append("service_id = ?")
|
||||||
|
params.append(service_id)
|
||||||
|
if task_id:
|
||||||
|
clauses.append("task_id = ?")
|
||||||
|
params.append(task_id)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
params.append(int(limit))
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM service_task_runs {where} ORDER BY created_at DESC LIMIT ?",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"task_id": row["task_id"],
|
||||||
|
"service_id": row["service_id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"exit_status": row["exit_status"],
|
||||||
|
"duration_ms": row["duration_ms"],
|
||||||
|
"stdout_tail": row["stdout_tail"],
|
||||||
|
"stderr_tail": row["stderr_tail"],
|
||||||
|
"error": row["error"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
_store: SettingsStore | None = None
|
_store: SettingsStore | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
"""Prometheus file-based service discovery target management.
|
"""Prometheus Node Exporter target discovery.
|
||||||
|
|
||||||
The backend owns the list of remote Node Exporter targets so that operators can
|
The backend owns the list of remote Node Exporter targets so that operators can
|
||||||
enable scraping per machine from the Manage UI. Prometheus reads the generated
|
enable scraping per machine from the Manage UI. The list is exposed over HTTP at
|
||||||
JSON file via `file_sd_configs`; this keeps Prometheus config static and pushes
|
``GET /api/monitoring/prometheus-targets`` and consumed by an external Prometheus
|
||||||
machine-specific changes into a file it can reload.
|
via ``http_sd_configs`` (no shared volume required).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,10 +31,9 @@ def _scrape_address(machine: dict[str, Any]) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
||||||
"""Build a file-SD target list for all enabled SSH machines.
|
"""Build an http-SD target list for all enabled SSH machines.
|
||||||
|
|
||||||
Local machines are excluded because the Compose-managed node-exporter
|
Local machines are excluded because the Docker host is scraped directly.
|
||||||
service already covers the Docker host.
|
|
||||||
"""
|
"""
|
||||||
targets: list[dict[str, Any]] = []
|
targets: list[dict[str, Any]] = []
|
||||||
for machine in store.list_machines():
|
for machine in store.list_machines():
|
||||||
@@ -60,18 +56,3 @@ def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def write_prometheus_targets(store: SettingsStore, file_sd_dir: Path | None = None) -> Path:
|
|
||||||
"""Render and persist Prometheus file-SD targets.
|
|
||||||
|
|
||||||
Returns the path written so callers can log or expose it.
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
|
||||||
file_sd_dir = file_sd_dir or Path(settings.prometheus_file_sd_dir)
|
|
||||||
file_sd_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path = file_sd_dir / "node_exporter_targets.json"
|
|
||||||
targets = build_node_exporter_targets(store)
|
|
||||||
file_path.write_text(json.dumps(targets, indent=2), encoding="utf-8")
|
|
||||||
logger.info("Wrote %s node_exporter targets to %s", len(targets), file_path)
|
|
||||||
return file_path
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Shared runner for saved tasks over SSH task services.
|
||||||
|
|
||||||
|
Both the Actions page (``routers/tasks.py``) and the SSH task widget
|
||||||
|
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` service instances.
|
||||||
|
This module is the single execution path: build the client from the service
|
||||||
|
record, render the command, run it with the service timeout, append a
|
||||||
|
``service_task_runs`` row, and return the result.
|
||||||
|
|
||||||
|
There is intentionally no local execution mode — tasks are SSH-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||||
|
from media_library_viewer_api.config import get_settings
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskRunResult:
|
||||||
|
"""Outcome of a single saved-task run."""
|
||||||
|
|
||||||
|
exit_status: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
duration_ms: int
|
||||||
|
status: str # "success" | "failure" | "error"
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
|
||||||
|
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
|
||||||
|
config = service.config
|
||||||
|
host = str(config.get("host") or "").strip()
|
||||||
|
username = str(config.get("username") or "").strip()
|
||||||
|
if not host or not username:
|
||||||
|
raise ValueError("SSH task service is missing host or username")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
private_key = ""
|
||||||
|
key_passphrase = ""
|
||||||
|
ssh_key_id = str(config.get("ssh_key_id") or "").strip()
|
||||||
|
if ssh_key_id:
|
||||||
|
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||||
|
if ssh_key:
|
||||||
|
private_key = str(ssh_key.get("private_key") or "")
|
||||||
|
key_passphrase = str(ssh_key.get("passphrase") or "")
|
||||||
|
# Service-level passphrase secret takes precedence.
|
||||||
|
key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase
|
||||||
|
|
||||||
|
return RemoteSSHClient(
|
||||||
|
host=host,
|
||||||
|
username=username,
|
||||||
|
port=int(config.get("port") or 22),
|
||||||
|
private_key=private_key or None,
|
||||||
|
private_key_passphrase=key_passphrase or None,
|
||||||
|
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||||
|
timeout=int(config.get("timeout_seconds") or 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_command(task: dict[str, Any]) -> str:
|
||||||
|
"""Render a saved task into a shell command (shell or python3 -c)."""
|
||||||
|
task_type = str(task.get("task_type") or "shell").lower()
|
||||||
|
command = str(task.get("content") or "")
|
||||||
|
if task_type == "python":
|
||||||
|
return f"python3 -c {shlex.quote(command)}"
|
||||||
|
if task_type == "shell":
|
||||||
|
return command
|
||||||
|
raise ValueError(f"Unknown task type: {task_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_saved_task(
|
||||||
|
store: SettingsStore,
|
||||||
|
task: dict[str, Any],
|
||||||
|
service: "ServiceRecord",
|
||||||
|
*,
|
||||||
|
timeout: int | None = None,
|
||||||
|
) -> TaskRunResult:
|
||||||
|
"""Run a saved task on an ssh_tasks service instance and log the run.
|
||||||
|
|
||||||
|
The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run
|
||||||
|
is recorded in ``service_task_runs`` regardless of outcome (success, failure,
|
||||||
|
error). Raises ``ValueError`` for an unsupported task type or an incomplete
|
||||||
|
service config (propagated from ``build_ssh_client`` / ``_render_command``).
|
||||||
|
"""
|
||||||
|
timeout = int(timeout if timeout is not None else service.config.get("timeout_seconds") or 30)
|
||||||
|
client = build_ssh_client(store, service)
|
||||||
|
command = _render_command(task)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = client.run(command, timeout=timeout)
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
_record(store, task, service, "error", duration_ms=duration_ms, error=str(exc)[:1000])
|
||||||
|
logger.exception("saved task run failed task_id=%s", task.get("id"))
|
||||||
|
return TaskRunResult(
|
||||||
|
exit_status=1,
|
||||||
|
stdout="",
|
||||||
|
stderr=str(exc),
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
status="error",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
stdout = result.stdout or ""
|
||||||
|
stderr = result.stderr or ""
|
||||||
|
status = "success" if result.exit_status == 0 else "failure"
|
||||||
|
_record(
|
||||||
|
store,
|
||||||
|
task,
|
||||||
|
service,
|
||||||
|
status,
|
||||||
|
exit_status=result.exit_status,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
stdout_tail=stdout,
|
||||||
|
stderr_tail=stderr,
|
||||||
|
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
|
||||||
|
)
|
||||||
|
return TaskRunResult(
|
||||||
|
exit_status=result.exit_status,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
store: SettingsStore,
|
||||||
|
task: dict[str, Any],
|
||||||
|
service: "ServiceRecord",
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
exit_status: int | None = None,
|
||||||
|
duration_ms: int = 0,
|
||||||
|
stdout_tail: str = "",
|
||||||
|
stderr_tail: str = "",
|
||||||
|
error: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Append a service_task_runs row for a saved-task run."""
|
||||||
|
store.record_service_task_run(
|
||||||
|
{
|
||||||
|
"task_id": str(task.get("id") or ""),
|
||||||
|
"service_id": service.id,
|
||||||
|
"status": status,
|
||||||
|
"exit_status": exit_status,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"stdout_tail": stdout_tail,
|
||||||
|
"stderr_tail": stderr_tail,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# backend/src/media_library_viewer_api/widgets (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/widgets
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data.
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- builtin.py
|
||||||
|
- sources.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change widgets behavior
|
||||||
|
read: __init__.py, builtin.py, sources.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# backend/src/media_library_viewer_api/widgets
|
||||||
|
dir: backend/src/media_library_viewer_api/widgets
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data.
|
||||||
|
## files
|
||||||
|
- __init__.py | Marks the directory as a Python package for the widget subsystem.
|
||||||
|
- builtin.py | Defines built-in widget kinds that don't require external services, providing their configurations, schemas, and validation. | exp: class:StaticConfig, func:get_builtin_widget_kind(kind: str) → WidgetKind | None, call:BUILTIN_WIDGET_KINDS.get, func:is_builtin_kind(kind: str) → bool, func:builtin_widget_kind_models() → dict[str, type], call:Field, func:validate_builtin_config(kind: str, config: dict[str, Any]) → dict[str, Any], call:builtin_widget_kind_models, call:models.get, call:dict, call:model_cls.model_validate(config or {}).model_dump | dep: typing, media_library_viewer_api.integrations.base, pydantic
|
||||||
|
- sources.py | Defines widget source adapters that fetch and transform data from various external services (Grafana, Prometheus, Jellyfin, etc.) into dashboard widget payloads. | exp: class:ServiceRecord, class:WidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], class:BackupsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:build_backup_dashboard_summary, call:summary.model_dump, call:logger.exception, class:StaticWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:config.get, class:GrafanaWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:config.get, call:logger.exception, class:PrometheusWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:logger.exception, class:AlertmanagerWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:service.secrets.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:isinstance, call:summarize_alerts, call:logger.exception, class:JellyfinWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:asyncio.wait_for, call:asyncio.to_thread, call:_map_sessions_to_activity_rows, call:logger.exception, class:SshTaskWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:config.get, call:store.get_task, call:task.get, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:_record_timeout, call:logger.exception, func:build_service_record(store: SettingsStore, service_row: dict[str, Any]) → ServiceRecord, call:ServiceRecord, call:service_row.get, call:decrypt_secrets, call:bool, func:_record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) → None, call:get_settings_store, call:store.record_service_task_run, call:str, call:config.get, call:logger.exception, func:get_service_adapter(service_type: str) → WidgetSource | None, call:SERVICE_ADAPTERS.get, func:get_builtin_adapter(kind: str) → WidgetSource | None, call:BUILTIN_ADAPTERS.get | dep: asyncio, logging, dataclasses, typing, requests, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.domain.dashboard, media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.services.secrets
|
||||||
|
## arch
|
||||||
|
Adapter pattern with modular source integrations that normalize heterogeneous external API responses into a unified widget payload format, complemented by schema-validated built-in widget configurations.
|
||||||
|
## tags
|
||||||
|
widget, source, fetch, call:logger.exception, builtin, call:config.get, record, call:str
|
||||||
|
## symbols
|
||||||
|
- StaticConfig
|
||||||
|
- ServiceRecord
|
||||||
|
- WidgetSource
|
||||||
|
- BackupsWidgetSource
|
||||||
|
- StaticWidgetSource
|
||||||
|
- GrafanaWidgetSource
|
||||||
|
- PrometheusWidgetSource
|
||||||
|
- AlertmanagerWidgetSource
|
||||||
|
## workflows
|
||||||
|
- change widgets behavior
|
||||||
|
read: __init__.py, builtin.py, sources.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Built-in, service-less widget kinds.
|
||||||
|
|
||||||
|
These widgets do not talk to an external service and therefore have no
|
||||||
|
``service_id``. They are kept out of the service registry (which models
|
||||||
|
configurable external services) and live here as a small closed set.
|
||||||
|
|
||||||
|
Currently: ``backups`` (reads the internal backup tables) and ``static``
|
||||||
|
(plain text/markdown).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import WidgetKind
|
||||||
|
|
||||||
|
BUILTIN_WIDGET_KINDS: dict[str, WidgetKind] = {
|
||||||
|
"backups": WidgetKind(
|
||||||
|
kind="backups",
|
||||||
|
name="Backups",
|
||||||
|
description="Backup job summary and active alerts.",
|
||||||
|
config_schema={"type": "object", "properties": {}, "required": []},
|
||||||
|
default_config={},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
|
"static": WidgetKind(
|
||||||
|
kind="static",
|
||||||
|
name="Static text",
|
||||||
|
description="Plain text or markdown note.",
|
||||||
|
config_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string", "description": "Text or markdown content"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
default_config={"text": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_builtin_widget_kind(kind: str) -> WidgetKind | None:
|
||||||
|
return BUILTIN_WIDGET_KINDS.get(kind)
|
||||||
|
|
||||||
|
|
||||||
|
def is_builtin_kind(kind: str) -> bool:
|
||||||
|
return kind in BUILTIN_WIDGET_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def builtin_widget_kind_models() -> dict[str, type]:
|
||||||
|
"""Pydantic widget-config models for built-in kinds (validated manually).
|
||||||
|
|
||||||
|
Backups has no user fields; static validates ``text``.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class StaticConfig(BaseModel):
|
||||||
|
text: str = Field(default="")
|
||||||
|
|
||||||
|
return {"static": StaticConfig}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_builtin_config(kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate (lightly) a built-in widget config and return the cleaned dict."""
|
||||||
|
models = builtin_widget_kind_models()
|
||||||
|
model_cls = models.get(kind)
|
||||||
|
if model_cls is None:
|
||||||
|
return dict(config or {})
|
||||||
|
return model_cls.model_validate(config or {}).model_dump(exclude_none=True)
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"""Closed, compile-time widget registry.
|
|
||||||
|
|
||||||
New widget types and source adapters require a code change in Phase 1.
|
|
||||||
There is no runtime plugin loading.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from media_library_viewer_api.models.widgets import WidgetTypeInfo
|
|
||||||
|
|
||||||
WIDGET_REGISTRY: dict[str, dict[str, Any]] = {
|
|
||||||
"jellyfin": {
|
|
||||||
"addon_id": "core",
|
|
||||||
"name": "Jellyfin activity",
|
|
||||||
"description": "Live sessions and idle users from a Jellyfin server.",
|
|
||||||
"source_type": "jellyfin",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"machine_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Jellyfin machine id (empty = default)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["machine_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"backups": {
|
|
||||||
"addon_id": "backups",
|
|
||||||
"name": "Backups",
|
|
||||||
"description": "Backup job summary and active alerts.",
|
|
||||||
"source_type": "backups",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"grafana-link": {
|
|
||||||
"addon_id": "grafana",
|
|
||||||
"name": "Grafana link",
|
|
||||||
"description": "Deep-link to a Grafana dashboard or panel.",
|
|
||||||
"source_type": "grafana",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"dashboard_uid": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Grafana dashboard UID",
|
|
||||||
},
|
|
||||||
"panel_id": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Optional panel id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["dashboard_uid"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"prometheus-metric": {
|
|
||||||
"addon_id": "prometheus",
|
|
||||||
"name": "Prometheus metric",
|
|
||||||
"description": "Instant query result rendered as a metric.",
|
|
||||||
"source_type": "prometheus",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"promql": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "PromQL instant query",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["promql"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"ssh-task": {
|
|
||||||
"addon_id": "ssh-tasks",
|
|
||||||
"name": "SSH task output",
|
|
||||||
"description": "Output of a saved task run on a machine.",
|
|
||||||
"source_type": "ssh_task",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Saved task id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["task_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"static": {
|
|
||||||
"addon_id": "core",
|
|
||||||
"name": "Static text",
|
|
||||||
"description": "Plain text or markdown note.",
|
|
||||||
"source_type": "static",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"text": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Text or markdown content",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["text"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_source_types() -> list[str]:
|
|
||||||
"""Return all registered source type names."""
|
|
||||||
return sorted({info["source_type"] for info in WIDGET_REGISTRY.values()})
|
|
||||||
|
|
||||||
|
|
||||||
def list_widget_types() -> list[WidgetTypeInfo]:
|
|
||||||
"""Return metadata for all registered widget types."""
|
|
||||||
return [
|
|
||||||
WidgetTypeInfo(
|
|
||||||
addon_id=info["addon_id"],
|
|
||||||
widget_type=widget_type,
|
|
||||||
name=info["name"],
|
|
||||||
description=info["description"],
|
|
||||||
source_type=info["source_type"],
|
|
||||||
config_schema=info["config_schema"],
|
|
||||||
)
|
|
||||||
for widget_type, info in WIDGET_REGISTRY.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_widget_info(widget_type: str) -> WidgetTypeInfo | None:
|
|
||||||
"""Return metadata for a single widget type, or None if unknown."""
|
|
||||||
info = WIDGET_REGISTRY.get(widget_type)
|
|
||||||
if not info:
|
|
||||||
return None
|
|
||||||
return WidgetTypeInfo(
|
|
||||||
addon_id=info["addon_id"],
|
|
||||||
widget_type=widget_type,
|
|
||||||
name=info["name"],
|
|
||||||
description=info["description"],
|
|
||||||
source_type=info["source_type"],
|
|
||||||
config_schema=info["config_schema"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_type(value: Any, expected: str) -> bool:
|
|
||||||
if expected == "string":
|
|
||||||
return isinstance(value, str)
|
|
||||||
if expected == "integer":
|
|
||||||
return isinstance(value, int) and not isinstance(value, bool)
|
|
||||||
if expected == "boolean":
|
|
||||||
return isinstance(value, bool)
|
|
||||||
if expected == "number":
|
|
||||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
||||||
if expected == "object":
|
|
||||||
return isinstance(value, dict)
|
|
||||||
if expected == "array":
|
|
||||||
return isinstance(value, list)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def validate_config(widget_type: str, config: dict[str, Any]) -> None:
|
|
||||||
"""Validate a widget config against its registered JSON schema.
|
|
||||||
|
|
||||||
Raises ValueError with a descriptive message if validation fails.
|
|
||||||
Phase 1 supports only required-field and primitive-type checks.
|
|
||||||
"""
|
|
||||||
info = WIDGET_REGISTRY.get(widget_type)
|
|
||||||
if not info:
|
|
||||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
|
||||||
|
|
||||||
schema = info["config_schema"]
|
|
||||||
required = schema.get("required", [])
|
|
||||||
properties = schema.get("properties", {})
|
|
||||||
|
|
||||||
for key in required:
|
|
||||||
if key not in config:
|
|
||||||
raise ValueError(f"Missing required config field: {key}")
|
|
||||||
|
|
||||||
for key, value in config.items():
|
|
||||||
prop = properties.get(key)
|
|
||||||
if not prop:
|
|
||||||
# Unknown keys are allowed in Phase 1 unless they look like secrets
|
|
||||||
# (handled by the model validator). Skip type checks for unknowns.
|
|
||||||
continue
|
|
||||||
expected_type = prop.get("type")
|
|
||||||
if expected_type and not _validate_type(value, expected_type):
|
|
||||||
raise ValueError(f"Config field '{key}' must be of type {expected_type}")
|
|
||||||
@@ -1,104 +1,113 @@
|
|||||||
"""Widget source adapters.
|
"""Widget source adapters.
|
||||||
|
|
||||||
Each adapter implements a uniform async interface and translates widget
|
Adapters translate a widget instance into dashboard data. Service-bound widgets
|
||||||
configuration into data for the dashboard. Adapters reuse existing clients,
|
are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the
|
||||||
machine registries, and environment settings; they never accept arbitrary
|
built-in widgets (backups, static) take ``service=None``.
|
||||||
commands or store credentials.
|
|
||||||
|
Adapters never accept arbitrary commands and never store credentials — secrets
|
||||||
|
are decrypted in memory only for the duration of a fetch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
from media_library_viewer_api.dependencies import get_jellyfin_client
|
|
||||||
from media_library_viewer_api.domain.dashboard import (
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
_map_sessions_to_activity_rows,
|
_map_sessions_to_activity_rows,
|
||||||
build_backup_dashboard_summary,
|
build_backup_dashboard_summary,
|
||||||
)
|
)
|
||||||
from media_library_viewer_api.routers.tasks import _client_for_machine, _resolve_machine_for_task
|
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||||
from media_library_viewer_api.services.settings_store import get_settings_store
|
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||||
|
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _request_with_machine_id(machine_id: str | None = None) -> Request:
|
@dataclass
|
||||||
"""Build a minimal Starlette Request carrying a machine_id query param."""
|
class ServiceRecord:
|
||||||
query = f"machine_id={machine_id}".encode() if machine_id else b""
|
"""Runtime view of a service instance with decrypted secrets."""
|
||||||
return Request({"type": "http", "query_string": query})
|
|
||||||
|
id: str
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
secrets: dict[str, str] = field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def build_service_record(store: SettingsStore, service_row: dict[str, Any]) -> ServiceRecord:
|
||||||
|
"""Build a :class:`ServiceRecord`, decrypting secrets in memory."""
|
||||||
|
from media_library_viewer_api.services.secrets import decrypt_secrets
|
||||||
|
|
||||||
|
return ServiceRecord(
|
||||||
|
id=service_row["id"],
|
||||||
|
service_type=service_row["service_type"],
|
||||||
|
name=service_row["name"],
|
||||||
|
config=service_row.get("config") or {},
|
||||||
|
secrets=decrypt_secrets(service_row.get("secrets") or {}),
|
||||||
|
enabled=bool(service_row.get("enabled", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WidgetSource(Protocol):
|
class WidgetSource(Protocol):
|
||||||
"""Protocol for widget source adapters."""
|
"""Protocol for widget source adapters."""
|
||||||
|
|
||||||
source_type: str
|
async def fetch(
|
||||||
|
self,
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
|
service: ServiceRecord | None,
|
||||||
|
widget_kind: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
class JellyfinWidgetSource:
|
# ---------------------------------------------------------------------------
|
||||||
"""Fetch Jellyfin sessions and map them to activity rows."""
|
# Built-in (service-less) adapters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
source_type = "jellyfin"
|
|
||||||
timeout = 10
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
request = _request_with_machine_id(config.get("machine_id") or None)
|
|
||||||
client = await asyncio.wait_for(
|
|
||||||
asyncio.to_thread(get_jellyfin_client, request),
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
sessions = await asyncio.wait_for(
|
|
||||||
asyncio.to_thread(client.sessions),
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
rows = _map_sessions_to_activity_rows(sessions)
|
|
||||||
return {"sessions": rows}
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return {"error": "Widget data fetch timed out"}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("jellyfin adapter failed")
|
|
||||||
return {"error": f"Jellyfin data fetch failed: {exc}"}
|
|
||||||
|
|
||||||
|
|
||||||
class BackupsWidgetSource:
|
class BackupsWidgetSource:
|
||||||
"""Compute the backup dashboard summary."""
|
"""Compute the backup dashboard summary from internal tables."""
|
||||||
|
|
||||||
source_type = "backups"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 10
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
summary = build_backup_dashboard_summary(store)
|
summary = build_backup_dashboard_summary(store)
|
||||||
return summary.model_dump()
|
return summary.model_dump()
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return {"error": "Widget data fetch timed out"}
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("backups adapter failed")
|
logger.exception("backups adapter failed")
|
||||||
return {"error": f"Backup summary failed: {exc}"}
|
return {"error": f"Backup summary failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class StaticWidgetSource:
|
||||||
|
"""Return static text/markdown unchanged."""
|
||||||
|
|
||||||
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"text": config.get("text", "")}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service-bound adapters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class GrafanaWidgetSource:
|
class GrafanaWidgetSource:
|
||||||
"""Build a Grafana deep-link (no embedding)."""
|
"""Build a Grafana deep-link (no embedding)."""
|
||||||
|
|
||||||
source_type = "grafana"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 5
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
if service is None:
|
||||||
|
return {"error": "Grafana widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
dashboard_uid = config.get("dashboard_uid")
|
dashboard_uid = config.get("dashboard_uid")
|
||||||
if not dashboard_uid:
|
if not dashboard_uid:
|
||||||
return {"error": "dashboard_uid is required"}
|
return {"error": "dashboard_uid is required"}
|
||||||
url = f"{settings.grafana_url.rstrip('/')}/d/{dashboard_uid}"
|
url = f"{base_url}/d/{dashboard_uid}"
|
||||||
panel_id = config.get("panel_id")
|
panel_id = config.get("panel_id")
|
||||||
if panel_id is not None:
|
if panel_id is not None:
|
||||||
url = f"{url}?viewPanel={panel_id}"
|
url = f"{url}?viewPanel={panel_id}"
|
||||||
@@ -109,26 +118,26 @@ class GrafanaWidgetSource:
|
|||||||
|
|
||||||
|
|
||||||
class PrometheusWidgetSource:
|
class PrometheusWidgetSource:
|
||||||
"""Run a PromQL instant query against Prometheus."""
|
"""Run a PromQL instant query against a Prometheus service."""
|
||||||
|
|
||||||
source_type = "prometheus"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 10
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
if service is None:
|
||||||
|
return {"error": "Prometheus widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||||
promql = config.get("promql")
|
promql = config.get("promql")
|
||||||
if not promql:
|
if not promql:
|
||||||
return {"error": "promql is required"}
|
return {"error": "promql is required"}
|
||||||
url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query"
|
url = f"{base_url}/api/v1/query"
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
asyncio.to_thread(
|
asyncio.to_thread(
|
||||||
requests.get,
|
requests.get,
|
||||||
url,
|
url,
|
||||||
params={"query": promql},
|
params={"query": promql},
|
||||||
timeout=self.timeout,
|
timeout=timeout,
|
||||||
),
|
),
|
||||||
timeout=self.timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
@@ -143,16 +152,81 @@ class PrometheusWidgetSource:
|
|||||||
return {"error": f"Prometheus query failed: {exc}"}
|
return {"error": f"Prometheus query failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
class SshTaskWidgetSource:
|
class AlertmanagerWidgetSource:
|
||||||
"""Run a saved task from the registry and return its output."""
|
"""Fetch firing alerts from an Alertmanager service and summarize them."""
|
||||||
|
|
||||||
source_type = "ssh_task"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 30
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
|
if service is None:
|
||||||
|
return {"error": "Alertmanager widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 5)
|
||||||
|
severity_filter = config.get("severity_filter") or None
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
api_key = str(service.secrets.get("api_key") or "")
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(
|
||||||
|
requests.get,
|
||||||
|
f"{base_url}/api/v1/alerts",
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
alerts = payload.get("data", []) if isinstance(payload, dict) else []
|
||||||
|
return summarize_alerts(alerts, severity_filter=severity_filter)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.exception("alertmanager adapter failed")
|
||||||
|
return {"error": f"Alertmanager query failed: {exc}"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("alertmanager adapter failed")
|
||||||
|
return {"error": f"Alertmanager query failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinWidgetSource:
|
||||||
|
"""Fetch Jellyfin sessions and map them to activity rows."""
|
||||||
|
|
||||||
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
timeout = 10
|
||||||
|
try:
|
||||||
|
if service is None:
|
||||||
|
return {"error": "Jellyfin widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "")
|
||||||
|
api_key = str(service.secrets.get("api_key") or "")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||||
|
client = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
sessions = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.sessions),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
rows = _map_sessions_to_activity_rows(sessions)
|
||||||
|
return {"sessions": rows}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("jellyfin adapter failed")
|
||||||
|
return {"error": f"Jellyfin data fetch failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class SshTaskWidgetSource:
|
||||||
|
"""Run a saved task on an SSH task runner instance and log the run."""
|
||||||
|
|
||||||
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
timeout = 30
|
||||||
|
try:
|
||||||
|
if service is None:
|
||||||
|
return {"error": "SSH task widget is missing its service"}
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
task_id = config.get("task_id")
|
task_id = config.get("task_id") or ""
|
||||||
if not task_id:
|
if not task_id:
|
||||||
return {"error": "task_id is required"}
|
return {"error": "task_id is required"}
|
||||||
task = store.get_task(task_id)
|
task = store.get_task(task_id)
|
||||||
@@ -161,53 +235,57 @@ class SshTaskWidgetSource:
|
|||||||
if not task.get("enabled", True):
|
if not task.get("enabled", True):
|
||||||
return {"error": "Task is disabled"}
|
return {"error": "Task is disabled"}
|
||||||
|
|
||||||
machine = _resolve_machine_for_task(store, task, None)
|
timeout = int(service.config.get("timeout_seconds") or 30)
|
||||||
if not machine:
|
|
||||||
return {"error": "No machine available for this task"}
|
|
||||||
|
|
||||||
client = _client_for_machine(store, machine)
|
|
||||||
task_type = str(task.get("task_type") or "shell").lower()
|
|
||||||
command = str(task.get("content") or "")
|
|
||||||
if task_type == "python":
|
|
||||||
command = f"python3 -c {shlex.quote(command)}"
|
|
||||||
elif task_type != "shell":
|
|
||||||
return {"error": f"Unknown task type: {task_type}"}
|
|
||||||
|
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
asyncio.to_thread(client.run, command, timeout=self.timeout),
|
asyncio.to_thread(run_saved_task, store, task, service),
|
||||||
timeout=self.timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
return {
|
return {"exit_status": result.exit_status, "stdout": result.stdout, "stderr": result.stderr}
|
||||||
"exit_status": result.exit_status,
|
|
||||||
"stdout": result.stdout or "",
|
|
||||||
"stderr": result.stderr or "",
|
|
||||||
}
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
_record_timeout(service, config, timeout)
|
||||||
return {"error": "Widget data fetch timed out"}
|
return {"error": "Widget data fetch timed out"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("ssh_task adapter failed")
|
logger.exception("ssh_task adapter failed")
|
||||||
return {"error": f"SSH task failed: {exc}"}
|
return {"error": f"SSH task failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
class StaticWidgetSource:
|
def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None:
|
||||||
"""Return static text/markdown unchanged."""
|
try:
|
||||||
|
store = get_settings_store()
|
||||||
source_type = "static"
|
store.record_service_task_run(
|
||||||
|
{
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
"task_id": str(config.get("task_id") or ""),
|
||||||
return {"text": config.get("text", "")}
|
"service_id": service.id if service else "",
|
||||||
|
"status": "timeout",
|
||||||
|
"duration_ms": timeout * 1000,
|
||||||
|
"error": f"Task timed out after {timeout}s",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - logging best-effort
|
||||||
|
logger.exception("failed to record ssh task timeout")
|
||||||
|
|
||||||
|
|
||||||
SOURCE_REGISTRY: dict[str, WidgetSource] = {
|
# ---------------------------------------------------------------------------
|
||||||
"jellyfin": JellyfinWidgetSource(),
|
# Registries
|
||||||
"backups": BackupsWidgetSource(),
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||||
"grafana": GrafanaWidgetSource(),
|
"grafana": GrafanaWidgetSource(),
|
||||||
"prometheus": PrometheusWidgetSource(),
|
"prometheus": PrometheusWidgetSource(),
|
||||||
"ssh_task": SshTaskWidgetSource(),
|
"alertmanager": AlertmanagerWidgetSource(),
|
||||||
|
"jellyfin": JellyfinWidgetSource(),
|
||||||
|
"ssh_tasks": SshTaskWidgetSource(),
|
||||||
|
}
|
||||||
|
|
||||||
|
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
|
||||||
|
"backups": BackupsWidgetSource(),
|
||||||
"static": StaticWidgetSource(),
|
"static": StaticWidgetSource(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_source_adapter(source_type: str) -> WidgetSource | None:
|
def get_service_adapter(service_type: str) -> WidgetSource | None:
|
||||||
"""Return the adapter for a source type, or None if unknown."""
|
return SERVICE_ADAPTERS.get(service_type)
|
||||||
return SOURCE_REGISTRY.get(source_type)
|
|
||||||
|
|
||||||
|
def get_builtin_adapter(kind: str) -> WidgetSource | None:
|
||||||
|
return BUILTIN_ADAPTERS.get(kind)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# backend/src/media_library_viewer_api/workers (index)
|
||||||
|
dir: backend/src/media_library_viewer_api/workers
|
||||||
|
|
||||||
|
## role
|
||||||
|
Background worker package providing subprocess-based media index building from Jellyfin libraries with progress tracking and cancellation support.
|
||||||
|
## parent
|
||||||
|
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- media_index_worker.py
|
||||||
|
## links
|
||||||
|
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
||||||
|
map: backend/src/media_library_viewer_api/workers/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change workers behavior
|
||||||
|
read: __init__.py, media_index_worker.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# backend/src/media_library_viewer_api/workers
|
||||||
|
dir: backend/src/media_library_viewer_api/workers
|
||||||
|
|
||||||
|
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
Background worker package providing subprocess-based media index building from Jellyfin libraries with progress tracking and cancellation support.
|
||||||
|
## files
|
||||||
|
- __init__.py | Marks the directory as a Python package for worker entrypoints used in background task processing.
|
||||||
|
- media_index_worker.py | Subprocess worker that builds a media index from Jellyfin libraries with progress tracking and cooperative cancellation support. | exp: func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_cancel_requested(index: MediaIndex) → bool, call:index.status, func:_start_state(index: MediaIndex, pid: int, library_count: int) → None, call:_set_build_metadata, func:_progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) → None, call:_set_build_metadata, call:state.get, func:run_build(final_index_path: str | Path, staging_index_path: str | Path) → int, call:get_settings, call:configure_logging, call:logger.info, call:describe_settings, call:get_jellyfin_client, call:get_user_id, call:client.libraries, call:MediaIndex, call:os.getpid, call:time.perf_counter, call:Path, call:staging_path.unlink, call:len, call:_start_state, call:build_media_index, call:_progress_callback, call:_cancel_requested, call:os.replace, call:completed_index.status, call:_set_build_metadata, call:logger.exception, call:str, call:staging_path.exists, func:main() → int, call:argparse.ArgumentParser, call:parser.add_argument, call:parser.parse_args, call:run_build | dep: argparse, logging, os, time, pathlib, typing, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.services.media_index
|
||||||
|
## arch
|
||||||
|
Subprocess worker pattern with cooperative cancellation, designed to be spawned and monitored by a parent process for long-running media indexing tasks.
|
||||||
|
## tags
|
||||||
|
call:, metadata, set, index, call:get, media, progress, worker
|
||||||
|
## symbols
|
||||||
|
- _set_build_metadata
|
||||||
|
- _cancel_requested
|
||||||
|
- _start_state
|
||||||
|
- _progress_callback
|
||||||
|
- run_build
|
||||||
|
- main
|
||||||
|
- call:state.items
|
||||||
|
- call:index.set_metadata
|
||||||
|
## workflows
|
||||||
|
- change workers behavior
|
||||||
|
read: __init__.py, media_index_worker.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# backend/tests (index)
|
||||||
|
dir: backend/tests
|
||||||
|
|
||||||
|
## role
|
||||||
|
Test suite providing unit and integration tests that validate API endpoints, configuration, external clients, utilities, and service logic across the backend.
|
||||||
|
## parent
|
||||||
|
index: backend/.pi-map.index.md
|
||||||
|
map: backend/.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- __init__.py
|
||||||
|
- test_api.py
|
||||||
|
- test_backups.py
|
||||||
|
- test_config.py
|
||||||
|
- test_domain_media.py
|
||||||
|
- test_jellyseerr_client.py
|
||||||
|
- test_jobs.py
|
||||||
|
- test_mailer.py
|
||||||
|
- test_media_index.py
|
||||||
|
- test_observability.py
|
||||||
|
- test_path_utils.py
|
||||||
|
- test_services.py
|
||||||
|
- test_ssh_client.py
|
||||||
|
- test_targets.py
|
||||||
|
- test_utils.py
|
||||||
|
- test_widgets.py
|
||||||
|
## links
|
||||||
|
index: backend/tests/.pi-map.index.md
|
||||||
|
map: backend/tests/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- change tests behavior
|
||||||
|
read: __init__.py, test_api.py, test_backups.py
|
||||||
|
- change tests CLI
|
||||||
|
read: test_jellyseerr_client.py, test_ssh_client.py
|
||||||
|
- change tests config
|
||||||
|
read: test_config.py
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
File diff suppressed because one or more lines are too long
+290
-67
@@ -24,6 +24,10 @@ from media_library_viewer_api.main import app
|
|||||||
from media_library_viewer_api.routers.media import get_media_index
|
from media_library_viewer_api.routers.media import get_media_index
|
||||||
from media_library_viewer_api.services.media_index import MediaIndex
|
from media_library_viewer_api.services.media_index import MediaIndex
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||||
|
|
||||||
|
# Short alias for the monitoring router module under test.
|
||||||
|
_MON = "media_library_viewer_api.routers.monitoring"
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
|
|
||||||
@@ -207,6 +211,22 @@ class TestDashboard:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data) == 2
|
assert len(data) == 2
|
||||||
|
|
||||||
|
def test_jellyfin_endpoints_return_503_when_not_configured(self, test_client):
|
||||||
|
# Remove the mocked Jellyfin dependency so the real one runs; with no
|
||||||
|
# Jellyfin service seeded, endpoints must degrade to 503, not 500.
|
||||||
|
app.dependency_overrides.pop(get_jellyfin_client, None)
|
||||||
|
app.dependency_overrides.pop(get_user_id, None)
|
||||||
|
try:
|
||||||
|
for path in ("/api/dashboard/counts", "/api/dashboard/activity"):
|
||||||
|
response = test_client.get(path)
|
||||||
|
assert response.status_code == 503, path
|
||||||
|
detail = response.json()["detail"]
|
||||||
|
assert "configured" in detail, path
|
||||||
|
finally:
|
||||||
|
# Restore the mocks for subsequent tests in this fixture session.
|
||||||
|
app.dependency_overrides[get_jellyfin_client] = lambda: MagicMock()
|
||||||
|
app.dependency_overrides[get_user_id] = lambda: "user123"
|
||||||
|
|
||||||
|
|
||||||
# --- Settings reset ---
|
# --- Settings reset ---
|
||||||
|
|
||||||
@@ -626,28 +646,98 @@ class TestMonitoring:
|
|||||||
assert data[0]["labels"]["job"] == "node-exporter-remote"
|
assert data[0]["labels"]["job"] == "node-exporter-remote"
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsMachines:
|
class TestResolveServiceRecord:
|
||||||
def test_post_machine_rewrites_prometheus_targets(self, test_client):
|
"""Unit tests for _resolve_service_record (service_id + first-enabled paths)."""
|
||||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
|
||||||
with patch("media_library_viewer_api.routers.settings._validate_saved_machine_ssh"):
|
|
||||||
response = test_client.post(
|
|
||||||
"/api/settings/machines",
|
|
||||||
json={
|
|
||||||
"name": "remote1",
|
|
||||||
"mode": "ssh",
|
|
||||||
"enabled": True,
|
|
||||||
"services": ["monitoring"],
|
|
||||||
"host": "10.0.0.5",
|
|
||||||
"username": "u",
|
|
||||||
"node_exporter_enabled": True,
|
|
||||||
"node_exporter_port": 9200,
|
|
||||||
"node_exporter_scrape_host": "1.2.3.4",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
write_targets.assert_called_once()
|
|
||||||
|
|
||||||
def test_delete_machine_rewrites_prometheus_targets(self, test_client):
|
def _store(self, rows):
|
||||||
|
store = MagicMock()
|
||||||
|
store.get_service = lambda sid: next((r for r in rows if r["id"] == sid), None)
|
||||||
|
|
||||||
|
def list_filtered(service_type=None):
|
||||||
|
return [r for r in rows if r["service_type"] == (service_type or r["service_type"])]
|
||||||
|
|
||||||
|
store.list_services = list_filtered
|
||||||
|
return store
|
||||||
|
|
||||||
|
def test_service_id_match_returns_record(self):
|
||||||
|
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||||
|
|
||||||
|
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
|
||||||
|
store = self._store([row])
|
||||||
|
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||||
|
result = _resolve_service_record(store, "alertmanager", "am1")
|
||||||
|
assert result == "RECORD"
|
||||||
|
mock_build.assert_called_once_with(store, row)
|
||||||
|
|
||||||
|
def test_service_id_type_mismatch_returns_none(self):
|
||||||
|
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||||
|
|
||||||
|
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
||||||
|
store = self._store([row])
|
||||||
|
assert _resolve_service_record(store, "alertmanager", "x1") is None
|
||||||
|
|
||||||
|
def test_service_id_disabled_returns_none(self):
|
||||||
|
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||||
|
|
||||||
|
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
|
||||||
|
store = self._store([row])
|
||||||
|
assert _resolve_service_record(store, "alertmanager", "am1") is None
|
||||||
|
|
||||||
|
def test_no_service_id_returns_first_enabled(self):
|
||||||
|
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": "am1",
|
||||||
|
"service_type": "alertmanager",
|
||||||
|
"name": "Disabled",
|
||||||
|
"enabled": False,
|
||||||
|
"config": {},
|
||||||
|
"secrets": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "am2",
|
||||||
|
"service_type": "alertmanager",
|
||||||
|
"name": "Active",
|
||||||
|
"enabled": True,
|
||||||
|
"config": {},
|
||||||
|
"secrets": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
store = self._store(rows)
|
||||||
|
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||||
|
result = _resolve_service_record(store, "alertmanager", None)
|
||||||
|
assert result == "RECORD"
|
||||||
|
mock_build.assert_called_once_with(store, rows[1])
|
||||||
|
|
||||||
|
def test_no_service_id_and_none_enabled_returns_none(self):
|
||||||
|
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||||
|
|
||||||
|
store = self._store([])
|
||||||
|
assert _resolve_service_record(store, "alertmanager", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsMachines:
|
||||||
|
def test_machine_appears_in_prometheus_targets(self, test_client):
|
||||||
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
|
store.upsert_machine(
|
||||||
|
{
|
||||||
|
"name": "remote1",
|
||||||
|
"mode": "ssh",
|
||||||
|
"enabled": True,
|
||||||
|
"services": ["monitoring"],
|
||||||
|
"host": "10.0.0.5",
|
||||||
|
"username": "u",
|
||||||
|
"node_exporter_enabled": True,
|
||||||
|
"node_exporter_port": 9200,
|
||||||
|
"node_exporter_scrape_host": "1.2.3.4",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
targets = test_client.get("/api/monitoring/prometheus-targets").json()
|
||||||
|
assert len(targets) == 1
|
||||||
|
assert targets[0]["targets"] == ["1.2.3.4:9200"]
|
||||||
|
|
||||||
|
def test_delete_machine_removed_from_prometheus_targets(self, test_client):
|
||||||
store = app.dependency_overrides[get_settings_store]()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
machine = store.upsert_machine(
|
machine = store.upsert_machine(
|
||||||
{
|
{
|
||||||
@@ -657,62 +747,130 @@ class TestSettingsMachines:
|
|||||||
"services": ["monitoring"],
|
"services": ["monitoring"],
|
||||||
"host": "10.0.0.5",
|
"host": "10.0.0.5",
|
||||||
"username": "u",
|
"username": "u",
|
||||||
|
"node_exporter_enabled": True,
|
||||||
|
"node_exporter_port": 9200,
|
||||||
|
"node_exporter_scrape_host": "1.2.3.4",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
||||||
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
write_targets.assert_called_once()
|
assert test_client.get("/api/monitoring/prometheus-targets").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def _am_service(name="Alertmanager", **config):
|
||||||
|
cfg = {"base_url": "http://alertmanager:9093", "timeout_seconds": 5}
|
||||||
|
cfg.update(config)
|
||||||
|
return ServiceRecord(id="am1", service_type="alertmanager", name=name, config=cfg)
|
||||||
|
|
||||||
|
|
||||||
class TestAlertmanager:
|
class TestAlertmanager:
|
||||||
def test_alerts_endpoint_when_alertmanager_unreachable(self, test_client):
|
def test_alerts_endpoint_when_not_configured(self, test_client):
|
||||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
# No alertmanager service seeded -> registry resolves None.
|
||||||
session = MagicMock()
|
response = test_client.get("/api/monitoring/alerts")
|
||||||
session.get.side_effect = Exception("connection refused")
|
assert response.status_code == 200
|
||||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["error"] == "alertmanager_not_configured"
|
||||||
|
|
||||||
|
def test_alerts_endpoint_when_unreachable(self, test_client):
|
||||||
|
service = _am_service()
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
|
||||||
|
):
|
||||||
response = test_client.get("/api/monitoring/alerts")
|
response = test_client.get("/api/monitoring/alerts")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["total"] == 0
|
assert data["total"] == 0
|
||||||
assert data["error"] == "alertmanager_unreachable"
|
assert data["error"] == "alertmanager_unreachable"
|
||||||
|
assert data["service_id"] == "am1"
|
||||||
|
assert data["name"] == "Alertmanager"
|
||||||
|
|
||||||
def test_alerts_endpoint_returns_summary(self, test_client):
|
def test_alerts_endpoint_returns_summary(self, test_client):
|
||||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
service = _am_service()
|
||||||
session = MagicMock()
|
resp = MagicMock()
|
||||||
session.get.return_value.json.return_value = {
|
resp.json.return_value = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
|
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
|
||||||
"annotations": {"summary": "Backup failed", "description": "details"},
|
"annotations": {"summary": "Backup failed", "description": "details"},
|
||||||
"startsAt": "2026-05-11T02:00:00Z",
|
"startsAt": "2026-05-11T02:00:00Z",
|
||||||
"status": "firing",
|
"status": "firing",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
session.get.return_value.raise_for_status = MagicMock()
|
resp.raise_for_status = MagicMock()
|
||||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", return_value=resp),
|
||||||
|
):
|
||||||
response = test_client.get("/api/monitoring/alerts")
|
response = test_client.get("/api/monitoring/alerts")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["total"] == 1
|
assert data["total"] == 1
|
||||||
assert data["by_severity"]["critical"] == 1
|
assert data["by_severity"]["critical"] == 1
|
||||||
assert data["alerts"][0]["name"] == "BackupJobFailed"
|
assert data["alerts"][0]["name"] == "BackupJobFailed"
|
||||||
|
assert data["service_id"] == "am1"
|
||||||
|
|
||||||
def test_alertmanager_status_endpoint_when_unreachable(self, test_client):
|
def test_alerts_endpoint_sends_bearer_token(self, test_client):
|
||||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
service = _am_service()
|
||||||
session = MagicMock()
|
service.secrets = {"api_key": "tok"}
|
||||||
session.get.side_effect = Exception("connection refused")
|
resp = MagicMock()
|
||||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
resp.json.return_value = {"status": "success", "data": []}
|
||||||
|
resp.raise_for_status = MagicMock()
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
|
||||||
|
):
|
||||||
|
test_client.get("/api/monitoring/alerts")
|
||||||
|
_, kwargs = mock_get.call_args
|
||||||
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||||
|
|
||||||
|
def test_alertmanager_status_when_not_configured(self, test_client):
|
||||||
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["up"] is False
|
||||||
|
assert data["error"] == "alertmanager_not_configured"
|
||||||
|
assert data["peers"] == []
|
||||||
|
|
||||||
|
def test_alertmanager_status_when_unreachable(self, test_client):
|
||||||
|
service = _am_service()
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
|
):
|
||||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["up"] is False
|
assert data["up"] is False
|
||||||
|
assert data["error"] == "alertmanager_unreachable"
|
||||||
|
assert data["name"] == "Alertmanager"
|
||||||
|
|
||||||
|
def test_alertmanager_status_returns_cluster_info(self, test_client):
|
||||||
|
service = _am_service()
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.json.return_value = {
|
||||||
|
"versionInfo": {"version": "0.27.0"},
|
||||||
|
"clusterStatus": {"createdAt": "2026-06-01T00:00:00Z"},
|
||||||
|
"cluster": {"peers": [{"name": "am-1"}, {"name": "am-2"}]},
|
||||||
|
}
|
||||||
|
resp.raise_for_status = MagicMock()
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", return_value=resp),
|
||||||
|
):
|
||||||
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["up"] is True
|
||||||
|
assert data["version"] == "0.27.0"
|
||||||
|
assert data["peers"] == ["am-1", "am-2"]
|
||||||
|
|
||||||
|
|
||||||
class TestAlertmanagerWebhook:
|
class TestAlertmanagerWebhook:
|
||||||
"""Tests for POST /api/monitoring/alertmanager-webhook."""
|
"""Tests for POST /api/monitoring/alertmanager-webhook (log-only receiver)."""
|
||||||
|
|
||||||
def test_webhook_receives_payload_and_logs(self, test_client, caplog):
|
def test_webhook_receives_payload_and_logs(self, test_client, caplog):
|
||||||
payload = {
|
payload = {
|
||||||
@@ -732,22 +890,87 @@ class TestAlertmanagerWebhook:
|
|||||||
assert response.json()["status"] == "received"
|
assert response.json()["status"] == "received"
|
||||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||||
|
|
||||||
def test_webhook_forwards_when_configured(self, test_client, caplog, monkeypatch):
|
|
||||||
payload = {"status": "resolved", "alerts": []}
|
|
||||||
forwarded = {"captured": False}
|
|
||||||
|
|
||||||
class FakeResponse:
|
class TestGrafanaStatus:
|
||||||
def raise_for_status(self):
|
def test_grafana_status_when_not_configured(self, test_client):
|
||||||
pass
|
response = test_client.get("/api/monitoring/grafana-status")
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
|
||||||
forwarded["captured"] = True
|
|
||||||
forwarded["url"] = url
|
|
||||||
forwarded["payload"] = json
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
monkeypatch.setattr("requests.Session.post", lambda _self, url, json, timeout: fake_post(url, json, timeout))
|
|
||||||
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
|
|
||||||
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert forwarded["captured"] is False
|
data = response.json()
|
||||||
|
assert data["up"] is False
|
||||||
|
assert data["error"] == "no_service_configured"
|
||||||
|
|
||||||
|
def test_grafana_status_when_unreachable(self, test_client):
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
|
):
|
||||||
|
response = test_client.get("/api/monitoring/grafana-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["up"] is False
|
||||||
|
assert data["error"] == "grafana_unreachable"
|
||||||
|
assert data["name"] == "Grafana"
|
||||||
|
|
||||||
|
def test_grafana_status_returns_version(self, test_client):
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||||
|
)
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||||
|
resp.raise_for_status = MagicMock()
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", return_value=resp),
|
||||||
|
):
|
||||||
|
response = test_client.get("/api/monitoring/grafana-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["up"] is True
|
||||||
|
assert data["version"] == "11.3.1"
|
||||||
|
assert data["service_id"] == "g1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrometheusStatus:
|
||||||
|
def test_prometheus_status_when_not_configured(self, test_client):
|
||||||
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["up"] is False
|
||||||
|
assert data["error"] == "no_service_configured"
|
||||||
|
|
||||||
|
def test_prometheus_status_when_unreachable(self, test_client):
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
|
):
|
||||||
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["up"] is False
|
||||||
|
assert data["error"] == "prometheus_unreachable"
|
||||||
|
|
||||||
|
def test_prometheus_status_returns_version(self, test_client):
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
||||||
|
)
|
||||||
|
health = MagicMock()
|
||||||
|
health.raise_for_status = MagicMock()
|
||||||
|
build_info = MagicMock()
|
||||||
|
build_info.raise_for_status = MagicMock()
|
||||||
|
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
||||||
|
with (
|
||||||
|
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||||
|
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
||||||
|
):
|
||||||
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["up"] is True
|
||||||
|
assert data["version"] == "2.55.1"
|
||||||
|
assert data["service_id"] == "p1"
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"""Tests for the service registry: definitions, encryption, CRUD, cascade delete."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.integrations.registry import (
|
||||||
|
SERVICE_DEFINITIONS,
|
||||||
|
get_service_definition,
|
||||||
|
get_widget_kind,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.main import app
|
||||||
|
from media_library_viewer_api.services.secrets import (
|
||||||
|
EncryptionKeyError,
|
||||||
|
decrypt_secrets,
|
||||||
|
decrypt_value,
|
||||||
|
encrypt_secrets,
|
||||||
|
encrypt_value,
|
||||||
|
get_encryption_key,
|
||||||
|
reset_encryption_key_cache,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
TEST_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _encryption_key(monkeypatch):
|
||||||
|
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
yield
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
"""FastAPI test client with a fresh settings store and auth disabled."""
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.ensure_defaults()
|
||||||
|
app.dependency_overrides[get_settings_store] = lambda: store
|
||||||
|
auth_settings = SimpleNamespace(auth_enabled=False)
|
||||||
|
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
|
||||||
|
yield TestClient(app)
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_contains_seven_service_types():
|
||||||
|
assert set(SERVICE_DEFINITIONS) == {
|
||||||
|
"grafana",
|
||||||
|
"prometheus",
|
||||||
|
"alertmanager",
|
||||||
|
"jellyfin",
|
||||||
|
"jellyseerr",
|
||||||
|
"nextcloud",
|
||||||
|
"ssh_tasks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_definitions_declare_widget_kinds():
|
||||||
|
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||||
|
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||||
|
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||||
|
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||||
|
assert get_service_definition("nextcloud").widget_kinds == []
|
||||||
|
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_kind_lookup():
|
||||||
|
assert get_widget_kind("grafana", "link") is not None
|
||||||
|
assert get_widget_kind("grafana", "missing") is None
|
||||||
|
assert get_widget_kind("unknown", "link") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_config_schema_is_json_schema():
|
||||||
|
schema = get_service_definition("grafana").config_schema
|
||||||
|
assert schema["type"] == "object"
|
||||||
|
assert "base_url" in schema["properties"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Encryption
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_round_trip():
|
||||||
|
cipher = encrypt_value("hunter2")
|
||||||
|
assert cipher != "hunter2"
|
||||||
|
assert decrypt_value(cipher) == "hunter2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_secrets_dict():
|
||||||
|
blob = encrypt_secrets({"api_key": "abc", "token": "xyz"})
|
||||||
|
assert decrypt_secrets(blob) == {"api_key": "abc", "token": "xyz"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_encryption_key_raises(monkeypatch):
|
||||||
|
monkeypatch.delenv("MANAGE_ENCRYPTION_KEY", raising=False)
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
with pytest.raises(EncryptionKeyError):
|
||||||
|
get_encryption_key()
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_with_wrong_key_raises(monkeypatch):
|
||||||
|
blob = encrypt_secrets({"api_key": "abc"})
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
with pytest.raises(EncryptionKeyError):
|
||||||
|
decrypt_secrets(blob)
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_ciphertext_raises():
|
||||||
|
with pytest.raises(EncryptionKeyError):
|
||||||
|
decrypt_value("not-a-real-token")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service type metadata endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_service_types(client):
|
||||||
|
response = client.get("/api/services/types")
|
||||||
|
assert response.status_code == 200
|
||||||
|
types = {item["service_type"] for item in response.json()}
|
||||||
|
assert types == {
|
||||||
|
"alertmanager",
|
||||||
|
"grafana",
|
||||||
|
"jellyfin",
|
||||||
|
"jellyseerr",
|
||||||
|
"nextcloud",
|
||||||
|
"prometheus",
|
||||||
|
"ssh_tasks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||||
|
response = client.get("/api/services/types")
|
||||||
|
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
||||||
|
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||||
|
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _grafana_payload(**overrides):
|
||||||
|
payload = {
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "Production Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"api_key": "secret-token"},
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_list_service(client):
|
||||||
|
response = client.post("/api/services/instances", json=_grafana_payload())
|
||||||
|
assert response.status_code == 201
|
||||||
|
created = response.json()
|
||||||
|
assert created["service_type"] == "grafana"
|
||||||
|
assert created["config"]["base_url"] == "https://grafana.example.com"
|
||||||
|
# Plaintext secrets are never returned.
|
||||||
|
assert "secrets" not in created
|
||||||
|
assert created["secrets_set"] == {"api_key": True}
|
||||||
|
|
||||||
|
response = client.get("/api/services/instances")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_instances_filters_by_type(client):
|
||||||
|
client.post("/api/services/instances", json=_grafana_payload())
|
||||||
|
client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={
|
||||||
|
"service_type": "prometheus",
|
||||||
|
"name": "Prom",
|
||||||
|
"config": {"base_url": "http://prometheus:9090"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = client.get("/api/services/instances?service_type=grafana")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 1
|
||||||
|
assert response.json()[0]["service_type"] == "grafana"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_service_preserves_unsent_secrets(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
# Update without sending secrets; the existing key should remain set.
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/services/instances/{created['id']}",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "Renamed Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert updated["name"] == "Renamed Grafana"
|
||||||
|
assert updated["secrets_set"] == {"api_key": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_service_can_clear_secret(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/services/instances/{created['id']}",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "Production Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"api_key": ""},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert updated["secrets_set"] == {"api_key": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_service_type_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "bogus", "name": "x", "config": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_config_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
# Force a real validation error via bad type.
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
|
||||||
|
)
|
||||||
|
def test_service_base_url_requires_http_schema(bad_url):
|
||||||
|
"""Every service base_url must include an http:// or https:// schema."""
|
||||||
|
model = get_service_definition("grafana").config_model
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"]
|
||||||
|
)
|
||||||
|
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||||
|
model = get_service_definition(service_type).config_model
|
||||||
|
instance = model.model_validate({"base_url": "https://example.com"})
|
||||||
|
assert instance.base_url == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_secret_field_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "x",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"password": "leak"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_key_in_config_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "x",
|
||||||
|
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nonexistent_returns_404(client):
|
||||||
|
response = client.put(
|
||||||
|
"/api/services/instances/missing",
|
||||||
|
json=_grafana_payload(id="missing"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_id_mismatch_returns_400(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
response = client.put(
|
||||||
|
f"/api/services/instances/{created['id']}",
|
||||||
|
json=_grafana_payload(id="other-id"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_service(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
response = client.delete(f"/api/services/instances/{created['id']}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert client.get("/api/services/instances").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_nonexistent_returns_404(client):
|
||||||
|
assert client.delete("/api/services/instances/missing").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cascade delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||||
|
"""Once widgets carry service_id (Slice 2), deleting a service removes them.
|
||||||
|
|
||||||
|
This test seeds a widget row directly with the column present to prove the
|
||||||
|
cascade path; the column is added defensively here so the test is meaningful
|
||||||
|
even before Slice 2 lands.
|
||||||
|
"""
|
||||||
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
|
service = store.upsert_service(
|
||||||
|
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the service_id column exists and seed a referencing widget.
|
||||||
|
with store.connect() as conn:
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||||
|
if "service_id" not in cols:
|
||||||
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO dashboard_widgets (id, addon_id, widget_type, title, config_json,
|
||||||
|
enabled, sort_order, created_at, updated_at, service_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
store.delete_service(service["id"])
|
||||||
|
assert store.get_service(service["id"]) is None
|
||||||
|
with store.connect() as conn:
|
||||||
|
remaining = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
|
||||||
|
(service["id"],),
|
||||||
|
).fetchone()
|
||||||
|
assert int(remaining[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service task run history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_and_list_service_task_runs(client):
|
||||||
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
|
service = store.upsert_service(
|
||||||
|
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h"}, "enabled": True}
|
||||||
|
)
|
||||||
|
store.record_service_task_run(
|
||||||
|
{
|
||||||
|
"task_id": "t1",
|
||||||
|
"service_id": service["id"],
|
||||||
|
"status": "success",
|
||||||
|
"exit_status": 0,
|
||||||
|
"stdout_tail": "ok",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
runs = store.list_service_task_runs(service_id=service["id"])
|
||||||
|
assert len(runs) == 1
|
||||||
|
assert runs[0]["status"] == "success"
|
||||||
|
assert runs[0]["stdout_tail"] == "ok"
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
"""Tests for Prometheus file-based service discovery target generation."""
|
"""Tests for Prometheus Node Exporter target discovery."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import (
|
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||||
build_node_exporter_targets,
|
|
||||||
write_prometheus_targets,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -88,22 +85,3 @@ class TestBuildNodeExporterTargets:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert build_node_exporter_targets(store) == []
|
assert build_node_exporter_targets(store) == []
|
||||||
|
|
||||||
|
|
||||||
class TestWritePrometheusTargets:
|
|
||||||
def test_writes_valid_json(self, store: SettingsStore, tmp_path: Path):
|
|
||||||
store.upsert_machine(
|
|
||||||
{
|
|
||||||
"name": "remote1",
|
|
||||||
"mode": "ssh",
|
|
||||||
"host": "10.0.0.5",
|
|
||||||
"username": "u",
|
|
||||||
"node_exporter_enabled": True,
|
|
||||||
"node_exporter_port": 9200,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
file_path = write_prometheus_targets(store, tmp_path)
|
|
||||||
assert file_path.exists()
|
|
||||||
assert file_path.name == "node_exporter_targets.json"
|
|
||||||
targets = build_node_exporter_targets(store)
|
|
||||||
assert len(targets) == 1
|
|
||||||
|
|||||||
+325
-342
@@ -1,22 +1,33 @@
|
|||||||
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters."""
|
"""Tests for the dashboard widget system: service-bound + built-in widgets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.main import app
|
from media_library_viewer_api.main import app
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.widgets.sources import (
|
from media_library_viewer_api.widgets.sources import (
|
||||||
SOURCE_REGISTRY,
|
AlertmanagerWidgetSource,
|
||||||
|
BackupsWidgetSource,
|
||||||
GrafanaWidgetSource,
|
GrafanaWidgetSource,
|
||||||
SshTaskWidgetSource,
|
ServiceRecord,
|
||||||
StaticWidgetSource,
|
StaticWidgetSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TEST_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _encryption_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(tmp_path):
|
def client(tmp_path):
|
||||||
@@ -30,446 +41,418 @@ def client(tmp_path):
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_widget_sources(client):
|
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
|
||||||
response = client.get("/api/widgets/sources")
|
config = {"base_url": "https://grafana.example.com"}
|
||||||
|
config.update(config_overrides)
|
||||||
|
return client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in kinds + built-in widget CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_builtin_kinds(client):
|
||||||
|
response = client.get("/api/widgets/builtin")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert set(response.json()) == {
|
kinds = {item["kind"] for item in response.json()}
|
||||||
"jellyfin",
|
assert kinds == {"backups", "static"}
|
||||||
"backups",
|
|
||||||
"grafana",
|
|
||||||
"prometheus",
|
|
||||||
"ssh_task",
|
|
||||||
"static",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_widget_types(client):
|
def test_create_and_read_static_widget(client):
|
||||||
response = client.get("/api/widgets/types")
|
|
||||||
assert response.status_code == 200
|
|
||||||
types = {item["widget_type"] for item in response.json()}
|
|
||||||
assert types == {
|
|
||||||
"jellyfin",
|
|
||||||
"backups",
|
|
||||||
"grafana-link",
|
|
||||||
"prometheus-metric",
|
|
||||||
"ssh-task",
|
|
||||||
"static",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_and_read_widget(client):
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"widget_kind": "static",
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Note",
|
"title": "Note",
|
||||||
"config": {"text": "hello"},
|
"config": {"text": "hello"},
|
||||||
"enabled": True,
|
|
||||||
"sort_order": 5,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
widget = response.json()
|
created = response.json()
|
||||||
assert widget["title"] == "Note"
|
assert created["widget_kind"] == "static"
|
||||||
assert widget["config"] == {"text": "hello"}
|
assert created["service_id"] is None
|
||||||
assert widget["enabled"] is True
|
assert created["config"]["text"] == "hello"
|
||||||
assert widget["sort_order"] == 5
|
|
||||||
widget_id = widget["id"]
|
|
||||||
|
|
||||||
response = client.get("/api/widgets/instances")
|
listed = client.get("/api/widgets/instances").json()
|
||||||
assert response.status_code == 200
|
assert len(listed) == 1
|
||||||
assert any(w["id"] == widget_id for w in response.json())
|
assert listed[0]["id"] == created["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_update_widget(client):
|
def test_create_backups_widget(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "backups", "title": "Backups", "config": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_builtin_kind_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "bogus", "title": "x", "config": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_key_in_config_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service-bound widget CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_service_bound_widget(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "static",
|
"widget_kind": "link",
|
||||||
"title": "Note",
|
"title": "Dashboard",
|
||||||
"config": {"text": "hello"},
|
"config": {"dashboard_uid": "overview"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
widget_id = response.json()["id"]
|
assert response.status_code == 201
|
||||||
|
created = response.json()
|
||||||
response = client.put(
|
assert created["service_id"] == service["id"]
|
||||||
f"/api/widgets/instances/{widget_id}",
|
assert created["widget_kind"] == "link"
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Updated",
|
|
||||||
"config": {"text": "world"},
|
|
||||||
"enabled": False,
|
|
||||||
"sort_order": 10,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["title"] == "Updated"
|
|
||||||
assert data["config"] == {"text": "world"}
|
|
||||||
assert data["enabled"] is False
|
|
||||||
assert data["sort_order"] == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_widget(client):
|
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "static",
|
"widget_kind": "metric",
|
||||||
"title": "To delete",
|
"title": "x",
|
||||||
"config": {"text": "bye"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.delete(f"/api/widgets/instances/{widget_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
response = client.get("/api/widgets/instances")
|
|
||||||
assert not any(w["id"] == widget_id for w in response.json())
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_widget_type_rejected(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/widgets/instances",
|
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "unknown",
|
|
||||||
"title": "Bad",
|
|
||||||
"config": {},
|
"config": {},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_addon_id_mismatch_rejected(client):
|
def test_service_bound_widget_service_not_found_rejected(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "grafana",
|
"service_id": "missing",
|
||||||
"widget_type": "static",
|
"widget_kind": "link",
|
||||||
"title": "Bad",
|
"title": "x",
|
||||||
"config": {"text": "x"},
|
"config": {"dashboard_uid": "u"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_credential_key_rejected(client):
|
def test_service_bound_widget_invalid_config_rejected(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "static",
|
"widget_kind": "link",
|
||||||
"title": "Bad",
|
"title": "x",
|
||||||
"config": {"api_key": "secret123"},
|
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Empty string passes Pydantic; force a real failure with a bad type.
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "link",
|
||||||
|
"title": "x",
|
||||||
|
"config": {"dashboard_uid": 123},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_update_nonexistent_widget(client):
|
def test_update_and_delete_widget(client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}},
|
||||||
|
).json()
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/widgets/instances/{created['id']}",
|
||||||
|
json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}},
|
||||||
|
).json()
|
||||||
|
assert updated["title"] == "Note2"
|
||||||
|
|
||||||
|
assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200
|
||||||
|
assert client.get("/api/widgets/instances").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nonexistent_returns_404(client):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
"/api/widgets/instances/does-not-exist",
|
"/api/widgets/instances/missing",
|
||||||
json={
|
json={"widget_kind": "static", "title": "x", "config": {}},
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Bad",
|
|
||||||
"config": {"text": "x"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_delete_nonexistent_widget(client):
|
|
||||||
response = client.delete("/api/widgets/instances/does-not-exist")
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_widgets_seeded(client):
|
|
||||||
response = client.get("/api/widgets/instances")
|
|
||||||
assert response.status_code == 200
|
|
||||||
widgets = response.json()
|
|
||||||
types = [w["widget_type"] for w in widgets]
|
|
||||||
assert "jellyfin" in types
|
|
||||||
assert "backups" in types
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_reseed_when_widgets_exist(tmp_path):
|
|
||||||
db_path = tmp_path / "settings.sqlite"
|
|
||||||
store = SettingsStore(db_path)
|
|
||||||
store.ensure_defaults()
|
|
||||||
widgets = store.list_widgets()
|
|
||||||
assert len(widgets) == 2
|
|
||||||
|
|
||||||
store.delete_widget(widgets[0]["id"])
|
|
||||||
store.ensure_defaults()
|
|
||||||
|
|
||||||
remaining = store.list_widgets()
|
|
||||||
assert len(remaining) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_id_mismatch_returns_400(client):
|
def test_update_id_mismatch_returns_400(client):
|
||||||
response = client.post(
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={"widget_kind": "static", "title": "x", "config": {}},
|
||||||
"addon_id": "core",
|
).json()
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Note",
|
|
||||||
"config": {"text": "hello"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/widgets/instances/{widget_id}",
|
f"/api/widgets/instances/{created['id']}",
|
||||||
json={
|
json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
|
||||||
"id": "different-id",
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Updated",
|
|
||||||
"config": {"text": "world"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_empty_title_rejected(client):
|
# ---------------------------------------------------------------------------
|
||||||
response = client.post(
|
# Data endpoint
|
||||||
"/api/widgets/instances",
|
# ---------------------------------------------------------------------------
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "",
|
|
||||||
"config": {"text": "hello"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_type_error_rejected(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/widgets/instances",
|
|
||||||
json={
|
|
||||||
"addon_id": "grafana",
|
|
||||||
"widget_type": "grafana-link",
|
|
||||||
"title": "Grafana",
|
|
||||||
"config": {"panel_id": "not-an-integer"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_instances_respects_sort_order(client):
|
|
||||||
response = client.get("/api/widgets/instances")
|
|
||||||
assert response.status_code == 200
|
|
||||||
widgets = response.json()
|
|
||||||
orders = [w["sort_order"] for w in widgets]
|
|
||||||
assert orders == sorted(orders)
|
|
||||||
|
|
||||||
|
|
||||||
def test_enabled_round_trip(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/widgets/instances",
|
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Toggle",
|
|
||||||
"config": {"text": "x"},
|
|
||||||
"enabled": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.put(
|
|
||||||
f"/api/widgets/instances/{widget_id}",
|
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Toggle",
|
|
||||||
"config": {"text": "x"},
|
|
||||||
"enabled": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["enabled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_static_widget_data(client):
|
def test_fetch_static_widget_data(client):
|
||||||
response = client.post(
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
|
||||||
"addon_id": "core",
|
).json()
|
||||||
"widget_type": "static",
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
"title": "Note",
|
|
||||||
"config": {"text": "hello world"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
body = response.json()
|
||||||
assert data["widget_id"] == widget_id
|
assert body["data"]["text"] == "hello"
|
||||||
assert data["widget_type"] == "static"
|
assert body["error"] is None
|
||||||
assert data["data"] == {"text": "hello world"}
|
|
||||||
assert data["error"] is None
|
|
||||||
assert isinstance(data["fetched_at"], int)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_grafana_widget_data(client):
|
def test_fetch_backups_widget_data(client):
|
||||||
response = client.post(
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={"widget_kind": "backups", "title": "Backups", "config": {}},
|
||||||
"addon_id": "grafana",
|
).json()
|
||||||
"widget_type": "grafana-link",
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
"title": "Grafana",
|
|
||||||
"config": {"dashboard_uid": "overview", "panel_id": 3},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert "total_jobs" in response.json()["data"]
|
||||||
assert data["widget_type"] == "grafana-link"
|
|
||||||
assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_prometheus_widget_data(client):
|
def test_fetch_grafana_link_widget_data(client):
|
||||||
response = client.post(
|
service = _make_grafana_service(client)
|
||||||
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "prometheus",
|
"service_id": service["id"],
|
||||||
"widget_type": "prometheus-metric",
|
"widget_kind": "link",
|
||||||
"title": "CPU",
|
"title": "Dashboard",
|
||||||
"config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'},
|
"config": {"dashboard_uid": "overview", "panel_id": 2},
|
||||||
},
|
},
|
||||||
)
|
).json()
|
||||||
widget_id = response.json()["id"]
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
|
|
||||||
fake_payload = {"data": {"resultType": "scalar", "result": [1718900000, "42.5"]}}
|
|
||||||
with patch("media_library_viewer_api.widgets.sources.requests.get") as mock_get:
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_response.json.return_value = fake_payload
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
|
|
||||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
|
||||||
assert data["widget_type"] == "prometheus-metric"
|
|
||||||
assert data["data"]["result"]["resultType"] == "scalar"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_jellyfin_widget_data_error(client):
|
def test_fetch_widget_service_not_found(client):
|
||||||
response = client.post(
|
service = _make_grafana_service(client)
|
||||||
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "jellyfin",
|
"widget_kind": "link",
|
||||||
"title": "Activity",
|
"title": "x",
|
||||||
"config": {"machine_id": ""},
|
"config": {"dashboard_uid": "u"},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
# Deleting the service cascade-deletes its widgets, so the widget is gone.
|
||||||
|
client.delete(f"/api/services/instances/{service['id']}")
|
||||||
|
assert client.get("/api/widgets/instances").json() == []
|
||||||
|
assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_widget_service_disabled(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
|
created = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "link",
|
||||||
|
"title": "x",
|
||||||
|
"config": {"dashboard_uid": "u"},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
client.put(
|
||||||
|
f"/api/services/instances/{service['id']}",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": service["name"],
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"enabled": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
widget_id = response.json()["id"]
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
|
|
||||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert "disabled" in response.json()["error"]
|
||||||
assert data["widget_type"] == "jellyfin"
|
|
||||||
assert data["data"] is None
|
|
||||||
assert data["error"] is not None
|
|
||||||
assert "Jellyfin" in data["error"] or "machine" in data["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_widget_data_not_found(client):
|
def test_fetch_widget_not_found(client):
|
||||||
response = client.get("/api/widgets/instances/does-not-exist/data")
|
assert client.get("/api/widgets/instances/missing/data").status_code == 404
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_widget_data_unhandled_exception_returns_500(client):
|
# ---------------------------------------------------------------------------
|
||||||
response = client.post(
|
# Adapter unit tests
|
||||||
"/api/widgets/instances",
|
# ---------------------------------------------------------------------------
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
@pytest.mark.asyncio
|
||||||
"title": "Note",
|
async def test_grafana_adapter_builds_url():
|
||||||
"config": {"text": "x"},
|
adapter = GrafanaWidgetSource()
|
||||||
|
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
|
||||||
|
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
|
||||||
|
assert result["url"] == "http://g:3000/d/ov"
|
||||||
|
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
|
||||||
|
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grafana_adapter_missing_service():
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alertmanager_adapter_summarizes_alerts():
|
||||||
|
adapter = AlertmanagerWidgetSource()
|
||||||
|
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
|
||||||
|
payload = SimpleNamespace(
|
||||||
|
raise_for_status=lambda: None,
|
||||||
|
json=lambda: {
|
||||||
|
"status": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"labels": {"alertname": "DiskFull", "severity": "critical"},
|
||||||
|
"annotations": {"summary": "disk full"},
|
||||||
|
"startsAt": "2026-06-23T00:00:00Z",
|
||||||
|
"status": "firing",
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
widget_id = response.json()["id"]
|
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||||
|
result = await adapter.fetch(service, "active_alerts", {})
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert result["by_severity"]["critical"] == 1
|
||||||
|
assert result["alerts"][0]["name"] == "DiskFull"
|
||||||
|
|
||||||
class _ExplodingAdapter:
|
|
||||||
source_type = "static"
|
|
||||||
|
|
||||||
async def fetch(self, config):
|
@pytest.mark.asyncio
|
||||||
raise RuntimeError("boom")
|
async def test_alertmanager_adapter_applies_severity_filter():
|
||||||
|
adapter = AlertmanagerWidgetSource()
|
||||||
|
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
|
||||||
|
payload = SimpleNamespace(
|
||||||
|
raise_for_status=lambda: None,
|
||||||
|
json=lambda: {
|
||||||
|
"status": "success",
|
||||||
|
"data": [
|
||||||
|
{"labels": {"alertname": "A", "severity": "critical"}, "annotations": {}, "status": "firing"},
|
||||||
|
{"labels": {"alertname": "B", "severity": "warning"}, "annotations": {}, "status": "firing"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||||
|
result = await adapter.fetch(service, "active_alerts", {"severity_filter": "critical"})
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert result["alerts"][0]["name"] == "A"
|
||||||
|
|
||||||
with patch("media_library_viewer_api.routers.widgets.get_source_adapter", return_value=_ExplodingAdapter()):
|
|
||||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
|
||||||
|
|
||||||
assert response.status_code == 500
|
@pytest.mark.asyncio
|
||||||
|
async def test_alertmanager_adapter_sends_bearer_token():
|
||||||
|
adapter = AlertmanagerWidgetSource()
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="s",
|
||||||
|
service_type="alertmanager",
|
||||||
|
name="am",
|
||||||
|
config={"base_url": "http://am:9093"},
|
||||||
|
secrets={"api_key": "tok"},
|
||||||
|
)
|
||||||
|
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"status": "success", "data": []})
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
||||||
|
result = await adapter.fetch(service, "active_alerts", {})
|
||||||
|
assert result["total"] == 0
|
||||||
|
_, kwargs = mock_get.call_args
|
||||||
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alertmanager_adapter_missing_service():
|
||||||
|
adapter = AlertmanagerWidgetSource()
|
||||||
|
result = await adapter.fetch(None, "active_alerts", {})
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_static_adapter():
|
async def test_static_adapter():
|
||||||
adapter = StaticWidgetSource()
|
adapter = StaticWidgetSource()
|
||||||
result = await adapter.fetch({"text": "hello"})
|
result = await adapter.fetch(None, "static", {"text": "hi"})
|
||||||
assert result == {"text": "hello"}
|
assert result == {"text": "hi"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_grafana_adapter():
|
async def test_backups_adapter(client):
|
||||||
adapter = GrafanaWidgetSource()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2})
|
with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
|
||||||
assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2"
|
adapter = BackupsWidgetSource()
|
||||||
|
result = await adapter.fetch(None, "backups", {})
|
||||||
result = await adapter.fetch({"dashboard_uid": "overview"})
|
assert "total_jobs" in result
|
||||||
assert result["url"] == "http://grafana:3000/d/overview"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ssh_task_adapter_timeout(tmp_path):
|
async def test_ssh_task_adapter_missing_service():
|
||||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
|
||||||
store.ensure_defaults()
|
|
||||||
|
|
||||||
# Create a local machine and a simple shell task.
|
|
||||||
machine = store.list_machines()[0]
|
|
||||||
task = store.upsert_task(
|
|
||||||
{
|
|
||||||
"name": "slow-task",
|
|
||||||
"task_type": "shell",
|
|
||||||
"content": "echo hello",
|
|
||||||
"enabled": True,
|
|
||||||
"default_machine_id": machine["id"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
adapter = SshTaskWidgetSource()
|
adapter = SshTaskWidgetSource()
|
||||||
with patch(
|
result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
|
||||||
"media_library_viewer_api.widgets.sources.get_settings_store",
|
|
||||||
return_value=store,
|
|
||||||
), patch(
|
|
||||||
"media_library_viewer_api.widgets.sources.asyncio.wait_for",
|
|
||||||
side_effect=asyncio.TimeoutError,
|
|
||||||
):
|
|
||||||
result = await adapter.fetch({"task_id": task["id"]})
|
|
||||||
|
|
||||||
assert "error" in result
|
assert "error" in result
|
||||||
assert "timed out" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_registry_closed():
|
@pytest.mark.asyncio
|
||||||
assert set(SOURCE_REGISTRY.keys()) == {
|
async def test_ssh_task_adapter_records_history_on_run(client):
|
||||||
"jellyfin",
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
"backups",
|
# Save a task and an ssh_tasks service instance.
|
||||||
"grafana",
|
task = store.upsert_task(
|
||||||
"prometheus",
|
{
|
||||||
"ssh_task",
|
"name": "echo",
|
||||||
"static",
|
"task_type": "shell",
|
||||||
}
|
"content": "echo hi",
|
||||||
|
"enabled": True,
|
||||||
|
"default_service_id": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = store.upsert_service(
|
||||||
|
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
|
||||||
|
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
|
||||||
|
|
||||||
|
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
|
||||||
|
|
||||||
|
adapter = SshTaskWidgetSource()
|
||||||
|
service_record = ServiceRecord(
|
||||||
|
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
|
||||||
|
patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=fake_client),
|
||||||
|
):
|
||||||
|
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
|
||||||
|
|
||||||
|
assert result["exit_status"] == 0
|
||||||
|
runs = store.list_service_task_runs(service_id=service["id"])
|
||||||
|
assert len(runs) == 1
|
||||||
|
assert runs[0]["status"] == "success"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user