Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ go mod tidy # clean dependencies
# Run once (no auto-reload)
DOCKIFY_HOST=127.0.0.1 DOCKIFY_DATA_DIR=./data go run ./cmd/dockify serve

# Boost/PWA: page scripts must be idempotent (docs/DESIGN-SYSTEM.md §7.1); ui.mjs covers boosted navigation

# Verify templates parse
go test ./internal/http/... -run TestTemplates

Expand Down
11 changes: 10 additions & 1 deletion docs/DESIGN-SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,16 @@ Radius: kartu 16, kontrol/tombol 12, lencana 6, chip/pill penuh. Target sentuh a

## 7. Performa dan aksesibilitas (diukur, `tests/e2e/ui.mjs`)
Viewport 390, CPU 4×, latensi 80 ms, median 5 ulangan: navigasi antar halaman utama ≤300 ms (localhost). DOM ≤12 node per baris daftar, <2.500 node untuk 200 baris.
Garis progres 3px saat navigasi >150 ms (htmx `hx-boost` opsional). Fokus terlihat 3px `accent`; Escape menutup sheet; `aria-pressed`; `aria-labelledby` di dialog.
Garis progres 3px (`#progress`) saat navigasi boosted >150 ms, hormati reduced-motion. Fokus terlihat 3px `accent`; Escape menutup sheet; `aria-pressed`; `aria-labelledby` di dialog.
### 7.1 Navigasi boosted (S9)
- `<body hx-boost="true" hx-ext="preload,head-support" hx-target="#main" hx-select="#main" hx-swap="outerHTML show:none">`. Setiap halaman kerja membungkus **seluruh** isi (konten, sheet, `<template>`, skrip inline) dalam satu `<div class="container" id="main" data-nav="{{.Nav}}">`; header, tab bar, dan head tetap. `login.html` tidak memuat htmx; form logout `hx-boost="false"`.
- Elemen htmx lain di dalam halaman (stats card, segmented) wajib `hx-target` sendiri dan `hx-select="unset"` agar tidak mewarisi `#main`.
- Skrip halaman **idempoten**: dipanggil pada `DOMContentLoaded` dan `htmx:afterSettle`, dengan guard `data-init` (lihat `list.js`, `formatTimes` di `app.js`). Listener ke `document`/`window` dari skrip inline dilarang (menumpuk setiap swap): pasang ke `#main` atau elemen di dalamnya. Sumber daya hidup (WebSocket, xterm) mendaftar fungsi pembersih ke `window.dockifyCleanups`; `app.js` memanggilnya pada `htmx:beforeSwap` target `#main`.
- Form dengan `onsubmit` yang bisa membatalkan harus memvalidasi lagi di `htmx:beforeRequest` (htmx tetap mengirim saat `onsubmit` mengembalikan false).
- Respons tanpa `#main` (redirect ke login) jatuh ke load penuh; 4xx/5xx boosted tetap di-swap (halaman error).
- Preload: atribut `preload="mousedown"` (touchstart otomatis) pada nav dan tautan baris; server menjawab permintaan `HX-Preloaded` dengan `Cache-Control: private, max-age=5`.
- PWA: `static/manifest.webmanifest`, `sw.js?v=AssetVersion` (hanya `/static/*`, cache-first; HTML/API tidak pernah di-cache), `Service-Worker-Allowed: ../`.

Bahasa UI Inggris, label tanpa titik dua, tombol kata kerja spesifik, tanpa emoji.

