web: footer shows the running build (branch and CI build number)
"foundations · build 6", on master only "build 6"; the full sha is the tooltip. CI passes BRANCH and BUILD_NUMBER (github.run_number) as build args next to REVISION; the server reads BUILD_* and serves them at /api/build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de5ee92057
commit
da40e50630
14 files changed
+130
-7
No files matched your search
@@ -38,6 +38,7 @@ jobs:
|
||||
env:
|
||||
REGISTRY_USER: ${{ github.repository_owner }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
BUILD_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/homelab-upgrade"
|
||||
@@ -46,7 +47,7 @@ jobs:
|
||||
[ "${GITHUB_REF_NAME}" = master ] && refs+=("${image}:latest")
|
||||
printf '%s' "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
|
||||
tag_args=(); for ref in "${refs[@]}"; do tag_args+=(-t "${ref}"); done
|
||||
docker build --build-arg "REVISION=${GITHUB_SHA}" "${tag_args[@]}" .
|
||||
docker build --build-arg "REVISION=${GITHUB_SHA}" --build-arg "BRANCH=${GITHUB_REF_NAME}" --build-arg "BUILD_NUMBER=${BUILD_NUMBER}" "${tag_args[@]}" .
|
||||
# Push only these refs: the daemon is the VM's, so `--all-tags` would also push the local :rollback tag from update.sh.
|
||||
for ref in "${refs[@]}"; do docker push "${ref}"; done
|
||||
docker logout "${REGISTRY}"
|
||||
@@ -16,7 +16,11 @@ RUN npm run build:web
|
||||
|
||||
FROM ${NODE_IMAGE} AS runtime
|
||||
ARG REVISION=unknown
|
||||
# Shown in the app's footer: the branch and the CI build number (empty in a local build).
|
||||
ARG BRANCH=
|
||||
ARG BUILD_NUMBER=
|
||||
LABEL org.opencontainers.image.title="homelab-upgrade" org.opencontainers.image.revision="${REVISION}"
|
||||
ENV BUILD_BRANCH=${BRANCH} BUILD_NUMBER=${BUILD_NUMBER} BUILD_REVISION=${REVISION}
|
||||
ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080 DB_PATH=/data/queue.sqlite WEB_DIR=/app/dist/web
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
@@ -46,6 +46,14 @@ export const AppOverviewSchema = z.object({
|
||||
last: StepSchema.nullable(),
|
||||
});
|
||||
|
||||
/** Which build the server runs: branch, CI build number and commit. Null where unknown (a local run). */
|
||||
export const BuildResponse = z.object({
|
||||
branch: z.string().nullable(),
|
||||
number: z.number().int().positive().nullable(),
|
||||
revision: z.string().nullable(),
|
||||
});
|
||||
export type BuildInfo = z.infer<typeof BuildResponse>;
|
||||
|
||||
export const RunsResponse = z.array(RunSummarySchema);
|
||||
export const RunDetailResponse = z.object({ run: RunSummarySchema, steps: z.array(StepSchema) });
|
||||
export const StepsResponse = z.array(StepSchema);
|
||||
|
||||
+13
-1
@@ -14,7 +14,7 @@ import { RecordingNotifier } from '../testing/fakes.ts';
|
||||
import { createTestDatabase } from '../testing/test-db.ts';
|
||||
import { createChangeHub } from './change-hub.ts';
|
||||
import type { ChangeHub } from './change-hub.ts';
|
||||
import { AppsResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from './contract.ts';
|
||||
import { AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from './contract.ts';
|
||||
import { buildServer } from './server.ts';
|
||||
import type { ServerDeps } from './server.ts';
|
||||
|
||||
@@ -50,6 +50,18 @@ async function json<T extends z.ZodType>(app: FastifyInstance, schema: T, reques
|
||||
const resolve = (id: number | string, body: unknown) => ({ method: 'POST' as const, url: `/api/steps/${String(id)}/resolve`, payload: body as object });
|
||||
const decision = (action: string, extra: object = {}) => ({ action, resolution: 'ok by owner', ...extra });
|
||||
|
||||
describe('build', () => {
|
||||
it('reports the build the server runs (branch, CI build number, revision)', async () => {
|
||||
const { app } = await fixture({ build: { branch: 'foundations', number: 6, revision: 'de5ee92' } });
|
||||
expect(await json(app, BuildResponse, '/api/build')).toEqual({ branch: 'foundations', number: 6, revision: 'de5ee92' });
|
||||
});
|
||||
|
||||
it('without build facts every field is null', async () => {
|
||||
const { app } = await fixture();
|
||||
expect(await json(app, BuildResponse, '/api/build')).toEqual({ branch: null, number: null, revision: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('runs', () => {
|
||||
it('starts empty', async () => {
|
||||
const { app } = await fixture();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { StepStatus } from '../domain/steps.ts';
|
||||
import { watchChanges } from './change-hub.ts';
|
||||
import type { ChangeHub } from './change-hub.ts';
|
||||
import { EVENTS_PATH, ResolutionRequest } from './contract.ts';
|
||||
import type { BuildInfo } from './contract.ts';
|
||||
|
||||
export interface ServerDeps {
|
||||
readonly store: StepStore;
|
||||
@@ -22,8 +23,12 @@ export interface ServerDeps {
|
||||
/** How often to look for changes made by the driver process; 0 disables (tests). */
|
||||
readonly pollChangesMs?: number;
|
||||
readonly logger?: boolean;
|
||||
/** Shown in the footer; unknown by default. */
|
||||
readonly build?: BuildInfo;
|
||||
}
|
||||
|
||||
const UNKNOWN_BUILD: BuildInfo = { branch: null, number: null, revision: null };
|
||||
|
||||
const DEFAULT_POLL_MS = 1000;
|
||||
const DEFAULT_RUNS = 50;
|
||||
const MAX_LIMIT = 500;
|
||||
@@ -87,6 +92,8 @@ export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
|
||||
|
||||
app.get('/api/health', (_request, reply) => reply.send({ ok: true }));
|
||||
|
||||
app.get('/api/build', (_request, reply) => reply.send(deps.build ?? UNKNOWN_BUILD));
|
||||
|
||||
app.get('/api/runs', (request, reply) => {
|
||||
const query = RunsQuery.safeParse(request.query);
|
||||
if (!query.success) return fail(reply, 400, 'invalid_request', issues(query.error));
|
||||
|
||||
@@ -11,12 +11,13 @@ describe('loadConfig', () => {
|
||||
driverIdleMs: 60_000,
|
||||
demo: false,
|
||||
appsFile: 'config/apps.json',
|
||||
build: { branch: null, number: null, revision: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads overrides from the environment', () => {
|
||||
expect(
|
||||
loadConfig({ DB_PATH: '/data/q.sqlite', HOST: '0.0.0.0', PORT: '9000', WEB_DIR: '/app/web', DRIVER_IDLE_MS: '1500', DEMO: '1', APPS_FILE: '/etc/apps.json' }),
|
||||
loadConfig({ DB_PATH: '/data/q.sqlite', HOST: '0.0.0.0', PORT: '9000', WEB_DIR: '/app/web', DRIVER_IDLE_MS: '1500', DEMO: '1', APPS_FILE: '/etc/apps.json', BUILD_BRANCH: 'foundations', BUILD_NUMBER: '6', BUILD_REVISION: 'de5ee92' }),
|
||||
).toEqual({
|
||||
dbPath: '/data/q.sqlite',
|
||||
host: '0.0.0.0',
|
||||
@@ -25,14 +26,19 @@ describe('loadConfig', () => {
|
||||
driverIdleMs: 1500,
|
||||
demo: true,
|
||||
appsFile: '/etc/apps.json',
|
||||
build: { branch: 'foundations', number: 6, revision: 'de5ee92' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ PORT: '0' }, { PORT: 'abc' }, { PORT: '70000' }, { DRIVER_IDLE_MS: '5' }, { DEMO: 'yes' }])('rejects %j with a readable message', (env) => {
|
||||
it.each([{ PORT: '0' }, { PORT: 'abc' }, { PORT: '70000' }, { DRIVER_IDLE_MS: '5' }, { DEMO: 'yes' }, { BUILD_NUMBER: 'six' }, { BUILD_NUMBER: '0' }])('rejects %j with a readable message', (env) => {
|
||||
expect(() => loadConfig(env)).toThrow(/invalid configuration/);
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty build variables (a local docker build without build args) as unknown', () => {
|
||||
expect(loadConfig({ BUILD_BRANCH: '', BUILD_NUMBER: '', BUILD_REVISION: '' }).build).toEqual({ branch: null, number: null, revision: null });
|
||||
});
|
||||
|
||||
describe('secretValues', () => {
|
||||
it('collects values of secret-looking variables so they can be masked literally', () => {
|
||||
const env = { HA_TOKEN: 'tok-123456', DB_PASSWORD: 'pw-abcdef', PATH: '/usr/bin', API_KEY: '', NOTES: 'hello world', CLIENT_SECRET: 's3cr3t!' };
|
||||
|
||||
@@ -10,8 +10,14 @@ const Env = z.object({
|
||||
DEMO: z.enum(['0', '1']).default('0'),
|
||||
/** The apps to load into app_config when it is empty (real mode). */
|
||||
APPS_FILE: z.string().min(1).default('config/apps.json'),
|
||||
/** Build facts baked into the image by CI (Dockerfile build args); empty or missing = unknown. */
|
||||
BUILD_BRANCH: z.string().optional(),
|
||||
BUILD_NUMBER: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(),
|
||||
BUILD_REVISION: z.string().optional(),
|
||||
});
|
||||
|
||||
const orNull = <T>(value: T | '' | undefined): T | null => (value === undefined || value === '' ? null : value);
|
||||
|
||||
export interface Config {
|
||||
readonly dbPath: string;
|
||||
readonly host: string;
|
||||
@@ -20,6 +26,7 @@ export interface Config {
|
||||
readonly driverIdleMs: number;
|
||||
readonly demo: boolean;
|
||||
readonly appsFile: string;
|
||||
readonly build: { readonly branch: string | null; readonly number: number | null; readonly revision: string | null };
|
||||
}
|
||||
|
||||
export function loadConfig(env: Readonly<Record<string, string | undefined>>): Config {
|
||||
@@ -37,6 +44,7 @@ export function loadConfig(env: Readonly<Record<string, string | undefined>>): C
|
||||
driverIdleMs: values.DRIVER_IDLE_MS,
|
||||
demo: values.DEMO === '1',
|
||||
appsFile: values.APPS_FILE,
|
||||
build: { branch: orNull(values.BUILD_BRANCH), number: orNull(values.BUILD_NUMBER), revision: orNull(values.BUILD_REVISION) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const app = await buildServer({
|
||||
clock: systemClock,
|
||||
hub: createChangeHub(),
|
||||
staticDir: config.webDir,
|
||||
build: config.build,
|
||||
logger: true,
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
import type { z } from 'zod';
|
||||
import { AppsResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from '../http/contract.ts';
|
||||
import { AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from '../http/contract.ts';
|
||||
import type { ResolutionRequest } from '../http/contract.ts';
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -29,6 +29,7 @@ export const api = {
|
||||
runs: () => call(RunsResponse, '/api/runs?limit=50'),
|
||||
run: (roundId: string) => call(RunDetailResponse, `/api/runs/${encodeURIComponent(roundId)}`),
|
||||
apps: () => call(AppsResponse, '/api/apps'),
|
||||
build: () => call(BuildResponse, '/api/build'),
|
||||
appSteps: (app: string) => call(StepsResponse, `/api/steps?app=${encodeURIComponent(app)}&limit=100`),
|
||||
queue: () => call(StepsResponse, '/api/queue'),
|
||||
startRun: () => call(StartRunResponse, '/api/runs', { type: 'manual' }),
|
||||
|
||||
+12
-1
@@ -49,7 +49,7 @@ beforeEach(async () => {
|
||||
seedDemoConfig(appConfig);
|
||||
const clock = new FakeClock();
|
||||
hub = createChangeHub();
|
||||
server = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0 });
|
||||
server = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } });
|
||||
const driver = createDriver({ store, clock, notifier: new RecordingNotifier(), handlers: createDemoHandlers({ appConfig, world: createDemoWorld() }) });
|
||||
startManualRun(store, clock);
|
||||
await driver.drain(new AbortController().signal);
|
||||
@@ -111,6 +111,17 @@ async function choose(select: HTMLSelectElement, value: string) {
|
||||
await nextTask();
|
||||
}
|
||||
|
||||
describe('footer', () => {
|
||||
it('shows which build is running, with the full revision on hover', async () => {
|
||||
const footer = await eventually(() => {
|
||||
const found = container.querySelector('footer');
|
||||
expect(found?.textContent).toBe('foundations · build 6');
|
||||
return found!;
|
||||
});
|
||||
expect(footer.querySelector('[title]')?.getAttribute('title')).toBe('de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decision queue', () => {
|
||||
it('shows what is waiting for the owner, with reason and detail, and a count in the navigation', async () => {
|
||||
await eventually(() => expect(cards()).toHaveLength(3));
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ErrorNote } from './components.tsx';
|
||||
import { LiveContext, useResource } from './hooks.ts';
|
||||
import { useLive } from './live.ts';
|
||||
import type { LiveStatus } from './live.ts';
|
||||
import { formatBuild } from './format.ts';
|
||||
import type { BuildInfo } from '../http/contract.ts';
|
||||
import { hrefFor, parseRoute } from './route.ts';
|
||||
import type { Route } from './route.ts';
|
||||
import { AppDetailView, AppsView } from './views/apps.tsx';
|
||||
@@ -100,6 +102,29 @@ function View({ route }: { route: Route }): ComponentChildren {
|
||||
}
|
||||
}
|
||||
|
||||
/** Which build is running. Loaded once: the server's build does not change while the page is open. */
|
||||
function Footer() {
|
||||
const [build, setBuild] = useState<BuildInfo | null>(null);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
api.build().then(
|
||||
(data) => {
|
||||
if (current) setBuild(data);
|
||||
},
|
||||
() => undefined, // the footer is a nicety: no build facts, no footer
|
||||
);
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, []);
|
||||
if (build === null) return null;
|
||||
return (
|
||||
<footer>
|
||||
<span title={build.revision ?? undefined}>{formatBuild(build)}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const route = useRoute();
|
||||
const { revision, status, refresh } = useLive();
|
||||
@@ -109,6 +134,7 @@ export function App() {
|
||||
<main>
|
||||
<View route={route} />
|
||||
</main>
|
||||
<Footer />
|
||||
</LiveContext.Provider>
|
||||
);
|
||||
}
|
||||
+23
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCost, formatDuration, formatInstant, humanizeCode } from './format.ts';
|
||||
import { formatBuild, formatCost, formatDuration, formatInstant, humanizeCode } from './format.ts';
|
||||
|
||||
describe('formatInstant', () => {
|
||||
it('shows date and time in the given zone', () => {
|
||||
@@ -46,3 +46,25 @@ describe('humanizeCode', () => {
|
||||
expect(humanizeCode('ok')).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBuild', () => {
|
||||
const REVISION = 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901';
|
||||
|
||||
it('master shows only the build number', () => {
|
||||
expect(formatBuild({ branch: 'master', number: 12, revision: REVISION })).toBe('build 12');
|
||||
});
|
||||
|
||||
it('another branch shows its name and the build number', () => {
|
||||
expect(formatBuild({ branch: 'foundations', number: 6, revision: REVISION })).toBe('foundations · build 6');
|
||||
});
|
||||
|
||||
it('without a build number it falls back to the short revision', () => {
|
||||
expect(formatBuild({ branch: 'foundations', number: null, revision: REVISION })).toBe('foundations · de5ee92');
|
||||
expect(formatBuild({ branch: 'master', number: null, revision: REVISION })).toBe('de5ee92');
|
||||
});
|
||||
|
||||
it('a local run without build facts says so', () => {
|
||||
expect(formatBuild({ branch: null, number: null, revision: null })).toBe('dev build');
|
||||
expect(formatBuild({ branch: 'foundations', number: null, revision: null })).toBe('foundations · dev build');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { BuildInfo } from '../http/contract.ts';
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
/** `2026-09-18 23:43:12` in the browser's time zone (or `timeZone` when given, for tests). */
|
||||
@@ -30,3 +32,9 @@ export function formatCost(usd: number): string {
|
||||
export function humanizeCode(code: string): string {
|
||||
return code.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
/** The footer's build label: `build 12` on master, `foundations · build 6` elsewhere; the short commit when there is no number. */
|
||||
export function formatBuild(build: BuildInfo): string {
|
||||
const id = build.number !== null ? `build ${String(build.number)}` : build.revision !== null ? build.revision.slice(0, 7) : 'dev build';
|
||||
return build.branch === null || build.branch === 'master' ? id : `${build.branch} · ${id}`;
|
||||
}
|
||||
@@ -64,6 +64,14 @@ main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 1.4rem;
|
||||
|
||||
Reference in new issue
Block a user