ui: improve account/apps layout and lab status responsiveness

This commit is contained in:
Brad Stein 2026-01-03 19:50:12 -03:00
parent 6b9aaa3e53
commit 35f7b77c1b
5 changed files with 61 additions and 24 deletions

View File

@ -60,6 +60,9 @@ def register(app) -> None:
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC): if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC):
return jsonify(cached) return jsonify(cached)
t_total = time.perf_counter()
timings_ms: dict[str, int] = {}
connected = False connected = False
atlas_up = False atlas_up = False
atlas_known = False atlas_known = False
@ -71,7 +74,9 @@ def register(app) -> None:
# Atlas # Atlas
try: try:
t_probe = time.perf_counter()
atlas_grafana_ok = _http_ok(settings.GRAFANA_HEALTH_URL, expect_substring="ok") atlas_grafana_ok = _http_ok(settings.GRAFANA_HEALTH_URL, expect_substring="ok")
timings_ms["grafana"] = int((time.perf_counter() - t_probe) * 1000)
if atlas_grafana_ok: if atlas_grafana_ok:
connected = True connected = True
atlas_up = True atlas_up = True
@ -82,7 +87,9 @@ def register(app) -> None:
if not atlas_known: if not atlas_known:
try: try:
t_probe = time.perf_counter()
value = _vm_query("up") value = _vm_query("up")
timings_ms["victoria_metrics"] = int((time.perf_counter() - t_probe) * 1000)
if value is not None: if value is not None:
connected = True connected = True
atlas_known = True atlas_known = True
@ -93,7 +100,9 @@ def register(app) -> None:
# Oceanus (node-exporter direct probe) # Oceanus (node-exporter direct probe)
try: try:
t_probe = time.perf_counter()
if _http_ok(settings.OCEANUS_NODE_EXPORTER_URL): if _http_ok(settings.OCEANUS_NODE_EXPORTER_URL):
timings_ms["oceanus_node_exporter"] = int((time.perf_counter() - t_probe) * 1000)
connected = True connected = True
oceanus_known = True oceanus_known = True
oceanus_up = True oceanus_up = True
@ -101,14 +110,16 @@ def register(app) -> None:
except Exception: except Exception:
pass pass
timings_ms["total"] = int((time.perf_counter() - t_total) * 1000)
payload = { payload = {
"connected": connected, "connected": connected,
"atlas": {"up": atlas_up, "known": atlas_known, "source": atlas_source}, "atlas": {"up": atlas_up, "known": atlas_known, "source": atlas_source},
"oceanus": {"up": oceanus_up, "known": oceanus_known, "source": oceanus_source}, "oceanus": {"up": oceanus_up, "known": oceanus_known, "source": oceanus_source},
"checked_at": int(now), "checked_at": int(now),
"timings_ms": timings_ms,
} }
_LAB_STATUS_CACHE["ts"] = now _LAB_STATUS_CACHE["ts"] = now
_LAB_STATUS_CACHE["value"] = payload _LAB_STATUS_CACHE["value"] = payload
return jsonify(payload) return jsonify(payload)

View File

@ -16,7 +16,7 @@ VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2"))
HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2")) HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2"))
K8S_API_TIMEOUT_SEC = float(os.getenv("K8S_API_TIMEOUT_SEC", "5")) K8S_API_TIMEOUT_SEC = float(os.getenv("K8S_API_TIMEOUT_SEC", "5"))
LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30")) LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30"))
GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "https://metrics.bstein.dev/api/health") GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "http://grafana.monitoring.svc.cluster.local/api/health")
OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics") OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics")
AI_CHAT_API = os.getenv("AI_CHAT_API", "http://ollama.ai.svc.cluster.local:11434").rstrip("/") AI_CHAT_API = os.getenv("AI_CHAT_API", "http://ollama.ai.svc.cluster.local:11434").rstrip("/")

View File

@ -25,19 +25,33 @@ const networkData = ref(fallbackNetwork());
const metricsData = ref(fallbackMetrics()); const metricsData = ref(fallbackMetrics());
const statusLoading = ref(true); const statusLoading = ref(true);
const statusFetching = ref(false);
const statusError = ref(""); const statusError = ref("");
async function refreshLabStatus() { async function refreshLabStatus() {
if (statusFetching.value) return;
statusFetching.value = true;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 6000);
try { try {
const resp = await fetch("/api/lab/status", { headers: { Accept: "application/json" } }); const resp = await fetch("/api/lab/status", {
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!resp.ok) throw new Error(`status ${resp.status}`); if (!resp.ok) throw new Error(`status ${resp.status}`);
labStatus.value = await resp.json(); labStatus.value = await resp.json();
statusError.value = ""; statusError.value = "";
} catch (err) { } catch (err) {
labStatus.value = null; labStatus.value = null;
statusError.value = "Live data unavailable"; if (err?.name === "AbortError") {
statusError.value = "Live data timed out";
} else {
statusError.value = "Live data unavailable";
}
} finally { } finally {
window.clearTimeout(timeoutId);
statusLoading.value = false; statusLoading.value = false;
statusFetching.value = false;
} }
} }

