diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md
index 11074f4..7818a91 100644
--- a/AUDIT_OPEN.md
+++ b/AUDIT_OPEN.md
@@ -2577,3 +2577,26 @@ tests also pass under -race; node --check on all three shipped frontend
bundles; node --test frontend suite 51/51 (baseline). Production code
untouched (git diff: four _test.go files). No commits were made; the tree
stays dirty for the orchestrator.
+
+## 2026-11-11 navigation and UI rework (branch nav-rework)
+
+Supersedes the D07 nav description above. The header is seven
+destinations — Home, Apps, Servers, Monitoring, Backups, Activity |
+Settings — and the pages that had their own slot are tabs: Monitoring
+(Uptime /monitors, Domains /domains, Alerts /alerts), Servers (Servers,
+Cleanup /cleanup; a server's report is its Cleanup tab), Apps (Apps,
+Templates), Backups (Coverage, Setup, Restore tests), Settings (+
+Integrations, Git sources). Every old URL and alias still resolves to the
+page and tab it moved to (ROUTES entries may carry fixed params); app,
+server, Backups and Settings tabs are in the URL as ?tab=. Home is a status
+page. One confirm dialog replaces every browser confirm()/prompt(). The
+nav collapses below 900px (re-measured; frontend-test/header_layout).
+Two small backend additions: /api/cleanup rows carry the audited disk
+line, /api/monitors carries each monitor's last_check.
+
+Left as they were, deliberately: the Cleanup destructive-fix and backup
+restore typed-name dialogs keep their own markup (same pattern as the
+shared dialog; their flows are covered by existing tests). The sources
+"UI surface" item under the webhook remainders above now has a table,
+detail, delete and delivery history; the per-forge freshness items stay
+open.
diff --git a/cmd/teploy-dash/frontend-test/async_states.test.mjs b/cmd/teploy-dash/frontend-test/async_states.test.mjs
index 5118daf..d370229 100644
--- a/cmd/teploy-dash/frontend-test/async_states.test.mjs
+++ b/cmd/teploy-dash/frontend-test/async_states.test.mjs
@@ -281,7 +281,7 @@ const rawOk = (value) => () => ({
// The empty states that used to paint on failure now gate on !loadFailed.
for (const marker of [
'!loading && !loadFailed && monitors.length === 0',
- '!loading && !loadFailed && tests.length === 0',
+ '!loading && !loadFailed && restoreTestRows(automaticTests(), tests).length === 0',
'!loading && !loadFailed && ops.length === 0',
'!loading && !loadFailed && visibleItems.length === 0',
]) {
diff --git a/cmd/teploy-dash/frontend-test/backup_setup.test.mjs b/cmd/teploy-dash/frontend-test/backup_setup.test.mjs
index 7cf5382..504cd7f 100644
--- a/cmd/teploy-dash/frontend-test/backup_setup.test.mjs
+++ b/cmd/teploy-dash/frontend-test/backup_setup.test.mjs
@@ -206,11 +206,11 @@ test('closing with an unsaved key warns that its file is no use', async () => {
p.recoveryGen = { publicKey: 'age1x', fileText: 'x', fileName: 'y' };
const seen = [];
globalThis.confirm = text => { seen.push(text); return false; };
- p.closeSetup();
+ await p.closeSetup();
assert.equal(p.setupOpen, true);
assert.match(seen[0], /delete it/);
globalThis.confirm = () => true;
- p.closeSetup();
+ await p.closeSetup();
assert.equal(p.setupOpen, false);
assert.equal(p.recoveryGen, null);
delete globalThis.confirm;
@@ -454,20 +454,26 @@ test('the hand-written-jobs list shows only when some are found', async () => {
test('the home page says so until protected, and a failed read says nothing', async () => {
const h = harness('/');
let view = baseView();
- h.respond(() => response(view));
- const banner = h.components.protectBanner();
- await banner.init();
- assert.equal(banner.visible(), true);
+ const ok = (url) => url === '/api/backups' ? response(view) : response({});
+ h.respond(ok);
+ const home = h.components.homeStatus();
+ await home.init();
+ const protect = () => home.attention().find((r) => r.key === 'protect');
+ assert.ok(protect(), 'unprotected: the protect row shows');
view = baseView({ state: 'protected' });
- await banner.init();
- assert.equal(banner.visible(), false);
+ await home.loadOne('backups');
+ assert.equal(protect(), undefined);
view = baseView({ supported: false });
- await banner.init();
- assert.equal(banner.visible(), false);
- h.respond(() => response({}, { status: 500 }));
- await banner.init();
- assert.equal(banner.visible(), false);
- assert.ok(h.readHTML().includes('x-data="protectBanner"'));
+ await home.loadOne('backups');
+ assert.equal(protect(), undefined);
+ // A failed first read shows no protect row, and names backups as unchecked.
+ const fresh = h.components.homeStatus();
+ h.respond((url) => url === '/api/backups' ? response({}, { status: 500 }) : response({}));
+ await fresh.init();
+ assert.equal(fresh.attention().find((r) => r.key === 'protect'), undefined);
+ assert.ok(fresh.unchecked().some((s) => s.key === 'backups'), 'a failed backups read is named as unchecked');
+ home.destroy(); fresh.destroy();
+ assert.ok(h.readHTML().includes('x-data="homeStatus"'));
});
test('no emoji in the page, and the layout holds at phone width', () => {
diff --git a/cmd/teploy-dash/frontend-test/cleanup_page.test.mjs b/cmd/teploy-dash/frontend-test/cleanup_page.test.mjs
index 05de112..9aaa094 100644
--- a/cmd/teploy-dash/frontend-test/cleanup_page.test.mjs
+++ b/cmd/teploy-dash/frontend-test/cleanup_page.test.mjs
@@ -27,9 +27,16 @@ test('the /cleanup route renders the page and the nav names it',async()=>{
const h=harness('/cleanup');h.stores.router.restore();
assert.equal(h.stores.router.page,'cleanup');
const html=h.readHTML();
- assert.ok(html.includes(`x-if="$store.router.page === 'cleanup' || $store.router.page === 'cleanup-detail'"`));
- assert.ok(html.includes('>Cleanup'));
- assert.ok(html.includes('x-data="cleanupPage"'));
+ assert.ok(html.includes(`x-if="$store.router.page === 'cleanup'"`));
+ // Cleanup is a tab of the Servers section, and a server's report is the
+ // Cleanup tab of its server page (/cleanup/:server still opens it).
+ assert.ok(h.stores.router.hubTabs.some(([page, label]) => page === 'cleanup' && label === 'Cleanup'));
+ assert.equal(h.stores.router.section, 'servers');
+ assert.equal((html.match(/x-data="cleanupPage"/g) || []).length, 2, 'the list page and the server tab');
+ assert.ok(html.includes(`x-if="tab === 'cleanup'"`));
+ const d = harness('/cleanup/prod'); d.stores.router.restore();
+ assert.equal(d.stores.router.page, 'server-detail');
+ assert.deepEqual({ ...d.stores.router.params }, { name: 'prod', tab: 'cleanup' });
});
test('fleet rows are honest: partial, never and unreachable never read clean',async()=>{
diff --git a/cmd/teploy-dash/frontend-test/config_home.test.mjs b/cmd/teploy-dash/frontend-test/config_home.test.mjs
index de4bd93..7eee176 100644
--- a/cmd/teploy-dash/frontend-test/config_home.test.mjs
+++ b/cmd/teploy-dash/frontend-test/config_home.test.mjs
@@ -6,6 +6,7 @@
// gated on the CLI capability that provides it.
//
// Run: node cmd/teploy-dash/frontend-test/config_home.test.mjs
+import { bridgeDialogs } from './dialog_bridge.mjs';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@@ -61,6 +62,7 @@ globalThis.fetch = async (url, init = {}) => {
new Function(readFileSync(join(here, '../frontend/js/app.js'), 'utf8'))();
alpineInitListeners.forEach((fn) => fn());
+bridgeDialogs(stores);
// app.js registers its own router store; record navigations on it.
stores.router.params = { server: 'prod', name: 'web' };
stores.router.navigate = (page, params) => navigations.push([page, params]);
@@ -263,7 +265,7 @@ function newPage() {
`x-show="hasFeature('config_home')"`,
`x-show="hasFeature('events')"`,
'id="app-panel-settings"',
- 'id="app-panel-activity"',
+ 'id="app-panel-history"',
'Make editable here',
'Deploy pending changes',
'Settings edited in',
diff --git a/cmd/teploy-dash/frontend-test/db_actions.test.mjs b/cmd/teploy-dash/frontend-test/db_actions.test.mjs
index 96a69c0..3393b16 100644
--- a/cmd/teploy-dash/frontend-test/db_actions.test.mjs
+++ b/cmd/teploy-dash/frontend-test/db_actions.test.mjs
@@ -8,6 +8,7 @@
// API responses.
//
// Run: node cmd/teploy-dash/frontend-test/db_actions.test.mjs
+import { bridgeDialogs } from './dialog_bridge.mjs';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@@ -55,9 +56,11 @@ globalThis.fetch = async (url, init) => {
};
const components = {};
+const dialogStore = {};
+bridgeDialogs({ dialog: dialogStore });
globalThis.Alpine = {
data: (name, factory) => { components[name] = factory; },
- store: () => ({ navigate() {}, restore() {}, params: { server: 'prod', name: 'web' }, page: 'app-detail' }),
+ store: (name) => name === 'dialog' ? dialogStore : ({ navigate() {}, restore() {}, params: { server: 'prod', name: 'web' }, page: 'app-detail' }),
effect: (fn) => fn(),
};
diff --git a/cmd/teploy-dash/frontend-test/dialog_bridge.mjs b/cmd/teploy-dash/frontend-test/dialog_bridge.mjs
new file mode 100644
index 0000000..5688c08
--- /dev/null
+++ b/cmd/teploy-dash/frontend-test/dialog_bridge.mjs
@@ -0,0 +1,29 @@
+// Test double for the confirm dialog store: routes askConfirm/askForm to the
+// test's globalThis.confirm / globalThis.prompt stubs, read at call time, so
+// a test answers the dialog the way it used to answer the browser one.
+// confirm gets "title\n\nmessage"; a typed-name dialog passes when prompt
+// returns the name; a form's first field takes prompt's answer.
+export function bridgeDialogs(stores) {
+ const dialog = stores.dialog;
+ if (!dialog) return;
+ dialog.opened = [];
+ dialog.open = (o) => {
+ dialog.opened.push(o);
+ const text = o.title + (o.message ? '\n\n' + o.message : '');
+ const fields = o.fields || [];
+ if (o.typed && typeof globalThis.prompt === 'function' && !fields.length) {
+ const typed = globalThis.prompt(text, '');
+ return Promise.resolve(o.kind === 'form' ? (typed === o.typed ? {} : null) : typed === o.typed);
+ }
+ if (o.kind === 'form' && typeof globalThis.prompt === 'function' && fields.length) {
+ const value = globalThis.prompt(text, fields[0].value ?? '');
+ if (value === null || value === undefined) return Promise.resolve(null);
+ const values = Object.fromEntries(fields.map((f) => [f.key, f.value ?? '']));
+ values[fields[0].key] = value;
+ return Promise.resolve(values);
+ }
+ const ok = typeof globalThis.confirm === 'function' ? !!globalThis.confirm(text) : true;
+ if (o.kind === 'form') return Promise.resolve(ok ? Object.fromEntries(fields.map((f) => [f.key, f.value ?? ''])) : null);
+ return Promise.resolve(ok);
+ };
+}
diff --git a/cmd/teploy-dash/frontend-test/domains_page.test.mjs b/cmd/teploy-dash/frontend-test/domains_page.test.mjs
index 1dbabd3..3a20980 100644
--- a/cmd/teploy-dash/frontend-test/domains_page.test.mjs
+++ b/cmd/teploy-dash/frontend-test/domains_page.test.mjs
@@ -12,7 +12,8 @@ test('the /domains route renders the page and the nav names it',async()=>{
assert.equal(h.stores.router.page,'domains');
const html=h.readHTML();
assert.ok(html.includes(`x-if="$store.router.page === 'domains'"`));
- assert.ok(html.includes('>Domains'));
+ assert.ok(h.stores.router.hubTabs.some(([page, label]) => page === 'domains' && label === 'Domains'));
+ assert.equal(h.stores.router.section, 'monitoring');
assert.ok(html.includes('x-data="domainsPage"'));
});
diff --git a/cmd/teploy-dash/frontend-test/git_source_card.test.mjs b/cmd/teploy-dash/frontend-test/git_source_card.test.mjs
index db73081..9f00c84 100644
--- a/cmd/teploy-dash/frontend-test/git_source_card.test.mjs
+++ b/cmd/teploy-dash/frontend-test/git_source_card.test.mjs
@@ -8,6 +8,7 @@
// responses.
//
// Run: node cmd/teploy-dash/frontend-test/git_source_card.test.mjs
+import { bridgeDialogs } from './dialog_bridge.mjs';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@@ -58,9 +59,11 @@ globalThis.fetch = async (url, init) => {
};
const components = {};
+const dialogStore = {};
+bridgeDialogs({ dialog: dialogStore });
globalThis.Alpine = {
data: (name, factory) => { components[name] = factory; },
- store: () => ({ navigate() {}, restore() {}, params: { server: 'prod', name: 'web' }, page: 'app-detail' }),
+ store: (name) => name === 'dialog' ? dialogStore : ({ navigate() {}, restore() {}, params: { server: 'prod', name: 'web' }, page: 'app-detail' }),
effect: (fn) => fn(),
};
@@ -201,7 +204,8 @@ function requestsFor(method, urlPart) {
promptAnswer = 'main';
await page.gitResetWatermark();
assert(requestsFor('POST', '/apps/prod/web/source/reset-watermark').length === 0, 'a mistyped ref must not reset');
- assert(lastToast && lastToast.includes('Type the ref'), `mistyped toast = ${lastToast}`);
+ const typedGate = dialogStore.opened.at(-1);
+ assert(typedGate && typedGate.typed === 'refs/heads/main' && typedGate.danger, 'the reset dialog is gated on typing the ref');
promptAnswer = 'refs/heads/main';
await page.gitResetWatermark();
@@ -239,7 +243,7 @@ function requestsFor(method, urlPart) {
const html = readFileSync(join(here, '../frontend/index.html'), 'utf8');
const tabStart = html.indexOf('id="app-tab-repository"');
const panelStart = html.indexOf('id="app-panel-repository"');
- const panelEnd = html.indexOf('id="app-panel-activity"');
+ const panelEnd = html.indexOf('id="app-panel-history"');
assert(tabStart !== -1 && panelStart !== -1 && panelEnd > panelStart, 'repository tab/panel not found in index.html');
const tabButton = html.slice(tabStart - 400, tabStart + 300);
assert(tabButton.includes("hasFeature('git_source') && teployHome()"), 'tab must hide without the git-source token or on non-Teploy homes');
diff --git a/cmd/teploy-dash/frontend-test/group_identity.test.mjs b/cmd/teploy-dash/frontend-test/group_identity.test.mjs
index 3de0276..f73caeb 100644
--- a/cmd/teploy-dash/frontend-test/group_identity.test.mjs
+++ b/cmd/teploy-dash/frontend-test/group_identity.test.mjs
@@ -73,7 +73,7 @@ test('D06: scoped deletion sends exact identity and legacy deletion cannot touch
page.groups = [{ name: 'Mixed', apps: ['web'], server_apps: [{ server_id: prodID, app: 'web' }, {server_id: stageID, app: 'web'}] }];
page.load = async () => {};
h.respond(() => response({ status: 'unassigned' }));
- const binding = h.readHTML().match(/@click.stop="(unassignFromGroup\([^"]+)"/)[1];
+ const binding = h.readHTML().match(/@click="open = false; (unassignFromGroup\([^"]+)"/)[1];
const remove = app => new Function('page', 'group', 'app', `return page.${binding}`)(page, page.groups[0], app);
await remove({ name: 'web', server: 'prod', server_id: prodID });
assert.equal(h.requests.at(-1).url, `/api/groups/Mixed/apps/web?server_id=${prodID}`);
diff --git a/cmd/teploy-dash/frontend-test/header_layout.test.mjs b/cmd/teploy-dash/frontend-test/header_layout.test.mjs
index 6c6bb2b..b9083de 100644
--- a/cmd/teploy-dash/frontend-test/header_layout.test.mjs
+++ b/cmd/teploy-dash/frontend-test/header_layout.test.mjs
@@ -21,12 +21,16 @@ assert(menu.includes('class="nav-pins"') && menu.includes('x-data="navLinks"'),
assert(/\.nav-pins \{ display: none; \}/.test(css), 'menu pins are hidden by default (desktop shows header icons)');
assert(/@media \(max-width: 640px\)[\s\S]*\.nav-pins \{ display: block;/.test(css), 'menu pins show on phones');
-// The nav collapses before it can overflow: thirteen destinations do not fit
-// beside the brand below about 1200px (measured), so the toggle must take over
-// at a width above that, not at the 980px content breakpoint.
-const collapse = css.match(/@media \(max-width: (\d+)px\) \{\s*\.navbar \{[\s\S]*?\.nav-toggle \{ display: flex; \}/);
-assert(collapse && Number(collapse[1]) >= 1200, 'the nav collapses to the toggle at 1200px or wider');
-assert((menu.match(/
]*>([^<]+)<\/a><\/li>/g)].map((m) => m[1]);
+assert(items.join(',') === 'Home,Apps,Servers,Monitoring,Backups,Activity,Settings', 'seven destinations: re-measure the collapse width when this changes (' + items.join(',') + ')');
// The theme store keeps toggle() and gains set().
const listeners = [];
diff --git a/cmd/teploy-dash/frontend-test/ia_routes.test.mjs b/cmd/teploy-dash/frontend-test/ia_routes.test.mjs
index 51f732a..d39e45f 100644
--- a/cmd/teploy-dash/frontend-test/ia_routes.test.mjs
+++ b/cmd/teploy-dash/frontend-test/ia_routes.test.mjs
@@ -72,6 +72,7 @@ assert(!!router, 'router store was not registered');
['/operations/abc', 'operation-detail'],
// task-named aliases deep-link to the same pages, with params.
['/projects', 'projects'],
+ ['/apps', 'projects'],
['/fleet', 'servers'],
['/fleet/prod', 'server-detail'],
['/activity', 'operations'],
@@ -124,31 +125,92 @@ assert(!!router, 'router store was not registered');
// semantics; page titles renamed.
{
const html = readFileSync(join(here, '../frontend/index.html'), 'utf8');
- for (const label of ['>Projects ', '>Fleet', '>Activity', '>Monitors', '>Domains', '>Restore Tests', '>Templates', '>Settings']) {
- assert(html.includes(label), `nav must render ${label}`);
- }
+ // Seven destinations: Home Apps Servers Monitoring Backups Activity | Settings.
+ const nav = html.slice(html.indexOf('id="primary-nav"'), html.indexOf('', html.indexOf('id="primary-nav"')));
+ const labels = [...nav.matchAll(/]*>([^<]+)<\/a><\/li>/g)].map((m) => m[1]);
+ assert(JSON.stringify(labels) === JSON.stringify(['Home', 'Apps', 'Servers', 'Monitoring', 'Backups', 'Activity', 'Settings']), `nav destinations: ${labels.join(', ')}`);
assert(!html.includes('>Deployments '), 'nav must not render the old Deployments label');
- const seps = (html.match(/class="nav-sep"/g) || []).length;
- assert(seps === 2, `nav must render two group separators (found ${seps})`);
- assert(html.includes('title="Groups, projects, and the apps deployed to them"'), 'Projects nav link must carry its task title');
- assert(html.includes('title="Every server this dash manages'), 'Fleet nav link must carry its task title');
+ const seps = (nav.match(/class="nav-sep"/g) || []).length;
+ assert(seps === 1, `nav must render one separator, before Settings (found ${seps})`);
+ assert((nav.match(/\$store\.router\.section === '/g) || []).length === 14, 'every destination is active (and aria-current) for its whole section');
+ assert(html.includes('title="Groups, projects, the apps deployed to them, and app templates"'), 'Apps nav link must carry its task title');
+ assert(html.includes('title="Every server this dash manages'), 'Servers nav link must carry its task title');
assert(html.includes('title="Operations started through dash'), 'Activity nav link must carry its task title');
// Page titles follow the nav names.
- assert(html.includes('Projects '), 'projects page title');
- assert(html.includes('Fleet '), 'fleet page title');
+ assert(html.includes('Apps '), 'apps page title');
+ assert(html.includes('Servers '), 'servers page title');
+ assert(!/>(Projects|Fleet|Siblings|Sources)<\/(a|h1)>/.test(html), 'old labels Projects, Fleet, Siblings, Sources are gone');
assert(html.includes('Activity '), 'activity page title');
assert(!html.includes('← Deployments'), 'back-links must say Projects, not Deployments');
- assert(html.includes('← Projects') && html.includes('← Fleet') && html.includes('← Activity'), 'back-links renamed');
+ assert(html.includes('← Apps') && html.includes('← Servers') && html.includes('← Activity'), 'back-links renamed');
// Tab semantics on the resource page (app) and the server page.
assert(html.includes('role="tablist"'), 'tabs render tablist semantics');
const tabs = (html.match(/role="tab"/g) || []).length;
- assert(tabs === 12, `expected 12 role=tab buttons (10 app incl. capability-gated Settings, Activity, Releases, Backups and Repository + 2 server), found ${tabs}`);
+ assert(tabs === 16, `expected 16 role=tab buttons (10 app incl. capability-gated Settings, History, Releases, Backups and Repository + 3 server + 3 backups), found ${tabs}`);
const panels = (html.match(/role="tabpanel"/g) || []).length;
- assert(panels === 12, `expected 12 role=tabpanel containers, found ${panels}`);
+ assert(panels === 13, `expected 13 role=tabpanel containers (10 app + 3 server), found ${panels}`);
assert(html.includes('aria-selected='), 'tabs expose aria-selected');
assert(html.includes('@keydown.arrow-right.prevent='), 'tablist supports arrow-key navigation');
}
+// 5. Pages that became tabs: the old URLs open the right page and tab, the
+// old page names still navigate, and every page belongs to a section.
+{
+ const resolve = (pathname, search = '') => {
+ globalThis.location = { pathname, search };
+ router.restore();
+ return { page: router.page, params: { ...router.params }, section: router.section };
+ };
+ const cases = [
+ ['/', 'homepage', {}, 'home'],
+ ['/apps', 'projects', {}, 'apps'],
+ ['/templates', 'templates', {}, 'apps'],
+ ['/fleet', 'servers', {}, 'servers'],
+ ['/cleanup', 'cleanup', {}, 'servers'],
+ ['/cleanup/prod', 'server-detail', { name: 'prod', tab: 'cleanup' }, 'servers'],
+ ['/monitors', 'monitors', {}, 'monitoring'],
+ ['/monitors/m1', 'monitor-detail', { id: 'm1' }, 'monitoring'],
+ ['/domains', 'domains', {}, 'monitoring'],
+ ['/alerts', 'alerts', {}, 'monitoring'],
+ ['/backups', 'backups', {}, 'backups'],
+ ['/restore-tests', 'backups', { tab: 'restore-tests' }, 'backups'],
+ ['/activity', 'operations', {}, 'activity'],
+ ['/siblings', 'settings', { tab: 'integrations' }, 'settings'],
+ ['/sources', 'settings', { tab: 'git-sources' }, 'settings'],
+ ];
+ for (const [path, page, params, section] of cases) {
+ const got = resolve(path);
+ assert(got.page === page && JSON.stringify(got.params) === JSON.stringify(params) && got.section === section,
+ `${path} -> ${JSON.stringify(got)}, want ${page} ${JSON.stringify(params)} in ${section}`);
+ }
+ const tabbed = [
+ ['/settings', '?tab=notifications', 'settings', 'notifications'],
+ ['/backups', '?tab=setup', 'backups', 'setup'],
+ ['/servers/prod', '?tab=proxy', 'server-detail', 'proxy'],
+ ['/deployments/prod/web', '?tab=env', 'app-detail', 'env'],
+ ];
+ for (const [path, search, page, tab] of tabbed) {
+ const got = resolve(path, search);
+ assert(got.page === page && got.params.tab === tab, `${path}${search} -> ${got.page} tab ${got.params.tab}`);
+ }
+ pushed.length = 0;
+ for (const [name, params, url] of [
+ ['cleanup-detail', { server: 'prod' }, '/servers/prod?tab=cleanup'],
+ ['siblings', {}, '/settings?tab=integrations'],
+ ['sources', {}, '/settings?tab=git-sources'],
+ ['restore-tests', {}, '/backups?tab=restore-tests'],
+ ]) {
+ router.navigate(name, params);
+ assert(pushed.at(-1) === url, `navigate('${name}') pushed ${pushed.at(-1)}, want ${url}`);
+ }
+ const hub = (page) => { globalThis.location = { pathname: router.url(page), search: '' }; router.restore(); return (router.hubTabs || []).map((t) => t[1]).join(','); };
+ assert(hub('projects') === 'Apps,Templates', 'Apps hub tabs');
+ assert(hub('servers') === 'Servers,Cleanup', 'Servers hub tabs');
+ assert(hub('monitors') === 'Uptime,Domains,Alerts', 'Monitoring hub tabs');
+ resolve('/servers/prod');
+ assert(router.hubTabs === null, 'detail pages have no hub tabs');
+}
+
if (failures.length) {
console.error(`FAIL (${failures.length}):`);
for (const f of failures) console.error(' - ' + f);
diff --git a/cmd/teploy-dash/frontend-test/identity_harness.mjs b/cmd/teploy-dash/frontend-test/identity_harness.mjs
index d7046ef..ba5bddd 100644
--- a/cmd/teploy-dash/frontend-test/identity_harness.mjs
+++ b/cmd/teploy-dash/frontend-test/identity_harness.mjs
@@ -4,6 +4,7 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
+import { bridgeDialogs } from './dialog_bridge.mjs';
export function harness(pathname = '/operations/A') {
const root = process.env.DASH_FRONTEND_ROOT || join(dirname(fileURLToPath(import.meta.url)), '../frontend');
@@ -41,6 +42,7 @@ export function harness(pathname = '/operations/A') {
};
new Function(readFileSync(join(root, 'js/app.js'), 'utf8') + '\n;' + readFileSync(join(root, 'js/operations.js'), 'utf8'))();
init.forEach(fn => fn());
+ bridgeDialogs(stores);
return {
components, stores, sources, requests, toasts, effects,
readHTML: () => readFileSync(join(root, 'index.html'), 'utf8'),
diff --git a/cmd/teploy-dash/frontend-test/monitor_routes.test.mjs b/cmd/teploy-dash/frontend-test/monitor_routes.test.mjs
index dd18639..e83ede0 100644
--- a/cmd/teploy-dash/frontend-test/monitor_routes.test.mjs
+++ b/cmd/teploy-dash/frontend-test/monitor_routes.test.mjs
@@ -34,7 +34,7 @@ test('an unknown monitor id is an error state with retry, not a blank page',asyn
const p=h.page('monitorsPage');await p.init();
assert.equal(p.selectedMonitor,null);assert.match(p.detailError,/gone/);
const html=h.readHTML();
- assert.ok(html.includes(`x-if="$store.router.page === 'monitors' || $store.router.page === 'monitor-detail'"`),'monitor-detail route must render the monitors component');
+ assert.ok(html.includes(`x-if="$store.router.page === 'monitors' || $store.router.page === 'monitor-detail' || $store.router.page === 'alerts'"`),'monitor-detail route must render the monitors component');
assert.ok(html.includes('x-if="detailId && !selectedMonitor"'),'deep-link loading/error state must render');
p.destroy();
});
diff --git a/cmd/teploy-dash/frontend-test/nav_rework.test.mjs b/cmd/teploy-dash/frontend-test/nav_rework.test.mjs
new file mode 100644
index 0000000..061c46c
--- /dev/null
+++ b/cmd/teploy-dash/frontend-test/nav_rework.test.mjs
@@ -0,0 +1,178 @@
+// Navigation and UI rework (2026-11-11): static checks over the shipped
+// frontend that keep the fixed defects fixed.
+//
+// Run: node cmd/teploy-dash/frontend-test/nav_rework.test.mjs
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const read = (p) => readFileSync(join(here, '../frontend', p), 'utf8');
+const html = read('index.html');
+const js = read('js/app.js');
+const css = read('css/style.css');
+const failures = [];
+const assert = (c, m) => { if (!c) failures.push(m); };
+
+// Every CSS variable the frontend reads is defined.
+{
+ const used = new Set([...(html + js + css).matchAll(/var\((--[a-z0-9-]+)/g)].map((m) => m[1]));
+ const defined = new Set([...css.matchAll(/^\s*(--[a-z0-9-]+):/gm)].map((m) => m[1]));
+ for (const v of used) assert(defined.has(v), `CSS variable ${v} is used but never defined`);
+}
+
+// No owner-specific addresses or product names in a public product.
+for (const bad of [/100\.108\.\d+\.\d+/, /akiroo/i, /fylun/i, /:49152/]) {
+ assert(!bad.test(html) && !bad.test(js), `owner-specific text ${bad} in the frontend`);
+}
+
+// Components whose templates read `loading` declare it.
+for (const name of ['cleanupPage', 'siblingsPage']) {
+ const start = js.indexOf(`Alpine.data('${name}'`);
+ assert(start >= 0 && /loading: true/.test(js.slice(start, start + 300)), `${name} declares loading`);
+}
+
+// Reads that used to throw on a missing object are guarded.
+assert(!html.includes("x-text=\"'dismissed by ' + f.dismissed.actor\""), 'dismissed-by read is guarded');
+assert(!/:href="c\.doc\.dashboard_url"/.test(html), 'sibling dashboard_url read is guarded');
+
+// Checkboxes inside form fields are not stretched into text-input boxes.
+assert(/\.field input\[type="checkbox"\][^{]*\{[^}]*width: auto/.test(css), 'field checkboxes keep their natural size');
+
+// Labels: common intervals read as words; an ok cleanup audit says Audited.
+{
+ const listeners = [];
+ globalThis.document = { addEventListener: (t, fn) => { if (t === 'alpine:init') listeners.push(fn); }, getElementById: () => null, documentElement: { setAttribute() {}, getAttribute: () => 'dark' } };
+ globalThis.window = { addEventListener() {}, dispatchEvent() {} };
+ globalThis.localStorage = { getItem: () => null, setItem() {} };
+ globalThis.history = { pushState() {}, replaceState() {} };
+ globalThis.location = { pathname: '/', search: '' };
+ globalThis.CustomEvent = class {};
+ const comps = {}, stores = {};
+ globalThis.Alpine = { data: (n, f) => { comps[n] = f; }, store: (n, d) => { if (d !== undefined) stores[n] = d; return stores[n]; }, effect() {} };
+ new Function(js + '\n;globalThis.__fi = formatInterval;')();
+ listeners.forEach((fn) => fn());
+ const fi = globalThis.__fi;
+ assert(fi(1) === 'hourly' && fi(24) === 'daily' && fi(168) === 'weekly' && fi(6) === 'every 6 h' && fi(48) === 'every 2 days', 'formatInterval names common intervals');
+ assert(!html.includes("'every ' + t.interval_hours + 'h'"), 'restore tests use formatInterval');
+ const cleanup = comps.cleanupPage();
+ assert(cleanup.auditLabel('ok', { warning: 2 }) === 'Audited — 2 warnings', 'ok audit with warnings says Audited and the count');
+ assert(cleanup.auditLabel('ok', {}) === 'Audited — no warnings', 'ok audit without warnings');
+ assert(!js.includes('Clean bill'), 'no "Clean bill" label');
+ assert(html.includes('>History') && html.includes('id="app-panel-history"'), 'the app tab reads History');
+ assert(html.includes('Uptime '), 'monitors page title matches its tab');
+}
+
+// Home is the status page: needs attention, running now, servers, then the
+// shortcut grid; the protect banner is a row of the status, not a banner.
+{
+ const home = html.slice(html.indexOf("x-data=\"homepagePage\""), html.indexOf('Projects Page'));
+ for (const id of ['home-attention-title', 'home-running-title', 'home-servers-title']) assert(home.includes(`id="${id}"`), `home section ${id}`);
+ assert(home.includes('x-data="homeStatus"') && home.includes('>Shortcuts'), 'home keeps the shortcut grid below the status');
+ assert(home.indexOf('homeStatus') < home.indexOf('>Shortcuts'), 'status comes before shortcuts');
+ assert(!html.includes('x-data="protectBanner"'), 'the protect banner is a status row now');
+ assert(home.includes('unchecked()') && home.includes('Nothing needs attention'), 'a failed source is named, never read as nothing wrong');
+}
+
+// One confirm dialog: no browser confirm() or prompt() is left.
+{
+ const strip = (src) => src.replace(/\/\/.*$/gm, '').replace(//g, '');
+ for (const [name, src] of [['js/app.js', js], ['index.html', html], ['js/operations.js', read('js/operations.js')]]) {
+ assert(!/(^|[^\w.$])confirm\(/.test(strip(src)), `${name} still calls confirm()`);
+ assert(!/(^|[^\w.$])prompt\(/.test(strip(src)), `${name} still calls prompt()`);
+ }
+ assert(html.includes('aria-labelledby="confirm-dialog-title"') && html.includes('$store.dialog.canSubmit()'), 'the shared dialog is rendered once');
+ const general = html.slice(html.indexOf('id="app-panel-general"'), html.indexOf('id="app-panel-env"'));
+ const buttons = general.slice(general.indexOf('class="btn-group"'), general.indexOf('', general.indexOf('class="btn-group"')));
+ assert(!buttons.includes('removeApp()'), 'Remove is not beside Restart');
+ assert(general.includes('class="danger-zone"') && general.lastIndexOf('removeApp()') > general.indexOf('class="danger-zone"'), 'Remove sits in the Danger section');
+ for (const m of html.matchAll(/]*>×<\/button>/g)) assert(m[0].includes(':aria-label='), 'every x button is named: ' + m[0].slice(0, 80));
+ assert((html.match(/:aria-label="'Actions for ' \+/g) || []).length === 3, 'card action menus are named');
+
+ const listeners = [];
+ globalThis.document = { addEventListener: (t, fn) => { if (t === 'alpine:init') listeners.push(fn); }, getElementById: () => null, documentElement: { setAttribute() {}, getAttribute: () => 'dark' } };
+ const stores = {};
+ globalThis.Alpine = { data() {}, store: (n, d) => { if (d !== undefined) stores[n] = d; return stores[n]; }, effect() {} };
+ new Function(js + '\n;globalThis.__q = questionDialog; globalThis.__ask = askConfirm; globalThis.__form = askForm;')();
+ listeners.forEach((fn) => fn());
+ const q = globalThis.__q('Remove secret K?\n\nContainers keep it.');
+ assert(q.title === 'Remove secret K?' && q.message === 'Containers keep it.' && q.danger && q.confirmLabel === 'Remove', 'a question splits into title, message and a verb label');
+ const d = stores.dialog;
+ const typed = globalThis.__ask({ title: 'Remove server x?', typed: 'x' });
+ assert(!d.canSubmit(), 'a typed-name dialog starts disabled');
+ d.typedValue = 'y'; assert(!d.canSubmit(), 'the wrong name keeps it disabled');
+ d.typedValue = 'x'; d.submit();
+ const form = globalThis.__form({ title: 'Rename', fields: [{ key: 'name', label: 'Name', value: 'a', required: true }] });
+ d.values.name = 'b'; d.submit();
+ const cancelled = globalThis.__ask('Delete it?');
+ d.cancel();
+ Promise.all([typed, form, cancelled]).then(([t, f, c]) => {
+ assert(t === true && f && f.name === 'b' && c === false, 'dialogs resolve confirm, form values and cancel');
+ finish();
+ });
+}
+
+// One date helper: no locale date strings in templates; times go through
+// x-time or relTime with the exact time in a title.
+{
+ assert(!/toLocale(Date)?String\(\)/.test(html), 'templates format no dates by hand');
+ assert(js.includes("Alpine.directive('time'"), 'the x-time directive exists');
+ assert(!/new Date\(d\)\.toLocaleString\(\)/.test(js), 'components delegate formatDate to relTime');
+ const ageCopies = (js.match(/ageLabel\(seconds\) \{\n\s+return seconds === undefined \|\| seconds === null \? '(never|not checked)' : ageText\(seconds\);/g) || []).length;
+ assert(ageCopies === 4, 'every ageLabel delegates to ageText (' + ageCopies + ')');
+}
+
+// Monitor and operation dots show the current state.
+{
+ assert(js.includes('monitorState(m)') && js.includes("c.status === 'up' ? 'up' : 'down'"), 'the monitor dot reads the newest check');
+ assert(!/uptime_percent >= 99 \? 'green'/.test(js), 'the dot no longer derives from 24h uptime');
+ assert(!html.includes("running: op.status === 'running' || op.status === 'succeeded'"), 'a running operation is not painted like a succeeded one');
+}
+
+// Phone layout: wide tables stack, status cards stay two-up, tab strips hint.
+{
+ assert(/@media \(max-width: 640px\) \{\s*\.table-wrap > table\.stack-table \{ display: block;/.test(css), 'stack-table rows become blocks on phones');
+ for (const marker of ['What is wrong ', 'Every target ', '', 'Added by ']) {
+ const at = html.indexOf(marker);
+ assert(/= 4 && html.includes('class="hub-tabs" aria-label="Section pages" x-scroll-hint'), 'tab strips carry the scroll hint');
+ assert(js.includes("Alpine.directive('scroll-hint'") && css.includes('.more-right {'), 'the scroll hint fades the hidden side');
+}
+
+// App General: state beside the name, links out, Start or Stop by state,
+// minor facts on one line. Servers, domains and monitors link both ways.
+{
+ const page = html.slice(html.indexOf("x-data=\"appDetailPage\""), html.indexOf('id="app-panel-env"'));
+ assert(page.includes('appState().label') && page.includes('class="state-pill"'), 'the app state sits beside its name');
+ assert(page.includes(':href="domainURL()"') && page.includes("navigate('server-detail', { name: app.server })"), 'domain and server are links');
+ assert(page.includes("appState().cls !== 'stopped'") && page.includes("appState().cls !== 'running'"), 'Start or Stop by state');
+ for (const label of ['Port
', 'Locked
', 'Maintenance
']) assert(!page.includes(label), 'folded into the facts line: ' + label);
+ assert(page.includes('related.monitor') && page.includes('related.domain'), 'links to the app monitor and domain row');
+ const servers = html.slice(html.indexOf('x-data="serversPage"'), html.indexOf('x-data="serverDetailPage"'));
+ assert(servers.includes('cleanupLine(s)') && servers.includes('diskOf(s)') && servers.includes('s.appCount'), 'server cards show apps, disk and cleanup');
+ assert(html.includes('On this server '), 'server detail links to its apps, cleanup, backups and domains');
+ assert(html.includes("navigate('app-detail', { server: row.server, name: row.app })"), 'domain rows link to the app');
+ assert(html.includes("navigate('app-detail', { server: selectedMonitor.monitor.server, name: selectedMonitor.monitor.app })"), 'a monitor links to its app');
+}
+
+// Git sources: a table with credential state, detail above the form, delete
+// through the confirm dialog, help for "Provider name", and a link from the
+// app's Repository tab.
+{
+ const tab = html.slice(html.indexOf('x-data="sourcesPage"'), html.indexOf('', html.indexOf('x-data="sourcesPage"')));
+ assert(tab.includes('') && tab.includes('verdictLabel(src.credential_verdict)'), 'sources list as a table with readable credential states');
+ assert(tab.indexOf('class="card source-detail"') < tab.indexOf('class="card source-form"'), 'the selected source sits above the add form');
+ assert(tab.includes('@click="remove(src)"') && js.includes('await api.del(`/api/sources/${encodeURIComponent(src.id)}`)'), 'a source can be deleted');
+ assert(tab.includes('TEPLOY_DASH_SOURCE_PROVIDERS'), 'Provider name is explained');
+ assert((html.match(/@submit\.prevent="create\(\)" class="card source-form"/g) || []).length === 1 && !html.includes('>Add repository'), 'one add form, the old one is gone');
+ const repo = html.slice(html.indexOf('id="app-panel-repository"'), html.indexOf('id="app-panel-repository"') + 600);
+ assert(repo.includes("navigate('settings', { tab: 'git-sources' })"), 'the Repository tab links to Git sources');
+}
+
+function finish() {
+if (failures.length) { console.error(`FAIL (${failures.length}):`); failures.forEach((f) => console.error(' - ' + f)); process.exit(1); }
+console.log('nav_rework.test: all assertions passed');
+}
diff --git a/cmd/teploy-dash/frontend-test/resource_page.test.mjs b/cmd/teploy-dash/frontend-test/resource_page.test.mjs
index db66021..594b996 100644
--- a/cmd/teploy-dash/frontend-test/resource_page.test.mjs
+++ b/cmd/teploy-dash/frontend-test/resource_page.test.mjs
@@ -67,7 +67,7 @@ alpineInitListeners.forEach((fn) => fn());
page.app = { observed_at: minutesAgo(4), deployed_at: minutesAgo(90) };
const age = page.observedAge();
- assert(age === '4m ago', `observedAge = ${age}, want "4m ago"`);
+ assert(age === '4 min ago', `observedAge = ${age}, want "4 min ago"`);
assert(page.lastChangeWhen().length > 0, 'lastChangeWhen renders a timestamp');
page.app = { observed_at: '0001-01-01T00:00:00Z' };
@@ -120,7 +120,7 @@ alpineInitListeners.forEach((fn) => fn());
// And the templates render both surfaces.
const html = readFileSync(join(here, '../frontend/index.html'), 'utf8');
const monStart = html.indexOf('Monitors Page');
- const monEnd = html.indexOf('Restore Tests Page');
+ const monEnd = html.indexOf('Servers Page');
const monSection = html.slice(monStart, monEnd);
assert(monStart !== -1 && monEnd > monStart, 'monitors section not found');
assert(monSection.includes('deliveryWarns(m.delivery)'), 'monitor list renders the delivery warning chip');
diff --git a/cmd/teploy-dash/frontend-test/restore_toggle.test.mjs b/cmd/teploy-dash/frontend-test/restore_toggle.test.mjs
index 27b28d6..bd1975c 100644
--- a/cmd/teploy-dash/frontend-test/restore_toggle.test.mjs
+++ b/cmd/teploy-dash/frontend-test/restore_toggle.test.mjs
@@ -38,5 +38,5 @@ test('D09: real GET-shaped restore rows toggle both directions with only configu
}
assert.deepEqual(input, original, 'toggle must not mutate its GET input');
const html = readFileSync(fileURLToPath(new URL('../frontend/index.html', import.meta.url)), 'utf8');
- assert.ok(html.includes('toggleEnabled(t)'), 'restore cards invoke this method with their GET row');
+ assert.ok(html.includes('toggleEnabled(row.manual)'), 'restore cards invoke this method with their GET row');
});
diff --git a/cmd/teploy-dash/frontend-test/siblings_page.test.mjs b/cmd/teploy-dash/frontend-test/siblings_page.test.mjs
index 7acbe29..02291b8 100644
--- a/cmd/teploy-dash/frontend-test/siblings_page.test.mjs
+++ b/cmd/teploy-dash/frontend-test/siblings_page.test.mjs
@@ -29,12 +29,13 @@ const body = (extra = {}) => ({
...extra,
});
-test('the /siblings route renders the page and the nav names it', async () => {
+test('/siblings opens Settings > Integrations, which renders the page', async () => {
const h = harness('/siblings'); h.stores.router.restore();
- assert.equal(h.stores.router.page, 'siblings');
+ assert.equal(h.stores.router.page, 'settings');
+ assert.equal(h.stores.router.params.tab, 'integrations');
const html = h.readHTML();
- assert.ok(html.includes(`x-if="$store.router.page === 'siblings'"`));
- assert.ok(html.includes('>Siblings'));
+ assert.ok(html.includes(`x-if="tab === 'integrations'"`));
+ assert.ok(html.includes(`@click="setTab('integrations')">Integrations`));
assert.ok(html.includes('x-data="siblingsPage"'));
});
diff --git a/cmd/teploy-dash/frontend-test/sources_page.test.mjs b/cmd/teploy-dash/frontend-test/sources_page.test.mjs
index 3177904..294ea49 100644
--- a/cmd/teploy-dash/frontend-test/sources_page.test.mjs
+++ b/cmd/teploy-dash/frontend-test/sources_page.test.mjs
@@ -48,3 +48,18 @@ for(const kind of ['create','verify']) for(const phase of ['post','list','detail
await pending;assert.equal(p.webhookSecret,'');assert.equal(p.selected?.id,change==='destroy'?undefined:'new');
});
}
+test('deleting a source asks first, then sends DELETE and closes its detail', async () => {
+ const h = sourceHarness(), p = h.page('sourcesPage');
+ h.respond((url, options) => options.method === 'DELETE' ? { status: 204, ok: true, headers: new Headers(), text: async () => '' } : response(url === '/api/sources' ? [] : { id: url.split('/').at(-1) }));
+ await p.select('s1');
+ globalThis.confirm = () => false;
+ await p.remove({ id: 's1', clone_url: 'https://git.example.com/a.git' });
+ assert.equal(h.requests.filter(r => r.method === 'DELETE').length, 0, 'cancel sends nothing');
+ assert.equal(h.stores.dialog.opened.at(-1).danger, true);
+ globalThis.confirm = () => true;
+ await p.remove({ id: 's1', clone_url: 'https://git.example.com/a.git' });
+ assert.deepEqual(h.requests.filter(r => r.method === 'DELETE').map(r => r.url), ['/api/sources/s1']);
+ assert.equal(p.selected, null);
+ delete globalThis.confirm;
+ p.destroy();
+});
diff --git a/cmd/teploy-dash/frontend-test/tab_routes.test.mjs b/cmd/teploy-dash/frontend-test/tab_routes.test.mjs
new file mode 100644
index 0000000..c18dda8
--- /dev/null
+++ b/cmd/teploy-dash/frontend-test/tab_routes.test.mjs
@@ -0,0 +1,56 @@
+// Tabs are in the URL (?tab=): reload, links and Back land on the tab.
+//
+// Run: node cmd/teploy-dash/frontend-test/tab_routes.test.mjs
+import { harness, response } from './identity_harness.mjs';
+
+const failures = [];
+const assert = (c, m) => { if (!c) failures.push(m); };
+
+const h = harness('/');
+const replaced = [];
+globalThis.history.replaceState = (_, __, url) => { replaced.push(url); const [p, q] = url.split('?'); location.pathname = p; location.search = q ? '?' + q : ''; };
+const router = h.stores.router;
+const go = (path, search = '') => { location.pathname = path; location.search = search; router.restore(); };
+
+// The router reads ?tab= and writes it with replaceState, leaving the default out.
+go('/deployments/prod/web', '?tab=env');
+assert(router.page === 'app-detail' && router.params.tab === 'env', 'app ?tab=env is read');
+router.setTab('logs', 'general');
+assert(replaced.at(-1) === '/deployments/prod/web?tab=logs', 'setTab replaces the URL with the new tab: ' + replaced.at(-1));
+router.setTab('general', 'general');
+assert(replaced.at(-1) === '/deployments/prod/web' && !('tab' in router.params), 'the default tab is left out of the URL');
+
+// App detail: switchTab writes the URL; a URL tab drives the page.
+h.respond(() => response({}));
+go('/deployments/prod/web', '?tab=deploys');
+const app = h.page('appDetailPage');
+app.$dispatch = () => {};
+await app.init();
+await h.flush();
+assert(app.tab === 'deploys', 'app detail opens on the URL tab: ' + app.tab);
+await app.switchTab('env');
+assert(router.params.tab === 'env' && replaced.at(-1).endsWith('?tab=env'), 'app tab click writes the URL');
+
+// Server detail.
+go('/servers/prod', '?tab=proxy');
+const srv = h.page('serverDetailPage');
+await srv.init();
+await h.flush();
+assert(srv.tab === 'proxy', 'server detail opens on the URL tab: ' + srv.tab);
+await srv.switchTab('overview');
+assert(!('tab' in router.params), 'server overview leaves the tab out');
+
+// Settings.
+go('/settings', '?tab=links');
+const settings = h.page('settingsPage');
+const ready = settings.init();
+assert(settings.tab === 'links', 'settings opens on the URL tab');
+settings.setTab('account');
+assert(router.params.tab === 'account' && replaced.at(-1) === '/settings?tab=account', 'settings tab click writes the URL');
+go('/settings', '?tab=groups');
+await h.flush();
+assert(settings.tab === 'groups', 'settings follows the URL (Back, links)');
+await ready;
+
+if (failures.length) { console.error(`FAIL (${failures.length}):`); failures.forEach((f) => console.error(' - ' + f)); process.exit(1); }
+console.log('tab_routes.test: all assertions passed');
diff --git a/cmd/teploy-dash/frontend/css/style.css b/cmd/teploy-dash/frontend/css/style.css
index 2e71a4f..73eab00 100644
--- a/cmd/teploy-dash/frontend/css/style.css
+++ b/cmd/teploy-dash/frontend/css/style.css
@@ -20,6 +20,11 @@
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
+ --success: var(--green);
+ --warning: var(--yellow);
+ --danger: var(--red);
+ --success-bg: rgba(63, 185, 80, 0.15);
+ --bg-inset: rgba(139, 148, 158, 0.15);
--font: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--mono: var(--font);
--radius: 8px;
@@ -163,6 +168,7 @@ a.nav-link:hover { background: var(--bg-card); }
.form-group, .field { margin-bottom: 14px; }
.form-label, .field label { display: block; margin-bottom: 5px; color: var(--text-muted); font-size: 12px; font-weight: 500; }
.form-input, .field input, .field select, .search-input, .log-controls input, .log-controls select { width: 100%; min-height: 34px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; outline: none; background: var(--bg-input); color: var(--text); font-size: 13px; }
+.field input[type="checkbox"], .field input[type="radio"], .form-input[type="checkbox"] { width: auto; min-height: 0; padding: 0; }
.form-input::placeholder, .field input::placeholder, .search-input::placeholder { color: var(--text-muted); opacity: .7; }
.form-input:focus, .field input:focus, .field select:focus, .search-input:focus, .log-controls input:focus, .log-controls select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
@@ -186,13 +192,20 @@ a.nav-link:hover { background: var(--bg-card); }
.status-value { margin-top: 4px; font-size: 16px; font-weight: 600; }
.status-value.mono { font-family: var(--mono); font-size: 13px; }
.stat-value { margin-bottom: 2px; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
-.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
+.table-wrap { position: relative; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: 12px; font-weight: 500; text-align: left; }
td { padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-secondary); vertical-align: top; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--bg-card-hover); }
-.tabs { display: flex; margin: -4px 0 22px; gap: 4px; border-bottom: 1px solid var(--border); }
+.tabs { display: flex; margin: -4px 0 22px; gap: 4px; border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; }
+.tabs::-webkit-scrollbar { display: none; }
+.tab { flex: 0 0 auto; white-space: nowrap; }
+/* Scroll hint (x-scroll-hint): a tab strip that runs past its edge fades
+ out on that side, so hidden tabs read as "more this way". */
+.more-right { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent); }
+.more-left { -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 48px), transparent); mask-image: linear-gradient(to left, #000 calc(100% - 48px), transparent); }
+.more-left.more-right { -webkit-mask-image: linear-gradient(to right, transparent, #000 48px, #000 calc(100% - 48px), transparent); mask-image: linear-gradient(to right, transparent, #000 48px, #000 calc(100% - 48px), transparent); }
.tab { padding: 7px 10px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 12px; }
.tab:hover { color: var(--text); }
.tab.active { border-bottom-color: var(--accent); color: var(--text); }
@@ -203,6 +216,36 @@ tbody tr:hover { background: var(--bg-card-hover); }
.status-dot.stopped, .status-dot.offline, .dot.red, .ws-indicator.disconnected { background: var(--red); }
.status-dot.unknown, .dot.yellow { background: var(--yellow); }
.dot.gray { background: var(--text-muted); }
+.dot.blue, .status-dot.active { background: var(--accent); }
+.status-dot.active { animation: dot-pulse 1.4s ease-in-out infinite; }
+.status-dot.idle { background: var(--text-muted); }
+.dot.pulse { animation: dot-pulse 1.4s ease-in-out infinite; }
+@keyframes dot-pulse { 50% { opacity: .35; } }
+.text-warn { color: var(--yellow); }
+
+/* Home status: rows that link to the page that owns them. */
+.home-status { display: flex; margin-bottom: 28px; flex-direction: column; gap: 22px; }
+.home-section-head { display: flex; margin-bottom: 8px; align-items: baseline; justify-content: space-between; gap: 12px; }
+.home-shortcuts-head { margin-bottom: 12px; align-items: center; }
+.home-asof, .home-more { font-size: 12px; }
+.home-more { color: var(--text-muted); text-decoration: none; }
+.home-more:hover { color: var(--accent); }
+.status-list { display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
+.status-row { display: flex; min-height: 44px; padding: 9px 14px; align-items: center; gap: 12px; color: var(--text); text-decoration: none; }
+.status-row + .status-row { border-top: 1px solid var(--border); }
+a.status-row:hover { background: var(--bg-card-hover); }
+.status-row-main { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 1px; }
+.status-row-title { font-size: 13px; overflow-wrap: anywhere; }
+.status-row-detail { color: var(--text-muted); font-size: 12px; overflow-wrap: anywhere; }
+.status-row-meta { flex-shrink: 0; color: var(--text-muted); font-size: 12px; white-space: nowrap; }
+.status-row-action { flex-shrink: 0; color: var(--accent); font-size: 12px; white-space: nowrap; }
+.status-row-facts { display: flex; flex-shrink: 0; gap: 14px; color: var(--text-secondary); font-size: 12px; white-space: nowrap; }
+@media (prefers-reduced-motion: reduce) { .dot.pulse, .status-dot.active { animation: none; } }
+@media (max-width: 640px) {
+ .status-row { flex-wrap: wrap; row-gap: 4px; }
+ .status-row-main { flex-basis: calc(100% - 20px); }
+ .status-row-meta, .status-row-action, .status-row-facts { margin-left: 19px; }
+}
.monitor-list { display: flex; flex-direction: column; gap: 8px; }
.monitor-card { display: block; color: inherit; text-decoration: none; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); cursor: pointer; }
.monitor-card:hover { border-color: var(--text-muted); }
@@ -258,6 +301,11 @@ tbody tr:hover { background: var(--bg-card-hover); }
.dialog { width: 100%; max-width: 470px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
.dialog h2 { margin-bottom: 18px; font-size: 16px; font-weight: 600; }
.dialog-actions { display: flex; margin-top: 20px; justify-content: flex-end; gap: 8px; }
+.danger-zone { margin-top: 32px; }
+.danger-zone .section-title { margin-bottom: 8px; color: var(--red); }
+.danger-row { display: flex; padding: 14px 16px; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; border: 1px solid var(--red); border-radius: var(--radius); background: var(--bg-card); }
+.danger-row .card-meta { max-width: 60ch; }
+.dialog-message { margin-bottom: 14px; color: var(--text-secondary); white-space: pre-line; overflow-wrap: anywhere; }
.toast-container { position: fixed; right: 20px; bottom: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; }
.toast { min-width: 220px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); color: var(--text); font-size: 12px; }
.toast.success { border-color: var(--green); color: var(--green); }
@@ -277,6 +325,15 @@ tbody tr:hover { background: var(--bg-card-hover); }
background: var(--bg-card); color: var(--yellow);
font-size: 12px; word-break: break-word;
}
+/* A neutral setting line (not a warning): same shape as the stale banner,
+ muted colours. */
+.info-banner {
+ display: flex; margin-bottom: 16px; padding: 8px 14px; gap: 12px;
+ align-items: center; justify-content: space-between; flex-wrap: wrap;
+ border: 1px solid var(--border); border-radius: var(--radius);
+ background: var(--bg-card); color: var(--text-secondary); font-size: 12px;
+}
+.backups-page .card { margin-bottom: 12px; cursor: default; }
.empty-icon { margin-bottom: 12px; opacity: .4; }
.loading, .spinner { display: none; }
.back-link { display: inline-flex; margin-bottom: 14px; align-items: center; gap: 6px; color: var(--text-muted); font-size: 12px; text-decoration: none; }
@@ -294,15 +351,14 @@ tbody tr:hover { background: var(--bg-card-hover); }
.progress-fill.high { background: var(--red); }
/* Sources page: plain labelled fields, stacked. */
-.source-form, .source-detail { margin-top: 16px; cursor: default; }
+.source-form, .source-detail { margin: 0 0 16px; padding: 16px; cursor: default; }
.source-form:hover, .source-detail:hover { border-color: var(--border); }
.source-form .section-title { margin-bottom: 12px; }
-.source-form .field { display: block; color: var(--text-muted); font-size: 12px; font-weight: 500; }
-.source-form .field input, .source-form .field select { display: block; margin-top: 5px; }
-.source-card { display: flex; margin-top: 8px; min-height: 0; flex-wrap: wrap; align-items: center; gap: 8px; }
-.source-detail h2 { margin-bottom: 8px; font-size: 14px; overflow-wrap: anywhere; }
-.source-detail h3 { margin: 14px 0 6px; font-size: 12px; }
-.source-detail p { margin-bottom: 6px; color: var(--text-secondary); font-size: 12px; overflow-wrap: anywhere; }
+.source-form .card-meta { margin-top: 4px; }
+.source-detail h3 { font-size: 14px; overflow-wrap: anywhere; }
+.source-detail .section-title { font-size: 12px; }
+.source-url { display: block; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-input); font-size: 12px; overflow-wrap: anywhere; }
+tr.row-selected td { background: var(--accent-soft); }
code { overflow-wrap: anywhere; }
/* Pinned links inside the phone menu (see the 640px block). */
@@ -318,17 +374,19 @@ code { overflow-wrap: anywhere; }
/* Nav toggle: only shown once the primary nav collapses into a panel. */
.nav-toggle { display: none; }
-/* Responsive header: the thirteen destinations need about 1275px beside the
- brand. Up to 1400px they tighten to about 1190px; below 1240px the nav
- collapses into a drop-down panel opened from the header toggle. Raise both
- when a destination is added. */
-@media (max-width: 1400px) {
+/* Responsive header (measured 2026-11-11): brand + the seven destinations
+ need 767px at full spacing and 695px tightened; four pinned icons add
+ about 160px (857px tightened), and further pins scroll sideways in their
+ strip. Below 1100px the nav tightens; below 900px it collapses into a
+ drop-down panel opened from the header toggle. Re-measure both when a
+ destination is added. */
+@media (max-width: 1099px) {
.navbar { gap: 14px; }
.navbar-nav { gap: 2px; }
.navbar-nav a { padding: 5px 8px; }
}
-@media (max-width: 1239px) {
+@media (max-width: 899px) {
.navbar {
min-height: 56px; gap: 10px;
padding: max(8px, env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) 8px max(14px, env(safe-area-inset-left));
@@ -453,7 +511,7 @@ code { overflow-wrap: anywhere; }
}
@media (max-width: 400px) {
- .stats-grid, .status-grid { grid-template-columns: minmax(0, 1fr); }
+ .stats-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (prefers-reduced-motion: reduce) {
@@ -504,6 +562,49 @@ code { overflow-wrap: anywhere; }
.product-switcher a.ps-item:hover { background: var(--bg-card-hover); }
.product-switcher .ps-item.current { color: var(--text-muted); cursor: default; }
+/* Small action menus (deploy entry, card actions). */
+.menu-wrap { position: relative; display: inline-flex; }
+.menu {
+ position: absolute; right: 0; top: 100%; margin-top: 5px; z-index: 60;
+ display: flex; min-width: 220px; flex-direction: column; padding: 4px;
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
+ box-shadow: 0 6px 24px rgba(1, 4, 9, .4);
+}
+.menu-item {
+ display: flex; padding: 8px 10px; flex-direction: column; align-items: flex-start; gap: 2px;
+ border: 0; border-radius: 6px; background: none; color: var(--text); font-size: 13px; text-align: left; cursor: pointer;
+}
+.menu-item:hover, .menu-item:focus-visible { background: var(--bg-card-hover); }
+.menu-item.danger { color: var(--red); }
+.card-menu { position: absolute; top: 6px; right: 6px; }
+.card-menu-btn { width: 28px; height: 28px; color: var(--text-muted); }
+.card-menu .menu { min-width: 180px; }
+.menu-note { color: var(--text-muted); font-size: 11px; }
+
+.btn-row { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+.row-actions { text-align: right; white-space: nowrap; }
+
+/* App header: state beside the name, server and domain as links. */
+.app-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.state-pill { display: inline-flex; padding: 2px 9px; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; color: var(--text-secondary); font-size: 12px; }
+.app-where a, .app-related a, .card-link, .quiet-link { color: var(--accent); text-decoration: none; }
+.app-where a:hover, .app-related a:hover, .card-link:hover, .quiet-link:hover { text-decoration: underline; }
+.app-where .mono { overflow-wrap: anywhere; }
+.app-facts { display: flex; margin: -12px 0 14px; flex-wrap: wrap; gap: 6px 18px; color: var(--text-secondary); font-size: 12px; }
+.app-facts a { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); text-decoration: none; }
+.app-related { margin-top: 0; }
+.section .app-facts { margin: 0 0 12px; }
+.card-facts { display: flex; margin-top: 6px; gap: 12px; color: var(--text-secondary); font-size: 12px; }
+.card-link { display: inline-block; margin-top: 6px; font-size: 12px; }
+
+/* Hub tabs: the page-level tab strip of Apps, Servers and Monitoring. */
+.hub-tabs { display: flex; margin: -8px 0 22px; gap: 4px; border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; }
+.hub-tabs::-webkit-scrollbar { display: none; }
+.hub-tab { padding: 8px 12px; margin-bottom: -1px; border-bottom: 2px solid transparent; color: var(--text-muted); font-size: 13px; text-decoration: none; white-space: nowrap; }
+.hub-tab:hover { color: var(--text); }
+.hub-tab.active { border-bottom-color: var(--accent); color: var(--text); font-weight: 600; }
+.subpage-title { font-size: 16px; font-weight: 600; }
+
/* ── D03 onboarding preflight panel (deploy-form host readiness) ── */
.preflight-panel {
margin-top: 10px; padding: 12px 14px;
@@ -532,3 +633,28 @@ code { overflow-wrap: anywhere; }
.preflight-check.check-fail.sev-warning .check-result { color: var(--yellow); }
.preflight-check.check-unknown { border-left-color: var(--yellow); }
.preflight-check.check-unknown .check-result { color: var(--yellow); }
+
+/* Phone tables (2026-11 rework): a .stack-table becomes one block per row.
+ The lead cell (what the row is) and the problem cell (what is wrong) come
+ first; other cells follow with their column name; .stack-inline cells sit
+ side by side; an empty problem cell is dropped. */
+@media (max-width: 640px) {
+ .table-wrap > table.stack-table { display: block; min-width: 0; }
+ .stack-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+ .stack-table tbody { display: block; }
+ .stack-table tr { display: flex; padding: 12px 14px; flex-wrap: wrap; gap: 4px 14px; border-top: 1px solid var(--border); }
+ .stack-table tbody tr:first-child { border-top: 0; }
+ .table-wrap > table.stack-table td, .table-wrap > table.stack-table td:first-child {
+ position: static; display: block; padding: 0; flex: 1 1 100%; min-width: 0;
+ min-width: 0; max-width: none; border: 0; box-shadow: none; background: none; white-space: normal; overflow-wrap: anywhere;
+ }
+ .table-wrap > table.stack-table tbody tr:hover td:first-child { background: none; }
+ .stack-table td[data-label]::before { content: attr(data-label) " "; color: var(--text-muted); font-size: 11px; }
+ .table-wrap > table.stack-table td.stack-inline { flex: 0 0 auto; }
+ .stack-table td.stack-lead { order: -2; font-weight: 600; }
+ .stack-table td.stack-lead div { font-weight: 400; }
+ .stack-table td.stack-problem { order: -1; color: var(--text); }
+ .stack-table td.stack-empty { display: none; }
+ .table-wrap > table.stack-table td.row-actions { text-align: left; }
+ .stack-table .btn-row { justify-content: flex-start; }
+}
\ No newline at end of file
diff --git a/cmd/teploy-dash/frontend/index.html b/cmd/teploy-dash/frontend/index.html
index 1cf74b7..ad876af 100644
--- a/cmd/teploy-dash/frontend/index.html
+++ b/cmd/teploy-dash/frontend/index.html
@@ -38,11 +38,11 @@
-
+