From d4aa3af8aded9d82c8aed0aa39240ade842433a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Sch=C3=B6nlau?= Date: Thu, 2 Apr 2026 21:41:44 +0200 Subject: [PATCH] feat(device-details): added device metrics and network interfaces --- agent/thymis_agent/agent.py | 60 ++- .../crud/test_agent_connection_extensions.py | 33 ++ .../crud/test_deployment_info_extensions.py | 42 ++ controller/tests/crud/test_device_metric.py | 118 +++++ controller/tests/routes/conftest.py | 37 ++ .../tests/routes/test_device_details_api.py | 130 ++++++ ...79a_add_network_interfaces_location_to_.py | 86 ++++ ...7e2f1a3c8d9_add_name_to_deployment_info.py | 24 ++ controller/thymis_controller/config.py | 3 + controller/thymis_controller/crud/__init__.py | 1 + .../crud/agent_connection.py | 15 + .../thymis_controller/crud/deployment_info.py | 31 ++ .../thymis_controller/crud/device_metric.py | 94 ++++ controller/thymis_controller/crud/logs.py | 3 + .../thymis_controller/database/connection.py | 13 + .../thymis_controller/db_models/__init__.py | 1 + .../db_models/deployment_info.py | 6 +- .../db_models/device_metric.py | 36 ++ controller/thymis_controller/models/device.py | 6 + controller/thymis_controller/network_relay.py | 25 ++ .../routers/api_deployment_info.py | 96 ++++- frontend/package-lock.json | 407 ++++++++---------- frontend/package.json | 4 + frontend/src/lib/deploymentInfo.ts | 101 +++++ frontend/src/locales/de.json | 29 ++ frontend/src/locales/en.json | 29 ++ .../(authenticated)/devices/+page.svelte | 18 +- .../(authenticated)/devices/[id]/+page.svelte | 156 +++++++ .../(authenticated)/devices/[id]/+page.ts | 11 + .../devices/[id]/Section.svelte | 15 + .../devices/[id]/SectionDeviceInfo.svelte | 142 ++++++ .../devices/[id]/SectionErrorLogs.svelte | 43 ++ .../devices/[id]/SectionMetrics.svelte | 96 +++++ .../devices/[id]/SectionOnlineStatus.svelte | 41 ++ 34 files changed, 1709 insertions(+), 243 deletions(-) create mode 100644 controller/tests/crud/test_agent_connection_extensions.py create mode 100644 controller/tests/crud/test_deployment_info_extensions.py create mode 100644 controller/tests/crud/test_device_metric.py create mode 100644 controller/tests/routes/conftest.py create mode 100644 controller/tests/routes/test_device_details_api.py create mode 100644 controller/thymis_controller/alembic/versions/6a32d509779a_add_network_interfaces_location_to_.py create mode 100644 controller/thymis_controller/alembic/versions/b7e2f1a3c8d9_add_name_to_deployment_info.py create mode 100644 controller/thymis_controller/crud/device_metric.py create mode 100644 controller/thymis_controller/db_models/device_metric.py create mode 100644 frontend/src/routes/(authenticated)/devices/[id]/+page.svelte create mode 100644 frontend/src/routes/(authenticated)/devices/[id]/+page.ts create mode 100644 frontend/src/routes/(authenticated)/devices/[id]/Section.svelte create mode 100644 frontend/src/routes/(authenticated)/devices/[id]/SectionDeviceInfo.svelte create mode 100644 frontend/src/routes/(authenticated)/devices/[id]/SectionErrorLogs.svelte create mode 100644 frontend/src/routes/(authenticated)/devices/[id]/SectionMetrics.svelte create mode 100644 frontend/src/routes/(authenticated)/devices/[id]/SectionOnlineStatus.svelte diff --git a/agent/thymis_agent/agent.py b/agent/thymis_agent/agent.py index 160ba2182..cefaf37f8 100644 --- a/agent/thymis_agent/agent.py +++ b/agent/thymis_agent/agent.py @@ -10,7 +10,8 @@ import subprocess import sys import uuid -from typing import Dict, List, Literal, Optional, Tuple, Union +from datetime import timezone +from typing import Any, Dict, List, Literal, Optional, Tuple, Union import http_network_relay.edge_agent import http_network_relay.edge_agent as ea @@ -167,7 +168,10 @@ def load_controller_public_key_into_root_authorized_keys(): class AgentToRelayMessage(BaseModel): # This is a custom message that the agent sends to the relay - inner: Union["EtRSwitchToNewConfigResultMessage",] = Field(discriminator="kind") + inner: Union[ + "EtRSwitchToNewConfigResultMessage", + "EtRMetricsMessage", + ] = Field(discriminator="kind") class EtRSwitchToNewConfigResultMessage(BaseModel): @@ -182,6 +186,14 @@ class EtRSwitchToNewConfigResultMessage(BaseModel): stderr: str | None = None # in v3 final +class EtRMetricsMessage(BaseModel): + kind: Literal["metrics"] = "metrics" + timestamp: datetime.datetime + cpu_percent: float + ram_percent: float + disk_percent: float + + class RelayToAgentMessage(BaseModel): # This is a custom message that the relay sends to the agent inner: Union[ @@ -226,7 +238,7 @@ class EdgeAgentToRelayStartMessage(ea.EtRStartMessage): hardware_ids: Dict[str, str] public_key: str deployed_config_id: str - ip_addresses: List[str] + network_interfaces: List[Dict[str, Any]] last_error: Optional[str] = None @@ -665,6 +677,7 @@ def place_secrets_on_start(self, token: str): async def on_connected(self): self.systemd_notifier.status("Connected to relay") + asyncio.create_task(self.collect_and_send_metrics()) async def create_start_message(self, last_error: Optional[str] = None): return EdgeAgentToRelayStartMessage( @@ -672,7 +685,7 @@ async def create_start_message(self, last_error: Optional[str] = None): hardware_ids=self.detect_hardware_id(), public_key=self.detect_public_key(), deployed_config_id=self.detect_system_config()[0], - ip_addresses=self.detect_ip_addresses(), + network_interfaces=self.detect_network_interfaces(), last_error=last_error, ) @@ -681,6 +694,24 @@ async def on_connection_closed(self): self.systemd_notifier.status("Connection closed, reconnecting...") await super().on_connection_closed() + async def collect_and_send_metrics(self): + """Collect system metrics every 60s and send to controller.""" + while True: + try: + msg = AgentToRelayMessage( + inner=EtRMetricsMessage( + timestamp=datetime.datetime.now(timezone.utc), + cpu_percent=psutil.cpu_percent(interval=1), + ram_percent=psutil.virtual_memory().percent, + disk_percent=psutil.disk_usage("/").percent, + ) + ) + if self.websocket and not self.websocket.closed: + await self.websocket.send(msg.model_dump_json()) + except Exception as e: + logger.error("Failed to collect/send metrics: %s", e) + await asyncio.sleep(60) + def update_config_commit(self, new_commit: str): self.agent_metadata["configuration_commit"] = new_commit metadata_path = find_file(AGENT_DATA_PATHS, AGENT_METADATA_FILENAME) @@ -742,6 +773,27 @@ def extract_file_content(path): } return {key: value for key, value in hardware_ids.items() if value} + def detect_network_interfaces(self): + interfaces = {} + for interface, snics in psutil.net_if_addrs().items(): + if interface == "lo": + continue + if interface not in interfaces: + interfaces[interface] = { + "interface": interface, + "ipv4_addresses": [], + "ipv6_addresses": [], + "mac_address": None, + } + for snic in snics: + if snic.family == socket.AF_INET: + interfaces[interface]["ipv4_addresses"].append(snic.address) + elif snic.family == socket.AF_INET6: + interfaces[interface]["ipv6_addresses"].append(snic.address) + elif snic.family == psutil.AF_LINK: + interfaces[interface]["mac_address"] = snic.address + return list(interfaces.values()) + def detect_ip_addresses(self): def get_ip_addresses(family): for interface, snics in psutil.net_if_addrs().items(): diff --git a/controller/tests/crud/test_agent_connection_extensions.py b/controller/tests/crud/test_agent_connection_extensions.py new file mode 100644 index 000000000..eff18a014 --- /dev/null +++ b/controller/tests/crud/test_agent_connection_extensions.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime, timezone + +from thymis_controller import db_models +from thymis_controller.crud import agent_connection as crud + + +def _make_di(db_session): + di = db_models.DeploymentInfo( + ssh_public_key=f"ssh-ed25519 AAAA{uuid.uuid4().hex}", + deployed_config_id="cfg", + ) + db_session.add(di) + db_session.commit() + db_session.refresh(di) + return di + + +def test_get_by_deployment_info_returns_last_10(db_session): + di = _make_di(db_session) + # Create 15 connections + for i in range(15): + conn = db_models.AgentConnection( + deployment_info_id=di.id, + connected_at=datetime(2026, 1, i + 1, tzinfo=timezone.utc), + ) + db_session.add(conn) + db_session.commit() + + results = crud.get_by_deployment_info(db_session, di.id) + assert len(results) == 10 + # Most recent first + assert results[0].connected_at > results[-1].connected_at diff --git a/controller/tests/crud/test_deployment_info_extensions.py b/controller/tests/crud/test_deployment_info_extensions.py new file mode 100644 index 000000000..2b235ce85 --- /dev/null +++ b/controller/tests/crud/test_deployment_info_extensions.py @@ -0,0 +1,42 @@ +import uuid + +from thymis_controller import db_models +from thymis_controller.crud import deployment_info as crud + + +def _make_di(db_session): + di = db_models.DeploymentInfo( + ssh_public_key=f"ssh-ed25519 AAAA{uuid.uuid4().hex}", + deployed_config_id="cfg", + ) + db_session.add(di) + db_session.commit() + db_session.refresh(di) + return di + + +def test_update_location(db_session): + di = _make_di(db_session) + updated = crud.update_location(db_session, di.id, "Server Room A") + assert updated.location == "Server Room A" + + +def test_update_location_to_none(db_session): + di = _make_di(db_session) + crud.update_location(db_session, di.id, "Old Location") + updated = crud.update_location(db_session, di.id, None) + assert updated.location is None + + +def test_update_stores_network_interfaces(db_session): + di = _make_di(db_session) + ifaces = [ + { + "interface": "eth0", + "ipv4_addresses": ["192.168.1.1"], + "ipv6_addresses": [], + "mac_address": "aa:bb:cc:dd:ee:ff", + } + ] + updated = crud.update(db_session, di.id, network_interfaces=ifaces) + assert updated.network_interfaces == ifaces diff --git a/controller/tests/crud/test_device_metric.py b/controller/tests/crud/test_device_metric.py new file mode 100644 index 000000000..bbdcaa543 --- /dev/null +++ b/controller/tests/crud/test_device_metric.py @@ -0,0 +1,118 @@ +import uuid +from datetime import datetime, timedelta, timezone + +from thymis_controller import db_models +from thymis_controller.crud import device_metric as crud + + +def _make_deployment_info(db_session): + di = db_models.DeploymentInfo( + ssh_public_key=f"ssh-ed25519 AAAA{uuid.uuid4().hex}", + deployed_config_id="test-config", + ) + db_session.add(di) + db_session.commit() + db_session.refresh(di) + return di + + +def test_device_metric_model_exists(): + """DeviceMetric model has expected columns.""" + metric = db_models.DeviceMetric( + deployment_info_id=uuid.uuid4(), + timestamp=datetime.now(timezone.utc), + cpu_percent=42.5, + ram_percent=67.3, + disk_percent=10.1, + ) + assert metric.cpu_percent == 42.5 + assert metric.ram_percent == 67.3 + assert metric.disk_percent == 10.1 + + +def test_create_metric(db_session): + di = _make_deployment_info(db_session) + now = datetime.now(timezone.utc) + metric = crud.create_metric(db_session, di.id, 50.0, 70.0, 30.0, now) + assert metric.id is not None + assert metric.cpu_percent == 50.0 + assert metric.ram_percent == 70.0 + assert metric.disk_percent == 30.0 + + +def test_get_metrics_downsampled_returns_averaged_buckets(db_session): + di = _make_deployment_info(db_session) + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + # Two entries in same 1h bucket + crud.create_metric(db_session, di.id, 40.0, 60.0, 20.0, base) + crud.create_metric( + db_session, di.id, 60.0, 80.0, 40.0, base + timedelta(minutes=30) + ) + + results = crud.get_metrics_downsampled( + db_session, + di.id, + from_datetime=base - timedelta(minutes=1), + to_datetime=base + timedelta(hours=1), + granularity="1h", + ) + assert len(results) == 1 + assert abs(results[0]["cpu_percent"] - 50.0) < 0.01 # average of 40 and 60 + + +def test_get_metrics_downsampled_1min_granularity(db_session): + di = _make_deployment_info(db_session) + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + crud.create_metric(db_session, di.id, 30.0, 50.0, 10.0, base) + crud.create_metric( + db_session, di.id, 50.0, 70.0, 30.0, base + timedelta(seconds=30) + ) + + results = crud.get_metrics_downsampled( + db_session, + di.id, + from_datetime=base - timedelta(seconds=1), + to_datetime=base + timedelta(minutes=1), + granularity="1min", + ) + assert len(results) == 1 + assert abs(results[0]["cpu_percent"] - 40.0) < 0.01 # average of 30 and 50 + + +def test_get_metrics_downsampled_15min_granularity(db_session): + di = _make_deployment_info(db_session) + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + # Both entries fall in the same 15-minute bucket (12:00) + crud.create_metric(db_session, di.id, 20.0, 40.0, 10.0, base) + crud.create_metric(db_session, di.id, 40.0, 60.0, 30.0, base + timedelta(minutes=7)) + + results = crud.get_metrics_downsampled( + db_session, + di.id, + from_datetime=base - timedelta(seconds=1), + to_datetime=base + timedelta(minutes=15), + granularity="15min", + ) + assert len(results) == 1 + assert abs(results[0]["cpu_percent"] - 30.0) < 0.01 # average of 20 and 40 + + +def test_delete_expired_metrics(db_session): + di = _make_deployment_info(db_session) + old = datetime(2025, 1, 1, tzinfo=timezone.utc) + recent = datetime.now(timezone.utc) + crud.create_metric(db_session, di.id, 1.0, 1.0, 1.0, old) + crud.create_metric(db_session, di.id, 2.0, 2.0, 2.0, recent) + + cutoff = datetime(2026, 1, 1, tzinfo=timezone.utc) + deleted = crud.delete_expired_metrics(db_session, cutoff) + assert deleted == 1 + + remaining = crud.get_metrics_downsampled( + db_session, + di.id, + from_datetime=datetime(2020, 1, 1, tzinfo=timezone.utc), + to_datetime=datetime(2030, 1, 1, tzinfo=timezone.utc), + granularity="1h", + ) + assert len(remaining) == 1 diff --git a/controller/tests/routes/conftest.py b/controller/tests/routes/conftest.py new file mode 100644 index 000000000..ce764ba06 --- /dev/null +++ b/controller/tests/routes/conftest.py @@ -0,0 +1,37 @@ +""" +Route-level conftest: provides a working test_client without needing +the Project fixture (which requires a real Engine, not a Session). +The new device-details endpoints don't use ProjectAD, so we can +override get_project with a simple None-returning mock. +""" +import pytest +from fastapi.testclient import TestClient +from thymis_controller.dependencies import ( + get_db_session, + get_project, + require_valid_user_session, +) + + +@pytest.fixture(scope="function") +def test_client(db_session) -> TestClient: + """Test client that overrides DB session and stubs out auth/project.""" + from thymis_controller.main import app + + def override_get_db(): + try: + yield db_session + finally: + pass + + def override_get_project(): + return None + + def override_authenticate(): + return True + + app.dependency_overrides[get_db_session] = override_get_db + app.dependency_overrides[get_project] = override_get_project + app.dependency_overrides[require_valid_user_session] = override_authenticate + yield TestClient(app) + app.dependency_overrides.clear() diff --git a/controller/tests/routes/test_device_details_api.py b/controller/tests/routes/test_device_details_api.py new file mode 100644 index 000000000..367888b38 --- /dev/null +++ b/controller/tests/routes/test_device_details_api.py @@ -0,0 +1,130 @@ +import uuid +from datetime import datetime, timezone + +from thymis_controller import db_models +from thymis_controller.crud import device_metric as crud_metric + + +def _make_di(db_session, location=None, network_interfaces=None): + di = db_models.DeploymentInfo( + ssh_public_key=f"ssh-ed25519 AAAA{uuid.uuid4().hex}", + deployed_config_id="cfg", + location=location, + network_interfaces=network_interfaces, + ) + db_session.add(di) + db_session.commit() + db_session.refresh(di) + return di + + +def test_get_connection_history(test_client, db_session): + di = _make_di(db_session) + # Connection without a disconnected_at (still active) + conn_open = db_models.AgentConnection( + deployment_info_id=di.id, + connected_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + # Connection with both timestamps so duration_seconds can be computed + conn_closed = db_models.AgentConnection( + deployment_info_id=di.id, + connected_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + disconnected_at=datetime(2026, 1, 2, 1, 0, 0, tzinfo=timezone.utc), + ) + db_session.add(conn_open) + db_session.add(conn_closed) + db_session.commit() + + response = test_client.get(f"/api/deployment_info/{di.id}/connection_history") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert "connected_at" in data[0] + + # Find the closed connection entry and verify duration_seconds is populated + closed_entries = [e for e in data if e.get("disconnected_at") is not None] + assert len(closed_entries) == 1 + assert closed_entries[0]["duration_seconds"] == 3600.0 + + # The open connection should have duration_seconds == None + open_entries = [e for e in data if e.get("disconnected_at") is None] + assert len(open_entries) == 1 + assert open_entries[0]["duration_seconds"] is None + + +def test_get_metrics(test_client, db_session): + di = _make_di(db_session) + now = datetime.now(timezone.utc) + crud_metric.create_metric(db_session, di.id, 50.0, 60.0, 30.0, now) + + response = test_client.get( + f"/api/deployment_info/{di.id}/metrics?hours=1&granularity=1min" + ) + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert "cpu_percent" in data[0] + + +def test_update_location(test_client, db_session): + di = _make_di(db_session) + response = test_client.put( + f"/api/deployment_info/{di.id}/location", + json={"location": "Server Room B"}, + ) + assert response.status_code == 200 + assert response.json()["location"] == "Server Room B" + + +def test_update_location_to_null(test_client, db_session): + di = _make_di(db_session, location="Old Location") + response = test_client.put( + f"/api/deployment_info/{di.id}/location", + json={"location": None}, + ) + assert response.status_code == 200 + assert response.json()["location"] is None + + +def test_get_error_logs(test_client, db_session): + di = _make_di(db_session) + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + + # severity=3 (Error) — should be returned by the max_severity=3 filter + error_entry = db_models.LogEntry( + id=uuid.uuid4(), + timestamp=now, + message="An error occurred", + hostname="testhost", + facility=3, + severity=3, + programname="testprog", + syslogtag="testprog[123]:", + deployment_info_id=di.id, + ssh_public_key=di.ssh_public_key, + ) + # severity=4 (Warning) — must be excluded because 4 > max_severity=3 + warning_entry = db_models.LogEntry( + id=uuid.uuid4(), + timestamp=now, + message="A warning occurred", + hostname="testhost", + facility=3, + severity=4, + programname="testprog", + syslogtag="testprog[123]:", + deployment_info_id=di.id, + ssh_public_key=di.ssh_public_key, + ) + db_session.add(error_entry) + db_session.add(warning_entry) + db_session.commit() + + response = test_client.get(f"/api/deployment_info/{di.id}/error_logs") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Only the Error entry (severity=3) should be returned; Warning (severity=4) excluded + assert len(data) == 1 + assert data[0]["message"] == "An error occurred" + assert data[0]["severity"] == 3 diff --git a/controller/thymis_controller/alembic/versions/6a32d509779a_add_network_interfaces_location_to_.py b/controller/thymis_controller/alembic/versions/6a32d509779a_add_network_interfaces_location_to_.py new file mode 100644 index 000000000..5abcf0e78 --- /dev/null +++ b/controller/thymis_controller/alembic/versions/6a32d509779a_add_network_interfaces_location_to_.py @@ -0,0 +1,86 @@ +"""add_network_interfaces_location_to_deployment_info + +Revision ID: 6a32d509779a +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-01 12:50:53.734067 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "6a32d509779a" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "device_metrics", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("deployment_info_id", sa.Uuid(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("cpu_percent", sa.Float(), nullable=False), + sa.Column("ram_percent", sa.Float(), nullable=False), + sa.Column("disk_percent", sa.Float(), nullable=False), + sa.ForeignKeyConstraint( + ["deployment_info_id"], + ["deployment_info.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("device_metrics", schema=None) as batch_op: + batch_op.create_index( + "ix_device_metrics_deployment_timestamp", + ["deployment_info_id", "timestamp"], + unique=False, + ) + + with op.batch_alter_table("controller_settings", schema=None) as batch_op: + batch_op.alter_column( + "auto_update_schedule", + existing_type=sa.VARCHAR(length=255), + type_=sa.String(length=512), + existing_nullable=False, + existing_server_default=sa.text("'daily'"), + ) + + with op.batch_alter_table("deployment_info", schema=None) as batch_op: + batch_op.add_column(sa.Column("network_interfaces", sa.JSON(), nullable=True)) + batch_op.add_column(sa.Column("location", sa.Text(), nullable=True)) + + with op.batch_alter_table("secrets", schema=None) as batch_op: + batch_op.alter_column( + "id", existing_type=sa.NUMERIC(), type_=sa.UUID(), existing_nullable=False + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("secrets", schema=None) as batch_op: + batch_op.alter_column( + "id", existing_type=sa.UUID(), type_=sa.NUMERIC(), existing_nullable=False + ) + + with op.batch_alter_table("deployment_info", schema=None) as batch_op: + batch_op.drop_column("location") + batch_op.drop_column("network_interfaces") + + with op.batch_alter_table("controller_settings", schema=None) as batch_op: + batch_op.alter_column( + "auto_update_schedule", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=255), + existing_nullable=False, + existing_server_default=sa.text("'daily'"), + ) + + with op.batch_alter_table("device_metrics", schema=None) as batch_op: + batch_op.drop_index("ix_device_metrics_deployment_timestamp") + + op.drop_table("device_metrics") + # ### end Alembic commands ### diff --git a/controller/thymis_controller/alembic/versions/b7e2f1a3c8d9_add_name_to_deployment_info.py b/controller/thymis_controller/alembic/versions/b7e2f1a3c8d9_add_name_to_deployment_info.py new file mode 100644 index 000000000..e07788bc9 --- /dev/null +++ b/controller/thymis_controller/alembic/versions/b7e2f1a3c8d9_add_name_to_deployment_info.py @@ -0,0 +1,24 @@ +"""add_name_to_deployment_info + +Revision ID: b7e2f1a3c8d9 +Revises: 6a32d509779a +Create Date: 2026-04-01 22:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +revision = "b7e2f1a3c8d9" +down_revision = "6a32d509779a" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("deployment_info", schema=None) as batch_op: + batch_op.add_column(sa.Column("name", sa.Text(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("deployment_info", schema=None) as batch_op: + batch_op.drop_column("name") diff --git a/controller/thymis_controller/config.py b/controller/thymis_controller/config.py index 07324c206..184072f0d 100644 --- a/controller/thymis_controller/config.py +++ b/controller/thymis_controller/config.py @@ -27,6 +27,9 @@ class GlobalSettings(BaseSettings): LOG_RETENTION_DAYS: int = 7 LOG_CLEANUP_INTERVAL_SECONDS: int = 60 * 60 # 1 hour + METRICS_RETENTION_DAYS: int = 30 + METRICS_CLEANUP_INTERVAL_SECONDS: int = 60 * 60 * 24 # 24 hours + model_config = ConfigDict( env_prefix="THYMIS_", env_file=".env", env_file_encoding="utf-8" ) diff --git a/controller/thymis_controller/crud/__init__.py b/controller/thymis_controller/crud/__init__.py index 83f3c2d82..a0a1e7e88 100644 --- a/controller/thymis_controller/crud/__init__.py +++ b/controller/thymis_controller/crud/__init__.py @@ -4,6 +4,7 @@ check_systemd_timer, controller_settings, deployment_info, + device_metric, hardware_device, logs, secrets, diff --git a/controller/thymis_controller/crud/agent_connection.py b/controller/thymis_controller/crud/agent_connection.py index 9443bbae4..170ac9282 100644 --- a/controller/thymis_controller/crud/agent_connection.py +++ b/controller/thymis_controller/crud/agent_connection.py @@ -133,3 +133,18 @@ def get_max_concurrent_connections( ) for row in result ] + + +def get_by_deployment_info( + db_session: Session, + deployment_info_id: uuid.UUID, + limit: int = 10, +) -> list[db_models.AgentConnection]: + """Return the most recent N connections for a device.""" + return ( + db_session.query(db_models.AgentConnection) + .filter(db_models.AgentConnection.deployment_info_id == deployment_info_id) + .order_by(db_models.AgentConnection.connected_at.desc()) + .limit(limit) + .all() + ) diff --git a/controller/thymis_controller/crud/deployment_info.py b/controller/thymis_controller/crud/deployment_info.py index 03324dd3b..c549f7439 100644 --- a/controller/thymis_controller/crud/deployment_info.py +++ b/controller/thymis_controller/crud/deployment_info.py @@ -40,6 +40,7 @@ def update( deployed_config_id: str | None = None, reachable_deployed_host: str | None = None, last_seen: str | None = None, + network_interfaces: list | None = None, ) -> db_models.DeploymentInfo: deployment_info = ( session.query(db_models.DeploymentInfo) @@ -58,6 +59,8 @@ def update( deployment_info.last_seen = last_seen if deployment_info.first_seen is None: deployment_info.first_seen = last_seen + if network_interfaces is not None: + deployment_info.network_interfaces = network_interfaces session.commit() session.refresh(deployment_info) return deployment_info @@ -182,6 +185,34 @@ def get_connected_deployment_infos(db_session: Session, network_relay: "NetworkR ] +def update_location( + session: Session, + deployment_info_id: uuid.UUID, + location: str | None, +) -> db_models.DeploymentInfo | None: + deployment_info = session.get(db_models.DeploymentInfo, deployment_info_id) + if deployment_info is None: + return None + deployment_info.location = location + session.commit() + session.refresh(deployment_info) + return deployment_info + + +def update_name( + session: Session, + deployment_info_id: uuid.UUID, + name: str | None, +) -> db_models.DeploymentInfo | None: + deployment_info = session.get(db_models.DeploymentInfo, deployment_info_id) + if deployment_info is None: + return None + deployment_info.name = name + session.commit() + session.refresh(deployment_info) + return deployment_info + + if "RUNNING_IN_PLAYWRIGHT" in os.environ: def delete_all(session: Session): diff --git a/controller/thymis_controller/crud/device_metric.py b/controller/thymis_controller/crud/device_metric.py new file mode 100644 index 000000000..8be09d004 --- /dev/null +++ b/controller/thymis_controller/crud/device_metric.py @@ -0,0 +1,94 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import Integer, func, literal +from sqlalchemy.orm import Session +from thymis_controller import db_models + + +def _bucket_expr(granularity: str): + """Return a SQLite strftime expression grouping timestamps into buckets.""" + ts = db_models.DeviceMetric.timestamp + if granularity == "1min": + return func.strftime("%Y-%m-%d %H:%M", ts) + elif granularity == "15min": + minute_bucket = ( + func.cast(func.strftime("%M", ts), Integer) + .op("/")(literal(15, Integer)) + .op("*")(literal(15, Integer)) + ) + return func.strftime("%Y-%m-%d %H:", ts).op("||")( + func.printf("%02d", minute_bucket) + ) + elif granularity == "1h": + return func.strftime("%Y-%m-%d %H", ts) + else: + raise ValueError(f"Unknown granularity: {granularity!r}") + + +def create_metric( + db_session: Session, + deployment_info_id: UUID, + cpu_percent: float, + ram_percent: float, + disk_percent: float, + timestamp: datetime, +) -> db_models.DeviceMetric: + metric = db_models.DeviceMetric( + deployment_info_id=deployment_info_id, + cpu_percent=cpu_percent, + ram_percent=ram_percent, + disk_percent=disk_percent, + timestamp=timestamp, + ) + db_session.add(metric) + db_session.commit() + db_session.refresh(metric) + return metric + + +def get_metrics_downsampled( + db_session: Session, + deployment_info_id: UUID, + from_datetime: datetime, + to_datetime: datetime, + granularity: str, # "1min" | "15min" | "1h" +) -> list[dict]: + """Return averaged metrics grouped by time bucket.""" + bucket = _bucket_expr(granularity) + rows = ( + db_session.query( + bucket.label("bucket"), + func.avg(db_models.DeviceMetric.cpu_percent).label("cpu_percent"), + func.avg(db_models.DeviceMetric.ram_percent).label("ram_percent"), + func.avg(db_models.DeviceMetric.disk_percent).label("disk_percent"), + ) + .filter( + db_models.DeviceMetric.deployment_info_id == deployment_info_id, + db_models.DeviceMetric.timestamp >= from_datetime, + db_models.DeviceMetric.timestamp <= to_datetime, + ) + .group_by(bucket) + .order_by(bucket.asc()) + .all() + ) + return [ + { + "timestamp": row.bucket, + "cpu_percent": row.cpu_percent, + "ram_percent": row.ram_percent, + "disk_percent": row.disk_percent, + } + for row in rows + ] + + +def delete_expired_metrics(db_session: Session, cutoff_date: datetime) -> int: + """Delete metrics older than cutoff_date. Returns number of deleted rows.""" + deleted = ( + db_session.query(db_models.DeviceMetric) + .filter(db_models.DeviceMetric.timestamp < cutoff_date) + .delete() + ) + db_session.commit() + return deleted diff --git a/controller/thymis_controller/crud/logs.py b/controller/thymis_controller/crud/logs.py index 420a36b78..117c620c5 100644 --- a/controller/thymis_controller/crud/logs.py +++ b/controller/thymis_controller/crud/logs.py @@ -72,6 +72,7 @@ def get_logs( exact_program_name: bool = False, limit: int = 100, offset: int = 0, + max_severity: int | None = None, # syslog severity (0=Emergency, 3=Error, 7=Debug) ) -> models.LogList: # where ID equals or ssh public key equals @@ -91,6 +92,8 @@ def get_logs( stmt = stmt.filter(db_models.LogEntry.programname == program_name) else: stmt = stmt.filter(db_models.LogEntry.programname.contains(program_name)) + if max_severity is not None: + stmt = stmt.filter(db_models.LogEntry.severity <= max_severity) stmt = stmt.order_by(nullslast(db_models.LogEntry.timestamp.desc())) stmt = stmt.limit(limit).offset(offset) diff --git a/controller/thymis_controller/database/connection.py b/controller/thymis_controller/database/connection.py index cd42b4bdb..216b51cba 100644 --- a/controller/thymis_controller/database/connection.py +++ b/controller/thymis_controller/database/connection.py @@ -62,10 +62,22 @@ def compact_database(db_session: Session): logger.info("Vacuum and WAL checkpoint completed") +def _delete_expired_metrics(session: Session) -> None: + from datetime import datetime, timedelta, timezone + + cutoff = datetime.now(timezone.utc) - timedelta( + days=global_settings.METRICS_RETENTION_DAYS + ) + deleted = crud.device_metric.delete_expired_metrics(session, cutoff) + if deleted: + logger.info("Deleted %d expired device metrics", deleted) + + async def initialize_cleanup(db_engine: Engine): with Session(db_engine) as session: enable_auto_vacuum(session) await crud.logs.remove_expired_logs(session) + _delete_expired_metrics(session) compact_database(session) # Also do initial image cleanup await images.periodic_image_cleanup() @@ -78,5 +90,6 @@ async def periodic_cleanup_loop(db_engine: Engine): await asyncio.sleep(global_settings.LOG_CLEANUP_INTERVAL_SECONDS) with Session(db_engine) as session: await crud.logs.remove_expired_logs(session) + _delete_expired_metrics(session) # Clean up old images periodically await images.periodic_image_cleanup() diff --git a/controller/thymis_controller/db_models/__init__.py b/controller/thymis_controller/db_models/__init__.py index 923a8aa19..3cde7aab2 100644 --- a/controller/thymis_controller/db_models/__init__.py +++ b/controller/thymis_controller/db_models/__init__.py @@ -2,6 +2,7 @@ from .agent_token import AccessClientToken, AgentToken from .controller_settings import ControllerSettings from .deployment_info import DeploymentInfo +from .device_metric import DeviceMetric from .hardware_device import HardwareDevice from .logs import LogEntry from .secrets import * diff --git a/controller/thymis_controller/db_models/deployment_info.py b/controller/thymis_controller/db_models/deployment_info.py index b16d96b9f..d549d8774 100644 --- a/controller/thymis_controller/db_models/deployment_info.py +++ b/controller/thymis_controller/db_models/deployment_info.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import Uuid +from sqlalchemy import JSON, Text, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from thymis_controller.database.base import Base @@ -34,3 +34,7 @@ class DeploymentInfo(Base): last_seen: Mapped[datetime] = mapped_column(nullable=True) first_seen: Mapped[Optional[datetime]] = mapped_column(nullable=True) + + network_interfaces: Mapped[list] = mapped_column(JSON, nullable=True, default=None) + location: Mapped[str] = mapped_column(Text, nullable=True, default=None) + name: Mapped[str] = mapped_column(Text, nullable=True, default=None) diff --git a/controller/thymis_controller/db_models/device_metric.py b/controller/thymis_controller/db_models/device_metric.py new file mode 100644 index 000000000..d4219b9b6 --- /dev/null +++ b/controller/thymis_controller/db_models/device_metric.py @@ -0,0 +1,36 @@ +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Index, Uuid +from sqlalchemy.orm import Mapped, mapped_column, relationship +from thymis_controller.database.base import Base + +if TYPE_CHECKING: + from thymis_controller.db_models.deployment_info import DeploymentInfo + + +class DeviceMetric(Base): + __tablename__ = "device_metrics" + + id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + deployment_info_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("deployment_info.id"), nullable=False + ) + deployment_info: Mapped["DeploymentInfo"] = relationship(lazy=True) + timestamp: Mapped[datetime] = mapped_column( + nullable=False, default=lambda: datetime.now(timezone.utc) + ) + cpu_percent: Mapped[float] = mapped_column(nullable=False) + ram_percent: Mapped[float] = mapped_column(nullable=False) + disk_percent: Mapped[float] = mapped_column(nullable=False) + + __table_args__ = ( + Index( + "ix_device_metrics_deployment_timestamp", + "deployment_info_id", + "timestamp", + ), + ) diff --git a/controller/thymis_controller/models/device.py b/controller/thymis_controller/models/device.py index 3f9994280..b6b329423 100644 --- a/controller/thymis_controller/models/device.py +++ b/controller/thymis_controller/models/device.py @@ -48,6 +48,9 @@ class DeploymentInfo(BaseModel): last_seen: Optional[datetime] first_seen: Optional[datetime] hardware_devices: List["HardwareDevice"] + network_interfaces: list[dict] | None = None + location: str | None = None + name: str | None = None @field_serializer("last_seen", "first_seen") def _ser_dt(self, dt: datetime | None) -> str | None: @@ -73,6 +76,9 @@ def from_deployment_info( hardware_devices=deployment_info.hardware_devices, last_seen=deployment_info.last_seen, first_seen=deployment_info.first_seen, + network_interfaces=deployment_info.network_interfaces, + location=deployment_info.location, + name=deployment_info.name, ) diff --git a/controller/thymis_controller/network_relay.py b/controller/thymis_controller/network_relay.py index d8fc1d9f7..fcee20695 100644 --- a/controller/thymis_controller/network_relay.py +++ b/controller/thymis_controller/network_relay.py @@ -12,6 +12,7 @@ import thymis_controller.crud.agent_connection as crud_agent_connection import thymis_controller.crud.agent_token as crud_agent_token import thymis_controller.crud.deployment_info as crud_deployment_info +import thymis_controller.crud.device_metric as crud_device_metric import thymis_controller.crud.hardware_device as crud_hardware_device import thymis_controller.models.task as models_task from fastapi import WebSocket @@ -136,6 +137,22 @@ async def handle_custom_agent_message( ) ), ) + case agent.EtRMetricsMessage(): + inner = message.inner + with sqlalchemy.orm.Session(self.db_engine) as db_session: + deployment_infos = crud_deployment_info.get_by_ssh_public_key( + db_session, + self.connection_id_to_public_key[connection_id], + ) + if deployment_infos: + crud_device_metric.create_metric( + db_session, + deployment_infos[0].id, + cpu_percent=inner.cpu_percent, + ram_percent=inner.ram_percent, + disk_percent=inner.disk_percent, + timestamp=inner.timestamp, + ) case _: assert_never(message.inner) @@ -341,6 +358,14 @@ async def accept_ws_and_start_msg_loop_for_edge_agents( None, ) + deployment_info = crud_deployment_info.update( + db_session, + deployment_info.id, + network_interfaces=self.connection_id_to_start_message[ + connection_id + ].network_interfaces, + ) + deployment_info_id = deployment_info.id crud_agent_connection.create( diff --git a/controller/thymis_controller/routers/api_deployment_info.py b/controller/thymis_controller/routers/api_deployment_info.py index cb538f561..ac7538838 100644 --- a/controller/thymis_controller/routers/api_deployment_info.py +++ b/controller/thymis_controller/routers/api_deployment_info.py @@ -1,9 +1,15 @@ import logging import os import uuid - -from fastapi import APIRouter, HTTPException -from thymis_controller import crud, models +from datetime import datetime, timedelta, timezone +from enum import Enum + +import thymis_controller.crud.agent_connection as crud_agent_connection +import thymis_controller.crud.device_metric as crud_device_metric +import thymis_controller.crud.logs as crud_logs +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel as PydanticBaseModel +from thymis_controller import crud, db_models, models from thymis_controller.dependencies import DBSessionAD, NetworkRelayAD, ProjectAD from thymis_controller.models import device @@ -144,3 +150,87 @@ def get_hardware_devices(db_session: DBSessionAD): Get all hardware devices """ return crud.hardware_device.get_all(db_session) + + +class MetricGranularity(str, Enum): + one_min = "1min" + fifteen_min = "15min" + one_hour = "1h" + + +class LocationUpdate(PydanticBaseModel): + location: str | None + + +class NameUpdate(PydanticBaseModel): + name: str | None + + +@router.put("/deployment_info/{id}/name", response_model=models.DeploymentInfo) +def update_device_name( + id: uuid.UUID, body: NameUpdate, db_session: DBSessionAD +) -> models.DeploymentInfo: + """Update the user-provided name for a device.""" + result = crud.deployment_info.update_name(db_session, id, body.name) + if result is None: + raise HTTPException(status_code=404, detail="Device not found") + return models.DeploymentInfo.from_deployment_info(result) + + +@router.get("/deployment_info/{id}/connection_history") +def get_connection_history(id: uuid.UUID, db_session: DBSessionAD) -> list: + """Get the last 10 connection events for a device.""" + connections = crud_agent_connection.get_by_deployment_info(db_session, id) + return [ + { + "connected_at": conn.connected_at, + "disconnected_at": conn.disconnected_at, + "duration_seconds": ( + (conn.disconnected_at - conn.connected_at).total_seconds() + if conn.disconnected_at + else None + ), + } + for conn in connections + ] + + +@router.get("/deployment_info/{id}/metrics") +def get_device_metrics( + id: uuid.UUID, + db_session: DBSessionAD, + hours: int = Query(default=168, ge=1, le=168), + granularity: MetricGranularity = Query(default=MetricGranularity.one_hour), +) -> list: + """Get downsampled device metrics for the past N hours.""" + now = datetime.now(timezone.utc) + from_time = now - timedelta(hours=hours) + return crud_device_metric.get_metrics_downsampled( + db_session, id, from_time, now, granularity.value + ) + + +@router.put("/deployment_info/{id}/location", response_model=models.DeploymentInfo) +def update_device_location( + id: uuid.UUID, body: LocationUpdate, db_session: DBSessionAD +) -> models.DeploymentInfo: + """Update the user-provided location label for a device.""" + result = crud.deployment_info.update_location(db_session, id, body.location) + if result is None: + raise HTTPException(status_code=404, detail="Device not found") + return models.DeploymentInfo.from_deployment_info(result) + + +@router.get("/deployment_info/{id}/error_logs") +def get_error_logs(id: uuid.UUID, db_session: DBSessionAD) -> list: + """Get the last 50 Error/Critical log entries for a device.""" + deployment_info = db_session.get(db_models.DeploymentInfo, id) + if deployment_info is None: + raise HTTPException(status_code=404, detail="Device not found") + log_list = crud_logs.get_logs( + db_session, + deployment_info, + limit=50, + max_severity=3, # syslog: 0=Emergency, 1=Alert, 2=Critical, 3=Error + ) + return [log.model_dump() for log in log_list.logs] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46899711f..840cb9f4d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "thymis-frontend", "version": "0.7.2-dev", + "dependencies": { + "chart.js": "^4.5.1", + "svelte-chartjs": "^4.0.1" + }, "devDependencies": { "@novnc/novnc": "npm:@elikoga/novnc@^1.6.0", "@playwright/test": "1.52.0", @@ -634,14 +638,14 @@ "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", - "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", + "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", "dev": true, "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.2", + "@formatjs/intl-localematcher": "0.6.1", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } @@ -657,32 +661,32 @@ } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", - "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", + "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", "dev": true, "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/icu-skeleton-parser": "1.8.16", + "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/icu-skeleton-parser": "1.8.14", "tslib": "^2.8.0" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.16", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", - "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "version": "1.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", + "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", "dev": true, "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/ecma402-abstract": "2.3.4", "tslib": "^2.8.0" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", - "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", + "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", "dev": true, "license": "MIT", "dependencies": { @@ -759,7 +763,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -770,7 +773,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -781,7 +783,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -791,20 +792,23 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "node_modules/@novnc/novnc": { "name": "@elikoga/novnc", "version": "1.6.0", @@ -958,9 +962,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", + "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", "cpu": [ "arm" ], @@ -972,9 +976,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", + "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", "cpu": [ "arm64" ], @@ -986,9 +990,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", + "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", "cpu": [ "arm64" ], @@ -1000,9 +1004,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", + "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", "cpu": [ "x64" ], @@ -1014,9 +1018,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", + "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", "cpu": [ "arm64" ], @@ -1028,9 +1032,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", + "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", "cpu": [ "x64" ], @@ -1042,9 +1046,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", + "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", "cpu": [ "arm" ], @@ -1056,9 +1060,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", + "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", "cpu": [ "arm" ], @@ -1070,9 +1074,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", + "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", "cpu": [ "arm64" ], @@ -1084,9 +1088,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", + "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", "cpu": [ "arm64" ], @@ -1125,6 +1129,34 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", + "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", + "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", @@ -1154,9 +1186,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", + "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", "cpu": [ "riscv64" ], @@ -1168,9 +1200,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", + "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", "cpu": [ "riscv64" ], @@ -1182,9 +1214,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", + "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", "cpu": [ "s390x" ], @@ -1196,9 +1228,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", + "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", "cpu": [ "x64" ], @@ -1210,9 +1242,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", + "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", "cpu": [ "x64" ], @@ -1252,9 +1284,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", + "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", "cpu": [ "arm64" ], @@ -1266,9 +1298,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", + "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", "cpu": [ "ia32" ], @@ -1294,9 +1326,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", + "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", "cpu": [ "x64" ], @@ -1318,7 +1350,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -2185,7 +2216,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2217,7 +2247,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -2416,7 +2445,6 @@ "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", - "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2702,7 +2730,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "peer": true, "bin": { @@ -2759,7 +2786,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2816,7 +2842,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2843,9 +2868,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { @@ -2921,6 +2946,18 @@ "node": ">=18" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2958,7 +2995,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3053,9 +3089,9 @@ } }, "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "dev": true, "license": "MIT" }, @@ -3097,7 +3133,6 @@ "version": "5.6.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", - "dev": true, "license": "MIT" }, "node_modules/dompurify": { @@ -3536,7 +3571,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esniff": { @@ -3603,7 +3637,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", @@ -3765,9 +3798,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, @@ -3965,15 +3998,15 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.18", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", - "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "version": "10.7.16", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", + "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.4", + "@formatjs/icu-messageformat-parser": "2.11.2", "tslib": "^2.8.0" } }, @@ -4397,7 +4430,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -4450,7 +4482,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -4718,9 +4749,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -4994,14 +5025,14 @@ } }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", + "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.7" }, "bin": { "rollup": "dist/bin/rollup" @@ -5011,131 +5042,35 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.43.0", + "@rollup/rollup-android-arm64": "4.43.0", + "@rollup/rollup-darwin-arm64": "4.43.0", + "@rollup/rollup-darwin-x64": "4.43.0", + "@rollup/rollup-freebsd-arm64": "4.43.0", + "@rollup/rollup-freebsd-x64": "4.43.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", + "@rollup/rollup-linux-arm-musleabihf": "4.43.0", + "@rollup/rollup-linux-arm64-gnu": "4.43.0", + "@rollup/rollup-linux-arm64-musl": "4.43.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-musl": "4.43.0", + "@rollup/rollup-linux-s390x-gnu": "4.43.0", + "@rollup/rollup-linux-x64-gnu": "4.43.0", + "@rollup/rollup-linux-x64-musl": "4.43.0", + "@rollup/rollup-win32-arm64-msvc": "4.43.0", + "@rollup/rollup-win32-ia32-msvc": "4.43.0", + "@rollup/rollup-win32-x64-msvc": "4.43.0", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/rollup/node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/sade": { "version": "1.8.1", @@ -5256,7 +5191,6 @@ "version": "5.55.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5281,6 +5215,15 @@ "node": ">=18" } }, + "node_modules/svelte-chartjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-chartjs/-/svelte-chartjs-4.0.1.tgz", + "integrity": "sha512-4z+0J+w/6ADH2Cy+/AnVek2HxRrznQ7dJfWTybc9BHm9//DCb1BmLrSE3NGDRDLj+kwJbKw2o1tPLBE3CmdHmw==", + "peerDependencies": { + "chart.js": "^3.5.0 || ^4.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/svelte-check": { "version": "4.4.6", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", @@ -5445,7 +5388,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -6508,7 +6450,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", - "dev": true, "license": "MIT" } } diff --git a/frontend/package.json b/frontend/package.json index 8f208fd28..3e2f75f81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -71,5 +71,9 @@ "@sveltejs/kit": { "cookie": "^1.0.0" } + }, + "dependencies": { + "chart.js": "^4.5.1", + "svelte-chartjs": "^4.0.1" } } diff --git a/frontend/src/lib/deploymentInfo.ts b/frontend/src/lib/deploymentInfo.ts index 2f51b8949..c73029471 100644 --- a/frontend/src/lib/deploymentInfo.ts +++ b/frontend/src/lib/deploymentInfo.ts @@ -10,6 +10,14 @@ export type DeploymentInfo = { last_seen: string | null; first_seen: string | null; hardware_devices: HardwareDevice[]; + network_interfaces?: Array<{ + interface: string; + ipv4_addresses: string[]; + ipv6_addresses: string[]; + mac_address: string | null; + }> | null; + location?: string | null; + name?: string | null; }; export const getDeploymentInfoByConfigId = async ( @@ -104,3 +112,96 @@ export const getAllDeploymentInfosAsMapFromConfigId = async (fetch: typeof windo } return deploymentInfosMap; }; + +export const getDeploymentInfo = async (fetch: typeof window.fetch, id: string) => { + const response = await fetchWithNotify( + `/api/deployment_info/${id}`, + undefined, + { 404: 'Device not found' }, + fetch + ); + if (response.ok) return (await response.json()) as DeploymentInfo; + return null; +}; + +export const getConnectionHistory = async ( + fetch: typeof window.fetch, + id: string +): Promise< + Array<{ connected_at: string; disconnected_at?: string; duration_seconds?: number }> +> => { + const response = await fetchWithNotify( + `/api/deployment_info/${id}/connection_history`, + undefined, + {}, + fetch + ); + if (response.ok) return response.json(); + return []; +}; + +export const getDeviceMetrics = async ( + fetch: typeof window.fetch, + id: string, + hours: number = 168, + granularity: '1min' | '15min' | '1h' = '1h' +): Promise< + Array<{ timestamp: string; cpu_percent: number; ram_percent: number; disk_percent: number }> +> => { + const response = await fetchWithNotify( + `/api/deployment_info/${id}/metrics?hours=${hours}&granularity=${granularity}`, + undefined, + {}, + fetch + ); + if (response.ok) return response.json(); + return []; +}; + +export const getErrorLogs = async ( + fetch: typeof window.fetch, + id: string +): Promise> => { + const response = await fetchWithNotify( + `/api/deployment_info/${id}/error_logs`, + undefined, + {}, + fetch + ); + if (response.ok) return response.json(); + return []; +}; + +export const updateLocation = async ( + fetch: typeof window.fetch, + id: string, + location: string | null +): Promise => { + return fetchWithNotify( + `/api/deployment_info/${id}/location`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ location }) + }, + {}, + fetch + ); +}; + +export const updateName = async ( + fetch: typeof window.fetch, + id: string, + name: string | null +): Promise => { + return fetchWithNotify( + `/api/deployment_info/${id}/name`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }, + {}, + fetch + ); +}; diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 9addb74b1..279fe358e 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -554,6 +554,35 @@ } } }, + "device-details": { + "network-info": "Netzwerkinformationen", + "no-network-info": "Keine Netzwerk-Interfaces gefunden", + "online-status": "Online-Status", + "online": "Online", + "offline": "Offline", + "connection-history": "Verbindungsverlauf", + "connected": "Verbunden", + "disconnected": "Getrennt", + "system-metrics": "Systemmetriken", + "history": "Verlauf", + "no-metrics": "Noch keine Metriken vorhanden. Daten erscheinen nach dem ersten Erfassungszyklus (60s).", + "location": "Standort", + "no-location": "Kein Standort gesetzt", + "location-placeholder": "z.B. Serverraum A, Rack 3", + "edit": "Name bearbeiten", + "edit-location": "Standort bearbeiten", + "device-info": "Geräteinformationen", + "configuration": "Konfiguration", + "commit": "Commit", + "hardware-ids": "Hardware-IDs", + "first-seen": "Zuerst gesehen", + "last-seen": "Zuletzt gesehen", + "save": "Speichern", + "error-logs": "Fehler-Protokolle", + "no-error-logs": "Keine Fehler aufgezeichnet", + "details": "Details", + "name-placeholder": "z.B. Display 1, Rack A" + }, "auto-update": { "title": "Auto-Update Einstellungen", "description": "Auto-Update führt automatisch ein Nix-Flake-Update durch, committet die aktualisierte flake.lock und rollt die neue Konfiguration nach dem konfigurierten Zeitplan auf alle verbundenen Geräte aus. Es werden nur committete Änderungen aktualisiert und ausgerollt. Uncommittete Änderungen werden während des Updates sicher zur Seite gelegt und danach wiederhergestellt.", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f4b9d37a4..d8ba4360a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -555,6 +555,35 @@ } } }, + "device-details": { + "network-info": "Network Information", + "no-network-info": "No network interfaces found", + "online-status": "Online Status", + "online": "Online", + "offline": "Offline", + "connection-history": "Connection History", + "connected": "Connected", + "disconnected": "Disconnected", + "system-metrics": "System Metrics", + "history": "History", + "no-metrics": "No metrics available yet. Data will appear after the first collection cycle (60s).", + "location": "Location", + "no-location": "No location set", + "location-placeholder": "e.g. Server Room A, Rack 3", + "edit": "Edit Name", + "edit-location": "Edit Location", + "device-info": "Device Information", + "configuration": "Configuration", + "commit": "Commit", + "hardware-ids": "Hardware IDs", + "first-seen": "First seen", + "last-seen": "Last seen", + "save": "Save", + "error-logs": "Error Logs", + "no-error-logs": "No errors recorded", + "details": "Details", + "name-placeholder": "e.g. Display 1, Rack A" + }, "auto-update": { "title": "Auto-Update Settings", "description": "Auto-Update automatically runs a Nix flake update, commits the updated flake.lock, and deploys the new configuration to all connected devices on the configured schedule. Only committed changes are updated and deployed. Any uncommitted work in progress is safely set aside during the update and restored afterwards.", diff --git a/frontend/src/routes/(authenticated)/devices/+page.svelte b/frontend/src/routes/(authenticated)/devices/+page.svelte index 3f4b11d89..2e508d3da 100644 --- a/frontend/src/routes/(authenticated)/devices/+page.svelte +++ b/frontend/src/routes/(authenticated)/devices/+page.svelte @@ -1,11 +1,12 @@ + +
+
+
+
+

