diff --git a/CLAUDE.md b/CLAUDE.md index 04999fc..febe28a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,5 +14,6 @@ All relevant implementation specs can be found in `docs/specs`. These must be ma 3. Wait for confirmation before proceeding - After implementing changes, always run npm run build to ensure that builds are not broken. - use `npm run test:server` and `npm run test:client` from project root when attempting to test either. +- When asked to help write a pr description, always use the template from `.github/` and put it in `pr.md` When being asked to handle linear tasks, refer to `LINEAR.md` for general instructions. \ No newline at end of file diff --git a/client/src/components/pages/Teams/TeamDetail.test.tsx b/client/src/components/pages/Teams/TeamDetail.test.tsx index 6ba8d74..b485f90 100644 --- a/client/src/components/pages/Teams/TeamDetail.test.tsx +++ b/client/src/components/pages/Teams/TeamDetail.test.tsx @@ -45,10 +45,38 @@ jest.mock('./AlertMutes', () => { AlertMutes.displayName = 'AlertMutes'; return AlertMutes; }); -jest.mock('./ManifestStatusCard', () => { - const ManifestStatusCard = () =>
; - ManifestStatusCard.displayName = 'ManifestStatusCard'; - return ManifestStatusCard; +// Mock useManifestConfig +const mockLoadManifestConfig = jest.fn(); +const mockUseManifestConfig = jest.fn(); +jest.mock('../../../hooks/useManifestConfig', () => ({ + useManifestConfig: (...args: unknown[]) => mockUseManifestConfig(...args), +})); + +// Mock manifest sub-components +jest.mock('../Manifest/ManifestConfig', () => { + const ManifestConfig = () =>
; + ManifestConfig.displayName = 'ManifestConfig'; + return ManifestConfig; +}); +jest.mock('../Manifest/ManifestSyncResult', () => { + const ManifestSyncResult = () =>
; + ManifestSyncResult.displayName = 'ManifestSyncResult'; + return ManifestSyncResult; +}); +jest.mock('../Manifest/DriftReview', () => { + const DriftReview = () =>
; + DriftReview.displayName = 'DriftReview'; + return DriftReview; +}); +jest.mock('../Manifest/SyncHistory', () => { + const SyncHistory = () =>
; + SyncHistory.displayName = 'SyncHistory'; + return SyncHistory; +}); +jest.mock('../Manifest/ServiceKeyLookup', () => { + const ServiceKeyLookup = () =>
; + ServiceKeyLookup.displayName = 'ServiceKeyLookup'; + return ServiceKeyLookup; }); function jsonResponse(data: unknown, status = 200) { @@ -97,6 +125,22 @@ beforeEach(() => { mockFetch.mockReset(); mockUseAuth.mockReset(); mockNavigate.mockReset(); + mockLoadManifestConfig.mockReset(); + mockUseManifestConfig.mockReturnValue({ + config: null, + isLoading: false, + error: null, + isSaving: false, + isSyncing: false, + syncResult: null, + loadConfig: mockLoadManifestConfig, + saveConfig: jest.fn(), + removeConfig: jest.fn(), + toggleEnabled: jest.fn(), + triggerSync: jest.fn(), + clearError: jest.fn(), + clearSyncResult: jest.fn(), + }); localStorage.clear(); }); @@ -703,7 +747,47 @@ describe('TeamDetail', () => { }); describe('manifests tab', () => { - it('renders ManifestStatusCard', async () => { + it('renders empty state when no manifest configured', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail('t1', false, 'manifests'); + + await waitFor(() => { + expect(screen.getByText(/No manifest configured/)).toBeInTheDocument(); + }); + }); + + it('renders manifest configuration when config exists', async () => { + mockUseManifestConfig.mockReturnValue({ + config: { + id: 'mc1', + team_id: 't1', + manifest_url: 'https://example.com/manifest.json', + is_enabled: 1, + sync_policy: null, + last_sync_at: '2024-06-01T00:00:00Z', + last_sync_status: 'success', + last_sync_error: null, + last_sync_summary: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + isLoading: false, + error: null, + isSaving: false, + isSyncing: false, + syncResult: null, + loadConfig: mockLoadManifestConfig, + saveConfig: jest.fn(), + removeConfig: jest.fn(), + toggleEnabled: jest.fn(), + triggerSync: jest.fn(), + clearError: jest.fn(), + clearSyncResult: jest.fn(), + }); + mockFetch .mockResolvedValueOnce(jsonResponse(mockTeam)) .mockResolvedValueOnce(jsonResponse([])); @@ -711,8 +795,12 @@ describe('TeamDetail', () => { renderTeamDetail('t1', false, 'manifests'); await waitFor(() => { - expect(screen.getByTestId('manifest-status-card')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-config')).toBeInTheDocument(); }); + expect(screen.getByTestId('manifest-sync-result')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-drift-review')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-sync-history')).toBeInTheDocument(); + expect(screen.getByTestId('manifest-service-key-lookup')).toBeInTheDocument(); }); }); diff --git a/client/src/components/pages/Teams/TeamDetail.tsx b/client/src/components/pages/Teams/TeamDetail.tsx index 1b9bd72..96be7a0 100644 --- a/client/src/components/pages/Teams/TeamDetail.tsx +++ b/client/src/components/pages/Teams/TeamDetail.tsx @@ -3,7 +3,9 @@ import { useParams, Link } from 'react-router-dom'; import { ChevronLeft, Pencil, Trash2 } from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { useTeamDetail, useTeamMembers } from '../../../hooks/useTeamDetail'; +import { useManifestConfig } from '../../../hooks/useManifestConfig'; import { parseContact } from '../../../utils/dependency'; +import { formatRelativeTime } from '../../../utils/formatting'; import Modal from '../../common/Modal'; import ConfirmDialog from '../../common/ConfirmDialog'; import { Tabs, TabList, Tab, TabPanel } from '../../common/Tabs'; @@ -13,8 +15,14 @@ import AlertRules from './AlertRules'; import AlertHistory from './AlertHistory'; import AlertMutes from './AlertMutes'; import TeamOverviewStats from './TeamOverviewStats'; -import ManifestStatusCard from './ManifestStatusCard'; +import ManifestConfig from '../Manifest/ManifestConfig'; +import ManifestSyncResult from '../Manifest/ManifestSyncResult'; +import DriftReview from '../Manifest/DriftReview'; +import SyncHistory from '../Manifest/SyncHistory'; +import ServiceKeyLookup from '../Manifest/ServiceKeyLookup'; import { useAlertChannels } from '../../../hooks/useAlertChannels'; +import cardStyles from '../../common/SummaryCards.module.css'; +import manifestStyles from '../Manifest/ManifestPage.module.css'; import styles from './Teams.module.css'; function TeamDetail() { @@ -30,6 +38,24 @@ function TeamDetail() { const { channels: alertChannels, loadChannels: loadAlertChannels } = useAlertChannels(id); + const { + config: manifestConfig, + isLoading: manifestLoading, + error: manifestError, + isSaving: manifestSaving, + isSyncing: manifestSyncing, + syncResult: manifestSyncResult, + loadConfig: loadManifestConfig, + saveConfig: saveManifestConfig, + removeConfig: removeManifestConfig, + toggleEnabled: toggleManifestEnabled, + triggerSync: triggerManifestSync, + clearError: clearManifestError, + clearSyncResult: clearManifestSyncResult, + } = useManifestConfig(id); + + const [isManifestCreating, setIsManifestCreating] = useState(false); + const { team, availableUsers, @@ -60,7 +86,8 @@ function TeamDetail() { useEffect(() => { loadTeam(); loadAlertChannels(); - }, [loadTeam, loadAlertChannels]); + loadManifestConfig(); + }, [loadTeam, loadAlertChannels, loadManifestConfig]); /* istanbul ignore next -- @preserve handleEditSuccess is triggered by TeamForm onSuccess inside a Modal. @@ -146,26 +173,6 @@ function TeamDetail() {

{team.name}

- {team.key && ( - {team.key} - )} - {team.description && ( -

{team.description}

- )} - {team.contact && (() => { - const contactData = parseContact(team.contact); - if (!contactData) return null; - return ( -
- {Object.entries(contactData).map(([label, value]) => ( - - {label}:{' '} - {String(value)} - - ))} -
- ); - })()}
{isAdmin && (
@@ -186,6 +193,65 @@ function TeamDetail() {
)}
+
+ {team.key && ( +
+ Team Key + {team.key} +
+ )} + {team.description && ( +
+ Description + {team.description} +
+ )} + {team.contact && (() => { + const contactData = parseContact(team.contact); + if (!contactData) return null; + return ( +
+ Contact +
+ {Object.entries(contactData).map(([label, value]) => ( +
+ {label}:{' '} + {String(value)} +
+ ))} +
+
+ ); + })()} + {!manifestLoading && ( +
+ Manifest Sync + {manifestConfig ? ( + <> + + {manifestConfig.is_enabled ? 'Enabled' : 'Disabled'} + + {manifestConfig.last_sync_at && ( + + Last sync: {formatRelativeTime(manifestConfig.last_sync_at)} + {manifestConfig.last_sync_status === 'failed' && ' (failed)'} + + )} + {!manifestConfig.last_sync_at && ( + No syncs yet + )} + + ) : ( + Not configured + )} +
+ )} +
- + {manifestLoading ? ( +
+
+ Loading manifest config... +
+ ) : ( + <> + {manifestError && ( +
+ {manifestError} + +
+ )} + + {/* No manifest configured — empty state */} + {!manifestConfig && !isManifestCreating && ( +
+

+ No manifest configured for this team. A manifest lets you declaratively define services, aliases, + and associations using a JSON file. Changes are automatically synced and manual edits are detected as drift. +

+ {canManageAlerts && ( + + )} +
+ )} + + {/* Create mode */} + {!manifestConfig && isManifestCreating && ( +
+
+

Configuration

+
+ setIsManifestCreating(false)} + /> +
+ )} + + {/* Full configuration when manifest exists */} + {manifestConfig && ( + <> +
+
+

Configuration

+
+ +
+ + + +
+ +
+ +
+
+

Drift Review

+
+ +
+ +
+
+

Sync History

+
+ +
+ + )} + + )} {/* Services Tab */} diff --git a/client/src/components/pages/Teams/Teams.module.css b/client/src/components/pages/Teams/Teams.module.css index 917dbc3..4a53016 100644 --- a/client/src/components/pages/Teams/Teams.module.css +++ b/client/src/components/pages/Teams/Teams.module.css @@ -273,27 +273,30 @@ margin: 0; } -.teamKey { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: var(--font-xs); - padding: 0.0625rem 0.375rem; - background-color: var(--color-surface-hover); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - color: var(--color-text-muted); +.infoCardsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-4); } -.teamDescription { - color: var(--color-text-muted); +.infoCardCode { + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; font-size: var(--font-base); - margin: 0; + color: var(--color-text); + word-break: break-all; +} + +.infoCardText { + font-size: var(--font-sm); + color: var(--color-text); + line-height: var(--line-height-relaxed, 1.6); } -.contactInfo { +.infoCardContact { display: flex; - flex-wrap: wrap; - gap: var(--space-2) var(--space-4); - margin-top: var(--space-1); + flex-direction: column; + gap: var(--space-1); } .contactItem { @@ -308,7 +311,7 @@ } .contactValue { - color: var(--color-text-primary); + color: var(--color-text); } .actions { diff --git a/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css b/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css index 8802ff0..4f1fe13 100644 --- a/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css +++ b/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css @@ -1,6 +1,6 @@ .panel { position: fixed; - top: var(--app-header-height, 3.75rem); + top: var(--app-header-height, 48px); right: 0; bottom: 0; width: 400px; diff --git a/client/src/components/pages/Wallboard/ServiceDetailPanel.module.css b/client/src/components/pages/Wallboard/ServiceDetailPanel.module.css index 4cb11fc..0010a5e 100644 --- a/client/src/components/pages/Wallboard/ServiceDetailPanel.module.css +++ b/client/src/components/pages/Wallboard/ServiceDetailPanel.module.css @@ -1,6 +1,6 @@ .panel { position: fixed; - top: var(--app-header-height, 3.75rem); + top: var(--app-header-height, 48px); right: 0; bottom: 0; width: 320px; diff --git a/docs/depsera-logo.svg b/docs/depsera-logo.svg index ab8c22f..17cd583 100644 --- a/docs/depsera-logo.svg +++ b/docs/depsera-logo.svg @@ -1001,7 +1001,7 @@ all-encompassing visibility across your service world - v1.11.1 + v1.11.2 apache 2.0 diff --git a/package-lock.json b/package-lock.json index 288219f..2e67463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "depsera", - "version": "1.11.1", + "version": "1.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "depsera", - "version": "1.11.1", + "version": "1.11.2", "license": "MIT", "devDependencies": { "concurrently": "^8.2.2", diff --git a/package.json b/package.json index 5982975..890867a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "depsera", - "version": "1.11.1", + "version": "1.11.2", "description": "Dependency monitoring and service health dashboard", "scripts": { "dev": "concurrently --kill-others \"npm run dev:server\" \"npm run dev:client\"",