View File

@ -25,7 +25,7 @@
</section> </section>
<section v-if="auth.ready && auth.authenticated"> <section v-if="auth.ready && auth.authenticated">
<div class="grid two"> <div class="account-grid">
<div class="card module"> <div class="card module">
<div class="module-head"> <div class="module-head">
<h2>Mail</h2> <h2>Mail</h2>
@ -123,6 +123,7 @@
</div> </div>
</div> </div>
<div class="account-stack">
<div class="card module"> <div class="card module">
<div class="module-head"> <div class="module-head">
<h2>Vaultwarden</h2> <h2>Vaultwarden</h2>
@ -200,6 +201,7 @@
<div v-if="jellyfin.syncDetail" class="hint mono jellyfin-detail">{{ jellyfin.syncDetail }}</div> <div v-if="jellyfin.syncDetail" class="hint mono jellyfin-detail">{{ jellyfin.syncDetail }}</div>
</div> </div>
</div> </div>
</div>
<div v-if="admin.enabled" class="card module admin"> <div v-if="admin.enabled" class="card module admin">
<div class="module-head"> <div class="module-head">
@ -580,13 +582,24 @@ h1 {
max-width: 640px; max-width: 640px;
} }
.grid.two { .account-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: 12px;
margin-top: 12px; margin-top: 12px;
} }
.account-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.account-stack .module {
flex: 1;
min-height: 0;
}
.module { .module {
padding: 18px; padding: 18px;
} }
@ -716,9 +729,13 @@ button.primary {
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.grid.two { .account-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.account-stack .module {
flex: none;
}
} }
.admin { .admin {

View File

@ -133,29 +133,14 @@ const sections = [
description: "Build and ship: source control, CI, registry, and GitOps.", description: "Build and ship: source control, CI, registry, and GitOps.",
groups: [ groups: [
{ {
title: "Source & CI", title: "Dev Stack",
apps: [ apps: [
{ name: "Gitea", url: "https://scm.bstein.dev", target: "_blank", description: "Git hosting and collaboration." }, { name: "Gitea", url: "https://scm.bstein.dev", target: "_blank", description: "Git hosting and collaboration." },
{ name: "Jenkins", url: "https://ci.bstein.dev", target: "_blank", description: "CI pipelines and automation." }, { name: "Jenkins", url: "https://ci.bstein.dev", target: "_blank", description: "CI pipelines and automation." },
],
},
{
title: "Registry & Deploy",
apps: [
{ name: "Harbor", url: "https://registry.bstein.dev", target: "_blank", description: "Artifact registry." }, { name: "Harbor", url: "https://registry.bstein.dev", target: "_blank", description: "Artifact registry." },
{ name: "GitOps", url: "https://cd.bstein.dev", target: "_blank", description: "GitOps UI for Flux." }, { name: "GitOps", url: "https://cd.bstein.dev", target: "_blank", description: "GitOps UI for Flux." },
],
},
{
title: "Observability",
apps: [
{ name: "Grafana", url: "https://metrics.bstein.dev", target: "_blank", description: "Dashboards and monitoring." },
],
},
{
title: "Infra & Secrets",
apps: [
{ name: "Vault", url: "https://secret.bstein.dev", target: "_blank", description: "Secrets management for infrastructure and apps." }, { name: "Vault", url: "https://secret.bstein.dev", target: "_blank", description: "Secrets management for infrastructure and apps." },
{ name: "Grafana", url: "https://metrics.bstein.dev", target: "_blank", description: "Dashboards and monitoring." },
], ],
}, },
], ],
@ -216,6 +201,7 @@ const sections = [
justify-content: space-between; justify-content: space-between;
gap: 18px; gap: 18px;
margin-bottom: 14px; margin-bottom: 14px;
min-height: 92px;
} }
.group + .group { .group + .group {
@ -232,6 +218,10 @@ const sections = [
margin: 6px 0 0; margin: 6px 0 0;
color: var(--text-muted); color: var(--text-muted);
max-width: 820px; max-width: 820px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.tiles { .tiles {
@ -248,6 +238,7 @@ const sections = [
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
transition: border-color 160ms ease, transform 160ms ease; transition: border-color 160ms ease, transform 160ms ease;
min-height: 112px;
} }
.tile:hover { .tile:hover {
@ -265,6 +256,10 @@ const sections = [
color: var(--text-muted); color: var(--text-muted);
font-size: 14px; font-size: 14px;
line-height: 1.4; line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.eyebrow { .eyebrow {