synfull 0.1.3 → 0.1.5

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.
package/README.md CHANGED
@@ -1,10 +1,30 @@
1
1
  # Synfull
2
2
 
3
- Synfull is a local-first host monitoring dashboard for Windows and Linux, providing CPU, memory, disk, network, process, service, integration and host-event views through a browser interface and REST API.
3
+ **Local-first host monitoring for Windows and Linux.** Synfull brings live system metrics, process and service visibility, operational checks, and Docker/PM2 integrations together in a self-hosted dashboard.
4
4
 
5
- ## Install from npm
5
+ [![npm version](https://img.shields.io/npm/v/synfull)](https://www.npmjs.com/package/synfull)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
7
 
7
- Requirements: Node.js 18+, Python 3.10+, and npm. The CLI creates its own Python virtual environment and installs the backend dependencies when needed. On Ubuntu/Debian, install `python3-venv` (or the matching `python3.X-venv` package) if venv creation reports that pip is missing. Linux may also require build prerequisites when a dependency has no wheel for the host platform.
8
+ ## What it does
9
+
10
+ - Live CPU, memory, storage, network, uptime, temperature, and process monitoring.
11
+ - Host service and scheduled-job inventory, system logs, and an administrator audit trail.
12
+ - Optional Docker and PM2 integrations, with container controls and Docker event alerts.
13
+ - Endpoint availability/TLS checks, backup freshness checks, resource history, and thresholds.
14
+ - User accounts, guest permissions, and per-user dashboard themes.
15
+ - A local-first web dashboard with an optional, explicit LAN-access choice.
16
+
17
+ Synfull is built with a **FastAPI/Python backend** and a **React/Vite frontend**, distributed through npm with a small Node.js CLI.
18
+
19
+ ## Install and run
20
+
21
+ ### Requirements
22
+
23
+ - Node.js 18 or newer and npm.
24
+ - Python 3.10 or newer. Synfull creates its own virtual environment on first setup.
25
+ - On Ubuntu/Debian, install `python3-venv` (or the matching `python3.X-venv` package) if Python reports that pip/venv support is missing. Some Linux distributions may need build tools when a Python dependency has no prebuilt wheel.
26
+
27
+ ### From npm
8
28
 
9
29
  ```sh
10
30
  npm install -g synfull
@@ -13,47 +33,88 @@ synfull start
13
33
  synfull open
14
34
  ```
15
35
 
16
- During interactive `synfull setup`, you can choose whether to bind to `0.0.0.0` for access from other devices on your LAN; localhost-only (`127.0.0.1`) remains the default, and non-interactive setup stays local-only. On Linux, setup also offers to add your account to the `docker` group so the Docker integration can access the daemon. This is optional and grants root-equivalent control of the computer; decline if you do not fully trust the account with administrator-level Docker access. If Synfull's systemd service is installed, setup restarts it after group membership changes; otherwise, an interactive user session may need a logout/login before new group membership applies. LAN binding exposes the login page and server to every reachable network interface: use a trusted LAN, configure the host firewall to allow only intended devices/subnets, change the initial `admin` / `admin` password locally before signing in remotely, and do not expose Synfull directly to the public internet. npm installation alone does not register a background service or change system startup.
36
+ `synfull setup` prepares the Python runtime and dependencies. It asks whether to enable LAN access when run in an interactive terminal; **No/Enter keeps Synfull on `127.0.0.1`**, accessible only from the host. Choosing LAN access binds to `0.0.0.0`; other devices can then connect to `http://<server-lan-ip>:8000`.
37
+
38
+ The first local login is `admin` / `admin`. Synfull requires a new password of at least 12 characters before allowing access to the dashboard. If you enable LAN access, change this password from the server itself before signing in remotely. Bind only on a trusted LAN, restrict port `8000` with your firewall, and do not expose the service directly to the public internet.
17
39
 
18
40
  ### Commands
19
41
 
20
- - `synfull setup` asks whether to allow LAN access and, on Linux, whether to grant the current account Docker-group access; it then prepares the isolated Python runtime and dependencies. Both choices are optional and are used by later starts/service registration. Docker group membership is root-equivalent. If an installed Synfull systemd service is present, setup restarts it after adding group access; otherwise log out and back in for a shell session to receive the new group membership.
21
- - `synfull start` runs in the background; `synfull run` keeps the server in the foreground.
22
- - `synfull status`, `synfull stop`, and `synfull open` manage a manually started instance.
23
- - `synfull install-service` explicitly enables automatic start and restart. On Windows this registers a per-user logon scheduled task. On Linux it creates and enables a system-wide systemd service and therefore requires `sudo`; the service runs as your invoking user, not root.
24
- - `synfull uninstall-service` removes startup registration but retains application data.
42
+ | Command | Description |
43
+ | --- | --- |
44
+ | `synfull setup` | Ask setup options and prepare the isolated Python runtime/dependencies. |
45
+ | `synfull start` | Start Synfull in the background. |
46
+ | `synfull reload` | Refresh installed application files from the current CLI package and restart the service/background instance. |
47
+ | `synfull run` | Run Synfull in the foreground. |
48
+ | `synfull status` | Show process status, dashboard address, and data location. |
49
+ | `synfull stop` | Stop a background instance. |
50
+ | `synfull open` | Open the dashboard on the local machine. |
51
+ | `synfull install-service` | Explicitly register automatic startup (Windows sign-in task or Linux systemd service). |
52
+ | `synfull uninstall-service` | Remove startup registration; retain application data. |
53
+
54
+ Service registration is optional and is never performed by npm installation. On Linux, installing or removing the system-wide systemd service requires `sudo`; Synfull runs as the invoking user, not as root. After updating the global npm package, run `synfull reload` to refresh the installed runtime and restart it. Reload does not download or install npm updates itself; use `npm update -g synfull` first.
55
+
56
+ ## Optional integrations and security
57
+
58
+ ### Docker on Linux
59
+
60
+ During interactive Linux setup, Synfull offers to add your account to the `docker` group so it can access the Docker daemon. This is optional. **Membership in the Docker group grants root-equivalent control of the machine.** Only approve this for an account you trust with administrator-level access.
61
+
62
+ If the Synfull systemd service is installed, setup restarts it after granting access so it picks up the new group membership. For a manually started instance, log out and back in (or start a new session) before running Synfull again. Docker must be installed and its daemon must be running.
63
+
64
+ ### LAN access
25
65
 
26
- Use the same user account to install/start Synfull and install its startup service. Service registration is optional and is not performed during `npm install -g`.
66
+ The LAN option is deliberately opt-in. `0.0.0.0` listens on every network interface, not just a private LAN interface. Use a trusted network and firewall rules that allow only the devices/subnet you intend. Authentication is required, but the initial password must be rotated locally before remote login.
27
67
 
28
- Configuration:
68
+ ## Configuration
29
69
 
30
- - `SYNFULL_HOME` selects the CLI runtime/application directory.
31
- - `SYNFULL_STATE_DIR` selects persistent database and app state.
32
- - `SYNFULL_BIND_PORT` changes the port (default `8000`). The bind host is selected during interactive setup and defaults to `127.0.0.1`; `SYNFULL_BIND_HOST=127.0.0.1` or `SYNFULL_BIND_HOST=0.0.0.0` can override it for a single run/service install.
70
+ The CLI stores its runtime and preferences under the user's local application-data directory. Persistent application state is stored separately.
33
71
 
34
- ## Development from source
72
+ | Variable | Purpose |
73
+ | --- | --- |
74
+ | `SYNFULL_HOME` | Override the CLI application/runtime directory. |
75
+ | `SYNFULL_STATE_DIR` | Override the directory for persistent database and application state. |
76
+ | `SYNFULL_BIND_PORT` | Change the listening port (default `8000`). |
77
+ | `SYNFULL_BIND_HOST` | Override the bind host for a command/service install: `127.0.0.1` or `0.0.0.0`. Setup's saved choice applies when this is unset. |
35
78
 
36
- The source tree includes a Python FastAPI backend and a React/Vite frontend.
79
+ ## Development
80
+
81
+ Clone the repository, then install the backend and frontend development dependencies.
82
+
83
+ ### Windows PowerShell
37
84
 
38
85
  ```powershell
39
86
  .\scripts\dev.ps1
40
87
  ```
41
88
 
89
+ ### Linux/macOS shell
90
+
42
91
  ```sh
43
- python -m venv .venv
92
+ python3 -m venv .venv
93
+ . .venv/bin/activate
44
94
  python -m pip install -r backend/requirements.txt
45
95
  npm --prefix frontend ci
46
- npm --prefix frontend run build
96
+ npm --prefix frontend run dev
47
97
  ```
48
98
 
49
- Run the API from the repository root with `python -m uvicorn app.main:app --app-dir backend --host 127.0.0.1 --port 8000`. Tests: `python -m pytest backend/tests` and `npm --prefix frontend run lint`.
99
+ The Vite development server proxies API requests to the backend. In another terminal, start the API from the project root:
50
100
 
51
- ## npm publishing
101
+ ```sh
102
+ PYTHONPATH=backend python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
103
+ ```
52
104
 
53
- Package metadata and the `synfull` executable live in the repository-root `package.json`. The publish package includes the backend, runtime requirements, CLI and built frontend, not development dependencies. `npm pack --dry-run` can be used to inspect the tarball contents before publishing.
105
+ Build the frontend for production with:
54
106
 
55
- The package name `synfull` was checked against the public npm registry while preparing this release and appeared available. Publishing still requires an npm account with permission to claim/publish the name and should only be done by an authorized maintainer.
107
+ ```sh
108
+ npm --prefix frontend run build
109
+ ```
110
+
111
+ Run checks from the project root (with backend development dependencies installed):
112
+
113
+ ```sh
114
+ PYTHONPATH=backend python -m pytest backend/tests
115
+ npm --prefix frontend run lint
116
+ ```
56
117
 
57
118
  ## License
58
119
 
59
- MIT. See [LICENSE](LICENSE).
120
+ Synfull is released under the [MIT License](LICENSE).
@@ -1,13 +1,18 @@
1
1
  from dataclasses import asdict
2
+ from pathlib import Path
3
+ import json
4
+ import re
2
5
 
3
6
  from fastapi import APIRouter, HTTPException, Query, Request, Response
4
7
 
5
8
  from ..collectors.processes import collect_processes
6
9
  from ..collectors.system import collect_system_metrics, platform_capabilities
7
10
  from ..config import get_settings
8
- from ..integrations.discord_alerts import public_config as discord_alert_config, send_test_message, update_config as update_discord_alert_config
11
+ from ..integrations.discord_alerts import (public_config as discord_alert_config, public_history_config,
12
+ send_test_message, update_config as update_discord_alert_config,
13
+ update_history_config)
9
14
  from ..integrations.status import docker_action, docker_logs, integrations_status
10
- from ..schemas.alerts import DiscordAlertUpdate
15
+ from ..schemas.alerts import DiscordAlertUpdate, HistoryDiscordAlertUpdate
11
16
  from ..schemas.system import ProcessResponse, SystemResponse
12
17
  from ..schemas.management import AdminSetup, BackupTargetsUpdate, EndpointTargetsUpdate, LoginPayload, PasswordChange, ServiceAllowlistUpdate, ThresholdUpdate, ThemeUpdate, UserCreate, UserUpdate
13
18
  from ..services import management
@@ -15,6 +20,16 @@ from ..services import management
15
20
  router = APIRouter(prefix="/api/v1")
16
21
 
17
22
 
23
+ def installed_version() -> str:
24
+ for package_file in (Path(__file__).resolve().parents[3] / "package.json",
25
+ Path(__file__).resolve().parents[4] / "package.json"):
26
+ try:
27
+ return json.loads(package_file.read_text(encoding="utf-8"))["version"]
28
+ except (OSError, KeyError, json.JSONDecodeError):
29
+ continue
30
+ return "unknown"
31
+
32
+
18
33
  @router.get("/auth/status")
19
34
  def authentication_status(request: Request):
20
35
  return management.auth_status(request.cookies.get(management.SESSION_COOKIE))
@@ -175,7 +190,8 @@ def put_discord_alert_config(payload: DiscordAlertUpdate, request: Request):
175
190
  try:
176
191
  result = update_discord_alert_config(enabled=payload.enabled, webhook_url=webhook_url,
177
192
  events=payload.events,
178
- minimum_interval_seconds=payload.minimum_interval_seconds)
193
+ minimum_interval_seconds=payload.minimum_interval_seconds,
194
+ webhook_username=payload.webhook_username, avatar_url=payload.avatar_url)
179
195
  management._audit(username=management.require_admin(request), action="docker.alerts.discord.update")
180
196
  return result
181
197
  except ValueError as exc:
@@ -186,7 +202,7 @@ def put_discord_alert_config(payload: DiscordAlertUpdate, request: Request):
186
202
  def test_discord_alert(request: Request):
187
203
  management.require_admin(request)
188
204
  try:
189
- send_test_message()
205
+ send_test_message("docker")
190
206
  management._audit(username=management.require_admin(request), action="docker.alerts.discord.test")
191
207
  return {"sent": True, "message": "Test alert sent to Discord."}
192
208
  except Exception as exc:
@@ -194,6 +210,42 @@ def test_discord_alert(request: Request):
194
210
  raise HTTPException(status_code=503, detail=str(exc)) from exc
195
211
 
196
212
 
213
+ @router.get("/management/metrics/discord")
214
+ def get_history_discord_config(request: Request):
215
+ management.require_admin(request)
216
+ return public_history_config()
217
+
218
+
219
+ @router.put("/management/metrics/discord")
220
+ def put_history_discord_config(payload: HistoryDiscordAlertUpdate, request: Request):
221
+ username = management.require_admin(request)
222
+ current = public_history_config()
223
+ webhook_url = "" if payload.clear_webhook else (payload.webhook_url or None)
224
+ if payload.enabled and not current["configured"] and webhook_url is None:
225
+ raise HTTPException(status_code=400, detail="Set a Discord webhook URL before enabling alerts")
226
+ if payload.enabled and webhook_url == "":
227
+ raise HTTPException(status_code=400, detail="Set a Discord webhook URL before enabling alerts")
228
+ try:
229
+ result = update_history_config(enabled=payload.enabled, webhook_url=webhook_url,
230
+ webhook_username=payload.webhook_username, avatar_url=payload.avatar_url)
231
+ management._audit(username=username, action="monitoring.alerts.discord.update")
232
+ return result
233
+ except ValueError as exc:
234
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
235
+
236
+
237
+ @router.post("/management/metrics/discord/test")
238
+ def test_history_discord_alert(request: Request):
239
+ username = management.require_admin(request)
240
+ try:
241
+ send_test_message("history")
242
+ management._audit(username=username, action="monitoring.alerts.discord.test")
243
+ return {"sent": True, "message": "Test alert sent to Discord."}
244
+ except Exception as exc:
245
+ management._audit(username=username, action="monitoring.alerts.discord.test", outcome="failed", details=str(exc))
246
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
247
+
248
+
197
249
  @router.get("/docker/containers/{container_id}/logs")
198
250
  def container_logs(container_id: str, request: Request, tail: int = Query(default=300, ge=1, le=2000)):
199
251
  management.require_admin(request)
@@ -232,6 +284,33 @@ def security_summary(request: Request):
232
284
  "service_allowlist": management.management_settings()["service_allowlist"]}
