From 65842e6d33b6f8410272dfd06d1a564f2edb0845 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 13 May 2026 23:00:41 -0400 Subject: [PATCH] Add dashboard drag-to-reorder and Discord service grouping --- dashboard.html | 70 +++++++++++++++++++++++++++++++++++++++++-- preview.html | 15 +++++++++- services.example.json | 6 ++++ status_bot.py | 58 +++++++++++++++++++++++------------ 4 files changed, 127 insertions(+), 22 deletions(-) diff --git a/dashboard.html b/dashboard.html index 0bd3c77..bdeb167 100644 --- a/dashboard.html +++ b/dashboard.html @@ -198,11 +198,32 @@ .service-row { display: grid; - grid-template-columns: minmax(130px, 0.8fr) minmax(220px, 1.5fr) minmax(180px, 1.2fr) 116px 88px 120px 40px; + grid-template-columns: 24px minmax(100px, 0.6fr) minmax(120px, 0.8fr) minmax(200px, 1.5fr) minmax(160px, 1.2fr) 100px 80px 110px 65px; gap: 10px; align-items: end; background: var(--panel); padding: 12px 14px; + border-top: 2px solid transparent; + border-bottom: 2px solid transparent; + } + + .service-row.drag-over { + border-top-color: var(--ok); + } + + .drag-handle { + cursor: grab; + color: var(--faint); + display: flex; + align-items: center; + justify-content: center; + align-self: center; + height: 100%; + font-size: 18px; + } + + .drag-handle:active { + cursor: grabbing; } .field label { @@ -426,6 +447,7 @@ function normalizeService(service = {}) { return { name: service.name || "", + group: service.group || "Main Services", url: service.url || "", displayUrl: service.displayUrl || service.url || "", method: service.method || "GET", @@ -454,8 +476,14 @@ const row = document.createElement("div"); row.className = "service-row"; row.dataset.index = String(index); + row.draggable = true; row.innerHTML = ` +
+
+ + +
@@ -479,7 +507,7 @@
${renderResult(result)}
- + `; return row; @@ -636,6 +664,44 @@ renderServices(); }); + let draggedRow = null; + + servicesEl.addEventListener("dragstart", (e) => { + const row = e.target.closest(".service-row"); + if (!row) return; + draggedRow = row; + e.dataTransfer.effectAllowed = "move"; + // Save current input values back to the services array before moving + services = collectServices(); + }); + + servicesEl.addEventListener("dragover", (e) => { + e.preventDefault(); + const row = e.target.closest(".service-row"); + if (!row || row === draggedRow) return; + e.dataTransfer.dropEffect = "move"; + row.classList.add("drag-over"); + }); + + servicesEl.addEventListener("dragleave", (e) => { + const row = e.target.closest(".service-row"); + if (row) row.classList.remove("drag-over"); + }); + + servicesEl.addEventListener("drop", (e) => { + e.preventDefault(); + const targetRow = e.target.closest(".service-row"); + if (targetRow) targetRow.classList.remove("drag-over"); + if (!draggedRow || !targetRow || draggedRow === targetRow) return; + + const fromIndex = parseInt(draggedRow.dataset.index, 10); + const toIndex = parseInt(targetRow.dataset.index, 10); + + const item = services.splice(fromIndex, 1)[0]; + services.splice(toIndex, 0, item); + renderServices(); + }); + loadSession().then(loadStatus).catch((error) => { if (error.status === 401) { showLogin(); diff --git a/preview.html b/preview.html index 7f72ee4..5b0e03d 100644 --- a/preview.html +++ b/preview.html @@ -165,6 +165,14 @@ margin-top: 10px; } + .embed-field { + grid-column: span 2; + } + + .embed-field.inline { + grid-column: span 1; + } + .embed-field-name { color: #f2f3f5; font-weight: 650; @@ -320,7 +328,9 @@ .replace(//g, ">"); - return escaped.replace(/\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/g, '$1'); + return escaped + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/g, '$1'); } function render(payload) { @@ -350,6 +360,9 @@ embed.fields.forEach((field) => { const item = document.createElement("div"); item.className = "embed-field"; + if (field.inline) { + item.classList.add("inline"); + } const name = document.createElement("div"); name.className = "embed-field-name"; name.textContent = field.name || "\u200b"; diff --git a/services.example.json b/services.example.json index a7be5b0..b29ece6 100644 --- a/services.example.json +++ b/services.example.json @@ -2,36 +2,42 @@ "services": [ { "name": "Portal", + "group": "Main Services", "url": "https://mithraic.cloud", "displayUrl": "https://mithraic.cloud", "expectedStatuses": ["200-399"] }, { "name": "Jellyfin", + "group": "Main Services", "url": "https://jellyfin.mithraic.cloud/health", "displayUrl": "https://jellyfin.mithraic.cloud", "expectedStatuses": ["200-399"] }, { "name": "Navidrome", + "group": "Main Services", "url": "https://listen.mithraic.cloud", "displayUrl": "https://listen.mithraic.cloud", "expectedStatuses": ["200-399"] }, { "name": "Voting Hub", + "group": "Main Services", "url": "https://vote.mithraic.cloud", "displayUrl": "https://vote.mithraic.cloud", "expectedStatuses": ["200-399"] }, { "name": "Forgejo", + "group": "Indexers & Search", "url": "https://git.mithraic.cloud", "displayUrl": "https://git.mithraic.cloud", "expectedStatuses": ["200-399"] }, { "name": "Support", + "group": "Indexers & Search", "url": "https://support.mithraic.cloud", "displayUrl": "https://support.mithraic.cloud", "expectedStatuses": ["200-399"] diff --git a/status_bot.py b/status_bot.py index 331cbd8..10da04b 100644 --- a/status_bot.py +++ b/status_bot.py @@ -40,6 +40,7 @@ PBKDF2_ITERATIONS = 390_000 @dataclass(frozen=True) class Service: name: str + group: str url: str display_url: str method: str @@ -337,6 +338,7 @@ def services_from_data(data: dict[str, Any]) -> list[Service]: services.append( Service( name=name, + group=str(item.get("group", "Main Services")).strip() or "Main Services", url=url, display_url=str(item.get("displayUrl", url)).strip() or url, method=str(item.get("method", "GET")).strip().upper(), @@ -379,6 +381,7 @@ def services_to_jsonable(services: list[Service]) -> list[dict[str, Any]]: expected.append(f"{service.expected_min}-{service.expected_max}") item: dict[str, Any] = { "name": service.name, + "group": service.group, "url": service.url, "displayUrl": service.display_url, "method": service.method, @@ -502,7 +505,10 @@ def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]: total = len(results) degraded = 0 < online < total - if online == total: + if total == 0: + color = 0x6B7280 + summary = "No services configured." + elif online == total: color = 0x10B981 summary = f"🟢 Operational · {online}/{total} online" elif degraded: @@ -514,14 +520,38 @@ def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]: color = 0xEF4444 summary = f"🔴 Outage · {online}/{total} online" - service_lines = [] - state_lines = [] + groups: dict[str, list[CheckResult]] = {} for result in results: - icon = "🟢" if result.ok else "🔴" - label = "Online" if result.ok else "Issue" + groups.setdefault(result.service.group, []).append(result) - service_lines.append(f"**{result.service.name}**") - state_lines.append(f"{icon} {label}") + fields = [] + for group_name, group_results in groups.items(): + service_lines = [] + state_lines = [] + for result in group_results: + icon = "🟢" if result.ok else "🔴" + label = "Online" if result.ok else "Issue" + service_lines.append(f"**{result.service.name}**") + state_lines.append(f"{icon} {label}") + + fields.append({ + "name": group_name, + "value": "\n".join(service_lines)[:1024] or "None", + "inline": True, + }) + fields.append({ + "name": "Status", + "value": "\n".join(state_lines)[:1024] or "None", + "inline": True, + }) + fields.append({ + "name": "\u200b", + "value": "\u200b", + "inline": False, + }) + + if fields and fields[-1]["name"] == "\u200b": + fields.pop() interval = os.getenv("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)).strip() return [ @@ -529,18 +559,7 @@ def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]: "title": "The Mithral Archive", "description": summary, "color": color, - "fields": [ - { - "name": "Service", - "value": "\n".join(service_lines)[:1024] or "No services configured.", - "inline": True, - }, - { - "name": "Status", - "value": "\n".join(state_lines)[:1024] or "n/a", - "inline": True, - } - ], + "fields": fields[:25], "footer": {"text": f"Refreshes every {interval}s • Last checked {checked_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"}, "timestamp": checked_at.isoformat(), } @@ -596,6 +615,7 @@ def print_preview(services: list[Service]) -> None: def result_to_jsonable(result: CheckResult) -> dict[str, Any]: return { "name": result.service.name, + "group": result.service.group, "url": result.service.url, "displayUrl": result.service.display_url, "ok": result.ok,