From cfdb22079f0795bd4639f6c088501ce6ef5e5379 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 14 Mar 2026 12:29:20 -0700 Subject: [PATCH 1/4] multiple manifests per team --- README.md | 6 +- client/src/App.tsx | 1 + client/src/api/adminManifests.ts | 5 +- client/src/api/manifest.test.ts | 103 ++-- client/src/api/manifest.ts | 112 +++- .../pages/Admin/ManifestAdmin.test.tsx | 8 +- .../components/pages/Admin/ManifestAdmin.tsx | 22 +- .../pages/Manifest/ManifestConfig.test.tsx | 2 + .../pages/Manifest/ManifestConfig.tsx | 52 +- .../pages/Manifest/ManifestList.tsx | 248 +++++++++ .../pages/Manifest/ManifestPage.test.tsx | 109 ++-- .../pages/Manifest/ManifestPage.tsx | 94 ++-- .../Manifest/ManifestSyncResult.test.tsx | 1 + .../pages/Manifest/SyncHistory.test.tsx | 24 +- .../components/pages/Manifest/SyncHistory.tsx | 5 +- .../pages/Teams/ManifestStatusCard.test.tsx | 233 +++++---- .../pages/Teams/ManifestStatusCard.tsx | 18 +- .../pages/Teams/TeamDetail.test.tsx | 109 +--- .../src/components/pages/Teams/TeamDetail.tsx | 193 ++----- client/src/hooks/useManifestConfig.test.ts | 82 ++- client/src/hooks/useManifestConfig.ts | 44 +- client/src/hooks/useManifestConfigs.ts | 74 +++ client/src/hooks/useSyncHistory.test.ts | 37 +- client/src/hooks/useSyncHistory.ts | 19 +- client/src/types/manifest.ts | 4 + docs/spec/15-manifest-sync.md | 141 +++-- server/src/db/migrate.ts | 7 + .../src/db/migrations/033_multi_manifest.ts | 152 ++++++ server/src/db/types.ts | 2 + server/src/routes/admin/manifests.test.ts | 55 +- server/src/routes/admin/manifests.ts | 90 +++- server/src/routes/drifts/index.ts | 4 + .../formatters/dependencyFormatter.test.ts | 1 + .../formatters/serviceFormatter.test.ts | 3 + server/src/routes/manifest/index.ts | 256 ++++++--- server/src/routes/manifest/manifest.test.ts | 484 +++++++++++++----- .../src/services/alerts/AlertService.test.ts | 1 + .../graph/DependencyGraphBuilder.test.ts | 1 + .../src/services/graph/GraphService.test.ts | 1 + .../services/manifest/ManifestDiffer.test.ts | 1 + .../manifest/ManifestSyncService.test.ts | 112 ++-- .../services/manifest/ManifestSyncService.ts | 209 ++++++-- server/src/services/manifest/types.test.ts | 9 +- server/src/services/manifest/types.ts | 4 + .../polling/DependencyUpsertService.test.ts | 1 + .../polling/HealthPollingService.test.ts | 1 + .../services/polling/PollStateManager.test.ts | 1 + .../services/polling/ServicePoller.test.ts | 1 + server/src/stores/impl/DriftFlagStore.test.ts | 1 + server/src/stores/impl/DriftFlagStore.ts | 13 +- .../stores/impl/ManifestConfigStore.test.ts | 110 ++-- server/src/stores/impl/ManifestConfigStore.ts | 66 ++- .../impl/ManifestSyncHistoryStore.test.ts | 9 + .../stores/impl/ManifestSyncHistoryStore.ts | 30 +- .../src/stores/interfaces/IDriftFlagStore.ts | 3 + .../stores/interfaces/IManifestConfigStore.ts | 9 +- .../interfaces/IManifestSyncHistoryStore.ts | 5 + 57 files changed, 2297 insertions(+), 1091 deletions(-) create mode 100644 client/src/components/pages/Manifest/ManifestList.tsx create mode 100644 client/src/hooks/useManifestConfigs.ts create mode 100644 server/src/db/migrations/033_multi_manifest.ts diff --git a/README.md b/README.md index d4d3435..a8b5a69 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ For detailed deployment options (bare Node.js, reverse proxy, backups), see the - Full alert delivery history (sent, failed, suppressed, muted) **Manifest Sync & Drift Detection** -- Declarative service configuration via JSON manifest URL per team +- Declarative service configuration via JSON manifest URLs (up to 20 per team) - Automated sync engine: fetch, validate, diff, and apply service definitions - Field-level drift detection when local edits diverge from the manifest - Sync policies: configurable behavior for field drift (flag/manifest wins/local wins) and service removal (flag/deactivate/delete) @@ -285,7 +285,7 @@ For production deployments with reverse proxy (nginx/Caddy), backup procedures, | `/` | Dashboard — health distribution, services with issues, polling issues (schema warnings + poll failures), team health summaries | | `/services` | Service list (team-scoped) with search and team filter; service detail with dependencies, charts, poll issues history, inline alias management (admin), and manual poll | | `/teams` | Team list with member/service counts; team detail with member management, manifest status, alert channels, rules, mutes, and history | -| `/teams/:id/manifest` | Manifest configuration, last sync result, drift review inbox, and sync history | +| `/teams/:id/manifest` | Manifest list (multi-config); `/teams/:id/manifest/:configId` for config detail with sync result, drift review, and sync history | | `/graph` | Interactive dependency graph with team filter, search, layout controls, automatic high-latency detection, and isolated tree view (right-click or detail panel) | | `/associations` | Manage associations (accordion browser with inline create/delete), alias management, and external service registry | | `/catalog` | Cross-team catalog with two tabs: **Services** (browse/search manifest keys) and **External Dependencies** (canonical name registry showing usage across teams, descriptions, and aliases) | @@ -312,7 +312,7 @@ All endpoints require authentication unless noted. Admin endpoints require the a | Graph | `GET /api/graph` with `team`, `service`, `dependency` filters | | History | `GET /api/latency/:id` + `/buckets`, `GET /api/errors/:id`, `GET /api/dependencies/:id/timeline`, `GET /api/services/:id/poll-history` | | Admin | `GET/PUT /api/admin/settings`, `GET /api/admin/audit-log` | -| Manifest | `GET/PUT/DELETE /api/teams/:id/manifest`, `POST /:id/manifest/sync`, `GET /:id/manifest/sync-history`, `POST /api/manifest/validate` | +| Manifest | `GET/POST /api/teams/:id/manifests`, `GET/PUT/DELETE /:id/manifests/:configId`, `POST /:id/manifests/sync`, `POST /:id/manifests/:configId/sync`, `GET /:id/manifests/:configId/sync-history`, `POST /api/manifest/validate`, `POST /api/manifest/test-url` | | Drift Flags | `GET /api/teams/:id/drifts` + `/summary`, `PUT /:driftId/accept` + `/dismiss` + `/reopen`, `POST /bulk-accept` + `/bulk-dismiss` | | Catalog | `GET /api/catalog/external-dependencies` — canonical name registry with team usage, descriptions, and aliases | | Alerts | CRUD on `/api/teams/:id/alert-channels` + `/test`, `GET/PUT /:id/alert-rules`, `GET /:id/alert-history`, `GET/POST /:id/alert-mutes`, `DELETE /:id/alert-mutes/:muteId`, `GET /api/admin/alert-mutes` | diff --git a/client/src/App.tsx b/client/src/App.tsx index 506d20a..546bb79 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -36,6 +36,7 @@ function App() { } /> } /> } /> + } /> } /> { mockFetch.mockReset(); }); -// --- Configuration --- +// --- Configuration (multi-config) --- -describe('getManifestConfig', () => { - it('fetches manifest config for a team', async () => { - const config = { id: 'c1', team_id: 't1', manifest_url: 'https://example.com/manifest.json' }; - mockFetch.mockResolvedValue(jsonResponse({ config })); +describe('getManifestConfigs', () => { + it('fetches manifest configs for a team', async () => { + const configs = [{ id: 'c1', team_id: 't1', name: 'Default', manifest_url: 'https://example.com/manifest.json' }]; + mockFetch.mockResolvedValue(jsonResponse({ configs })); - const result = await getManifestConfig('t1'); + const result = await getManifestConfigs('t1'); - expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifest', { credentials: 'include' }); - expect(result).toEqual(config); + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests', { credentials: 'include' }); + expect(result).toEqual(configs); }); +}); - it('returns null when no config exists', async () => { - mockFetch.mockResolvedValue(jsonResponse({ config: null })); +describe('getManifestConfig', () => { + it('fetches a single manifest config', async () => { + const config = { id: 'c1', team_id: 't1', name: 'Default', manifest_url: 'https://example.com/manifest.json' }; + mockFetch.mockResolvedValue(jsonResponse({ config })); - const result = await getManifestConfig('t1'); + const result = await getManifestConfig('t1', 'c1'); - expect(result).toBeNull(); + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests/c1', { credentials: 'include' }); + expect(result).toEqual(config); }); it('throws on error response', async () => { mockFetch.mockResolvedValue(jsonResponse({ message: 'Not found' }, 404)); - await expect(getManifestConfig('t1')).rejects.toThrow('Not found'); + await expect(getManifestConfig('t1', 'c1')).rejects.toThrow('Not found'); }); }); -describe('saveManifestConfig', () => { - it('saves manifest config', async () => { - const input = { manifest_url: 'https://example.com/manifest.json' }; +describe('createManifestConfig', () => { + it('creates a manifest config', async () => { + const input = { name: 'Default', manifest_url: 'https://example.com/manifest.json' }; const config = { id: 'c1', team_id: 't1', ...input }; mockFetch.mockResolvedValue(jsonResponse({ config })); - const result = await saveManifestConfig('t1', input); + const result = await createManifestConfig('t1', input); - expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifest', { - method: 'PUT', + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': 'test-csrf-token' }, body: JSON.stringify(input), credentials: 'include', @@ -74,18 +80,19 @@ describe('saveManifestConfig', () => { expect(result).toEqual(config); }); - it('saves config with sync policy', async () => { + it('creates config with sync policy', async () => { const input = { + name: 'Default', manifest_url: 'https://example.com/manifest.json', sync_policy: { on_field_drift: 'manifest_wins' as const }, }; - const config = { id: 'c1', team_id: 't1', manifest_url: input.manifest_url }; + const config = { id: 'c1', team_id: 't1', name: input.name, manifest_url: input.manifest_url }; mockFetch.mockResolvedValue(jsonResponse({ config })); - await saveManifestConfig('t1', input); + await createManifestConfig('t1', input); - expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifest', { - method: 'PUT', + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': 'test-csrf-token' }, body: JSON.stringify(input), credentials: 'include', @@ -96,7 +103,7 @@ describe('saveManifestConfig', () => { mockFetch.mockResolvedValue(jsonResponse({ message: 'SSRF blocked' }, 400)); await expect( - saveManifestConfig('t1', { manifest_url: 'http://localhost' }) + createManifestConfig('t1', { name: 'Default', manifest_url: 'http://localhost' }) ).rejects.toThrow('SSRF blocked'); }); }); @@ -105,9 +112,9 @@ describe('removeManifestConfig', () => { it('removes manifest config', async () => { mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => Promise.resolve({}) }); - await removeManifestConfig('t1'); + await removeManifestConfig('t1', 'c1'); - expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifest', { + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests/c1', { method: 'DELETE', headers: { 'X-CSRF-Token': 'test-csrf-token' }, credentials: 'include', @@ -121,7 +128,7 @@ describe('removeManifestConfig', () => { json: () => Promise.resolve({ message: 'Config not found' }), }); - await expect(removeManifestConfig('t1')).rejects.toThrow('Config not found'); + await expect(removeManifestConfig('t1', 'c1')).rejects.toThrow('Config not found'); }); it('throws with default message when json parse fails', async () => { @@ -131,20 +138,20 @@ describe('removeManifestConfig', () => { json: () => Promise.reject(new Error('Parse error')), }); - await expect(removeManifestConfig('t1')).rejects.toThrow('Delete failed'); + await expect(removeManifestConfig('t1', 'c1')).rejects.toThrow('Delete failed'); }); }); // --- Sync --- describe('triggerSync', () => { - it('triggers manual sync', async () => { + it('triggers manual team sync', async () => { const syncResult = { status: 'success', summary: {}, errors: [], warnings: [], changes: [], duration_ms: 500 }; mockFetch.mockResolvedValue(jsonResponse({ result: syncResult })); const result = await triggerSync('t1'); - expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifest/sync', { + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests/sync', { method: 'POST', headers: { 'X-CSRF-Token': 'test-csrf-token' }, credentials: 'include', @@ -165,14 +172,30 @@ describe('triggerSync', () => { }); }); -describe('getSyncHistory', () => { - it('fetches sync history with defaults', async () => { +describe('triggerConfigSync', () => { + it('triggers manual config sync', async () => { + const syncResult = { status: 'success', summary: {}, errors: [], warnings: [], changes: [], duration_ms: 500 }; + mockFetch.mockResolvedValue(jsonResponse({ result: syncResult })); + + const result = await triggerConfigSync('t1', 'c1'); + + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests/c1/sync', { + method: 'POST', + headers: { 'X-CSRF-Token': 'test-csrf-token' }, + credentials: 'include', + }); + expect(result).toEqual(syncResult); + }); +}); + +describe('getConfigSyncHistory', () => { + it('fetches sync history for a config', async () => { const data = { history: [], total: 0 }; mockFetch.mockResolvedValue(jsonResponse(data)); - const result = await getSyncHistory('t1'); + const result = await getConfigSyncHistory('t1', 'c1'); - expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifest/sync-history', { + expect(mockFetch).toHaveBeenCalledWith('/api/teams/t1/manifests/c1/sync-history', { credentials: 'include', }); expect(result).toEqual(data); @@ -182,10 +205,10 @@ describe('getSyncHistory', () => { const data = { history: [{ id: 'h1' }], total: 5 }; mockFetch.mockResolvedValue(jsonResponse(data)); - const result = await getSyncHistory('t1', { limit: 10, offset: 20 }); + const result = await getConfigSyncHistory('t1', 'c1', { limit: 10, offset: 20 }); expect(mockFetch).toHaveBeenCalledWith( - '/api/teams/t1/manifest/sync-history?limit=10&offset=20', + '/api/teams/t1/manifests/c1/sync-history?limit=10&offset=20', { credentials: 'include' } ); expect(result).toEqual(data); @@ -194,7 +217,7 @@ describe('getSyncHistory', () => { it('throws on error response', async () => { mockFetch.mockResolvedValue(jsonResponse({ message: 'Server error' }, 500)); - await expect(getSyncHistory('t1')).rejects.toThrow('Server error'); + await expect(getConfigSyncHistory('t1', 'c1')).rejects.toThrow('Server error'); }); }); diff --git a/client/src/api/manifest.ts b/client/src/api/manifest.ts index 2f5a1c0..0c5fa49 100644 --- a/client/src/api/manifest.ts +++ b/client/src/api/manifest.ts @@ -15,23 +15,49 @@ import type { import { handleResponse } from './common'; import { withCsrfToken } from './csrf'; -// --- Configuration --- +// --- Configuration (multi-config) --- -export async function getManifestConfig( +export async function getManifestConfigs( teamId: string -): Promise { - const response = await fetch(`/api/teams/${teamId}/manifest`, { +): Promise { + const response = await fetch(`/api/teams/${teamId}/manifests`, { + credentials: 'include', + }); + const data = await handleResponse<{ configs: TeamManifestConfig[] }>(response); + return data.configs; +} + +export async function getManifestConfig( + teamId: string, + configId: string +): Promise { + const response = await fetch(`/api/teams/${teamId}/manifests/${configId}`, { credentials: 'include', }); - const data = await handleResponse<{ config: TeamManifestConfig | null }>(response); + const data = await handleResponse<{ config: TeamManifestConfig }>(response); return data.config; } -export async function saveManifestConfig( +export async function createManifestConfig( teamId: string, input: ManifestConfigInput ): Promise { - const response = await fetch(`/api/teams/${teamId}/manifest`, { + const response = await fetch(`/api/teams/${teamId}/manifests`, { + method: 'POST', + headers: withCsrfToken({ 'Content-Type': 'application/json' }), + body: JSON.stringify(input), + credentials: 'include', + }); + const data = await handleResponse<{ config: TeamManifestConfig }>(response); + return data.config; +} + +export async function updateManifestConfig( + teamId: string, + configId: string, + input: Partial +): Promise { + const response = await fetch(`/api/teams/${teamId}/manifests/${configId}`, { method: 'PUT', headers: withCsrfToken({ 'Content-Type': 'application/json' }), body: JSON.stringify(input), @@ -41,8 +67,11 @@ export async function saveManifestConfig( return data.config; } -export async function removeManifestConfig(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}/manifest`, { +export async function removeManifestConfig( + teamId: string, + configId: string +): Promise { + const response = await fetch(`/api/teams/${teamId}/manifests/${configId}`, { method: 'DELETE', headers: withCsrfToken(), credentials: 'include', @@ -55,8 +84,8 @@ export async function removeManifestConfig(teamId: string): Promise { // --- Sync --- -export async function triggerSync(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}/manifest/sync`, { +export async function triggerTeamSync(teamId: string): Promise { + const response = await fetch(`/api/teams/${teamId}/manifests/sync`, { method: 'POST', headers: withCsrfToken(), credentials: 'include', @@ -65,8 +94,22 @@ export async function triggerSync(teamId: string): Promise { return data.result; } -export async function getSyncHistory( +export async function triggerConfigSync( + teamId: string, + configId: string +): Promise { + const response = await fetch(`/api/teams/${teamId}/manifests/${configId}/sync`, { + method: 'POST', + headers: withCsrfToken(), + credentials: 'include', + }); + const data = await handleResponse<{ result: ManifestSyncResult }>(response); + return data.result; +} + +export async function getConfigSyncHistory( teamId: string, + configId: string, options: SyncHistoryListOptions = {} ): Promise { const params = new URLSearchParams(); @@ -74,7 +117,7 @@ export async function getSyncHistory( if (options.offset !== undefined) params.set('offset', String(options.offset)); const query = params.toString(); - const url = `/api/teams/${teamId}/manifest/sync-history${query ? `?${query}` : ''}`; + const url = `/api/teams/${teamId}/manifests/${configId}/sync-history${query ? `?${query}` : ''}`; const response = await fetch(url, { credentials: 'include' }); return handleResponse(response); @@ -118,6 +161,7 @@ export async function getDriftFlags( if (options.status) params.set('status', options.status); if (options.drift_type) params.set('drift_type', options.drift_type); if (options.service_id) params.set('service_id', options.service_id); + if (options.manifest_config_id) params.set('manifest_config_id', options.manifest_config_id); if (options.limit !== undefined) params.set('limit', String(options.limit)); if (options.offset !== undefined) params.set('offset', String(options.offset)); @@ -202,3 +246,45 @@ export async function bulkDismissDrifts( const data = await handleResponse<{ result: BulkDriftActionResult }>(response); return data.result; } + +// --- Legacy aliases for backwards compatibility during transition --- +// These can be removed once all consumers are migrated to multi-config API + +/** @deprecated Use getManifestConfigs instead */ +export async function getManifestConfig_legacy( + teamId: string +): Promise { + const configs = await getManifestConfigs(teamId); + return configs.length > 0 ? configs[0] : null; +} + +/** @deprecated Use createManifestConfig or updateManifestConfig instead */ +export async function saveManifestConfig( + teamId: string, + input: ManifestConfigInput +): Promise { + // Check if any config exists + const configs = await getManifestConfigs(teamId); + if (configs.length > 0) { + return updateManifestConfig(teamId, configs[0].id, input); + } + return createManifestConfig(teamId, input); +} + +/** @deprecated Use triggerTeamSync or triggerConfigSync instead */ +export async function triggerSync(teamId: string): Promise { + return triggerTeamSync(teamId); +} + +/** @deprecated Use getConfigSyncHistory instead */ +export async function getSyncHistory( + teamId: string, + options: SyncHistoryListOptions = {} +): Promise { + // Try to get config ID for the first config + const configs = await getManifestConfigs(teamId); + if (configs.length > 0) { + return getConfigSyncHistory(teamId, configs[0].id, options); + } + return { history: [], total: 0 }; +} diff --git a/client/src/components/pages/Admin/ManifestAdmin.test.tsx b/client/src/components/pages/Admin/ManifestAdmin.test.tsx index 0223888..dc5a842 100644 --- a/client/src/components/pages/Admin/ManifestAdmin.test.tsx +++ b/client/src/components/pages/Admin/ManifestAdmin.test.tsx @@ -19,7 +19,8 @@ const mockEntries = [ team_name: 'Alpha Team', team_key: 'alpha-team', contact: JSON.stringify({ email: 'alpha@example.com' }), - has_config: true, + config_id: 'mc1', + config_name: 'Production', manifest_url: 'https://example.com/manifest.json', is_enabled: true, last_sync_at: new Date().toISOString(), @@ -33,7 +34,8 @@ const mockEntries = [ team_name: 'Beta Team', team_key: 'beta-team', contact: null, - has_config: false, + config_id: null, + config_name: null, manifest_url: null, is_enabled: false, last_sync_at: null, @@ -133,7 +135,7 @@ describe('ManifestAdmin', () => { // Mock sync-all response followed by data reload mockFetch.mockResolvedValueOnce( jsonResponse({ - results: [{ team_id: 't1', team_name: 'Alpha Team', status: 'success' }], + results: [{ team_id: 't1', team_name: 'Alpha Team', config_id: 'mc1', config_name: 'Production', status: 'success' }], }) ); mockFetch.mockResolvedValueOnce(jsonResponse(mockEntries)); diff --git a/client/src/components/pages/Admin/ManifestAdmin.tsx b/client/src/components/pages/Admin/ManifestAdmin.tsx index 9a61de9..43c0f26 100644 --- a/client/src/components/pages/Admin/ManifestAdmin.tsx +++ b/client/src/components/pages/Admin/ManifestAdmin.tsx @@ -49,7 +49,9 @@ function ManifestAdmin() { if (!searchQuery.trim()) return entries; const q = searchQuery.toLowerCase(); return entries.filter( - e => e.team_name.toLowerCase().includes(q) || (e.team_key ?? '').toLowerCase().includes(q) + e => e.team_name.toLowerCase().includes(q) + || (e.team_key ?? '').toLowerCase().includes(q) + || (e.config_name ?? '').toLowerCase().includes(q) ); }, [entries, searchQuery]); @@ -130,11 +132,11 @@ function ManifestAdmin() { {syncResults.map((r) => ( -
+
{r.status} - {r.team_name} + {r.team_name} / {r.config_name} {r.error && — {r.error}}
))} @@ -157,6 +159,7 @@ function ManifestAdmin() { Team + Config URL Enabled Last Sync @@ -173,17 +176,26 @@ function ManifestAdmin() { : null; return ( - + {entry.team_name} + + {entry.config_id ? ( + + {entry.config_name ?? 'Default'} + + ) : ( + + )} + {entry.manifest_url ?? '—'} - {entry.has_config ? ( + {entry.config_id ? ( {entry.is_enabled ? 'Enabled' : 'Disabled'} diff --git a/client/src/components/pages/Manifest/ManifestConfig.test.tsx b/client/src/components/pages/Manifest/ManifestConfig.test.tsx index 84988d1..692dcf5 100644 --- a/client/src/components/pages/Manifest/ManifestConfig.test.tsx +++ b/client/src/components/pages/Manifest/ManifestConfig.test.tsx @@ -11,6 +11,7 @@ beforeAll(() => { const baseConfig: TeamManifestConfig = { id: 'mc1', team_id: 't1', + name: 'Default', manifest_url: 'https://example.com/manifest.json', is_enabled: 1, sync_policy: JSON.stringify({ @@ -160,6 +161,7 @@ describe('ManifestConfig — edit mode', () => { await waitFor(() => { expect(onSave).toHaveBeenCalledWith({ + name: 'Default', manifest_url: 'https://new.example.com/manifest.json', sync_policy: { on_field_drift: 'flag', diff --git a/client/src/components/pages/Manifest/ManifestConfig.tsx b/client/src/components/pages/Manifest/ManifestConfig.tsx index 214817d..915f95e 100644 --- a/client/src/components/pages/Manifest/ManifestConfig.tsx +++ b/client/src/components/pages/Manifest/ManifestConfig.tsx @@ -60,10 +60,12 @@ function ManifestConfig({ // Form state const policy = parseSyncPolicy(config?.sync_policy ?? null); + const [formName, setFormName] = useState(config?.name ?? ''); const [formUrl, setFormUrl] = useState(config?.manifest_url ?? ''); const [formFieldDrift, setFormFieldDrift] = useState(policy.on_field_drift || 'flag'); const [formRemoval, setFormRemoval] = useState(policy.on_removal || 'flag'); const [urlError, setUrlError] = useState(null); + const [nameError, setNameError] = useState(null); // Test URL state const [isTesting, setIsTesting] = useState(false); @@ -72,10 +74,12 @@ function ManifestConfig({ const handleEdit = () => { const p = parseSyncPolicy(config?.sync_policy ?? null); + setFormName(config?.name ?? ''); setFormUrl(config?.manifest_url ?? ''); setFormFieldDrift(p.on_field_drift || 'flag'); setFormRemoval(p.on_removal || 'flag'); setUrlError(null); + setNameError(null); setTestResult(null); setTestError(null); setIsEditing(true); @@ -119,17 +123,32 @@ function ManifestConfig({ }; const handleSave = async () => { + const trimmedName = formName.trim(); const trimmedUrl = formUrl.trim(); + + let hasError = false; + + if (!trimmedName) { + setNameError('Name is required'); + hasError = true; + } else { + setNameError(null); + } + if (!trimmedUrl) { setUrlError('Manifest URL is required'); - return; - } - if (!isValidUrl(trimmedUrl)) { + hasError = true; + } else if (!isValidUrl(trimmedUrl)) { setUrlError('Please enter a valid HTTP or HTTPS URL'); - return; + hasError = true; + } else { + setUrlError(null); } + if (hasError) return; + const input: ManifestConfigInput = { + name: trimmedName, manifest_url: trimmedUrl, sync_policy: { on_field_drift: formFieldDrift, @@ -154,6 +173,26 @@ function ManifestConfig({ return (
+
+ + { + setFormName(e.target.value); + setNameError(null); + }} + placeholder="e.g. Production, Staging, Core Services" + required + maxLength={100} + /> + {nameError && {nameError}} +
+