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
23 changes: 23 additions & 0 deletions AUDIT_OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion cmd/teploy-dash/frontend-test/async_states.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]) {
Expand Down
34 changes: 20 additions & 14 deletions cmd/teploy-dash/frontend-test/backup_setup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand Down
13 changes: 10 additions & 3 deletions cmd/teploy-dash/frontend-test/cleanup_page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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</a>'));
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()=>{
Expand Down
4 changes: 3 additions & 1 deletion cmd/teploy-dash/frontend-test/config_home.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion cmd/teploy-dash/frontend-test/db_actions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
};

Expand Down
29 changes: 29 additions & 0 deletions cmd/teploy-dash/frontend-test/dialog_bridge.mjs
Original file line number Diff line number Diff line change
@@ -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);
};
}
3 changes: 2 additions & 1 deletion cmd/teploy-dash/frontend-test/domains_page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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</a>'));
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"'));
});

Expand Down
10 changes: 7 additions & 3 deletions cmd/teploy-dash/frontend-test/git_source_card.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion cmd/teploy-dash/frontend-test/group_identity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
16 changes: 10 additions & 6 deletions cmd/teploy-dash/frontend-test/header_layout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<li><a /g) || []).length === 13, 'thirteen destinations: re-measure the collapse width when this changes');
// The nav collapses before it can overflow. Measured 2026-11-11: brand + the
// seven destinations need 695px tightened, 857px with four pinned icons (more
// pins scroll in their strip), so the toggle takes over below 900px and the
// nav tightens below 1100px.
const collapse = css.match(/@media \(max-width: (\d+)px\) \{\s*\.navbar \{\s*min-height[^}]*\}\s*\.nav-toggle \{ display: flex; \}/);
assert(collapse && Number(collapse[1]) === 899, 'the nav collapses to the toggle below 900px (measured)');
const tighten = css.match(/@media \(max-width: (\d+)px\) \{\s*\.navbar \{ gap: 14px; \}/);
assert(tighten && Number(tighten[1]) === 1099, 'the nav tightens below 1100px');
const items = [...menu.matchAll(/<li><a [^>]*>([^<]+)<\/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 = [];
Expand Down
Loading
Loading