synfull 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Synfull contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # Synfull
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
4
+
5
+ ## Install from npm
6
+
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
+
9
+ ```sh
10
+ npm install -g synfull
11
+ synfull setup
12
+ synfull start
13
+ synfull open
14
+ ```
15
+
16
+ The dashboard listens on **127.0.0.1:8000** only. The initial local login is username `admin`, password `admin`; Synfull will require you to set a new password of at least 12 characters before accessing the application. Do not expose the service publicly or through an untrusted proxy. npm installation alone does not register a background service or change system startup.
17
+
18
+ ### Commands
19
+
20
+ - `synfull setup` prepares the isolated Python runtime and dependencies.
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.
25
+
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`.
27
+
28
+ Configuration:
29
+
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 localhost port (default `8000`). The listener is deliberately fixed to `127.0.0.1` in npm mode.
33
+
34
+ ## Development from source
35
+
36
+ The source tree includes a Python FastAPI backend and a React/Vite frontend.
37
+
38
+ ```powershell
39
+ .\scripts\dev.ps1
40
+ ```
41
+
42
+ ```sh
43
+ python -m venv .venv
44
+ python -m pip install -r backend/requirements.txt
45
+ npm --prefix frontend ci
46
+ npm --prefix frontend run build
47
+ ```
48
+
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`.
50
+
51
+ ## npm publishing
52
+
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.
54
+
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.
56
+
57
+ ## License
58
+
59
+ MIT. See [LICENSE](LICENSE).
File without changes
File without changes
@@ -0,0 +1,380 @@
1
+ from dataclasses import asdict
2
+
3
+ from fastapi import APIRouter, HTTPException, Query, Request, Response
4
+
5
+ from ..collectors.processes import collect_processes
6
+ from ..collectors.system import collect_system_metrics, platform_capabilities
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
9
+ from ..integrations.status import docker_action, docker_logs, integrations_status
10
+ from ..schemas.alerts import DiscordAlertUpdate
11
+ from ..schemas.system import ProcessResponse, SystemResponse
12
+ from ..schemas.management import AdminSetup, BackupTargetsUpdate, EndpointTargetsUpdate, LoginPayload, PasswordChange, ServiceAllowlistUpdate, ThresholdUpdate, ThemeUpdate, UserCreate, UserUpdate
13
+ from ..services import management
14
+
15
+ router = APIRouter(prefix="/api/v1")
16
+
17
+
18
+ @router.get("/auth/status")
19
+ def authentication_status(request: Request):
20
+ return management.auth_status(request.cookies.get(management.SESSION_COOKIE))
21
+
22
+
23
+ @router.post("/auth/setup")
24
+ def setup_administrator(payload: AdminSetup, response: Response, request: Request):
25
+ if not request.client or request.client.host not in {"127.0.0.1", "::1", "testclient"}:
26
+ raise HTTPException(status_code=403, detail="Initial administrator setup is available only from this host")
27
+ try:
28
+ token = management.create_admin(setup_token=payload.setup_token, username=payload.username, password=payload.password)
29
+ except ValueError as exc:
30
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
31
+ response.set_cookie(management.SESSION_COOKIE, token, httponly=True, secure=request.url.scheme == "https",
32
+ samesite="strict", max_age=management.SESSION_SECONDS, path="/")
33
+ return {"authenticated": True, "username": payload.username.strip(), "must_change_password": False}
34
+
35
+
36
+ @router.post("/auth/login")
37
+ def authenticate(payload: LoginPayload, response: Response, request: Request):
38
+ try:
39
+ client_ip = request.client.host if request.client else "unknown"
40
+ token = management.login(username=payload.username, password=payload.password, client_ip=client_ip)
41
+ except PermissionError as exc:
42
+ raise HTTPException(status_code=429, detail=str(exc)) from exc
43
+ except ValueError as exc:
44
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
45
+ response.set_cookie(management.SESSION_COOKIE, token, httponly=True, secure=request.url.scheme == "https",
46
+ samesite="strict", max_age=management.SESSION_SECONDS, path="/")
47
+ current = management.current_user(payload.username.strip())
48
+ return {"authenticated": True, "username": payload.username.strip(),
49
+ "must_change_password": current["must_change_password"]}
50
+
51
+
52
+ @router.post("/auth/change-password")
53
+ def update_own_password(payload: PasswordChange, request: Request, response: Response):
54
+ username = management.get_session_username(request.cookies.get(management.SESSION_COOKIE))
55
+ if not username:
56
+ raise HTTPException(status_code=401, detail="Sign in to change your password")
57
+ try:
58
+ token = management.change_password(username, payload.new_password)
59
+ except ValueError as exc:
60
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
61
+ response.set_cookie(management.SESSION_COOKIE, token, httponly=True, secure=request.url.scheme == "https",
62
+ samesite="strict", max_age=management.SESSION_SECONDS, path="/")
63
+ return {"authenticated": True, "username": username, "must_change_password": False}
64
+
65
+
66
+ @router.post("/auth/logout")
67
+ def unauthenticate(request: Request, response: Response):
68
+ username = management.get_session_username(request.cookies.get(management.SESSION_COOKIE))
69
+ if username:
70
+ management.logout(request.cookies.get(management.SESSION_COOKIE), username)
71
+ response.delete_cookie(management.SESSION_COOKIE, path="/", httponly=True, samesite="strict")
72
+ return {"authenticated": False}
73
+
74
+
75
+ @router.get("/auth/me")
76
+ def current_administrator(request: Request):
77
+ username = management.require_admin(request)
78
+ return {"authenticated": True, "username": username}
79
+
80
+
81
+ @router.get("/auth/preferences")
82
+ def user_preferences(request: Request):
83
+ username = management.require_admin(request)
84
+ return management.current_user(username)
85
+
86
+
87
+ @router.put("/auth/preferences/theme")
88
+ @router.post("/auth/preferences/theme")
89
+ def set_user_theme(payload: ThemeUpdate, request: Request):
90
+ username = management.require_admin(request)
91
+ return {"theme": management.update_theme(username, payload.theme)}
92
+
93
+
94
+ @router.get("/management/users/permissions")
95
+ def available_user_permissions(request: Request):
96
+ management.require_admin(request)
97
+ return {"permissions": management.USER_PERMISSIONS}
98
+
99
+
100
+ @router.get("/management/users")
101
+ def list_user_accounts(request: Request):
102
+ username = management.require_admin(request)
103
+ if management.current_user(username)["role"] != "admin":
104
+ raise HTTPException(status_code=403, detail="Only administrators can manage user accounts")
105
+ return management.user_accounts()
106
+
107
+
108
+ @router.post("/management/users")
109
+ def add_user_account(payload: UserCreate, request: Request):
110
+ username = management.require_admin(request)
111
+ if management.current_user(username)["role"] != "admin":
112
+ raise HTTPException(status_code=403, detail="Only administrators can manage user accounts")
113
+ try:
114
+ created = management.create_user(username=payload.username, password=payload.password, role=payload.role,
115
+ permissions=payload.permissions, theme=payload.theme)
116
+ management._audit(username=username, action="users.create", target=created["username"], details=f"Created {created['role']} account")
117
+ return created
118
+ except ValueError as exc:
119
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
120
+
121
+
122
+ @router.put("/management/users/{user_id}")
123
+ def change_user_account(user_id: int, payload: UserUpdate, request: Request):
124
+ username = management.require_admin(request)
125
+ if management.current_user(username)["role"] != "admin":
126
+ raise HTTPException(status_code=403, detail="Only administrators can manage user accounts")
127
+ try:
128
+ updated = management.update_user(user_id, role=payload.role, enabled=payload.enabled,
129
+ permissions=payload.permissions, password=payload.password, theme=payload.theme)
130
+ management._audit(username=username, action="users.update", target=updated["username"], details="Updated account settings")
131
+ return updated
132
+ except ValueError as exc:
133
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
134
+
135
+
136
+ @router.get("/system", response_model=SystemResponse)
137
+ def system(request: Request):
138
+ management.require_admin(request)
139
+ return asdict(collect_system_metrics())
140
+
141
+
142
+ @router.get("/processes", response_model=list[ProcessResponse])
143
+ def processes(request: Request):
144
+ management.require_admin(request)
145
+ return collect_processes()
146
+
147
+
148
+ @router.get("/capabilities")
149
+ def capabilities(request: Request):
150
+ management.require_admin(request)
151
+ return platform_capabilities()
152
+
153
+
154
+ @router.get("/integrations")
155
+ def integrations(request: Request):
156
+ management.require_admin(request)
157
+ return integrations_status()
158
+
159
+
160
+ @router.get("/docker/alerts/discord")
161
+ def get_discord_alert_config(request: Request):
162
+ management.require_admin(request)
163
+ return discord_alert_config()
164
+
165
+
166
+ @router.put("/docker/alerts/discord")
167
+ def put_discord_alert_config(payload: DiscordAlertUpdate, request: Request):
168
+ management.require_admin(request)
169
+ current = discord_alert_config()
170
+ webhook_url = "" if payload.clear_webhook else (payload.webhook_url or None)
171
+ if payload.enabled and not current["configured"] and webhook_url is None:
172
+ raise HTTPException(status_code=400, detail="Set a Discord webhook URL before enabling alerts")
173
+ if payload.enabled and webhook_url == "":
174
+ raise HTTPException(status_code=400, detail="Set a Discord webhook URL before enabling alerts")
175
+ try:
176
+ result = update_discord_alert_config(enabled=payload.enabled, webhook_url=webhook_url,
177
+ events=payload.events,
178
+ minimum_interval_seconds=payload.minimum_interval_seconds)
179
+ management._audit(username=management.require_admin(request), action="docker.alerts.discord.update")
180
+ return result
181
+ except ValueError as exc:
182
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
183
+
184
+
185
+ @router.post("/docker/alerts/discord/test")
186
+ def test_discord_alert(request: Request):
187
+ management.require_admin(request)
188
+ try:
189
+ send_test_message()
190
+ management._audit(username=management.require_admin(request), action="docker.alerts.discord.test")
191
+ return {"sent": True, "message": "Test alert sent to Discord."}
192
+ except Exception as exc:
193
+ management._audit(username=management.require_admin(request), action="docker.alerts.discord.test", outcome="failed", details=str(exc))
194
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
195
+
196
+
197
+ @router.get("/docker/containers/{container_id}/logs")
198
+ def container_logs(container_id: str, request: Request, tail: int = Query(default=300, ge=1, le=2000)):
199
+ management.require_admin(request)
200
+ try:
201
+ return docker_logs(container_id, tail)
202
+ except ValueError as exc:
203
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
204
+ except Exception as exc:
205
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
206
+
207
+
208
+ @router.post("/docker/containers/{container_id}/{action}")
209
+ def manage_container(container_id: str, action: str, request: Request):
210
+ username = management.require_admin(request)
211
+ if action not in {"start", "stop", "restart"}:
212
+ raise HTTPException(status_code=400, detail="Action must be start, stop, or restart")
213
+ try:
214
+ result = docker_action(container_id, action)
215
+ management._audit(username=username, action=f"docker.{action}", target=result.get("name", container_id))
216
+ return result
217
+ except ValueError as exc:
218
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
219
+ except Exception as exc:
220
+ management._audit(username=username, action=f"docker.{action}", target=container_id, outcome="failed", details=str(exc))
221
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
222
+
223
+
224
+ @router.get("/management/security")
225
+ def security_summary(request: Request):
226
+ username = management.require_admin(request)
227
+ settings = get_settings()
228
+ return {"username": username, "bind_host": settings.bind_host, "bind_port": settings.bind_port,
229
+ "is_local_only": settings.bind_host in {"127.0.0.1", "localhost", "::1"},
230
+ "transport_is_https": request.url.scheme == "https",
231
+ "session_duration_seconds": management.SESSION_SECONDS,
232
+ "service_allowlist": management.management_settings()["service_allowlist"]}
233
+
234
+
235
+ @router.get("/settings")
236
+ def settings_summary(request: Request):
237
+ management.require_admin(request)
238
+ settings = get_settings()
239
+ return {
240
+ "bind_host": settings.bind_host,
241
+ "bind_port": settings.bind_port,
242
+ "state_dir": str(settings.state_dir),
243
+ "database_path": str(settings.resolved_database_path),
244
+ "log_dir": str(settings.resolved_log_dir),
245
+ "collection_interval_seconds": settings.collection_interval_seconds,
246
+ "metric_retention_days": settings.metric_retention_days,
247
+ "development": settings.development,
248
+ "docker_endpoint_configured": bool(settings.docker_endpoint),
249
+ "pm2_executable_configured": bool(settings.pm2_executable),
250
+ "pm2_owner": settings.pm2_owner,
251
+ }
252
+
253
+
254
+ @router.get("/management/services")
255
+ def services(request: Request):
256
+ management.require_admin(request)
257
+ configured = set(management.management_settings()["service_allowlist"])
258
+ return [{**service, "allowlisted": service["name"] in configured} for service in management.list_services()]
259
+
260
+
261
+ @router.put("/management/services/allowlist")
262
+ def save_service_allowlist(payload: ServiceAllowlistUpdate, request: Request):
263
+ username = management.require_admin(request)
264
+ try:
265
+ return {"services": management.update_service_allowlist(payload.services, username=username)}
266
+ except ValueError as exc:
267
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
268
+
269
+
270
+ @router.post("/management/services/{name}/{action}")
271
+ def control_service(name: str, action: str, request: Request):
272
+ username = management.require_admin(request)
273
+ try:
274
+ return management.service_action(name, action, username=username)
275
+ except PermissionError as exc:
276
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
277
+ except ValueError as exc:
278
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
279
+ except Exception as exc:
280
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
281
+
282
+
283
+ @router.get("/management/jobs")
284
+ def jobs(request: Request):
285
+ management.require_admin(request)
286
+ return management.scheduled_jobs()
287
+
288
+
289
+ @router.get("/management/endpoints")
290
+ def endpoints(request: Request):
291
+ management.require_admin(request)
292
+ return management.endpoint_results()
293
+
294
+
295
+ @router.put("/management/endpoints")
296
+ def save_endpoints(payload: EndpointTargetsUpdate, request: Request):
297
+ username = management.require_admin(request)
298
+ try:
299
+ targets = management.save_endpoint_targets([target.model_dump() for target in payload.targets], username=username)
300
+ return {"targets": targets}
301
+ except ValueError as exc:
302
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
303
+
304
+
305
+ @router.post("/management/endpoints/check")
306
+ def check_endpoints(request: Request):
307
+ username = management.require_admin(request)
308
+ results = []
309
+ targets = management.management_settings()["endpoint_targets"]
310
+ for target in targets:
311
+ try:
312
+ result = management.check_endpoint(target)
313
+ except ValueError as exc:
314
+ result = {**target, "status": "unhealthy", "message": str(exc), "response_code": None,
315
+ "response_ms": None, "tls_days_remaining": None}
316
+ results.append(result)
317
+ healthy = sum(result["status"] == "healthy" for result in results)
318
+ management._audit(
319
+ username=username,
320
+ action="network.endpoint.check",
321
+ target=f"{len(results)} endpoint(s)",
322
+ outcome="success" if healthy == len(results) else "failed",
323
+ details=f"Checked {len(results)} endpoint(s): {healthy} healthy, {len(results) - healthy} unhealthy.",
324
+ )
325
+ return results
326
+
327
+
328
+ @router.get("/management/backups")
329
+ def backups(request: Request):
330
+ management.require_admin(request)
331
+ return management.backup_results()
332
+
333
+
334
+ @router.get("/management/storage/smart")
335
+ def smart_storage(request: Request):
336
+ management.require_admin(request)
337
+ return management.smart_health()
338
+
339
+
340
+ @router.put("/management/backups")
341
+ def save_backups(payload: BackupTargetsUpdate, request: Request):
342
+ username = management.require_admin(request)
343
+ try:
344
+ targets = management.save_backup_targets([target.model_dump() for target in payload.targets], username=username)
345
+ return {"targets": targets}
346
+ except ValueError as exc:
347
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
348
+
349
+
350
+ @router.get("/management/metrics/history")
351
+ def metric_history(request: Request, hours: int = Query(default=24, ge=1, le=720)):
352
+ management.require_admin(request)
353
+ return management.metric_history(hours=hours)
354
+
355
+
356
+ @router.get("/management/metrics/alerts")
357
+ def metric_alert_state(request: Request):
358
+ management.require_admin(request)
359
+ return {"thresholds": management.management_settings()["thresholds"], "alerts": management.metric_alerts()}
360
+
361
+
362
+ @router.put("/management/metrics/thresholds")
363
+ def save_metric_thresholds(payload: ThresholdUpdate, request: Request):
364
+ username = management.require_admin(request)
365
+ try:
366
+ return {"thresholds": management.save_thresholds(payload.thresholds, username=username)}
367
+ except ValueError as exc:
368
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
369
+
370
+
371
+ @router.get("/management/audit")
372
+ def audit_log(request: Request, limit: int = Query(default=200, ge=1, le=500), offset: int = Query(default=0, ge=0)):
373
+ management.require_admin(request)
374
+ return management.audit_entries(limit=limit, offset=offset)
375
+
376
+
377
+ @router.get("/management/logs")
378
+ def logs(request: Request, query: str = Query(default="", max_length=200), limit: int = Query(default=200, ge=1, le=500)):
379
+ management.require_admin(request)
380
+ return management.system_logs(query=query, limit=limit)
@@ -0,0 +1,13 @@
1
+ import platform
2
+
3
+ from .linux import LinuxAdapter
4
+ from .windows import WindowsAdapter
5
+
6
+
7
+ def get_platform_adapter():
8
+ system = platform.system()
9
+ if system == "Windows":
10
+ return WindowsAdapter()
11
+ if system == "Linux":
12
+ return LinuxAdapter()
13
+ raise RuntimeError(f"Unsupported operating system: {system}")
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+ from typing import Protocol
3
+
4
+
5
+ @dataclass
6
+ class SystemMetrics:
7
+ platform: str
8
+ hostname: str
9
+ cpu_percent: float | None
10
+ per_cpu_percent: list[float] | None
11
+ memory_total: int | None
12
+ memory_used: int | None
13
+ memory_available: int | None
14
+ swap_total: int | None
15
+ swap_used: int | None
16
+ disks: list[dict]
17
+ networks: list[dict]
18
+ uptime_seconds: float | None
19
+ load_average: list[float] | None
20
+ temperatures: list[dict] | None
21
+
22
+
23
+ class PlatformAdapter(Protocol):
24
+ def system_metrics(self) -> SystemMetrics: ...
25
+ def processes(self) -> list[dict]: ...
26
+ def capabilities(self) -> dict: ...
@@ -0,0 +1,56 @@
1
+ import platform
2
+ import time
3
+
4
+ import psutil
5
+
6
+ from .base import SystemMetrics
7
+
8
+
9
+ class LinuxAdapter:
10
+ def system_metrics(self) -> SystemMetrics:
11
+ memory = psutil.virtual_memory()
12
+ swap = psutil.swap_memory()
13
+ disks = []
14
+ for part in psutil.disk_partitions(all=False):
15
+ try:
16
+ usage = psutil.disk_usage(part.mountpoint)
17
+ disks.append({"device": part.device, "mountpoint": part.mountpoint, "filesystem": part.fstype,
18
+ "total": usage.total, "used": usage.used, "free": usage.free})
19
+ except (PermissionError, OSError):
20
+ continue
21
+ io = psutil.net_io_counters(pernic=True) or {}
22
+ networks = [{"name": name, "bytes_sent": counters.bytes_sent, "bytes_received": counters.bytes_recv}
23
+ for name, counters in io.items()]
24
+ temps = None
25
+ try:
26
+ sensor_data = psutil.sensors_temperatures()
27
+ temps = [{"sensor": name, "label": entry.label or name, "current": entry.current}
28
+ for name, entries in (sensor_data or {}).items() for entry in entries]
29
+ except (AttributeError, OSError):
30
+ pass
31
+ load = list(psutil.getloadavg()) if hasattr(psutil, "getloadavg") else None
32
+ return SystemMetrics(platform.system(), platform.node(), psutil.cpu_percent(interval=None),
33
+ psutil.cpu_percent(interval=None, percpu=True), memory.total, memory.used,
34
+ memory.available, swap.total, swap.used, disks, networks,
35
+ max(0.0, time.time() - psutil.boot_time()), load, temps)
36
+
37
+ def processes(self) -> list[dict]:
38
+ result = []
39
+ attrs = ["pid", "name", "username", "exe", "ppid", "status", "memory_info"]
40
+ for proc in psutil.process_iter(attrs=attrs, ad_value=None):
41
+ try:
42
+ data = proc.info
43
+ result.append({"pid": data["pid"], "name": data["name"], "username": data["username"],
44
+ "executable": data["exe"], "command_line": None,
45
+ "parent_pid": data["ppid"], "status": data["status"],
46
+ "memory_bytes": data["memory_info"].rss if data["memory_info"] else None,
47
+ "cpu_percent": proc.cpu_percent(interval=None)})
48
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
49
+ continue
50
+ return result
51
+
52
+ def capabilities(self) -> dict:
53
+ systemd = platform.system() == "Linux" and __import__("os").path.exists("/run/systemd/system")
54
+ return {"platform": "linux", "service_manager": "systemd" if systemd else None,
55
+ "temperature": hasattr(psutil, "sensors_temperatures"),
56
+ "load_average": hasattr(psutil, "getloadavg"), "process_management": False}
@@ -0,0 +1,54 @@
1
+ import platform
2
+ import time
3
+
4
+ import psutil
5
+
6
+ from .base import SystemMetrics
7
+
8
+
9
+ class WindowsAdapter:
10
+ def system_metrics(self) -> SystemMetrics:
11
+ memory = psutil.virtual_memory()
12
+ swap = psutil.swap_memory()
13
+ disks = []
14
+ for part in psutil.disk_partitions(all=False):
15
+ try:
16
+ usage = psutil.disk_usage(part.mountpoint)
17
+ disks.append({"device": part.device, "mountpoint": part.mountpoint, "filesystem": part.fstype,
18
+ "total": usage.total, "used": usage.used, "free": usage.free})
19
+ except (PermissionError, OSError):
20
+ continue
21
+ io = psutil.net_io_counters(pernic=True) or {}
22
+ networks = [{"name": name, "bytes_sent": counters.bytes_sent, "bytes_received": counters.bytes_recv}
23
+ for name, counters in io.items()]
24
+ temps = None
25
+ try:
26
+ sensor_data = psutil.sensors_temperatures()
27
+ temps = [{"sensor": name, "label": entry.label or name, "current": entry.current}
28
+ for name, entries in (sensor_data or {}).items() for entry in entries]
29
+ except (AttributeError, OSError):
30
+ pass
31
+ boot = psutil.boot_time()
32
+ return SystemMetrics(platform.system(), platform.node(), psutil.cpu_percent(interval=None),
33
+ psutil.cpu_percent(interval=None, percpu=True), memory.total, memory.used,
34
+ memory.available, swap.total, swap.used, disks, networks,
35
+ max(0.0, time.time() - boot), None, temps)
36
+
37
+ def processes(self) -> list[dict]:
38
+ result = []
39
+ attrs = ["pid", "name", "username", "exe", "ppid", "status", "memory_info"]
40
+ for proc in psutil.process_iter(attrs=attrs, ad_value=None):
41
+ try:
42
+ data = proc.info
43
+ result.append({"pid": data["pid"], "name": data["name"], "username": data["username"],
44
+ "executable": data["exe"], "command_line": None,
45
+ "parent_pid": data["ppid"], "status": data["status"],
46
+ "memory_bytes": data["memory_info"].rss if data["memory_info"] else None,
47
+ "cpu_percent": proc.cpu_percent(interval=None)})
48
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
49
+ continue
50
+ return result
51
+
52
+ def capabilities(self) -> dict:
53
+ return {"platform": "windows", "service_manager": "scm", "temperature": hasattr(psutil, "sensors_temperatures"),
54
+ "load_average": False, "process_management": False}
@@ -0,0 +1,7 @@
1
+ from .platform import get_platform_adapter
2
+
3
+ _adapter = get_platform_adapter()
4
+
5
+
6
+ def collect_processes():
7
+ return _adapter.processes()
@@ -0,0 +1,11 @@
1
+ from .platform import get_platform_adapter
2
+
3
+ _adapter = get_platform_adapter()
4
+
5
+
6
+ def collect_system_metrics():
7
+ return _adapter.system_metrics()
8
+
9
+
10
+ def platform_capabilities():
11
+ return _adapter.capabilities()