Surface runtime build identity across About, shell, and Admiral Account (#1899)

* feat: surface runtime build identity across About, shell, and Admiral Account

Add one canonical source for the control instance's runtime build identity so
a development image is visibly identified even when its packaged semver still
matches the previous stable release.

SelfIdentityService now retains the running image reference and assembles a
BuildInfo (version, channel, imageRef, imageId, revision) via a bounded,
failure-isolated image-inspect step during initialization. getBuildInfo() is a
cached read that never triggers Docker. classifyBuildChannel() labels the
running reference stable, dev, preview, or unknown.

A new proxy-exempt GET /api/build-info returns that identity to a signed-in
human session, redacting hardened image references to non-admins with a
restricted flag so the UI shows Restricted rather than Unknown. Public
/api/meta gains a bounded buildChannel enum without leaking the image
reference.

A BuildInfoProvider context shares one fetch across the About section, the
sidebar DEV/PREVIEW chip, the mobile tab bar, and the Admiral Account Channel
and Current image rows, keeping the control-instance identity separate from
remote nodes.

* fix: move chipDetail helper out of the component file to satisfy fast refresh

SidebarBrand.tsx exported both the SidebarBrand component and the chipDetail
helper, which trips react-refresh/only-export-components and fails the lint
gate. The helper now lives in its own chipDetail.ts module so the component
file exports only the component; the direct unit test keeps its coverage by
importing from the new module.

* fix(build-info): resolve revision-enrichment race and harden identity rendering

- Await the detached revision enrichment before serving /api/build-info so a
  successful response never freezes a transient null revision.
- Lay the mobile DEV/PREVIEW pill in-flow as a non-overlapping flex sibling
  instead of an absolutely positioned overlay.
- Wrap long image and revision tokens with break-all so they do not overflow
  the About panel.
- Surface a toast when copying the image id fails instead of leaving an
  unhandled rejection.
This commit is contained in:
Anso
2026-09-07 14:20:47 -04:00
committed by GitHub
parent b6766572df
commit 1ad20da31f
30 changed files with 1464 additions and 29 deletions
@@ -0,0 +1,225 @@
/**
* Route coverage for the canonical build identity:
*
* - GET /api/build-info is proxy-exempt (always served by the control
* instance), requires a signed-in human session (rejects machine / API-token
* credentials), redacts hardened image references to non-admins via
* `restricted: true`, and never mislabels a redacted field "Unknown".
* - GET /api/meta exposes only the bounded `buildChannel` enum on the public
* surface and never leaks the running image reference.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
let tmpDir: string;
let app: import('express').Express;
let adminAuth: string;
let viewerAuth: string;
let machineAuth: string;
let remoteNodeId: number;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let SelfIdentityService: typeof import('../services/SelfIdentityService').default;
const IMAGE_ID = 'b'.repeat(64);
const DIGEST = 'a'.repeat(64);
function mockBuildInfo(over: Record<string, unknown> = {}) {
const svc = SelfIdentityService.getInstance();
vi.spyOn(svc, 'getBuildInfo').mockReturnValue({
version: '0.97.1',
channel: 'dev',
imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev-abc1234',
imageId: IMAGE_ID,
revision: 'dev-abc1234',
...over,
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ DatabaseService } = await import('../services/DatabaseService'));
SelfIdentityService = (await import('../services/SelfIdentityService')).default;
const db = DatabaseService.getInstance();
remoteNodeId = db.addNode({
name: 'build-info-remote',
type: 'remote',
compose_dir: '/tmp',
is_default: false,
api_url: 'http://127.0.0.1:1',
api_token: 'build-info-remote-token',
});
// A signed-in non-admin human session (role resolved from the DB row).
db.addUser({
username: 'build-info-viewer',
password_hash: await bcrypt.hash('pw', 1),
role: 'viewer',
});
adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
viewerAuth = `Bearer ${jwt.sign({ username: 'build-info-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
// node_proxy machine credential: authMiddleware maps it to role admin with
// userId 0, so requireUserSession must reject it as not a human session.
machineAuth = `Bearer ${jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
describe('GET /api/build-info auth', () => {
it('requires authentication', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info');
expect(res.status).toBe(401);
});
it('rejects node_proxy machine credentials (not a human session)', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info').set('Authorization', machineAuth);
expect(res.status).toBe(403);
expect(res.body.code).toBe('SESSION_REQUIRED');
});
});
describe('GET /api/build-info is proxy-exempt', () => {
it('serves locally even when x-node-id targets a remote node', async () => {
mockBuildInfo();
// The remote node's api_url is a closed loopback port. A 502 would mean the
// proxy intercepted the request; anything else proves the local handler
// matched, exactly as the existing /api/nodes proxy-exempt test asserts.
const res = await withLoopbackTargetProtection(() => request(app)
.get('/api/build-info')
.set('Authorization', adminAuth)
.set('x-node-id', String(remoteNodeId)));
expect(res.status).not.toBe(502);
expect(res.body.channel).toBe('dev');
});
});
describe('GET /api/build-info as admin', () => {
it('returns the full dev identity for a dev image (regression: semver still previous stable)', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info').set('Authorization', adminAuth);
expect(res.status).toBe(200);
expect(res.body.version).toBe('0.97.1');
expect(res.body.channel).toBe('dev');
expect(res.body.imageChannel).toBe('community');
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
expect(res.body.imageId).toBe(IMAGE_ID);
expect(res.body.revision).toBe('dev-abc1234');
expect(res.body.restricted).toBe(false);
});
it('returns the bounded imageChannel for a hardened image', async () => {
mockBuildInfo({
channel: 'stable',
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1',
});
const res = await request(app).get('/api/build-info').set('Authorization', adminAuth);
expect(res.status).toBe(200);
expect(res.body.channel).toBe('stable');
expect(res.body.imageChannel).toBe('hardened');
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-hardened:0.97.1');
expect(res.body.restricted).toBe(false);
});
});
describe('GET /api/build-info as a non-admin', () => {
it('redacts a hardened image reference and revision to a non-admin via restricted:true', async () => {
mockBuildInfo({
channel: 'stable',
imageRef: 'ghcr.io/studio-saelix/sencho-hardened:0.97.1',
revision: `sha256:${DIGEST}`,
});
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
expect(res.status).toBe(200);
// The build channel stays stable; the procurement channel is what gates
// redaction. Both reference fields are nulled with restricted:true so the
// UI can label them "Restricted", never "Unknown".
expect(res.body.channel).toBe('stable');
expect(res.body.imageChannel).toBe('hardened');
expect(res.body.imageRef).toBeNull();
expect(res.body.revision).toBeNull();
expect(res.body.restricted).toBe(true);
// The image ID is not a registry reference and is always returned.
expect(res.body.imageId).toBe(IMAGE_ID);
});
it('does not redact a community image for a non-admin', async () => {
mockBuildInfo();
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
expect(res.status).toBe(200);
expect(res.body.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
expect(res.body.revision).toBe('dev-abc1234');
expect(res.body.restricted).toBe(false);
});
it('reports unknown procurement channel on bare metal without a running reference', async () => {
mockBuildInfo({ channel: 'unknown', imageRef: null, revision: null });
const res = await request(app).get('/api/build-info').set('Authorization', viewerAuth);
expect(res.status).toBe(200);
// No reference means no classification, and no redaction can apply.
expect(res.body.channel).toBe('unknown');
expect(res.body.imageChannel).toBe('unknown');
expect(res.body.imageRef).toBeNull();
expect(res.body.revision).toBeNull();
expect(res.body.restricted).toBe(false);
});
});
describe('GET /api/build-info awaits revision enrichment', () => {
it('blocks the response until the enrichment settle promise resolves', async () => {
mockBuildInfo();
const svc = SelfIdentityService.getInstance();
let release!: () => void;
vi.spyOn(svc, 'whenRevisionResolved').mockImplementation(
() => new Promise<void>((res) => { release = res; }),
);
let settled = false;
const pending = request(app)
.get('/api/build-info')
.set('Authorization', adminAuth)
.then((res) => { settled = true; return res; });
// Give the route a tick to reach the await. It must not have responded yet,
// proving a transient null is never the settled value of a success.
await new Promise((r) => setTimeout(r, 10));
expect(settled).toBe(false);
release();
const res = await pending;
expect(res.status).toBe(200);
expect(res.body.revision).toBe('dev-abc1234');
});
});
describe('GET /api/meta buildChannel', () => {
it('exposes the bounded build channel on the public endpoint', async () => {
mockBuildInfo();
const res = await request(app).get('/api/meta');
expect(res.status).toBe(200);
expect(res.body.buildChannel).toBe('dev');
});
it('never leaks the running image reference on the public endpoint', async () => {
mockBuildInfo();
const res = await request(app).get('/api/meta');
const body = JSON.stringify(res.body);
expect(body).not.toContain('ghcr.io/studio-saelix/sencho-dev');
expect(body).not.toContain('dev-abc1234');
});
it('omits buildChannel when the running image reference is unknown', async () => {
mockBuildInfo({ imageRef: null, channel: 'unknown', revision: null });
const res = await request(app).get('/api/meta');
expect(res.status).toBe(200);
expect(res.body.buildChannel).toBeUndefined();
});
});
@@ -0,0 +1,42 @@
/**
* Truth table for classifyBuildChannel, the canonical build-identity classifier.
* Answers "is this a dev, preview, or stable build" from the image reference
* alone, independent of the packaged semver.
*/
import { describe, it, expect } from 'vitest';
import { classifyBuildChannel } from '../helpers/selfUpdateCompose';
describe('classifyBuildChannel', () => {
it('classifies the dev repository as dev regardless of tag', () => {
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev')).toBe('dev');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:dev-abc1234')).toBe('dev');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:latest')).toBe('dev');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev@sha256:abc')).toBe('dev');
});
it('classifies stable-repo preview tags as preview', () => {
expect(classifyBuildChannel('saelix/sencho:pr-42')).toBe('preview');
expect(classifyBuildChannel('saelix/sencho:preview-abc1234')).toBe('preview');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:pr-7')).toBe('preview');
});
it('classifies stable-repo release/floating tags as stable', () => {
expect(classifyBuildChannel('saelix/sencho:0.97.1')).toBe('stable');
expect(classifyBuildChannel('saelix/sencho:latest')).toBe('stable');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho:v1.2.3')).toBe('stable');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:1.2.3')).toBe('stable');
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-hardened:latest')).toBe('stable');
});
it('treats a dev-<sha> tag as preview-matching-tolerant (still dev repo wins)', () => {
// dev repo takes precedence over the preview/stable tag classification
expect(classifyBuildChannel('ghcr.io/studio-saelix/sencho-dev:pr-42')).toBe('dev');
});
it('classifies unknown repositories as unknown', () => {
expect(classifyBuildChannel('ubuntu:22.04')).toBe('unknown');
expect(classifyBuildChannel('registry.example.com/private/app:1.0')).toBe('unknown');
expect(classifyBuildChannel('')).toBe('unknown');
expect(classifyBuildChannel(' ')).toBe('unknown');
});
});
@@ -1621,6 +1621,41 @@ describe('MonitorService - Sencho dev build check', () => {
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.anything());
});
// C1: update eligibility is compose-declared. A dev *running* identity must
// not make a stable-declared pin eligible for the dev-build detector, and an
// unknown running identity must still gate before any registry call. These
// two disagreement directions pin that the running identity surfaced by
// SelfIdentityService never changes update behavior.
it('does not treat a stable-declared pin as eligible even when the running identity is a dev build', async () => {
// beforeEach() already mints a dev running imageId; the declared pin is stable.
// Reset the version-update inputs: an earlier suppression test queues a
// once-value that a stable-pin run would otherwise drain into the version
// path, which is not what this test observes.
mockGetLatestVersionInfo.mockReset();
mockGetPinInfo.mockResolvedValue(STABLE_PIN);
await runEvaluate();
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
expect(devBuildCalls()).toHaveLength(0);
expect(mockSetSystemState).not.toHaveBeenCalledWith('sencho_dev_build_available_digest', expect.anything());
});
it('still gates the registry comparison on a known running image id for an eligible dev-declared pin', async () => {
mockGetPinInfo.mockResolvedValue(DEV_PIN);
mockGetIdentity.mockReturnValue({
containerId: null, containerName: null, composeProjectName: null,
imageId: null, networkNames: [], volumeNames: [],
});
await runEvaluate();
expect(mockDetectSelfDevBuildUpdate).not.toHaveBeenCalled();
expect(devBuildCalls()).toHaveLength(0);
// Unknown running id takes the retry-sooner path, not the detector path.
expect((MonitorService.getInstance() as any).lastDevBuildCheckGateMs).toBe(5 * 60 * 1000);
});
});
// ── Per-container parallel fan-out ────────────────────────────────────
@@ -0,0 +1,197 @@
/**
* Unit tests for SelfIdentityService.getBuildInfo(): the canonical runtime
* build identity (version, channel, imageRef, imageId, revision), the detached
* bounded revision enrichment, and the failure-isolation guarantee.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockContainer, mockDocker, mockInspectImage, mockGetSenchoVersion } = vi.hoisted(() => {
const mockContainer = { inspect: vi.fn() };
const mockDocker = {
getContainer: vi.fn(() => mockContainer),
getImage: vi.fn(),
listImages: vi.fn().mockResolvedValue([]),
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
listNetworks: vi.fn().mockResolvedValue([]),
listContainers: vi.fn().mockResolvedValue([]),
};
return {
mockContainer,
mockDocker,
mockInspectImage: vi.fn(),
mockGetSenchoVersion: vi.fn(() => '0.97.1'),
};
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDocker: () => mockDocker,
getDefaultNodeId: () => 1,
}),
},
}));
// Replace defaultInspectImage so enrichment is deterministic and isolated.
vi.mock('../services/selfDevBuildDetect', () => ({
defaultInspectImage: (...args: unknown[]) => mockInspectImage(...args),
}));
vi.mock('../services/CapabilityRegistry', () => ({
getSenchoVersion: () => mockGetSenchoVersion(),
}));
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
vi.mock('util', () => ({ promisify: () => vi.fn() }));
import SelfIdentityService from '../services/SelfIdentityService';
const FULL_IMAGE_ID_HEX = 'b'.repeat(64);
const DIGEST = 'a'.repeat(64);
const originalHostname = process.env.HOSTNAME;
beforeEach(() => {
vi.clearAllMocks();
mockContainer.inspect.mockReset();
mockDocker.getImage.mockReset();
mockInspectImage.mockReset();
mockGetSenchoVersion.mockReturnValue('0.97.1');
SelfIdentityService.getInstance().resetForTesting();
});
afterEach(() => {
vi.restoreAllMocks();
if (originalHostname === undefined) delete process.env.HOSTNAME;
else process.env.HOSTNAME = originalHostname;
});
async function initWith(configImage: string | undefined): Promise<SelfIdentityService> {
process.env.HOSTNAME = 'sencho-1';
mockContainer.inspect.mockResolvedValue({
Id: 'a'.repeat(64),
Name: '/sencho',
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
...(configImage !== undefined ? { Config: { Image: configImage } } : {}),
NetworkSettings: { Networks: {} },
Mounts: [],
});
const svc = SelfIdentityService.getInstance();
await svc.initialize();
return svc;
}
/** Enrichment runs detached; poll until the condition holds so assertions are stable. */
async function until(assert: () => void): Promise<void> {
await vi.waitFor(assert, { timeout: 2000 });
}
describe('SelfIdentityService.getBuildInfo', () => {
it('identifies a dev image as DEV even when the packaged semver matches the previous stable', async () => {
// The regression case from the ticket: dev image, version still 0.97.1.
// The inspect mock must be set before initialize(): enrichment fires
// detached during initialize(), before the awaited call returns.
mockInspectImage.mockResolvedValue({
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`));
const info = svc.getBuildInfo();
expect(info.version).toBe('0.97.1');
expect(info.channel).toBe('dev');
expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
});
it('derives the revision from a pinned dev-<sha> tag without an image inspect', async () => {
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev-abc1234');
await until(() => expect(svc.getBuildInfo().revision).toBe('dev-abc1234'));
const info = svc.getBuildInfo();
expect(info.channel).toBe('dev');
expect(mockInspectImage).not.toHaveBeenCalled();
});
it('classifies a stable image as stable', async () => {
mockInspectImage.mockResolvedValue({
RepoDigests: [`ghcr.io/studio-saelix/sencho@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
const svc = await initWith('ghcr.io/studio-saelix/sencho:0.97.1');
await until(() => expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`));
expect(svc.getBuildInfo().channel).toBe('stable');
});
it('reads unknown for partial metadata (no running image reference)', async () => {
const svc = await initWith(undefined);
const info = svc.getBuildInfo();
expect(info.imageRef).toBeNull();
expect(info.channel).toBe('unknown');
expect(info.revision).toBeNull();
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
});
it('keeps the dev channel but null revision when image inspection fails', async () => {
mockInspectImage.mockRejectedValue(new Error('docker daemon unreachable'));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
await until(() => expect(warnSpy).toHaveBeenCalled());
const info = svc.getBuildInfo();
expect(info.channel).toBe('dev');
expect(info.revision).toBeNull();
// C2: a failed enrichment must not corrupt the already-captured core identity.
expect(info.imageRef).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
expect(info.imageId).toBe(FULL_IMAGE_ID_HEX);
});
it('never inspects the image again on repeated reads', async () => {
mockInspectImage.mockResolvedValue({
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
await until(() => expect(mockInspectImage).toHaveBeenCalledTimes(1));
for (let i = 0; i < 5; i++) svc.getBuildInfo();
expect(mockInspectImage).toHaveBeenCalledTimes(1);
});
it('exposes revision only after enrichment settles, and whenRevisionResolved awaits that', async () => {
let resolveInspect!: (v: { RepoDigests: string[]; Os: string; Architecture: string }) => void;
mockInspectImage.mockReturnValue(new Promise((res) => { resolveInspect = res; }));
const svc = await initWith('ghcr.io/studio-saelix/sencho-dev:dev');
// initialize() returned without awaiting the detached enrichment, so the
// revision is still transiently null and the settle promise is pending.
expect(svc.getBuildInfo().revision).toBeNull();
// A reader that awaits the settle promise (the build-info route) blocks
// until enrichment lands, then observes the resolved digest, so a single
// successful read never freezes a transient null.
const settled = svc.whenRevisionResolved();
resolveInspect({
RepoDigests: [`ghcr.io/studio-saelix/sencho-dev@sha256:${DIGEST}`],
Os: 'linux',
Architecture: 'amd64',
});
await settled;
expect(svc.getBuildInfo().revision).toBe(`sha256:${DIGEST}`);
});
it('resolves whenRevisionResolved immediately when no enrichment ever started', async () => {
process.env.HOSTNAME = undefined;
const svc = SelfIdentityService.getInstance();
await svc.whenRevisionResolved();
expect(svc.getBuildInfo().revision).toBeNull();
});
});
+1
View File
@@ -13,6 +13,7 @@ export const PROXY_EXEMPT_PREFIXES: readonly string[] = [
'/api/fleet/',
'/api/webhooks',
'/api/meta',
'/api/build-info',
];
/** Returns true when the path should bypass the remote proxy (handled locally). */
+41 -4
View File
@@ -104,13 +104,50 @@ export function isSenchoDevFloatingTag(imageRef: string): boolean {
// A digest pin disqualifies the reference (e.g., `@sha256:...`)
if (ref.includes('@sha256:') || ref.startsWith('sha256:')) return false;
// Extract the tag using the same logic as classifyImagePin.
const tag = extractTagFromRef(ref);
return tag === 'dev';
}
/**
* Build channel of a running or declared image, derived from the image
* reference alone. This is the canonical build identity classifier used by
* `/api/build-info` and the shell/About surfaces: it answers "is this a dev,
* preview, or stable build" from the ref, independent of the packaged semver
* (a dev image carries the last released version, so version alone cannot
* identify it).
*
* - dev repo (`ghcr.io/studio-saelix/sencho-dev`): always `'dev'`. An
* arbitrary `:dev-<sha>` tag still reads `dev` (a deliberate operator
* choice); the immutable revision is surfaced separately from the digest.
* - stable repos (`ghcr.io/studio-saelix/sencho-hardened`, `saelix/sencho`,
* `ghcr.io/studio-saelix/sencho`): `pr-<n>` and `preview-<sha>` tags are
* `'preview'` (CI builds on the stable repo that are not releases);
* everything else is `'stable'`.
* - any other repository: `'unknown'`.
*/
export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown';
export function classifyBuildChannel(imageRef: string): BuildChannel {
const repository = normalizeImageRepository(imageRef);
if (repository === 'ghcr.io/studio-saelix/sencho-dev') return 'dev';
if (
repository === 'ghcr.io/studio-saelix/sencho-hardened' ||
repository === 'saelix/sencho' ||
repository === 'ghcr.io/studio-saelix/sencho'
) {
const tag = extractTagFromRef(imageRef.trim());
if (tag && (/^pr-\d+$/.test(tag) || /^preview-[0-9a-f]{7,40}$/.test(tag))) return 'preview';
return 'stable';
}
return 'unknown';
}
/** Extract the tag portion of an image ref (`.../repo:tag`), or '' when absent. */
function extractTagFromRef(ref: string): string {
const lastSlash = ref.lastIndexOf('/');
const lastColon = ref.lastIndexOf(':');
// A colon after the last slash is a tag separator; before it is a registry port.
const tag = lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
return tag === 'dev';
return lastColon > lastSlash ? ref.slice(lastColon + 1) : '';
}
/**
+2
View File
@@ -19,6 +19,7 @@ import { mfaRouter } from './routes/mfa';
import { ssoRouter } from './routes/sso';
import { licenseRouter, systemUpdateRouter } from './routes/license';
import { imageChannelRouter } from './routes/imageChannel';
import { buildInfoRouter } from './routes/buildInfo';
import { webhooksRouter } from './routes/webhooks';
import { usersRouter } from './routes/users';
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
@@ -115,6 +116,7 @@ app.use('/api/', createRemoteProxyMiddleware());
app.use('/api/license', licenseRouter);
app.use('/api/license/image-channel', imageChannelRouter);
app.use('/api/build-info', buildInfoRouter);
app.use('/api/system', systemUpdateRouter);
app.use('/api/permissions', permissionsRouter);
app.use('/api/convert', convertRouter);
+47
View File
@@ -0,0 +1,47 @@
import { Router, type Request, type Response } from 'express';
import { requireUserSession } from '../middleware/tierGates';
import { classifyImageChannel, type ImageChannel } from '../helpers/imageChannel';
import type { BuildChannel } from '../helpers/selfUpdateCompose';
import SelfIdentityService from '../services/SelfIdentityService';
export const buildInfoRouter = Router();
/** Wire shape of GET /api/build-info. `restricted: true` implies `imageRef` and
* `revision` are nulled for a hardened image viewed by a non-admin. */
interface BuildInfoResponse {
version: string | null;
channel: BuildChannel;
imageChannel: ImageChannel;
imageRef: string | null;
imageId: string | null;
revision: string | null;
restricted: boolean;
}
// Canonical runtime build identity of the control instance. Proxy-exempt (see
// helpers/proxyExemptPaths.ts) so it is always served by the local hub, never
// forwarded to a remote node. The running image reference can carry a private
// registry/repository name, so the endpoint requires a human session and
// redacts hardened-image references to non-admins via `restricted: true` (the
// UI shows "Restricted", never "Unknown", when set).
buildInfoRouter.get('/', async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
const service = SelfIdentityService.getInstance();
// Await the detached revision enrichment so a transient null is never the
// settled value of a successful response. Bounded by the inspect timeout and
// never rejects, so this adds at most a short wait on the first read.
await service.whenRevisionResolved();
const identity = service.getBuildInfo();
const isAdmin = req.user?.role === 'admin';
const imageChannel = identity.imageRef ? classifyImageChannel(identity.imageRef) : 'unknown';
const restricted = !isAdmin && imageChannel === 'hardened';
res.json({
version: identity.version,
channel: identity.channel,
imageChannel,
imageRef: restricted ? null : identity.imageRef,
imageId: identity.imageId,
revision: restricted ? null : identity.revision,
restricted,
} satisfies BuildInfoResponse);
});
+7 -1
View File
@@ -1,9 +1,10 @@
import { Router, type Request, type Response } from 'express';
import { getActiveCapabilities, getSenchoVersion } from '../services/CapabilityRegistry';
import { classifyImageChannel } from '../helpers/imageChannel';
import { isRepinBlocked } from '../helpers/selfUpdateCompose';
import { classifyBuildChannel, isRepinBlocked } from '../helpers/selfUpdateCompose';
import { MeshService } from '../services/MeshService';
import SelfUpdateService from '../services/SelfUpdateService';
import SelfIdentityService from '../services/SelfIdentityService';
// Captured at boot. Exposed via /api/health and /api/meta so the Fleet update
// overlay can distinguish a brand-new process from the old one still mid-pull.
@@ -41,6 +42,7 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
const updateError = selfUpdate.getLastError();
const pin = await selfUpdate.getPinInfo({ cacheOnly: true });
const updateBlocked = pin ? isRepinBlocked(pin.pinKind) : false;
const runningRef = SelfIdentityService.getInstance().getBuildInfo().imageRef;
res.json({
version: getSenchoVersion(),
capabilities: getActiveCapabilities(),
@@ -50,6 +52,10 @@ metaRouter.get('/meta', async (_req: Request, res: Response): Promise<void> => {
imagePinKind: pin.pinKind,
imageChannel: classifyImageChannel(pin.composeImageRef),
} : {}),
// Bounded build channel of the RUNNING image (stable|dev|preview|unknown).
// Like imagePinKind, this is a non-sensitive enum; no image reference is
// ever exposed on this public endpoint.
...(runningRef ? { buildChannel: classifyBuildChannel(runningRef) } : {}),
updateBlocked,
...(updateError ? { updateError: 'update_failed' } : {}),
});
@@ -1,5 +1,10 @@
import fs from 'fs/promises';
import DockerController from './DockerController';
import { classifyBuildChannel, isSenchoDevRepository, type BuildChannel } from '../helpers/selfUpdateCompose';
import { defaultInspectImage } from './selfDevBuildDetect';
import { parseImageRef, selectLocalRepoDigest } from './registry-api';
import { getSenchoVersion } from './CapabilityRegistry';
import { withTimeout } from '../utils/withTimeout';
/**
* Identifies the Docker resources that belong to the running Sencho container
@@ -20,17 +25,32 @@ import DockerController from './DockerController';
* stays in its empty state, every `isOwn*()` returns false, and today's
* behavior is preserved.
*/
/** Canonical runtime build identity of the running Sencho container. */
export interface BuildInfo {
version: string | null;
channel: BuildChannel;
/** The image reference the running container was started with, null when unknown. */
imageRef: string | null;
/** Running image sha256 hex (no prefix), null when unknown. */
imageId: string | null;
/** Validated registry digest or pinned `dev-<sha>` tag, null when unknown. */
revision: string | null;
}
class SelfIdentityService {
private static instance: SelfIdentityService;
private containerId: string | null = null;
private containerName: string | null = null;
private composeProjectName: string | null = null;
private imageIdHex: string | null = null;
private imageRef: string | null = null;
private revision: string | null = null;
private networkIds = new Set<string>();
private networkNames = new Set<string>();
private volumeNames = new Set<string>();
private initialized = false;
private initializePromise: Promise<void> | null = null;
private enrichmentPromise: Promise<void> | null = null;
public static getInstance(): SelfIdentityService {
if (!SelfIdentityService.instance) {
@@ -58,6 +78,14 @@ class SelfIdentityService {
this.containerName = (info.Name || '').replace(/^\//, '') || null;
this.composeProjectName = info.Config?.Labels?.['com.docker.compose.project'] ?? null;
this.imageIdHex = SelfIdentityService.stripSha(info.Image ?? '') || null;
this.imageRef = info.Config?.Image ?? null;
// Bounded revision enrichment runs detached so it never blocks the callers
// awaiting initialize() (Docker event monitoring, resources discovery). Core
// identity above is already captured; enrichment only adds the registry
// digest / pinned dev-<sha> and is failure-isolated. The promise is retained
// so a reader that needs the settled revision can await it (see
// whenRevisionResolved) instead of observing a transient null.
this.enrichmentPromise = this.enrichRevision(this.imageRef, this.imageIdHex);
const nets = info.NetworkSettings?.Networks ?? {};
for (const [name, net] of Object.entries(nets)) {
@@ -118,6 +146,64 @@ class SelfIdentityService {
}
}
/**
* Canonical runtime build identity. All fields are captured fields or derived
* synchronously from them; this read never triggers a Docker call. `revision`
* is populated by the detached enrichment step fired during initialize() and
* reads null until that resolves (or if it fails).
*/
getBuildInfo(): BuildInfo {
return {
version: getSenchoVersion(),
channel: this.imageRef ? classifyBuildChannel(this.imageRef) : 'unknown',
imageRef: this.imageRef,
imageId: this.imageIdHex,
revision: this.revision,
};
}
/**
* Resolves once the detached revision enrichment has settled (success or
* failure), or immediately when none was started. Awaiting cannot hang or
* throw (enrichment is bounded and failure-isolated). A reader that needs
* the final `revision` awaits this before getBuildInfo() so a successful
* response never freezes a transient null.
*/
async whenRevisionResolved(): Promise<void> {
if (this.enrichmentPromise) await this.enrichmentPromise;
}
/**
* Resolve the immutable revision from the running image. For a dev-repo image
* carrying a pinned `dev-<sha>` tag, the tag itself is the revision. Otherwise
* the running image's `RepoDigests` are inspected for a digest matching the
* running reference. Any failure (inspect rejection, timeout, no matching
* digest) leaves `revision` null; enrichment never throws to the caller.
*/
private async enrichRevision(imageRef: string | null, imageIdHex: string | null): Promise<void> {
try {
let revision: string | null = null;
if (imageRef && isSenchoDevRepository(imageRef)) {
const tag = parseImageRef(imageRef)?.tag;
if (tag && /^dev-[0-9a-f]{7,40}$/.test(tag)) revision = tag;
}
if (!revision && imageRef && imageIdHex) {
revision = await this.resolveDigestRevision(imageRef, imageIdHex);
}
this.revision = revision;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn('[SelfIdentity] build revision enrichment failed:', message);
}
}
private async resolveDigestRevision(imageRef: string, imageIdHex: string): Promise<string | null> {
const parsed = parseImageRef(imageRef);
if (!parsed) return null;
const inspected = await withTimeout(defaultInspectImage(imageIdHex), 2000, 'build revision inspect');
return selectLocalRepoDigest(inspected.RepoDigests ?? [], parsed);
}
/** True when the given container ID or name matches the running Sencho container. Accepts short or full IDs. */
isOwnContainer(idOrName: string): boolean {
if (!idOrName) return false;
@@ -205,11 +291,14 @@ class SelfIdentityService {
this.containerName = null;
this.composeProjectName = null;
this.imageIdHex = null;
this.imageRef = null;
this.revision = null;
this.networkIds.clear();
this.networkNames.clear();
this.volumeNames.clear();
this.initialized = false;
this.initializePromise = null;
this.enrichmentPromise = null;
}
private static stripSha(s: string): string {
+6 -2
View File
@@ -27,13 +27,17 @@ export type SelfDevBuildDetectResult =
| { kind: 'inconclusive'; reason: string };
/** The subset of `docker image inspect` output the detector reads. */
interface InspectedImage {
export interface InspectedImage {
RepoDigests: string[];
Os: string;
Architecture: string;
}
async function defaultInspectImage(imageId: string): Promise<InspectedImage> {
/** Bounded read of `docker image inspect` (RepoDigests, OS, architecture) for a
* resolved image. Reused by `SelfIdentityService` for revision enrichment;
* callers wrap it so any rejection stays isolated from the fields already
* captured. */
export async function defaultInspectImage(imageId: string): Promise<InspectedImage> {
const inspect = await DockerController.getInstance().getDocker().getImage(`sha256:${imageId}`).inspect();
return { RepoDigests: inspect.RepoDigests ?? [], Os: inspect.Os, Architecture: inspect.Architecture };
}
+1 -1
View File
@@ -108,7 +108,7 @@ The **Plan** card lists:
- **License** (Community only): a link to view the AGPLv3 source on GitHub.
- **Recovery Vault** (Admiral only): confirms the entitlement is included in your subscription.
- **Hardened Build** (Admiral only): a **Switch to Hardened** button; see [Switching to Hardened Build](#switching-to-hardened-build) below.
- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running.
- **Current image** and **Channel**: the image reference and channel (`Community` or `Hardened`) this control plane is currently running. These rows reflect the running build, not the configured target, so a compose edit does not change them until the container is recreated. A hardened image is shown as `Restricted` to non-administrators.
- **Customer**, **Product**, and **License key** (active licenses only): purchase metadata and your key masked to its last four characters (`****-****-****-XXXX`). The full key is never re-displayed after activation.
## Switching to Hardened Build
+14
View File
@@ -154,3 +154,17 @@ Maintainers publish these from open PRs for external validation. They are unsign
|---|---|---|
| `pr-<N>` | `saelix/sencho:pr-1526` | Each re-run of the preview workflow for that PR |
| `preview-<sha>` | `saelix/sencho:preview-abc1234` | Never (immutable per build) |
## Reading your running build
Sencho surfaces the build it is actually running in **Settings → About → Build** (and in the **Channel** and **Current image** rows of **Settings → Admiral Account**). These fields come from the running container's identity, not from the compose file, so a compose edit does not change them until the container is recreated.
| Field | Meaning |
|---|---|
| **Version** | The packaged semantic version of the build. A dev build keeps the last stable version here, which is why the Channel row matters. |
| **Channel** | The build track of the running image: `Dev`, `Preview`, `Stable`, or `Unknown`. A `dev` or `dev-<sha>` image reads `Dev`; a `pr-<N>` or `preview-<sha>` image reads `Preview`; a release image reads `Stable`. |
| **Current image** | The image reference the container was started with, for example `ghcr.io/studio-saelix/sencho-dev:dev-a1b2c3d`. |
| **Revision** | The immutable digest this build resolves to, or the pinned `dev-<sha>` tag when the running image is on the integration track. |
| **Image ID** | The first twelve characters of the running image's sha256 identifier; click to copy the full id. |
These fields describe the **control plane** instance you are logged in to, not remote nodes. When identity metadata cannot be determined, the reference fields read `Unknown` rather than guessing. A hardened image viewed by a non-administrator shows `Restricted` for the reference fields instead of `Unknown`.
+9 -3
View File
@@ -167,8 +167,8 @@ Activate, view, or deactivate the license for this Sencho control plane, and see
| **Sencho Admiral** | The active license on this control plane, with a tier badge. Community instances see an upgrade prompt here instead. |
| **Recovery Vault** | Whether the current subscription includes Recovery Vault entitlement. |
| **Hardened Build** | Switches this control plane between the Community image channel and the Admiral Hardened Build channel. Review entitlement and registry access before switching; see [Plans](/features/licensing#feature-breakdown) for what Hardened Build changes. |
| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. |
| **Channel** | The active image channel (Community or Hardened). |
| **Current image** | The image reference this control plane is currently running, so you can confirm which channel took effect after a switch. A hardened image is shown as `Restricted` to non-administrators. |
| **Channel** | The running image channel this control plane is on: Community, Hardened, or Unknown. Reflects the running build, not the configured target. |
| **Customer** | The customer name on file with Lemon Squeezy (paid plans only). |
| **Product** | The product (paid plans only). |
| **License key** | The active key, masked to the last four characters. |
@@ -751,9 +751,15 @@ Displays instance information at a glance.
| Field | Description |
|-------|-------------|
| **Version** | Current Sencho version. |
| **Version** | The semantic version of this control plane instance. |
| **Channel** | The build track this control plane is running: `Dev`, `Preview`, `Stable`, or `Unknown`. A dev build is identified here even when its packaged version still matches the previous stable release. |
| **Current image** | The image reference this control plane was started with. A compose edit changes the configured target until the container is recreated; this row always shows the running image, never the configured one. |
| **Revision** | The immutable digest or pinned `dev-<sha>` tag this build resolves to, or `Unknown` when it cannot be determined. |
| **Image ID** | The first twelve characters of the running image's sha256 identifier. Click to copy the full id. Hidden when no image identity is available. |
| **Tier** | Community or Admiral badge. |
| **Plan Status** | active, trial, expired, or community (Admiral entitlement state, not the AGPL software license). |
| **Instance ID** | First eight characters of the unique identifier for this Sencho control plane (used by the license server to identify it). |
The Build rows describe the **control plane** instance (the Sencho you are logged in to), not remote nodes. When identity metadata is unavailable, the reference rows read `Unknown` rather than inferring a value. For a hardened image seen by a non-administrator, the reference fields read `Restricted` instead. See [Verifying images](/operations/verifying-images) for how the build tracks and immutable tags relate.
The **Links** section contains Source code, AGPLv3 License, Licensing documentation, and Changelog links.
+8 -5
View File
@@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './context/AuthContext';
import { useReducedMotion } from './hooks/use-theme';
import { NodeProvider } from './context/NodeContext';
import { LicenseProvider } from './context/LicenseContext';
import { BuildInfoProvider } from './context/BuildInfoProvider';
import { Login } from './components/Login';
import { Setup } from './components/Setup';
import EditorLayout from './components/EditorLayout';
@@ -67,11 +68,13 @@ function AppContent() {
</Button>
</div>
)}
<EditorLayout />
{/* Portal lives inside LicenseProvider so the editor surface and its
portalled overlays can read license state via useLicense().
Outer DeployFeedbackProvider is still an ancestor through App. */}
<DeployFeedbackPortal />
<BuildInfoProvider>
<EditorLayout />
{/* Portal lives inside LicenseProvider so the editor surface and its
portalled overlays can read license state via useLicense().
Outer DeployFeedbackProvider is still an ancestor through App. */}
<DeployFeedbackPortal />
</BuildInfoProvider>
</LicenseProvider>
</NodeProvider>
</MotionProvider>
+3
View File
@@ -22,6 +22,7 @@ import { useOverlayState } from './EditorLayout/hooks/useOverlayState';
import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions';
import { useSelectedStackLiveRefresh } from './EditorLayout/hooks/useSelectedStackLiveRefresh';
import { useTheme } from '@/hooks/use-theme';
import { useBuildInfo } from '@/hooks/useBuildInfo';
import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch';
import { useNotifications } from './EditorLayout/hooks/useNotifications';
import { useContainerStats } from './EditorLayout/hooks/useContainerStats';
@@ -453,6 +454,7 @@ export default function EditorLayout() {
const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill);
const { isDarkMode } = useTheme();
const { buildInfo } = useBuildInfo();
// ---- Mobile shell (below md) ---------------------------------------------
// Desktop renders the persistent sidebar + workspace untouched. On a phone we
@@ -974,6 +976,7 @@ export default function EditorLayout() {
const sidebarEl = (
<StackSidebar
isDarkMode={isDarkMode}
buildInfo={buildInfo}
nodeSwitcherSlot={
<NodeSwitcher
onManageNodes={() => openSettings('nodes')}
+72 -1
View File
@@ -1,15 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Home, Radar, Clock } from 'lucide-react';
import { MobileTabBar } from './MobileTabBar';
import { useBuildInfo } from '@/hooks/useBuildInfo';
import type { BuildInfo } from '@/context/BuildInfoProvider';
import type { NavItem } from './EditorLayout/hooks/useViewNavigationState';
vi.mock('@/hooks/useBuildInfo', () => ({
useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })),
}));
const mockUseBuildInfo = vi.mocked(useBuildInfo);
const allItems: NavItem[] = [
{ value: 'dashboard', label: 'Home', icon: Home },
{ value: 'fleet', label: 'Fleet', icon: Radar },
{ value: 'scheduled-ops', label: 'Schedules', icon: Clock },
];
function buildInfo(channel: BuildInfo['channel']): BuildInfo {
return {
version: '0.97.1',
channel,
imageChannel: 'community',
imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1',
imageId: 'a'.repeat(64),
revision: null,
restricted: false,
};
}
const noPill = { buildInfo: null, status: 'ready' as const, retry: vi.fn() };
function renderBar(over: Partial<React.ComponentProps<typeof MobileTabBar>> = {}) {
const props: React.ComponentProps<typeof MobileTabBar> = {
navItems: allItems,
@@ -76,3 +98,52 @@ describe('MobileTabBar', () => {
expect(screen.getByRole('button', { name: 'Stacks' })).not.toHaveAttribute('aria-current');
});
});
describe('MobileTabBar build-identity pill', () => {
beforeEach(() => {
mockUseBuildInfo.mockReturnValue(noPill);
});
it('shows a text DEV pill for a dev build, not an interactive control', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() });
renderBar();
expect(screen.getByText('DEV')).toBeInTheDocument();
// The pill is a plain span: it adds no competing tap target in the tab row.
expect(screen.queryByRole('button', { name: 'DEV' })).not.toBeInTheDocument();
});
it('shows a text PREVIEW pill for a preview build', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('preview'), status: 'ready', retry: vi.fn() });
renderBar();
expect(screen.getByText('PREVIEW')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'PREVIEW' })).not.toBeInTheDocument();
});
it('renders no pill for a stable build', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('stable'), status: 'ready', retry: vi.fn() });
renderBar();
expect(screen.queryByText('DEV')).not.toBeInTheDocument();
expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument();
});
it('keeps the tab touch targets present alongside the pill', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() });
renderBar();
expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('lays the pill in-flow as a non-overlapping sibling of the tabs', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo('dev'), status: 'ready', retry: vi.fn() });
renderBar();
const pill = screen.getByText('DEV');
// In-flow (flex sibling), not absolutely positioned over the Settings tab.
expect(pill).not.toHaveClass('absolute');
expect(pill).toHaveClass('self-center', 'shrink-0');
// Sibling of the tab buttons inside the nav, so flexbox reserves its own
// region rather than letting it overlap the rightmost tab.
const settingsTab = screen.getByRole('button', { name: 'Settings' });
expect(pill.parentElement).toBe(settingsTab.parentElement);
});
});
+18 -1
View File
@@ -1,6 +1,7 @@
import { Home, Layers, Radar, Clock, Settings as SettingsIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useBuildInfo } from '@/hooks/useBuildInfo';
import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState';
import type { MobileView } from './EditorLayout/mobile-surface';
@@ -45,7 +46,12 @@ export function MobileTabBar({
onNavigate,
onSettings,
}: MobileTabBarProps) {
const { buildInfo } = useBuildInfo();
const has = (value: ActiveView) => navItems.some(i => i.value === value);
const channel = buildInfo?.channel;
const showPill = channel === 'dev' || channel === 'preview';
const pillLabel = channel === 'dev' ? 'DEV' : 'PREVIEW';
const tabs: Tab[] = [
{ id: 'home', label: 'Home', icon: Home },
@@ -79,7 +85,7 @@ export function MobileTabBar({
aria-label="Primary mobile"
data-sn-glass="mobile-tabbar"
className={cn(
'md:hidden flex shrink-0 items-stretch',
'md:hidden relative flex shrink-0 items-stretch',
'border-t border-hairline',
'bg-[color-mix(in_oklch,var(--card)_70%,transparent)] backdrop-blur-md backdrop-saturate-150',
'pb-[max(8px,env(safe-area-inset-bottom))]',
@@ -108,6 +114,17 @@ export function MobileTabBar({
</button>
);
})}
{showPill ? (
<span
className={
channel === 'dev'
? 'shrink-0 self-center mr-2 pointer-events-none font-mono text-[8px] leading-none uppercase tracking-[0.16em] px-1 py-0.5 rounded bg-warning/15 text-warning border border-warning/30'
: 'shrink-0 self-center mr-2 pointer-events-none font-mono text-[8px] leading-none uppercase tracking-[0.16em] px-1 py-0.5 rounded bg-brand/15 text-brand border border-brand/30'
}
>
{pillLabel}
</span>
) : null}
</nav>
);
}
@@ -1,5 +1,11 @@
import { useState } from 'react';
import { useLicense } from '@/context/LicenseContext';
import { useBuildInfo } from '@/hooks/useBuildInfo';
import { TierBadge } from '@/components/TierBadge';
import { Badge } from '@/components/ui/badge';
import { FlaskConical } from 'lucide-react';
import { copyToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui/toast-store';
import { TogglePill } from '@/components/ui/toggle-pill';
import { useWhatsNewPreference } from '@/hooks/useWhatsNewPreference';
import { whatsNewEntries } from '@/whats-new/entries';
@@ -15,16 +21,110 @@ import {
const linkClassName =
'font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-brand hover:text-brand/80 transition-colors';
const mono = 'font-mono text-sm text-stat-value';
function BuildChannelChip({ label }: { label: string }) {
if (label === 'Dev') {
return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
<FlaskConical className="w-2.5 h-2.5 mr-0.5" strokeWidth={1.5} /> Dev
</Badge>
);
}
if (label === 'Preview') {
return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-brand/15 text-brand border-brand/30 shrink-0">
Preview
</Badge>
);
}
return <span className={mono}>{label}</span>;
}
export function AboutSection() {
const { license } = useLicense();
const { buildInfo, status } = useBuildInfo();
const { enabled: whatsNewEnabled, setEnabled: setWhatsNewEnabled } = useWhatsNewPreference();
const [copied, setCopied] = useState(false);
// Loading surfaces a placeholder and error surfaces "Unknown" (truthful);
// the reference and revision fields below read "Restricted" for a redacted
// hardened image, before their null-check.
const channelLabel = (() => {
if (status === 'loading') return '…';
if (status === 'error' || !buildInfo) return 'Unknown';
switch (buildInfo.channel) {
case 'dev': return 'Dev';
case 'preview': return 'Preview';
case 'stable': return 'Stable';
default: return 'Unknown';
}
})();
const resolveLabel = (value: string | null | undefined) =>
buildInfo?.restricted
? 'Restricted'
: status === 'loading'
? '…'
: status === 'error'
? 'Unknown'
: value ?? 'Unknown';
const imageRefLabel = resolveLabel(buildInfo?.imageRef);
const revisionLabel = resolveLabel(buildInfo?.revision);
const imageIdLabel =
status === 'loading' ? '…'
: status === 'error' || !buildInfo?.imageId ? 'Unknown'
: `sha256:${buildInfo.imageId.slice(0, 12)}`;
const copyImageId = async () => {
if (!buildInfo?.imageId) return;
try {
await copyToClipboard(`sha256:${buildInfo.imageId}`);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error('Could not copy the image id.');
}
};
return (
<div className="flex flex-col gap-10">
<SettingsSection title="Build">
<SettingsField label="Version">
<span className="font-mono text-sm text-stat-value">v{__APP_VERSION__}</span>
<span className={mono}>v{buildInfo?.version ?? __APP_VERSION__}</span>
</SettingsField>
<SettingsField
label="Channel"
helper="The build track this control plane instance is running."
>
<BuildChannelChip label={channelLabel} />
</SettingsField>
<SettingsField
label="Current image"
helper="The image this control plane was started with. A compose edit changes the configured target until the container is recreated."
>
<span className={`${mono} break-all`}>{imageRefLabel}</span>
</SettingsField>
<SettingsField
label="Revision"
helper="The immutable digest or pinned dev commit this build resolves to."
>
<span className={`${mono} break-all`}>{revisionLabel}</span>
</SettingsField>
{imageIdLabel !== 'Unknown' && imageIdLabel !== '…' ? (
<SettingsField
label="Image ID"
helper="The sha256 identifier of the running image. Click to copy the full id."
>
<button
type="button"
onClick={() => void copyImageId()}
className="text-xs font-mono bg-muted px-2 py-1 rounded cursor-pointer text-stat-value hover:text-brand transition-colors"
>
{copied ? 'Copied' : imageIdLabel}
</button>
</SettingsField>
) : null}
<SettingsField label="Tier">
<TierBadge />
</SettingsField>
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { useBuildInfo } from '@/hooks/useBuildInfo';
import { TierBadge } from '@/components/TierBadge';
import {
Crown, CheckCircle, XCircle, Clock, ExternalLink,
@@ -47,8 +48,8 @@ function formatChannel(channel: ImageChannel): string {
return 'Community';
case 'hardened':
return 'Hardened';
default:
return 'Custom';
case 'unknown':
return 'Unknown';
}
}
@@ -64,6 +65,7 @@ function getTierMastheadValue(tier?: string): string {
export function LicenseSection() {
const { license, isPaid, activate, deactivate } = useLicense();
const { buildInfo, status: buildInfoStatus } = useBuildInfo();
const [licenseKeyInput, setLicenseKeyInput] = useState('');
const [isActivating, setIsActivating] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
@@ -253,16 +255,28 @@ export function LicenseSection() {
) : null}
<SettingsField
label="Current image"
helper={channelStatus?.channel === 'hardened' && !channelStatus.composeImageRef
helper={buildInfo?.restricted
? 'Hardened image details are available to administrators only.'
: 'Current image channel for this control plane.'}
>
<span className="font-mono text-xs text-stat-value break-all">
{channelStatus?.composeImageRef ?? formatChannel(channelStatus?.channel ?? 'unknown')}
{buildInfo?.restricted
? 'Restricted'
: buildInfoStatus === 'loading'
? '…'
: buildInfoStatus === 'error'
? 'Unknown'
: buildInfo?.imageRef ?? 'Unknown'}
</span>
</SettingsField>
<SettingsField label="Channel">
<span className="text-sm text-stat-value">{formatChannel(channelStatus?.channel ?? 'unknown')}</span>
<span className="text-sm text-stat-value">
{buildInfoStatus === 'loading'
? '…'
: buildInfoStatus === 'error' || !buildInfo
? 'Unknown'
: formatChannel(buildInfo.imageChannel)}
</span>
</SettingsField>
{channelStatus?.operation?.state === 'failed' ? (
<SettingsField
@@ -42,6 +42,36 @@ vi.mock('@/hooks/useWhatsNewPreference', () => ({
useWhatsNewPreference: () => ({ enabled: true, setEnabled: mockSetEnabled, hasUnseen: false, markSeen: vi.fn() }),
}));
vi.mock('@/hooks/useBuildInfo', () => ({
useBuildInfo: vi.fn(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() })),
}));
import { useBuildInfo } from '@/hooks/useBuildInfo';
import type { BuildInfo } from '@/context/BuildInfoProvider';
const { mockCopyToClipboard, mockToastError } = vi.hoisted(() => ({
mockCopyToClipboard: vi.fn(),
mockToastError: vi.fn(),
}));
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: mockCopyToClipboard }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: mockToastError, success: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
const mockUseBuildInfo = vi.mocked(useBuildInfo);
function buildInfo(over: Partial<BuildInfo> = {}): BuildInfo {
return {
version: '0.97.1',
channel: 'dev',
imageChannel: 'community',
imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev',
imageId: 'a'.repeat(64),
revision: 'dev-abc1234',
restricted: false,
...over,
};
}
// The shipped entries.json is empty, so populate it here; the empty state has its own file.
vi.mock('@/whats-new/entries', () => ({
whatsNewEntries: [{ id: 'entry-a', title: 'A feature', blurb: 'Does a thing.' }],
@@ -91,3 +121,49 @@ describe('AboutSection Preferences', () => {
expect(mockSetEnabled).toHaveBeenCalledWith(false);
});
});
describe('AboutSection Build identity', () => {
it('shows the runtime channel, current image, revision and version', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() });
render(<AboutSection />);
expect(screen.getByText('Dev')).toBeTruthy();
expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toBeTruthy();
expect(screen.getByText('dev-abc1234')).toBeTruthy();
expect(screen.getByText('v0.97.1')).toBeTruthy();
});
it('labels redacted hardened reference fields Restricted, not Unknown', () => {
mockUseBuildInfo.mockReturnValue({
buildInfo: buildInfo({ channel: 'stable', restricted: true, imageRef: null, revision: null }),
status: 'ready',
retry: vi.fn(),
});
render(<AboutSection />);
expect(screen.getByText('Stable')).toBeTruthy();
expect(screen.getAllByText('Restricted').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('Unknown')).toBeNull();
});
it('shows Unknown for reference fields when build info is unavailable', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: null, status: 'error', retry: vi.fn() });
render(<AboutSection />);
expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
});
it('wraps long image and revision tokens so they do not overflow', () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() });
render(<AboutSection />);
expect(screen.getByText('ghcr.io/studio-saelix/sencho-dev:dev')).toHaveClass('break-all');
expect(screen.getByText('dev-abc1234')).toHaveClass('break-all');
});
it('surfaces an error toast when copying the image id fails', async () => {
mockUseBuildInfo.mockReturnValue({ buildInfo: buildInfo(), status: 'ready', retry: vi.fn() });
mockCopyToClipboard.mockRejectedValue(new Error('clipboard blocked'));
render(<AboutSection />);
await userEvent.click(screen.getByRole('button', { name: /sha256:/ }));
expect(mockCopyToClipboard).toHaveBeenCalledWith(`sha256:${'a'.repeat(64)}`);
expect(mockToastError).toHaveBeenCalledWith('Could not copy the image id.');
});
});
@@ -43,6 +43,10 @@ vi.mock('@/hooks/useWhatsNewPreference', () => ({
useWhatsNewPreference: () => ({ enabled: true, setEnabled: vi.fn(), hasUnseen: false, markSeen: vi.fn() }),
}));
vi.mock('@/hooks/useBuildInfo', () => ({
useBuildInfo: () => ({ buildInfo: null, status: 'ready', retry: vi.fn() }),
}));
describe("AboutSection with no What's New entries authored", () => {
it('hides the Preferences section entirely, so no toggle describes an absent icon', () => {
render(<AboutSection />);
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { LicenseInfo } from '@/context/LicenseContext';
import type { BuildInfoContextType } from '@/context/BuildInfoProvider';
const useLicenseMock = vi.fn();
@@ -12,6 +13,12 @@ vi.mock('../MastheadStatsContext', () => ({
useMastheadStats: () => {},
}));
const useBuildInfoMock = vi.fn<() => BuildInfoContextType>(() => ({ buildInfo: null, status: 'ready', retry: vi.fn() }));
vi.mock('@/hooks/useBuildInfo', () => ({
useBuildInfo: () => useBuildInfoMock(),
}));
vi.mock('@/components/TierBadge', () => ({
TierBadge: () => <span data-testid="tier-badge">tier</span>,
}));
@@ -135,3 +142,72 @@ describe('LicenseSection pricing link', () => {
expect(screen.getByText('See pricing')).toBeTruthy();
});
});
describe('LicenseSection build rows (running identity consistency)', () => {
beforeEach(() => {
useLicenseMock.mockReset();
useBuildInfoMock.mockReset();
useBuildInfoMock.mockReturnValue({ buildInfo: null, status: 'ready', retry: vi.fn() });
mockLicense(baseLicense());
});
it('renders the running Community channel and image ref, regardless of configured target', () => {
useBuildInfoMock.mockReturnValue({
buildInfo: {
version: '0.97.1',
channel: 'stable',
imageChannel: 'community',
imageRef: 'ghcr.io/studio-saelix/sencho:0.97.1',
imageId: 'a'.repeat(64),
revision: null,
restricted: false,
},
status: 'ready',
retry: vi.fn(),
});
render(<LicenseSection />);
// The configured/compose target is hardened, but the running build is
// Community: the row must show the running image, never the target.
expect(screen.getByText('ghcr.io/studio-saelix/sencho:0.97.1')).toBeTruthy();
expect(screen.queryByText('Hardened')).toBeNull();
});
it('renders a hardened running image as Hardened channel and Restricted image, not Unknown', () => {
useBuildInfoMock.mockReturnValue({
buildInfo: {
version: '0.97.1',
channel: 'stable',
imageChannel: 'hardened',
imageRef: null,
imageId: 'b'.repeat(64),
revision: null,
restricted: true,
},
status: 'ready',
retry: vi.fn(),
});
render(<LicenseSection />);
expect(screen.getByText('Hardened')).toBeTruthy();
expect(screen.getByText('Restricted')).toBeTruthy();
expect(screen.queryByText('Unknown')).toBeNull();
});
it('labels an unclassifiable running image Channel Unknown, never Custom', () => {
useBuildInfoMock.mockReturnValue({
buildInfo: {
version: '0.97.1',
channel: 'unknown',
imageChannel: 'unknown',
imageRef: null,
imageId: null,
revision: null,
restricted: false,
},
status: 'ready',
retry: vi.fn(),
});
render(<LicenseSection />);
expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
expect(screen.queryByText('Custom')).toBeNull();
});
});
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SidebarBrand } from './SidebarBrand';
import { chipDetail } from './chipDetail';
import type { BuildInfo } from '@/context/BuildInfoProvider';
function info(channel: BuildInfo['channel']): BuildInfo {
return {
version: '0.97.1',
channel,
imageChannel: 'community',
imageRef: channel === 'dev' ? 'ghcr.io/studio-saelix/sencho-dev:dev' : 'ghcr.io/studio-saelix/sencho:0.97.1',
imageId: 'a'.repeat(64),
revision: null,
restricted: false,
};
}
describe('SidebarBrand build-identity chip', () => {
it('shows a DEV text chip for a dev build', () => {
render(<SidebarBrand isDarkMode={false} buildInfo={info('dev')} />);
const chip = screen.getByText('DEV');
expect(chip).toBeInTheDocument();
// Text is the cue, not color alone.
expect(chip.tagName).toBe('SPAN');
expect(chip.textContent).toContain('DEV');
});
it('shows a PREVIEW text chip for a preview build', () => {
render(<SidebarBrand isDarkMode={false} buildInfo={info('preview')} />);
expect(screen.getByText('PREVIEW')).toBeInTheDocument();
});
it('renders no chip for a stable build', () => {
render(<SidebarBrand isDarkMode={false} buildInfo={info('stable')} />);
expect(screen.queryByText('DEV')).not.toBeInTheDocument();
expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument();
});
it('renders no chip when build info is unavailable', () => {
render(<SidebarBrand isDarkMode={false} buildInfo={null} />);
expect(screen.queryByText('DEV')).not.toBeInTheDocument();
expect(screen.queryByText('PREVIEW')).not.toBeInTheDocument();
});
it('prefers the runtime version when available', () => {
render(<SidebarBrand isDarkMode={false} buildInfo={info('stable')} />);
expect(screen.getByText('v0.97.1')).toBeInTheDocument();
});
});
describe('chipDetail', () => {
it('reads Restricted for a redacted hardened reference', () => {
const b: BuildInfo = { ...info('stable'), restricted: true, imageRef: null, revision: null };
expect(chipDetail(b)).toBe('Restricted');
});
it('combines the reference and revision when both are present', () => {
const b: BuildInfo = { ...info('dev'), revision: 'dev-abc1234' };
expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev · dev-abc1234');
});
it('reads the reference alone when the revision is unknown', () => {
const b: BuildInfo = { ...info('dev'), revision: null };
expect(chipDetail(b)).toBe('ghcr.io/studio-saelix/sencho-dev:dev');
});
it('reads Unknown when the reference is absent and not restricted', () => {
const b: BuildInfo = { ...info('dev'), imageRef: null };
expect(chipDetail(b)).toBe('Unknown');
});
});
@@ -1,8 +1,17 @@
import { FlaskConical } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { BuildInfo } from '@/context/BuildInfoProvider';
import { chipDetail } from './chipDetail';
interface SidebarBrandProps {
isDarkMode: boolean;
buildInfo?: BuildInfo | null;
}
export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
export function SidebarBrand({ isDarkMode, buildInfo }: SidebarBrandProps) {
const channel = buildInfo?.channel;
const showChip = channel === 'dev' || channel === 'preview';
return (
<div className="flex items-center justify-center gap-3 px-4 h-14 border-b border-glass-border">
<img
@@ -10,11 +19,32 @@ export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
alt=""
className="w-9 h-9 shrink-0"
/>
<div className="flex items-baseline gap-1.5">
<div className="flex items-center gap-1.5">
<span className="font-display italic text-[28px] leading-none text-foreground">Sencho</span>
<span className="font-mono text-[10px] tracking-[0.18em] uppercase text-stat-subtitle">
v{__APP_VERSION__}
v{buildInfo?.version ?? __APP_VERSION__}
</span>
{showChip ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className={
channel === 'dev'
? 'inline-flex items-center font-mono text-[9px] leading-3 uppercase tracking-[0.16em] px-1.5 py-0.5 rounded bg-warning/15 text-warning border border-warning/30'
: 'inline-flex items-center font-mono text-[9px] leading-3 uppercase tracking-[0.16em] px-1.5 py-0.5 rounded bg-brand/15 text-brand border border-brand/30'
}
>
{channel === 'dev' ? <FlaskConical className="w-2 h-2 mr-1" strokeWidth={1.5} /> : null}
{channel === 'dev' ? 'DEV' : 'PREVIEW'}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<span className="font-mono text-[10px]">{chipDetail(buildInfo)}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
</div>
);
@@ -11,10 +11,12 @@ import { StackList, type StackListProps } from './StackList';
import type { FilterChip } from './sidebar-types';
import type { BulkAction } from '@/hooks/useBulkStackActions';
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
import type { BuildInfo } from '@/context/BuildInfoProvider';
import { isStacksListSettled } from './stacksLoadUi';
export interface StackSidebarProps {
isDarkMode: boolean;
buildInfo?: BuildInfo | null;
nodeSwitcherSlot: ReactNode;
createStackSlot: ReactNode | null;
onScan: () => void;
@@ -43,7 +45,7 @@ export interface StackSidebarProps {
export function StackSidebar(props: StackSidebarProps) {
const {
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
isDarkMode, buildInfo, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
list, activitySummary, onActivityAction,
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
@@ -76,7 +78,7 @@ export function StackSidebar(props: StackSidebarProps) {
its kicker chip), so the in-sidebar brand and node rows are redundant
there and hidden to save vertical space. */}
<div className="max-md:hidden">
<SidebarBrand isDarkMode={isDarkMode} />
<SidebarBrand isDarkMode={isDarkMode} buildInfo={buildInfo} />
</div>
<div className="max-md:hidden px-4 pt-2 pb-0">{nodeSwitcherSlot}</div>
{canCreate && createStackSlot !== null && (
@@ -0,0 +1,11 @@
import type { BuildInfo } from '@/context/BuildInfoProvider';
/** Detail shown under the DEV/PREVIEW chip, or the truthful Unknown / Restricted
* states when the running reference is unavailable or redacted for this user. */
export function chipDetail(buildInfo: BuildInfo | null | undefined): string {
if (buildInfo?.restricted) return 'Restricted';
if (buildInfo?.imageRef) {
return buildInfo.revision ? `${buildInfo.imageRef} · ${buildInfo.revision}` : buildInfo.imageRef;
}
return 'Unknown';
}
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { BuildInfoProvider } from './BuildInfoProvider';
import { useBuildInfo } from '@/hooks/useBuildInfo';
const { currentUserRef } = vi.hoisted(() => ({
currentUserRef: { value: null as { username: string; role: string } | null },
}));
vi.mock('./AuthContext', () => ({
useAuth: () => ({ user: currentUserRef.value }),
}));
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}));
import { apiFetch } from '@/lib/api';
const restrictedViewer = {
version: '0.97.1',
channel: 'stable',
imageChannel: 'hardened',
imageRef: null,
imageId: 'b'.repeat(64),
revision: null,
restricted: true,
};
const adminCommunity = {
version: '0.97.1',
channel: 'dev',
imageChannel: 'community',
imageRef: 'ghcr.io/studio-saelix/sencho-dev:dev',
imageId: 'a'.repeat(64),
revision: null,
restricted: false,
};
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { status });
}
function Harness() {
const { buildInfo, status } = useBuildInfo();
return <div data-testid="s">{status}:{buildInfo?.imageRef ?? 'none'}</div>;
}
function wrapper({ children }: { children: ReactNode }) {
return <BuildInfoProvider>{children}</BuildInfoProvider>;
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('BuildInfoProvider', () => {
beforeEach(() => {
currentUserRef.value = null;
vi.mocked(apiFetch).mockReset();
});
it('clears permission-filtered state and refetches when the auth identity changes', async () => {
currentUserRef.value = { username: 'admin', role: 'admin' };
vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity));
const { rerender } = render(<Harness />, { wrapper });
await flush();
expect(screen.getByTestId('s').textContent).toContain('ready');
expect(screen.getByTestId('s').textContent).toContain('ghcr.io/studio-saelix/sencho-dev:dev');
// A viewer session must never see the admin-cached ref: the provider
// clears immediately and rejects the stale in-flight completion.
currentUserRef.value = { username: 'viewer', role: 'viewer' };
vi.mocked(apiFetch).mockResolvedValue(json(restrictedViewer));
rerender(<Harness />);
// Cleared synchronously on the identity change, before the refetch lands.
expect(screen.getByTestId('s').textContent).toBe('loading:none');
await flush();
expect(screen.getByTestId('s').textContent).toBe('ready:none');
});
it('rejects a stale in-flight completion from a prior identity', async () => {
currentUserRef.value = { username: 'admin', role: 'admin' };
let resolveAdmin!: (r: Response) => void;
vi.mocked(apiFetch).mockReturnValue(new Promise((res) => { resolveAdmin = res; }));
const { rerender } = render(<Harness />, { wrapper });
currentUserRef.value = { username: 'viewer', role: 'viewer' };
vi.mocked(apiFetch).mockResolvedValue(json(restrictedViewer));
rerender(<Harness />);
await flush();
expect(screen.getByTestId('s').textContent).toBe('ready:none');
// The old admin request resolving later must not surface its ref.
await act(async () => { resolveAdmin(json(adminCommunity)); });
expect(screen.getByTestId('s').textContent).toBe('ready:none');
});
it('shares one fetch across two consumers', async () => {
currentUserRef.value = { username: 'admin', role: 'admin' };
vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity));
render(
<BuildInfoProvider>
<Harness />
<Harness />
</BuildInfoProvider>,
);
await flush();
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch).toHaveBeenCalledWith('/build-info', expect.objectContaining({ localOnly: true }));
expect(screen.getAllByTestId('s')).toHaveLength(2);
for (const el of screen.getAllByTestId('s')) {
expect(el.textContent).toContain('ghcr.io/studio-saelix/sencho-dev:dev');
}
});
it('surfaces error as Unknown and retries on focus', async () => {
currentUserRef.value = { username: 'admin', role: 'admin' };
vi.mocked(apiFetch).mockRejectedValue(new Error('down'));
render(<Harness />, { wrapper });
await flush();
expect(screen.getByTestId('s').textContent).toBe('error:none');
vi.mocked(apiFetch).mockResolvedValue(json(adminCommunity));
await act(async () => {
window.dispatchEvent(new Event('focus'));
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId('s').textContent).toBe('ready:ghcr.io/studio-saelix/sencho-dev:dev');
});
});
+103
View File
@@ -0,0 +1,103 @@
import { createContext, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { apiFetch } from '@/lib/api';
import { useAuth } from './AuthContext';
export type BuildChannel = 'stable' | 'dev' | 'preview' | 'unknown';
export type ImageChannel = 'community' | 'hardened' | 'unknown';
export type BuildInfoStatus = 'loading' | 'ready' | 'error';
/** Canonical runtime build identity of the control instance. Mirrors the
* /api/build-info response: imageRef and revision are nulled for hardened
* images when the viewer is not an admin, with `restricted` marking that
* redaction (the UI shows "Restricted", never "Unknown", when set).
* `restricted === true` implies `imageRef === null && revision === null`. */
export interface BuildInfo {
version: string | null;
channel: BuildChannel;
imageChannel: ImageChannel;
imageRef: string | null;
imageId: string | null;
revision: string | null;
restricted: boolean;
}
export interface BuildInfoContextType {
buildInfo: BuildInfo | null;
status: BuildInfoStatus;
retry: () => void;
}
// eslint-disable-next-line react-refresh/only-export-components
export const BuildInfoContext = createContext<BuildInfoContextType | undefined>(undefined);
/** Single owner of the control-instance build identity. Mounted inside the
* authenticated subtree. One shared fetch (localOnly, so it always targets the
* local hub); permission-filtered state is cleared the moment the auth identity
* or role changes, and stale in-flight completions from a prior session are
* rejected by a generation counter. Focus retries an error so it never sticks
* failed; consumers show a placeholder while loading and "Unknown" on error. */
export function BuildInfoProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [buildInfo, setBuildInfo] = useState<BuildInfo | null>(null);
const [status, setStatus] = useState<BuildInfoStatus>('loading');
const generationRef = useRef(0);
const statusRef = useRef<BuildInfoStatus>('loading');
const identityRef = useRef<string | null>(null);
const load = useCallback(async () => {
const gen = ++generationRef.current;
setStatus('loading');
try {
const res = await apiFetch('/build-info', { localOnly: true });
if (gen !== generationRef.current) return;
if (res.ok) {
const data = (await res.json()) as BuildInfo;
if (gen !== generationRef.current) return;
setBuildInfo(data);
setStatus('ready');
} else {
console.error(`[BuildInfo] fetch returned ${res.status}`);
setStatus('error');
}
} catch (err) {
if (gen !== generationRef.current) return;
console.error('[BuildInfo] fetch failed', err);
setStatus('error');
}
}, []);
// Reload whenever the signed-in identity or role changes. Clearing state
// synchronously (before the replacement fetch resolves) guarantees a
// hardened admin's reference never surfaces in a viewer session.
useEffect(() => {
const identity = user ? `${user.username}:${user.role}` : null;
if (identity !== identityRef.current) {
identityRef.current = identity;
setBuildInfo(null);
setStatus('loading');
void load();
}
}, [user, load]);
useEffect(() => {
statusRef.current = status;
}, [status]);
// A transient failure (auth expiry, hub restart) should not stick: retry the
// moment the tab regains focus.
useEffect(() => {
const onFocus = () => {
if (statusRef.current === 'error') void load();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [load]);
return (
<BuildInfoContext.Provider value={{ buildInfo, status, retry: () => void load() }}>
{children}
</BuildInfoContext.Provider>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useContext } from 'react';
import { BuildInfoContext, type BuildInfoContextType } from '@/context/BuildInfoProvider';
/** Consumer of the single shared control-instance build identity. The shell,
* About, and Admiral Account all read the same fetched BuildInfo by reference
* instead of issuing their own fetches. */
export function useBuildInfo(): BuildInfoContextType {
const context = useContext(BuildInfoContext);
if (context === undefined) {
throw new Error('useBuildInfo must be used within a BuildInfoProvider');
}
return context;
}