synfull 0.1.2 → 0.1.4

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,87 @@ 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. LAN binding exposes the login page and server to every reachable network interface: use a trusted LAN, configure the host firewall to allow only the devices/subnet you intend, 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, then prepares the isolated Python runtime and dependencies. The choice is saved in the CLI config and used by later starts and service registration.
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 run` | Run Synfull in the foreground. |
47
+ | `synfull status` | Show process status, dashboard address, and data location. |
48
+ | `synfull stop` | Stop a background instance. |
49
+ | `synfull open` | Open the dashboard on the local machine. |
50
+ | `synfull install-service` | Explicitly register automatic startup (Windows sign-in task or Linux systemd service). |
51
+ | `synfull uninstall-service` | Remove startup registration; retain application data. |
52
+
53
+ 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.
54
+
55
+ ## Optional integrations and security
56
+
57
+ ### Docker on Linux
58
+
59
+ 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.
60
+
61
+ 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.
62
+
63
+ ### LAN access
25
64
 
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`.
65
+ 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
66
 
28
- Configuration:
67
+ ## Configuration
29
68
 
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.
69
+ The CLI stores its runtime and preferences under the user's local application-data directory. Persistent application state is stored separately.
33
70
 
34
- ## Development from source
71
+ | Variable | Purpose |
72
+ | --- | --- |
73
+ | `SYNFULL_HOME` | Override the CLI application/runtime directory. |
74
+ | `SYNFULL_STATE_DIR` | Override the directory for persistent database and application state. |
75
+ | `SYNFULL_BIND_PORT` | Change the listening port (default `8000`). |
76
+ | `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
77
 
36
- The source tree includes a Python FastAPI backend and a React/Vite frontend.
78
+ ## Development
79
+
80
+ Clone the repository, then install the backend and frontend development dependencies.
81
+
82
+ ### Windows PowerShell
37
83
 
38
84
  ```powershell
39
85
  .\scripts\dev.ps1
40
86
  ```
41
87
 
88
+ ### Linux/macOS shell
89
+
42
90
  ```sh
43
- python -m venv .venv
91
+ python3 -m venv .venv
92
+ . .venv/bin/activate
44
93
  python -m pip install -r backend/requirements.txt
45
94
  npm --prefix frontend ci
46
- npm --prefix frontend run build
95
+ npm --prefix frontend run dev
47
96
  ```
48
97
 
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`.
98
+ The Vite development server proxies API requests to the backend. In another terminal, start the API from the project root:
50
99
 
51
- ## npm publishing
100
+ ```sh
101
+ PYTHONPATH=backend python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
102
+ ```
52
103
 
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.
104
+ Build the frontend for production with:
54
105
 
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.
106
+ ```sh
107
+ npm --prefix frontend run build
108
+ ```
109
+
110
+ Run checks from the project root (with backend development dependencies installed):
111
+
112
+ ```sh
113
+ PYTHONPATH=backend python -m pytest backend/tests
114
+ npm --prefix frontend run lint
115
+ ```
56
116
 
57
117
  ## License
58
118
 
59
- MIT. See [LICENSE](LICENSE).
119
+ Synfull is released under the [MIT License](LICENSE).
@@ -5,9 +5,11 @@ from fastapi import APIRouter, HTTPException, Query, Request, Response
5
5
  from ..collectors.processes import collect_processes
6
6
  from ..collectors.system import collect_system_metrics, platform_capabilities
7
7
  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
8
+ from ..integrations.discord_alerts import (public_config as discord_alert_config, public_history_config,
9
+ send_test_message, update_config as update_discord_alert_config,
10
+ update_history_config)
9
11
  from ..integrations.status import docker_action, docker_logs, integrations_status
10
- from ..schemas.alerts import DiscordAlertUpdate
12
+ from ..schemas.alerts import DiscordAlertUpdate, HistoryDiscordAlertUpdate
11
13
  from ..schemas.system import ProcessResponse, SystemResponse
12
14
  from ..schemas.management import AdminSetup, BackupTargetsUpdate, EndpointTargetsUpdate, LoginPayload, PasswordChange, ServiceAllowlistUpdate, ThresholdUpdate, ThemeUpdate, UserCreate, UserUpdate
13
15
  from ..services import management
@@ -175,7 +177,8 @@ def put_discord_alert_config(payload: DiscordAlertUpdate, request: Request):
175
177
  try:
176
178
  result = update_discord_alert_config(enabled=payload.enabled, webhook_url=webhook_url,
177
179
  events=payload.events,
178
- minimum_interval_seconds=payload.minimum_interval_seconds)
180
+ minimum_interval_seconds=payload.minimum_interval_seconds,
181
+ webhook_username=payload.webhook_username, avatar_url=payload.avatar_url)
179
182
  management._audit(username=management.require_admin(request), action="docker.alerts.discord.update")
180
183
  return result
181
184
  except ValueError as exc:
@@ -186,7 +189,7 @@ def put_discord_alert_config(payload: DiscordAlertUpdate, request: Request):
186
189
  def test_discord_alert(request: Request):
187
190
  management.require_admin(request)
188
191
  try:
189
- send_test_message()
192
+ send_test_message("docker")
190
193
  management._audit(username=management.require_admin(request), action="docker.alerts.discord.test")
191
194
  return {"sent": True, "message": "Test alert sent to Discord."}
192
195
  except Exception as exc:
@@ -194,6 +197,42 @@ def test_discord_alert(request: Request):
194
197
  raise HTTPException(status_code=503, detail=str(exc)) from exc
195
198
 
196
199
 
200
+ @router.get("/management/metrics/discord")
201
+ def get_history_discord_config(request: Request):
202
+ management.require_admin(request)
203
+ return public_history_config()
204
+
205
+
206
+ @router.put("/management/metrics/discord")
207
+ def put_history_discord_config(payload: HistoryDiscordAlertUpdate, request: Request):
208
+ username = management.require_admin(request)
209
+ current = public_history_config()
210
+ webhook_url = "" if payload.clear_webhook else (payload.webhook_url or None)
211
+ if payload.enabled and not current["configured"] and webhook_url is None:
212
+ raise HTTPException(status_code=400, detail="Set a Discord webhook URL before enabling alerts")
213
+ if payload.enabled and webhook_url == "":
214
+ raise HTTPException(status_code=400, detail="Set a Discord webhook URL before enabling alerts")
215
+ try:
216
+ result = update_history_config(enabled=payload.enabled, webhook_url=webhook_url,
217
+ webhook_username=payload.webhook_username, avatar_url=payload.avatar_url)
218
+ management._audit(username=username, action="monitoring.alerts.discord.update")
219
+ return result
220
+ except ValueError as exc:
221
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
222
+
223
+
224
+ @router.post("/management/metrics/discord/test")
225
+ def test_history_discord_alert(request: Request):
226
+ username = management.require_admin(request)
227
+ try:
228
+ send_test_message("history")
229
+ management._audit(username=username, action="monitoring.alerts.discord.test")
230
+ return {"sent": True, "message": "Test alert sent to Discord."}
231
+ except Exception as exc:
232
+ management._audit(username=username, action="monitoring.alerts.discord.test", outcome="failed", details=str(exc))
233
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
234
+
235
+
197
236
  @router.get("/docker/containers/{container_id}/logs")
198
237
  def container_logs(container_id: str, request: Request, tail: int = Query(default=300, ge=1, le=2000)):
199
238
  management.require_admin(request)
@@ -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)