Add dashboard frontend: sidebar, iframe viewer and settings page

This commit is contained in:
Archibold committed 2026-10-28 21:44:52 +11:00
1 parent 73315a939e
commit 85bad1d0e8
3 files changed
+552

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
/* UwuDash MK3 - dashboard front end. */
"use strict";
const el = {
serviceList: document.getElementById("service-list"),
viewer: document.getElementById("viewer"),
viewerTitle: document.getElementById("viewer-title"),
viewerUrl: document.getElementById("viewer-url"),
viewerFrame: document.getElementById("viewer-frame"),
emptyState: document.getElementById("empty-state"),
emptyText: document.getElementById("empty-text"),
reloadBtn: document.getElementById("reload-btn"),
settings: document.getElementById("settings"),
settingsToggle: document.getElementById("settings-toggle"),
settingsClose: document.getElementById("settings-close"),
serviceTable: document.getElementById("service-table"),
form: document.getElementById("service-form"),
formTitle: document.getElementById("form-title"),
name: document.getElementById("f-name"),
url: document.getElementById("f-url"),
desc: document.getElementById("f-desc"),
icon: document.getElementById("f-icon"),
open: document.getElementById("f-open"),
headers: document.getElementById("f-headers"),
enabled: document.getElementById("f-enabled"),
formCancel: document.getElementById("form-cancel"),
toast: document.getElementById("toast"),
};
const state = { services: [], selected: null, editing: null };
/* ---------------------------------------------------------------- helpers */
const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char]);
}
function toast(message, kind = "ok") {
el.toast.textContent = message;
el.toast.className = `toast ${kind}`;
el.toast.hidden = false;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { el.toast.hidden = true; }, 4000);
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
if (!response.ok) {
let detail = response.statusText;
try {
const body = await response.json();
if (body.detail) detail = body.detail;
} catch (_) { /* keep statusText */ }
throw new Error(detail);
}
return response.status === 204 ? null : response.json();
}
function headersToText(headers) {
return Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`).join("\n");
}
function textToHeaders(text) {
const out = {};
for (const line of (text || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const index = trimmed.indexOf(":");
if (index === 0) continue;
out[trimmed.slice(0, index).trim()] = trimmed.slice(index + 1).trim();
}
return out;
}
/* ---------------------------------------------------------------- rendering */
function renderSidebar() {
el.serviceList.innerHTML = "";
if (!state.services.length) {
el.serviceList.innerHTML = '<p class="status">No services configured yet.</p>';
return;
}
for (const service of state.services) {
const button = document.createElement("button");
button.className =
"service-btn" +
(state.selected === service.id ? " active" : "") +
(service.enabled ? "" : " disabled");
button.title = service.url;
button.innerHTML =
`<span class="icon">${esc(service.icon || (service.name[0] || "?").toUpperCase())}</span>` +
`<span><span class="label">${esc(service.name)}</span>` +
`<span class="sub">${esc(service.description || service.origin)}</span></span>`;
button.addEventListener("click", () => openService(service));
el.serviceList.appendChild(button);
}
}
function renderTable() {
el.serviceTable.innerHTML = "";
if (!state.services.length) {
el.serviceTable.innerHTML = '<p class="status">No services yet - add one on the left.</p>';
return;
}
for (const service of state.services) {
const row = document.createElement("div");
row.className = "service-row";
row.innerHTML =
`<div class="meta"><strong>${esc(service.name)}</strong>` +
`<code>${esc(service.url)}</code>` +
`<span>${esc(service.description || "")}</span></div>` +
`<span class="status" data-status="${esc(service.id)}">${service.enabled ? "enabled" : "disabled"}</span>` +
`<button class="btn ghost" data-action="test" data-id="${esc(service.id)}">Test</button>` +
`<button class="btn ghost" data-action="edit" data-id="${esc(service.id)}">Edit</button>` +
`<button class="btn ghost" data-action="delete" data-id="${esc(service.id)}">Delete</button>`;
el.serviceTable.appendChild(row);
}
}
function findService(id) {
return state.services.find((s) => s.id === id);
}
/* ---------------------------------------------------------------- actions */
function openService(service) {
state.selected = service.id;
renderSidebar();
if (!service.enabled) {
toast(`${service.name} is disabled`, "error");
return;
}
if (service.open_in === "newtab") {
window.open(service.proxy_path, "_blank");
el.settings.hidden = true;
el.viewerFrame.hidden = true;
el.viewerFrame.src = "";
el.reloadBtn.hidden = true;
el.emptyState.hidden = false;
el.emptyText.textContent = `${service.name} opened in a new tab (${service.proxy_path})`;
return;
}
el.settings.hidden = true;
el.viewer.hidden = false;
el.emptyState.hidden = true;
el.viewerFrame.hidden = false;
el.reloadBtn.hidden = false;
el.viewerTitle.textContent = service.name;
el.viewerUrl.textContent = service.proxy_path;
el.viewerFrame.src = service.proxy_path;
}
function showSettings() {
el.settings.hidden = false;
el.viewer.hidden = true;
el.viewerFrame.src = "";
renderTable();
}
function resetForm() {
state.editing = null;
el.formTitle.textContent = "Add service";
el.name.value = "";
el.url.value = "";
el.desc.value = "";
el.icon.value = "";
el.open.value = "iframe";
el.headers.value = "";
el.enabled.checked = true;
}
function fillForm(service) {
state.editing = service.id;
el.formTitle.textContent = `Edit ${service.name}`;
el.name.value = service.name;
el.url.value = service.url;
el.desc.value = service.description || "";
el.icon.value = service.icon || "";
el.open.value = service.open_in;
el.headers.value = headersToText(service.headers);
el.enabled.checked = service.enabled;
}
async function refresh() {
state.services = await api("/api/services");
renderSidebar();
renderTable();
}
async function saveService(event) {
event.preventDefault();
const payload = {
name: el.name.value.trim(),
url: el.url.value.trim(),
description: el.desc.value.trim(),
icon: el.icon.value.trim(),
open_in: el.open.value,
enabled: el.enabled.checked,
headers: textToHeaders(el.headers.value),
};
try {
const saved = state.editing
? await api(`/api/services/${state.editing}`, { method: "PATCH", body: JSON.stringify(payload) })
: await api("/api/services", { method: "POST", body: JSON.stringify(payload) });
toast(state.editing ? `Updated ${saved.name}` : `Added ${saved.name}`);
resetForm();
await refresh();
} catch (error) {
toast(error.message, "error");
}
}
async function deleteService(id) {
const service = findService(id);
if (!service || !confirm(`Delete ${service.name}?`)) return;
try {
await api(`/api/services/${id}`, { method: "DELETE" });
if (state.selected === id) state.selected = null;
toast(`Deleted ${service.name}`);
await refresh();
} catch (error) {
toast(error.message, "error");
}
}
async function testService(id, statusEl) {
statusEl.textContent = "testing...";
try {
const health = await api(`/api/services/${id}/health`);
statusEl.textContent = health.reachable
? `reachable (${health.status})`
: `unreachable: ${health.error || health.status}`;
statusEl.className = `status ${health.reachable ? "ok" : "bad"}`;
} catch (error) {
statusEl.textContent = error.message;
statusEl.className = "status bad";
}
}
/* ---------------------------------------------------------------- wiring */
el.serviceTable.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const id = button.dataset.id;
if (button.dataset.action === "edit") {
const service = findService(id);
if (service) fillForm(service);
} else if (button.dataset.action === "delete") {
deleteService(id);
} else if (button.dataset.action === "test") {
const statusEl = el.serviceTable.querySelector(`[data-status="${id}"]`);
testService(id, statusEl);
}
});
el.form.addEventListener("submit", saveService);
el.formCancel.addEventListener("click", resetForm);
el.settingsToggle.addEventListener("click", showSettings);
el.settingsClose.addEventListener("click", () => {
el.settings.hidden = true;
el.viewer.hidden = false;
});
el.reloadBtn.addEventListener("click", () => {
if (el.viewerFrame.src) el.viewerFrame.src = el.viewerFrame.src;
});
refresh().catch((error) => toast(error.message, "error"));
+191
View File
@@ -0,0 +1,191 @@
:root {
--bg: #10131a;
--panel: #171c26;
--panel-2: #1f2532;
--border: #2a3242;
--fg: #e8edf5;
--muted: #8b96a8;
--accent: #4f8cff;
--success: #3fbf7f;
--error: #e0556b;
--warning: #d9a441;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f6fa;
--panel: #ffffff;
--panel-2: #eef2f7;
--border: #d5dde8;
--fg: #16202e;
--muted: #61708a;
--accent: #2f6fd0;
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
min-height: 100vh;
}
/* ---------------------------------------------------------------- sidebar */
.sidebar {
width: 220px;
flex: 0 0 220px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px 12px;
}
.brand { display: flex; align-items: baseline; gap: 8px; margin-bottom: 16px; }
.brand-name { font-size: 18px; font-weight: 700; }
.brand-sub { color: var(--muted); font-size: 12px; }
.service-list { display: flex; flex-direction: column; gap: 8px; flex: 1; }
.service-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-2);
color: var(--fg);
cursor: pointer;
text-align: left;
font: inherit;
}
.service-btn:hover { border-color: var(--accent); }
.service-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; }
.service-btn .icon { font-size: 16px; }
.service-btn .label { display: block; font-weight: 600; }
.service-btn .sub { display: block; font-size: 11px; color: var(--muted); }
.service-btn.disabled { opacity: 0.5; }
.sidebar-foot { margin-top: 12px; }
/* ------------------------------------------------------------------- main */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.viewer { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.viewer-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.viewer-title { font-weight: 600; }
.viewer-url { color: var(--muted); font-size: 12px; }
.viewer-bar .btn { margin-left: auto; }
#viewer-frame { flex: 1; width: 100%; border: 0; background: var(--bg); }
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
padding: 40px;
}
/* --------------------------------------------------------------- settings */
.settings { padding: 20px; }
.settings-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.settings-head h1 { margin: 0; font-size: 20px; }
.settings-head .btn { margin-left: auto; }
.settings-grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 16px;
align-items: start;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.form { display: flex; flex-direction: column; gap: 10px; }
.form h2 { margin: 0 0 4px; font-size: 16px; }
.form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); }
.form input, .form select, .form textarea {
font: inherit;
color: var(--fg);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
}
.form .checkbox { flex-direction: row; align-items: center; }
.form-actions { display: flex; gap: 8px; margin-top: 6px; }
.service-table { display: flex; flex-direction: column; gap: 10px; }
.service-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 12px;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.service-row .meta { min-width: 0; }
.service-row .meta strong { display: block; }
.service-row .meta span { color: var(--muted); font-size: 12px; display: block; }
.service-row .meta code { color: var(--muted); font-size: 12px; }
.status { font-size: 12px; color: var(--muted); }
.status.ok { color: var(--success); }
.status.bad { color: var(--error); }
/* ------------------------------------------------------------------ btns */
.btn {
font: inherit;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
}
.btn.ghost { background: transparent; color: var(--fg); border-color: var(--border); }
.btn:hover { filter: brightness(1.1); }
/* ----------------------------------------------------------------- toast */
.toast {
position: fixed;
bottom: 16px;
right: 16px;
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 10px 14px;
max-width: 360px;
font-size: 13px;
}
.toast.error { border-left-color: var(--error); }
.toast.ok { border-left-color: var(--success); }
@media (max-width: 860px) {
.settings-grid { grid-template-columns: 1fr; }
.sidebar { width: 180px; flex-basis: 180px; }
}
+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UwuDash MK3</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<nav class="sidebar">
<div class="brand">
<span class="brand-name">UwuDash</span>
<span class="brand-sub">mk3</span>
</div>
<div class="service-list" id="service-list"></div>
<div class="sidebar-foot">
<button class="btn ghost" id="settings-toggle">Settings</button>
</div>
</nav>
<main class="main">
<section class="viewer" id="viewer">
<header class="viewer-bar">
<span class="viewer-title" id="viewer-title">Nothing selected</span>
<span class="viewer-url" id="viewer-url"></span>
<button class="btn ghost" id="reload-btn" hidden>Reload</button>
</header>
<iframe id="viewer-frame" title="Service view" hidden></iframe>
<div class="empty" id="empty-state">
<p id="empty-text">Pick a service from the sidebar, or add one in Settings.</p>
</div>
</section>
<section class="settings" id="settings" hidden>
<header class="settings-head">
<h1>Service settings</h1>
<button class="btn ghost" id="settings-close">Back to dashboard</button>
</header>
<div class="settings-grid">
<form id="service-form" class="card form" autocomplete="off">
<h2 id="form-title">Add service</h2>
<label>
Name
<input id="f-name" required maxlength="120" placeholder="Grafana" />
</label>
<label>
URL
<input id="f-url" required placeholder="http://localhost:3000" />
</label>
<label>
Description
<input id="f-desc" maxlength="500" placeholder="Metrics dashboards" />
</label>
<label>
Icon
<input id="f-icon" maxlength="8" placeholder="emoji or initials" />
</label>
<label>
Open as
<select id="f-open">
<option value="iframe">iframe</option>
<option value="newtab">new tab</option>
</select>
</label>
<label>
Extra headers (one per line, <code>Name: value</code>)
<textarea id="f-headers" rows="3" placeholder="Authorization: Bearer ..."></textarea>
</label>
<label class="checkbox">
<input type="checkbox" id="f-enabled" checked /> Enabled
</label>
<div class="form-actions">
<button type="submit" class="btn">Save</button>
<button type="button" class="btn ghost" id="form-cancel">Cancel</button>
</div>
</form>
<div class="service-table" id="service-table"></div>
</div>
</section>
</main>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>