diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d05a7af..f5157df 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,6 +18,15 @@ Issues and pull requests are welcome. Before opening one:
`docker/t3-client/setup-bridge.js`, and prints a pairing link.
`scripts/setup-bridge-audit.js` checks those pages in a browser, through the
same router.
+ `node scripts/console-screens.js` photographs every setup page in light and
+ dark, on a phone, a tablet, a desktop and inside T3 Code, in three states:
+ set up, first start, and everything needing attention at once. Its data
+ comes from `scripts/fixtures/console`, so the pictures do not depend on the
+ machine, and nothing it presses reaches the server. Screenshots go to
+ `/tmp/t3-console-screens`; `--routes`, `--sizes`, `--themes` and
+ `--scenarios` narrow a run. Attach the pages you changed to the pull request.
+ `node scripts/ui-audit.js http://127.0.0.1:23780/__setup/ dev` measures the
+ same pages for clipped text, uneven buttons and misaligned glyphs.
`node scripts/storage-ui-audit.js http://127.0.0.1:23780/__setup/ dev`
checks storage layouts and keyboard flows in light/dark and embedded modes.
Its storage data and mutations are intercepted, so it cannot clean a real
diff --git a/docker/setup/app.js b/docker/setup/app.js
index 2211e30..9b7d82c 100644
--- a/docker/setup/app.js
+++ b/docker/setup/app.js
@@ -7,16 +7,17 @@
// because the corrupted result still parses. Kept as a real file, nothing
// rewrites it between here and the browser.
//
-// Four scripts share one scope, in this order: client/base.js (BASE, where the
-// API lives), design/ui.js (window.T3C: icons and copy), client/model.js
+// Five scripts share one scope, in this order: client/base.js (BASE, where the
+// API lives), design/ui.js (window.T3C: icons and copy), client/routes.js
+// (T3Routes: the table of pages), client/model.js
// (T3Model: what every row, badge and step should say, as pure functions) and
// client/kit.js (Kit: markup, patching, overlays, toasts, keys). This file is
// the console on top of them:
//
// data /status every 15s (3s while work runs), /ports every 4s, paused
// while the tab is hidden
-// routes #overview #devices #agents #toolchains #ports #environment #more
-// pages one renderer per route, markup in, Kit.patch out
+// routes one per page in client/routes.js, plus #more on a phone
+// pages one renderer per route, its head first, markup in, Kit.patch out
// commands every button carries data-cmd; the palette runs the same table
// flows pairing, sign-in, API keys, versions, confirmations
//
@@ -91,7 +92,6 @@
portsAt: 0,
providers: null,
route: 'overview',
- agentFilter: 'all',
toolFilter: '',
};
// What the page knows that /status cannot: see the header comment.
@@ -594,9 +594,9 @@
const versionLabel = (row) => {
if (!row.version) return '';
if (row.versionTo) return html`${row.version} → ${row.versionTo}`;
- // While work runs the row shows its progress instead.
- const glyph = row.updateAvailable && row.state !== 'running' && row.state !== 'queued';
- return html`${row.version}${glyph ? html` ${icon('circle-arrow-up')}` : ''}`;
+ // A newer release says where it goes; while work runs the row shows its progress instead.
+ const next = row.updateAvailable && row.state !== 'running' && row.state !== 'queued';
+ return html`${row.version}${next ? html` →${row.latest}` : ''}`;
};
const progressBar = (p) => html`
`;
const phaseText = (p, kind) => {
@@ -612,9 +612,11 @@
const variant = a.variant === 'primary' && opts.primary === false ? null : a.variant;
return html``;
};
+ // A row without a menu keeps its place, so every row's one action lines up.
+ const menuSlot = () => html``;
const menuButton = (row) => row.menu.length
? html``
- : '';
+ : menuSlot();
/** An agent or toolchain row: tile, name and version, one status line, one verb, a menu. */
const resourceRow = (row, opts = {}) => {
@@ -622,7 +624,7 @@
const noticeError = M.rowError(row, 'notice');
const detail = (source) => html` `;
return html`
-
${body}
`;
+ /**
+ * The top of every page: its title, one line saying what it is for with a
+ * link to the guide that explains the rest, and the page's main action.
+ * Pages carry no explanation tables of their own; the guide is one press away.
+ */
+ const learnMore = (doc) => doc ? html`Learn more (opens the guide in a new tab)` : '';
+ const pageHead = (route, { title, sub, actions, doc } = {}) => {
+ const page = T3Routes.page(route) || {};
+ const line = sub === undefined ? page.summary : sub;
+ const guide = doc === undefined ? page.doc : doc;
+ return html`
+
+
` : ''}
+ `;
+ };
const muted = (text) => html`${text}`;
// An empty state's icon, on its card with two more fanned out behind it.
const emptyIcon = (name, cls) => html`${icon(name, cls)}`;
@@ -670,33 +691,34 @@
const skeletonGroup = (rows) => html`
${Array.from({ length: rows }, (_, i) => html`
`)}
`;
// ------------------------------------------------------- page notices --
- /** What could not be read, or is not current, above the page it affects. */
+ /**
+ * What could not be read, above the page it affects. Only failures are
+ * said: a slow read, or one answered from the last check while a fresh one
+ * runs, fills in by itself and is no news to anyone.
+ */
const pageNotices = (route) => {
const s = state.status;
const out = [];
if (state.statusError) {
- out.push(notice('danger', 'circle-alert', 'Could not read status',
+ out.push(notice('danger', 'circle-alert', 'Couldn’t reach the setup service',
html`From ${BASE + '/status'}: ${state.statusError}.${BASE ? '' : html` If this page is served under a path prefix, set T3_SETUP_BASE_PATH.`}`,
html``));
}
if (!s) return out;
+ // What the first start is still installing, on every page but Overview,
+ // whose checklist and activity already say it.
+ const banner = M.setupBanner(s);
+ if (banner && route !== 'overview') {
+ out.push(html`
`);
+ }
// A part that could not be read says so, instead of rendering as "none".
if ((s.degraded || []).length) {
- out.push(notice('warn', 'triangle-alert', 'Some of this is unreadable right now',
- 'Could not read ' + M.listOf(s.degraded.map((d) => d.what)) + '. Try refreshing in a moment.'));
+ out.push(notice('warn', 'triangle-alert', 'Some details couldn’t be read',
+ 'Couldn’t read ' + M.listOf(s.degraded.map((d) => d.what)) + '. The console tries again on its own.'));
}
- if (route === 'devices' || route === 'overview') {
- const lists = Object.values(s.authLists || {});
- const unavailable = lists.some((entry) => !entry.available);
- const failed = lists.some((entry) => entry.error);
- if (unavailable || failed) {
- out.push(notice(failed ? 'warn' : null, failed ? 'triangle-alert' : 'refresh-cw',
- failed ? 'Device details could not refresh' : 'Reading device details',
- failed ? 'T3 Code has not returned a fresh list. Available details are from the last successful check; missing details are unavailable. The console retries automatically.'
- : 'Pairing links and paired devices are loading in the background. This page updates automatically.'));
- } else if (lists.some((entry) => entry.stale)) {
- out.push(notice(null, 'history', null, 'Device details are from the last successful check while a fresh read finishes.'));
- }
+ if ((route === 'devices' || route === 'overview') && Object.values(s.authLists || {}).some((entry) => entry.error)) {
+ out.push(notice('warn', 'triangle-alert', 'Couldn’t refresh the device list',
+ 'Showing devices from the last check. The console keeps trying.'));
}
const setup = s.setup;
if (setup && (route === 'overview' || route === 'agents' || route === 'toolchains' || route === 'sourcecontrol')) {
@@ -705,21 +727,15 @@
// stops after three failures in a row, which is nearly always no network.
const failed = M.setupFailures(s);
if (setup.state !== 'running' && failed.length) {
- const title = failed.length === 1 ? 'First start could not install ' + (failed[0].name || failed[0].id)
+ const title = failed.length === 1 ? 'First start couldn’t install ' + (failed[0].name || failed[0].id)
: 'First start left ' + M.plural(failed.length, 'install') + ' unfinished';
out.push(notice('warn', 'triangle-alert', title,
- html`Try again from ${failed.length === 1 ? 'the row' : 'each row'}. `));
+ html`Try again from ${failed.length === 1 ? 'its row' : 'each row'}. `));
} else if (setup.state === 'interrupted') {
const done = items.filter((i) => i.state === 'done').length;
out.push(notice(null, 'history', 'First start stopped part way', done + ' of ' + items.length + ' installed. It carries on the next time the container starts.'));
}
}
- // The sign-in state below is the last definite answer, not a fresh probe:
- // offline, the refresh exceeds its budget and warms the next poll instead.
- const cache = route === 'agents' ? s.harnessCache : route === 'sourcecontrol' ? s.sourceControlCache : null;
- if (cache && cache.stale && (cache.source === 'cache' || cache.source === 'cheap')) {
- out.push(notice(null, 'history', null, 'Sign-in state is from the last check while a fresh one finishes. It refreshes on the next poll.'));
- }
return out;
};
@@ -737,7 +753,7 @@
`);
};
- const deviceRow = (d, { revoke, fresh } = {}) => html`
+ /** A paired device: what it is, when it was last here, and (on Devices) its menu. */
+ const deviceRow = (d, { manage, fresh } = {}) => html`
`;
const recentSection = (s, n) => {
@@ -837,13 +853,18 @@
const overviewPage = (s, n) => {
const r = M.readiness(s, ui);
- const needs = M.needsYou(s, state.ports, ui);
+ // Until setup is done, signing agents in is its last step: said there, not twice.
+ const needs = M.needsYou(s, state.ports, ui).filter((item) => r.ready || item.state !== 'signin');
const devices = M.deviceRows(s.sessions, n);
// A link to the one page they all live on; from several, each row's own
// action is the way in.
const pages = new Set(needs.map(needsPage));
const [page] = pages;
+ const actions = r.ready
+ ? html``
+ : EMBED ? '' : html`${icon('external-link')}Open T3 Code`;
return html`
+ ${pageHead('overview', { actions })}
${pageNotices('overview')}
${s.storage && s.storage.disk && s.storage.disk.usedPercent >= 80 ? notice(s.storage.disk.usedPercent >= 90 ? 'danger' : 'warn', 'server',
s.storage.disk.usedPercent >= 90 ? 'Disk space is nearly full' : 'Disk space is running low',
@@ -966,7 +987,7 @@
Expires in
${TTLS.map(([value, label]) => html``)}
-
+
${noUrl ? html`
${notice('warn', 'triangle-alert', 'Set the public URL first', 'A pairing link points at it: the address your phone or browser uses to reach this server.', urlButtons(s, 'tc-btn--sm', 'tc-choice'))}
` : ''}
${pair.error && !noUrl ? html`
${pair.error}
` : ''}
`;
@@ -1007,7 +1028,7 @@
${m.qr ? html`
${qrSvg(m.qr)}
Scan with the T3 Code app
` : ''}
-
Listening for the device… this page updates on its own.
+
Listening for the device… this page updates on its own.
${emptyIcon('smartphone')}
No devices paired yetCreate a link above, then scan it with the phone you want to drive this server from.
- or run t3-pair in the container
-
`) : ''}
- ${section('anywhere-title', 'From anywhere', connectCard(s))}
- ${devices.length ? '' : notice(null, 'history', 'Sessions last 30 days', 'After that the device asks to pair again, which takes a few seconds here. Threads, projects and agent sign-ins are kept on the volume.')}`;
+ ${section('anywhere-title', 'From anywhere', connectCard(s))}`;
};
- /** T3 Connect on Devices: where it stands, and the one thing to do next. */
+ /** T3 Connect: where it stands, the one thing to do next, and a menu for the rest. */
const connectCard = (s) => {
const view = M.connectView(s);
+ const menu = view.menu || [];
return html`
No added tool matches “${state.toolFilter.trim()}”.
`
: toolsEmpty()}
-
${icon('terminal', 'tc-icon--sm')}Anything in mise’s registry, or a backend spec like npm:prettier. Tools added with mise use -g in a terminal show up here too.
+ ${added.length ? html`
${icon('info', 'tc-icon--sm')}Tools added with mise use -g in a terminal show up here too.
`,
- { actions: muted('Updated by pulling a new image') })}
- ${notice(null, 'shield-check', 'Exact versions stay pinned', html`mise records exact versions, for added tools too, and never falls back to another tool on PATH. Updates happen only when you press Update, run mise use, or opt in to automatic updates.`)}`;
+
Also in the image: ${M.listOf([...IN_THE_IMAGE, ...(browserImage ? ['headless Chromium'] : [])])}.
`;
};
// A first look at what can be added: one press opens the sheet on that tool.
@@ -1194,7 +1211,7 @@
const toolsEmpty = () => html`
${emptyIcon('plus')}
Add any tool mise can install
- Cloud CLIs, other languages, linters: about a thousand tools, each pinned to an exact version like the toolchains above.
+ Cloud CLIs, other languages, linters: about a thousand tools, each pinned to an exact version like the toolchains above. Or name one with a backend, like npm:prettier.
${icon('info', 'tc-icon--sm')}T3 Code opens pull requests through these. Bitbucket needs none: add its token in T3 Code under Settings → Source Control.
GitHub and Azure DevOps: approve a code on github.com or Microsoft’s page, from any device. There is no token to make.
-
Tokens
GitLab, Forgejo, Gitea, and GitHub Enterprise: a token you make on the server. The CLI gets it on stdin or in its environment, never on a command line, and it only counts once the server accepts it.
-
Credentials
On the state volume at ${(s.paths && s.paths.volume) || '/home/t3'}. They survive a recreate.
-
From a shell
t3-harness source-control
-
`)}`;
+
+ ${rows.length ? html`
${rows.map((row) => resourceRow(row))}
`
+ : html`
Couldn’t read the source control CLIs just now. The console tries again on its own.
`}
+
${icon('info', 'tc-icon--sm')}Bitbucket needs no CLI: add its token in T3 Code under Settings → Source Control.
+
`;
};
// ---------------------------------------------------------------- ports --
const portRow = (p) => {
- const tileEl = html`${p.port}`;
+ // The number is the row's name; the tile says whether it is out on the internet.
+ // A link or an error needs the row's width, so on a phone its buttons go under it.
+ const tileEl = html`${icon(p.state === 'open' || p.state === 'starting' ? 'globe' : 'ethernet-port')}`;
const proc = p.process ? html`${p.process}` : null;
const where = p.address && p.address !== '0.0.0.0' && p.address !== '*' && p.address !== '::' ? html`${p.address}` : null;
const joinParts = (parts) => parts.filter(Boolean).map((x, i) => i ? html` · ${x}` : x);
@@ -1235,57 +1247,53 @@
if (p.state === 'open') {
title = html`${title}Published`;
status = html`${p.url}`;
- actions = html`
- ${copyButton(p.url, 'Copy URL for port ' + p.port, { iconOnly: true, ghost: false, size: 'tc-btn--sm' })}
- `;
- // On a phone the QR and the URL are the row; on a desktop, one press away.
- panel = html`
+ // The link is what a published port is for: one press copies it, and
+ // the QR code, opening it and stopping it are in its menu.
+ actions = html`${copyButton(p.url, 'Copy link', { ghost: false, size: 'tc-btn--sm' })}`;
+ // The QR code, for another device, once asked for.
+ panel = p.expanded ? html`
${p.qr ? html`
${qrSvg(p.qr)}
` : ''}
-
Scan to open port ${p.port} on another device.${copyField(p.url.replace(/^https:\/\//, ''), { actions: copyButton(p.url, 'Copy URL for port ' + p.port, { iconOnly: true, size: 'tc-btn--sm' }) })}
-
`;
+
Scan to open port ${p.port} on another device.
+
` : '';
} else if (p.state === 'starting') {
title = html`${title}Publishing`;
status = html`${joinParts([proc, 'waiting for a public hostname…'])}${progressBar(null)}`;
- actions = html``;
+ actions = html`${menuSlot()}`;
} else if (p.state === 'failed') {
title = html`${title}Failed`;
status = statusLine({ dot: 'danger', text: p.error || 'The tunnel failed' });
- actions = html``;
+ actions = html`${menuSlot()}`;
} else {
status = p.db
? html`${dot('warn')}${joinParts([proc, 'looks like a database, so Publish asks first'])}`
: html`${proc || where ? joinParts([proc, where]) : p.listening ? 'Listening in the container' : 'Not listening right now'}`;
- actions = html``;
+ actions = html`${menuSlot()}`;
}
return html`
-
+
${tileEl}
${title}
${status}
-
${actions}
+
${actions}
${panel}
`;
};
const portsPage = (s, n) => {
const ports = state.ports;
- if (!ports) return html`${pageNotices('ports')}${skeletonGroup(3)}`;
+ const head = pageHead('ports');
+ if (!ports) return html`${head}${pageNotices('ports')}${skeletonGroup(3)}`;
if (ports.available === false) {
- return html`${pageNotices('ports')}${section('pl-title', 'Listening in the container', html`
Publishing is unavailable in this buildcloudflared is not in this image, so ports cannot get a public URL from here.
`, { actions: muted('Checks every few seconds') })}
- ${section('pl-how', 'How publishing works', html`
-
Tunnel
A Cloudflare quick tunnel. No account, no DNS record, no certificate.
-
Address
A random trycloudflare.com hostname with HTTPS, new each time you publish.
-
Bound to 127.0.0.1
Included. Most dev servers listen on loopback, and those are the ones that need this most.
-
`)}
- ${notice('warn', 'triangle-alert', 'A published URL is public', 'It is random and stops working the moment you stop it, but anyone holding it can reach that port. Publish a dev server, not your database.')}`;
+ : html`
${emptyIcon('ethernet-port')}Nothing is listening yetStart a dev server in a T3 Code terminal and it shows up here within a few seconds.
`}
+
${icon('info', 'tc-icon--sm')}A published address is public: anyone who has it can open that port. Stop it when you’re done.
@@ -1391,6 +1399,7 @@
const legacy = s.legacyEnv || [];
const legacyVars = legacy.filter((name) => name !== 'PATH');
return html`
+ ${pageHead('environment', { actions: html`` })}
${pageNotices('environment')}
${legacy.length ? notice('warn', 'triangle-alert', 'Settings left over from an older image',
html`This container’s environment still has ${legacyVars.map((name, i) => html`${i ? (i === legacyVars.length - 1 ? ' and ' : ', ') : ''}${name}`)}${legacy.includes('PATH') ? html`${legacyVars.length ? ' and ' : ''}the old PATH` : ''}, from an image that shipped its own Go, Rust, Bun and Deno. Everything this image starts ignores them. Remove them from the container’s settings so docker exec stops seeing them too; the image sets its own PATH.`) : ''}
@@ -1423,7 +1432,7 @@
${
// linking to T3, opens in a tab of its own on request, and leaves the theme to T3.
const CLOSE = ``;
+/**
+ * "G A": a page's two-key shortcut, as its sidebar tooltip says it. Not
+ * aria-keyshortcuts, which lists alternatives and has no way to say "then".
+ */
+const shortcut = (page) => (page.key ? `G ${page.key.toUpperCase()}` : "");
+
+const navItem = (page) => `${escapeHtml(page.title)}`;
+
+/** The sidebar's pages in their groups; a group without a label draws no heading. */
+const sidebarNav = () => ROUTES.grouped().filter((group) => group.pages.length).map((group) =>
+ `
${group.label ? `
${escapeHtml(group.label)}
` : ""}${group.pages.map(navItem).join("")}
`).join("");
+
/**
* The console once unlocked: the shell, with every page awaiting its first
* status. `embed` is the variant T3 Code's settings open in a dialog.
*/
export function renderConsole({ assets, nonce, mount, embed = false }) {
- const nav = NAV.map(([route, label, icon, key]) =>
- `${label}`).join("");
- const tabs = TABS.map(([route, label, icon]) =>
- `${label}`).join("");
- const pages = [...NAV.map(([route]) => route), "more"].map((route) =>
- `${skeleton(route === "more" ? 2 : 3)}`).join("\n");
+ const tabs = ROUTES.TABS.map((tab) =>
+ `${escapeHtml(tab.title)}`).join("");
+ const pages = [...ROUTES.PAGES, ROUTES.page("more")].map((page) =>
+ `${skeleton(page.route === "more" ? 2 : 3)}`).join("\n");
const foot = embed
? `Open in a new tab`
: `Open T3 Code
@@ -152,7 +160,7 @@ export function renderConsole({ assets, nonce, mount, embed = false }) {