refactor: rename package directory fabledscryer → roundtable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:10:31 -04:00
parent fd9c6d941d
commit 8aad2ab43d
117 changed files with 0 additions and 0 deletions
@@ -0,0 +1,107 @@
{# dashboard/_edit_panels.html — returned by all mutation routes and included by edit.html #}
<div id="edit-panels" style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start;">
{# ── Current layout ───────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Current Layout</div>
{% if widgets %}
<div id="widget-sort-list" data-dash-id="{{ dashboard.id }}" style="display:grid;gap:0.5rem;">
{% for item in widgets %}
<div class="card" data-widget-id="{{ item.db.id }}"
style="margin-bottom:0;display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<div class="drag-handle" style="flex-shrink:0;cursor:grab;color:var(--text-muted);
font-size:1.1rem;line-height:1;user-select:none;"
title="Drag to reorder"></div>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.9rem;">{{ item.title }}</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;">{{ item.defn.description }}</div>
</div>
<button hx-post="/d/{{ dashboard.id }}/edit/remove/{{ item.db.id }}"
hx-target="#edit-panels" hx-swap="outerHTML"
class="btn btn-danger btn-sm" style="flex-shrink:0;">Remove</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;font-size:0.9rem;">
No widgets yet. Draw one from the right to begin.
</div>
{% endif %}
</div>
{# ── Available widgets ────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Available Widgets</div>
{% if addable %}
<div style="display:grid;gap:0.5rem;">
{% for w in addable %}
<div class="card" style="margin-bottom:0;padding:0;overflow:hidden;">
<details>
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
padding:0.85rem 1rem;cursor:pointer;list-style:none;user-select:none;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.875rem;">{{ w.label }}</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
</div>
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
</summary>
<form method="post"
action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-post="/d/{{ dashboard.id }}/edit/add/{{ w.key }}"
hx-target="#edit-panels"
hx-swap="outerHTML"
style="padding:0.75rem 1rem;padding-top:0;border-top:1px solid var(--border);">
<div style="display:grid;gap:0.5rem;padding-top:0.75rem;">
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
Widget title <span style="color:var(--text-dim);">(optional)</span>
</label>
<input type="text" name="title" placeholder="{{ w.label }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
</div>
{% for p in w.params %}
<div>
<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
{{ p.label }}
</label>
{% if p.type == "select" %}
<select name="param_{{ p.key }}"
style="width:100%;padding:0.32rem 0.6rem;background:var(--bg);
border:1px solid var(--border-mid);border-radius:4px;
color:var(--text);font-size:0.85rem;">
{% for opt in p.options %}
<option value="{{ opt.value }}" {% if opt.value == p.default %}selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
</select>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-sm" style="width:100%;margin-top:0.1rem;">
Add to Dashboard
</button>
</div>
</form>
</details>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem 0;">
No plugin widgets available. Enable plugins in Settings → Plugins.
</div>
{% endif %}
</div>
</div>
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Edit: {{ dashboard.name }} — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Edit: {{ dashboard.name }}</h1>
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Done</a>
</div>
{% include "dashboard/_edit_panels.html" %}
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script>
(function () {
function initSortable() {
var el = document.getElementById('widget-sort-list');
if (!el) return;
var dashId = el.dataset.dashId;
Sortable.create(el, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: function () {
var order = Array.from(el.querySelectorAll('[data-widget-id]'))
.map(function (row) { return parseInt(row.dataset.widgetId, 10); });
fetch('/d/' + dashId + '/edit/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: order }),
});
},
});
}
// Init on first load and after every HTMX swap (add/remove re-renders the list)
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSwap', initSortable);
})();
</script>
<style>
.sortable-ghost { opacity: 0.4; }
.drag-handle:active { cursor: grabbing; }
</style>
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}{{ dashboard.name }} — Fabled Scryer{% endblock %}
{% block content %}
{# ── Header ────────────────────────────────────────────────────────────────── #}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<h1 class="page-title" style="margin-bottom:0;">{{ dashboard.name }}</h1>
{% if dashboard.is_default %}
<span class="badge badge-gold" style="font-size:0.7rem;">Default</span>
{% endif %}
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
{# Dashboard switcher — shown when more than one dashboard exists #}
{% if all_dashboards | length > 1 %}
<select onchange="window.location='/d/'+this.value"
style="font-size:0.82rem;padding:0.3rem 0.6rem;background:var(--bg-card);border:1px solid var(--border-mid);border-radius:5px;color:var(--text);cursor:pointer;">
{% for d in all_dashboards %}
<option value="{{ d.id }}" {% if d.id == dashboard.id %}selected{% endif %}>{{ d.name }}</option>
{% endfor %}
</select>
{% endif %}
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost btn-sm">Share</a>
{% endif %}
<a href="/dashboards/" class="btn btn-ghost btn-sm">Dashboards</a>
</div>
</div>
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Hosts</div>
<span class="stat-val">{{ total_hosts }}</span>
<span class="stat-label">total</span>
<div style="margin-top:0.5rem;"><a href="/hosts/" style="font-size:0.8rem;">Manage →</a></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Ping</div>
{% if ping_up + ping_down == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ ping_up }}</span><span class="stat-label">up</span>
{% if ping_down %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ ping_down }}</span><span class="stat-label">down</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">DNS</div>
{% if dns_ok + dns_fail == 0 %}
<span style="color:var(--text-muted);font-size:0.85rem;">No hosts</span>
{% else %}
<span class="stat-val" style="color:var(--green);">{{ dns_ok }}</span><span class="stat-label">ok</span>
{% if dns_fail %}&nbsp;<span class="stat-val" style="color:var(--red);">{{ dns_fail }}</span><span class="stat-label">failed</span>{% endif %}
{% endif %}
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Alerts</div>
<a href="/alerts/" style="font-size:0.85rem;">View rules →</a>
</div>
</div>
{# ── Widget grid ───────────────────────────────────────────────────────────── #}
{% if widgets %}
<div style="column-width:380px;column-count:3;column-gap:1rem;">
{% for item in widgets %}
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="widget-header">
<span class="widget-title">{{ item.title }}</span>
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
</div>
<div id="widget-{{ item.db.id }}"
hx-get="{{ item.hx_url }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML">
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="font-size:1.1rem;color:var(--text-muted);margin-bottom:1rem;">No widgets on this dashboard yet.</div>
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn">Add Widgets</a>
{% endif %}
</div>
{% endif %}
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Dashboards — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Dashboards</h1>
</div>
<div style="display:grid;grid-template-columns:1fr 300px;gap:1.5rem;align-items:start;">
{# ── Left: dashboard lists ─────────────────────────────────────────── #}
<div style="display:grid;gap:1.5rem;">
{# My dashboards #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">My Dashboards</div>
{% if my_dashboards %}
<div style="display:grid;gap:0.5rem;">
{% for dash in my_dashboards %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.is_default %}
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
{% endif %}
{% if dash.is_shared %}
<span class="badge badge-dim" style="font-size:0.68rem;">Shared</span>
{% endif %}
</div>
</div>
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<details style="position:relative;">
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">
<input type="text" name="name" value="{{ dash.name }}"
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
<button type="submit" class="btn btn-sm">Save</button>
</form>
</div>
</details>
<form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}">
<button type="submit" class="btn btn-ghost btn-sm">{{ '🔒' if dash.is_shared else '🔗' }}</button>
</form>
{% if not dash.is_default %}
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
<button type="submit" class="btn btn-ghost btn-sm"></button>
</form>
{% endif %}
{% if my_dashboards | length > 1 %}
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
You don't have any personal dashboards yet.
</div>
{% endif %}
</div>
{# Shared / system dashboards #}
{% if shared_dashboards %}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Shared Dashboards</div>
<div style="display:grid;gap:0.5rem;">
{% for dash in shared_dashboards %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.owner_id is none %}
<span class="badge badge-dim" style="font-size:0.68rem;">System</span>
{% endif %}
{% if dash.is_default %}
<span class="badge badge-gold" style="font-size:0.68rem;">Global Default</span>
{% endif %}
</div>
</div>
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
<a href="/d/{{ dash.id }}" class="btn btn-ghost btn-sm">View</a>
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
<button type="submit" class="btn btn-ghost btn-sm"></button>
</form>
{# Admins can also edit system dashboards #}
{% if user_role == 'admin' %}
<a href="/d/{{ dash.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{# ── Right: create new dashboard ──────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div>
<div class="card" style="margin-bottom:0;">
<form method="post" action="/dashboards/create">
<div class="form-group" style="margin-bottom:0.75rem;">
<label>Name</label>
<input type="text" name="name" placeholder="e.g. Network Overview" required>
</div>
<button type="submit" class="btn">Create</button>
</form>
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
New dashboards are private by default.<br>
Use 🔗 to share with all users.<br>
Use ★ to set as your default view.
</div>
</div>
</div>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Share: {{ dashboard.name }} — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Share: {{ dashboard.name }}</h1>
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost btn-sm">Back to Dashboard</a>
</div>
<div style="display:grid;grid-template-columns:1fr 320px;gap:1.5rem;align-items:start;max-width:900px;">
{# ── Active tokens ─────────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Active Share Links</div>
{% if tokens %}
<div style="display:grid;gap:0.5rem;">
{% for token in tokens %}
{% set expired = token.expires_at is not none and token.expires_at <= now %}
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;{% if expired %}opacity:0.5;{% endif %}">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.875rem;">
{{ token.label or "Unnamed link" }}
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.15rem;">
Created {{ token.created_at.strftime("%Y-%m-%d") }}
{% if token.expires_at %}
· Expires {{ token.expires_at.strftime("%Y-%m-%d") }}
{% else %}
· Never expires
{% endif %}
</div>
</div>
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No active share links. Create one on the right.
</div>
{% endif %}
</div>
{# ── Create new token ──────────────────────────────────────────────── #}
<div>
<div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div>
<div class="card" style="margin-bottom:0;">
<form method="post" action="/d/{{ dashboard.id }}/share/create">
<div class="form-group" style="margin-bottom:0.75rem;">
<label>Label <span style="color:var(--text-muted);font-size:0.78rem;">(optional)</span></label>
<input type="text" name="label" placeholder="e.g. Public display screen">
</div>
<div class="form-group" style="margin-bottom:0.75rem;">
<label>Expires after</label>
<select name="expires_in">
<option value="">Never</option>
<option value="1">1 day</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<button type="submit" class="btn">Generate Link</button>
</form>
</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
The link is shown <strong>once</strong> after creation.<br>
Revoke it at any time to cut off access immediately.
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Share Link Created — Fabled Scryer{% endblock %}
{% block content %}
<div style="max-width:640px;">
<h1 class="page-title">Share Link Created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
Copy this link now — it cannot be shown again
</div>
{% set share_url = request.host_url.rstrip('/') + '/share/' + raw_token %}
<div style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" id="share-url" value="{{ share_url }}" readonly
style="flex:1;font-family:ui-monospace,monospace;font-size:0.8rem;background:var(--bg);color:var(--text);">
<button onclick="navigator.clipboard.writeText(document.getElementById('share-url').value).then(()=>this.textContent='Copied!')"
class="btn btn-ghost btn-sm" style="white-space:nowrap;">Copy</button>
</div>
<div style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted);">
{% if label %}<strong>Label:</strong> {{ label }} &nbsp;·&nbsp;{% endif %}
{% if expires_at %}<strong>Expires:</strong> {{ expires_at.strftime("%Y-%m-%d") }}
{% else %}<strong>Expires:</strong> Never{% endif %}
</div>
</div>
<div style="display:flex;gap:0.75rem;">
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost">Manage Share Links</a>
<a href="/d/{{ dashboard.id }}" class="btn btn-ghost">Back to Dashboard</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ dashboard.name }} — Fabled Scryer</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Libertinus+Serif:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #07071a; --bg-card: #0f0f24; --bg-elevated: #17172e;
--border: #1e1e3e; --border-mid: #28284c;
--text: #d2cef0; --text-muted: #6858a0; --text-dim: #282860;
--accent: #7c5ef4; --gold: #c9a84c;
--green: #26d96b; --green-dim: #0e3a1e;
--yellow: #e6b818; --red: #e83848; --red-dim: #3a0e14;
--orange: #e07828;
--font-serif: 'Libertinus Serif', Georgia, serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
.section-title { font-family: var(--font-serif); font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: #ff8090; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
.ping-name { font-size:0.875rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; }
.pill-empty { background:var(--bg-elevated); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); } .ping-cur-warn { color:var(--yellow); }
.ping-cur-bad { color:var(--orange); } .ping-cur-down { color:var(--red); font-weight:bold; }
.ping-cur-nd { color:var(--text-dim); }
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
header { background: var(--bg-card); padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-mid); height: 48px; font-family: var(--font-serif); }
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; }
</style>
</head>
<body>
<header>
<span style="font-weight:700;font-size:1rem;background:linear-gradient(110deg,#c8b4f8,#c9a84c);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">
{{ dashboard.name }}
</span>
<span style="font-size:0.78rem;color:var(--text-muted);">Fabled Scryer · read-only view</span>
</header>
<main>
{% if widgets %}
<div style="column-width:380px;column-count:3;column-gap:1rem;">
{% for item in widgets %}
<div class="card ping-card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="widget-header">
<span class="widget-title">{{ item.title }}</span>
</div>
{# Pass share token as &s= param so require_role allows through #}
<div id="widget-{{ item.db.id }}"
hx-get="{{ item.hx_url }}&s={{ raw_token }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML">
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color:var(--text-muted);text-align:center;padding:4rem;font-size:0.9rem;">
This dashboard has no widgets.
</div>
{% endif %}
</main>
</body>
</html>