Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) |
Expand All @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function App() {
<Route path="teams" element={<TeamsList />} />
<Route path="teams/:id" element={<TeamDetail />} />
<Route path="teams/:id/manifest" element={<ManifestPage />} />
<Route path="teams/:id/manifest/:configId" element={<ManifestPage />} />
<Route path="graph" element={<DependencyGraph />} />
<Route
path="admin/associations"
Expand Down
5 changes: 4 additions & 1 deletion client/src/api/adminManifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export interface AdminManifestEntry {
team_name: string;
team_key: string | null;
contact: string | null;
has_config: boolean;
config_id: string | null;
config_name: string | null;
manifest_url: string | null;
is_enabled: boolean;
last_sync_at: string | null;
Expand All @@ -19,6 +20,8 @@ export interface AdminManifestEntry {
export interface SyncAllResultEntry {
team_id: string;
team_name: string;
config_id: string;
config_name: string;
status: string;
error?: string;
}
Expand Down
103 changes: 63 additions & 40 deletions client/src/api/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
getManifestConfig,
saveManifestConfig,
getManifestConfigs,
createManifestConfig,
removeManifestConfig,
triggerSync,
getSyncHistory,
triggerConfigSync,
getConfigSyncHistory,
validateManifest,
getDriftFlags,
getDriftSummary,
Expand All @@ -29,63 +31,68 @@ beforeEach(() => {
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',
});
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',
Expand All @@ -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');
});
});
Expand All @@ -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',
Expand All @@ -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 () => {
Expand All @@ -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',
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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');
});
});

Expand Down
Loading
Loading