{deploymentInfo.name ?? deploymentInfo.id}

+ + {isOnline ? $t('device-details.online') : $t('device-details.offline')} + +
+ {#if deploymentInfo.name} +

{deploymentInfo.id}

+ {/if} +
+ +
+ + + +
+ + +
+
+ +
+ +
+
+
+ {#each ['1h', '24h', '7d'] as w (w)} + + {/each} +
+ {#if metricsLoading} + + {:else} + + {/if} +
+
+ + +
+ +
+
+ +
+ + +
+
diff --git a/frontend/src/routes/(authenticated)/devices/[id]/+page.ts b/frontend/src/routes/(authenticated)/devices/[id]/+page.ts new file mode 100644 index 000000000..1d7242879 --- /dev/null +++ b/frontend/src/routes/(authenticated)/devices/[id]/+page.ts @@ -0,0 +1,11 @@ +import { error } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; +import { getDeploymentInfo } from '$lib/deploymentInfo'; + +export const load: PageLoad = async ({ params, fetch }) => { + const deploymentInfo = await getDeploymentInfo(fetch, params.id); + if (!deploymentInfo) { + throw error(404, 'Device not found'); + } + return { deploymentInfo }; +}; diff --git a/frontend/src/routes/(authenticated)/devices/[id]/Section.svelte b/frontend/src/routes/(authenticated)/devices/[id]/Section.svelte new file mode 100644 index 000000000..fa34bc2ca --- /dev/null +++ b/frontend/src/routes/(authenticated)/devices/[id]/Section.svelte @@ -0,0 +1,15 @@ + + +
+

{title}

+ {@render children?.()} +
diff --git a/frontend/src/routes/(authenticated)/devices/[id]/SectionDeviceInfo.svelte b/frontend/src/routes/(authenticated)/devices/[id]/SectionDeviceInfo.svelte new file mode 100644 index 000000000..3b960f7c3 --- /dev/null +++ b/frontend/src/routes/(authenticated)/devices/[id]/SectionDeviceInfo.svelte @@ -0,0 +1,142 @@ + + + +

{$t('device-details.device-info')}

+
+ + {$t('device-details.location')} +
+ {deploymentInfo.location ?? $t('device-details.no-location')} + +
+ + + {$t('device-details.configuration')} +
+ {#if deploymentInfo.deployed_config_id} + + {:else} + + {/if} +
+ + + {$t('device-details.commit')} + + {#if deploymentInfo.deployed_config_commit} + {deploymentInfo.deployed_config_commit.slice(0, 8)} + {:else} + + {/if} + + + + {#if deploymentInfo.hardware_devices.length > 0} + {$t('device-details.hardware-ids')} +
+ {#each Object.entries(deploymentInfo.hardware_devices[0].hardware_ids) as [key, value]} +
{key}: {value}
+ {/each} +
+ {/if} + + + {$t('device-details.first-seen')} + + {#if deploymentInfo.first_seen} + + {:else} + + {/if} + + + + {$t('device-details.last-seen')} + + {#if deploymentInfo.last_seen} + + {:else} + + {/if} + +
+ + + {#if deploymentInfo.network_interfaces?.length} +
+

{$t('device-details.network-info')}

+ {#each deploymentInfo.network_interfaces as iface} +
+

{iface.interface}

+ {#if iface.ipv4_addresses.length} +

+ IPv4: {iface.ipv4_addresses.join(', ')} +

+ {/if} + {#if iface.ipv6_addresses.length} +

+ IPv6: {iface.ipv6_addresses.join(', ')} +

+ {/if} + {#if iface.mac_address} +

+ MAC: {iface.mac_address} +

+ {/if} +
+ {/each} +
+ {:else} +

{$t('device-details.no-network-info')}

+ {/if} +
+ + + + +
+ + +
+
diff --git a/frontend/src/routes/(authenticated)/devices/[id]/SectionErrorLogs.svelte b/frontend/src/routes/(authenticated)/devices/[id]/SectionErrorLogs.svelte new file mode 100644 index 000000000..21932fdb7 --- /dev/null +++ b/frontend/src/routes/(authenticated)/devices/[id]/SectionErrorLogs.svelte @@ -0,0 +1,43 @@ + + +
+ {#if !errorLogs.length} +

{$t('device-details.no-error-logs')}

+ {:else} +
+ {#each errorLogs as log} +
+
+ + {severityLabel[log.severity] ?? 'Error'} + + + {new Date(log.timestamp).toLocaleString()} + +
+

{log.syslogtag}: {log.message}

+
+ {/each} +
+ {/if} +
diff --git a/frontend/src/routes/(authenticated)/devices/[id]/SectionMetrics.svelte b/frontend/src/routes/(authenticated)/devices/[id]/SectionMetrics.svelte new file mode 100644 index 000000000..1bebdf0e1 --- /dev/null +++ b/frontend/src/routes/(authenticated)/devices/[id]/SectionMetrics.svelte @@ -0,0 +1,96 @@ + + +{#if !metrics.length} +

{$t('device-details.no-metrics')}

+{:else} +
+ {#each metricItems as item} +
+
+

{item.label}

+ {(latest?.[item.key] ?? 0).toFixed(1)}% +
+ +
+ +
+
+ {/each} +
+{/if} diff --git a/frontend/src/routes/(authenticated)/devices/[id]/SectionOnlineStatus.svelte b/frontend/src/routes/(authenticated)/devices/[id]/SectionOnlineStatus.svelte new file mode 100644 index 000000000..11800c6c0 --- /dev/null +++ b/frontend/src/routes/(authenticated)/devices/[id]/SectionOnlineStatus.svelte @@ -0,0 +1,41 @@ + + +
+
+ + {isOnline ? $t('device-details.online') : $t('device-details.offline')} + +
+

{$t('device-details.connection-history')}

+
+ {#each connectionHistory as conn} +
+

+ {$t('device-details.connected')}: + {formatTime(conn.connected_at)} +

+ {#if conn.disconnected_at} +

+ {$t('device-details.disconnected')}: + {formatTime(conn.disconnected_at)} +

+ {/if} +
+ {/each} +
+