176 lines
8.8 KiB
HTML
176 lines
8.8 KiB
HTML
{# Shared tool grid. Renders the home-dash sections + a client-side search filter.
|
|
NOTE: no HTMX auto-poll here — re-swapping the whole grid every 30s made cards
|
|
vanish on scroll and wiped the typed search. Status dots are static per catalogue. #}
|
|
<div id="tool-grids">
|
|
<div class="dash-search">
|
|
<input class="text-input" type="search" id="dashFilter"
|
|
placeholder="Search the home dash…"
|
|
data-filter-input
|
|
oninput="window.__dashFilter=this.value;applyDashFilter()">
|
|
</div>
|
|
{% for section in sections %}
|
|
<section class="section" data-dash-section>
|
|
<div class="section-head">
|
|
<button type="button" class="section-toggle" data-section-toggle
|
|
title="Collapse / expand this panel" aria-label="Collapse expand panel" aria-expanded="true">▾</button>
|
|
<span class="category-pill">
|
|
<span class="pill-dot" style="background:{{ section.color }}"></span>
|
|
{{ section.label }}
|
|
</span>
|
|
</div>
|
|
<div class="tool-grid">
|
|
{% for tool in section.tools %}
|
|
<a class="tool-card" href="/tool/{{ tool.id }}" data-dash-card>
|
|
<div class="tool-card-top">
|
|
<span class="tool-icon" style="background:{{ section.color }}">{{ tool.name[:1]|upper }}</span>
|
|
<span class="status-dot status-{{ tool.status }}"></span>
|
|
</div>
|
|
<h3 class="tool-name">{{ tool.name }}</h3>
|
|
<p class="tool-desc">{{ tool.description }}</p>
|
|
<div class="tool-card-bottom">
|
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
|
<span class="launch-chip">Open in tab</span>
|
|
{% if tool.lan %}<span class="tag-chip" title="Only reachable on your home network">LAN</span>{% endif %}
|
|
{% if tool.login %}<span class="tag-chip tag-chip-login" title="This app has its own login — Family Home Lab auth is not passed through">Login</span>{% endif %}
|
|
{% if tool.admin %}<span class="tag-chip tag-chip-admin" title="Admins only">Admin</span>{% endif %}
|
|
</div>
|
|
</div>
|
|
{% if tool.togglable and tool.service_id %}
|
|
<div class="svc-strip" data-svc="{{ tool.service_id }}"
|
|
data-ram="{{ tool.ram_mb or '' }}" data-name="{{ tool.name }}"
|
|
data-logs="{{ tool.logs_url or '' }}"
|
|
onclick="event.preventDefault();event.stopPropagation();">
|
|
<span class="svc-state" data-svc-state>
|
|
<span class="svc-dot" data-svc-dot></span><span data-svc-label>…</span>
|
|
</span>
|
|
<a class="svc-logs" data-svc-logs style="display:none" target="_blank" rel="noopener">logs</a>
|
|
<span class="svc-ram" data-svc-ram></span>
|
|
<button type="button" class="button button-utility button-sm" data-svc-btn disabled>…</button>
|
|
</div>
|
|
{% endif %}
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
{% endfor %}
|
|
<p id="dashNoResults" class="faint" style="display:none;margin-top:var(--space-lg)">
|
|
No tools match "<span id="dashNoResultsQ"></span>".
|
|
</p>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
function apply() {
|
|
var q = (window.__dashFilter || "").trim().toLowerCase();
|
|
var any = false;
|
|
document.querySelectorAll("[data-dash-section]").forEach(function (sec) {
|
|
var vis = false;
|
|
sec.querySelectorAll("[data-dash-card]").forEach(function (card) {
|
|
var hit = !q || (card.textContent || "").toLowerCase().indexOf(q) !== -1;
|
|
card.classList.toggle("hidden", !hit);
|
|
if (hit) vis = true;
|
|
});
|
|
sec.classList.toggle("hidden-section", !vis);
|
|
if (vis) any = true;
|
|
});
|
|
var noRes = document.getElementById("dashNoResults");
|
|
var q2 = document.getElementById("dashNoResultsQ");
|
|
if (noRes) noRes.style.display = (q && !any) ? "block" : "none";
|
|
if (q2 && q) q2.textContent = window.__dashFilter;
|
|
}
|
|
window.applyDashFilter = apply;
|
|
window.__dashFilter = window.__dashFilter || "";
|
|
var inp = document.querySelector("[data-filter-input]");
|
|
if (inp) inp.value = window.__dashFilter;
|
|
apply();
|
|
|
|
// --- Collapsible panels (persist per-section in localStorage) ---
|
|
function paneKey(label) { return "fhlPane_" + (label || "").trim().toLowerCase().replace(/\s+/g, "_"); }
|
|
function syncToggle(sec) {
|
|
var b = sec.querySelector("[data-section-toggle]");
|
|
if (b) b.setAttribute("aria-expanded", sec.classList.contains("collapsed") ? "false" : "true");
|
|
}
|
|
document.querySelectorAll("[data-dash-section]").forEach(function (sec) {
|
|
var head = sec.querySelector(".section-head");
|
|
var label = head ? head.textContent.trim() : "";
|
|
var wasCollapsed = false;
|
|
try { wasCollapsed = localStorage.getItem(paneKey(label)) === "1"; } catch (_) {}
|
|
if (wasCollapsed) sec.classList.add("collapsed");
|
|
syncToggle(sec);
|
|
var b = sec.querySelector("[data-section-toggle]");
|
|
if (b) b.addEventListener("click", function () {
|
|
sec.classList.toggle("collapsed");
|
|
try { localStorage.setItem(paneKey(label), sec.classList.contains("collapsed") ? "1" : "0"); } catch (_) {}
|
|
syncToggle(sec);
|
|
});
|
|
});
|
|
|
|
// --- ON/OFF strips (live state from /api/services via host-side controller) ---
|
|
var svcEls = document.querySelectorAll("[data-svc]");
|
|
var svcState = {}; // id -> latest controller state
|
|
var svcPollers = {}; // id -> timer handle
|
|
if (svcEls.length) {
|
|
function normState(s) {
|
|
var st = (s && s.state) || "unknown";
|
|
if (st === "active") st = "running";
|
|
if (st === "inactive") st = "stopped";
|
|
return st;
|
|
}
|
|
function paint(id, showPoll) {
|
|
var strip = document.querySelector('[data-svc="' + id + '"]');
|
|
if (!strip) return;
|
|
var st = normState(svcState[id]);
|
|
var btn = strip.querySelector("[data-svc-btn]");
|
|
var lab = strip.querySelector("[data-svc-label]");
|
|
var dot = strip.querySelector("[data-svc-dot]");
|
|
var ram = strip.querySelector("[data-svc-ram]");
|
|
var can = svcState[id] && svcState[id].can_toggle;
|
|
var transitional = st === "starting" || st === "stopping";
|
|
if (ram && svcState[id] && svcState[id].ram_mb) ram.textContent = "~" + svcState[id].ram_mb + "MB";
|
|
var cls = st === "running" ? "on" : st === "stopped" ? "off" : (st === "failed" ? "fail" : st === "partial" ? "part" : "trans");
|
|
dot.className = "svc-dot svc-" + cls;
|
|
if (lab) lab.textContent = st === "running" ? "Running" : st === "stopped" ? "Stopped" : st === "failed" ? (svcState[id].error || "Failed") : st === "partial" ? "Partial" : (st[0].toUpperCase() + st.slice(1));
|
|
// partial behaves like running for the button (offered action: stop the rest)
|
|
// failed -> offer the logs deep link (dozzle)
|
|
var logs = strip.querySelector("[data-svc-logs]");
|
|
if (logs) {
|
|
var lu = strip.getAttribute("data-logs") || "";
|
|
if (st === "failed" && lu) {
|
|
logs.style.display = ""; logs.href = lu; logs.title = "View logs";
|
|
} else {
|
|
logs.style.display = "none"; logs.href = "#";
|
|
}
|
|
}
|
|
if (btn) {
|
|
btn.disabled = !can || transitional;
|
|
if (transitional) btn.textContent = st === "starting" ? "Starting…" : "Stopping…";
|
|
else if (can) btn.textContent = st === "running" ? "Stop" : "Start";
|
|
else btn.textContent = st === "running" ? "On" : "Off";
|
|
}
|
|
// keep polling only while a toggle is in flight
|
|
var poll = /starting|stopping/.test(st);
|
|
if (poll && !svcPollers[id]) svcPollers[id] = setInterval(function () { refresh(); }, 2500);
|
|
if (!poll && svcPollers[id]) { clearInterval(svcPollers[id]); delete svcPollers[id]; }
|
|
}
|
|
function refresh() {
|
|
fetch('/api/services').then(function (r) { return r.json(); }).then(function (d) {
|
|
(d.services || []).forEach(function (s) { svcState[s.id] = s; paint(s.id); });
|
|
}).catch(function () {});
|
|
}
|
|
document.querySelectorAll("[data-svc]").forEach(function (strip) {
|
|
var id = strip.getAttribute("data-svc");
|
|
var btn = strip.querySelector("[data-svc-btn]");
|
|
if (btn) btn.addEventListener("click", function (e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
var st = normState(svcState[id]);
|
|
var action = st === "running" ? "stop" : "start";
|
|
btn.disabled = true; btn.textContent = action === "start" ? "Starting…" : "Stopping…";
|
|
fetch('/service/' + id + '/' + action, { method: 'POST' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) { if (d.ok) { svcState[id] = Object.assign({}, svcState[id], { state: d.state || (action === 'start' ? 'running' : 'stopped') }); paint(id); refresh(); } })
|
|
.catch(function () { btn.disabled = false; refresh(); });
|
|
});
|
|
});
|
|
refresh();
|
|
}
|
|
})();
|
|
</script> |