diff --git a/backend/src/__tests__/template-service-registry.test.ts b/backend/src/__tests__/template-service-registry.test.ts new file mode 100644 index 00000000..99598d70 --- /dev/null +++ b/backend/src/__tests__/template-service-registry.test.ts @@ -0,0 +1,106 @@ +/** + * Registry-fetch tests for TemplateService.getTemplates: the response-size + * cap surfaces a clean error instead of letting an oversized body propagate, + * and the fetch is issued with the size-limit axios options. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import axios from 'axios'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + isAxiosError: (e: unknown): boolean => !!(e as { isAxiosError?: boolean })?.isAxiosError, + }, +})); + +const mockedGet = vi.mocked(axios.get); + +let tmpDir: string; +let TemplateService: typeof import('../services/TemplateService').TemplateService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ TemplateService } = await import('../services/TemplateService')); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + mockedGet.mockReset(); +}); + +describe('TemplateService.getTemplates registry size cap', () => { + it('issues the fetch with content/body size limits', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockResolvedValueOnce({ data: { data: { repositories: { linuxserver: {} } } } }); + + await service.getTemplates(); + + expect(mockedGet).toHaveBeenCalledTimes(1); + const [, options] = mockedGet.mock.calls[0]; + expect(options?.maxContentLength).toBe(25 * 1024 * 1024); + expect(options?.maxBodyLength).toBe(25 * 1024 * 1024); + }); + + it('maps an oversized-response error to a clean size-limit message', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockRejectedValueOnce({ + isAxiosError: true, + message: 'maxContentLength size of 26214400 exceeded', + }); + + await expect(service.getTemplates()).rejects.toThrow(/response exceeded the size limit/i); + }); + + it('maps other registry failures to the generic fetch error', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockRejectedValueOnce({ isAxiosError: true, message: 'ECONNREFUSED' }); + + await expect(service.getTemplates()).rejects.toThrow(/Could not fetch templates from registry$/); + }); + + it('maps the default LinuxServer.io response shape into templates', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockResolvedValueOnce({ + data: { + data: { + repositories: { + linuxserver: { + plex: { + name: 'plex', + description: 'Media server', + stars: 100, + arch: ['x86-64'], + github: 'https://github.com/linuxserver/docker-plex', + readme: 'https://docs.example/plex', + config: { + ports: [{ external: 32400, internal: 32400, protocol: 'tcp' }], + volumes: [{ path: '/config' }], + environment: [{ name: 'PUID', desc: 'User ID', default: '1000' }], + }, + }, + }, + }, + }, + }, + }); + + const templates = await service.getTemplates(); + const plex = templates.find(t => t.title === 'plex'); + expect(plex).toBeDefined(); + expect(plex!.image).toBe('lscr.io/linuxserver/plex:latest'); + expect(plex!.source).toBe('linuxserver'); + expect(plex!.ports).toEqual(['32400:32400/tcp']); + expect(plex!.volumes).toEqual([{ container: '/config', bind: './config' }]); + expect(plex!.env).toEqual([{ name: 'PUID', label: 'User ID', default: '1000' }]); + expect(plex!.categories).toEqual(['Media']); + }); +}); diff --git a/backend/src/__tests__/template-service.test.ts b/backend/src/__tests__/template-service.test.ts index 964d8eec..263324bc 100644 --- a/backend/src/__tests__/template-service.test.ts +++ b/backend/src/__tests__/template-service.test.ts @@ -3,7 +3,17 @@ * env string generation, conditional env_file, and cache clearing. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { TemplateService, Template } from '../services/TemplateService'; +import YAML from 'yaml'; +import { TemplateService } from '../services/TemplateService'; + +// Parse the generated compose and return the single service block keyed by +// `name`. Asserting on the parsed structure (rather than exact text lines) +// keeps the tests stable across the YAML emitter's quoting choices. +function serviceOf(yaml: string, name = 'app'): Record { + const parsed = YAML.parse(yaml) as { services?: Record> }; + expect(parsed.services).toBeDefined(); + return parsed.services![name]; +} describe('TemplateService', () => { let service: TemplateService; @@ -16,177 +26,134 @@ describe('TemplateService', () => { describe('generateComposeFromTemplate', () => { it('generates minimal compose with just image and restart policy', () => { - const template: Template = { - title: 'nginx', - description: 'Web server', - image: 'nginx:latest', - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('image: nginx:latest'); - expect(yaml).toContain('restart: unless-stopped'); - expect(yaml).not.toContain('ports:'); - expect(yaml).not.toContain('volumes:'); - expect(yaml).not.toContain('env_file:'); + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'nginx', description: 'Web server', image: 'nginx:latest', + }, 'app')); + expect(svc.image).toBe('nginx:latest'); + expect(svc.restart).toBe('unless-stopped'); + expect(svc.ports).toBeUndefined(); + expect(svc.volumes).toBeUndefined(); + expect(svc.env_file).toBeUndefined(); }); it('includes ports when template has port mappings', () => { - const template: Template = { - title: 'nginx', - description: 'Web server', - image: 'nginx:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'nginx', description: 'Web server', image: 'nginx:latest', ports: ['80:80', '443:443/tcp'], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('ports:'); - expect(yaml).toContain('"80:80"'); - expect(yaml).toContain('"443:443/tcp"'); + }, 'app')); + expect(svc.ports).toEqual(['80:80', '443:443/tcp']); }); it('handles string volumes with host:container format', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', volumes: ['/host/data:/container/data'], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('volumes:'); - expect(yaml).toContain('/host/data:/container/data'); + }, 'app')); + expect(svc.volumes).toEqual(['/host/data:/container/data']); }); it('handles string volumes with single path (named volume)', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', volumes: ['/data'], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('- /data'); + }, 'app')); + expect(svc.volumes).toEqual(['/data']); }); it('handles object volumes with container and bind', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/config', bind: './config' }], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('./config:/config'); + }, 'app')); + expect(svc.volumes).toEqual(['./config:/config']); }); it('generates bind path from container folder when bind is not specified', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/app/data' }], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('./data:/app/data'); + }, 'app')); + expect(svc.volumes).toEqual(['./data:/app/data']); }); it('adds :ro suffix for readonly volumes', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/config', bind: './config', readonly: true }], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('./config:/config:ro'); + }, 'app')); + expect(svc.volumes).toEqual(['./config:/config:ro']); }); it('includes env_file only when env vars are present', () => { - const withEnv: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const withEnv = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', env: [{ name: 'TZ', default: 'UTC' }], - }; - - const withoutEnv: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', - env: [], - }; - - expect(service.generateComposeFromTemplate(withEnv, 'app')).toContain('env_file:'); - expect(service.generateComposeFromTemplate(withoutEnv, 'app')).not.toContain('env_file:'); + }, 'app')); + const withoutEnv = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', env: [], + }, 'app')); + expect(withEnv.env_file).toEqual(['.env']); + expect(withoutEnv.env_file).toBeUndefined(); }); it('does not include env_file when env is undefined', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', - }; - - expect(service.generateComposeFromTemplate(template, 'app')).not.toContain('env_file:'); + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', + }, 'app')); + expect(svc.env_file).toBeUndefined(); }); it('handles string volumes with options (e.g., host:container:ro)', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', volumes: ['/host/config:/config:ro'], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toContain('/host/config:/config:ro'); + }, 'app')); + expect(svc.volumes).toEqual(['/host/config:/config:ro']); }); it('skips object volumes without container path', () => { - const template: Template = { - title: 'app', - description: 'Test', - image: 'test:latest', + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '' }], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - // Empty container means `continue` is hit, no volume line emitted - expect(yaml).toContain('volumes:'); - // The volume header is added but no actual volume entry - const volumeLines = yaml.split('\n').filter(l => l.trim().startsWith('- ')); - expect(volumeLines).toHaveLength(0); - }); - - it('produces valid YAML structure starting with services key', () => { - const template: Template = { - title: 'full', - description: 'Full template', - image: 'app:v1', - ports: ['8080:80'], - volumes: [{ container: '/data', bind: './data' }], - env: [{ name: 'KEY', default: 'val' }], - }; - - const yaml = service.generateComposeFromTemplate(template, 'app'); - expect(yaml).toMatch(/^services:\n/); - expect(yaml).toContain(' app:'); + }, 'app')); + // Empty container is skipped, leaving no volume entries; the key is + // omitted entirely rather than emitted as an empty list. + expect(svc.volumes).toBeUndefined(); }); it('uses the supplied service name as the compose service key', () => { - const template: Template = { - title: 'Plex', - description: 'Media server', - image: 'plex:latest', - }; + const yaml = service.generateComposeFromTemplate({ + title: 'Plex', description: 'Media server', image: 'plex:latest', + }, 'plex'); + const parsed = YAML.parse(yaml) as { services: Record }; + expect(Object.keys(parsed.services)).toEqual(['plex']); + }); - const yaml = service.generateComposeFromTemplate(template, 'plex'); - expect(yaml).toMatch(/^services:\n {2}plex:\n/); - expect(yaml).not.toContain(' app:'); + it('escapes values that would break hand-built YAML (round-trips intact)', () => { + // Registry values containing YAML structural characters (newlines, + // indentation, anchors, flow collectors, colon-space) must survive a + // serialize/parse round-trip rather than injecting sibling keys. + const hostileImage = 'evil:latest\n privileged: true'; + const yaml = service.generateComposeFromTemplate({ + title: 'x', description: 'Test', image: hostileImage, + ports: ['*anchor', '8080:80\n cap_add: [ALL]'], + volumes: ['/h: weird:/c # not-a-comment'], + }, 'app'); + const svc = serviceOf(yaml); + expect(svc.image).toBe(hostileImage); + expect(svc.ports).toEqual(['*anchor', '8080:80\n cap_add: [ALL]']); + expect(svc.volumes).toEqual(['/h: weird:/c # not-a-comment']); + // None of the injected sibling keys may escape into the service block. + expect(svc).not.toHaveProperty('privileged'); + expect(svc).not.toHaveProperty('cap_add'); + }); + + it('keeps later valid volumes when an earlier entry is skipped', () => { + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'app', description: 'Test', image: 'test:latest', + volumes: [{ container: '' }, { container: '/config', bind: './config' }], + }, 'app')); + expect(svc.volumes).toEqual(['./config:/config']); }); }); diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index 69f31f2c..a2289175 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -53,7 +53,7 @@ templatesRouter.get('/', authMiddleware, async (req: Request, res: Response) => res.json(enriched); } catch (error) { console.error('[Templates] Failed to fetch:', error); - res.status(500).json({ error: 'Failed to fetch templates' }); + res.status(500).json({ error: getErrorMessage(error, 'Failed to fetch templates') }); } }); @@ -101,8 +101,18 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon } const debug = isDebugEnabled(); + const deployStartedAt = Date.now(); console.log(`[Templates] Deploy started: ${stackName}`); - if (debug) console.debug('[Templates:debug] Deploy payload', { stackName, templateTitle: template.title, envVarCount: envVars ? Object.keys(envVars).length : 0 }); + if (debug) console.debug('[Templates:debug] Deploy payload', { + stackName, + // title/source are registry/client-supplied; bound the title so a long + // value cannot bloat the log line. No env values or YAML are logged. + templateTitle: String(template.title ?? '').slice(0, 80), + source: String(template.source ?? '').slice(0, 40), + portCount: Array.isArray(template.ports) ? template.ports.length : 0, + volumeCount: Array.isArray(template.volumes) ? template.volumes.length : 0, + envVarCount: envVars ? Object.keys(envVars).length : 0, + }); await fsService.createStack(stackName); @@ -128,6 +138,7 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic); invalidateNodeCaches(req.nodeId); console.log(`[Templates] Deploy completed: ${stackName}`); + if (debug) console.debug(`[Templates:debug] Deploy timing: ${stackName} took ${Date.now() - deployStartedAt}ms`); res.json({ success: true, message: 'Template deployed successfully' }); if (!skip_scan) { triggerPostDeployScan(stackName, req.nodeId).catch(err => diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index 70f3bbd8..1c60f021 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -1,4 +1,5 @@ -import axios from 'axios'; +import axios, { type AxiosRequestConfig } from 'axios'; +import YAML from 'yaml'; import { DatabaseService } from './DatabaseService'; import { CacheService } from './CacheService'; import { isDebugEnabled } from '../utils/debug'; @@ -43,6 +44,17 @@ interface TemplatesResponse { templates: Template[]; } +// Shape of a single compose service block emitted for a deployed template. +// restart is always set; the other fields appear only when the template +// supplies them. +interface ComposeServiceDefinition { + image?: string; + restart: string; + ports?: string[]; + volumes?: string[]; + env_file?: string[]; +} + // Typed shapes for the LinuxServer.io API response interface LsioPort { external?: number; internal: number; protocol?: string } interface LsioVolume { path: string } @@ -214,6 +226,14 @@ function getCategoriesForApp(name: string): string[] { export class TemplateService { private static readonly CACHE_KEY = 'templates:all'; private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours + // Cap the registry response so a large or compromised custom registry + // cannot exhaust backend memory by streaming an unbounded body. + private static readonly MAX_REGISTRY_RESPONSE_BYTES = 25 * 1024 * 1024; + private static readonly REGISTRY_FETCH_OPTIONS = { + timeout: 20_000, + maxContentLength: TemplateService.MAX_REGISTRY_RESPONSE_BYTES, + maxBodyLength: TemplateService.MAX_REGISTRY_RESPONSE_BYTES, + } satisfies AxiosRequestConfig; public clearCache(): void { CacheService.getInstance().invalidate(TemplateService.CACHE_KEY); @@ -222,6 +242,12 @@ export class TemplateService { public async getTemplates(): Promise { try { + // getOrFetch serves the last-known-good catalogue when the fetcher + // rejects and a (now-expired) cache entry exists, so the mapped + // errors below surface only on a cold or freshly cleared cache. + // That is deliberate: a transient registry failure keeps the + // catalogue usable. The response size cap still protects memory in + // every case, since axios aborts before buffering the full body. return await CacheService.getInstance().getOrFetch( TemplateService.CACHE_KEY, this.CACHE_DURATION_MS, @@ -236,7 +262,7 @@ export class TemplateService { let registryHost = ''; try { registryHost = new URL(registryUrl).hostname.toLowerCase(); } catch { /* invalid URL, treated as non-LSIO */ } if (registryHost === 'api.linuxserver.io') { - const response = await axios.get(registryUrl, { timeout: 20_000 }); + const response = await axios.get(registryUrl, TemplateService.REGISTRY_FETCH_OPTIONS); // Official LSIO API Schema Mapping const lsioApps = response.data?.data?.repositories?.linuxserver ?? {}; @@ -275,7 +301,7 @@ export class TemplateService { // Legacy Portainer v2 Format (Fallback for custom registries) // The Portainer v2 spec includes a native `categories` field; pass it through. - const response = await axios.get(registryUrl, { timeout: 20_000 }); + const response = await axios.get(registryUrl, TemplateService.REGISTRY_FETCH_OPTIONS); const templates = (response.data.templates || []) .filter((t: Template) => !!t.image && t.type === 1) .map((t: Template) => ({ ...t, source: 'custom' })); @@ -286,62 +312,56 @@ export class TemplateService { ); } catch (error) { console.error('[Templates] Failed to fetch from registry:', error); - throw new Error('Could not fetch templates from registry'); + // Match the stable axios error code first; fall back to the + // message text in case a transport reports the cap differently. + const oversized = axios.isAxiosError(error) + && (error.code === 'ERR_FR_MAX_CONTENT_LENGTH_EXCEEDED' + || /maxContentLength|maxBodyLength/i.test(error.message ?? '')); + if (oversized) { + throw new Error('Could not fetch templates from registry (response exceeded the size limit)', { cause: error }); + } + throw new Error('Could not fetch templates from registry', { cause: error }); } } public generateComposeFromTemplate(template: Template, serviceName: string): string { - let yaml = `services:\n ${serviceName}:\n`; + const service: ComposeServiceDefinition = { restart: 'unless-stopped' }; if (template.image) { - yaml += ` image: ${template.image}\n`; + service.image = template.image; } - yaml += ` restart: unless-stopped\n`; - if (template.ports && template.ports.length > 0) { - yaml += ` ports:\n`; - for (const port of template.ports) { - yaml += ` - "${port}"\n`; - } + service.ports = [...template.ports]; } if (template.volumes && template.volumes.length > 0) { - yaml += ` volumes:\n`; + const volumes: string[] = []; for (const vol of template.volumes) { - let hostPath = ''; - let containerPath = ''; - let options = ''; - if (typeof vol === 'string') { - const parts = vol.split(':'); - if (parts.length === 1) { - yaml += ` - ${vol}\n`; - continue; - } - hostPath = parts[0]; - containerPath = parts[1]; - options = parts.slice(2).join(':'); - if (options) options = `:${options}`; + // Pass string volumes through verbatim; the YAML emitter + // handles any escaping the raw value needs. + volumes.push(vol); } else if (vol.container) { - containerPath = vol.container; + const containerPath = vol.container; const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data'; - hostPath = vol.bind ? vol.bind : `./${containerFolder}`; - options = vol.readonly ? ':ro' : ''; - } else { - continue; + const hostPath = vol.bind ? vol.bind : `./${containerFolder}`; + const options = vol.readonly ? ':ro' : ''; + volumes.push(`${hostPath}:${containerPath}${options}`); } - - - yaml += ` - ${hostPath}:${containerPath}${options}\n`; + } + if (volumes.length > 0) { + service.volumes = volumes; } } if (template.env && template.env.length > 0) { - yaml += ` env_file:\n - .env\n`; + service.env_file = ['.env']; } - return yaml; + // Serialize through the YAML emitter so registry-supplied values are + // escaped correctly instead of interpolated raw into hand-built lines. + return YAML.stringify({ services: { [serviceName]: service } }, { lineWidth: 0 }); } public generateEnvString(envVars: Record): string { diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index 2558bc9a..821c44b7 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -11,7 +11,7 @@ The **App Store** is a browseable catalogue of Docker Compose templates. Each te ## Browsing templates -Templates load from a remote registry. The default registry is **LinuxServer.io**; you can point Sencho at any [Portainer v2 compatible JSON catalogue](#custom-template-registry) instead. +Templates load from a remote registry. The default registry is **LinuxServer.io**; you can point Sencho at any [Portainer v2 compatible JSON catalogue](#custom-template-registry) instead. The registry is configured per node, so the catalogue reflects whichever node is active and reloads automatically when you switch nodes. ### Search and counts @@ -143,7 +143,7 @@ The default registry display at the top of the page is informational only; you c - Sencho fetches templates from the configured registry on demand. If the grid stays empty and the loading spinner is gone, the registry returned an empty list or the request failed before parsing. Open **Settings › Advanced › App Store** and confirm the **Registry URL** is reachable from the node (try the URL in a browser tab); a custom catalogue served from a private host will not load if the Sencho container cannot resolve or reach it. Click **Save & refresh** to invalidate the cache and try again. If you switched to a custom registry by mistake, click **Reset to default**. + Sencho fetches templates from the configured registry on demand. If the grid stays empty and the loading spinner is gone, the registry returned an empty list or the request failed before parsing. Open **Settings › Advanced › App Store** and confirm the **Registry URL** is reachable from the node (try the URL in a browser tab); a custom catalogue served from a private host will not load if the Sencho container cannot resolve or reach it. Click **Save & refresh** to invalidate the cache and try again. If you switched to a custom registry by mistake, click **Reset to default**. Sencho also bounds the registry response size: a catalogue that returns an excessively large payload is rejected with a size-limit error instead of being loaded, so a misconfigured or runaway endpoint cannot stall the node. The conflict indicator is computed against a snapshot of the node's bound ports taken when you opened the deployment sheet. If you freed the port from another stack while the sheet was open, the warning does not refresh on its own. Close the sheet and reopen it to recompute, or pick a different host port. The message `in use by an external app` means a process outside Sencho holds the binding (often the host's reverse proxy or a system service); changing the host port is usually faster than tracking down the offender. diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 64cb06c5..1200a3a5 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -59,28 +59,37 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const [trivyAvailable, setTrivyAvailable] = useState(false); const [sheetTab, setSheetTab] = useState<'essentials' | 'advanced'>('essentials'); + // The template registry is node-scoped, so the catalogue (and Trivy + // availability) must reload when the active node changes. Both are reset at + // the start of every run so a failed fetch shows the new node's empty state + // rather than leaving the previous node's catalogue or scan toggle on + // screen. The cancelled flag drops a slow response from a previous node so + // it cannot overwrite the current node's catalogue after a fast switch. useEffect(() => { - fetchTemplates(); + let cancelled = false; + (async () => { + setLoading(true); + setTemplates([]); + setTrivyAvailable(false); + try { + const res = await apiFetch('/templates'); + if (!res.ok) throw new Error('Failed to fetch templates'); + const data = await res.json(); + if (!cancelled) setTemplates(data || []); + } catch (err) { + if (!cancelled) toast.error(err instanceof Error ? err.message : 'Failed to load App Store'); + } finally { + if (!cancelled) setLoading(false); + } + })(); apiFetch('/security/trivy-status') .then(r => r.ok ? r.json() : null) - .then(d => { if (d) setTrivyAvailable(!!d.available); }) + .then(d => { if (!cancelled && d) setTrivyAvailable(!!d.available); }) .catch((err) => { console.error('Failed to fetch Trivy status:', err); }); - }, []); - - const fetchTemplates = async () => { - try { - const res = await apiFetch('/templates'); - if (!res.ok) throw new Error('Failed to fetch templates'); - const data = await res.json(); - setTemplates(data || []); - } catch (err) { - toast.error((err as Error).message || 'Failed to load App Store'); - } finally { - setLoading(false); - } - }; + return () => { cancelled = true; }; + }, [activeNode?.id]); const handleSelectTemplate = (t: Template) => { const envsCopy = [...(t.env || [])];