## 8. Checklist PR UI
Expand Down
1 change: 1 addition & 0 deletions docs/UI-REVAMP-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ PR dengan bukti (screenshot 3 viewport × 2 tema untuk PR UI, tabel perf sebelum
| S6 | Form (apps add/edit, servers add/edit, settings, import/export): label 12.8px, input 16px, select bergaya, modal → sheet, baris env/file/domain memakai grid kolom tetap; Settings → Appearance: segmented Palette (3) dan Theme (Light/Dark/System) (K10) | Inline style = 0 di template form; iOS tidak zoom; palet dan tema tersimpan dan bertahan setelah reload | Sonnet | screenshot 390 |
| S7 | `tests/e2e/ui.mjs` (K8) + perbaikan temuan perf/a11y/kontras | Semua target §9/§10 terpenuhi; tabel sebelum/sesudah | Haiku jalankan (low), Sonnet perbaiki | tabel perf, laporan kontras |
| S8 | Audit akhir leader (worktree `../dockify-audit`), perbarui `SPEC.md` §UI Style Guide → rujuk `docs/DESIGN-SYSTEM.md`, `README` screenshot, release patch | Checklist §11 semua tercentang; `./scripts/release.sh` | Fable | temuan bernomor, tag rilis |
| S9 | Navigasi instan: `hx-boost` di `<body>` (hanya `#main` di-swap), ekstensi `preload` + `head-support` (vendor di `static/`), garis progres 3px, PWA ringan (manifest, ikon 192/512, `sw.js` cache `/static/*`), skrip halaman idempoten | Klik tab ≤150 ms (preload) / ≤250 ms (tanpa) di CPU 4x + 80 ms; header tidak dirender ulang; 10 bolak-balik tanpa kebocoran node; `ui.mjs` lolos dengan auth; login/logout tanpa boost | Sonnet (medium) | `docs/ui-revamp/perf-s9.md`, screenshot 390 |

Perkiraan: 8 PR + 1 PR dokumen ini. Eksekutor: satu sesi Sonnet (`dockify · eksekusi ui`), effort medium, dibuat setelah S0 selesai; ditutup setelah S7 di-merge.

Expand Down
27 changes: 27 additions & 0 deletions docs/ui-revamp/perf-s9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Perf S9 (hx-boost + preload + head-support), cabang `feat/ui-s9-boost`

`UI_USER=admin UI_PASS=uji123 node tests/e2e/ui.mjs http://127.0.0.1:18090 <out>` (build mock, viewport 390, CPU 4x, latensi 80 ms, median 5).
Sebelum = `perf-sesudah.md` (S7, navigasi dokumen penuh); sesudah = run ini.

## Navigasi antar halaman utama

| Kasus | Sebelum (S7) | Sesudah (S9) | Target |
| --- | --- | --- | --- |
| Klik tab Servers → Apps, boost, preload (touchstart dulu) | - | **65 ms** (`htmx:beforeRequest` → `afterSettle`) | ≤150 ms ✅ |
| Sama, tanpa preload | - | **153 ms** | ≤250 ms ✅ |
| `goto` penuh dashboard / servers / apps / app-detail (DCL) | 300 / 301 / 301 / 328 ms | 190 / 180 / 190 / 197 ms | (bukan target; sama persis halaman penuh) |

Header tidak dirender ulang (`isConnected` true), `aria-current` dan `document.title` mengikuti halaman, scroll ke atas, Back/Forward bekerja.
Kebocoran: 10 bolak-balik daftar ↔ detail app (grafik live + WebSocket): node DOM 236 → 236, daftar `dockifyCleanups` kosong di halaman daftar.

## Transfer dingin (di kawat)

| Halaman | KB S7 → S9 | Request S7 → S9 |
| --- | --- | --- |
| dashboard | 126 → 207 | 7 → 12 |
| servers | 120 → 196 | 7 → 12 |
| apps | 122 → 203 | 7 → 12 |
| app-detail | 192 → 206 | 10 → 13 |

Kenaikan cold-load (+~80 KB, +5 request) berasal dari htmx 2.0.4 + 2 ekstensi + charts.js + console.js yang kini dimuat di semua halaman, plus manifest/ikon.
Sekali dimuat, service worker meng-cache `/static/*` (cache-first); kompresi gzip HTTP tetap tugas terpisah yang sudah dicatat.
7 changes: 3 additions & 4 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ func serveStatic(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if strings.HasSuffix(name, ".webmanifest") {
w.Header().Set("Content-Type", "application/manifest+json")
}
if name == "sw.js" {
// The worker script must always be revalidated; it controls the whole app path (scope ".").
w.Header().Set("Cache-Control", "no-cache")
Expand All @@ -293,10 +296,6 @@ func securityHeaders(next http.Handler) http.Handler {
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
// htmx preload (hover/touch prefetch) relies on the browser cache: let the prefetched page live a few seconds.
if r.Method == http.MethodGet && r.Header.Get("HX-Preloaded") == "true" {
h.Set("Cache-Control", "private, max-age=5")
}
next.ServeHTTP(w, r)
})
}
Expand Down
43 changes: 34 additions & 9 deletions internal/http/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,25 @@ function syncNav() {
else a.removeAttribute('aria-current');
});
}
document.addEventListener('DOMContentLoaded', function () { formatTimes(); });
// Auto-refresh of status pages (deploying/initializing): <main data-poll="2000"> re-fetches itself; the timer is a page cleanup.
var pollTimer = null;
function startPoll() {
clearTimeout(pollTimer);
var m = document.getElementById('main'), ms = m && Number(m.getAttribute('data-poll'));
if (!ms || !window.htmx) return;
pollTimer = setTimeout(function () {
htmx.ajax('GET', location.href, { target: '#main', select: '#main', swap: 'outerHTML' });
}, ms);
(window.dockifyCleanups = window.dockifyCleanups || []).push(function () { clearTimeout(pollTimer); });
}
// Everything a page needs after its #main arrives (swap or history restore); idempotent.
function pageInit() {
formatTimes();
syncNav();
if (window.dockifyApplyTheme) window.dockifyApplyTheme(); // head merge replaces <meta name=theme-color>
startPoll();
}
document.addEventListener('DOMContentLoaded', function () { formatTimes(); startPoll(); });
if (window.htmx) {
var progressTimer = null;
function progress(show) {
Expand All @@ -137,19 +155,20 @@ if (window.htmx) {
var d = e.detail;
if (d.boosted) {
// Login redirect, session expiry, anything without #main: fall back to a full page load.
if (d.xhr.responseText.indexOf('id="main"') < 0) {
var doc = new DOMParser().parseFromString(d.xhr.responseText, 'text/html');
if (!doc.getElementById('main')) {
d.shouldSwap = false;
progress(false);
location.assign(d.xhr.responseURL || location.href);
// A GET can be repeated as a normal navigation; a POST must not be replayed as one.
if (d.requestConfig.verb === 'get') location.assign(d.xhr.responseURL || location.href);
else showToast('The server did not return a page. Reload and check the result.', true);
return;
}
// 4xx/5xx pages (error.html) are shown like any other page.
if (d.xhr.status >= 400) { d.shouldSwap = true; d.isError = false; }
}
if (d.target && d.target.id === 'main') {
var c = window.dockifyCleanups || [];
window.dockifyCleanups = [];
c.forEach(function (f) { try { f(); } catch (err) {} });
runCleanups();
}
});
// head-support: never drop scripts/styles already loaded (fonts link mutates media=all, xterm is added at runtime).
Expand All @@ -164,11 +183,17 @@ if (window.htmx) {
if (url && document.head.querySelector((el.tagName === 'LINK' ? 'link[href="' : 'script[src="') + url + '"]')) e.preventDefault();
});
document.addEventListener('htmx:afterSettle', function (e) {
formatTimes();
syncNav();
if (window.dockifyApplyTheme) window.dockifyApplyTheme(); // head merge replaces <meta name=theme-color>
pageInit();
if (e.detail.boosted) window.scrollTo(0, 0);
});
// Back/Forward never fire beforeSwap/afterSettle: close live sockets before the restored body is swapped in, re-init after.
function runCleanups() {
var c = window.dockifyCleanups || [];
window.dockifyCleanups = [];
c.forEach(function (f) { try { f(); } catch (err) {} });
}
document.addEventListener('htmx:historyCacheMissLoad', runCleanups);
document.addEventListener('htmx:historyRestore', pageInit);
// Back/forward: do not restore stale DOM snapshots; fetch the page again.
htmx.config.historyCacheSize = 0;
}
Expand Down
1 change: 1 addition & 0 deletions internal/http/static/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@
}
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('htmx:afterSettle', init);
document.addEventListener('htmx:historyRestore', init);
})();
5 changes: 3 additions & 2 deletions internal/http/static/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"name": "Dockify",
"short_name": "Dockify",
"start_url": ".",
"scope": ".",
"id": "../",
"start_url": "../",
"scope": "../",
"display": "standalone",
"theme_color": "#f5f7fb",
"background_color": "#f5f7fb",
Expand Down
4 changes: 4 additions & 0 deletions internal/http/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ func Render(w http.ResponseWriter, r *http.Request, status int, name string, dat
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// htmx preload (hover/touch prefetch) relies on the browser cache: let a prefetched page live a few seconds.
if status == http.StatusOK && r.Method == http.MethodGet && r.Header.Get("HX-Preloaded") == "true" {
w.Header().Set("Cache-Control", "private, max-age=5")
}
w.WriteHeader(status)
buf.WriteTo(w)
}
4 changes: 4 additions & 0 deletions internal/http/templates/apps_add.html
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,10 @@ <h3>Basic auth</h3>
document.getElementById('advanced-section').hidden = !adv;
});
});
// Boosted submit: htmx sends the request even when onsubmit returned false, so validate again here and cancel.
document.getElementById('app-form').addEventListener('htmx:beforeRequest', function (e) {
if (!validateServer()) e.preventDefault();
});
var serverSel = document.getElementById('server-select');
if (serverSel) serverSel.addEventListener('change', function () { document.getElementById('server-error').hidden = true; });
(function () {
Expand Down
5 changes: 2 additions & 3 deletions internal/http/templates/apps_detail.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en" data-palette="indigo" data-theme="light">
<head>{{template "head" .}}{{if eq .App.Status "deploying"}}<meta http-equiv="refresh" content="2">{{end}}</head>
<head>{{template "head" .}}</head>
<body hx-boost="true" hx-ext="preload,head-support" hx-target="#main" hx-select="#main" hx-swap="outerHTML show:none">
{{template "nav" .}}
<div class="container" id="main" data-nav="{{.Nav}}">
<div class="container" id="main" data-nav="{{.Nav}}"{{if or (eq .App.Status "deploying") (eq .App.Status "pending")}} data-poll="2000"{{end}}>
{{if .Flash}}<div class="toast" role="status">{{.Flash}}</div>{{end}}
<div class="breadcrumb">
<a href="apps">Apps</a>
Expand Down Expand Up @@ -116,7 +116,6 @@ <h3>Resource usage <span id="app-live-indicator" class="badge badge-online live-
}
}
});
window.addEventListener('beforeunload', appCharts.disconnect);
</script>
{{end}}

