feat(health): Little Blue health band — avatar, grouped service tiles, local icons
This commit is contained in:
14
public/components/littleblue_avatar.js
Normal file
14
public/components/littleblue_avatar.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { el } from '../dom.js';
|
||||||
|
// Placeholder: a simple blue humanoid water-wisp. Swap for final art later.
|
||||||
|
export function littleblueAvatar() {
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 40 48'); svg.setAttribute('width', '40'); svg.setAttribute('height', '48');
|
||||||
|
svg.innerHTML = `
|
||||||
|
<defs><radialGradient id="lbg" cx="40%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#bff0f3"/><stop offset="55%" stop-color="#4fb6c4"/><stop offset="100%" stop-color="#1d5f70"/>
|
||||||
|
</radialGradient></defs>
|
||||||
|
<path d="M20 2 C12 14 6 20 6 30 a14 14 0 0 0 28 0 C34 20 28 14 20 2 Z" fill="url(#lbg)"/>
|
||||||
|
<circle cx="15" cy="28" r="2.4" fill="#06222a"/><circle cx="25" cy="28" r="2.4" fill="#06222a"/>
|
||||||
|
<path d="M15 35 q5 4 10 0" stroke="#06222a" stroke-width="1.6" fill="none" stroke-linecap="round"/>`;
|
||||||
|
return el('div', { class: 'lb-av' }, svg);
|
||||||
|
}
|
||||||
11
public/components/service_tile.js
Normal file
11
public/components/service_tile.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { el, safeHref } from '../dom.js';
|
||||||
|
export function serviceTile(s) {
|
||||||
|
const img = el('img', { class: 'tile-icon', loading: 'lazy', src: `/api/icons/${s.icon}.png`, alt: s.name });
|
||||||
|
img.onerror = () => img.replaceWith(el('div', { class: 'tile-icon-fb' }, (s.name[0] || '?').toUpperCase()));
|
||||||
|
return el('a', { class: `tile status-${s.status}`, href: safeHref(s.url), target: '_blank', rel: 'noreferrer' },
|
||||||
|
img,
|
||||||
|
el('div', { class: 'tile-main' },
|
||||||
|
el('div', { class: 'tile-nm' }, el('span', { class: 'dot' }), s.name),
|
||||||
|
el('div', { class: 'tile-host' }, s.host || '')),
|
||||||
|
el('span', { class: 'tile-go' }, 'open ↗'));
|
||||||
|
}
|
||||||
@@ -212,3 +212,31 @@ ul.plain li:last-child { border-bottom: none; }
|
|||||||
.sv-card:hover .sv-bar > i { box-shadow: 0 0 9px rgba(255,79,46,.55); }
|
.sv-card:hover .sv-bar > i { box-shadow: 0 0 9px rgba(255,79,46,.55); }
|
||||||
.sv-search-input{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-family:var(--font-mono);font-size:12px}
|
.sv-search-input{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-family:var(--font-mono);font-size:12px}
|
||||||
.sv-run{background:var(--accent-soft);border:1px solid var(--accent-dim);color:var(--accent);border-radius:5px;padding:3px 10px;font-family:var(--font-ui);font-size:11px;cursor:pointer}
|
.sv-run{background:var(--accent-soft);border:1px solid var(--accent-dim);color:var(--accent);border-radius:5px;padding:3px 10px;font-family:var(--font-ui);font-size:11px;cursor:pointer}
|
||||||
|
|
||||||
|
/* ===== Little Blue health band (Task 22) ===== */
|
||||||
|
#sv-health { margin-top: 28px; }
|
||||||
|
.lbwrap { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
||||||
|
.lb-av { width: 46px; height: 46px; filter: drop-shadow(0 0 10px rgba(125,211,216,.4)); animation: lb-bob 4s ease-in-out infinite; }
|
||||||
|
@keyframes lb-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||||
|
.lb-name { font-family: var(--font-display); letter-spacing: .08em; font-size: 15px; color: var(--text); }
|
||||||
|
.lb-sub { font-family: var(--font-body); font-style: italic; color: var(--lb); font-size: 14px; }
|
||||||
|
.lb-group { margin: 14px 0 8px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.lb-group .gname { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .14em; font-size: 11px; color: var(--muted); }
|
||||||
|
.lb-group .gcount { font-family: var(--font-mono); font-size: 10px; color: var(--lb); }
|
||||||
|
.lb-group .line { flex: 1; height: 1px; background: linear-gradient(90deg, rgba(125,211,216,.25), transparent); }
|
||||||
|
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||||
|
.tile { display: flex; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
|
||||||
|
background: linear-gradient(160deg, #14141c, #101017); text-decoration: none; color: var(--text); transition: border-color .25s, transform .25s; }
|
||||||
|
.tile:hover { transform: translateY(-2px); border-color: #37404a; }
|
||||||
|
.tile-icon, .tile-icon-fb { width: 26px; height: 26px; border-radius: 6px; flex: none; }
|
||||||
|
.tile-icon-fb { display: grid; place-items: center; background: #20202a; color: var(--muted); font-family: var(--font-mono); font-size: 13px; }
|
||||||
|
.tile-main { flex: 1; min-width: 0; }
|
||||||
|
.tile-nm { font-family: var(--font-mono); font-size: 12px; display: flex; align-items: center; gap: 7px; }
|
||||||
|
.tile-host { font-family: var(--font-mono); font-size: 10px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.tile .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.tile.status-ok .dot { background: var(--ok); box-shadow: 0 0 7px var(--ok); }
|
||||||
|
.tile.status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
|
||||||
|
.tile.status-down .dot { background: var(--bad); box-shadow: 0 0 7px var(--bad); }
|
||||||
|
.tile.status-unknown .dot { background: var(--muted); }
|
||||||
|
.tile-go { color: var(--lb); font-size: 11px; opacity: 0; transition: opacity .25s; }
|
||||||
|
.tile:hover .tile-go { opacity: 1; }
|
||||||
|
|||||||
27
public/views/health_band.js
Normal file
27
public/views/health_band.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { el, mount } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { littleblueAvatar } from '../components/littleblue_avatar.js';
|
||||||
|
import { serviceTile } from '../components/service_tile.js';
|
||||||
|
|
||||||
|
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
|
||||||
|
let host, timer;
|
||||||
|
async function load() {
|
||||||
|
if (!host) return;
|
||||||
|
try {
|
||||||
|
const groups = await api.get('/api/health/services');
|
||||||
|
const sections = groups.map(g =>
|
||||||
|
el('div', { class: 'lb-section' },
|
||||||
|
el('div', { class: 'lb-group' },
|
||||||
|
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
|
||||||
|
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
|
||||||
|
el('span', { class: 'line' })),
|
||||||
|
el('div', { class: 'tiles' }, g.services.map(serviceTile))));
|
||||||
|
mount(host,
|
||||||
|
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
||||||
|
el('div', {}, el('div', { class: 'lb-name' }, 'Little Blue'),
|
||||||
|
el('div', { class: 'lb-sub' }, 'Health & Uptime of the lab'))),
|
||||||
|
sections);
|
||||||
|
} catch { mount(host, el('span', { class: 'muted' }, 'Health band unavailable')); }
|
||||||
|
}
|
||||||
|
export function renderHealthBand(el_) { host = el_; load(); timer = setInterval(load, 60000); }
|
||||||
|
export function stopHealthBand() { clearInterval(timer); host = null; }
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { el, mount } from '../dom.js';
|
import { el, mount } from '../dom.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { renderHealthBand, stopHealthBand } from './health_band.js';
|
||||||
import { svCard } from '../components/sv_card.js';
|
import { svCard } from '../components/sv_card.js';
|
||||||
import { attachReorder } from '../components/sv_reorder.js';
|
import { attachReorder } from '../components/sv_reorder.js';
|
||||||
import { orderCards } from './cards/registry.js';
|
import { orderCards } from './cards/registry.js';
|
||||||
@@ -15,7 +16,7 @@ const CARD_MODULES = [clock, weather, hostPerf, jobs, inbox, search, speedtest];
|
|||||||
let active = []; // mounted cards needing stop()
|
let active = []; // mounted cards needing stop()
|
||||||
|
|
||||||
export async function render(main) {
|
export async function render(main) {
|
||||||
active.forEach(c => c.stop && c.stop()); active = [];
|
active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand();
|
||||||
mount(main,
|
mount(main,
|
||||||
el('h1', { class: 'view-h1' }, 'Sacred Valley'),
|
el('h1', { class: 'view-h1' }, 'Sacred Valley'),
|
||||||
el('p', { class: 'view-sub' }, 'The homelab, at a glance.'),
|
el('p', { class: 'view-sub' }, 'The homelab, at a glance.'),
|
||||||
@@ -43,5 +44,5 @@ export async function render(main) {
|
|||||||
try { await api.put('/api/dashboard/layout', { ...layout, card_order: newOrder }); layout.card_order = newOrder; }
|
try { await api.put('/api/dashboard/layout', { ...layout, card_order: newOrder }); layout.card_order = newOrder; }
|
||||||
catch (e) { console.error('save layout', e); }
|
catch (e) { console.error('save layout', e); }
|
||||||
});
|
});
|
||||||
// health band wiring arrives in Task 22.
|
renderHealthBand(document.getElementById('sv-health'));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user