Add dashboard frontend: sidebar, iframe viewer and settings page
This commit is contained in:
1 parent
73315a939e
commit
85bad1d0e8
3 files changed
+552
No files matched your search
@@ -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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
|
||||
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"));
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user