mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
feat(registries): add exact-ID tag browser with non-401 failures (#1613)
* feat(resources): show multi-stack usedByStacks on images Classify images with a deduped sorted stack reverse index, surface chips in the Images table and inspect sheet, and clear node-bound sheet selection on active-node change. * feat(registries): add exact-ID tag browser with non-401 failures Add GET /api/registries/:id/tags using credentials for that registry row only, map upstream auth failures to 424, and surface a Registry tags section on the image inspect sheet. * fix(registries): distinguish unreachable hosts from auth failures Map auth transport errors to REGISTRY_UPSTREAM (502), surface registry list-load failures in the tag panel, document Used by and Registry tags, and add parser coverage. * fix(registries): drop unused RegistryTagsPanel __test export The non-component export tripped react-refresh/only-export-components and failed Frontend lint in CI.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
vi.mock('../services/registry-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('../services/registry-api')>('../services/registry-api');
|
||||
return {
|
||||
...actual,
|
||||
listRegistryTagsResult: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { listRegistryTagsResult } from '../services/registry-api';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let RegistryService: typeof import('../services/RegistryService').RegistryService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ RegistryService } = await import('../services/RegistryService'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(listRegistryTagsResult).mockReset();
|
||||
});
|
||||
|
||||
describe('GET /api/registries/:id/tags', () => {
|
||||
it('returns tags for an exact registry id', async () => {
|
||||
const id = RegistryService.getInstance().create({
|
||||
name: 'Hub',
|
||||
url: 'https://index.docker.io/v1/',
|
||||
type: 'dockerhub',
|
||||
username: 'user',
|
||||
secret: 'token',
|
||||
});
|
||||
vi.mocked(listRegistryTagsResult).mockResolvedValue({ ok: true, tags: ['latest', '1.0'] });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/registries/${id}/tags`)
|
||||
.query({ repository: 'library/nginx' })
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toEqual(['latest', '1.0']);
|
||||
expect(res.body.registryId).toBe(id);
|
||||
expect(listRegistryTagsResult).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps upstream unauthorized to 424 with REGISTRY_UNAUTHORIZED (never 401)', async () => {
|
||||
const id = RegistryService.getInstance().create({
|
||||
name: 'Hub2',
|
||||
url: 'https://index.docker.io/v1/',
|
||||
type: 'dockerhub',
|
||||
username: 'user',
|
||||
secret: 'bad',
|
||||
});
|
||||
vi.mocked(listRegistryTagsResult).mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'REGISTRY_UNAUTHORIZED',
|
||||
message: 'Registry rejected credentials',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/registries/${id}/tags`)
|
||||
.query({ repository: 'library/nginx' })
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(424);
|
||||
expect(res.body.code).toBe('REGISTRY_UNAUTHORIZED');
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('rejects host-looking repository values', async () => {
|
||||
const id = RegistryService.getInstance().create({
|
||||
name: 'GHCR',
|
||||
url: 'ghcr.io',
|
||||
type: 'ghcr',
|
||||
username: 'user',
|
||||
secret: 'token',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/registries/${id}/tags`)
|
||||
.query({ repository: 'ghcr.io/org/app' })
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(listRegistryTagsResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps REGISTRY_UPSTREAM from the client to HTTP 502', async () => {
|
||||
const id = RegistryService.getInstance().create({
|
||||
name: 'Down',
|
||||
url: 'https://registry.example.invalid/',
|
||||
type: 'custom',
|
||||
username: 'user',
|
||||
secret: 'token',
|
||||
});
|
||||
vi.mocked(listRegistryTagsResult).mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'REGISTRY_UPSTREAM',
|
||||
message: 'Registry unreachable',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/registries/${id}/tags`)
|
||||
.query({ repository: 'org/app' })
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.code).toBe('REGISTRY_UPSTREAM');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown registry id', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/registries/999999/tags')
|
||||
.query({ repository: 'org/app' })
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ function fakeRequest(url: string, options: { method?: string }, cb: (res: EventE
|
||||
vi.mock('https', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
|
||||
vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
|
||||
|
||||
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, parseImageRef } from '../services/registry-api';
|
||||
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, listRegistryTagsResult, parseImageRef } from '../services/registry-api';
|
||||
|
||||
const TOKEN_BODY = JSON.stringify({ token: 'test-token' });
|
||||
const REMOTE = 'sha256:remote000000000000000000000000000000000000000000000000000000';
|
||||
@@ -234,4 +234,86 @@ describe('getRemoteDigestResult failure reasons', () => {
|
||||
route = () => { throw new Error('ENOTFOUND'); };
|
||||
expect(await get()).toEqual({ ok: false, reason: `Registry unreachable for ${REF} (ENOTFOUND)` });
|
||||
});
|
||||
|
||||
it('falls back to anonymous manifest lookup when auth transport fails', async () => {
|
||||
route = (url, method): FakeResp => {
|
||||
if (url.includes('auth.docker.io/token')) {
|
||||
throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' });
|
||||
}
|
||||
if (method === 'HEAD') return { statusCode: 200, headers: { 'docker-content-digest': REMOTE } };
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
expect(await get()).toEqual({ ok: true, digest: REMOTE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRegistryTagsResult', () => {
|
||||
const creds = { username: 'u', password: 'p' };
|
||||
const GHCR_CHALLENGE = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"';
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
function authThenTags(tagResp: FakeResp): (url: string, method: string) => FakeResp {
|
||||
return (url) => {
|
||||
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
|
||||
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 200, headers: {}, body: TOKEN_BODY };
|
||||
if (url.includes('/tags/list')) return tagResp;
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
}
|
||||
|
||||
async function expectFailure(code: string, message?: string): Promise<void> {
|
||||
const result = await listRegistryTagsResult('ghcr.io', 'acme/app', creds);
|
||||
expect(result).toMatchObject(message ? { ok: false, code, message } : { ok: false, code });
|
||||
}
|
||||
|
||||
it('returns tags on a successful list', async () => {
|
||||
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: ['latest', '1.0'] }) });
|
||||
await expect(listRegistryTagsResult('ghcr.io', 'acme/app', creds)).resolves.toEqual({
|
||||
ok: true,
|
||||
tags: ['latest', '1.0'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps transport failure during auth ping to REGISTRY_UPSTREAM (not UNAUTHORIZED)', async () => {
|
||||
route = () => { throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); };
|
||||
await expectFailure('REGISTRY_UPSTREAM', 'Registry unreachable');
|
||||
});
|
||||
|
||||
it('maps a rejected token to REGISTRY_UNAUTHORIZED', async () => {
|
||||
route = (url): FakeResp => {
|
||||
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
|
||||
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 401, headers: {} };
|
||||
return { statusCode: 200, headers: {}, body: '{}' };
|
||||
};
|
||||
await expectFailure('REGISTRY_UNAUTHORIZED', 'Registry rejected credentials');
|
||||
});
|
||||
|
||||
it('maps 403 on tags/list to REGISTRY_FORBIDDEN', async () => {
|
||||
route = authThenTags({ statusCode: 403, headers: {} });
|
||||
await expectFailure('REGISTRY_FORBIDDEN');
|
||||
});
|
||||
|
||||
it('maps 429 on tags/list to REGISTRY_RATE_LIMITED', async () => {
|
||||
route = authThenTags({ statusCode: 429, headers: {} });
|
||||
await expectFailure('REGISTRY_RATE_LIMITED');
|
||||
});
|
||||
|
||||
it('maps invalid JSON to REGISTRY_INVALID_RESPONSE', async () => {
|
||||
route = authThenTags({ statusCode: 200, headers: {}, body: 'not-json' });
|
||||
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry returned invalid JSON');
|
||||
});
|
||||
|
||||
it('maps a non-array tags field to REGISTRY_INVALID_RESPONSE', async () => {
|
||||
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: 'latest' }) });
|
||||
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list was malformed');
|
||||
});
|
||||
|
||||
it('maps an oversized body to REGISTRY_INVALID_RESPONSE', async () => {
|
||||
const huge = '{"tags":["' + 'x'.repeat(2 * 1024 * 1024) + '"]}';
|
||||
route = authThenTags({ statusCode: 200, headers: {}, body: huge });
|
||||
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list response too large');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { RegistryService } from '../services/RegistryService';
|
||||
import { listRegistryTagsResult, type TagListCode } from '../services/registry-api';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const;
|
||||
const REGISTRY_SCOPE_MESSAGE = 'API tokens cannot manage registry credentials.';
|
||||
@@ -30,6 +32,40 @@ function allowRegistryType(type: string | undefined, req: Request, res: Response
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
const REPO_MAX_LEN = 255;
|
||||
const REPO_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/i;
|
||||
|
||||
/** Path-safe repository name only (no scheme, host, or tag). */
|
||||
function parseRepositoryParam(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const repo = raw.trim();
|
||||
if (!repo || repo.length > REPO_MAX_LEN) return null;
|
||||
if (repo.includes('://') || repo.includes('@') || repo.startsWith('/') || repo.includes(':')) return null;
|
||||
const first = repo.split('/')[0];
|
||||
// Host-looking first segment (e.g. ghcr.io/org/name) is rejected; host comes from the registry row.
|
||||
if (first.includes('.')) return null;
|
||||
if (!REPO_PATTERN.test(repo)) return null;
|
||||
return repo;
|
||||
}
|
||||
|
||||
function tagListHttpStatus(code: TagListCode): number {
|
||||
switch (code) {
|
||||
case 'REGISTRY_UNAUTHORIZED':
|
||||
case 'REGISTRY_FORBIDDEN':
|
||||
case 'REGISTRY_RATE_LIMITED':
|
||||
case 'REGISTRY_UNSUPPORTED':
|
||||
return 424;
|
||||
case 'REGISTRY_NOT_FOUND':
|
||||
return 404;
|
||||
case 'REGISTRY_INVALID_RESPONSE':
|
||||
return 400;
|
||||
case 'REGISTRY_UPSTREAM':
|
||||
default:
|
||||
return 502;
|
||||
}
|
||||
}
|
||||
|
||||
export const registriesRouter = Router();
|
||||
|
||||
registriesRouter.get('/', (req: Request, res: Response): void => {
|
||||
@@ -136,6 +172,80 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Browse tags for a configured registry + validated repository (hub-only).
|
||||
// Upstream registry auth failures map to 424/502 — never HTTP 401 (that would
|
||||
// log the browser session out via the frontend unauthorized handler).
|
||||
registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'registry ID');
|
||||
if (id === null) return;
|
||||
|
||||
const repository = parseRepositoryParam(req.query.repository);
|
||||
if (!repository) {
|
||||
res.status(400).json({ error: 'Query parameter repository is required and must be a path-safe repository name' });
|
||||
return;
|
||||
}
|
||||
|
||||
const limitRaw = req.query.limit;
|
||||
let limit = 50;
|
||||
if (limitRaw !== undefined) {
|
||||
const n = typeof limitRaw === 'string' ? Number.parseInt(limitRaw, 10) : NaN;
|
||||
if (!Number.isFinite(n) || n < 1 || n > 100) {
|
||||
res.status(400).json({ error: 'limit must be an integer from 1 to 100' });
|
||||
return;
|
||||
}
|
||||
limit = n;
|
||||
}
|
||||
const cursor = typeof req.query.cursor === 'string' && req.query.cursor.trim()
|
||||
? req.query.cursor.trim()
|
||||
: undefined;
|
||||
|
||||
const auth = await RegistryService.getInstance().getAuthForRegistryId(id);
|
||||
if (!auth.ok) {
|
||||
if (auth.code === 'missing') {
|
||||
res.status(404).json({ error: auth.message, code: 'REGISTRY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
const code = auth.code === 'ecr_failed' || auth.code === 'decrypt_failed'
|
||||
? 'REGISTRY_UNAUTHORIZED'
|
||||
: 'REGISTRY_UPSTREAM';
|
||||
res.status(424).json({ error: auth.message, code });
|
||||
return;
|
||||
}
|
||||
if (!allowRegistryType(auth.type, req, res)) return;
|
||||
|
||||
// Docker Hub official images are often referenced as library/<name>.
|
||||
let repo = repository;
|
||||
if (auth.type === 'dockerhub' && !repo.includes('/')) {
|
||||
repo = `library/${repo}`;
|
||||
}
|
||||
|
||||
const result = await listRegistryTagsResult(
|
||||
auth.registryHost,
|
||||
repo,
|
||||
{ username: auth.username, password: auth.password },
|
||||
{ limit, cursor },
|
||||
);
|
||||
if (!result.ok) {
|
||||
res.status(tagListHttpStatus(result.code)).json({ error: result.message, code: result.code });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
tags: result.tags,
|
||||
nextCursor: result.nextCursor ?? null,
|
||||
registryId: id,
|
||||
registryName: auth.name,
|
||||
repository: repo,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Registries] Tag list error:', sanitizeForLog(error instanceof Error ? error.message : String(error)));
|
||||
res.status(500).json({ error: 'Failed to list registry tags' });
|
||||
}
|
||||
});
|
||||
|
||||
registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
@@ -42,6 +42,23 @@ export interface ResolvedDockerConfig {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export type RegistryAuthForIdResult =
|
||||
| {
|
||||
ok: true;
|
||||
username: string;
|
||||
password: string;
|
||||
registryHost: string;
|
||||
type: RegistryType;
|
||||
name: string;
|
||||
}
|
||||
| { ok: false; code: 'missing' | 'decrypt_failed' | 'ecr_failed'; message: string };
|
||||
|
||||
/** Host used for Docker Registry HTTP API calls (/v2/...). */
|
||||
export function registryApiHost(reg: Pick<Registry, 'url' | 'type'>): string {
|
||||
if (reg.type === 'dockerhub') return 'registry-1.docker.io';
|
||||
return hostFromStoredRegistry(reg);
|
||||
}
|
||||
|
||||
interface HttpResult {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
@@ -83,7 +100,7 @@ function toProbeUrl(url: string, type: RegistryType): string {
|
||||
}
|
||||
|
||||
/** Canonical host for matching (image ref → stored credential). */
|
||||
function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
|
||||
export function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
|
||||
if (reg.type === 'dockerhub') return 'index.docker.io';
|
||||
try {
|
||||
const withProtocol = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`;
|
||||
@@ -375,6 +392,50 @@ export class RegistryService {
|
||||
return { config: { auths }, warnings };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve credentials for a specific registry row by ID only.
|
||||
* Never falls back to host-based matching (duplicate hosts must use the requested ID).
|
||||
*/
|
||||
public async getAuthForRegistryId(id: number): Promise<RegistryAuthForIdResult> {
|
||||
const reg = DatabaseService.getInstance().getRegistry(id);
|
||||
if (!reg) {
|
||||
return { ok: false, code: 'missing', message: 'Registry not found' };
|
||||
}
|
||||
try {
|
||||
const registryHost = registryApiHost(reg);
|
||||
if (reg.type === 'ecr') {
|
||||
const creds = await this.getEcrCredentials(reg);
|
||||
return {
|
||||
ok: true,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
registryHost,
|
||||
type: reg.type,
|
||||
name: reg.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
username: reg.username,
|
||||
password: this.crypto.decrypt(reg.secret),
|
||||
registryHost,
|
||||
type: reg.type,
|
||||
name: reg.name,
|
||||
};
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
console.warn(
|
||||
`[RegistryService] getAuthForRegistryId(${id}) failed:`,
|
||||
sanitizeForLog(detail),
|
||||
);
|
||||
if (reg.type === 'ecr') {
|
||||
return { ok: false, code: 'ecr_failed', message: 'Failed to refresh ECR credentials' };
|
||||
}
|
||||
return { ok: false, code: 'decrypt_failed', message: 'Failed to decrypt registry credentials' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registry auth for ImageUpdateService ────────────────────────────────
|
||||
|
||||
public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> {
|
||||
|
||||
@@ -98,39 +98,41 @@ export async function getAuthToken(
|
||||
repo: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<string | null> {
|
||||
// Transport errors propagate (callers map to REGISTRY_UPSTREAM). null = auth/token failure only.
|
||||
const basicHeaders: Record<string, string> = {};
|
||||
if (credentials) {
|
||||
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
let tokenUrl: string;
|
||||
if (registry === 'registry-1.docker.io') {
|
||||
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
|
||||
} else {
|
||||
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
|
||||
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
|
||||
if (!wwwAuth) return null;
|
||||
|
||||
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
|
||||
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
|
||||
if (!realmMatch) return null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (serviceMatch) params.set('service', serviceMatch[1]);
|
||||
// The /v2/ ping carries no repository context, so any scope it echoes is a
|
||||
// placeholder (ghcr.io returns repository:user/image:pull). Always request
|
||||
// the scope for the repository we actually want; reusing the echoed scope
|
||||
// makes ghcr.io mint a token for the wrong repo and then reject the pull.
|
||||
params.set('scope', `repository:${repo}:pull`);
|
||||
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const tokenRes = await httpGet(tokenUrl, basicHeaders);
|
||||
if (tokenRes.statusCode !== 200) return null;
|
||||
|
||||
try {
|
||||
const basicHeaders: Record<string, string> = {};
|
||||
if (credentials) {
|
||||
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
let tokenUrl: string;
|
||||
if (registry === 'registry-1.docker.io') {
|
||||
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
|
||||
} else {
|
||||
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
|
||||
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
|
||||
if (!wwwAuth) return null;
|
||||
|
||||
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
|
||||
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
|
||||
if (!realmMatch) return null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (serviceMatch) params.set('service', serviceMatch[1]);
|
||||
// The /v2/ ping carries no repository context, so any scope it echoes is a
|
||||
// placeholder (ghcr.io returns repository:user/image:pull). Always request
|
||||
// the scope for the repository we actually want; reusing the echoed scope
|
||||
// makes ghcr.io mint a token for the wrong repo and then reject the pull.
|
||||
params.set('scope', `repository:${repo}:pull`);
|
||||
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const tokenRes = await httpGet(tokenUrl, basicHeaders);
|
||||
if (tokenRes.statusCode !== 200) return null;
|
||||
|
||||
const parsed = JSON.parse(tokenRes.body);
|
||||
return parsed.token ?? parsed.access_token ?? null;
|
||||
const parsed = JSON.parse(tokenRes.body) as { token?: unknown; access_token?: unknown };
|
||||
const token = parsed.token ?? parsed.access_token;
|
||||
return typeof token === 'string' ? token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -210,7 +212,21 @@ export async function getRemoteDigestResult(
|
||||
): Promise<RemoteDigestResult> {
|
||||
const ref = `${registry}/${repo}:${tag}`;
|
||||
try {
|
||||
const token = await getAuthToken(registry, repo, credentials);
|
||||
// Auth transport failures used to collapse to null inside getAuthToken.
|
||||
// Tag listing now needs those errors to propagate (REGISTRY_UPSTREAM), so
|
||||
// digest lookup keeps anonymous fallback here when the token endpoint is down.
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = await getAuthToken(registry, repo, credentials);
|
||||
} catch (authErr) {
|
||||
const cause = authErr instanceof Error
|
||||
? ((authErr as NodeJS.ErrnoException).code ?? authErr.message)
|
||||
: String(authErr);
|
||||
console.error(
|
||||
`[registry-api] Auth for ${sanitizeForLog(ref)} failed; trying anonymous:`,
|
||||
sanitizeForLog(cause),
|
||||
);
|
||||
}
|
||||
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const url = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
||||
@@ -260,22 +276,105 @@ export async function getRemoteDigest(
|
||||
return result.ok ? result.digest : null;
|
||||
}
|
||||
|
||||
export type TagListCode =
|
||||
| 'REGISTRY_UNAUTHORIZED'
|
||||
| 'REGISTRY_FORBIDDEN'
|
||||
| 'REGISTRY_NOT_FOUND'
|
||||
| 'REGISTRY_RATE_LIMITED'
|
||||
| 'REGISTRY_UNSUPPORTED'
|
||||
| 'REGISTRY_UPSTREAM'
|
||||
| 'REGISTRY_INVALID_RESPONSE';
|
||||
|
||||
export type TagListResult =
|
||||
| { ok: true; tags: string[]; nextCursor?: string }
|
||||
| { ok: false; code: TagListCode; message: string };
|
||||
|
||||
const TAG_LIST_BODY_CAP = 2 * 1024 * 1024; // 2 MiB
|
||||
|
||||
function tagListFailure(statusCode: number): TagListResult {
|
||||
if (statusCode === 401) {
|
||||
return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' };
|
||||
}
|
||||
if (statusCode === 403) {
|
||||
return { ok: false, code: 'REGISTRY_FORBIDDEN', message: 'Registry denied access to this repository' };
|
||||
}
|
||||
if (statusCode === 404) {
|
||||
return { ok: false, code: 'REGISTRY_NOT_FOUND', message: 'Repository not found on registry' };
|
||||
}
|
||||
if (statusCode === 429) {
|
||||
return { ok: false, code: 'REGISTRY_RATE_LIMITED', message: 'Registry rate limit exceeded' };
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry error (${statusCode})` };
|
||||
}
|
||||
return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry returned status ${statusCode}` };
|
||||
}
|
||||
|
||||
function parseNextCursor(linkHeader: string | string[] | undefined): string | undefined {
|
||||
const raw = Array.isArray(linkHeader) ? linkHeader.join(',') : linkHeader;
|
||||
if (!raw) return undefined;
|
||||
// Rel=next Link: </v2/repo/tags/list?n=50&last=foo>; rel="next"
|
||||
const match = raw.match(/<[^>]*[?&]last=([^&>]+)[^>]*>\s*;\s*rel="?next"?/i);
|
||||
if (!match) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed tag list for the Resources registry browser. Never collapses auth
|
||||
* failures into an empty array (that would hide credential problems).
|
||||
*/
|
||||
export async function listRegistryTagsResult(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials: RegistryCredentials,
|
||||
opts: { limit?: number; cursor?: string } = {},
|
||||
): Promise<TagListResult> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||
try {
|
||||
const token = await getAuthToken(registry, repo, credentials);
|
||||
if (!token) {
|
||||
return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' };
|
||||
}
|
||||
const headers: Record<string, string> = { Accept: 'application/json', Authorization: `Bearer ${token}` };
|
||||
const params = new URLSearchParams({ n: String(limit) });
|
||||
if (opts.cursor) params.set('last', opts.cursor);
|
||||
const url = `https://${registry}/v2/${repo}/tags/list?${params.toString()}`;
|
||||
const res = await httpGet(url, headers);
|
||||
if (res.statusCode !== 200) return tagListFailure(res.statusCode);
|
||||
if (res.body.length > TAG_LIST_BODY_CAP) {
|
||||
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list response too large' };
|
||||
}
|
||||
let parsed: { tags?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(res.body) as { tags?: unknown };
|
||||
} catch {
|
||||
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry returned invalid JSON' };
|
||||
}
|
||||
if (!Array.isArray(parsed.tags) || !parsed.tags.every((t) => typeof t === 'string')) {
|
||||
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list was malformed' };
|
||||
}
|
||||
const nextCursor = parseNextCursor(res.headers['link']);
|
||||
return nextCursor
|
||||
? { ok: true, tags: parsed.tags as string[], nextCursor }
|
||||
: { ok: true, tags: parsed.tags as string[] };
|
||||
} catch (e) {
|
||||
const cause = e instanceof Error ? ((e as NodeJS.ErrnoException).code ?? e.message) : String(e);
|
||||
console.error('[registry-api] Tag list failed:', sanitizeForLog(cause));
|
||||
return { ok: false, code: 'REGISTRY_UPSTREAM', message: 'Registry unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility wrapper for update-preview: empty list on any failure. */
|
||||
export async function listRegistryTags(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const token = await getAuthToken(registry, repo, credentials);
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await httpGet(`https://${registry}/v2/${repo}/tags/list`, headers);
|
||||
if (res.statusCode !== 200) return [];
|
||||
|
||||
const parsed = JSON.parse(res.body) as { tags?: string[] };
|
||||
return Array.isArray(parsed.tags) ? parsed.tags : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!credentials) return [];
|
||||
const result = await listRegistryTagsResult(registry, repo, credentials);
|
||||
return result.ok ? result.tags : [];
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ Sencho stores credentials for your private Docker registries and injects them au
|
||||
|
||||
AWS ECR requires a Sencho **Admiral** license; Docker Hub, GHCR, and custom registries are available on every tier.
|
||||
|
||||
|
||||
|
||||
## Browse tags from Resources
|
||||
|
||||
When you inspect an image on the Resources page, the detail sheet includes a **Registry tags** section for admins. Sencho lists tags from the configured registry that matches the image host (credentials come from that registry row only). Tag browsing always runs on the hub instance; local image digests still come from the active node.
|
||||
|
||||
If credentials are wrong, Sencho reports a registry error without signing you out of the dashboard.
|
||||
|
||||
## Where to find it
|
||||
|
||||
Open **Settings → Infrastructure → Registries** on the Sencho instance you are signed into directly. The section is hidden when you are viewing another node through the node switcher.
|
||||
|
||||
@@ -83,13 +83,15 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta
|
||||
|
||||
#### Inspect image
|
||||
|
||||
Click the eye icon on any image row to open a detail sheet with three sections:
|
||||
Click the eye icon on any image row to open a detail sheet with these sections:
|
||||
|
||||
- **Overview** lists the ID, size, creation date, architecture and OS, author, all repository tags, and which Sencho stacks use the image.
|
||||
- **Used by** lists every Sencho stack that references the image (chips open that stack). Sourced from the Resources inventory, so it stays visible while inspect loads or if inspect fails.
|
||||
- **Overview** lists the ID, size, creation date, architecture and OS, author, and all repository tags.
|
||||
- **Config** shows the default `Cmd`, `Entrypoint`, `WorkingDir`, `User`, exposed ports, environment variables, and labels. Env vars and labels are collapsible.
|
||||
- **Layers** lists the layer history in build order. Each row shows the layer index, size, age, and the build command (`CreatedBy`). Empty layers (metadata-only, zero bytes) are dimmed.
|
||||
- **Registry tags** (admins) lists remote tags from a matching configured registry. See [Private registries](/features/private-registries) for credential and failure behavior.
|
||||
|
||||
The inspect sheet is read-only and available to all roles.
|
||||
The inspect sheet is available to all roles. Registry tag browsing is admin-only.
|
||||
|
||||
### Volumes
|
||||
|
||||
|
||||
@@ -644,6 +644,26 @@ components:
|
||||
description: Required when `type` is `ecr`.
|
||||
example: us-east-1
|
||||
|
||||
RegistryTagList:
|
||||
type: object
|
||||
required: [tags, nextCursor, registryId, registryName, repository]
|
||||
properties:
|
||||
tags:
|
||||
type: array
|
||||
items: { type: string }
|
||||
example: [latest, "1.0"]
|
||||
nextCursor:
|
||||
type: ["string", "null"]
|
||||
description: Opaque cursor for the next page, or null when there are no more tags.
|
||||
registryId:
|
||||
type: integer
|
||||
registryName:
|
||||
type: string
|
||||
repository:
|
||||
type: string
|
||||
description: Path-safe repository name after Docker Hub library/ normalization when applicable.
|
||||
example: library/nginx
|
||||
|
||||
ImageUpdateStatus:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3605,3 +3625,44 @@ paths:
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
description: Non-admin users cannot change the schedule.
|
||||
|
||||
/api/registries/{id}/tags:
|
||||
get:
|
||||
tags: [Registries]
|
||||
summary: List tags for a repository on a configured registry
|
||||
description: |
|
||||
Hub-only. Admin only. Credentials are resolved by exact registry ID.
|
||||
Upstream registry auth failures return 424 with a REGISTRY_* code, never HTTP 401.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- name: repository
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
description: Path-safe repository name (no host or scheme)
|
||||
- name: cursor
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
|
||||
responses:
|
||||
'200':
|
||||
description: Tag page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegistryTagList"
|
||||
'400':
|
||||
description: Invalid repository or response
|
||||
'404':
|
||||
description: Registry or repository not found
|
||||
'424':
|
||||
description: Upstream registry auth or policy failure (not a Sencho session 401)
|
||||
'502':
|
||||
description: Upstream registry transport failure
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiFetch } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { RegistryTagsPanel } from './RegistryTagsPanel';
|
||||
import { Copy } from 'lucide-react';
|
||||
|
||||
interface ImageInspect {
|
||||
@@ -76,6 +78,7 @@ function formatRelativeAge(timestampSec: number): string {
|
||||
|
||||
export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsSheetProps) {
|
||||
const imageId = image?.Id ?? null;
|
||||
const { isAdmin } = useAuth();
|
||||
const [data, setData] = useState<ImageDetails | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -113,7 +116,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
|
||||
const inspect = data?.inspect;
|
||||
const history = data?.history ?? [];
|
||||
const totalLayers = history.length;
|
||||
const usedByStacks = image?.usedByStacks ?? [];
|
||||
|
||||
const name = image?.RepoTags?.[0]
|
||||
|| inspect?.RepoTags?.[0]
|
||||
@@ -145,6 +147,33 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
|
||||
</div>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<SheetSection title="Used by">
|
||||
{(image.usedByStacks?.length ?? 0) === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{image.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{image.usedByStacks.map((stack) => (
|
||||
onOpenStack ? (
|
||||
<button
|
||||
key={stack}
|
||||
type="button"
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium hover:bg-success/15 transition-colors"
|
||||
onClick={() => onOpenStack(stack)}
|
||||
>
|
||||
{stack}
|
||||
</button>
|
||||
) : (
|
||||
<Badge key={stack} variant="outline" className="text-[10px] h-5">{stack}</Badge>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{!loading && inspect && (
|
||||
<>
|
||||
<SheetSection title="Overview">
|
||||
@@ -191,30 +220,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Used by" span={2}>
|
||||
{usedByStacks.length === 0 ? (
|
||||
<p className="text-xs mt-0.5 text-muted-foreground">
|
||||
{image?.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{usedByStacks.map((stack) => (
|
||||
onOpenStack ? (
|
||||
<button
|
||||
key={stack}
|
||||
type="button"
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium hover:bg-success/15 transition-colors"
|
||||
onClick={() => onOpenStack(stack)}
|
||||
>
|
||||
{stack}
|
||||
</button>
|
||||
) : (
|
||||
<Badge key={stack} variant="outline" className="text-[10px] h-5">{stack}</Badge>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
@@ -279,6 +284,15 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
|
||||
</ol>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Registry tags">
|
||||
<RegistryTagsPanel
|
||||
repoTags={inspect.RepoTags ?? image?.RepoTags ?? []}
|
||||
repoDigests={inspect.RepoDigests ?? []}
|
||||
nodeId={image?.nodeId ?? 0}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
</SystemSheet>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface RegistryRow {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
|
||||
has_secret: boolean;
|
||||
}
|
||||
|
||||
interface TagListResponse {
|
||||
tags: string[];
|
||||
nextCursor: string | null;
|
||||
registryId: number;
|
||||
registryName: string;
|
||||
repository: string;
|
||||
}
|
||||
|
||||
export interface RegistryTagsPanelProps {
|
||||
repoTags: string[];
|
||||
repoDigests: string[];
|
||||
nodeId: string | number;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
function parseRepoFromTag(tag: string): { host: string; repo: string; tagName: string } | null {
|
||||
if (!tag || tag === '<none>:<none>') return null;
|
||||
const at = tag.indexOf('@');
|
||||
const ref = at === -1 ? tag : tag.slice(0, at);
|
||||
let rest = ref;
|
||||
let host = 'docker.io';
|
||||
const slash = ref.indexOf('/');
|
||||
if (slash !== -1) {
|
||||
const first = ref.slice(0, slash);
|
||||
if (first.includes('.') || first.includes(':') || first === 'localhost') {
|
||||
host = first.toLowerCase();
|
||||
rest = ref.slice(slash + 1);
|
||||
}
|
||||
}
|
||||
let tagName = 'latest';
|
||||
const colon = rest.lastIndexOf(':');
|
||||
if (colon > 0) {
|
||||
tagName = rest.slice(colon + 1);
|
||||
rest = rest.slice(0, colon);
|
||||
}
|
||||
if (host === 'docker.io' && !rest.includes('/')) {
|
||||
rest = `library/${rest}`;
|
||||
}
|
||||
return { host, repo: rest, tagName };
|
||||
}
|
||||
|
||||
function registryMatchesHost(reg: RegistryRow, host: string): boolean {
|
||||
const h = host.toLowerCase();
|
||||
if (reg.type === 'dockerhub') {
|
||||
return h === 'docker.io' || h === 'index.docker.io' || h === 'registry-1.docker.io' || h === '';
|
||||
}
|
||||
try {
|
||||
const withProto = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`;
|
||||
return new URL(withProto).host.toLowerCase() === h;
|
||||
} catch {
|
||||
return reg.url.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase() === h;
|
||||
}
|
||||
}
|
||||
|
||||
export function RegistryTagsPanel({
|
||||
repoTags,
|
||||
isAdmin,
|
||||
}: RegistryTagsPanelProps) {
|
||||
const candidates = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: { host: string; repo: string; tagName: string; label: string }[] = [];
|
||||
for (const t of repoTags) {
|
||||
const parsed = parseRepoFromTag(t);
|
||||
if (!parsed) continue;
|
||||
const key = `${parsed.host}/${parsed.repo}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ ...parsed, label: t });
|
||||
}
|
||||
return out;
|
||||
}, [repoTags]);
|
||||
|
||||
const [registries, setRegistries] = useState<RegistryRow[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [regsError, setRegsError] = useState<string | null>(null);
|
||||
const [selectedRepoIdx, setSelectedRepoIdx] = useState(0);
|
||||
const [selectedRegistryId, setSelectedRegistryId] = useState<number | null>(null);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loadingTags, setLoadingTags] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selected = candidates[selectedRepoIdx] ?? null;
|
||||
|
||||
const matchingRegistries = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return registries.filter((r) => registryMatchesHost(r, selected.host));
|
||||
}, [registries, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
let cancelled = false;
|
||||
setLoadingRegs(true);
|
||||
setRegsError(null);
|
||||
apiFetch('/registries', { localOnly: true })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to load registries');
|
||||
return res.json() as Promise<RegistryRow[]>;
|
||||
})
|
||||
.then((rows) => {
|
||||
if (!cancelled) setRegistries(Array.isArray(rows) ? rows : []);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load registries';
|
||||
setRegsError(msg);
|
||||
setRegistries([]);
|
||||
toast.error(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingRegs(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (matchingRegistries.length === 0) {
|
||||
setSelectedRegistryId(null);
|
||||
return;
|
||||
}
|
||||
setSelectedRegistryId((prev) =>
|
||||
prev && matchingRegistries.some((r) => r.id === prev) ? prev : matchingRegistries[0].id,
|
||||
);
|
||||
}, [matchingRegistries]);
|
||||
|
||||
const loadTags = async (cursor?: string | null, append = false) => {
|
||||
if (!selected || selectedRegistryId == null) return;
|
||||
setLoadingTags(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ repository: selected.repo, limit: '50' });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await apiFetch(`/registries/${selectedRegistryId}/tags?${params}`, { localOnly: true });
|
||||
const data = await res.json().catch(() => null) as (TagListResponse & { error?: string }) | null;
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || `Failed to list tags (${res.status})`);
|
||||
}
|
||||
const page = Array.isArray(data?.tags) ? data!.tags : [];
|
||||
setTags((prev) => (append ? [...prev, ...page] : page));
|
||||
setNextCursor(data?.nextCursor ?? null);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to list tags';
|
||||
setError(msg);
|
||||
if (!append) setTags([]);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoadingTags(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTags([]);
|
||||
setNextCursor(null);
|
||||
setError(null);
|
||||
if (selected && selectedRegistryId != null) {
|
||||
void loadTags(null, false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected?.host, selected?.repo, selectedRegistryId]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Registry tag browsing is available to admins with a configured registry.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">No repository tags to browse.</p>;
|
||||
}
|
||||
|
||||
let registryBody: ReactNode;
|
||||
if (loadingRegs) {
|
||||
registryBody = (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> Loading registries…
|
||||
</p>
|
||||
);
|
||||
} else if (regsError) {
|
||||
registryBody = <p className="text-xs text-destructive">{regsError}</p>;
|
||||
} else if (matchingRegistries.length === 0) {
|
||||
registryBody = (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No configured registry matches {selected?.host}. Add credentials under Settings → Registries.
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
registryBody = (
|
||||
<>
|
||||
{matchingRegistries.length > 1 && (
|
||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Registry
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-foreground text-xs"
|
||||
value={selectedRegistryId ?? ''}
|
||||
onChange={(e) => setSelectedRegistryId(Number(e.target.value))}
|
||||
>
|
||||
{matchingRegistries.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{loadingTags && tags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> Loading tags…
|
||||
</p>
|
||||
) : tags.length === 0 && !error ? (
|
||||
<p className="text-xs text-muted-foreground">No tags returned.</p>
|
||||
) : (
|
||||
<ul className="max-h-48 overflow-y-auto space-y-1 font-mono text-[11px]">
|
||||
{tags.map((tag) => {
|
||||
const isCurrent = selected?.tagName === tag;
|
||||
return (
|
||||
<li key={tag} className="flex items-center gap-2 truncate">
|
||||
<span className={isCurrent ? 'text-foreground font-medium' : 'text-stat-subtitle/90'}>
|
||||
{tag}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<Badge variant="outline" className="text-[9px] h-4">current</Badge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{nextCursor && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={loadingTags}
|
||||
onClick={() => void loadTags(nextCursor, true)}
|
||||
>
|
||||
{loadingTags ? 'Loading…' : 'Load more'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
{candidates.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{candidates.map((c, i) => (
|
||||
<button
|
||||
key={c.label}
|
||||
type="button"
|
||||
className={`px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors ${
|
||||
i === selectedRepoIdx
|
||||
? 'border-foreground/30 bg-muted text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => setSelectedRepoIdx(i)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{registryBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ImageDetailsSheet } from '../ImageDetailsSheet';
|
||||
|
||||
const apiFetch = vi.fn();
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { RegistryTagsPanel } from '../RegistryTagsPanel';
|
||||
|
||||
const apiFetch = vi.fn();
|
||||
const toastError = vi.fn();
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: (...args: unknown[]) => toastError(...args), success: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('RegistryTagsPanel', () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
toastError.mockReset();
|
||||
});
|
||||
|
||||
const panelProps = {
|
||||
repoTags: ['ghcr.io/acme/app:latest'],
|
||||
repoDigests: [] as string[],
|
||||
nodeId: 1,
|
||||
isAdmin: true as const,
|
||||
};
|
||||
|
||||
it('surfaces registry list failures instead of a no-match message', async () => {
|
||||
apiFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'boom' }) });
|
||||
|
||||
render(<RegistryTagsPanel {...panelProps} />);
|
||||
|
||||
expect(await screen.findByText('Failed to load registries')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No configured registry matches/i)).toBeNull();
|
||||
expect(toastError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows no-match copy when registries load but none match the host', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ([{ id: 1, name: 'Hub', url: 'https://index.docker.io/v1/', type: 'dockerhub', has_secret: true }]),
|
||||
});
|
||||
|
||||
render(<RegistryTagsPanel {...panelProps} />);
|
||||
|
||||
expect(await screen.findByText(/No configured registry matches ghcr.io/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user