mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 01:15:55 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
@@ -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) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user