Files
FabledSteward/fabledscryer/templates/base.html
T
bvandeusen 230b542015 feat: rename to FabledScryer, multi-dashboard system, plugin management, branding
- Rename package fablednetmon → fabledscryer throughout
- Multi-dashboard: ownership, per-user defaults, HTMX edit (add/remove/reorder)
- Read-only share tokens scoped to individual dashboards
- Dashboard edit is HTMX-driven (no page reloads)
- Plugin management system: remote catalog, download/install, hot-reload, in-app restart
- plugin_index.py: fetch/cache remote index.yaml; default URL → bvandeusen/fabledscryer-plugins
- plugin_manager.py: download_and_install_plugin, hot_reload_plugin, restart_app
  - ZIP extraction handles GitHub archive formats (name-v1.0.0/, name-main/)
- Settings split into tabbed sections: General, Notifications, Ansible, Plugins
- Plugins tab: catalog browser (HTMX), install/activate/update/restart actions
- UI/branding: dark palette (#07071a), crystal ball SVG logo, animated star field,
  Libertinus Serif applied to headings, nav, labels, and section titles
- Widget registry (core/widgets.py) for dashboard plugin integration
- UPS widget.html (dashboard card) and settings/_tabs.html include
- Migrations 0005–0008: dashboards, is_default, ownership, share tokens
- docs/plugins/: writing-a-plugin.md updated with publishing guide,
  index.yaml.example template for fabledscryer-plugins repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:27:56 -04:00

356 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Fabled Scryer{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.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;
--accent-hover: #9070f8;
--gold: #c9a84c;
--gold-dim: #221b00;
--green: #26d96b;
--green-dim: #0e3a1e;
--yellow: #e6b818;
--yellow-dim: #3a3000;
--orange: #e07828;
--orange-dim: #3a1e00;
--red: #e83848;
--red-dim: #3a0e14;
--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; }
a:hover { color: var(--accent-hover); }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
/* Star field */
#star-field { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
@keyframes star-orbit { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes star-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Nav */
nav {
background: var(--bg-card);
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 0;
border-bottom: 1px solid var(--border-mid);
height: 48px;
position: relative;
z-index: 10;
}
nav .brand {
font-family: var(--font-serif);
font-weight: 700;
font-size: 1.1rem;
margin-right: 2rem;
letter-spacing: 0.01em;
display: flex;
align-items: center;
gap: 0.45rem;
background: linear-gradient(110deg, #c8b4f8 0%, var(--gold) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-decoration: none;
}
nav .brand svg { flex-shrink: 0; }
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
nav a.nav-end { margin-left: auto; }
/* Layout */
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.alert-error { background: var(--red-dim); border: 1px solid #6a1820; color: #ffb0b8; }
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
/* Cards */
.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; }
/* Typography */
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 600; color: #c8b4f8; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
.section-title { font-family: var(--font-serif); font-size: 0.82rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
h1, h2, h3 { font-family: var(--font-serif); }
/* Tables */
.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); }
.table .td-actions { text-align: right; white-space: nowrap; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-serif); }
input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-mid);
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
select { cursor: pointer; }
textarea { resize: vertical; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
.btn:hover { background: var(--accent-hover); color: #fff; }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
.btn-danger { background: #7a1820; color: #ffb0b0; }
.btn-danger:hover { background: #9a2030; }
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
.btn-warn:hover { background: #4a4000; }
/* Badges / Status */
.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; }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-orange { background: var(--orange-dim); color: var(--orange); }
.badge-gold { background: var(--gold-dim); color: var(--gold); }
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
.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); }
/* Empty state */
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
/* Stat cards (dashboard) */
.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; }
/* Ping pills */
.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; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.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 headers */
.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; }
.widget-link { font-size:0.8rem; color:var(--text-muted); }
.widget-link:hover { color:var(--text); }
/* Time range selector */
.range-btn { background:none; border:1px solid var(--border-mid); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
.range-btn:hover { background:var(--bg-elevated); color:var(--text); border-color:var(--accent); }
.range-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; }
</style>
{% block head %}{% endblock %}
</head>
<body>
{# Star field — orbits around top-left corner, populated by JS below #}
<div id="star-field"></div>
{% if session.user_id is defined %}
<nav>
<a href="/" class="brand">
{# Crystal ball logo #}
<svg width="20" height="24" viewBox="0 0 20 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="orb-grad" cx="38%" cy="32%" r="62%">
<stop offset="0%" stop-color="#6040c8"/>
<stop offset="55%" stop-color="#2a1468"/>
<stop offset="100%" stop-color="#0e0830"/>
</radialGradient>
<radialGradient id="glow-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#c9a84c" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#c9a84c" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Ball body -->
<circle cx="10" cy="10" r="9" fill="url(#orb-grad)" stroke="#7c5ef4" stroke-width="0.5"/>
<!-- Inner gold glow -->
<circle cx="10" cy="10" r="5" fill="url(#glow-grad)"/>
<!-- Reflection highlight -->
<ellipse cx="7.5" cy="7" rx="2.2" ry="1.1" fill="white" opacity="0.14" transform="rotate(-25 7.5 7)"/>
<!-- Inner star -->
<path d="M10 7.2 L10.45 8.85 L12.2 8.85 L10.88 9.9 L11.35 11.55 L10 10.55 L8.65 11.55 L9.12 9.9 L7.8 8.85 L9.55 8.85 Z"
fill="#c9a84c" opacity="0.55"/>
<!-- Stand neck -->
<path d="M8.2 19 L8.9 15.8 L11.1 15.8 L11.8 19" fill="none" stroke="#5040a0" stroke-width="0.75" stroke-linejoin="round"/>
<!-- Stand base -->
<ellipse cx="10" cy="19.5" rx="4" ry="1" fill="none" stroke="#5040a0" stroke-width="0.75"/>
</svg>
Fabled Scryer
</a>
<a href="/">Dashboard</a>
<a href="/hosts/">Hosts</a>
<a href="/ping/">Ping</a>
<a href="/dns/">DNS</a>
<a href="/alerts/">Alerts</a>
<a href="/ansible/">Ansible</a>
{% if session.user_role == 'admin' %}
<a href="/settings/">Settings</a>
{% endif %}
<a href="/logout" class="nav-end">{{ session.username }}</a>
</nav>
{% endif %}
<script>
function setTimeRange(val) {
var input = document.getElementById('time-range');
if (input) input.value = val;
document.querySelectorAll('.range-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.range === val);
});
var url = new URL(window.location);
url.searchParams.set('range', val);
history.replaceState({}, '', url);
document.body.dispatchEvent(new CustomEvent('rangeChange'));
}
</script>
<main>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
(function () {
var field = document.getElementById('star-field');
if (!field) return;
var W = window.innerWidth;
var H = window.innerHeight;
var maxR = Math.sqrt(W * W + H * H);
// Subtle purple glow anchoring the field to the top-left corner
var glow = document.createElement('div');
glow.style.cssText = 'position:absolute;width:500px;height:500px;left:-250px;top:-250px;border-radius:50%;background:radial-gradient(circle,rgba(124,94,244,0.06) 0%,transparent 65%);pointer-events:none;';
field.appendChild(glow);
// Compute polygon points for a 5-pointed star inside a size×size box
function starPoints(size) {
var cx = size / 2, cy = size / 2;
var R = size / 2 * 0.95; // outer radius
var r = R * 0.382; // inner radius (golden ratio)
var pts = [];
for (var i = 0; i < 5; i++) {
var oa = (i * 72 - 90) * Math.PI / 180;
var ia = (i * 72 - 90 + 36) * Math.PI / 180;
pts.push((cx + R * Math.cos(oa)).toFixed(2) + ',' + (cy + R * Math.sin(oa)).toFixed(2));
pts.push((cx + r * Math.cos(ia)).toFixed(2) + ',' + (cy + r * Math.sin(ia)).toFixed(2));
}
return pts.join(' ');
}
// 22 wizard-hat stars: [size(px), opacity, isGold, orbitDuration(s), spinDuration(s)]
var defs = [
[230, 0.09, true, 200, 28],
[144, 0.08, true, 260, 40],
[ 92, 0.10, false, 150, 22],
[190, 0.07, true, 310, 35],
[116, 0.09, false, 175, 18],
[260, 0.07, true, 280, 45],
[ 80, 0.11, false, 135, 20],
[164, 0.08, true, 230, 32],
[104, 0.09, false, 165, 24],
[200, 0.07, true, 295, 38],
[ 70, 0.10, false, 120, 16],
[150, 0.08, true, 245, 30],
[ 88, 0.09, false, 140, 21],
[210, 0.07, true, 320, 42],
[128, 0.08, false, 180, 26],
[ 76, 0.10, true, 160, 19],
[175, 0.07, true, 290, 36],
[ 96, 0.09, false, 155, 23],
[220, 0.07, true, 305, 44],
[110, 0.08, false, 170, 25],
[ 84, 0.09, true, 235, 31],
[140, 0.08, false, 185, 27],
];
var n = defs.length;
defs.forEach(function (def, i) {
var size = def[0], opacity = def[1], isGold = def[2], orbitDur = def[3], spinDur = def[4];
// Spread evenly across a ~110° fan from top-left, small random jitter
var angleDeg = 4 + (i / (n - 1)) * 108 + (Math.random() * 8 - 4);
var angle = angleDeg * Math.PI / 180;
var radius = (0.15 + (i / n) * 0.7 + Math.random() * 0.15) * maxR * 0.88 + 60;
// Star center position in parent coords
var cx = Math.cos(angle) * radius;
var cy = Math.sin(angle) * radius;
var left = cx - size / 2;
var top = cy - size / 2;
var orbitDelay = -(Math.random() * orbitDur);
var spinDelay = -(Math.random() * spinDur);
var color = isGold ? '#c9a84c' : '#c8b4f8';
// Outer wrapper handles orbit — transform-origin points back to (0,0) corner
var wrapper = document.createElement('div');
wrapper.style.cssText = [
'position:absolute',
'left:' + left + 'px',
'top:' + top + 'px',
'width:' + size + 'px',
'height:'+ size + 'px',
'opacity:' + opacity,
'transform-origin:' + (-left) + 'px ' + (-top) + 'px',
'animation:star-orbit ' + orbitDur + 's ' + orbitDelay + 's linear infinite',
'pointer-events:none'
].join(';');
// Inner SVG handles spin — transform-origin defaults to 50% 50% (star center)
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', size);
svg.setAttribute('height', size);
svg.setAttribute('viewBox', '0 0 ' + size + ' ' + size);
svg.style.cssText = 'display:block;animation:star-spin ' + spinDur + 's ' + spinDelay + 's linear infinite;';
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
poly.setAttribute('points', starPoints(size));
poly.setAttribute('fill', color);
svg.appendChild(poly);
wrapper.appendChild(svg);
field.appendChild(wrapper);
});
}());
</script>
</body>
</html>