233
285
 
234
286
 
287
+ @router.get("/settings/update-check")
288
+ def check_for_update(request: Request):
289
+ management.require_admin(request)
290
+ import urllib.error
291
+ import urllib.request
292
+
293
+ current = installed_version()
294
+ request_message = urllib.request.Request(
295
+ "https://registry.npmjs.org/synfull/latest",
296
+ headers={"Accept": "application/json", "User-Agent": "Synfull-update-check"},
297
+ )
298
+ try:
299
+ with urllib.request.urlopen(request_message, timeout=4) as response:
300
+ package = json.loads(response.read(100_000).decode("utf-8"))
301
+ latest = package.get("version")
302
+ if not isinstance(latest, str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", latest):
303
+ raise ValueError("Package registry returned an invalid version")
304
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc:
305
+ raise HTTPException(status_code=503, detail="Unable to check npm for updates. Please try again later.") from exc
306
+ current_core = tuple(int(part) for part in current.split(".")[:3]) if re.fullmatch(r"\d+\.\d+\.\d+", current) else None
307
+ latest_core = tuple(int(part) for part in latest.split(".")[:3]) if re.fullmatch(r"\d+\.\d+\.\d+", latest) else None
308
+ return {"current_version": current, "latest_version": latest,
309
+ "update_available": current_core is not None and latest_core is not None and latest_core > current_core,
310
+ "update_command": "npm install -g synfull@latest\nsynfull reload",
311
+ "package_url": "https://www.npmjs.com/package/synfull"}
312
+
313
+
235
314
  @router.get("/settings")
236
315
  def settings_summary(request: Request):
237
316
  management.require_admin(request)
@@ -239,6 +318,7 @@ def settings_summary(request: Request):
239
318
  return {
240
319
  "bind_host": settings.bind_host,
241
320
  "bind_port": settings.bind_port,
321
+ "version": installed_version(),
242
322
  "state_dir": str(settings.state_dir),
243
323
  "database_path": str(settings.resolved_database_path),
244
324
  "log_dir": str(settings.resolved_log_dir),
@@ -23,6 +23,18 @@ class DiscordAlertConfig(Base):
23
23
  enabled = Column(Boolean, nullable=False, default=False)
24
24
  events_json = Column(Text, nullable=False, default='{"start":true,"stop":true,"crash":true,"restart":true}')
25
25
  minimum_interval_seconds = Column(Integer, nullable=False, default=60)
26
+ webhook_username = Column(Text, nullable=False, default="Synfull - AIO Monitoring Software")
27
+ avatar_url = Column(Text, nullable=False, default="https://api.iconify.design/lucide/activity.svg?color=%23ffffff")
28
+
29
+
30
+ class HistoryDiscordAlertConfig(Base):
31
+ __tablename__ = "history_discord_alert_config"
32
+
33
+ id = Column(Integer, primary_key=True, default=1)
34
+ webhook_url = Column(Text, nullable=False, default="")
35
+ enabled = Column(Boolean, nullable=False, default=False)
36
+ webhook_username = Column(Text, nullable=False, default="Synfull - Monitoring Alerts")
37
+ avatar_url = Column(Text, nullable=False, default="https://api.iconify.design/lucide/activity.svg?color=%23ffffff")
26
38
 
27
39
 
28
40
  Base.metadata.create_all(bind=engine)
@@ -31,6 +43,22 @@ DEFAULT_EVENTS = {"start": True, "stop": True, "crash": True, "restart": True}
31
43
  EMBED_BLUE = 0x3B82F6
32
44
  WEBHOOK_USERNAME = "Synfull - AIO Monitoring Software"
33
45
  WEBHOOK_AVATAR_URL = "https://api.iconify.design/lucide/activity.svg?color=%23ffffff"
46
+ HISTORY_WEBHOOK_USERNAME = "Synfull - Monitoring Alerts"
47
+
48
+
49
+ def _migrate_docker_config_schema() -> None:
50
+ from sqlalchemy import inspect
51
+ columns = {column["name"] for column in inspect(engine).get_columns("discord_alert_config")}
52
+ with engine.begin() as connection:
53
+ if "webhook_username" not in columns:
54
+ connection.exec_driver_sql(
55
+ "ALTER TABLE discord_alert_config ADD COLUMN webhook_username TEXT NOT NULL DEFAULT 'Synfull - AIO Monitoring Software'")
56
+ if "avatar_url" not in columns:
57
+ connection.exec_driver_sql(
58
+ "ALTER TABLE discord_alert_config ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://api.iconify.design/lucide/activity.svg?color=%23ffffff'")
59
+
60
+
61
+ _migrate_docker_config_schema()
34
62
 
35
63
 
36
64
  def _config_from_db() -> DiscordAlertConfig:
@@ -44,6 +72,38 @@ def _config_from_db() -> DiscordAlertConfig:
44
72
  return config
45
73
 
46
74
 
75
+ def _history_config_from_db() -> HistoryDiscordAlertConfig:
76
+ with SessionLocal() as session:
77
+ config = session.get(HistoryDiscordAlertConfig, 1)
78
+ if config is None:
79
+ config = HistoryDiscordAlertConfig(id=1)
80
+ session.add(config)
81
+ session.commit()
82
+ session.refresh(config)
83
+ return config
84
+
85
+
86
+ def _validate_webhook_url(webhook_url: str) -> str:
87
+ normalized_url = webhook_url.strip()
88
+ parsed = urlparse(normalized_url)
89
+ if (parsed.scheme != "https" or parsed.hostname != "discord.com"
90
+ or not parsed.path.startswith("/api/webhooks/") or parsed.username or parsed.password):
91
+ raise ValueError("Enter a valid Discord HTTPS webhook URL")
92
+ return normalized_url
93
+
94
+
95
+ def _validate_identity(webhook_username: str, avatar_url: str) -> tuple[str, str]:
96
+ username = webhook_username.strip()
97
+ avatar = avatar_url.strip()
98
+ if not username or len(username) > 80:
99
+ raise ValueError("Webhook name must be between 1 and 80 characters")
100
+ if avatar:
101
+ parsed = urlparse(avatar)
102
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
103
+ raise ValueError("Webhook avatar URL must be a valid HTTPS URL")
104
+ return username, avatar
105
+
106
+
47
107
  def _parse_events(raw: str) -> dict[str, bool]:
48
108
  try:
49
109
  events = json.loads(raw)
@@ -56,10 +116,40 @@ def public_config() -> dict[str, Any]:
56
116
  config = _config_from_db()
57
117
  masked = "https://discord.com/api/webhooks/••••••••" if config.webhook_url else ""
58
118
  return {"enabled": config.enabled, "configured": bool(config.webhook_url), "webhook_url_masked": masked,
119
+ "webhook_username": config.webhook_username, "avatar_url": config.avatar_url,
59
120
  "events": _parse_events(config.events_json), "minimum_interval_seconds": config.minimum_interval_seconds}
60
121
 
61
122
 
62
- def update_config(*, enabled: bool, webhook_url: str | None, events: dict[str, bool], minimum_interval_seconds: int) -> dict[str, Any]:
123
+ def public_history_config() -> dict[str, Any]:
124
+ config = _history_config_from_db()
125
+ masked = "https://discord.com/api/webhooks/••••••••" if config.webhook_url else ""
126
+ return {"enabled": config.enabled, "configured": bool(config.webhook_url), "webhook_url_masked": masked,
127
+ "webhook_username": config.webhook_username, "avatar_url": config.avatar_url}
128
+
129
+
130
+ def update_history_config(*, enabled: bool, webhook_url: str | None, webhook_username: str, avatar_url: str) -> dict[str, Any]:
131
+ username, avatar = _validate_identity(webhook_username, avatar_url)
132
+ with SessionLocal() as session:
133
+ config = session.get(HistoryDiscordAlertConfig, 1)
134
+ if config is None:
135
+ config = HistoryDiscordAlertConfig(id=1)
136
+ session.add(config)
137
+ session.flush()
138
+ if webhook_url == "":
139
+ config.webhook_url = ""
140
+ elif webhook_url is not None:
141
+ config.webhook_url = _validate_webhook_url(webhook_url)
142
+ if enabled and not config.webhook_url:
143
+ raise ValueError("Set a Discord webhook URL before enabling alerts")
144
+ config.enabled = enabled
145
+ config.webhook_username = username
146
+ config.avatar_url = avatar
147
+ session.commit()
148
+ return public_history_config()
149
+
150
+
151
+ def update_config(*, enabled: bool, webhook_url: str | None, events: dict[str, bool], minimum_interval_seconds: int,
152
+ webhook_username: str = WEBHOOK_USERNAME, avatar_url: str = WEBHOOK_AVATAR_URL) -> dict[str, Any]:
63
153
  with SessionLocal() as session:
64
154
  config = session.get(DiscordAlertConfig, 1)
65
155
  if config is None:
@@ -69,27 +159,34 @@ def update_config(*, enabled: bool, webhook_url: str | None, events: dict[str, b
69
159
  if webhook_url == "":
70
160
  config.webhook_url = ""
71
161
  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
162
+ config.webhook_url = _validate_webhook_url(webhook_url)
163
+ username, avatar = _validate_identity(webhook_username, avatar_url)
77
164
  if minimum_interval_seconds < 0 or minimum_interval_seconds > 3600:
78
165
  raise ValueError("Minimum alert interval must be between 0 and 3600 seconds")
79
166
  config.enabled = enabled
80
167
  config.events_json = json.dumps({key: bool(events.get(key, False)) for key in DEFAULT_EVENTS})
81
168
  config.minimum_interval_seconds = minimum_interval_seconds
169
+ config.webhook_username = username
170
+ config.avatar_url = avatar
82
171
  session.commit()
83
172
  return public_config()
84
173
 
85
174
 
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")
175
+ def send_discord_message(content: str, *, title: str = "Monitoring alert", timeout_seconds: float = 8.0,
176
+ channel: str = "docker") -> None:
177
+ if channel == "history":
178
+ config = _history_config_from_db()
179
+ default_username = HISTORY_WEBHOOK_USERNAME
180
+ if not config.enabled or not config.webhook_url:
181
+ raise ValueError("Configure and enable the History & Alerts Discord webhook first")
182
+ else:
183
+ config = _config_from_db()
184
+ default_username = WEBHOOK_USERNAME
185
+ if not config.webhook_url:
186
+ raise ValueError("Configure a Discord webhook URL first")
90
187
  payload = {
91
- "username": WEBHOOK_USERNAME,
92
- "avatar_url": WEBHOOK_AVATAR_URL,
188
+ "username": config.webhook_username or default_username,
189
+ "avatar_url": config.avatar_url or WEBHOOK_AVATAR_URL,
93
190
  "allowed_mentions": {"parse": []},
94
191
  "embeds": [{
95
192
  "title": title[:256],
@@ -110,7 +207,7 @@ def send_discord_message(content: str, *, title: str = "Monitoring alert", timeo
110
207
  raise RuntimeError("Could not reach Discord webhook. Check the connection and webhook URL.") from None
111
208
 
112
209
 
113
- def send_test_message() -> None:
210
+ def send_test_message(channel: str = "docker") -> None:
114
211
  send_discord_message(
115
212
  "\n".join((
116
213
  "✅ **Webhook connected successfully**",
@@ -118,6 +215,7 @@ def send_test_message() -> None:
118
215
  "Synfull can now deliver monitoring alerts to this channel.",
119
216
  )),
120
217
  title="Webhook test",
218
+ channel=channel,
121
219
  )
122
220
 
123
221
 
@@ -9,6 +9,8 @@ class DiscordAlertUpdate(BaseModel):
9
9
  events: dict[str, bool] = Field(default_factory=lambda: {"start": True, "stop": True, "crash": True, "restart": True})
10
10
  minimum_interval_seconds: int = Field(default=60, ge=0, le=3600)
11
11
  webhook_url: str | None = None
12
+ webhook_username: str = "Synfull - AIO Monitoring Software"
13
+ avatar_url: str = "https://api.iconify.design/lucide/activity.svg?color=%23ffffff"
12
14
  clear_webhook: bool = False
13
15
 
14
16
  @field_validator("events")
@@ -17,3 +19,11 @@ class DiscordAlertUpdate(BaseModel):
17
19
  if set(value) != DEFAULT_DOCKER_ALERT_EVENTS:
18
20
  raise ValueError("Choose a value for each supported Docker alert event")
19
21
  return value
22
+
23
+
24
+ class HistoryDiscordAlertUpdate(BaseModel):
25
+ enabled: bool
26
+ webhook_url: str | None = None
27
+ webhook_username: str = Field(default="Synfull - Monitoring Alerts", min_length=1, max_length=80)
28
+ avatar_url: str = "https://api.iconify.design/lucide/activity.svg?color=%23ffffff"
29
+ clear_webhook: bool = False
@@ -350,6 +350,8 @@ def _permission_for_request(request) -> str | None:
350
350
  if path == "capabilities": return "view_dashboard"
351
351
  if path == "docker/alerts/discord": return "manage_alerts" if method in {"PUT", "POST"} else "view_alerts"
352
352
  if path == "docker/alerts/discord/test": return "manage_alerts"
353
+ if path == "management/metrics/discord": return "manage_alerts" if method == "PUT" else "view_alerts"
354
+ if path == "management/metrics/discord/test": return "manage_alerts"
353
355
  if path.startswith("docker/containers/"):
354
356
  return "manage_docker" if method == "POST" else "view_docker"
355
357
  if path.startswith("management/users"): return "view_security"
@@ -980,9 +982,9 @@ def collect_monitoring_sample() -> None:
980
982
  session.commit()
981
983
  for title, message in notifications:
982
984
  try:
983
- from ..integrations.discord_alerts import public_config, send_discord_message
984
- if public_config()["enabled"]:
985
- send_discord_message(message, title=title)
985
+ from ..integrations.discord_alerts import public_history_config, send_discord_message
986
+ if public_history_config()["enabled"]:
987
+ send_discord_message(message, title=title, channel="history")
986
988
  except Exception as exc:
987
989
  logger.debug("Metric alert notification unavailable: %s", exc)
988
990
 
@@ -1004,8 +1006,8 @@ def run_configured_checks() -> None:
1004
1006
  continue
1005
1007
  if result["status"] == "unhealthy" or (result.get("tls_days_remaining") is not None and result["tls_days_remaining"] <= 30):
1006
1008
  try:
1007
- from ..integrations.discord_alerts import public_config, send_discord_message
1008
- if public_config()["enabled"]:
1009
+ from ..integrations.discord_alerts import public_history_config, send_discord_message
1010
+ if public_history_config()["enabled"]:
1009
1011
  send_discord_message(
1010
1012
  "\n".join((
1011
1013
  f"⚠️ **Endpoint:** {target['name']}",
@@ -1014,6 +1016,7 @@ def run_configured_checks() -> None:
1014
1016
  f"**Details:** {result['message']}",
1015
1017
  )),
1016
1018
  title="Endpoint health alert",
1019
+ channel="history",
1017
1020
  )
1018
1021
  except Exception as exc:
1019
1022
  logger.debug("Endpoint alert notification unavailable: %s", exc)