Skip to content

Commit e15ba31

Browse files
bogdanpricopclaude
andcommitted
feat(diagnostics): Diagnostic Sessions — containers and VMs on one time axis
Implements the retrospective slice of plans/deep-spec-unified-live-diagnostics.md (I1, I3, I4, I7), which is what that spec recommended shipping first: it is the cheap half, and it produces the evidence live capture should be gated on — whether operators create cross-provider sessions at all, and whether real clock skew is small enough for correlation to mean anything. A session answers "what was happening across my estate at 14:32?" and changes nothing. Design - Stores only its DEFINITION: which subjects, over which window. Every series is re-read from container_stats*, vm_metric_samples, docker_events, health_events and audit_log, all of which already exist. No sample table, deliberately: that is what live capture needs, and adding it now would be storage for a feature that has not earned it. - One shared axis — bucket N is the same instant for every subject, which is what makes a container and a VM comparable. Nothing is interpolated to align them. The gate the deep-spec imposed, discharged before the schema settled - 25 series x 900 samples reduce to 15 000 points in ~20 ms server-side. - The page draws SVG polylines with no charting library. Three failure modes, each a real monitoring-UI bug, encoded and tested - A gap is a gap: empty buckets yield null, never 0. The implementation itself fell into this — `Number(null)` is 0, so an early version turned missing readings into measured zeros. The tests caught it before it shipped. - Cumulative counters reset: a container restart returns net_rx to zero, so the delta breaks the line instead of drawing a cliff. - Clock skew is reported, never corrected. Correcting would hide the thing most likely to make two series incomparable. Two further bugs found by driving a real browser, which the unit tests could not see - The i18n block was registered at the top level while the page reads `pages.diagnostics.*`, so every label rendered as its raw key. - Sparse data rendered as an empty chart: at 600 buckets each reading lands in its own bucket, and dropping single-point runs made real data invisible — the same "missing looks like nothing happened" failure, one layer up. Isolated readings now render as dots. Also: annotations carry usernames but never client IPs; create, delete and export are audited; the page is registered with a help entry and EN/RO labels. 38 new tests across 2 suites. Full suite: 336 suites, 4170 passing. Browser-verified 7/7 against seeded history with a deliberate gap and a counter reset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a6dc95b commit e15ba31

21 files changed

Lines changed: 1259 additions & 7 deletions

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,41 @@
22

33
All notable changes to Docker Dash are documented here.
44

5+
## [8.96.0] - 2026-08-11 — Diagnostic Sessions (retrospective)
6+
7+
Answers "what was happening across my estate at 14:32?" by putting containers
8+
and VMs on one time axis. Read-only: a session changes nothing and collects
9+
nothing.
10+
11+
- A session stores only its definition — which subjects, over which window. Every
12+
series is re-read from metrics already collected, so creating one costs no
13+
extra sampling and no sample storage. Materialising samples is what live
14+
capture needs; adding it now would be storage for a feature that has not yet
15+
earned it.
16+
- One shared axis: bucket N is the same instant for every subject, which is what
17+
makes a container and a VM comparable. Nothing is interpolated to align them.
18+
- A gap renders as a gap. Empty buckets yield null, never zero — the most common
19+
monitoring-UI bug, and one the implementation itself fell into: `Number(null)`
20+
is 0, so an early version turned missing readings into measured zeros. The
21+
tests caught it.
22+
- Cumulative counters are shown as deltas with resets broken, so a container
23+
restart no longer draws a cliff.
24+
- Clock skew between sources is reported, never corrected. Correcting it would
25+
hide the one thing most likely to make two series incomparable.
26+
- Events, health transitions and audit entries are marked on the axis. Usernames
27+
appear; client IPs never do.
28+
- Sessions are exportable as JSON; create, delete and export are audited.
29+
- Deliberately out of scope, with reasons in the deep-spec: live capture,
30+
observation ranking, log excerpts. This release exists to produce the evidence
31+
live capture should be gated on.
32+
33+
The deep-spec gated the schema behind a rendering proof. Discharged: 25 series ×
34+
900 samples reduce to 15 000 points in ~20 ms, and the page draws SVG polylines
35+
with no charting library — a null bucket splits the line, and an isolated reading
36+
renders as a dot rather than vanishing.
37+
38+
38 new tests across 2 suites. Full suite: 336 suites, 4170 passing.
39+
540
## [8.95.1] - 2026-08-11 — Three deferred defects
641

