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 @@ - +