synfull 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ from functools import lru_cache
2
+ import os
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ from pydantic import Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ def default_state_dir() -> Path:
11
+ if sys.platform == "win32":
12
+ root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
13
+ else:
14
+ root = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
15
+ return root / "Synfull" if sys.platform == "win32" else root / "synfull"
16
+
17
+
18
+ class Settings(BaseSettings):
19
+ model_config = SettingsConfigDict(env_prefix="SYNFULL_", env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore")
20
+
21
+ bind_host: str = "127.0.0.1"
22
+ bind_port: int = 8000
23
+ state_dir: Path = Field(default_factory=default_state_dir)
24
+ database_path: Path | None = None
25
+ log_dir: Path | None = None
26
+ collection_interval_seconds: float = 5.0
27
+ metric_retention_days: int = 30
28
+ development: bool = False
29
+ docker_endpoint: str | None = None
30
+ pm2_executable: str | None = None
31
+ pm2_owner: str | None = None
32
+ service_allowlist: str = ""
33
+ initial_admin_token: str | None = None
34
+
35
+ @property
36
+ def resolved_database_path(self) -> Path:
37
+ return self.database_path or self.state_dir / "synfull.sqlite3"
38
+
39
+ @property
40
+ def resolved_log_dir(self) -> Path:
41
+ return self.log_dir or self.state_dir / "logs"
42
+
43
+ def prepare_directories(self) -> None:
44
+ self.state_dir.mkdir(parents=True, exist_ok=True)
45
+ self.resolved_database_path.parent.mkdir(parents=True, exist_ok=True)
46
+ self.resolved_log_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+
49
+ @lru_cache
50
+ def get_settings() -> Settings:
51
+ return Settings()
@@ -0,0 +1,22 @@
1
+ from sqlalchemy import URL, create_engine
2
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
3
+
4
+ from .config import get_settings
5
+
6
+
7
+ class Base(DeclarativeBase):
8
+ pass
9
+
10
+
11
+ settings = get_settings()
12
+ settings.prepare_directories()
13
+ engine = create_engine(URL.create("sqlite", database=str(settings.resolved_database_path)), connect_args={"check_same_thread": False})
14
+ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
15
+
16
+
17
+ def get_db():
18
+ session = SessionLocal()
19
+ try:
20
+ yield session
21
+ finally:
22
+ session.close()
@@ -0,0 +1,239 @@
1
+ import json
2
+ import logging
3
+ import threading
4
+ import time
5
+ from typing import Any
6
+ from urllib.error import HTTPError, URLError
7
+ from urllib.parse import urlparse
8
+ from urllib.request import Request, urlopen
9
+
10
+ from sqlalchemy import Boolean, Column, Integer, Text
11
+
12
+ from ..config import get_settings
13
+ from ..database import Base, SessionLocal, engine
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class DiscordAlertConfig(Base):
19
+ __tablename__ = "discord_alert_config"
20
+
21
+ id = Column(Integer, primary_key=True, default=1)
22
+ webhook_url = Column(Text, nullable=False, default="")
23
+ enabled = Column(Boolean, nullable=False, default=False)
24
+ events_json = Column(Text, nullable=False, default='{"start":true,"stop":true,"crash":true,"restart":true}')
25
+ minimum_interval_seconds = Column(Integer, nullable=False, default=60)
26
+
27
+
28
+ Base.metadata.create_all(bind=engine)
29
+
30
+ DEFAULT_EVENTS = {"start": True, "stop": True, "crash": True, "restart": True}
31
+ EMBED_BLUE = 0x3B82F6
32
+ WEBHOOK_USERNAME = "Synfull - AIO Monitoring Software"
33
+ WEBHOOK_AVATAR_URL = "https://api.iconify.design/lucide/activity.svg?color=%23ffffff"
34
+
35
+
36
+ def _config_from_db() -> DiscordAlertConfig:
37
+ with SessionLocal() as session:
38
+ config = session.get(DiscordAlertConfig, 1)
39
+ if config is None:
40
+ config = DiscordAlertConfig(id=1)
41
+ session.add(config)
42
+ session.commit()
43
+ session.refresh(config)
44
+ return config
45
+
46
+
47
+ def _parse_events(raw: str) -> dict[str, bool]:
48
+ try:
49
+ events = json.loads(raw)
50
+ except (TypeError, json.JSONDecodeError):
51
+ events = {}
52
+ return {key: bool(events.get(key, False)) for key in DEFAULT_EVENTS} if isinstance(events, dict) else DEFAULT_EVENTS.copy()
53
+
54
+
55
+ def public_config() -> dict[str, Any]:
56
+ config = _config_from_db()
57
+ masked = "https://discord.com/api/webhooks/••••••••" if config.webhook_url else ""
58
+ return {"enabled": config.enabled, "configured": bool(config.webhook_url), "webhook_url_masked": masked,
59
+ "events": _parse_events(config.events_json), "minimum_interval_seconds": config.minimum_interval_seconds}
60
+
61
+
62
+ def update_config(*, enabled: bool, webhook_url: str | None, events: dict[str, bool], minimum_interval_seconds: int) -> dict[str, Any]:
63
+ with SessionLocal() as session:
64
+ config = session.get(DiscordAlertConfig, 1)
65
+ if config is None:
66
+ config = DiscordAlertConfig(id=1)
67
+ session.add(config)
68
+ session.flush()
69
+ if webhook_url == "":
70
+ config.webhook_url = ""
71
+ elif webhook_url is not None:
72
+ normalized_url = webhook_url.strip()
73
+ parsed = urlparse(normalized_url)
74
+ if parsed.scheme != "https" or parsed.hostname != "discord.com" or not parsed.path.startswith("/api/webhooks/") or parsed.username or parsed.password:
75
+ raise ValueError("Enter a valid Discord HTTPS webhook URL")
76
+ config.webhook_url = normalized_url
77
+ if minimum_interval_seconds < 0 or minimum_interval_seconds > 3600:
78
+ raise ValueError("Minimum alert interval must be between 0 and 3600 seconds")
79
+ config.enabled = enabled
80
+ config.events_json = json.dumps({key: bool(events.get(key, False)) for key in DEFAULT_EVENTS})
81
+ config.minimum_interval_seconds = minimum_interval_seconds
82
+ session.commit()
83
+ return public_config()
84
+
85
+
86
+ def send_discord_message(content: str, *, title: str = "Monitoring alert", timeout_seconds: float = 8.0) -> None:
87
+ config = _config_from_db()
88
+ if not config.webhook_url:
89
+ raise ValueError("Configure a Discord webhook URL first")
90
+ payload = {
91
+ "username": WEBHOOK_USERNAME,
92
+ "avatar_url": WEBHOOK_AVATAR_URL,
93
+ "allowed_mentions": {"parse": []},
94
+ "embeds": [{
95
+ "title": title[:256],
96
+ "description": content[:4096],
97
+ "color": EMBED_BLUE,
98
+ "footer": {"text": "Synfull · AIO Monitoring Software"},
99
+ }],
100
+ }
101
+ request = Request(config.webhook_url, data=json.dumps(payload).encode("utf-8"),
102
+ headers={"Content-Type": "application/json", "User-Agent": "Synfull/0.1"}, method="POST")
103
+ try:
104
+ with urlopen(request, timeout=timeout_seconds) as response:
105
+ if response.status not in (200, 204):
106
+ raise RuntimeError(f"Discord returned HTTP {response.status}")
107
+ except HTTPError as exc:
108
+ raise RuntimeError(f"Discord webhook returned HTTP {exc.code}") from None
109
+ except URLError:
110
+ raise RuntimeError("Could not reach Discord webhook. Check the connection and webhook URL.") from None
111
+
112
+
113
+ def send_test_message() -> None:
114
+ send_discord_message(
115
+ "\n".join((
116
+ "✅ **Webhook connected successfully**",
117
+ "",
118
+ "Synfull can now deliver monitoring alerts to this channel.",
119
+ )),
120
+ title="Webhook test",
121
+ )
122
+
123
+
124
+ class DockerEventMonitor:
125
+ def __init__(self, client_factory=None, interval_seconds: float = 2.0):
126
+ self._client_factory = client_factory or self._default_client
127
+ self._interval_seconds = interval_seconds
128
+ self._stop_event = threading.Event()
129
+ self._thread: threading.Thread | None = None
130
+ self._client = None
131
+ self._last_sent: dict[tuple[str, str], float] = {}
132
+ self._previous_states: dict[str, tuple[str, str]] = {}
133
+
134
+ @staticmethod
135
+ def _default_client():
136
+ import docker # type: ignore[import-not-found]
137
+ settings = get_settings()
138
+ return docker.DockerClient(base_url=settings.docker_endpoint, timeout=5) if settings.docker_endpoint else docker.from_env(timeout=5)
139
+
140
+ def start(self) -> None:
141
+ if self._thread and self._thread.is_alive():
142
+ return
143
+ self._stop_event.clear()
144
+ self._thread = threading.Thread(target=self._run, name="synfull-docker-alerts", daemon=True)
145
+ self._thread.start()
146
+
147
+ def stop(self) -> None:
148
+ self._stop_event.set()
149
+ if self._client is not None:
150
+ try:
151
+ self._client.close()
152
+ except Exception:
153
+ pass
154
+ self._client = None
155
+ if self._thread:
156
+ self._thread.join(timeout=2)
157
+
158
+ def _run(self) -> None:
159
+ while not self._stop_event.is_set():
160
+ try:
161
+ with SessionLocal() as session:
162
+ config = session.get(DiscordAlertConfig, 1)
163
+ enabled = bool(config and config.enabled and config.webhook_url)
164
+ except Exception as exc:
165
+ logger.debug("Unable to read Docker alert settings: %s", exc)
166
+ self._stop_event.wait(self._interval_seconds)
167
+ continue
168
+ if not enabled:
169
+ self._stop_event.wait(self._interval_seconds)
170
+ continue
171
+ try:
172
+ client = self._client_factory()
173
+ self._client = client
174
+ try:
175
+ for event in client.events(decode=True, filters={"type": "container", "event": ["start", "stop", "die", "restart"]}):
176
+ if self._stop_event.is_set():
177
+ return
178
+ self.process_event(event)
179
+ finally:
180
+ if self._client is client:
181
+ self._client = None
182
+ close = getattr(client, "close", None)
183
+ if close:
184
+ close()
185
+ except Exception as exc:
186
+ if not self._stop_event.is_set():
187
+ logger.debug("Docker event monitor reconnecting after error: %s", exc)
188
+ self._stop_event.wait(self._interval_seconds)
189
+
190
+ def process_event(self, event: dict[str, Any]) -> bool:
191
+ config = _config_from_db()
192
+ if not config.enabled or not config.webhook_url:
193
+ return False
194
+ action = str(event.get("Action") or event.get("status") or "").lower()
195
+ actor = event.get("Actor") or {}
196
+ attributes = actor.get("Attributes") or {}
197
+ name = attributes.get("name") or event.get("id") or "unknown container"
198
+ container_id = str(event.get("id") or event.get("ID") or name)[:64]
199
+ if action == "die":
200
+ try:
201
+ exit_code = int(attributes.get("exitCode", "1"))
202
+ except (TypeError, ValueError):
203
+ exit_code = 1
204
+ event_key = "stop" if exit_code == 0 else "crash"
205
+ else:
206
+ event_key = action
207
+ events = _parse_events(config.events_json)
208
+ if event_key not in events or not events[event_key]:
209
+ return False
210
+ previous = self._previous_states.get(container_id)
211
+ if event_key == "crash" and previous and previous[0] == "stopped":
212
+ return False
213
+ if event_key == "start" and previous and previous[0] == "stopped" and events.get("restart"):
214
+ event_key = "restart"
215
+ self._previous_states[container_id] = ("stopped" if event_key in {"stop", "crash"} else "running", name)
216
+ now = time.monotonic()
217
+ throttle_key = (container_id, event_key)
218
+ if now - self._last_sent.get(throttle_key, 0) < config.minimum_interval_seconds:
219
+ return False
220
+ labels = {"start": "started", "stop": "stopped", "crash": "crashed unexpectedly", "restart": "restarted"}
221
+ titles = {"start": "Container started", "stop": "Container stopped", "crash": "Container crashed", "restart": "Container restarted"}
222
+ safe_name = str(name).replace("`", "ˋ")
223
+ try:
224
+ send_discord_message(
225
+ "\n".join((
226
+ f"🐳 **Container:** `{safe_name}`",
227
+ f"**ID:** `{container_id[:12]}`",
228
+ f"**Event:** {labels[event_key].capitalize()}.",
229
+ )),
230
+ title=titles[event_key],
231
+ )
232
+ self._last_sent[throttle_key] = now
233
+ return True
234
+ except Exception as exc:
235
+ logger.warning("Unable to send Docker %s alert for %s: %s", event_key, name, exc)
236
+ return False
237
+
238
+
239
+ monitor = DockerEventMonitor()
@@ -0,0 +1,192 @@
1
+ import json
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from ..config import get_settings
8
+
9
+
10
+ COMMAND_TIMEOUT_SECONDS = 3
11
+ CONTAINER_ID_PATTERN = re.compile(r"^[a-fA-F0-9]{1,64}$")
12
+ CONTAINER_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")
13
+
14
+
15
+ def validate_container_identifier(container_id: str) -> str:
16
+ if not isinstance(container_id, str) or not (CONTAINER_ID_PATTERN.fullmatch(container_id) or CONTAINER_NAME_PATTERN.fullmatch(container_id)):
17
+ raise ValueError("Invalid Docker container identifier")
18
+ return container_id
19
+
20
+
21
+ def docker_action(container_id: str, action: str) -> dict[str, Any]:
22
+ identifier = validate_container_identifier(container_id)
23
+ if action not in {"start", "stop", "restart"}:
24
+ raise ValueError("Unsupported Docker action")
25
+ try:
26
+ import docker # type: ignore[import-not-found]
27
+ except ImportError as exc:
28
+ raise RuntimeError("Docker SDK is not installed; container actions are disabled") from exc
29
+ settings = get_settings()
30
+ client = docker.DockerClient(base_url=settings.docker_endpoint, timeout=COMMAND_TIMEOUT_SECONDS) if settings.docker_endpoint else docker.from_env(timeout=COMMAND_TIMEOUT_SECONDS)
31
+ container = client.containers.get(identifier)
32
+ getattr(container, action)(timeout=10) if action in {"stop", "restart"} else getattr(container, action)()
33
+ container.reload()
34
+ return {"id": container.short_id, "name": container.name, "state": container.status, "action": action}
35
+
36
+
37
+ def docker_logs(container_id: str, tail: int = 300) -> dict[str, Any]:
38
+ identifier = validate_container_identifier(container_id)
39
+ if not 1 <= tail <= 2000:
40
+ raise ValueError("Log tail must be between 1 and 2000 lines")
41
+ try:
42
+ import docker # type: ignore[import-not-found]
43
+ except ImportError as exc:
44
+ raise RuntimeError("Docker SDK is not installed; container logs are unavailable") from exc
45
+ settings = get_settings()
46
+ client = docker.DockerClient(base_url=settings.docker_endpoint, timeout=COMMAND_TIMEOUT_SECONDS) if settings.docker_endpoint else docker.from_env(timeout=COMMAND_TIMEOUT_SECONDS)
47
+ container = client.containers.get(identifier)
48
+ content = container.logs(tail=tail, timestamps=True, stdout=True, stderr=True)
49
+ if isinstance(content, bytes):
50
+ content = content.decode("utf-8", errors="replace")
51
+ return {"id": container.short_id, "name": container.name, "logs": content, "tail": tail}
52
+
53
+
54
+ def _format_size(value: int) -> str:
55
+ amount = float(value)
56
+ for unit in ("B", "KB", "MB", "GB", "TB"):
57
+ if amount < 1024 or unit == "TB":
58
+ return f"{amount:.1f} {unit}"
59
+ amount /= 1024
60
+ return f"{amount:.1f} TB"
61
+
62
+
63
+ def _run(arguments: list[str]) -> tuple[int, str, str]:
64
+ try:
65
+ result = subprocess.run(arguments, capture_output=True, text=True, timeout=COMMAND_TIMEOUT_SECONDS,
66
+ check=False, shell=False)
67
+ return result.returncode, result.stdout, result.stderr
68
+ except (OSError, subprocess.TimeoutExpired) as exc:
69
+ return 127, "", str(exc)
70
+
71
+
72
+ def docker_status() -> dict[str, Any]:
73
+ settings = get_settings()
74
+ docker_bin = shutil.which("docker")
75
+ result: dict[str, Any] = {"installed": False, "available": False, "status": "unavailable",
76
+ "message": "Docker CLI is not installed.", "containers": []}
77
+ try:
78
+ import docker # type: ignore[import-not-found]
79
+ except ImportError:
80
+ docker_sdk = False
81
+ else:
82
+ docker_sdk = True
83
+ result["sdk_installed"] = docker_sdk
84
+ result["cli_installed"] = docker_bin is not None
85
+ result["installed"] = docker_sdk or docker_bin is not None
86
+ if docker_sdk:
87
+ try:
88
+ client = docker.DockerClient(base_url=settings.docker_endpoint, timeout=COMMAND_TIMEOUT_SECONDS) if settings.docker_endpoint else docker.from_env(timeout=COMMAND_TIMEOUT_SECONDS)
89
+ containers = client.containers.list(all=True)
90
+ normalized = []
91
+ for container in containers:
92
+ item = {"id": container.short_id, "name": container.name,
93
+ "image": str(container.image.tags[0] if container.image.tags else container.image.short_id),
94
+ "state": container.status, "cpu_percent": None, "memory_usage": None, "memory_percent": None}
95
+ if container.status == "running":
96
+ try:
97
+ stats = container.stats(stream=False)
98
+ cpu = stats.get("cpu_stats", {})
99
+ previous_cpu = stats.get("precpu_stats", {})
100
+ cpu_delta = cpu.get("cpu_usage", {}).get("total_usage", 0) - previous_cpu.get("cpu_usage", {}).get("total_usage", 0)
101
+ system_delta = cpu.get("system_cpu_usage", 0) - previous_cpu.get("system_cpu_usage", 0)
102
+ cpu_count = cpu.get("online_cpus") or len(cpu.get("cpu_usage", {}).get("percpu_usage", [])) or 1
103
+ if cpu_delta >= 0 and system_delta > 0:
104
+ item["cpu_percent"] = round((cpu_delta / system_delta) * cpu_count * 100, 1)
105
+ memory = stats.get("memory_stats", {})
106
+ usage = memory.get("usage")
107
+ cache = memory.get("stats", {}).get("inactive_file", memory.get("stats", {}).get("cache", 0))
108
+ limit = memory.get("limit")
109
+ if usage is not None:
110
+ item["memory_usage"] = _format_size(max(0, usage - cache))
111
+ if usage is not None and limit:
112
+ item["memory_percent"] = round(max(0, usage - cache) / limit * 100, 1)
113
+ except Exception:
114
+ pass
115
+ normalized.append(item)
116
+ result.update(available=True, status="available", message="Docker daemon is reachable.", containers=normalized)
117
+ result["running"] = sum(c["state"] == "running" for c in result["containers"])
118
+ return result
119
+ except Exception as exc:
120
+ result.update(status="unavailable", message=f"Docker daemon is not reachable: {exc}")
121
+ if docker_bin:
122
+ code, stdout, stderr = _run([docker_bin, "ps", "--all", "--format", "{{json .}}"])
123
+ if code == 0:
124
+ containers = []
125
+ for line in stdout.splitlines():
126
+ try:
127
+ row = json.loads(line)
128
+ except json.JSONDecodeError:
129
+ continue
130
+ containers.append({"id": row.get("ID", ""), "name": row.get("Names", ""),
131
+ "image": row.get("Image", ""), "state": row.get("State", "unknown")})
132
+ stats_code, stats_output, _ = _run([docker_bin, "stats", "--no-stream", "--format", "{{json .}}"])
133
+ if stats_code == 0:
134
+ stats_by_name = {}
135
+ for line in stats_output.splitlines():
136
+ try:
137
+ row = json.loads(line)
138
+ except json.JSONDecodeError:
139
+ continue
140
+ stats_by_name[row.get("Name", "")] = row
141
+ for container in containers:
142
+ stats = stats_by_name.get(container["name"])
143
+ if stats:
144
+ container.update(cpu_percent=stats.get("CPUPerc"), memory_usage=stats.get("MemUsage"),
145
+ memory_percent=stats.get("MemPerc"))
146
+ else:
147
+ container.update(cpu_percent=None, memory_usage=None, memory_percent=None)
148
+ else:
149
+ for container in containers:
150
+ container.update(cpu_percent=None, memory_usage=None, memory_percent=None)
151
+ result.update(available=True, status="available", message="Docker daemon is reachable.", containers=containers,
152
+ running=sum(c["state"] == "running" for c in containers))
153
+ else:
154
+ result.update(status="unavailable", message=(stderr.strip() or "Docker daemon is stopped or inaccessible."))
155
+ return result
156
+
157
+
158
+ def pm2_status() -> dict[str, Any]:
159
+ settings = get_settings()
160
+ executable = settings.pm2_executable or shutil.which("pm2")
161
+ result: dict[str, Any] = {"installed": executable is not None, "available": False,
162
+ "status": "unavailable", "message": "PM2 is not installed.", "processes": []}
163
+ if executable is None:
164
+ return result
165
+ result["message"] = "PM2 is installed but its process list is unavailable."
166
+ code, stdout, stderr = _run([executable, "jlist"])
167
+ if code != 0:
168
+ result["message"] = stderr.strip() or "PM2 process list is inaccessible for the configured user."
169
+ return result
170
+ try:
171
+ rows = json.loads(stdout)
172
+ if not isinstance(rows, list):
173
+ raise ValueError("PM2 returned an unexpected process list")
174
+ except (json.JSONDecodeError, ValueError) as exc:
175
+ result["message"] = f"Could not parse PM2 process list: {exc}"
176
+ return result
177
+ processes = []
178
+ for row in rows:
179
+ env = row.get("pm2_env") or {}
180
+ monit = row.get("monit") or {}
181
+ processes.append({"id": row.get("pm_id"), "name": row.get("name"),
182
+ "status": env.get("status", "unknown"), "cpu_percent": monit.get("cpu"),
183
+ "memory_bytes": monit.get("memory"), "uptime_ms": env.get("pm_uptime")})
184
+ result.update(available=True, status="available", message="PM2 process list is available.", processes=processes,
185
+ running=sum(p["status"] == "online" for p in processes))
186
+ if settings.pm2_owner:
187
+ result["configured_owner"] = settings.pm2_owner
188
+ return result
189
+
190
+
191
+ def integrations_status() -> dict[str, dict[str, Any]]:
192
+ return {"docker": docker_status(), "pm2": pm2_status()}
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+
3
+ from contextlib import asynccontextmanager
4
+ import threading
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from .api.routes import router
11
+
12
+ from .integrations.discord_alerts import monitor
13
+ from .services.management import monitor_loop
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(_app: FastAPI):
18
+ monitor.start()
19
+ stop_event = threading.Event()
20
+ management_thread = threading.Thread(target=monitor_loop, args=(stop_event,), name="synfull-monitoring", daemon=True)
21
+ management_thread.start()
22
+ try:
23
+ yield
24
+ finally:
25
+ stop_event.set()
26
+ management_thread.join(timeout=3)
27
+ monitor.stop()
28
+
29
+
30
+ app = FastAPI(title="Synfull", version="0.1.0", lifespan=lifespan)
31
+ app.include_router(router)
32
+
33
+
34
+ @app.get("/health")
35
+ def health():
36
+ return {"status": "ok"}
37
+
38
+
39
+ _FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
40
+ if (_FRONTEND_DIST / "assets").is_dir():
41
+ app.mount("/assets", StaticFiles(directory=_FRONTEND_DIST / "assets"), name="assets")
42
+
43
+
44
+ @app.get("/{path:path}", include_in_schema=False)
45
+ def frontend(path: str):
46
+ if path.startswith("api/") or path in {"docs", "redoc", "openapi.json", "health"}:
47
+ raise HTTPException(status_code=404)
48
+ index = _FRONTEND_DIST / "index.html"
49
+ if not index.is_file():
50
+ raise HTTPException(status_code=404, detail="Frontend assets are not built")
51
+ from .services.management import auth_status
52
+ return FileResponse(index, headers={"Cache-Control": "no-store"} if auth_status()["setup_required"] else None)
@@ -0,0 +1,19 @@
1
+ from pydantic import BaseModel, Field, field_validator
2
+
3
+
4
+ DEFAULT_DOCKER_ALERT_EVENTS = {"start", "stop", "crash", "restart"}
5
+
6
+
7
+ class DiscordAlertUpdate(BaseModel):
8
+ enabled: bool
9
+ events: dict[str, bool] = Field(default_factory=lambda: {"start": True, "stop": True, "crash": True, "restart": True})
10
+ minimum_interval_seconds: int = Field(default=60, ge=0, le=3600)
11
+ webhook_url: str | None = None
12
+ clear_webhook: bool = False
13
+
14
+ @field_validator("events")
15
+ @classmethod
16
+ def validate_event_keys(cls, value: dict[str, bool]) -> dict[str, bool]:
17
+ if set(value) != DEFAULT_DOCKER_ALERT_EVENTS:
18
+ raise ValueError("Choose a value for each supported Docker alert event")
19
+ return value
@@ -0,0 +1,63 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class AdminSetup(BaseModel):
5
+ setup_token: str = Field(min_length=16, max_length=256)
6
+ username: str = Field(min_length=1, max_length=80)
7
+ password: str = Field(min_length=12, max_length=256)
8
+
9
+
10
+ class LoginPayload(BaseModel):
11
+ username: str = Field(min_length=1, max_length=80)
12
+ password: str = Field(min_length=1, max_length=256)
13
+
14
+
15
+ class PasswordChange(BaseModel):
16
+ new_password: str = Field(min_length=12, max_length=256)
17
+
18
+
19
+ class ServiceAllowlistUpdate(BaseModel):
20
+ services: list[str] = Field(max_length=250)
21
+
22
+
23
+ class EndpointTarget(BaseModel):
24
+ name: str = Field(min_length=1, max_length=100)
25
+ url: str = Field(min_length=8, max_length=2048)
26
+
27
+
28
+ class EndpointTargetsUpdate(BaseModel):
29
+ targets: list[EndpointTarget] = Field(max_length=100)
30
+
31
+
32
+ class BackupTarget(BaseModel):
33
+ name: str = Field(min_length=1, max_length=100)
34
+ path: str = Field(min_length=1, max_length=1024)
35
+ max_age_hours: int = Field(default=48, ge=1, le=8760)
36
+
37
+
38
+ class BackupTargetsUpdate(BaseModel):
39
+ targets: list[BackupTarget] = Field(max_length=100)
40
+
41
+
42
+ class ThresholdUpdate(BaseModel):
43
+ thresholds: dict[str, float]
44
+
45
+
46
+ class UserCreate(BaseModel):
47
+ username: str = Field(min_length=1, max_length=80)
48
+ password: str = Field(min_length=12, max_length=256)
49
+ role: str = Field(pattern="^(admin|guest)$")
50
+ permissions: list[str] = Field(default_factory=list, max_length=32)
51
+ theme: str = Field(default="synfull", pattern="^(synfull|monochrome|hacker|light|oled)$")
52
+
53
+
54
+ class UserUpdate(BaseModel):
55
+ role: str = Field(pattern="^(admin|guest)$")
56
+ enabled: bool = True
57
+ permissions: list[str] = Field(default_factory=list, max_length=32)
58
+ password: str | None = Field(default=None, min_length=12, max_length=256)
59
+ theme: str | None = Field(default=None, pattern="^(synfull|monochrome|hacker|light|oled)$")
60
+
61
+
62
+ class ThemeUpdate(BaseModel):
63
+ theme: str = Field(pattern="^(synfull|monochrome|hacker|light|oled)$")
@@ -0,0 +1,30 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class SystemResponse(BaseModel):
5
+ platform: str
6
+ hostname: str
7
+ cpu_percent: float | None
8
+ per_cpu_percent: list[float] | None
9
+ memory_total: int | None
10
+ memory_used: int | None
11
+ memory_available: int | None
12
+ swap_total: int | None
13
+ swap_used: int | None
14
+ disks: list[dict]
15
+ networks: list[dict]
16
+ uptime_seconds: float | None
17
+ load_average: list[float] | None
18
+ temperatures: list[dict] | None
19
+
20
+
21
+ class ProcessResponse(BaseModel):
22
+ pid: int
23
+ name: str | None
24
+ username: str | None
25
+ cpu_percent: float | None
26
+ memory_bytes: int | None
27
+ executable: str | None
28
+ command_line: list[str] | None
29
+ parent_pid: int | None
30
+ status: str | None