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,