742
All three were found during earlier work, reported, and deliberately left for a

docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ services:
44
context: .
55
dockerfile: Dockerfile
66
args:
7-
APP_VERSION: "${APP_VERSION:-8.95.1}"
8-
image: docker-dash:${APP_VERSION:-8.95.1}
7+
APP_VERSION: "${APP_VERSION:-8.96.0}"
8+
image: docker-dash:${APP_VERSION:-8.96.0}
99
container_name: docker-dash
1010
restart: unless-stopped
1111
env_file:
@@ -56,7 +56,7 @@ services:
5656
dd-egress-filter:
5757
build:
5858
context: ./docker/egress-filter
59-
image: docker-dash-egress-filter:${APP_VERSION:-8.95.1}
59+
image: docker-dash-egress-filter:${APP_VERSION:-8.96.0}
6060
container_name: dd-egress-filter
6161
restart: unless-stopped
6262
# Uses the default bridge so target containers on the default bridge can

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docker-dash",
3-
"version": "8.95.1",
3+
"version": "8.96.0",
44
"description": "Full-featured Docker management dashboard",
55
"main": "src/server.js",
66
"scripts": {

public/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@
135135
<a href="#/timeline" class="nav-item" data-page="timeline">
136136
<i class="fas fa-stream"></i><span>Timeline</span>
137137
</a>
138+
<a href="#/diagnostics" class="nav-item" data-page="diagnostics">
139+
<i class="fas fa-wave-square"></i><span>Diagnostics</span>
140+
</a>
138141
<a href="#/cost-optimizer" class="nav-item" data-page="cost-optimizer">
139142
<i class="fas fa-dollar-sign"></i><span>Cost</span>
140143
</a>
@@ -405,6 +408,7 @@
405408
<script src="/js/pages/ssh-key-deployer.js?v=__VERSION__"></script>
406409
<script src="/js/update-notifier.js?v=__VERSION__"></script>
407410
<script src="/js/pages/dashboard.js?v=__VERSION__"></script>
411+
<script src="/js/pages/diagnostics.js?v=__VERSION__"></script>
408412
<script src="/js/pages/containers.js?v=__VERSION__"></script>
409413
<script src="/js/pages/images.js?v=__VERSION__"></script>
410414
<script src="/js/pages/security.js?v=__VERSION__"></script>

public/js/api.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ const Api = {
151151
getCliPreview(action, params = {}) { return this.post('/cli-preview', { action, params }); },
152152
getCliPreviewActions() { return this.get('/cli-preview/actions'); },
153153

154+
// ─── Diagnostic sessions ─────────────────────────
155+
getDiagnosticSessions() { return this.get('/diagnostics/sessions'); },
156+
createDiagnosticSession(data) { return this.post('/diagnostics/sessions', data); },
157+
getDiagnosticTimeline(id, buckets) { return this.get(`/diagnostics/sessions/${id}/timeline${buckets ? '?buckets=' + buckets : ''}`); },
158+
exportDiagnosticSession(id) { return this.get(`/diagnostics/sessions/${id}/export`); },
159+
deleteDiagnosticSession(id) { return this.delete(`/diagnostics/sessions/${id}`); },
160+
154161
// ─── Containers ──────────────────────────────────
155162
getContainers(all = true) { return this.get(`/containers?all=${all}`); },
156163
getContainer(id) { return this.get(`/containers/${id}/inspect`); },

public/js/app.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const App = {
7171
'self-service': () => SelfServicePage,
7272
'edge-platform': () => EdgePlatformPage,
7373
'workstation-fleet': () => WorkstationFleetPage,
74+
diagnostics: () => DiagnosticsPage,
7475
},
7576

7677
async init() {

public/js/help-content.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,31 @@
2222
*/
2323

2424
const HelpContent = {
25+
'diagnostics': {
26+
en: {
27+
title: 'Diagnostic Sessions', icon: 'fa-wave-square',
28+
intro: 'Answers "what was happening across my estate at 14:32?" by putting containers and VMs on one time axis.',
29+
sections: [
30+
{ icon: 'fa-clock-rotate-left', title: 'Retrospective, not a collector', body: 'A session stores only which subjects over which window. Every series is re-read from metrics already collected, so creating one changes nothing and costs no extra sampling.' },
31+
{ icon: 'fa-chart-line', title: 'One shared axis', body: 'Bucket N means the same instant for every subject, which is what makes a container and a VM comparable at a glance. Nothing is interpolated to line them up.' },
32+
{ icon: 'fa-square-minus', title: 'A gap is a gap', body: 'Missing data renders as a break in the line, never as zero. A cumulative counter that resets when a container restarts breaks the line too, rather than drawing a cliff.' },
33+
{ icon: 'fa-triangle-exclamation', title: 'Clock skew is reported', body: 'If the sources disagree by more than two seconds you are told, and by how much. The offset is never silently corrected, because that would turn a coincidence into an apparent cause.' },
34+
],
35+
tip: 'Correlation is not causation. A session shows you what happened together; it does not claim one caused the other.',
36+
},
37+
ro: {
38+
title: 'Sesiuni de diagnostic', icon: 'fa-wave-square',
39+
intro: 'Raspunde la "ce se intampla in parcul meu la 14:32?" punand containere si VM-uri pe aceeasi axa de timp.',
40+
sections: [
41+
{ icon: 'fa-clock-rotate-left', title: 'Retrospectiv, nu colector', body: 'O sesiune retine doar ce subiecte si ce fereastra. Fiecare serie e recitita din metricile deja colectate, deci crearea ei nu schimba nimic si nu costa esantionare in plus.' },
42+
{ icon: 'fa-chart-line', title: 'O singura axa comuna', body: 'Bucket-ul N inseamna acelasi moment pentru fiecare subiect, ceea ce face comparabile un container si un VM dintr-o privire. Nimic nu e interpolat ca sa se alinieze.' },
43+
{ icon: 'fa-square-minus', title: 'Un gol ramane gol', body: 'Datele lipsa apar ca intrerupere a liniei, niciodata ca zero. Un contor cumulativ care se reseteaza la restartul containerului rupe la fel linia, in loc sa deseneze o prapastie.' },
44+
{ icon: 'fa-triangle-exclamation', title: 'Derapajul de ceas e raportat', body: 'Daca sursele difera cu peste doua secunde esti anuntat, si cu cat. Decalajul nu e corectat tacit, fiindca asta ar transforma o coincidenta intr-o cauza aparenta.' },
45+
],
46+
tip: 'Corelatia nu e cauzalitate. O sesiune arata ce s-a intamplat impreuna; nu pretinde ca una a cauzat-o pe cealalta.',
47+
},
48+
},
49+
2550
'security': {
2651
en: {
2752
title: 'Security', icon: 'fa-shield-halved',

public/js/i18n/en.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ i18n.register('en', 'EN', 'English', {
4141
insights: 'Insights', 'git-stacks': 'Git Stacks', stacks: 'Stacks', compare: 'Compare', timeline: 'Timeline',
4242
notifications: 'Notifications', 'api-playground': 'API', 'cost-optimizer': 'Cost', 'dependency-map': 'Dep Map', swarm: 'Swarm', 'multi-host': 'Multi-Host',
4343
logs: 'Log Explorer', howto: 'How-To', observability: 'Observability', 'sample-feature': 'Sample Plugin', 'registry-browse': 'Registries', onboarding: 'Environment Setup',
44+
diagnostics: 'Diagnostics',
4445
posture: 'Posture', blueprints: 'Reconciler', copilot: 'Copilot', procedures: 'Procedures',
4546
'incus-instances': 'Incus / LXD (alpha)', 'proxmox-resources': 'Proxmox (alpha)', 'migration-vm': 'VM Migration (alpha)', 'kubernetes-resources': 'Kubernetes (alpha)', 'nomad-jobs': 'Nomad (alpha)', 'vsphere-resources': 'vSphere / ESXi (alpha)',
4647
'virtual-machines': 'Virtual Machines', 'high-availability': 'High Availability', 'storage-posture': 'Storage Posture', 'network-posture': 'Network Posture',
@@ -129,6 +130,26 @@ i18n.register('en', 'EN', 'English', {
129130
redacted: 'Secret values are masked in this preview.',
130131
},
131132
pages: {
133+
diagnostics: {
134+
title: 'Diagnostic Sessions',
135+
subtitle: 'Containers and VMs on one time axis. Read-only — a session changes nothing.',
136+
newSession: 'New session', defaultName: 'Investigation',
137+
empty: 'No sessions yet. Create one to correlate what was happening across your estate at a point in time.',
138+
window: 'Window', subjects: 'Subjects', createdBy: 'Created by', export: 'Export',
139+
pickSubjects: 'Subjects', filter: 'Filter containers...', subjectCap: 'Up to 25 subjects per session.',
140+
pickAtLeastOne: 'Pick at least one subject', noContainers: 'No running containers on this host.',
141+
created: 'Session created', deleted: 'Session deleted',
142+
confirmDelete: 'Delete this session? The metrics it reads are not affected.',
143+
range1h: 'Last hour', range6h: 'Last 6 hours', range24h: 'Last 24 hours', range7d: 'Last 7 days',
144+
resolution: 'Resolution: {{resolution}}',
145+
annotations: 'Events on this window',
146+
noSeries: 'No subjects returned any data for this window.',
147+
noSamples: 'No metrics recorded for this subject in this window.',
148+
noVmTelemetry: 'No VM telemetry has been ingested for this subject. VM metrics arrive by ingest, not polling.',
149+
skewWarning: 'Sources disagree by {{ms}} ms. Compare these series with care — the offset is reported, not corrected.',
150+
chartLabel: 'sparkline, peak {{max}}',
151+
},
152+
132153
// ── Dashboard ───────────────────────────────
133154
dashboard: {
134155
title: 'Dashboard', subtitle: 'System overview and real-time monitoring',

public/js/i18n/ro.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ i18n.register('ro', 'RO', "Română", {
104104
"api-playground": "API",
105105
"cost-optimizer": "Cost",
106106
"dependency-map": "Hartă Dependențe",
107+
diagnostics: "Diagnostic",
107108
posture: "Postură",
108109
procedures: "Proceduri",
109110
blueprints: "Reconciler",
@@ -230,6 +231,26 @@ i18n.register('ro', 'RO', "Română", {
230231
redacted: "Valorile secrete sunt mascate in aceasta previzualizare."
231232
},
232233
pages: {
234+
diagnostics: {
235+
title: "Sesiuni de diagnostic",
236+
subtitle: "Containere si VM-uri pe aceeasi axa de timp. Doar citire — o sesiune nu schimba nimic.",
237+
newSession: "Sesiune noua", defaultName: "Investigatie",
238+
empty: "Nicio sesiune inca. Creeaza una ca sa corelezi ce se intampla in parcul tau la un moment dat.",
239+
window: "Fereastra", subjects: "Subiecte", createdBy: "Creata de", export: "Export",
240+
pickSubjects: "Subiecte", filter: "Filtreaza containere...", subjectCap: "Maximum 25 de subiecte per sesiune.",
241+
pickAtLeastOne: "Alege cel putin un subiect", noContainers: "Niciun container pornit pe acest host.",
242+
created: "Sesiune creata", deleted: "Sesiune stearsa",
243+
confirmDelete: "Stergi aceasta sesiune? Metricile pe care le citeste nu sunt afectate.",
244+
range1h: "Ultima ora", range6h: "Ultimele 6 ore", range24h: "Ultimele 24 de ore", range7d: "Ultimele 7 zile",
245+
resolution: "Rezolutie: {{resolution}}",
246+
annotations: "Evenimente in aceasta fereastra",
247+
noSeries: "Niciun subiect nu a returnat date pentru aceasta fereastra.",
248+
noSamples: "Nicio metrica inregistrata pentru acest subiect in aceasta fereastra.",
249+
noVmTelemetry: "Nu s-a ingerat telemetrie pentru acest VM. Metricile de VM vin prin ingest, nu prin polling.",
250+
skewWarning: "Sursele difera cu {{ms}} ms. Compara aceste serii cu atentie — decalajul e raportat, nu corectat.",
251+
chartLabel: "grafic mic, varf {{max}}"
252+
},
253+
233254
dashboard: {
234255
title: "Dashboard",
235256
subtitle: "Prezentare generala si monitorizare in timp real",

0 commit comments

Comments
 (0)