Add dashboard drag-to-reorder and Discord service grouping

This commit is contained in:
MiTHRAL 2026-05-13 23:00:41 -04:00
parent e6fbd670ac
commit 65842e6d33
4 changed files with 127 additions and 22 deletions

View file

@ -198,11 +198,32 @@
.service-row { .service-row {
display: grid; 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; gap: 10px;
align-items: end; align-items: end;
background: var(--panel); background: var(--panel);
padding: 12px 14px; 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 { .field label {
@ -426,6 +447,7 @@
function normalizeService(service = {}) { function normalizeService(service = {}) {
return { return {
name: service.name || "", name: service.name || "",
group: service.group || "Main Services",
url: service.url || "", url: service.url || "",
displayUrl: service.displayUrl || service.url || "", displayUrl: service.displayUrl || service.url || "",
method: service.method || "GET", method: service.method || "GET",
@ -454,8 +476,14 @@
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "service-row"; row.className = "service-row";
row.dataset.index = String(index); row.dataset.index = String(index);
row.draggable = true;
row.innerHTML = ` row.innerHTML = `
<div class="drag-handle" title="Drag to reorder"></div>
<div class="field">
<label>Group</label>
<input data-key="group" value="${escapeAttr(service.group)}">
</div>
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
<input data-key="name" value="${escapeAttr(service.name)}"> <input data-key="name" value="${escapeAttr(service.name)}">
@ -479,7 +507,7 @@
<div class="row-status"> <div class="row-status">
${renderResult(result)} ${renderResult(result)}
</div> </div>
<button class="danger" type="button" data-remove="${index}" title="Remove service">Remove</button> <button class="danger" type="button" data-remove="${index}" title="Remove service">Del</button>
`; `;
return row; return row;
@ -636,6 +664,44 @@
renderServices(); 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) => { loadSession().then(loadStatus).catch((error) => {
if (error.status === 401) { if (error.status === 401) {
showLogin(); showLogin();

View file

@ -165,6 +165,14 @@
margin-top: 10px; margin-top: 10px;
} }
.embed-field {
grid-column: span 2;
}
.embed-field.inline {
grid-column: span 1;
}
.embed-field-name { .embed-field-name {
color: #f2f3f5; color: #f2f3f5;
font-weight: 650; font-weight: 650;
@ -320,7 +328,9 @@
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;"); .replace(/>/g, "&gt;");
return escaped.replace(/\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>'); return escaped
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
} }
function render(payload) { function render(payload) {
@ -350,6 +360,9 @@
embed.fields.forEach((field) => { embed.fields.forEach((field) => {
const item = document.createElement("div"); const item = document.createElement("div");
item.className = "embed-field"; item.className = "embed-field";
if (field.inline) {
item.classList.add("inline");
}
const name = document.createElement("div"); const name = document.createElement("div");
name.className = "embed-field-name"; name.className = "embed-field-name";
name.textContent = field.name || "\u200b"; name.textContent = field.name || "\u200b";

View file

@ -2,36 +2,42 @@
"services": [ "services": [
{ {
"name": "Portal", "name": "Portal",
"group": "Main Services",
"url": "https://mithraic.cloud", "url": "https://mithraic.cloud",
"displayUrl": "https://mithraic.cloud", "displayUrl": "https://mithraic.cloud",
"expectedStatuses": ["200-399"] "expectedStatuses": ["200-399"]
}, },
{ {
"name": "Jellyfin", "name": "Jellyfin",
"group": "Main Services",
"url": "https://jellyfin.mithraic.cloud/health", "url": "https://jellyfin.mithraic.cloud/health",
"displayUrl": "https://jellyfin.mithraic.cloud", "displayUrl": "https://jellyfin.mithraic.cloud",
"expectedStatuses": ["200-399"] "expectedStatuses": ["200-399"]
}, },
{ {
"name": "Navidrome", "name": "Navidrome",
"group": "Main Services",
"url": "https://listen.mithraic.cloud", "url": "https://listen.mithraic.cloud",
"displayUrl": "https://listen.mithraic.cloud", "displayUrl": "https://listen.mithraic.cloud",
"expectedStatuses": ["200-399"] "expectedStatuses": ["200-399"]
}, },
{ {
"name": "Voting Hub", "name": "Voting Hub",
"group": "Main Services",
"url": "https://vote.mithraic.cloud", "url": "https://vote.mithraic.cloud",
"displayUrl": "https://vote.mithraic.cloud", "displayUrl": "https://vote.mithraic.cloud",
"expectedStatuses": ["200-399"] "expectedStatuses": ["200-399"]
}, },
{ {
"name": "Forgejo", "name": "Forgejo",
"group": "Indexers & Search",
"url": "https://git.mithraic.cloud", "url": "https://git.mithraic.cloud",
"displayUrl": "https://git.mithraic.cloud", "displayUrl": "https://git.mithraic.cloud",
"expectedStatuses": ["200-399"] "expectedStatuses": ["200-399"]
}, },
{ {
"name": "Support", "name": "Support",
"group": "Indexers & Search",
"url": "https://support.mithraic.cloud", "url": "https://support.mithraic.cloud",
"displayUrl": "https://support.mithraic.cloud", "displayUrl": "https://support.mithraic.cloud",
"expectedStatuses": ["200-399"] "expectedStatuses": ["200-399"]

View file

@ -40,6 +40,7 @@ PBKDF2_ITERATIONS = 390_000
@dataclass(frozen=True) @dataclass(frozen=True)
class Service: class Service:
name: str name: str
group: str
url: str url: str
display_url: str display_url: str
method: str method: str
@ -337,6 +338,7 @@ def services_from_data(data: dict[str, Any]) -> list[Service]:
services.append( services.append(
Service( Service(
name=name, name=name,
group=str(item.get("group", "Main Services")).strip() or "Main Services",
url=url, url=url,
display_url=str(item.get("displayUrl", url)).strip() or url, display_url=str(item.get("displayUrl", url)).strip() or url,
method=str(item.get("method", "GET")).strip().upper(), 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}") expected.append(f"{service.expected_min}-{service.expected_max}")
item: dict[str, Any] = { item: dict[str, Any] = {
"name": service.name, "name": service.name,
"group": service.group,
"url": service.url, "url": service.url,
"displayUrl": service.display_url, "displayUrl": service.display_url,
"method": service.method, "method": service.method,
@ -502,7 +505,10 @@ def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]:
total = len(results) total = len(results)
degraded = 0 < online < total degraded = 0 < online < total
if online == total: if total == 0:
color = 0x6B7280
summary = "No services configured."
elif online == total:
color = 0x10B981 color = 0x10B981
summary = f"🟢 Operational · {online}/{total} online" summary = f"🟢 Operational · {online}/{total} online"
elif degraded: elif degraded:
@ -514,14 +520,38 @@ def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]:
color = 0xEF4444 color = 0xEF4444
summary = f"🔴 Outage · {online}/{total} online" summary = f"🔴 Outage · {online}/{total} online"
service_lines = [] groups: dict[str, list[CheckResult]] = {}
state_lines = []
for result in results: for result in results:
icon = "🟢" if result.ok else "🔴" groups.setdefault(result.service.group, []).append(result)
label = "Online" if result.ok else "Issue"
service_lines.append(f"**{result.service.name}**") fields = []
state_lines.append(f"{icon} {label}") 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() interval = os.getenv("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)).strip()
return [ return [
@ -529,18 +559,7 @@ def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]:
"title": "The Mithral Archive", "title": "The Mithral Archive",
"description": summary, "description": summary,
"color": color, "color": color,
"fields": [ "fields": fields[:25],
{
"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,
}
],
"footer": {"text": f"Refreshes every {interval}s • Last checked {checked_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"}, "footer": {"text": f"Refreshes every {interval}s • Last checked {checked_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"},
"timestamp": checked_at.isoformat(), "timestamp": checked_at.isoformat(),
} }
@ -596,6 +615,7 @@ def print_preview(services: list[Service]) -> None:
def result_to_jsonable(result: CheckResult) -> dict[str, Any]: def result_to_jsonable(result: CheckResult) -> dict[str, Any]:
return { return {
"name": result.service.name, "name": result.service.name,
"group": result.service.group,
"url": result.service.url, "url": result.service.url,
"displayUrl": result.service.display_url, "displayUrl": result.service.display_url,
"ok": result.ok, "ok": result.ok,