2067 lines
96 KiB
Text
2067 lines
96 KiB
Text
dashboard 2026-08-30 02:42:55 → now 1 session(s)
|
||
|
||
Session Started Where Title
|
||
Ch6QXt↓ 2026-08-31 01:53 . Explore tab structure (@explore subagent)
|
||
|
||
|
||
⚑ ❬Ch6QXt↓❭ Explore tab structure (@explore subagent) ~/Code/dashboard
|
||
┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
|
||
|
||
│ 2026-08-31 01:53:49 • user
|
||
│
|
||
│ Explore the dashboard codebase at /home/mac/Code/dashboard. I need to understand the tab system
|
||
│ in detail:
|
||
│
|
||
│ 1. Read templates/ directory - list all files, and read the main index template (probably
|
||
│ templates/index.html) and 2-3 example tab templates (e.g. overview.html, disks.html,
|
||
│ processes.html or similar)
|
||
│ 2. How are tabs structured? Look for tab navigation markup, tab switching mechanism (JS, htmx
|
||
│ attributes, classes like .active)
|
||
│ 3. How does polling work? Look for hx-get, hx-trigger, hx-swap attributes - which elements poll,
|
||
│ at what intervals
|
||
│ 4. Is there any existing JavaScript in the templates or static/ files? Read any .js files found
|
||
│ 5. Check if there's a static/ directory with CSS/JS
|
||
│ 6. Look at how the tab buttons work - is it pure htmx, or is there JS handling tab activation?
|
||
│ 7. Check app/routers/ for any tab-related endpoints
|
||
│
|
||
│ Report back with:
|
||
│ - Exact file listing of templates/ and static/ (if exists)
|
||
│ - The full content of the main index.html template
|
||
│ - Full content of 2-3 representative tab templates (showing their polling setup)
|
||
│ - Any existing JS code
|
||
│ - How tab switching currently works (the exact mechanism)
|
||
│ - Polling intervals used per tab/section
|
||
│
|
||
│ Be very thorough - include actual code snippets with line numbers.
|
||
|
||
|
||
│ 2026-08-31 01:53:49 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ I'll start by exploring the structure of the codebase. I'll list the templates directory and the
|
||
│ static directory, and read the main files in parallel.
|
||
│
|
||
│ I'll explore the dashboard codebase thoroughly, starting with the directory structure and main
|
||
│ files.
|
||
│
|
||
│ bash ls -la /home/mac/Code/dashboard/templates/ 2>/dev/null; echo "---STATIC---";… ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "command": "ls -la /home/mac/Code/dashboard/templates/ 2>/dev/null; echo \"---STATIC---\";
|
||
│ ls -la /home/mac/Code/dashboard/static/ 2>/dev/null || echo \"no static dir\""
|
||
│ }
|
||
│ out
|
||
│ total 36
|
||
│ drwxr-xr-x 1 mac mac 220 30. Aug 19:44 .
|
||
│ drwxr-xr-x 1 mac mac 306 30. Aug 22:11 ..
|
||
│ -rw-r--r-- 1 mac mac 2013 29. Aug 21:17 disks.html
|
||
│ -rw-r--r-- 1 mac mac 4442 30. Aug 20:40 index.html
|
||
│ -rw-r--r-- 1 mac mac 404 29. Aug 20:17 journal.html
|
||
│ -rw-r--r-- 1 mac mac 4057 29. Aug 22:14 overview.html
|
||
│ drwxr-xr-x 1 mac mac 300 31. Aug 01:29 plugins
|
||
│ -rw-r--r-- 1 mac mac 326 30. Aug 21:31 plugins.html
|
||
│ -rw-r--r-- 1 mac mac 2052 29. Aug 21:54 processes.html
|
||
│ -rw-r--r-- 1 mac mac 1112 30. Aug 20:37 service_detail.html
|
||
│ -rw-r--r-- 1 mac mac 4024 30. Aug 20:40 services.html
|
||
│ ---STATIC---
|
||
│ total 0
|
||
│ drwxr-xr-x 1 mac mac 22 29. Aug 21:19 .
|
||
│ drwxr-xr-x 1 mac mac 306 30. Aug 22:11 ..
|
||
│ drwxr-xr-x 1 mac mac 18 30. Aug 22:11 css
|
||
│ drwxr-xr-x 1 mac mac 12 30. Aug 18:16 js
|
||
│ drwxr-xr-x 1 mac mac 54 29. Aug 20:07 vendor
|
||
│ glob **/*.js ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "pattern": "**/*.js"
|
||
│ }
|
||
│ out
|
||
│ /home/mac/Code/dashboard/static/vendor/chart.umd.min.js
|
||
│ /home/mac/Code/dashboard/static/vendor/htmx.min.js
|
||
│ /home/mac/Code/dashboard/static/js/app.js
|
||
│ glob app/routers/* ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "pattern": "app/routers/*"
|
||
│ }
|
||
│ out
|
||
│ /home/mac/Code/dashboard/app/routers/services.py
|
||
│ /home/mac/Code/dashboard/app/routers/overview.py
|
||
│ /home/mac/Code/dashboard/app/routers/plugins.py
|
||
│ /home/mac/Code/dashboard/app/routers/__init__.py
|
||
│ /home/mac/Code/dashboard/app/routers/journal.py
|
||
│ /home/mac/Code/dashboard/app/routers/processes.py
|
||
│ /home/mac/Code/dashboard/app/routers/disks.py
|
||
│
|
||
│ tokens 5114 in 180 out 0 cached 0 written $0.0000 tool-calls
|
||
|
||
|
||
│ 2026-08-31 01:54:00 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ Let's explore further. I'll list the static directory, the plugins templates directory, and then
|
||
│ read the main files.
|
||
│
|
||
│ Let me explore the static directory, plugins templates, and read the main index template.
|
||
│
|
||
│ bash echo "=== static/css ==="; ls -la /home/mac/Code/dashboard/static/css/; echo… ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "command": "echo \"=== static/css ===\"; ls -la /home/mac/Code/dashboard/static/css/; echo
|
||
│ \"=== static/js ===\"; ls -la /home/mac/Code/dashboard/static/js/; echo \"===
|
||
│ static/vendor ===\"; ls -la /home/mac/Code/dashboard/static/vendor/; echo \"===
|
||
│ templates/plugins ===\"; ls -la /home/mac/Code/dashboard/templates/plugins/"
|
||
│ }
|
||
│ out
|
||
│ === static/css ===
|
||
│ total 12
|
||
│ drwxr-xr-x 1 mac mac 18 30. Aug 22:11 .
|
||
│ drwxr-xr-x 1 mac mac 22 29. Aug 21:19 ..
|
||
│ -rw-r--r-- 1 mac mac 9441 30. Aug 22:11 style.css
|
||
│ === static/js ===
|
||
│ total 8
|
||
│ drwxr-xr-x 1 mac mac 12 30. Aug 18:16 .
|
||
│ drwxr-xr-x 1 mac mac 22 29. Aug 21:19 ..
|
||
│ -rw-r--r-- 1 mac mac 8178 30. Aug 20:37 app.js
|
||
│ === static/vendor ===
|
||
│ total 252
|
||
│ drwxr-xr-x 1 mac mac 54 29. Aug 20:07 .
|
||
│ drwxr-xr-x 1 mac mac 22 29. Aug 21:19 ..
|
||
│ -rw-r--r-- 1 mac mac 205125 29. Aug 20:07 chart.umd.min.js
|
||
│ -rw-r--r-- 1 mac mac 48101 29. Aug 20:07 htmx.min.js
|
||
│ === templates/plugins ===
|
||
│ total 32
|
||
│ drwxr-xr-x 1 mac mac 300 31. Aug 01:29 .
|
||
│ drwxr-xr-x 1 mac mac 220 30. Aug 19:44 ..
|
||
│ -rw-r--r-- 1 mac mac 1011 30. Aug 22:06 lact_skeleton.html
|
||
│ -rw-r--r-- 1 mac mac 713 30. Aug 22:06 lact_state.html
|
||
│ -rw-r--r-- 1 mac mac 982 30. Aug 19:44 llamacpp_skeleton.html
|
||
│ -rw-r--r-- 1 mac mac 1705 30. Aug 19:44 llamacpp_state.html
|
||
│ -rw-r--r-- 1 mac mac 194 31. Aug 01:29 sessions_skeleton.html
|
||
│ -rw-r--r-- 1 mac mac 1091 31. Aug 01:29 sessions_state.html
|
||
│ -rw-r--r-- 1 mac mac 182 30. Aug 21:21 sleep_skeleton.html
|
||
│ -rw-r--r-- 1 mac mac 1502 30. Aug 21:21 sleep_state.html
|
||
│ read /home/mac/Code/dashboard/templates/index.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/index.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/index.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <!doctype html>
|
||
│ 2: <html lang="en">
|
||
│ 3: <head>
|
||
│ 4: <meta charset="utf-8">
|
||
│ 5: <meta name="viewport" content="width=device-width, initial-scale=1">
|
||
│ 6: <title>{{ hostname }}</title>
|
||
│ 7: <link rel="stylesheet" href="/static/css/style.css">
|
||
│ 8: <script src="/static/vendor/htmx.min.js"></script>
|
||
│ 9: <script src="/static/vendor/chart.umd.min.js"></script>
|
||
│ 10: </head>
|
||
│ 11: <body>
|
||
│ 12: <header class="topbar">
|
||
│ 13: <h1>{{ hostname }}</h1>
|
||
│ 14: <nav id="tabs">
|
||
│ 15: <button class="tab-btn active" data-tab="overview">Overview</button>
|
||
│ 16: <button class="tab-btn" data-tab="disks">Disks</button>
|
||
│ 17: <button class="tab-btn" data-tab="processes">Processes</button>
|
||
│ 18: <button class="tab-btn" data-tab="journal">Journal</button>
|
||
│ 19: <button class="tab-btn" data-tab="services">Services</button>
|
||
│ 20: <button class="tab-btn" data-tab="plugins">Plugins</button>
|
||
│ 21: </nav>
|
||
│ 22: </header>
|
||
│ 23: <main>
|
||
│ 24: <section id="tab-overview" class="tab">
|
||
│ 25: <div id="overview" class="panel" hx-get="/api/overview" hx-trigger="load, every 2s"
|
||
│ hx-swap="outerHTML"></div>
|
||
│ 26: <div class="charts-grid">
|
||
│ 27: <div class="chart-card"><h3>CPU / GPU %</h3><canvas id="chart-cpu"></canvas></div>
|
||
│ 28: <div class="chart-card"><h3>Memory / VRAM %</h3><canvas
|
||
│ id="chart-mem"></canvas></div>
|
||
│ 29: <div class="chart-card"><h3>Disk I/O</h3><canvas id="chart-io"></canvas></div>
|
||
│ 30: </div>
|
||
│ 31: </section>
|
||
│ 32:
|
||
│ 33: <section id="tab-disks" class="tab hidden">
|
||
│ 34: <div id="disks" class="panel" hx-get="/api/disks" hx-trigger="load, every 2s"
|
||
│ hx-swap="outerHTML"></div>
|
||
│ 35: </section>
|
||
│ 36:
|
||
│ 37: <section id="tab-processes" class="tab hidden">
|
||
│ 38: <form id="proc-controls" class="inline-form" hx-get="/api/processes"
|
||
│ hx-target="#processes-body" hx-swap="outerHTML">
|
||
│ 39: <input id="proc-q" name="q" placeholder="filter name / pid">
|
||
│ 40: <select name="sort" id="proc-sort">
|
||
│ 41:
|
||
│ … [2964 more chars]
|
||
│
|
||
│ tokens 606 in 181 out 5294 cached 0 written $0.0000 tool-calls
|
||
|
||
|
||
│ 2026-08-31 01:54:06 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ I've obtained the complete index.html. Next, I'll read the following:
|
||
│ 1. app.js (existing JS)
|
||
│ 2. style.css (to check the .hidden class and tab styles)
|
||
│ 3. Representative tab templates: overview.html, disks.html, processes.html
|
||
│ 4. Router for tab-related endpoints
|
||
│
|
||
│ Let's execute these reads in parallel.
|
||
│
|
||
│ Now let me read the JS, CSS, and the representative tab templates.
|
||
│
|
||
│ read /home/mac/Code/dashboard/static/js/app.js ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/static/js/app.js"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/static/js/app.js</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: (function () {
|
||
│ 2: "use strict";
|
||
│ 3:
|
||
│ 4: // ---------- tabs ----------
|
||
│ 5: const tabBtns = document.querySelectorAll(".tab-btn");
|
||
│ 6: const sections = document.querySelectorAll(".tab");
|
||
│ 7:
|
||
│ 8: function showTab(name) {
|
||
│ 9: tabBtns.forEach((b) => b.classList.toggle("active", b.dataset.tab === name));
|
||
│ 10: sections.forEach((s) => s.classList.toggle("hidden", s.id !== "tab-" + name));
|
||
│ 11: try {
|
||
│ 12: localStorage.setItem("dash.tab", name);
|
||
│ 13: } catch (e) {}
|
||
│ 14: }
|
||
│ 15: tabBtns.forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
||
│ 16: try {
|
||
│ 17: const saved = localStorage.getItem("dash.tab");
|
||
│ 18: if (saved && document.getElementById("tab-" + saved)) showTab(saved);
|
||
│ 19: } catch (e) {}
|
||
│ 20:
|
||
│ 21: // ---------- helpers ----------
|
||
│ 22: function fmtBytes(n, digits) {
|
||
│ 23: if (n == null || isNaN(n)) return "—";
|
||
│ 24: if (digits == null) digits = 1;
|
||
│ 25: const u = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||
│ 26: let i = 0;
|
||
│ 27: while (Math.abs(n) >= 1024 && i < u.length - 1) {
|
||
│ 28: n /= 1024;
|
||
│ 29: i++;
|
||
│ 30: }
|
||
│ 31: return n.toFixed(digits) + " " + u[i];
|
||
│ 32: }
|
||
│ 33:
|
||
│ 34: function fmtTime(ts) {
|
||
│ 35: const d = new Date(ts * 1000);
|
||
│ 36: return d.toLocaleTimeString([], { hour12: false });
|
||
│ 37: }
|
||
│ 38:
|
||
│ 39: // ---------- charts ----------
|
||
│ 40: const charts = {};
|
||
│ 41:
|
||
│ 42: function baseOpts(extra) {
|
||
│ 43: const o = {
|
||
│ 44: animation: false,
|
||
│ 45: responsive: true,
|
||
│ 46: maintainAspectRatio: false,
|
||
│ 47: interaction: { mode: "index", intersect: false },
|
||
│ 48: plugins: { legend: { display: false } },
|
||
│ 49: scales: {
|
||
│ 50: x: { ticks: { maxTicksLimit: 7, maxRotation: 0, color: "#7d8a9c" }, grid: {
|
||
│ display: false } },
|
||
│ 51: y: { beginAtZero: true, ticks: { color: "#7d8a9c" }, grid: { color:
|
||
│ "rgba(42,51,66,.5)" } },
|
||
│ 52: },
|
||
│ 53: };
|
||
│ 54: if (extra) Object.assign(o.scal
|
||
│ … [7354 more chars]
|
||
│ read /home/mac/Code/dashboard/templates/overview.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/overview.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/overview.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div id="overview" class="panel cards" hx-get="/api/overview" hx-trigger="every 2s"
|
||
│ hx-swap="outerHTML">
|
||
│ 2: {% macro bar(pct) %}{% if pct is not none %}<div class="bar"><div class="bar-fill{% if
|
||
│ pct > 85 %} hot{% elif pct > 65 %} warm{% endif %}" style="width: {{ pct }}%"></div></div>{%
|
||
│ endif %}{% endmacro %}
|
||
│ 3: {% macro barrow(label, pct, cls) %}{% if pct is not none %}<div class="barrow"><span
|
||
│ class="barrow-label">{{ label }}</span><div class="bar"><div class="bar-fill{% if cls %} {{
|
||
│ cls }}{% endif %}{% if pct > 85 %} hot{% elif pct > 65 %} warm{% endif %}" style="width: {{
|
||
│ pct }}%"></div></div></div>{% endif %}{% endmacro %}
|
||
│ 4: <div class="card">
|
||
│ 5: <div class="card-label">CPU / GPU</div>
|
||
│ 6: <div class="card-value">{{ (c.cpu | default(0, true)) | round(1) }}%</div>
|
||
│ 7: {{ barrow("CPU", c.cpu) }}
|
||
│ 8: {{ barrow("GPU", c.gpu, "gpu") }}
|
||
│ 9: {% if c.cpu_temp is not none or c.gpu_temp is not none %}
|
||
│ 10: <div class="card-sub">
|
||
│ 11: {% if c.cpu_temp is not none %}CPU {{ c.cpu_temp | round(0) | int }}°C{%
|
||
│ endif %}
|
||
│ 12: {% if c.cpu_temp is not none and c.gpu_temp is not none %} ·{% endif %}
|
||
│ 13: {% if c.gpu_temp is not none %}GPU {{ c.gpu_temp | round(0) | int }}°C{%
|
||
│ endif %}
|
||
│ 14: </div>
|
||
│ 15: {% endif %}
|
||
│ 16: <div class="card-sub">load {{ (c.load1 | default(0, true)) | round(2) }} · {{
|
||
│ c.cores }} cores</div>
|
||
│ 17: </div>
|
||
│ 18: <div class="card">
|
||
│ 19: <div class="card-label">Memory</div>
|
||
│ 20: <div class="card-value">{{ c.mem_used | humanize }} <span class="muted">/ {{
|
||
│ c.mem_total | humanize }} ({{ (c.mem_pct | default(0, true)) | round(0) | int
|
||
│ }}%)</span></div>
|
||
│ 21: {{ bar(c.mem_pct) }}
|
||
│ 22: <div class="memrow">
|
||
│ 23: <div class="memrow-text">Swap {% if c.swap_total %}{{ c.swap_used | humanize }}
|
||
│ <span class="muted">/ {{ c.swap_total | humanize }} ({{ (c.swap_pct | default(0, true)) |
|
||
│ … [2464 more chars]
|
||
│ read /home/mac/Code/dashboard/templates/disks.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/disks.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/disks.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div id="disks" class="panel" hx-get="/api/disks" hx-trigger="every 2s"
|
||
│ hx-swap="outerHTML">
|
||
│ 2: <h2>Filesystems</h2>
|
||
│ 3: <div class="part-list">
|
||
│ 4: {% for p in partitions %}
|
||
│ 5: <div class="part">
|
||
│ 6: <div class="part-head">
|
||
│ 7: <span class="mono">{{ p.device }}</span>
|
||
│ 8: <span class="muted">{{ p.fstype }}</span>
|
||
│ 9: <span class="muted part-mounts" title="{{ p.mounts | join("\n") }}">{{
|
||
│ p.mounts_disp }}</span>
|
||
│ 10: <span class="part-usage">{{ p.used | humanize }} / {{ p.total | humanize }} ({{
|
||
│ p.pct | round(0) }}%)</span>
|
||
│ 11: </div>
|
||
│ 12: <div class="bar"><div class="bar-fill{% if p.pct > 90 %} hot{% elif p.pct > 75 %}
|
||
│ warm{% endif %}" style="width: {{ p.pct }}%"></div></div>
|
||
│ 13: </div>
|
||
│ 14: {% else %}
|
||
│ 15: <div class="muted">no partitions found</div>
|
||
│ 16: {% endfor %}
|
||
│ 17: </div>
|
||
│ 18: <h2>Device I/O</h2>
|
||
│ 19: <div class="table-wrap">
|
||
│ 20: <table class="table">
|
||
│ 21: <colgroup>
|
||
│ 22: <col style="width: 110px">
|
||
│ 23: <col style="width: 100px">
|
||
│ 24: <col style="width: 100px">
|
||
│ 25: <col style="width: 100px">
|
||
│ 26: <col style="width: 100px">
|
||
│ 27: <col style="width: 110px">
|
||
│ 28: <col style="width: 110px">
|
||
│ 29: </colgroup>
|
||
│ 30: <thead>
|
||
│ 31: <tr><th>Device</th><th class="num">Read</th><th class="num">Write</th><th
|
||
│ class="num">Read rate</th><th class="num">Write rate</th><th class="num">Reads</th><th
|
||
│ class="num">Writes</th></tr>
|
||
│ 32: </thead>
|
||
│ 33: <tbody>
|
||
│ 34: {% for d in per_disk %}
|
||
│ 35: <tr>
|
||
│ 36: <td class="mono cell-clip" title="{{ d.device }}">{{ d.device }}</td>
|
||
│ 37: <td class="num">{{ d.read_bytes | humanize }}</td>
|
||
│ 38: <td class="num">{{ d.write_bytes | humanize }}</td>
|
||
│ 39: <td class="num">{{ d.read_rate | rate }}</td>
|
||
│ 40: <td class="num">{{ d.write_rate | rate }}</td>
|
||
│ 41:
|
||
│ … [333 more chars]
|
||
│ read /home/mac/Code/dashboard/templates/processes.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/processes.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/processes.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div id="processes-body" class="panel" hx-get="/api/processes" hx-trigger="every 3s"
|
||
│ hx-swap="outerHTML" hx-include="#proc-controls">
|
||
│ 2: <div class="muted small">showing {{ procs | length }} of {{ total }} processes</div>
|
||
│ 3: <div class="table-wrap">
|
||
│ 4: <table class="table">
|
||
│ 5: <colgroup>
|
||
│ 6: <col style="width: 64px">
|
||
│ 7: <col>
|
||
│ 8: <col style="width: 100px">
|
||
│ 9: <col style="width: 72px">
|
||
│ 10: <col style="width: 72px">
|
||
│ 11: <col style="width: 92px">
|
||
│ 12: <col style="width: 92px">
|
||
│ 13: <col style="width: 92px">
|
||
│ 14: <col style="width: 92px">
|
||
│ 15: </colgroup>
|
||
│ 16: <thead>
|
||
│ 17: <tr>
|
||
│ 18: {% for col, label in [("pid", "PID"), ("name", "Name"), ("user", "User"),
|
||
│ ("cpu", "CPU%"), ("mem_pct", "MEM%"), ("rss", "RSS"), ("gpu", "GPU"), ("io_read", "IO
|
||
│ read"), ("io_write", "IO write")] %}
|
||
│ 19: <th class="{% if col in ('cpu','mem_pct','rss','gpu','io_read','io_write')
|
||
│ %}num{% endif %}">
|
||
│ 20: <a href="#" class="sortlink{% if sort == col %} active{% endif %}"
|
||
│ data-sort="{{ col }}" data-order="{% if sort == col and order == 'desc' %}asc{% else
|
||
│ %}desc{% endif %}">{{ label }}{% if sort == col %} {{ "↓" if order == "desc" else "↑" }}{%
|
||
│ endif %}</a>
|
||
│ 21: </th>
|
||
│ 22: {% endfor %}
|
||
│ 23: </tr>
|
||
│ 24: </thead>
|
||
│ 25: <tbody>
|
||
│ 26: {% for p in procs %}
|
||
│ 27: <tr>
|
||
│ 28: <td class="mono">{{ p.pid }}</td>
|
||
│ 29: <td class="pname" title="{{ p.name }}">{{ p.name }}</td>
|
||
│ 30: <td class="muted">{{ p.user }}</td>
|
||
│ 31: <td class="num">{{ p.cpu | round(1) }}</td>
|
||
│ 32: <td class="num">{{ p.mem_pct | round(1) }}</td>
|
||
│ 33: <td class="num">{{ p.rss | humanize }}</td>
|
||
│ 34: <td class="num">{% if p.gpu is not none %}{{ p.gpu }} MB{% else %}<span
|
||
│ class="muted">—</span>{% endif %}</td>
|
||
│ 35:
|
||
│ … [348 more chars]
|
||
│
|
||
│ tokens 2352 in 224 out 6082 cached 0 written $0.0000 tool-calls
|
||
|
||
|
||
│ 2026-08-31 01:54:15 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ What I currently have:
|
||
│ - Complete index.html
|
||
│ - Complete app.js
|
||
│ - overview.html, disks.html, processes.html
|
||
│ - Directory listings
|
||
│
|
||
│ Next, I'll check the tab-related endpoints and CSS (for the .hidden and .tab-btn classes) in the
|
||
│ router. In addition, I'll also read a few more templates: services.html (to check the
|
||
│ accordion's htmx setup), journal.html, and plugins.html and the plugin's state templates (to
|
||
│ check per-plugin polling). I'll also check the tab-related endpoints in app/routers.
|
||
│
|
||
│ I'll read style.css and the remaining templates, and try grepping the router for tab-related
|
||
│ stuff.
|
||
│
|
||
│ I have the core files. Now let me check the CSS (for tab/hidden classes), the remaining
|
||
│ templates (services, journal, plugins), and the routers.
|
||
│
|
||
│ read /home/mac/Code/dashboard/static/css/style.css ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/static/css/style.css"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/static/css/style.css</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: :root {
|
||
│ 2: --bg: #0e1116;
|
||
│ 3: --bg-panel: #161b23;
|
||
│ 4: --bg-card: #1c2330;
|
||
│ 5: --border: #2a3342;
|
||
│ 6: --text: #dbe2ea;
|
||
│ 7: --muted: #7d8a9c;
|
||
│ 8: --accent: #4fc3f7;
|
||
│ 9: --ok: #66bb6a;
|
||
│ 10: --warn: #ffb74d;
|
||
│ 11: --err: #ef5350;
|
||
│ 12: --busy: #fff176;
|
||
│ 13: }
|
||
│ 14:
|
||
│ 15: * { box-sizing: border-box; }
|
||
│ 16:
|
||
│ 17: html, body {
|
||
│ 18: margin: 0;
|
||
│ 19: padding: 0;
|
||
│ 20: background: var(--bg);
|
||
│ 21: color: var(--text);
|
||
│ 22: font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
│ 23: font-size: 14px;
|
||
│ 24: }
|
||
│ 25:
|
||
│ 26: h1 { font-size: 18px; margin: 0; }
|
||
│ 27: h2 { font-size: 15px; margin: 18px 0 8px; }
|
||
│ 28: h3 { font-size: 14px; margin: 0 0 8px; }
|
||
│ 29: h4 { font-size: 13px; margin: 14px 0 6px; }
|
||
│ 30: .mono { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size:
|
||
│ 12.5px; }
|
||
│ 31: .muted { color: var(--muted); }
|
||
│ 32: .small { font-size: 12px; }
|
||
│ 33: .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||
│ 34:
|
||
│ 35: .topbar {
|
||
│ 36: display: flex;
|
||
│ 37: align-items: center;
|
||
│ 38: gap: 24px;
|
||
│ 39: padding: 10px 16px;
|
||
│ 40: background: var(--bg-panel);
|
||
│ 41: border-bottom: 1px solid var(--border);
|
||
│ 42: position: sticky;
|
||
│ 43: top: 0;
|
||
│ 44: z-index: 10;
|
||
│ 45: flex-wrap: wrap;
|
||
│ 46: }
|
||
│ 47:
|
||
│ 48: #tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
│ 49:
|
||
│ 50: .tab-btn {
|
||
│ 51: background: transparent;
|
||
│ 52: color: var(--muted);
|
||
│ 53: border: 1px solid transparent;
|
||
│ 54: padding: 6px 12px;
|
||
│ 55: border-radius: 6px;
|
||
│ 56: cursor: pointer;
|
||
│ 57: font-size: 13.5px;
|
||
│ 58: }
|
||
│ 59: .tab-btn:hover { color: var(--text); background: var(--bg-card); }
|
||
│ 60: .tab-btn.active {
|
||
│ 61: color: var(--text);
|
||
│ 62: background: var(--bg-card);
|
||
│ 63: border-color: var(--border);
|
||
│ 64: }
|
||
│ 65:
|
||
│ 66: main { padding: 14px 16px 40px; max-width: 1500px; margin: 0 auto; }
|
||
│ 67: .hidden { display: none !important; }
|
||
│ 68:
|
||
│ 69: .panel {
|
||
│ 70: background: var(--bg-panel);
|
||
│ 71: border: 1px solid var(--border);
|
||
│ 72:
|
||
│ … [8848 more chars]
|
||
│ read /home/mac/Code/dashboard/templates/services.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/services.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/services.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div id="services" class="panel" hx-get="/api/services" hx-trigger="every 15s"
|
||
│ hx-swap="outerHTML" hx-include="#svc-filters">
|
||
│ 2: {% if error %}<div class="alert">{{ error }}</div>{% endif %}
|
||
│ 3: <div class="muted small">system: {{ state }} · {{ units | length }} units</div>
|
||
│ 4: <div class="table-wrap">
|
||
│ 5: <table class="table">
|
||
│ 6: <colgroup>
|
||
│ 7: <col style="width: 30px">
|
||
│ 8: <col style="width: 250px">
|
||
│ 9: <col>
|
||
│ 10: <col style="width: 130px">
|
||
│ 11: <col style="width: 90px">
|
||
│ 12: <col style="width: 190px">
|
||
│ 13: </colgroup>
|
||
│ 14: <thead>
|
||
│ 15: <tr>
|
||
│ 16: <th></th>
|
||
│ 17: <th><a href="#" class="sortlink{% if sort == "name" %} active{% endif %}"
|
||
│ data-form="svc-filters" data-sort="name" data-order="{% if sort == "name" and order == "asc"
|
||
│ %}desc{% else %}asc{% endif %}">Unit{% if sort == "name" %} {{ "↓" if order == "desc" else
|
||
│ "↑" }}{% endif %}</a></th>
|
||
│ 18: <th class="muted">Description</th>
|
||
│ 19: <th><a href="#" class="sortlink{% if sort == "state" %} active{% endif %}"
|
||
│ data-form="svc-filters" data-sort="state" data-order="{% if sort == "state" and order ==
|
||
│ "asc" %}desc{% else %}asc{% endif %}">State{% if sort == "state" %} {{ "↓" if order ==
|
||
│ "desc" else "↑" }}{% endif %}</a></th>
|
||
│ 20: <th><a href="#" class="sortlink{% if sort == "enabled" %} active{% endif %}"
|
||
│ data-form="svc-filters" data-sort="enabled" data-order="{% if sort == "enabled" and order ==
|
||
│ "asc" %}desc{% else %}asc{% endif %}">Enabled{% if sort == "enabled" %} {{ "↓" if order ==
|
||
│ "desc" else "↑" }}{% endif %}</a></th>
|
||
│ 21: <th>Actions</th>
|
||
│ 22: </tr>
|
||
│ 23: </thead>
|
||
│ 24: <tbody>
|
||
│ 25: {% for u in units %}
|
||
│ 26: {% set dot = "dot-dead" %}
|
||
│ 27: {% if u.sub in ("running", "exited") and u.active == "active" %}{% set dot =
|
||
│ "dot-run" %}{% endif %}
|
||
│ 28: {% if u
|
||
│ … [2353 more chars]
|
||
│ read /home/mac/Code/dashboard/templates/journal.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/journal.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/journal.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: {% if error %}
|
||
│ 2: <div class="jline j-error-once"><span class="muted">journalctl: {{ error }}</span></div>
|
||
│ 3: {% endif %}
|
||
│ 4: {% for e in entries %}
|
||
│ 5: <div class="jline{% if e.prio <= 3 %} j-err{% elif e.prio == 4 %} j-warn{% endif %}"
|
||
│ data-cursor="{{ e.cursor }}">
|
||
│ 6: <span class="j-time">{{ e.stamp }}</span>
|
||
│ 7: <span class="j-ident">{{ e.ident }}</span>
|
||
│ 8: <span class="j-msg">{{ e.msg }}</span>
|
||
│ 9: </div>
|
||
│ 10: {% endfor %}
|
||
│
|
||
│ (End of file - total 10 lines)
|
||
│ </content>
|
||
│ read /home/mac/Code/dashboard/templates/plugins.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/plugins.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/plugins.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div id="plugins" class="panel">
|
||
│ 2: {% for item in items %}
|
||
│ 3: <div class="plugin-card">
|
||
│ 4: <h3>{{ item.p.title }}</h3>
|
||
│ 5: <p class="muted small">{{ item.p.description }}</p>
|
||
│ 6: <div class="plugin-body">{{ item.body | safe }}</div>
|
||
│ 7: </div>
|
||
│ 8: {% else %}
|
||
│ 9: <div class="muted">no plugins registered</div>
|
||
│ 10: {% endfor %}
|
||
│ 11: </div>
|
||
│
|
||
│ (End of file - total 11 lines)
|
||
│ </content>
|
||
│
|
||
│ tokens 6608 in 302 out 8658 cached 0 written $0.0000 tool-calls
|
||
|
||
|
||
│ 2026-08-31 01:54:34 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ Next, let's check the routers to see if there are any tab-related endpoints. Also, let's check
|
||
│ the plugin's state templates to see if there's any per-plugin polling. I'll look at the routers
|
||
│ and plugin templates, and also check main.py for the route definitions.
|
||
│
|
||
│ Now let me examine the routers and the plugin state templates to complete the picture.
|
||
│
|
||
│ grep tab|@router\.|@app\. ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "pattern": "tab|@router\\.|@app\\.",
|
||
│ "path": "/home/mac/Code/dashboard/app",
|
||
│ "include": "*.py"
|
||
│ }
|
||
│ out
|
||
│ Found 38 matches
|
||
│ /home/mac/Code/dashboard/app/journal.py:
|
||
│ Line 42: """Reduce raw export entries to the fields the journal tab renders.
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/plugins/sessions.py:
|
||
│ Line 48: """Shape loginctl session entries into table rows for the UI.
|
||
│
|
||
│ Line 125: @router.get("/state")
|
||
│
|
||
│ Line 131: @router.post("/terminate")
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/main.py:
|
||
│ Line 58: The shell only holds the tab bar and containers; each tab polls its
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/plugins/llamacpp.py:
|
||
│ Line 192: @router.get("/state")
|
||
│
|
||
│ Line 198: @router.post("/load")
|
||
│
|
||
│ Line 217: @router.post("/unload")
|
||
│
|
||
│ Line 236: @router.post("/rescan")
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/plugins/sleep.py:
|
||
│ Line 63: """Shape block-mode inhibitor entries into table rows for the UI.
|
||
│
|
||
│ Line 218: @router.get("/state")
|
||
│
|
||
│ Line 224: @router.post("/toggle")
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/plugins/lact.py:
|
||
│ Line 185: @router.get("/state")
|
||
│
|
||
│ Line 191: @router.post("/set")
|
||
│
|
||
│ Line 225: @router.post("/reload")
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/routers/services.py:
|
||
│ Line 100: @router.get("")
|
||
│
|
||
│ Line 102: """Render the Services tab fragment.
|
||
│
|
||
│ Line 115: @router.get("/{unit}/detail")
|
||
│
|
||
│ Line 144: @router.post("/{unit}/action")
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/collect/cpu.py:
|
||
│ Line 21: The sysfs file to read, or None if no suitable sensor exists.
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/plugins/base.py:
|
||
│ Line 1: from collections.abc import Awaitable, Callable
|
||
│
|
||
│ Line 12: renders the plugin's initial fragment for the Plugins tab.
|
||
│
|
||
│ Line 18: skeleton_fn: Callable[[], Awaitable[str]] | None = field(default=None)
|
||
│
|
||
│ Line 19: open_fn: Callable[[], Awaitable[None]] | None = field(default=None)
|
||
│
|
||
│ Line 20: close_fn: Callable[[], Awaitable[None]] | None = field(default=None)
|
||
│
|
||
│ Line 26: The HTML fragment for the Plugins tab.
|
||
│
|
||
│
|
||
│ /home/mac/Code/dashboard/app/routers/overview.py:
|
||
│ Line 19: @router.get("/overview")
|
||
│
|
||
│ Line 21: """Render the Overview tab fr
|
||
│ … [881 more chars]
|
||
│ read /home/mac/Code/dashboard/templates/plugins/sleep_state.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/plugins/sleep_state.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/plugins/sleep_state.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: {% if error %}<div class="alert">systemd-inhibit failed: {{ error }}</div>{% endif %}
|
||
│ 2: {% if message %}<div class="notice">{{ message }}</div>{% endif %}
|
||
│ 3: <div class="llama-health">
|
||
│ 4: <span class="dot {{ "dot-failed" if verdict == "blocked" else "dot-run" }}"></span>
|
||
│ 5: <span class="llama-health-text">{{ "sleep blocked" if verdict == "blocked" else "sleep
|
||
│ allowed" }}</span>
|
||
│ 6: <span class="muted small">block-mode locks only — delay-mode locks are ignored</span>
|
||
│ 7: </div>
|
||
│ 8: <label class="chk">
|
||
│ 9: <input type="checkbox" name="on" value="1" {% if holding %}checked{% endif %}
|
||
│ hx-post="/api/plugins/sleep/toggle" hx-target="closest .plugin-body" hx-swap="innerHTML">
|
||
│ 10: keep system awake (this dashboard holds a sleep lock)
|
||
│ 11: </label>
|
||
│ 12: {% if inhibitors %}
|
||
│ 13: <div class="table-wrap">
|
||
│ 14: <table class="table">
|
||
│ 15: <thead><tr><th>who</th><th>what</th><th>why</th><th>mode</th></tr></thead>
|
||
│ 16: <tbody>
|
||
│ 17: {% for e in inhibitors %}
|
||
│ 18: <tr class="{{ "inh-own" if e.own else "" }}">
|
||
│ 19: <td class="cell-clip">{{ e.who }}{% if e.proc %} <span class="muted small mono">{{
|
||
│ e.proc }}</span>{% endif %}</td>
|
||
│ 20: <td>{% for w in e.what.split(":") if w %}<span class="badge">{{ w }}</span> {%
|
||
│ endfor %}</td>
|
||
│ 21: <td class="cell-clip muted">{{ e.why }}</td>
|
||
│ 22: <td><span class="badge {{ "badge-block" if e.mode == "block" else "" }}">{{ e.mode
|
||
│ }}</span></td>
|
||
│ 23: </tr>
|
||
│ 24: {% endfor %}
|
||
│ 25: </tbody>
|
||
│ 26: </table>
|
||
│ 27: </div>
|
||
│ 28: {% else %}
|
||
│ 29: <div class="muted small">no active block-mode inhibitors — the system may sleep</div>
|
||
│ 30: {% endif %}
|
||
│
|
||
│ (End of file - total 30 lines)
|
||
│ </content>
|
||
│ read /home/mac/Code/dashboard/templates/plugins/llamacpp_state.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/plugins/llamacpp_state.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/plugins/llamacpp_state.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: {% if not reachable %}
|
||
│ 2: <div class="alert">llama-server unreachable at <span class="mono">{{ base_url }}</span>{%
|
||
│ if error %}: {{ error }}{% endif %}</div>
|
||
│ 3: <div class="muted small">start it in router mode: <span class="mono">llama-server
|
||
│ --models-dir /path/to/models</span></div>
|
||
│ 4: {% else %}
|
||
│ 5: <div class="llama-health">
|
||
│ 6: <span class="dot {{ "dot-run" if health == "ok" else "dot-failed" }}"></span>
|
||
│ 7: <span class="llama-health-text">{{ "healthy" if health == "ok" else (health or "status
|
||
│ unknown") }}</span>
|
||
│ 8: <span class="muted small mono">{{ base_url }}</span>
|
||
│ 9: </div>
|
||
│ 10: {% if message %}<div class="notice">{{ message }}</div>{% endif %}
|
||
│ 11: {% if error_msg %}<div class="alert">{{ error_msg }}</div>{% endif %}
|
||
│ 12:
|
||
│ 13: <h4>Loaded models</h4>
|
||
│ 14: {% if loaded %}
|
||
│ 15: <div class="llama-models">
|
||
│ 16: {% for m in loaded %}
|
||
│ 17: <div class="llama-model">
|
||
│ 18: <span class="dot {{ "dot-run" if m.state == "loaded" else ("dot-sleep" if m.state ==
|
||
│ "sleeping" else ("dot-failed" if m.failed else "dot-busy")) }}"></span>
|
||
│ 19: <span class="mono llama-id" title="{{ m.path }}">{{ m.id }}</span>
|
||
│ 20: <span class="badge badge-{{ "failed" if m.failed else m.state }}">{{ m.state }}{% if
|
||
│ m.failed %} (exit {{ m.exit_code }}){% endif %}</span>
|
||
│ 21: {% if m.progress is not none %}<div class="bar small-bar"><div class="bar-fill"
|
||
│ style="width: {{ m.progress }}%"></div></div>{% endif %}
|
||
│ 22: <span class="llama-model-actions">
|
||
│ 23: <button class="btn" hx-post="/api/plugins/llamacpp/unload" hx-vals='{"model":"{{
|
||
│ m.id }}"}' hx-target="closest .plugin-body" hx-swap="innerHTML" hx-confirm="Unload {{ m.id
|
||
│ }}?">unload</button>
|
||
│ 24: </span>
|
||
│ 25: </div>
|
||
│ 26: {% endfor %}
|
||
│ 27: </div>
|
||
│ 28: {% else %}
|
||
│ 29: <div class="muted small">no models loaded</div>
|
||
│ 30: {% endif %}
|
||
│ 31: {% endif %}
|
||
│
|
||
│ (End of file - total 31 lines)
|
||
│ </content>
|
||
│
|
||
│ tokens 6777 in 212 out 15568 cached 0 written $0.0000 tool-calls
|
||
|
||
|
||
│ 2026-08-31 01:54:52 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ Let me check main.py to see how the routes are registered, and also check the router's
|
||
│ __init__.py to understand the URL structure. I also want to verify how the plugin tab works —
|
||
│ plugins.html has `hx-get="/api/plugins" hx-trigger="load"`, and the individual plugin skeletons
|
||
│ might have their own polling. Let me check the skeleton templates.
|
||
│
|
||
│ Let me check main.py, the router registration, and the plugin skeleton templates (to see
|
||
│ per-plugin polling).
|
||
│
|
||
│ read /home/mac/Code/dashboard/app/main.py ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/app/main.py"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/app/main.py</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: import asyncio
|
||
│ 2: import socket
|
||
│ 3: from contextlib import asynccontextmanager
|
||
│ 4:
|
||
│ 5: from fastapi import FastAPI
|
||
│ 6: from fastapi.responses import HTMLResponse
|
||
│ 7: from fastapi.staticfiles import StaticFiles
|
||
│ 8:
|
||
│ 9: from app.config import get_settings
|
||
│ 10: from app.plugins import PLUGINS, ROUTERS as PLUGIN_ROUTERS
|
||
│ 11: from app.render import BASE, render
|
||
│ 12: from app.routers import disks, overview, plugins, processes, services
|
||
│ 13: from app.routers import journal as journal_router
|
||
│ 14: from app.sampling import sampler_loop
|
||
│ 15: from app.state import HistoryStore
|
||
│ 16:
|
||
│ 17:
|
||
│ 18: @asynccontextmanager
|
||
│ 19: async def lifespan(app: FastAPI):
|
||
│ 20: """Start shared state and run plugin lifecycle hooks around the app.
|
||
│ 21:
|
||
│ 22: Startup: stores settings and the history ring buffer on `app.state`,
|
||
│ 23: opens every plugin (a plugin `open()` failure is ignored, not fatal),
|
||
│ 24: and spawns the background sampler task. Shutdown: cancels the
|
||
│ 25: sampler task and closes every plugin.
|
||
│ 26:
|
||
│ 27: Args:
|
||
│ 28: app: the FastAPI instance.
|
||
│ 29:
|
||
│ 30: Yields:
|
||
│ 31: Control to the ASGI app for the server's lifetime.
|
||
│ 32: """
|
||
│ 33: settings = get_settings()
|
||
│ 34: app.state.settings = settings
|
||
│ 35: app.state.store = HistoryStore(maxlen=settings.history_maxlen)
|
||
│ 36: for p in PLUGINS:
|
||
│ 37: try:
|
||
│ 38: await p.open()
|
||
│ 39: except Exception: # noqa
|
||
│ 40: pass
|
||
│ 41: task = asyncio.create_task(sampler_loop(app.state.store, settings.sample_interval))
|
||
│ 42: yield
|
||
│ 43: _ = task.cancel()
|
||
│ 44: try:
|
||
│ 45: await task
|
||
│ 46: except asyncio.CancelledError:
|
||
│ 47: pass
|
||
│ 48: for p in PLUGINS:
|
||
│ 49: try:
|
||
│ 50: await p.close()
|
||
│ 51: except Exception: # noqa
|
||
│ 52: pass
|
||
│ 53:
|
||
│ 54:
|
||
│ 55: async def index():
|
||
│ 56: """Serve the single-page dashboard shell at "/".
|
||
│ 57:
|
||
│ 58: The shell only holds the tab bar and contai
|
||
│ … [1182 more chars]
|
||
│ read /home/mac/Code/dashboard/app/routers/__init__.py ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/app/routers/__init__.py"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/app/routers/__init__.py</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: from app.routers import disks, journal, overview, plugins, processes, services
|
||
│ 2:
|
||
│ 3: __all__ = ["overview", "disks", "processes", "journal", "services", "plugins"]
|
||
│
|
||
│ (End of file - total 3 lines)
|
||
│ </content>
|
||
│ read /home/mac/Code/dashboard/templates/plugins/lact_skeleton.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/plugins/lact_skeleton.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/plugins/lact_skeleton.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div class="lact">
|
||
│ 2: <div id="lact-state" hx-get="/api/plugins/lact/state" hx-trigger="every 5s"
|
||
│ hx-swap="innerHTML">
|
||
│ 3: {% include "plugins/lact_state.html" %}
|
||
│ 4: </div>
|
||
│ 5: {% if gpus and not error %}
|
||
│ 6: {% for g in gpus %}
|
||
│ 7: <div class="lact-load">
|
||
│ 8: <select id="lact-pick-{{ g.id }}" name="profile">
|
||
│ 9: {% for p in g.profiles %}
|
||
│ 10: <option value="{{ p }}" {{ "selected" if p == g.active }}>{{ p }}{% if p ==
|
||
│ g.active %} (active){% endif %}</option>
|
||
│ 11: {% else %}
|
||
│ 12: <option value="" disabled selected>no profiles</option>
|
||
│ 13: {% endfor %}
|
||
│ 14: </select>
|
||
│ 15: <span class="actions">
|
||
│ 16: <button class="btn" hx-post="/api/plugins/lact/set" hx-vals='{"gpu_id":"{{ g.id
|
||
│ }}"}' hx-include="#lact-pick-{{ g.id }}" hx-target="closest .plugin-body"
|
||
│ hx-swap="innerHTML">set</button>
|
||
│ 17: <button class="btn" hx-post="/api/plugins/lact/reload" hx-vals='{"gpu_id":"{{ g.id
|
||
│ }}"}' hx-target="closest .plugin-body" hx-swap="innerHTML">reload</button>
|
||
│ 18: </span>
|
||
│ 19: </div>
|
||
│ 20: {% endfor %}
|
||
│ 21: {% endif %}
|
||
│ 22: </div>
|
||
│
|
||
│ (End of file - total 22 lines)
|
||
│ </content>
|
||
│ read /home/mac/Code/dashboard/templates/plugins/llamacpp_skeleton.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/plugins/llamacpp_skeleton.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/plugins/llamacpp_skeleton.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div class="llama">
|
||
│ 2: <div id="llama-state" hx-get="/api/plugins/llamacpp/state" hx-trigger="every 5s"
|
||
│ hx-swap="innerHTML">
|
||
│ 3: {% include "plugins/llamacpp_state.html" %}
|
||
│ 4: </div>
|
||
│ 5: {% if reachable %}
|
||
│ 6: <h4>Available models</h4>
|
||
│ 7: {% if available %}
|
||
│ 8: <div class="llama-load">
|
||
│ 9: <select id="llama-model-pick" name="model">
|
||
│ 10: {% for m in available %}
|
||
│ 11: <option value="{{ m.id }}">{{ m.id }}{% if m.failed %} (failed, exit {{
|
||
│ m.exit_code }}){% endif %}</option>
|
||
│ 12: {% endfor %}
|
||
│ 13: </select>
|
||
│ 14: <button class="btn" hx-post="/api/plugins/llamacpp/load"
|
||
│ hx-include="#llama-model-pick" hx-target="closest .plugin-body"
|
||
│ hx-swap="innerHTML">load</button>
|
||
│ 15: </div>
|
||
│ 16: {% else %}
|
||
│ 17: <div class="muted small">no unloaded models (is --models-dir set on the server?)</div>
|
||
│ 18: {% endif %}
|
||
│ 19:
|
||
│ 20: <div class="actions">
|
||
│ 21: <button class="btn" hx-post="/api/plugins/llamacpp/rescan" hx-target="closest
|
||
│ .plugin-body" hx-swap="innerHTML">rescan models</button>
|
||
│ 22: </div>
|
||
│ 23: {% endif %}
|
||
│ 24: </div>
|
||
│
|
||
│ (End of file - total 24 lines)
|
||
│ </content>
|
||
│ read /home/mac/Code/dashboard/templates/plugins/sessions_skeleton.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/plugins/sessions_skeleton.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/plugins/sessions_skeleton.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div class="sessions">
|
||
│ 2: <div id="sessions-state" hx-get="/api/plugins/sessions/state" hx-trigger="every 5s"
|
||
│ hx-swap="innerHTML">
|
||
│ 3: {% include "plugins/sessions_state.html" %}
|
||
│ 4: </div>
|
||
│ 5: </div>
|
||
│
|
||
│ (End of file - total 5 lines)
|
||
│ </content>
|
||
│ read /home/mac/Code/dashboard/templates/plugins/sleep_skeleton.html ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/templates/plugins/sleep_skeleton.html"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/templates/plugins/sleep_skeleton.html</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: <div class="sleep">
|
||
│ 2: <div id="sleep-state" hx-get="/api/plugins/sleep/state" hx-trigger="every 5s"
|
||
│ hx-swap="innerHTML">
|
||
│ 3: {% include "plugins/sleep_state.html" %}
|
||
│ 4: </div>
|
||
│ 5: </div>
|
||
│
|
||
│ (End of file - total 5 lines)
|
||
│ </content>
|
||
│
|
||
│ tokens 2148 in 314 out 22556 cached 0 written $0.0000 tool-calls
|
||
|
||
|
||
│ 2026-08-31 01:55:05 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ Now I have a very complete overall picture. Let me do one more check at the end: verify that no
|
||
│ other .js files exist (only app.js and the vendor files), and check the plugins router to see
|
||
│ how /api/plugins renders the skeletons. Also, quickly verify the route paths of each router to
|
||
│ document the endpoints. For the sake of accuracy, let me read the plugins router and skim
|
||
│ through the route definitions of the routers.
|
||
│
|
||
│ Let me verify the plugins router and get exact route paths from all routers.
|
||
│
|
||
│ read /home/mac/Code/dashboard/app/routers/plugins.py ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "filePath": "/home/mac/Code/dashboard/app/routers/plugins.py"
|
||
│ }
|
||
│ out
|
||
│ <path>/home/mac/Code/dashboard/app/routers/plugins.py</path>
|
||
│ <type>file</type>
|
||
│ <content>
|
||
│ 1: from fastapi import APIRouter
|
||
│ 2: from fastapi.responses import HTMLResponse
|
||
│ 3:
|
||
│ 4: from app.plugins import PLUGINS, Plugin
|
||
│ 5: from app.render import render
|
||
│ 6:
|
||
│ 7: router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||
│ 8:
|
||
│ 9:
|
||
│ 10: @router.get("")
|
||
│ 11: async def plugins_index():
|
||
│ 12: """Render the Plugins tab: a skeleton fragment for every registered plugin.
|
||
│ 13:
|
||
│ 14: A plugin whose skeleton() raises gets an inline error card instead of
|
||
│ 15: taking down the whole page.
|
||
│ 16:
|
||
│ 17: Returns:
|
||
│ 18: The rendered plugins.html as an HTMLResponse.
|
||
│ 19: """
|
||
│ 20: items: list[dict[str, Plugin | str]] = []
|
||
│ 21: for p in PLUGINS:
|
||
│ 22: try:
|
||
│ 23: body = await p.skeleton()
|
||
│ 24: except Exception as e: # noqa
|
||
│ 25: body = f"<div class='alert'>plugin error: {e}</div>"
|
||
│ 26: items.append({"p": p, "body": body})
|
||
│ 27: return HTMLResponse(render("plugins.html", items=items))
|
||
│
|
||
│ (End of file - total 27 lines)
|
||
│ </content>
|
||
│ bash rg -n "APIRouter\(|@router\.(get|post)" /home/mac/Code/dashboard/app/routers… ok 0.0s
|
||
│ in
|
||
│ {
|
||
│ "command": "rg -n \"APIRouter\\(|@router\\.(get|post)\"
|
||
│ /home/mac/Code/dashboard/app/routers/ /home/mac/Code/dashboard/app/plugins/"
|
||
│ }
|
||
│ out
|
||
│ /home/mac/Code/dashboard/app/plugins/sessions.py:12:router =
|
||
│ APIRouter(prefix="/api/plugins/sessions", tags=["plugins"])
|
||
│ /home/mac/Code/dashboard/app/plugins/sessions.py:125:@router.get("/state")
|
||
│ /home/mac/Code/dashboard/app/plugins/sessions.py:131:@router.post("/terminate")
|
||
│ /home/mac/Code/dashboard/app/routers/services.py:10:router =
|
||
│ APIRouter(prefix="/api/services", tags=["services"])
|
||
│ /home/mac/Code/dashboard/app/routers/services.py:100:@router.get("")
|
||
│ /home/mac/Code/dashboard/app/routers/services.py:115:@router.get("/{unit}/detail")
|
||
│ /home/mac/Code/dashboard/app/routers/services.py:144:@router.post("/{unit}/action")
|
||
│ /home/mac/Code/dashboard/app/plugins/sleep.py:13:router =
|
||
│ APIRouter(prefix="/api/plugins/sleep", tags=["plugins"])
|
||
│ /home/mac/Code/dashboard/app/plugins/sleep.py:218:@router.get("/state")
|
||
│ /home/mac/Code/dashboard/app/plugins/sleep.py:224:@router.post("/toggle")
|
||
│ /home/mac/Code/dashboard/app/plugins/lact.py:14:router =
|
||
│ APIRouter(prefix="/api/plugins/lact", tags=["plugins"])
|
||
│ /home/mac/Code/dashboard/app/plugins/lact.py:185:@router.get("/state")
|
||
│ /home/mac/Code/dashboard/app/plugins/lact.py:191:@router.post("/set")
|
||
│ /home/mac/Code/dashboard/app/plugins/lact.py:225:@router.post("/reload")
|
||
│ /home/mac/Code/dashboard/app/plugins/llamacpp.py:11:router =
|
||
│ APIRouter(prefix="/api/plugins/llamacpp", tags=["plugins"])
|
||
│ /home/mac/Code/dashboard/app/plugins/llamacpp.py:192:@router.get("/state")
|
||
│ /home/mac/Code/dashboard/app/plugins/llamacpp.py:198:@router.post("/load")
|
||
│ /home/mac/Code/dashboard/app/plugins/llamacpp.py:217:@router.post("/unload")
|
||
│ /home/mac/Code/dashboard/app/plugins/llamacpp.py:236:@router.post("/rescan")
|
||
│ /home/mac/Code/dashboard/app/routers/journal.py:9:router = APIRouter(prefix="/api",
|
||
│ tags=["journal"])
|
||
│ /home/mac/Code/dashboard/app/routers/journal.py:12:@router.get("/journal")
|
||
│ /home/mac/Code/dashboard/app/routers/overview.py:16:router = APIRouter(prefix="/api",
|
||
│ tags=["overview"])
|
||
│ /home/mac/Code/dashboard/app/routers/overview.py:19:@router.get("/overview")
|
||
│ /h
|
||
│ … [605 more chars]
|
||
│
|
||
│ tokens 2187 in 201 out 25018 cached 0 written $0.0000 tool-calls
|
||
|
||
|
||
│ 2026-08-31 01:55:16 • assistant • Qwen3.8-27B-Think @explore
|
||
│
|
||
│ Now I have a grasp of the overall picture. Let me put together a thorough report.
|
||
│
|
||
│ Summary of the main investigation findings:
|
||
│
|
||
│ **File listing:**
|
||
│
|
||
│ templates/:
|
||
│ - disks.html (2013 bytes)
|
||
│ - index.html (4442 bytes)
|
||
│ - journal.html (404 bytes)
|
||
│ - overview.html (4057 bytes)
|
||
│ - plugins.html (326 bytes)
|
||
│ - processes.html (2052 bytes)
|
||
│ - service_detail.html (1112 bytes)
|
||
│ - services.html (4024 bytes)
|
||
│ - plugins/ (subdirectory):
|
||
│ - lact_skeleton.html
|
||
│ - lact_state.html
|
||
│ - llamacpp_skeleton.html
|
||
│ - llamacpp_state.html
|
||
│ - sessions_skeleton.html
|
||
│ - sessions_state.html
|
||
│ - sleep_skeleton.html
|
||
│ - sleep_state.html
|
||
│
|
||
│ static/:
|
||
│ - css/style.css (9441 bytes)
|
||
│ - js/app.js (8178 bytes)
|
||
│ - vendor/chart.umd.min.js (205125 bytes)
|
||
│ - vendor/htmx.min.js (48101 bytes)
|
||
│
|
||
│ **Tab system:**
|
||
│ - index.html has a `<nav id="tabs">`, with `<button class="tab-btn [active]" data-tab="...">`
|
||
│ buttons
|
||
│ - `<main>` has `<section id="tab-<name>" class="tab [hidden]">` sections
|
||
│ - Pure JS inside app.js (no htmx on the tab buttons): `showTab(name)` toggles the `.active`
|
||
│ class on the button and the `.hidden` class on the section
|
||
│ - The selected tab is persisted in localStorage under the key `dash.tab`
|
||
│ - CSS: `.tab-btn.active` styles, `.hidden { display: none !important; }`
|
||
│ - Key point: there's no pausing/resuming of polling on tab switch — all polling divs are inside
|
||
│ the DOM at all times, so htmx's `hx-trigger="load, every Ns"` continues polling all tabs even
|
||
│ while hidden. The plugins tab's state divs are only loaded after /api/plugins fetches the
|
||
│ skeleton (hx-trigger="load" on the #plugins div), and once loaded the every-5s polling of each
|
||
│ plugin state div continues regardless of visibility.
|
||
│
|
||
│ **Polling:**
|
||
│ - Overview panel: `/api/overview` every 2 seconds (load + every 2s in index; every 2s in the
|
||
│ fragment)
|
||
│ - Overview charts: plain JS `setInterval(pollHistory, 2000)` fetches `/api/history` (JSON) — not
|
||
│ htmx
|
||
│ - Disks: `/api/disks` every 2 seconds
|
||
│ - Processes: `/api/processes` every 3 seconds, includes `#proc-controls` (filter
|
||
│ … [3532 more chars]
|
||
│
|
||
│ I now have a complete picture of the tab system. Here is the thorough report.
|
||
│
|
||
│ # Dashboard Tab System — Full Analysis
|
||
│
|
||
│ ## 1. File Listings
|
||
│
|
||
│ ### `templates/` (absolute paths)
|
||
│ ```
|
||
│ /home/mac/Code/dashboard/templates/index.html (4442 B) — SPA shell: tab bar + all
|
||
│ section containers
|
||
│ /home/mac/Code/dashboard/templates/overview.html (4057 B) — Overview fragment (cards)
|
||
│ /home/mac/Code/dashboard/templates/disks.html (2013 B) — Disks fragment
|
||
│ /home/mac/Code/dashboard/templates/processes.html (2052 B) — Processes fragment
|
||
│ /home/mac/Code/dashboard/templates/journal.html ( 404 B) — Journal fragment (line batch)
|
||
│ /home/mac/Code/dashboard/templates/services.html (4024 B) — Services fragment
|
||
│ /home/mac/Code/dashboard/templates/service_detail.html (1112 B) — Service accordion detail
|
||
│ fragment
|
||
│ /home/mac/Code/dashboard/templates/plugins.html ( 326 B) — Plugins fragment (wraps
|
||
│ skeletons)
|
||
│ /home/mac/Code/dashboard/templates/plugins/
|
||
│ ├── llamacpp_skeleton.html ( 982 B)
|
||
│ ├── llamacpp_state.html (1705 B)
|
||
│ ├── sleep_skeleton.html ( 182 B)
|
||
│ ├── sleep_state.html (1502 B)
|
||
│ ├── lact_skeleton.html (1011 B)
|
||
│ ├── lact_state.html ( 713 B)
|
||
│ └── sessions_skeleton.html ( 194 B)
|
||
│ sessions_state.html (1091 B)
|
||
│ ```
|
||
│
|
||
│ ### `static/` (exists)
|
||
│ ```
|
||
│ /home/mac/Code/dashboard/static/css/style.css (9441 B)
|
||
│ /home/mac/Code/dashboard/static/js/app.js (8178 B) — the only hand-written JS
|
||
│ /home/mac/Code/dashboard/static/vendor/htmx.min.js (48101 B)
|
||
│ /home/mac/Code/dashboard/static/vendor/chart.umd.min.js(205125 B)
|
||
│ ```
|
||
│ No other `.js` files exist in the repo (verified by glob: only `app.js` + the two vendor libs).
|
||
│
|
||
│ ---
|
||
│
|
||
│ ## 2. Full Content of `templates/index.html` (the SPA shell)
|
||
│
|
||
│ ```html
|
||
│ 1: <!doctype html>
|
||
│ 2: <html lang="en">
|
||
│ 3: <head>
|
||
│ 4: <meta charset="utf-8">
|
||
│ 5: <meta name="viewport" content="width=device-width, initial-scale=1">
|
||
│ 6: <title>{{ hostname }}</title>
|
||
│ 7: <link rel="stylesheet" href="/static/css/style.css">
|
||
│ 8: <script src="/static/vendor/htmx.min.js"></script>
|
||
│ 9: <script src="/static/vendor/chart.umd.min.js"></script>
|
||
│ 10: </head>
|
||
│ 11: <body>
|
||
│ 12: <header class="topbar">
|
||
│ 13: <h1>{{ hostname }}</h1>
|
||
│ 14: <nav id="tabs">
|
||
│ 15: <button class="tab-btn active" data-tab="overview">Overview</button>
|
||
│ 16: <button class="tab-btn" data-tab="disks">Disks</button>
|
||
│ 17: <button class="tab-btn" data-tab="processes">Processes</button>
|
||
│ 18: <button class="tab-btn" data-tab="journal">Journal</button>
|
||
│ 19: <button class="tab-btn" data-tab="services">Services</button>
|
||
│ 20: <button class="tab-btn" data-tab="plugins">Plugins</button>
|
||
│ 21: </nav>
|
||
│ 22: </header>
|
||
│ 23: <main>
|
||
│ 24: <section id="tab-overview" class="tab">
|
||
│ 25: <div id="overview" class="panel" hx-get="/api/overview" hx-trigger="load, every 2s"
|
||
│ hx-swap="outerHTML"></div>
|
||
│ 26: <div class="charts-grid">
|
||
│ 27: <div class="chart-card"><h3>CPU / GPU %</h3><canvas id="chart-cpu"></canvas></div>
|
||
│ 28: <div class="chart-card"><h3>Memory / VRAM %</h3><canvas
|
||
│ id="chart-mem"></canvas></div>
|
||
│ 29: <div class="chart-card"><h3>Disk I/O</h3><canvas id="chart-io"></canvas></div>
|
||
│ 30: </div>
|
||
│ 31: </section>
|
||
│ 32:
|
||
│ 33: <section id="tab-disks" class="tab hidden">
|
||
│ 34: <div id="disks" class="panel" hx-get="/api/disks" hx-trigger="load, every 2s"
|
||
│ hx-swap="outerHTML"></div>
|
||
│ 35: </section>
|
||
│ 36:
|
||
│ 37: <section id="tab-processes" class="tab hidden">
|
||
│ 38: <form id="proc-controls" class="inline-form" hx-get="/api/processes"
|
||
│ hx-target="#processes-body" hx-swap="outerHTML">
|
||
│ 39: <input id="proc-q" name="q" placeholder="filter name / pid">
|
||
│ 40: <select name="sort" id="proc-sort">
|
||
│ 41: <option value="cpu">sort: CPU</option>
|
||
│ 42: <option value="rss">sort: MEM</option>
|
||
│ 43: <option value="mem_pct">sort: MEM%</option>
|
||
│ 44: <option value="io_read">sort: IO read</option>
|
||
│ 45: <option value="io_write">sort: IO write</option>
|
||
│ 46: <option value="pid">sort: PID</option>
|
||
│ 47: <option value="name">sort: Name</option>
|
||
│ 48: </select>
|
||
│ 49: <select name="order" id="proc-order">
|
||
│ 50: <option value="desc">desc</option>
|
||
│ 51: <option value="asc">asc</option>
|
||
│ 52: </select>
|
||
│ 53: <button type="submit">apply</button>
|
||
│ 54: </form>
|
||
│ 55: <div id="processes-body" class="panel" hx-get="/api/processes" hx-trigger="load, every
|
||
│ 3s" hx-swap="outerHTML" hx-include="#proc-controls"></div>
|
||
│ 56: </section>
|
||
│ 57:
|
||
│ 58: <section id="tab-journal" class="tab hidden">
|
||
│ 59: <div id="journal-status" class="muted small"></div>
|
||
│ 60: <form id="journal-filters" class="inline-form" hx-get="/api/journal"
|
||
│ hx-target="#journal-log" hx-swap="innerHTML">
|
||
│ 61: <select name="level">
|
||
│ 62: <option value="all">all</option>
|
||
│ 63: <option value="warn">warn+</option>
|
||
│ 64: <option value="err">err+</option>
|
||
│ 65: </select>
|
||
│ 66: <input name="unit" placeholder="unit (ssh.service)">
|
||
│ 67: <input name="search" placeholder="search text">
|
||
│ 68: <label class="chk"><input type="checkbox" name="hide_sudo" checked> hide sudo</label>
|
||
│ 69: <button type="submit">filter</button>
|
||
│ 70: </form>
|
||
│ 71: <div id="journal-logwrap">
|
||
│ 72: <input type="hidden" id="journal-cursor" name="cursor" value="">
|
||
│ 73: <div id="journal-log" hx-get="/api/journal" hx-trigger="load, every 5s"
|
||
│ hx-swap="beforeend" hx-include="#journal-filters, #journal-cursor"></div>
|
||
│ 74: </div>
|
||
│ 75: </section>
|
||
│ 76:
|
||
│ 77: <section id="tab-services" class="tab hidden">
|
||
│ 78: <form id="svc-filters" class="inline-form" hx-get="/api/services" hx-target="#services"
|
||
│ hx-swap="outerHTML">
|
||
│ 79: <input id="svc-q" name="q" placeholder="filter units">
|
||
│ 80: <select name="sort" id="svc-sort">
|
||
│ 81: <option value="name">sort: name</option>
|
||
│ 82: <option value="state">sort: state</option>
|
||
│ 83: <option value="enabled">sort: enabled</option>
|
||
│ 84: </select>
|
||
│ 85: <select name="order" id="svc-order">
|
||
│ 86: <option value="asc">asc</option>
|
||
│ 87: <option value="desc">desc</option>
|
||
│ 88: </select>
|
||
│ 89: <button type="submit">filter</button>
|
||
│ 90: </form>
|
||
│ 91: <div id="services" class="panel" hx-get="/api/services" hx-trigger="load, every 15s"
|
||
│ hx-swap="outerHTML" hx-include="#svc-filters"></div>
|
||
│ 92: </section>
|
||
│ 93:
|
||
│ 94: <section id="tab-plugins" class="tab hidden">
|
||
│ 95: <div id="plugins" class="panel" hx-get="/api/plugins" hx-trigger="load"
|
||
│ hx-swap="outerHTML"></div>
|
||
│ 96: </section>
|
||
│ 97: </main>
|
||
│ 98: <script src="/static/js/app.js"></script>
|
||
│ 99: </body>
|
||
│ 100: </html>
|
||
│ ```
|
||
│
|
||
│ Structural pattern: one `<button class="tab-btn" data-tab="X">` per tab in `nav#tabs`; one
|
||
│ `<section id="tab-X" class="tab [hidden]">` per tab in `<main>`. All six sections are present in
|
||
│ the DOM from the start, and **every section's polling div is live from page load regardless of
|
||
│ which tab is visible** (no htmx pausing on hidden elements).
|
||
│
|
||
│ ---
|
||
│
|
||
│ ## 3. Tab Switching Mechanism (exact)
|
||
│
|
||
│ **It is NOT htmx.** The tab buttons carry no `hx-*` attributes. Switching is plain JavaScript in
|
||
│ `static/js/app.js`, lines 4–19:
|
||
│
|
||
│ ```js
|
||
│ 4: // ---------- tabs ----------
|
||
│ 5: const tabBtns = document.querySelectorAll(".tab-btn");
|
||
│ 6: const sections = document.querySelectorAll(".tab");
|
||
│ 7:
|
||
│ 8: function showTab(name) {
|
||
│ 9: tabBtns.forEach((b) => b.classList.toggle("active", b.dataset.tab === name));
|
||
│ 10: sections.forEach((s) => s.classList.toggle("hidden", s.id !== "tab-" + name));
|
||
│ 11: try {
|
||
│ 12: localStorage.setItem("dash.tab", name);
|
||
│ 13: } catch (e) {}
|
||
│ 14: }
|
||
│ 15: tabBtns.forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
||
│ 16: try {
|
||
│ 17: const saved = localStorage.getItem("dash.tab");
|
||
│ 18: if (saved && document.getElementById("tab-" + saved)) showTab(saved);
|
||
│ 19: } catch (e) {}
|
||
│ ```
|
||
│
|
||
│ Mechanism, precisely:
|
||
│ 1. Click on any `.tab-btn` → `showTab(b.dataset.tab)`.
|
||
│ 2. `showTab` toggles `.active` on the single matching button and `.hidden` on the single
|
||
│ matching `#tab-<name>` section.
|
||
│ 3. The chosen tab name is persisted to `localStorage["dash.tab"]`; on page load, if that section
|
||
│ exists, it is restored (lines 16–19).
|
||
│ 4. The server never knows the active tab — `app/main.py` `index()` renders the shell once
|
||
│ ("renders once and never again", line 59) and no endpoint accepts a tab parameter.
|
||
│
|
||
│ CSS support in `static/css/style.css`:
|
||
│ ```css
|
||
│ 48: #tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
│ 50: .tab-btn {
|
||
│ 51: background: transparent;
|
||
│ 52: color: var(--muted);
|
||
│ 53: border: 1px solid transparent;
|
||
│ 54: padding: 6px 12px;
|
||
│ 55: border-radius: 6px;
|
||
│ 56: cursor: pointer;
|
||
│ 57: font-size: 13.5px;
|
||
│ 58: }
|
||
│ 59: .tab-btn:hover { color: var(--text); background: var(--bg-card); }
|
||
│ 60: .tab-btn.active {
|
||
│ 61: color: var(--text);
|
||
│ 62: background: var(--bg-card);
|
||
│ 63: border-color: var(--border);
|
||
│ 64: }
|
||
│ ...
|
||
│ 66: main { padding: 14px 16px 40px; max-width: 1500px; margin: 0 auto; }
|
||
│ 67: .hidden { display: none !important; }
|
||
│ ```
|
||
│ So `.hidden` is a global utility (`display: none !important`), and `.active` is the only state
|
||
│ class on buttons. No URL/hash/`history.pushState` involvement.
|
||
│
|
||
│ ---
|
||
│
|
||
│ ## 4. Representative Tab Templates (full content, showing polling)
|
||
│
|
||
│ ### `templates/overview.html` — self-polling fragment, `outerHTML` swap
|
||
│ ```html
|
||
│ 1: <div id="overview" class="panel cards" hx-get="/api/overview" hx-trigger="every 2s"
|
||
│ hx-swap="outerHTML">
|
||
│ 2: {% macro bar(pct) %}{% if pct is not none %}<div class="bar"><div class="bar-fill{% if pct
|
||
│ > 85 %} hot{% elif pct > 65 %} warm{% endif %}" style="width: {{ pct }}%"></div></div>{% endif
|
||
│ %}{% endmacro %}
|
||
│ 3: {% macro barrow(label, pct, cls) %}{% if pct is not none %}<div class="barrow"><span
|
||
│ class="barrow-label">{{ label }}</span><div class="bar"><div class="bar-fill{% if cls %} {{ cls
|
||
│ }}{% endif %}{% if pct > 85 %} hot{% elif pct > 65 %} warm{% endif %}" style="width: {{ pct
|
||
│ }}%"></div></div></div>{% endif %}{% endmacro %}
|
||
│ 4: <div class="card">
|
||
│ 5: <div class="card-label">CPU / GPU</div>
|
||
│ 6: <div class="card-value">{{ (c.cpu | default(0, true)) | round(1) }}%</div>
|
||
│ 7: {{ barrow("CPU", c.cpu) }}
|
||
│ 8: {{ barrow("GPU", c.gpu, "gpu") }}
|
||
│ 9: {% if c.cpu_temp is not none or c.gpu_temp is not none %}
|
||
│ 10: <div class="card-sub">
|
||
│ 11: {% if c.cpu_temp is not none %}CPU {{ c.cpu_temp | round(0) | int }}°C{% endif
|
||
│ %}
|
||
│ 12: {% if c.cpu_temp is not none and c.gpu_temp is not none %} ·{% endif %}
|
||
│ 13: {% if c.gpu_temp is not none %}GPU {{ c.gpu_temp | round(0) | int }}°C{% endif
|
||
│ %}
|
||
│ 14: </div>
|
||
│ 15: {% endif %}
|
||
│ 16: <div class="card-sub">load {{ (c.load1 | default(0, true)) | round(2) }} · {{
|
||
│ c.cores }} cores</div>
|
||
│ 17: </div>
|
||
│ 18: <div class="card">
|
||
│ 19: <div class="card-label">Memory</div>
|
||
│ 20: <div class="card-value">{{ c.mem_used | humanize }} <span class="muted">/ {{ c.mem_total
|
||
│ | humanize }} ({{ (c.mem_pct | default(0, true)) | round(0) | int }}%)</span></div>
|
||
│ 21: {{ bar(c.mem_pct) }}
|
||
│ 22: <div class="memrow">
|
||
│ 23: <div class="memrow-text">Swap {% if c.swap_total %}{{ c.swap_used | humanize }} <span
|
||
│ class="muted">/ {{ c.swap_total | humanize }} ({{ (c.swap_pct | default(0, true)) | round(0) |
|
||
│ int }}%)</span>{% else %}<span class="muted">none</span>{% endif %}</div>
|
||
│ 24: {{ bar(c.swap_pct if c.swap_total else none) }}
|
||
│ 25: </div>
|
||
│ 26: <div class="memrow">
|
||
│ 27: <div class="memrow-text">VRAM {% if c.vram_total %}{{ c.vram_used | humanize }} <span
|
||
│ class="muted">/ {{ c.vram_total | humanize }} ({{ (c.vram_pct | default(0, true)) | round(0) |
|
||
│ int }}%)</span>{% else %}<span class="muted">—</span>{% endif %}</div>
|
||
│ 28: {{ bar(c.vram_pct if c.vram_total else none) }}
|
||
│ 29: </div>
|
||
│ 30: </div>
|
||
│ 31: {% if c.battery is not none %}
|
||
│ 32: <div class="card">
|
||
│ 33: <div class="card-label">Battery</div>
|
||
│ 34: <div class="card-value">{{ c.battery }}%</div>
|
||
│ 35: <div class="bar"><div class="bar-fill{% if c.battery < 20 %} low{% elif c.battery < 45
|
||
│ %} warm{% endif %}" style="width: {{ c.battery }}%"></div></div>
|
||
│ 36: {% if c.ac_online is not none %}
|
||
│ 37: <div class="card-sub">
|
||
│ 38: {% if c.ac_online %}on AC{% else %}on battery{% endif %}
|
||
│ 39: {% set st = (c.battery_status or "") | lower %}
|
||
│ 40: {% if st == "charging" %} · charging{% elif c.ac_online and st in ("full",
|
||
│ "not charging") %} · full{% endif %}
|
||
│ 41: </div>
|
||
│ 42: {% endif %}
|
||
│ 43: </div>
|
||
│ 44: {% endif %}
|
||
│ 45: <div class="card">
|
||
│ 46: <div class="card-label">Network</div>
|
||
│ 47: {% if c.net_wifi and c.net_wifi.ssid %}
|
||
│ 48: <div class="card-value">{{ c.net_wifi.ssid }}</div>
|
||
│ 49: <div class="card-sub">Wi-Fi via {{ c.net_wifi.iface }}</div>
|
||
│ 50: {% elif c.net_wifi %}
|
||
│ 51: <div class="card-value muted">Wi-Fi</div>
|
||
│ 52: <div class="card-sub">{{ c.net_wifi.iface }} · no connection</div>
|
||
│ 53: {% elif c.net_ifaces %}
|
||
│ 54: <div class="card-value muted">wired</div>
|
||
│ 55: {% else %}
|
||
│ 56: <div class="card-value muted">—</div>
|
||
│ 57: {% endif %}
|
||
│ 58: <div class="card-sub">
|
||
│ 59: {% for i in c.net_ifaces %}
|
||
│ 60: <div class="mono small">{{ i.name }}: {% if i.ipv4 %}{{ i.ipv4 | join(", ") }}{%
|
||
│ else %}<span class="muted">no IPv4</span>{% endif %}</div>
|
||
│ 61: {% else %}
|
||
│ 62: <span class="muted">no active interfaces</span>
|
||
│ 63: {% endfor %}
|
||
│ 64: </div>
|
||
│ 65: </div>
|
||
│ 66: <div class="card">
|
||
│ 67: <div class="card-label">Uptime</div>
|
||
│ 68: <div class="card-value">{{ c.uptime }}</div>
|
||
│ 69: <div class="card-sub">{{ c.hostname }}</div>
|
||
│ 70: </div>
|
||
│ 71: </div>
|
||
│ ```
|
||
│ Note the two-stage trigger pattern: the shell (index.html:25) uses `hx-trigger="load, every 2s"`
|
||
│ for the first fetch; every server-rendered fragment (line 1) uses only `hx-trigger="every 2s"`,
|
||
│ and because `hx-swap="outerHTML"` replaces the element with the identical re-armed div, the
|
||
│ self-poll continues.
|
||
│
|
||
│ ### `templates/disks.html`
|
||
│ ```html
|
||
│ 1: <div id="disks" class="panel" hx-get="/api/disks" hx-trigger="every 2s" hx-swap="outerHTML">
|
||
│ 2: <h2>Filesystems</h2>
|
||
│ 3: <div class="part-list">
|
||
│ 4: {% for p in partitions %}
|
||
│ 5: <div class="part">
|
||
│ 6: <div class="part-head">
|
||
│ 7: <span class="mono">{{ p.device }}</span>
|
||
│ 8: <span class="muted">{{ p.fstype }}</span>
|
||
│ 9: <span class="muted part-mounts" title="{{ p.mounts | join("\n") }}">{{ p.mounts_disp
|
||
│ }}</span>
|
||
│ 10: <span class="part-usage">{{ p.used | humanize }} / {{ p.total | humanize }} ({{
|
||
│ p.pct | round(0) }}%)</span>
|
||
│ 11: </div>
|
||
│ 12: <div class="bar"><div class="bar-fill{% if p.pct > 90 %} hot{% elif p.pct > 75 %}
|
||
│ warm{% endif %}" style="width: {{ p.pct }}%"></div></div>
|
||
│ 13: </div>
|
||
│ 14: {% else %}
|
||
│ 15: <div class="muted">no partitions found</div>
|
||
│ 16: {% endfor %}
|
||
│ 17: </div>
|
||
│ 18: <h2>Device I/O</h2>
|
||
│ 19: <div class="table-wrap">
|
||
│ 20: <table class="table">
|
||
│ 21: <colgroup>
|
||
│ 22: <col style="width: 110px">
|
||
│ 23: <col style="width: 100px">
|
||
│ 24: <col style="width: 100px">
|
||
│ 25: <col style="width: 100px">
|
||
│ 26: <col style="width: 100px">
|
||
│ 27: <col style="width: 110px">
|
||
│ 28: <col style="width: 110px">
|
||
│ 29: </colgroup>
|
||
│ 30: <thead>
|
||
│ 31: <tr><th>Device</th><th class="num">Read</th><th class="num">Write</th><th
|
||
│ class="num">Read rate</th><th class="num">Write rate</th><th class="num">Reads</th><th
|
||
│ class="num">Writes</th></tr>
|
||
│ 32: </thead>
|
||
│ 33: <tbody>
|
||
│ 34: {% for d in per_disk %}
|
||
│ 35: <tr>
|
||
│ 36: <td class="mono cell-clip" title="{{ d.device }}">{{ d.device }}</td>
|
||
│ 37: <td class="num">{{ d.read_bytes | humanize }}</td>
|
||
│ 38: <td class="num">{{ d.write_bytes | humanize }}</td>
|
||
│ 39: <td class="num">{{ d.read_rate | rate }}</td>
|
||
│ 40: <td class="num">{{ d.write_rate | rate }}</td>
|
||
│ 41: <td class="num">{{ d.reads }}</td>
|
||
│ 42: <td class="num">{{ d.writes }}</td>
|
||
│ 43: </tr>
|
||
│ 44: {% else %}
|
||
│ 45: <tr><td colspan="7" class="muted">no block devices</td></tr>
|
||
│ 46: {% endfor %}
|
||
│ 47: </tbody>
|
||
│ 48: </table>
|
||
│ 49: </div>
|
||
│ 50: </div>
|
||
│ ```
|
||
│
|
||
│ ### `templates/processes.html` (shows `hx-include` of the filter form + sortlinks)
|
||
│ ```html
|
||
│ 1: <div id="processes-body" class="panel" hx-get="/api/processes" hx-trigger="every 3s"
|
||
│ hx-swap="outerHTML" hx-include="#proc-controls">
|
||
│ 2: <div class="muted small">showing {{ procs | length }} of {{ total }} processes</div>
|
||
│ 3: <div class="table-wrap">
|
||
│ 4: <table class="table">
|
||
│ 5: <colgroup>
|
||
│ 6: <col style="width: 64px">
|
||
│ 7: <col>
|
||
│ 8: <col style="width: 100px">
|
||
│ 9: <col style="width: 72px">
|
||
│ 10: <col style="width: 72px">
|
||
│ 11: <col style="width: 92px">
|
||
│ 12: <col style="width: 92px">
|
||
│ 13: <col style="width: 92px">
|
||
│ 14: <col style="width: 92px">
|
||
│ 15: </colgroup>
|
||
│ 16: <thead>
|
||
│ 17: <tr>
|
||
│ 18: {% for col, label in [("pid", "PID"), ("name", "Name"), ("user", "User"), ("cpu",
|
||
│ "CPU%"), ("mem_pct", "MEM%"), ("rss", "RSS"), ("gpu", "GPU"), ("io_read", "IO read"),
|
||
│ ("io_write", "IO write")] %}
|
||
│ 19: <th class="{% if col in ('cpu','mem_pct','rss','gpu','io_read','io_write') %}num{%
|
||
│ endif %}">
|
||
│ 20: <a href="#" class="sortlink{% if sort == col %} active{% endif %}" data-sort="{{
|
||
│ col }}" data-order="{% if sort == col and order == 'desc' %}asc{% else %}desc{% endif %}">{{
|
||
│ label }}{% if sort == col %} {{ "↓" if order == "desc" else "↑" }}{% endif %}</a>
|
||
│ 21: </th>
|
||
│ 22: {% endfor %}
|
||
│ 23: </tr>
|
||
│ 24: </thead>
|
||
│ 25: <tbody>
|
||
│ 26: {% for p in procs %}
|
||
│ 27: <tr>
|
||
│ 28: <td class="mono">{{ p.pid }}</td>
|
||
│ 29: <td class="pname" title="{{ p.name }}">{{ p.name }}</td>
|
||
│ 30: <td class="muted">{{ p.user }}</td>
|
||
│ 31: <td class="num">{{ p.cpu | round(1) }}</td>
|
||
│ 32: <td class="num">{{ p.mem_pct | round(1) }}</td>
|
||
│ 33: <td class="num">{{ p.rss | humanize }}</td>
|
||
│ 34: <td class="num">{% if p.gpu is not none %}{{ p.gpu }} MB{% else %}<span
|
||
│ class="muted">—</span>{% endif %}</td>
|
||
│ 35: <td class="num">{{ p.io_read | rate }}</td>
|
||
│ 36: <td class="num">{{ p.io_write | rate }}</td>
|
||
│ 37: </tr>
|
||
│ 38: {% else %}
|
||
│ 39: <tr><td colspan="9" class="muted">no processes match</td></tr>
|
||
│ 40: {% endfor %}
|
||
│ 41: </tbody>
|
||
│ 42: </table>
|
||
│ 43: </div>
|
||
│ 44: </div>
|
||
│ ```
|
||
│
|
||
│ ### Other tabs (key lines)
|
||
│
|
||
│ `templates/services.html` (poll + lazy accordion rows + action buttons):
|
||
│ ```html
|
||
│ 1: <div id="services" class="panel" hx-get="/api/services" hx-trigger="every 15s"
|
||
│ hx-swap="outerHTML" hx-include="#svc-filters">
|
||
│ ...
|
||
│ 30: <tr class="svc-row" data-unit="{{ u.name }}">
|
||
│ ...
|
||
│ 32: <td class="mono svc-name" role="button" tabindex="0" title="click for
|
||
│ details"><span class="svc-caret">▸</span>{{ u.name }}</td>
|
||
│ ...
|
||
│ 38: <button class="btn" hx-post="/api/services/{{ u.name }}/action"
|
||
│ hx-vals='{"action":"start"}' hx-include="#svc-filters" hx-target="#services"
|
||
│ hx-swap="outerHTML">start</button>
|
||
│ ...
|
||
│ 40: <button class="btn" hx-post="/api/services/{{ u.name }}/action"
|
||
│ hx-vals='{"action":"stop"}' hx-include="#svc-filters" hx-target="#services" hx-swap="outerHTML"
|
||
│ hx-confirm="Stop {{ u.name }}?">stop</button>
|
||
│ ...
|
||
│ 46: <tr class="svc-detail-row" hidden data-unit="{{ u.name }}">
|
||
│ 47: <td colspan="6"><div class="svc-detail"></div></td>
|
||
│ 48: </tr>
|
||
│ ```
|
||
│
|
||
│ `templates/journal.html` (fragment is a batch of appended lines; the polling element lives in
|
||
│ index.html:73):
|
||
│ ```html
|
||
│ 1: {% if error %}
|
||
│ 2: <div class="jline j-error-once"><span class="muted">journalctl: {{ error }}</span></div>
|
||
│ 3: {% endif %}
|
||
│ 4: {% for e in entries %}
|
||
│ 5: <div class="jline{% if e.prio <= 3 %} j-err{% elif e.prio == 4 %} j-warn{% endif %}"
|
||
│ data-cursor="{{ e.cursor }}">
|
||
│ 6: <span class="j-time">{{ e.stamp }}</span>
|
||
│ 7: <span class="j-ident">{{ e.ident }}</span>
|
||
│ 8: <span class="j-msg">{{ e.msg }}</span>
|
||
│ 9: </div>
|
||
│ 10: {% endfor %}
|
||
│ ```
|
||
│
|
||
│ `templates/plugins.html` (shell for per-plugin skeletons; the tab itself only fetches once):
|
||
│ ```html
|
||
│ 1: <div id="plugins" class="panel">
|
||
│ 2: {% for item in items %}
|
||
│ 3: <div class="plugin-card">
|
||
│ 4: <h3>{{ item.p.title }}</h3>
|
||
│ 5: <p class="muted small">{{ item.p.description }}</p>
|
||
│ 6: <div class="plugin-body">{{ item.body | safe }}</div>
|
||
│ 7: </div>
|
||
│ 8: {% else %}
|
||
│ 9: <div class="muted">no plugins registered</div>
|
||
│ 10: {% endfor %}
|
||
│ 11: </div>
|
||
│ ```
|
||
│ Each plugin's skeleton contains its own inner state div that polls (e.g.
|
||
│ `templates/plugins/sleep_skeleton.html` lines 1–5):
|
||
│ ```html
|
||
│ 1: <div class="sleep">
|
||
│ 2: <div id="sleep-state" hx-get="/api/plugins/sleep/state" hx-trigger="every 5s"
|
||
│ hx-swap="innerHTML">
|
||
│ 3: {% include "plugins/sleep_state.html" %}
|
||
│ 4: </div>
|
||
│ 5: </div>
|
||
│ ```
|
||
│ Identical 5s pattern in `lact_skeleton.html:2`, `llamacpp_skeleton.html:2`,
|
||
│ `sessions_skeleton.html:2`.
|
||
│
|
||
│ ---
|
||
│
|
||
│ ## 5. All Existing JavaScript (`static/js/app.js`, full, 232 lines)
|
||
│
|
||
│ ```js
|
||
│ 1: (function () {
|
||
│ 2: "use strict";
|
||
│ 3:
|
||
│ 4: // ---------- tabs ----------
|
||
│ 5: const tabBtns = document.querySelectorAll(".tab-btn");
|
||
│ 6: const sections = document.querySelectorAll(".tab");
|
||
│ 7:
|
||
│ 8: function showTab(name) {
|
||
│ 9: tabBtns.forEach((b) => b.classList.toggle("active", b.dataset.tab === name));
|
||
│ 10: sections.forEach((s) => s.classList.toggle("hidden", s.id !== "tab-" + name));
|
||
│ 11: try {
|
||
│ 12: localStorage.setItem("dash.tab", name);
|
||
│ 13: } catch (e) {}
|
||
│ 14: }
|
||
│ 15: tabBtns.forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
||
│ 16: try {
|
||
│ 17: const saved = localStorage.getItem("dash.tab");
|
||
│ 18: if (saved && document.getElementById("tab-" + saved)) showTab(saved);
|
||
│ 19: } catch (e) {}
|
||
│ 20:
|
||
│ 21: // ---------- helpers ----------
|
||
│ 22: function fmtBytes(n, digits) {
|
||
│ 23: if (n == null || isNaN(n)) return "—";
|
||
│ 24: if (digits == null) digits = 1;
|
||
│ 25: const u = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||
│ 26: let i = 0;
|
||
│ 27: while (Math.abs(n) >= 1024 && i < u.length - 1) {
|
||
│ 28: n /= 1024;
|
||
│ 29: i++;
|
||
│ 30: }
|
||
│ 31: return n.toFixed(digits) + " " + u[i];
|
||
│ 32: }
|
||
│ 33:
|
||
│ 34: function fmtTime(ts) {
|
||
│ 35: const d = new Date(ts * 1000);
|
||
│ 36: return d.toLocaleTimeString([], { hour12: false });
|
||
│ 37: }
|
||
│ 38:
|
||
│ 39: // ---------- charts ----------
|
||
│ 40: const charts = {};
|
||
│ 41:
|
||
│ 42: function baseOpts(extra) {
|
||
│ 43: const o = {
|
||
│ 44: animation: false,
|
||
│ 45: responsive: true,
|
||
│ 46: maintainAspectRatio: false,
|
||
│ 47: interaction: { mode: "index", intersect: false },
|
||
│ 48: plugins: { legend: { display: false } },
|
||
│ 49: scales: {
|
||
│ 50: x: { ticks: { maxTicksLimit: 7, maxRotation: 0, color: "#7d8a9c" }, grid: {
|
||
│ display: false } },
|
||
│ 51: y: { beginAtZero: true, ticks: { color: "#7d8a9c" }, grid: { color:
|
||
│ "rgba(42,51,66,.5)" } },
|
||
│ 52: },
|
||
│ 53: };
|
||
│ 54: if (extra) Object.assign(o.scales.y, extra);
|
||
│ 55: return o;
|
||
│ 56: }
|
||
│ 57:
|
||
│ 58: function envelopeDs(name, color, s) {
|
||
│ 59: s = s || {};
|
||
│ 60: return [
|
||
│ 61: { label: name, data: s.max || [], borderColor: color, backgroundColor: color + "2e",
|
||
│ borderWidth: 1, pointRadius: 0, tension: 0, fill: false, order: 0 },
|
||
│ 62: { label: name + " min", band: true, data: s.min || [], borderColor: color,
|
||
│ backgroundColor: color, borderWidth: 1, pointRadius: 0, tension: 0, fill: false, order: 1 },
|
||
│ 63: ];
|
||
│ 64: }
|
||
│ 65:
|
||
│ 66: const LEGEND = { display: true, labels: { boxWidth: 10, color: "#7d8a9c", filter: (item,
|
||
│ data) => !data.datasets[item.datasetIndex]?.band } };
|
||
│ 67:
|
||
│ 68: function initCharts() {
|
||
│ 69: if (typeof Chart === "undefined") return;
|
||
│ 70: charts.cpu = new Chart(document.getElementById("chart-cpu"), {
|
||
│ 71: type: "line",
|
||
│ 72: data: {
|
||
│ 73: labels: [],
|
||
│ 74: datasets: [].concat(envelopeDs("CPU", "#4fc3f7"), envelopeDs("GPU", "#ba68c8")),
|
||
│ 75: },
|
||
│ 76: options: baseOpts({ max: 100 }),
|
||
│ 77: });
|
||
│ 78: charts.cpu.options.plugins.legend = LEGEND;
|
||
│ 79: charts.mem = new Chart(document.getElementById("chart-mem"), {
|
||
│ 80: type: "line",
|
||
│ 81: data: {
|
||
│ 82: labels: [],
|
||
│ 83: datasets: [].concat(envelopeDs("Memory", "#81c784"), envelopeDs("VRAM",
|
||
│ "#ffb74d")),
|
||
│ 84: },
|
||
│ 85: options: baseOpts({ max: 100 }),
|
||
│ 86: });
|
||
│ 87: charts.mem.options.plugins.legend = LEGEND;
|
||
│ 88: charts.io = new Chart(document.getElementById("chart-io"), {
|
||
│ 89: type: "line",
|
||
│ 90: data: {
|
||
│ 91: labels: [],
|
||
│ 92: datasets: [].concat(envelopeDs("read", "#e57373"), envelopeDs("write", "#fff176")),
|
||
│ 93: },
|
||
│ 94: options: baseOpts({
|
||
│ 95: ticks: { color: "#7d8a9c", callback: (v) => fmtBytes(v, 0) },
|
||
│ 96: beginAtZero: true,
|
||
│ 97: }),
|
||
│ 98: });
|
||
│ 99: charts.io.options.plugins.legend = LEGEND;
|
||
│ 100: }
|
||
│ 101:
|
||
│ 102: function updateCharts(hist) {
|
||
│ 103: if (!hist || !hist.ts || !hist.ts.length) return;
|
||
│ 104: const labels = hist.ts.map(fmtTime);
|
||
│ 105: const series = hist.series || {};
|
||
│ 106: const build = (chart, pairs) => {
|
||
│ 107: chart.data.labels = labels;
|
||
│ 108: chart.data.datasets = [];
|
||
│ 109: pairs.forEach(([name, color, key], i) => {
|
||
│ 110: const ds = envelopeDs(name, color, series[key]);
|
||
│ 111: ds[0].fill = i * 2 + 1;
|
||
│ 112: chart.data.datasets.push(...ds);
|
||
│ 113: });
|
||
│ 114: chart.update("none");
|
||
│ 115: };
|
||
│ 116: build(charts.cpu, [["CPU", "#4fc3f7", "cpu"], ["GPU", "#ba68c8", "gpu"]]);
|
||
│ 117: build(charts.mem, [["Memory", "#81c784", "mem_pct"], ["VRAM", "#ffb74d", "vram_pct"]]);
|
||
│ 118: build(charts.io, [["read", "#e57373", "io_read"], ["write", "#fff176", "io_write"]]);
|
||
│ 119: }
|
||
│ 120:
|
||
│ 121: function pollHistory() {
|
||
│ 122: fetch("/api/history")
|
||
│ 123: .then((r) => r.json())
|
||
│ 124: .then(updateCharts)
|
||
│ 125: .catch(() => {});
|
||
│ 126: }
|
||
│ 127:
|
||
│ 128: initCharts();
|
||
│ 129: pollHistory();
|
||
│ 130: setInterval(pollHistory, 2000);
|
||
│ 131:
|
||
│ 132: // ---------- journal ----------
|
||
│ 133: const journalLog = document.getElementById("journal-log");
|
||
│ 134: const journalCursor = document.getElementById("journal-cursor");
|
||
│ 135: const journalStatus = document.getElementById("journal-status");
|
||
│ 136: const MAX_LINES = 500;
|
||
│ 137:
|
||
│ 138: document.body.addEventListener("htmx:afterSwap", (e) => {
|
||
│ 139: if (e.target.id !== "journal-log") return;
|
||
│ 140: // surface one-shot errors in the status bar instead of the log
|
||
│ 141: document.querySelectorAll("#journal-log .j-error-once").forEach((el) => {
|
||
│ 142: journalStatus.textContent = el.textContent.trim();
|
||
│ 143: el.remove();
|
||
│ 144: });
|
||
│ 145: // track cursor from the most recent line
|
||
│ 146: const lines = journalLog.querySelectorAll(".jline");
|
||
│ 147: const last = lines[lines.length - 1];
|
||
│ 148: if (last && last.dataset.cursor) journalCursor.value = last.dataset.cursor;
|
||
│ 149: // trim
|
||
│ 150: const extra = lines.length - MAX_LINES;
|
||
│ 151: if (extra > 0) {
|
||
│ 152: for (let i = 0; i < extra; i++) lines[i].remove();
|
||
│ 153: }
|
||
│ 154: // auto-scroll if user is at the bottom (within 40px)
|
||
│ 155: const wrap = document.getElementById("journal-logwrap");
|
||
│ 156: const atBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 40;
|
||
│ 157: if (atBottom) wrap.scrollTop = wrap.scrollHeight;
|
||
│ 158: });
|
||
│ 159:
|
||
│ 160: // reset cursor when the filter form is submitted
|
||
│ 161: const journalFilters = document.getElementById("journal-filters");
|
||
│ 162: journalFilters.addEventListener("submit", () => {
|
||
│ 163: journalCursor.value = "";
|
||
│ 164: journalStatus.textContent = "";
|
||
│ 165: journalLog.innerHTML = "";
|
||
│ 166: });
|
||
│ 167:
|
||
│ 168: // ---------- table sorting (processes, services) ----------
|
||
│ 169: document.body.addEventListener("click", (e) => {
|
||
│ 170: const link = e.target.closest(".sortlink");
|
||
│ 171: if (!link) return;
|
||
│ 172: e.preventDefault();
|
||
│ 173: const formId = link.dataset.form || "proc-controls";
|
||
│ 174: const form = document.getElementById(formId);
|
||
│ 175: if (!form) return;
|
||
│ 176: const sortSel = form.querySelector("select[name=sort]");
|
||
│ 177: const orderSel = form.querySelector("select[name=order]");
|
||
│ 178: if (sortSel) sortSel.value = link.dataset.sort;
|
||
│ 179: if (orderSel) orderSel.value = link.dataset.order;
|
||
│ 180: htmx.trigger(form, "submit");
|
||
│ 181: });
|
||
│ 182:
|
||
│ 183: // ---------- services accordion ----------
|
||
│ 184: const openSvc = new Set();
|
||
│ 185:
|
||
│ 186: function loadSvcDetail(unit, target) {
|
||
│ 187: htmx.ajax("GET", "/api/services/" + encodeURIComponent(unit) + "/detail", { target });
|
||
│ 188: }
|
||
│ 189:
|
||
│ 190: function toggleSvcRow(row) {
|
||
│ 191: const detailRow = row.nextElementSibling;
|
||
│ 192: if (!detailRow || !detailRow.classList.contains("svc-detail-row")) return;
|
||
│ 193: const unit = row.dataset.unit;
|
||
│ 194: if (openSvc.has(unit)) {
|
||
│ 195: openSvc.delete(unit);
|
||
│ 196: row.classList.remove("open");
|
||
│ 197: detailRow.hidden = true;
|
||
│ 198: } else {
|
||
│ 199: openSvc.add(unit);
|
||
│ 200: row.classList.add("open");
|
||
│ 201: detailRow.hidden = false;
|
||
│ 202: loadSvcDetail(unit, detailRow.querySelector(".svc-detail"));
|
||
│ 203: }
|
||
│ 204: }
|
||
│ 205:
|
||
│ 206: document.body.addEventListener("click", (e) => {
|
||
│ 207: const row = e.target.closest("#services tr.svc-row");
|
||
│ 208: if (!row || e.target.closest("button")) return;
|
||
│ 209: toggleSvcRow(row);
|
||
│ 210: });
|
||
│ 211:
|
||
│ 212: document.body.addEventListener("keydown", (e) => {
|
||
│ 213: const el = e.target.closest(".svc-name");
|
||
│ 214: if (el && (e.key === "Enter" || e.key === " ")) {
|
||
│ 215: e.preventDefault();
|
||
│ 216: toggleSvcRow(el.closest("tr.svc-row"));
|
||
│ 217: }
|
||
│ 218: });
|
||
│ 219:
|
||
│ 220: document.body.addEventListener("htmx:afterSwap", (e) => {
|
||
│ 221: if (e.target.id !== "services" || !openSvc.size) return;
|
||
│ 222: for (const row of e.target.querySelectorAll("tr.svc-row")) {
|
||
│ 223: const unit = row.dataset.unit;
|
||
│ 224: if (!openSvc.has(unit)) continue;
|
||
│ 225: const detailRow = row.nextElementSibling;
|
||
│ 226: if (!detailRow || !detailRow.classList.contains("svc-detail-row")) continue;
|
||
│ 227: row.classList.add("open");
|
||
│ 228: detailRow.hidden = false;
|
||
│ 229: loadSvcDetail(unit, detailRow.querySelector(".svc-detail"));
|
||
│ 230: }
|
||
│ 231: });
|
||
│ 232: })();
|
||
│ ```
|
||
│
|
||
│ JS responsibilities in `app.js`, by section:
|
||
│ - **Tabs** (lines 4–19): click handlers, `.active`/`.hidden` toggling,
|
||
│ `localStorage["dash.tab"]` persistence + restore.
|
||
│ - **Charts** (lines 39–130): Chart.js init for the 3 canvases in the Overview section; **plain
|
||
│ `fetch` + `setInterval(pollHistory, 2000)`** polling `/api/history` (JSON, not htmx); envelope
|
||
│ (max/min band) datasets; legend filter hides the "min" band datasets.
|
||
│ - **Journal** (lines 132–166): `htmx:afterSwap` on `#journal-log` — moves `.j-error-once`
|
||
│ messages to `#journal-status`, tracks `data-cursor` from the last `.jline` into the hidden
|
||
│ `#journal-cursor` input, trims DOM to 500 lines, auto-scrolls if within 40px of bottom; filter
|
||
│ form submit clears cursor/status/log.
|
||
│ - **Table sorting** (lines 168–181): delegated click on `.sortlink` → writes
|
||
│ `data-sort`/`data-order` into the form's `select[name=sort]`/`select[name=order]` (form id from
|
||
│ `data-form`, default `proc-controls`) → `htmx.trigger(form, "submit")`.
|
||
│ - **Services accordion** (lines 183–231): delegated click/keydown on `#services tr.svc-row` →
|
||
│ `toggleSvcRow`, which shows/hides the sibling `.svc-detail-row` and lazy-fetches
|
||
│ `/api/services/<unit>/detail` via `htmx.ajax`; `htmx:afterSwap` on `#services` re-expands open
|
||
│ rows after the 15s poll re-renders the table (state kept in `openSvc` Set).
|
||
│
|
||
│ Everything is wrapped in an IIFE with `"use strict"`. It relies on the global `htmx` (loaded in
|
||
│ `<head>`) and `Chart` (vendor, loaded in `<head>`); `app.js` is loaded at the end of `<body>`
|
||
│ (index.html:98).
|
||
│
|
||
│ ---
|
||
│
|
||
│ ## 6. Polling Intervals per Tab/Section (summary table)
|
||
│
|
||
│ | Tab / element | Polling element (id) | Endpoint | Trigger | Swap | Interval |
|
||
│ |---|---|---|---|---|---|
|
||
│ | Overview cards | `#overview` (index.html:25 / overview.html:1) | `GET /api/overview` | `load,
|
||
│ every 2s` (shell) / `every 2s` (fragment) | `outerHTML` | **2s** |
|
||
│ | Overview charts (×3 canvases) | JS, not htmx (app.js:121–130) | `GET /api/history` (JSON) |
|
||
│ `setInterval` + initial call | n/a (Chart.js) | **2s** |
|
||
│ | Disks | `#disks` (index.html:34 / disks.html:1) | `GET /api/disks` | `load, every 2s` / `every
|
||
│ 2s` | `outerHTML` | **2s** |
|
||
│ | Processes | `#processes-body` (index.html:55 / processes.html:1) | `GET /api/processes` |
|
||
│ `load, every 3s` / `every 3s` | `outerHTML` (with `hx-include="#proc-controls"`) | **3s** |
|
||
│ | Journal | `#journal-log` (index.html:73) | `GET /api/journal` | `load, every 5s` | `beforeend`
|
||
│ (appends; `hx-include="#journal-filters, #journal-cursor"`) | **5s** |
|
||
│ | Services | `#services` (index.html:91 / services.html:1) | `GET /api/services` | `load, every
|
||
│ 15s` / `every 15s` | `outerHTML` (with `hx-include="#svc-filters"`) | **15s** |
|
||
│ | Service detail (lazy) | `.svc-detail` rows (services.html:47) | `GET
|
||
│ /api/services/{unit}/detail` | manual (`htmx.ajax` on expand) | `innerHTML` | on-demand |
|
||
│ | Plugins shell | `#plugins` (index.html:95) | `GET /api/plugins` | `load` only | `outerHTML` |
|
||
│ **once** |
|
||
│ | Plugin: llama.cpp | `#llama-state` (llamacpp_skeleton.html:2) | `GET
|
||
│ /api/plugins/llamacpp/state` | `every 5s` | `innerHTML` | **5s** |
|
||
│ | Plugin: sleep | `#sleep-state` (sleep_skeleton.html:2) | `GET /api/plugins/sleep/state` |
|
||
│ `every 5s` | `innerHTML` | **5s** |
|
||
│ | Plugin: LACT | `#lact-state` (lact_skeleton.html:2) | `GET /api/plugins/lact/state` | `every
|
||
│ 5s` | `innerHTML` | **5s** |
|
||
│ | Plugin: sessions | `#sessions-state` (sessions_skeleton.html:2) | `GET
|
||
│ /api/plugins/sessions/state` | `every 5s` | `innerHTML` | **5s** |
|
||
│
|
||
│ Non-polling htmx actions: service start/stop/restart/enable/disable buttons `POST
|
||
│ /api/services/{unit}/action` (services.html:38–43, `hx-target="#services"`, `hx-confirm` on
|
||
│ stop/restart); plugin POST actions (`/api/plugins/llamacpp/load|unload|rescan`,
|
||
│ `/api/plugins/sleep/toggle`, `/api/plugins/lact/set|reload`, `/api/plugins/sessions/terminate`)
|
||
│ all target `closest .plugin-body` with `innerHTML`.
|
||
│
|
||
│ ---
|
||
│
|
||
│ ## 7. Routers / Endpoints (tab-related, from `app/routers/` + `app/plugins/`)
|
||
│
|
||
│ Registered in `app/main.py:79–82` (six core routers + plugin-contributed routers):
|
||
│
|
||
│ | Method & path | Source file:line | Renders/does |
|
||
│ |---|---|---|
|
||
│ | `GET /` | `app/main.py:55–64, 83` | the `index.html` shell, rendered once |
|
||
│ | `GET /api/overview` | `app/routers/overview.py:19` | `overview.html` fragment |
|
||
│ | `GET /api/history` | `app/routers/overview.py:68` | JSON: window-averaged ring buffer (`{ts[],
|
||
│ series{key:{avg,min,max}}}`) |
|
||
│ | `GET /api/disks` | `app/routers/disks.py:16` | `disks.html` fragment |
|
||
│ | `GET /api/processes` | `app/routers/processes.py:14` | `processes.html` fragment (query: `q`,
|
||
│ `sort`, `order`) |
|
||
│ | `GET /api/journal` | `app/routers/journal.py:12` | `journal.html` batch (query: `level`,
|
||
│ `unit`, `search`, `hide_sudo`, `cursor`) |
|
||
│ | `GET /api/services` | `app/routers/services.py:100` | `services.html` fragment (query: `q`,
|
||
│ `sort`, `order`) |
|
||
│ | `GET /api/services/{unit}/detail` | `app/routers/services.py:115` | `service_detail.html`
|
||
│ fragment |
|
||
│ | `POST /api/services/{unit}/action` | `app/routers/services.py:144` | form field `action`
|
||
│ (start/stop/restart/enable/disable) |
|
||
│ | `GET /api/plugins` | `app/routers/plugins.py:10` | `plugins.html` with each plugin's
|
||
│ `skeleton()` (errors inlined per plugin) |
|
||
│ | `GET /api/plugins/llamacpp/state` | `app/plugins/llamacpp.py:192` (+ `POST /load`:198,
|
||
│ `/unload`:217, `/rescan`:236) | `llamacpp_state.html` |
|
||
│ | `GET /api/plugins/sleep/state` | `app/plugins/sleep.py:218` (+ `POST /toggle`:224) |
|
||
│ `sleep_state.html` |
|
||
│ | `GET /api/plugins/lact/state` | `app/plugins/lact.py:185` (+ `POST /set`:191, `/reload`:225) |
|
||
│ `lact_state.html` |
|
||
│ | `GET /api/plugins/sessions/state` | `app/plugins/sessions.py:125` (+ `POST /terminate`:131) |
|
||
│ `sessions_state.html` |
|
||
│
|
||
│ There are **no tab-related endpoints** (no `/api/tabs`, no server-side active-tab state). Tab
|
||
│ state is 100% client-side: DOM classes + `localStorage`.
|
||
│
|
||
│ ---
|
||
│
|
||
│ ## Key observations for anyone modifying the tab system
|
||
│
|
||
│ 1. **All tabs poll simultaneously from page load.** htmx does not pause `hx-trigger="every Ns"`
|
||
│ for `display:none` elements, so even while hidden, Disks (2s), Processes (3s), Journal (5s),
|
||
│ Services (15s), and the four plugin state divs (5s each) keep hitting the backend. Only the
|
||
│ Plugins tab's inner state divs start after the one-shot `/api/plugins` load resolves (since the
|
||
│ skeleton HTML — containing the `hx-trigger="every 5s"` divs — doesn't exist in the DOM until
|
||
│ then).
|
||
│ 2. **Self-poll pattern:** every fragment's root element re-declares its own
|
||
│ `hx-get`/`hx-trigger`/`hx-swap` with the same id, and uses `hx-swap="outerHTML"`, so each
|
||
│ response re-arms its own timer. The shell uses `hx-trigger="load, every 2s"` for the initial
|
||
│ fetch; fragments drop the `load` trigger.
|
||
│ 3. **Tab switching is pure vanilla JS** (no htmx on the buttons): class toggling (`.active` on
|
||
│ `button[data-tab]`, `.hidden` on `section#tab-<name>`) + `localStorage["dash.tab"]`
|
||
│ persistence/restore. No URL routing, no hash, no pushState.
|
||
│ 4. **The one non-htmx poll** is the Overview charts: `fetch("/api/history")` + `setInterval(...,
|
||
│ 2000)` in `app.js:121–130`, updating three Chart.js instances with `{avg,min,max}` envelope
|
||
│ series (min drawn as a band, hidden from the legend via the `band` flag +
|
||
│ `LEGEND.labels.filter`).
|
||
│ 5. **Form state survives polls** because polling divs use `hx-include` on the filter forms
|
||
│ (`#proc-controls`, `#svc-filters`, `#journal-filters`), and the forms live outside the swapped
|
||
│ panels — the swapped fragments never re-render the forms.
|
||
│ 6. **Journal is the only `beforeend`-swap tab** (append, not replace): cursor tracked via
|
||
│ `data-cursor` on `.jline` into a hidden input, DOM trimmed to 500 lines, and auto-scroll only
|
||
│ when the user is within 40px of the bottom (app.js:138–158).
|
||
│
|
||
│ tokens 1064 in 14649 out 27407 cached 0 written $0.0000 stop
|
||
|