Compare commits
2 Commits
c36262d7b6
...
691d78ff06
| Author | SHA1 | Date | |
|---|---|---|---|
| 691d78ff06 | |||
| 1e636fdbe2 |
@@ -20,8 +20,9 @@ from media_library_viewer_api.config import get_settings
|
|||||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||||
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,29 +37,6 @@ class MessageRequest(BaseModel):
|
|||||||
html_body: str
|
html_body: str
|
||||||
|
|
||||||
|
|
||||||
def _resolve_service_record(
|
|
||||||
store: SettingsStore,
|
|
||||||
service_id: str | None = None,
|
|
||||||
) -> ServiceRecord | None:
|
|
||||||
"""Return the requested authentik instance, else the first enabled one.
|
|
||||||
|
|
||||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
|
||||||
when no enabled ``authentik`` instance is configured.
|
|
||||||
"""
|
|
||||||
service_type = "authentik"
|
|
||||||
if service_id:
|
|
||||||
row = store.get_service(service_id)
|
|
||||||
if not row or row.get("service_type") != service_type:
|
|
||||||
return None
|
|
||||||
if not row.get("enabled", True):
|
|
||||||
return None
|
|
||||||
return build_service_record(store, row)
|
|
||||||
for row in store.list_services(service_type):
|
|
||||||
if row.get("enabled", True):
|
|
||||||
return build_service_record(store, row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
api_token = str(service.secrets.get("api_token") or "")
|
api_token = str(service.secrets.get("api_token") or "")
|
||||||
@@ -82,7 +60,7 @@ def get_authentik_users(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Paginated Authentik user directory for a specific service instance."""
|
"""Paginated Authentik user directory for a specific service instance."""
|
||||||
service = _resolve_service_record(store, service_id)
|
service = resolve_service_record(store, "authentik", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
||||||
return _empty("Authentik service not configured")
|
return _empty("Authentik service not configured")
|
||||||
@@ -102,7 +80,7 @@ def get_authentik_message_status(
|
|||||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||||
service = _resolve_service_record(store, service_id)
|
service = resolve_service_record(store, "authentik", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||||
return mail_queue.status()
|
return mail_queue.status()
|
||||||
@@ -116,7 +94,7 @@ def post_authentik_message(
|
|||||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||||
service = _resolve_service_record(store, service_id)
|
service = resolve_service_record(store, "authentik", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
return {"status": "error", "error": "Authentik service not configured"}
|
return {"status": "error", "error": "Authentik service not configured"}
|
||||||
|
|
||||||
|
|||||||
@@ -15,34 +15,14 @@ import requests
|
|||||||
from fastapi import APIRouter, Body, Depends
|
from fastapi import APIRouter, Body, Depends
|
||||||
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_service_record(
|
|
||||||
store: SettingsStore, service_type: str, service_id: str | None = None
|
|
||||||
) -> ServiceRecord | None:
|
|
||||||
"""Return the requested service instance, else the first enabled one.
|
|
||||||
|
|
||||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
|
||||||
when no enabled instance of ``service_type`` is configured.
|
|
||||||
"""
|
|
||||||
if service_id:
|
|
||||||
row = store.get_service(service_id)
|
|
||||||
if not row or row.get("service_type") != service_type:
|
|
||||||
return None
|
|
||||||
if not row.get("enabled", True):
|
|
||||||
return None
|
|
||||||
return build_service_record(store, row)
|
|
||||||
for row in store.list_services(service_type):
|
|
||||||
if row.get("enabled", True):
|
|
||||||
return build_service_record(store, row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _base_url(service: ServiceRecord) -> str:
|
def _base_url(service: ServiceRecord) -> str:
|
||||||
return str(service.config.get("base_url") or "").rstrip("/")
|
return str(service.config.get("base_url") or "").rstrip("/")
|
||||||
|
|
||||||
@@ -104,7 +84,7 @@ def get_alertmanager_alerts(
|
|||||||
is configured the endpoint returns an empty summary with an
|
is configured the endpoint returns an empty summary with an
|
||||||
``alertmanager_not_configured`` error so the UI can render a health card.
|
``alertmanager_not_configured`` error so the UI can render a health card.
|
||||||
"""
|
"""
|
||||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
service = resolve_service_record(store, "alertmanager", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
|
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
|
||||||
try:
|
try:
|
||||||
@@ -149,7 +129,7 @@ def get_alertmanager_status(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return Alertmanager cluster/status for the UI health card."""
|
"""Return Alertmanager cluster/status for the UI health card."""
|
||||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
service = resolve_service_record(store, "alertmanager", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
return {
|
return {
|
||||||
"up": False,
|
"up": False,
|
||||||
@@ -198,7 +178,7 @@ def get_grafana_status(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||||
service = _resolve_service_record(store, "grafana", service_id)
|
service = resolve_service_record(store, "grafana", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
return _status_response(None, error="no_service_configured")
|
return _status_response(None, error="no_service_configured")
|
||||||
try:
|
try:
|
||||||
@@ -221,7 +201,7 @@ def get_prometheus_status(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Probe a Prometheus service instance's health and build info."""
|
"""Probe a Prometheus service instance's health and build info."""
|
||||||
service = _resolve_service_record(store, "prometheus", service_id)
|
service = resolve_service_record(store, "prometheus", service_id)
|
||||||
if service is None:
|
if service is None:
|
||||||
return _status_response(None, error="no_service_configured")
|
return _status_response(None, error="no_service_configured")
|
||||||
base = _base_url(service)
|
base = _base_url(service)
|
||||||
|
|||||||
@@ -269,6 +269,19 @@ def delete_reference(
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/references/{reference_id}")
|
||||||
|
def update_reference(
|
||||||
|
reference_id: str,
|
||||||
|
sort_order: int,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update a widget reference's sort_order (per-dashboard reordering)."""
|
||||||
|
try:
|
||||||
|
return store.update_widget_reference(reference_id, sort_order)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/references/{reference_id}/detach")
|
@router.post("/references/{reference_id}/detach")
|
||||||
def detach_reference(
|
def detach_reference(
|
||||||
reference_id: str,
|
reference_id: str,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Shared helpers for resolving service instances at request time.
|
||||||
|
|
||||||
|
Extracted from the duplicated ``_resolve_service_record`` helpers that lived
|
||||||
|
in ``routers/monitoring.py`` and ``routers/authentik_users.py``. Both routers
|
||||||
|
need the same logic: return the requested service instance (by id), or fall
|
||||||
|
back to the first enabled instance of the type. Returns ``None`` when the
|
||||||
|
instance does not exist, is the wrong type, is disabled, or when no enabled
|
||||||
|
instance of the type is configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_service_record(
|
||||||
|
store: SettingsStore,
|
||||||
|
service_type: str,
|
||||||
|
service_id: str | None = None,
|
||||||
|
) -> ServiceRecord | None:
|
||||||
|
"""Return the requested service instance, else the first enabled one.
|
||||||
|
|
||||||
|
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||||
|
when no enabled instance of ``service_type`` is configured.
|
||||||
|
"""
|
||||||
|
if service_id:
|
||||||
|
row = store.get_service(service_id)
|
||||||
|
if not row or row.get("service_type") != service_type:
|
||||||
|
return None
|
||||||
|
if not row.get("enabled", True):
|
||||||
|
return None
|
||||||
|
return build_service_record(store, row)
|
||||||
|
for row in store.list_services(service_type):
|
||||||
|
if row.get("enabled", True):
|
||||||
|
return build_service_record(store, row)
|
||||||
|
return None
|
||||||
@@ -1570,6 +1570,30 @@ class SettingsStore:
|
|||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
|
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
|
||||||
|
|
||||||
|
def update_widget_reference(self, reference_id: str, sort_order: int) -> dict[str, Any]:
|
||||||
|
"""Update only the sort_order on a widget reference (per-dashboard reordering)."""
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM widget_references WHERE id = ?",
|
||||||
|
(reference_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"Reference {reference_id} not found")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE widget_references SET sort_order = ? WHERE id = ?",
|
||||||
|
(sort_order, reference_id),
|
||||||
|
)
|
||||||
|
widget = self.get_widget(row["widget_id"])
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"dashboard_scope": row["dashboard_scope"],
|
||||||
|
"widget_id": row["widget_id"],
|
||||||
|
"sort_order": sort_order,
|
||||||
|
"created_at": int(row["created_at"]),
|
||||||
|
"widget": widget,
|
||||||
|
}
|
||||||
|
|
||||||
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
|
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
|
||||||
"""Clone the referenced widget into a new standalone instance owned by the scope."""
|
"""Clone the referenced widget into a new standalone instance owned by the scope."""
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
@@ -1583,10 +1607,11 @@ class SettingsStore:
|
|||||||
source = self.get_widget(row["widget_id"])
|
source = self.get_widget(row["widget_id"])
|
||||||
if not source:
|
if not source:
|
||||||
raise ValueError(f"Source widget {row['widget_id']} not found")
|
raise ValueError(f"Source widget {row['widget_id']} not found")
|
||||||
# Clone: new widget with service_id=NULL (dashboard scope), same config/kind/title.
|
# Clone: copy the widget verbatim including service_id (so service-bound
|
||||||
|
# widgets keep working), only the id/created_at change.
|
||||||
cloned = self.upsert_widget(
|
cloned = self.upsert_widget(
|
||||||
{
|
{
|
||||||
"service_id": None,
|
"service_id": source.get("service_id"),
|
||||||
"widget_kind": source["widget_kind"],
|
"widget_kind": source["widget_kind"],
|
||||||
"title": source["title"],
|
"title": source["title"],
|
||||||
"config": source["config"],
|
"config": source["config"],
|
||||||
|
|||||||
+23
-22
@@ -26,6 +26,7 @@ from media_library_viewer_api.widgets.sources import ServiceRecord
|
|||||||
|
|
||||||
# Short alias for the monitoring router module under test.
|
# Short alias for the monitoring router module under test.
|
||||||
_MON = "media_library_viewer_api.routers.monitoring"
|
_MON = "media_library_viewer_api.routers.monitoring"
|
||||||
|
_SVC = "media_library_viewer_api.services.service_resolution"
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
|
|
||||||
@@ -496,7 +497,7 @@ class TestMonitoring:
|
|||||||
|
|
||||||
|
|
||||||
class TestResolveServiceRecord:
|
class TestResolveServiceRecord:
|
||||||
"""Unit tests for _resolve_service_record (service_id + first-enabled paths)."""
|
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
|
||||||
|
|
||||||
def _store(self, rows):
|
def _store(self, rows):
|
||||||
store = MagicMock()
|
store = MagicMock()
|
||||||
@@ -509,31 +510,31 @@ class TestResolveServiceRecord:
|
|||||||
return store
|
return store
|
||||||
|
|
||||||
def test_service_id_match_returns_record(self):
|
def test_service_id_match_returns_record(self):
|
||||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
|
|
||||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
|
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
|
||||||
store = self._store([row])
|
store = self._store([row])
|
||||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
||||||
result = _resolve_service_record(store, "alertmanager", "am1")
|
result = resolve_service_record(store, "alertmanager", "am1")
|
||||||
assert result == "RECORD"
|
assert result == "RECORD"
|
||||||
mock_build.assert_called_once_with(store, row)
|
mock_build.assert_called_once_with(store, row)
|
||||||
|
|
||||||
def test_service_id_type_mismatch_returns_none(self):
|
def test_service_id_type_mismatch_returns_none(self):
|
||||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
|
|
||||||
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
||||||
store = self._store([row])
|
store = self._store([row])
|
||||||
assert _resolve_service_record(store, "alertmanager", "x1") is None
|
assert resolve_service_record(store, "alertmanager", "x1") is None
|
||||||
|
|
||||||
def test_service_id_disabled_returns_none(self):
|
def test_service_id_disabled_returns_none(self):
|
||||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
|
|
||||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
|
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
|
||||||
store = self._store([row])
|
store = self._store([row])
|
||||||
assert _resolve_service_record(store, "alertmanager", "am1") is None
|
assert resolve_service_record(store, "alertmanager", "am1") is None
|
||||||
|
|
||||||
def test_no_service_id_returns_first_enabled(self):
|
def test_no_service_id_returns_first_enabled(self):
|
||||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
@@ -554,16 +555,16 @@ class TestResolveServiceRecord:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
store = self._store(rows)
|
store = self._store(rows)
|
||||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
||||||
result = _resolve_service_record(store, "alertmanager", None)
|
result = resolve_service_record(store, "alertmanager", None)
|
||||||
assert result == "RECORD"
|
assert result == "RECORD"
|
||||||
mock_build.assert_called_once_with(store, rows[1])
|
mock_build.assert_called_once_with(store, rows[1])
|
||||||
|
|
||||||
def test_no_service_id_and_none_enabled_returns_none(self):
|
def test_no_service_id_and_none_enabled_returns_none(self):
|
||||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||||
|
|
||||||
store = self._store([])
|
store = self._store([])
|
||||||
assert _resolve_service_record(store, "alertmanager", None) is None
|
assert resolve_service_record(store, "alertmanager", None) is None
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsMachines:
|
class TestSettingsMachines:
|
||||||
@@ -624,7 +625,7 @@ class TestAlertmanager:
|
|||||||
def test_alerts_endpoint_when_unreachable(self, test_client):
|
def test_alerts_endpoint_when_unreachable(self, test_client):
|
||||||
service = _am_service()
|
service = _am_service()
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
|
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/alerts")
|
response = test_client.get("/api/monitoring/alerts")
|
||||||
@@ -651,7 +652,7 @@ class TestAlertmanager:
|
|||||||
}
|
}
|
||||||
resp.raise_for_status = MagicMock()
|
resp.raise_for_status = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", return_value=resp),
|
patch(f"{_MON}.requests.get", return_value=resp),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/alerts")
|
response = test_client.get("/api/monitoring/alerts")
|
||||||
@@ -669,7 +670,7 @@ class TestAlertmanager:
|
|||||||
resp.json.return_value = {"status": "success", "data": []}
|
resp.json.return_value = {"status": "success", "data": []}
|
||||||
resp.raise_for_status = MagicMock()
|
resp.raise_for_status = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
|
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
|
||||||
):
|
):
|
||||||
test_client.get("/api/monitoring/alerts")
|
test_client.get("/api/monitoring/alerts")
|
||||||
@@ -687,7 +688,7 @@ class TestAlertmanager:
|
|||||||
def test_alertmanager_status_when_unreachable(self, test_client):
|
def test_alertmanager_status_when_unreachable(self, test_client):
|
||||||
service = _am_service()
|
service = _am_service()
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||||
@@ -707,7 +708,7 @@ class TestAlertmanager:
|
|||||||
}
|
}
|
||||||
resp.raise_for_status = MagicMock()
|
resp.raise_for_status = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", return_value=resp),
|
patch(f"{_MON}.requests.get", return_value=resp),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||||
@@ -753,7 +754,7 @@ class TestGrafanaStatus:
|
|||||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/grafana-status")
|
response = test_client.get("/api/monitoring/grafana-status")
|
||||||
@@ -771,7 +772,7 @@ class TestGrafanaStatus:
|
|||||||
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||||
resp.raise_for_status = MagicMock()
|
resp.raise_for_status = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", return_value=resp),
|
patch(f"{_MON}.requests.get", return_value=resp),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/grafana-status")
|
response = test_client.get("/api/monitoring/grafana-status")
|
||||||
@@ -795,7 +796,7 @@ class TestPrometheusStatus:
|
|||||||
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/prometheus-status")
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
@@ -814,7 +815,7 @@ class TestPrometheusStatus:
|
|||||||
build_info.raise_for_status = MagicMock()
|
build_info.raise_for_status = MagicMock()
|
||||||
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
||||||
with (
|
with (
|
||||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||||
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
||||||
):
|
):
|
||||||
response = test_client.get("/api/monitoring/prometheus-status")
|
response = test_client.get("/api/monitoring/prometheus-status")
|
||||||
|
|||||||
@@ -730,6 +730,7 @@ async def test_jellyfin_activity_shows_all_sessions():
|
|||||||
# Widget references (live-link widgets across dashboards)
|
# Widget references (live-link widgets across dashboards)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def widget_ref_client(monkeypatch):
|
def widget_ref_client(monkeypatch):
|
||||||
"""TestClient with an isolated SettingsStore + encryption key."""
|
"""TestClient with an isolated SettingsStore + encryption key."""
|
||||||
@@ -770,21 +771,26 @@ def test_widget_reference_lifecycle(widget_ref_client):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
service = store.list_services("grafana")[0]
|
service = store.list_services("grafana")[0]
|
||||||
widget = store.upsert_widget({
|
widget = store.upsert_widget(
|
||||||
"service_id": service["id"],
|
{
|
||||||
"widget_kind": "chart",
|
"service_id": service["id"],
|
||||||
"title": "CPU IOWait",
|
"widget_kind": "chart",
|
||||||
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
"title": "CPU IOWait",
|
||||||
"enabled": True,
|
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||||
"sort_order": 0,
|
"enabled": True,
|
||||||
})
|
"sort_order": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Reference it on "main" dashboard.
|
# Reference it on "main" dashboard.
|
||||||
resp = client.post("/api/widgets/references", json={
|
resp = client.post(
|
||||||
"dashboard_scope": "main",
|
"/api/widgets/references",
|
||||||
"widget_id": widget["id"],
|
json={
|
||||||
"sort_order": 5,
|
"dashboard_scope": "main",
|
||||||
})
|
"widget_id": widget["id"],
|
||||||
|
"sort_order": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
ref = resp.json()
|
ref = resp.json()
|
||||||
assert ref["dashboard_scope"] == "main"
|
assert ref["dashboard_scope"] == "main"
|
||||||
@@ -823,20 +829,25 @@ def test_widget_reference_detach(widget_ref_client):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
service = store.list_services("grafana")[0]
|
service = store.list_services("grafana")[0]
|
||||||
widget = store.upsert_widget({
|
widget = store.upsert_widget(
|
||||||
"service_id": service["id"],
|
{
|
||||||
"widget_kind": "chart",
|
"service_id": service["id"],
|
||||||
"title": "Memory",
|
"widget_kind": "chart",
|
||||||
"config": {"query": "mem", "datasource_uid": "prometheus"},
|
"title": "Memory",
|
||||||
"enabled": True,
|
"config": {"query": "mem", "datasource_uid": "prometheus"},
|
||||||
"sort_order": 0,
|
"enabled": True,
|
||||||
})
|
"sort_order": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Reference on "main".
|
# Reference on "main".
|
||||||
resp = client.post("/api/widgets/references", json={
|
resp = client.post(
|
||||||
"dashboard_scope": "main",
|
"/api/widgets/references",
|
||||||
"widget_id": widget["id"],
|
json={
|
||||||
})
|
"dashboard_scope": "main",
|
||||||
|
"widget_id": widget["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
ref_id = resp.json()["id"]
|
ref_id = resp.json()["id"]
|
||||||
|
|
||||||
# Detach.
|
# Detach.
|
||||||
@@ -845,7 +856,7 @@ def test_widget_reference_detach(widget_ref_client):
|
|||||||
cloned = resp.json()
|
cloned = resp.json()
|
||||||
assert cloned["title"] == "Memory"
|
assert cloned["title"] == "Memory"
|
||||||
assert cloned["widget_kind"] == "chart"
|
assert cloned["widget_kind"] == "chart"
|
||||||
assert cloned["service_id"] is None # dashboard-scoped clone
|
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
|
||||||
assert cloned["config"]["query"] == "mem"
|
assert cloned["config"]["query"] == "mem"
|
||||||
assert cloned["id"] != widget["id"] # new independent widget
|
assert cloned["id"] != widget["id"] # new independent widget
|
||||||
|
|
||||||
@@ -854,3 +865,62 @@ def test_widget_reference_detach(widget_ref_client):
|
|||||||
assert len(refs) == 0
|
assert len(refs) == 0
|
||||||
# Original still exists.
|
# Original still exists.
|
||||||
assert store.get_widget(widget["id"]) is not None
|
assert store.get_widget(widget["id"]) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_reference_update_sort_order(widget_ref_client):
|
||||||
|
"""PUT /references/{id} updates only the reference's sort_order (Fix 1)."""
|
||||||
|
client, store = widget_ref_client
|
||||||
|
|
||||||
|
widget_a = store.upsert_widget(
|
||||||
|
{
|
||||||
|
"service_id": None,
|
||||||
|
"widget_kind": "static",
|
||||||
|
"title": "A",
|
||||||
|
"config": {"text": "a"},
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
widget_b = store.upsert_widget(
|
||||||
|
{
|
||||||
|
"service_id": None,
|
||||||
|
"widget_kind": "static",
|
||||||
|
"title": "B",
|
||||||
|
"config": {"text": "b"},
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Two references on the same dashboard scope.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/widgets/references",
|
||||||
|
json={
|
||||||
|
"dashboard_scope": "named:test",
|
||||||
|
"widget_id": widget_a["id"],
|
||||||
|
"sort_order": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ref_a = resp.json()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/widgets/references",
|
||||||
|
json={
|
||||||
|
"dashboard_scope": "named:test",
|
||||||
|
"widget_id": widget_b["id"],
|
||||||
|
"sort_order": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ref_b = resp.json()
|
||||||
|
|
||||||
|
# Swap sort orders via PUT (per-dashboard reorder).
|
||||||
|
resp = client.put(f"/api/widgets/references/{ref_a['id']}?sort_order=1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["sort_order"] == 1
|
||||||
|
|
||||||
|
resp = client.put(f"/api/widgets/references/{ref_b['id']}?sort_order=0")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["sort_order"] == 0
|
||||||
|
|
||||||
|
# Widget instances themselves are unchanged.
|
||||||
|
assert store.get_widget(widget_a["id"])["sort_order"] == 0
|
||||||
|
assert store.get_widget(widget_b["id"])["sort_order"] == 1
|
||||||
|
|||||||
@@ -87,3 +87,13 @@ export async function detachWidgetReference(
|
|||||||
): Promise<WidgetInstance> {
|
): Promise<WidgetInstance> {
|
||||||
return post<WidgetInstance>(`/api/widgets/references/${referenceId}/detach`);
|
return post<WidgetInstance>(`/api/widgets/references/${referenceId}/detach`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateWidgetReference(
|
||||||
|
referenceId: string,
|
||||||
|
sortOrder: number,
|
||||||
|
): Promise<WidgetReference> {
|
||||||
|
return put<WidgetReference>(
|
||||||
|
`/api/widgets/references/${referenceId}?sort_order=${sortOrder}`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
useDeleteWidgetReference,
|
useDeleteWidgetReference,
|
||||||
useDetachWidgetReference,
|
useDetachWidgetReference,
|
||||||
useSaveWidgetInstance,
|
useSaveWidgetInstance,
|
||||||
|
useUpdateWidgetReference,
|
||||||
useWidgetInstances,
|
useWidgetInstances,
|
||||||
useWidgetReferences,
|
useWidgetReferences,
|
||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
@@ -212,20 +213,13 @@ export function WidgetConfigDialog({
|
|||||||
const createRef = useCreateWidgetReference();
|
const createRef = useCreateWidgetReference();
|
||||||
const deleteRef = useDeleteWidgetReference();
|
const deleteRef = useDeleteWidgetReference();
|
||||||
const detachRef = useDetachWidgetReference();
|
const detachRef = useDetachWidgetReference();
|
||||||
|
const updateRef = useUpdateWidgetReference();
|
||||||
const { data: allWidgets = [] } = useWidgetInstances();
|
const { data: allWidgets = [] } = useWidgetInstances();
|
||||||
const [showExisting, setShowExisting] = useState(false);
|
const [showExisting, setShowExisting] = useState(false);
|
||||||
const [existingSearch, setExistingSearch] = useState("");
|
const [existingSearch, setExistingSearch] = useState("");
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
|
||||||
const sortedInstances = useMemo(
|
|
||||||
() =>
|
|
||||||
[...instances].sort(
|
|
||||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
|
||||||
),
|
|
||||||
[instances],
|
|
||||||
);
|
|
||||||
|
|
||||||
function startAddBuiltIn(kind: string) {
|
function startAddBuiltIn(kind: string) {
|
||||||
const binding = BUILTIN_WIDGETS[kind];
|
const binding = BUILTIN_WIDGETS[kind];
|
||||||
setDraft({
|
setDraft({
|
||||||
@@ -297,13 +291,29 @@ export function WidgetConfigDialog({
|
|||||||
|
|
||||||
async function moveInstance(index: number, direction: -1 | 1) {
|
async function moveInstance(index: number, direction: -1 | 1) {
|
||||||
const targetIndex = index + direction;
|
const targetIndex = index + direction;
|
||||||
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
|
if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
|
||||||
const a = sortedInstances[index];
|
const a = combinedWidgets[index];
|
||||||
const b = sortedInstances[targetIndex];
|
const b = combinedWidgets[targetIndex];
|
||||||
// Sequential (not Promise.all) to avoid a race where the first mutation's
|
const aRefId = (a as { _ref_id?: string })._ref_id;
|
||||||
// cache invalidation refetches before the second completes, reverting the swap.
|
const bRefId = (b as { _ref_id?: string })._ref_id;
|
||||||
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
|
// References use their own sort_order on the widget_references row;
|
||||||
await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
|
// owned widgets use the widget instance's sort_order.
|
||||||
|
if (aRefId) {
|
||||||
|
await updateRef.mutateAsync({
|
||||||
|
referenceId: aRefId,
|
||||||
|
sortOrder: b.sort_order,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
|
||||||
|
}
|
||||||
|
if (bRefId) {
|
||||||
|
await updateRef.mutateAsync({
|
||||||
|
referenceId: bRefId,
|
||||||
|
sortOrder: a.sort_order,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeInstance(instance: WidgetInstance) {
|
async function removeInstance(instance: WidgetInstance) {
|
||||||
@@ -311,20 +321,19 @@ export function WidgetConfigDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build a combined view of owned widgets + references for display.
|
// Build a combined view of owned widgets + references for display.
|
||||||
|
const owned = [...instances].sort(
|
||||||
|
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||||
|
);
|
||||||
|
const refs = references.map((r) => ({
|
||||||
|
...r.widget,
|
||||||
|
_ref_id: r.id,
|
||||||
|
_is_reference: true as const,
|
||||||
|
}));
|
||||||
|
const combinedWidgets = [...owned, ...refs].sort(
|
||||||
|
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||||
|
);
|
||||||
|
|
||||||
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
|
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
|
||||||
const combinedWidgets = useMemo(() => {
|
|
||||||
const owned = [...instances].sort(
|
|
||||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
|
||||||
);
|
|
||||||
const refs = references.map((r) => ({
|
|
||||||
...r.widget,
|
|
||||||
_ref_id: r.id,
|
|
||||||
_is_reference: true as const,
|
|
||||||
}));
|
|
||||||
return [...owned, ...refs].sort(
|
|
||||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
|
||||||
);
|
|
||||||
}, [instances, references]);
|
|
||||||
|
|
||||||
// Available widgets for the "Add existing" picker: all widgets not already
|
// Available widgets for the "Add existing" picker: all widgets not already
|
||||||
// on this dashboard (owned or referenced).
|
// on this dashboard (owned or referenced).
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock("../../hooks/useWidgets", () => ({
|
|||||||
useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||||
useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||||
useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useUpdateWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useServices", () => ({
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
fetchWidgetInstances,
|
fetchWidgetInstances,
|
||||||
fetchWidgetReferences,
|
fetchWidgetReferences,
|
||||||
updateWidgetInstance,
|
updateWidgetInstance,
|
||||||
|
updateWidgetReference,
|
||||||
} from "../api/widgets";
|
} from "../api/widgets";
|
||||||
import type { WidgetInstanceInput } from "../types";
|
import type { WidgetInstanceInput } from "../types";
|
||||||
|
|
||||||
@@ -101,3 +102,19 @@ export function useDetachWidgetReference() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateWidgetReference() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
referenceId,
|
||||||
|
sortOrder,
|
||||||
|
}: {
|
||||||
|
referenceId: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}) => updateWidgetReference(referenceId, sortOrder),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Boxes } from "lucide-react";
|
import { Boxes, Settings2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useDashboardBySlug } from "../hooks/useDashboards";
|
import { useDashboardBySlug } from "../hooks/useDashboards";
|
||||||
|
import { useWidgetReferences } from "../hooks/useWidgets";
|
||||||
import { PinnedServiceLink } from "../components/PinnedServiceLink";
|
import { PinnedServiceLink } from "../components/PinnedServiceLink";
|
||||||
|
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||||
|
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload model for named dashboards (design choice: inline items, not widget
|
* Payload model for named dashboards (design choice: inline items, not widget
|
||||||
@@ -42,12 +46,24 @@ function parseItems(payload: Record<string, unknown>): DashboardItem[] {
|
|||||||
export function NamedDashboardPage() {
|
export function NamedDashboardPage() {
|
||||||
const { slug = "" } = useParams<{ slug: string }>();
|
const { slug = "" } = useParams<{ slug: string }>();
|
||||||
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
|
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
|
||||||
|
const dashboardScope = `named:${slug}`;
|
||||||
|
const { data: widgetRefs = [] } = useWidgetReferences(dashboardScope);
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => parseItems(dashboard?.payload ?? {}),
|
() => parseItems(dashboard?.payload ?? {}),
|
||||||
[dashboard?.payload],
|
[dashboard?.payload],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const visibleWidgets = useMemo(
|
||||||
|
() =>
|
||||||
|
widgetRefs
|
||||||
|
.filter((r) => r.widget.enabled)
|
||||||
|
.map((r) => r.widget)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
[widgetRefs],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Skeleton className="h-32 w-full" />;
|
return <Skeleton className="h-32 w-full" />;
|
||||||
}
|
}
|
||||||
@@ -64,17 +80,36 @@ export function NamedDashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
|
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => setConfigOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
Edit widgets
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 ? (
|
|
||||||
|
{visibleWidgets.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{visibleWidgets.map((widget) => (
|
||||||
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{items.length === 0 && visibleWidgets.length === 0 ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
This dashboard has no shortcuts yet. Add pinned service links from
|
This dashboard is empty. Add widgets via "Edit widgets" or pinned
|
||||||
the dashboard management panel on the Services page.
|
service links from the dashboard management panel on the Services
|
||||||
|
page.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : items.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<PinnedServiceLink
|
<PinnedServiceLink
|
||||||
@@ -85,7 +120,13 @@ export function NamedDashboardPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
<WidgetConfigDialog
|
||||||
|
open={configOpen}
|
||||||
|
onClose={() => setConfigOpen(false)}
|
||||||
|
dashboardScope={dashboardScope}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { NamedDashboardPage } from "../NamedDashboardPage";
|
import { NamedDashboardPage } from "../NamedDashboardPage";
|
||||||
|
|
||||||
vi.mock("../../hooks/useDashboards", () => ({
|
vi.mock("../../hooks/useDashboards", () => ({
|
||||||
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
|
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetReferences: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||||
|
WidgetConfigDialog: () => <div data-testid="config-dialog-stub" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/WidgetInstance", () => ({
|
||||||
|
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
||||||
|
}));
|
||||||
|
|
||||||
import { useDashboardBySlug } from "../../hooks/useDashboards";
|
import { useDashboardBySlug } from "../../hooks/useDashboards";
|
||||||
|
|
||||||
function renderPage(slug: string) {
|
function renderPage(slug: string) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[`/d/${slug}`]}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Routes>
|
<MemoryRouter initialEntries={[`/d/${slug}`]}>
|
||||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
<Routes>
|
||||||
</Routes>
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
</MemoryRouter>,
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +106,26 @@ describe("NamedDashboardPage", () => {
|
|||||||
} as never);
|
} as never);
|
||||||
renderPage("empty");
|
renderPage("empty");
|
||||||
expect(screen.getByText("Empty")).toBeInTheDocument();
|
expect(screen.getByText("Empty")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/This dashboard is empty/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an edit-widgets button", () => {
|
||||||
|
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||||
|
data: {
|
||||||
|
id: "d1",
|
||||||
|
label: "Storage",
|
||||||
|
slug: "storage",
|
||||||
|
sort_order: 0,
|
||||||
|
payload: { items: [] },
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as never);
|
||||||
|
renderPage("storage");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Edit widgets/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user