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/LICENSE +674 -21
- package/README.md +83 -23
- package/backend/app/api/routes.py +43 -4
- package/backend/app/integrations/discord_alerts.py +111 -13
- package/backend/app/schemas/alerts.py +10 -0
- package/backend/app/services/management.py +8 -5
- package/frontend/dist/assets/index-hx4yW_Xg.js +249 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/synfull.cjs +60 -9
- package/frontend/dist/assets/index-ERXSRoTY.js +0 -249
package/README.md
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
# Synfull
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/synfull)
|
|
6
|
+
[](LICENSE)
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
95
|
+
npm --prefix frontend run dev
|
|
47
96
|
```
|
|
48
97
|
|
|
49
|
-
|
|
98
|
+
The Vite development server proxies API requests to the backend. In another terminal, start the API from the project root:
|
|
50
99
|
|
|
51
|
-
|
|
100
|
+
```sh
|
|
101
|
+
PYTHONPATH=backend python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
|
102
|
+
```
|
|
52
103
|
|
|
53
|
-
|
|
104
|
+
Build the frontend for production with:
|
|
54
105
|
|
|
55
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
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":
|
|
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
|
|
984
|
-
if
|
|
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
|
|
1008
|
-
if
|
|
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)
|