Expand Down
8 changes: 1 addition & 7 deletions internal/http/templates/export.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<hgroup><h1>Export Configuration</h1></hgroup>

<form method="POST" action="api/backup/export">
<form method="POST" action="api/backup/export" hx-boost="false">
<div class="card">
<h3>Download backup</h3>
<p class="card-sub">Export all servers and apps configuration to a YAML file. SSH keys are included and encrypted with your passphrase.</p>
Expand Down Expand Up @@ -54,12 +54,6 @@ <h3>What is exported</h3>
document.getElementById('passphrase').value = generatePassphrase();
}

(function() {
var btn = document.getElementById('copy-pass');
btn.textContent = 'Copied';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
}

(function() {
if (!document.getElementById('passphrase').value) {
Expand Down
2 changes: 1 addition & 1 deletion internal/http/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
<base href="{{.BasePath}}">
<title>{{.Title}} - Dockify</title>
{{template "theme_head" .}}
<link rel="manifest" href="static/manifest.webmanifest?v={{.AssetVersion}}">
<link rel="manifest" href="static/manifest.webmanifest?v={{.AssetVersion}}" crossorigin="use-credentials">
<link rel="icon" type="image/png" href="static/icon-192.png?v={{.AssetVersion}}">
<script src="static/htmx.min.js?v={{.AssetVersion}}"></script>
<script src="static/htmx-ext-preload.min.js?v={{.AssetVersion}}"></script>
Expand Down
5 changes: 2 additions & 3 deletions internal/http/templates/servers_detail.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en" data-palette="indigo" data-theme="light">
<head>{{template "head" .}}{{if eq .Server.Status "initializing"}}<meta http-equiv="refresh" content="2">{{end}}</head>
<head>{{template "head" .}}</head>
<body hx-boost="true" hx-ext="preload,head-support" hx-target="#main" hx-select="#main" hx-swap="outerHTML show:none">
{{template "nav" .}}
<div class="container" id="main" data-nav="{{.Nav}}">
<div class="container" id="main" data-nav="{{.Nav}}"{{if or (eq .Server.Status "initializing") (eq .Server.Status "pending")}} data-poll="2000"{{end}}>
<div class="breadcrumb">
<a href="servers">Servers</a>
<span>/</span>
Expand Down Expand Up @@ -228,7 +228,6 @@ <h3>Danger zone</h3>
}
}
});
window.addEventListener('beforeunload', serverCharts.disconnect);
</script>
{{end}}
</div>
Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ const boost = {};
await cdp.send('Network.enable');
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 80, downloadThroughput: -1, uploadThroughput: -1 });
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
const wsList = [], pageErrors = [];
p.on('websocket', (w) => { const o = { closed: false }; wsList.push(o); w.on('close', () => { o.closed = true; }); });
p.on('pageerror', (e) => pageErrors.push(e.message));
await p.goto(BASE + '/servers');
await p.evaluate(() => {
window.__hdr = document.querySelector('header.top-nav');
Expand Down Expand Up @@ -253,6 +256,23 @@ const boost = {};
const charts = await p.evaluate(() => ({ cleanups: (window.dockifyCleanups || []).length, line: !!document.querySelector('[id$="-chart-line"]') }));
if (charts.cleanups < 1) fail('live charts were not initialised after a boosted navigation');
boost.detail = charts;
// Back/Forward (htmx history restore fires no beforeSwap/afterSettle): sockets must close, list scripts must re-init
const openWs = () => wsList.filter((w) => !w.closed).length;
await p.goBack(); await p.waitForFunction(() => /\/apps$/.test(location.pathname)); await p.waitForTimeout(800);
boost.backWs = openWs();
if (boost.backWs !== 0) fail(`websocket still open after Back: ${boost.backWs}`);
if ((await p.evaluate(() => (window.dockifyCleanups || []).length)) !== 0) fail('dockifyCleanups not empty after Back');
await p.fill('[data-list-search]', 'no-such-app-zzzz');
const cnt = await p.textContent('[data-list-count]');
if (!/^0 of/.test(cnt.trim())) fail(`list filter dead after Back: "${cnt}"`);
await p.goForward(); await p.waitForFunction(() => /\/apps\/\d+$/.test(location.pathname)); await p.waitForTimeout(2500);
boost.fwdWs = openWs();
if (boost.fwdWs !== 1) fail(`expected exactly 1 websocket after Forward, got ${boost.fwdWs}`);
// Export: boost off for this form, so the browser must receive a download (not a boosted swap or a 405 page)
await p.goto(BASE + '/settings'); await p.goto(BASE + '/export');
const [dl] = await Promise.all([p.waitForEvent('download', { timeout: 8000 }).catch(() => null), p.click('button:has-text("Download YAML")')]);
if (!dl) fail('export did not produce a download');
if (pageErrors.length) fail('pageerror: ' + pageErrors.join(' | '));
// login -> logout -> login again (these are never boosted)
await p.click('button[aria-label="Sign out"]');
await p.waitForURL(/\/login$/);
Expand Down
Loading