Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
90e9f1a
DPS-67: Design system foundation — tokens, typography & shared styles
dantheuber Mar 6, 2026
60a7186
DPS-68: Header & footer refinement — extract components, design token…
dantheuber Mar 6, 2026
b97db9a
DPS-73: Modal & ConfirmDialog visual polish — design tokens, Lucide i…
dantheuber Mar 6, 2026
133c69a
DPS-69: Dashboard layout stability & polish — stable CSS Grid, design…
dantheuber Mar 6, 2026
ea945a3
DPS-70: Add reusable Tabs component with URL-persisted state
dantheuber Mar 6, 2026
5a0ceb4
DPS-70: Refactor TeamDetail into tabbed layout with tests
dantheuber Mar 6, 2026
639d03a
DPS-71: Refactor ServiceDetail into tabbed layout with tests
dantheuber Mar 6, 2026
b92aa19
DPS-71: Add dependency detail modal with latency chart and contact info
dantheuber Mar 6, 2026
b52d4ed
DPS-71: Add tests for URL param tab selection and dependency detail m…
dantheuber Mar 6, 2026
f80d85a
DPS-72: Polish graph toolbar, settings dropdown, and side panels
dantheuber Mar 6, 2026
cfd4faf
DPS-74: Polish Services list page with design tokens and Lucide icons
dantheuber Mar 6, 2026
d52012e
DPS-74: Polish Teams list page with design tokens and Lucide icons
dantheuber Mar 6, 2026
3c7b8c9
DPS-74: Polish Wallboard page with design tokens and Lucide icons
dantheuber Mar 6, 2026
a89aeb9
DPS-74: Polish Catalog page with design tokens and Lucide icons
dantheuber Mar 6, 2026
95faea4
DPS-74: Polish Admin, Associations, and Manifest pages with design to…
dantheuber Mar 6, 2026
9c8b688
DPS-74: Polish Login and NotFound pages with design tokens and Lucide…
dantheuber Mar 6, 2026
cd7716b
DPS-74: Polish SearchableSelect and StatusBadge with design tokens an…
dantheuber Mar 6, 2026
9772b2d
DPS-74: Final audit — replace hardcoded values, transition durations,…
dantheuber Mar 6, 2026
ba8a351
DPS-74: Update client architecture spec and README with design tokens…
dantheuber Mar 6, 2026
6b24658
UI polish pass
dantheuber Mar 6, 2026
8be65e6
1.11.1
dantheuber Mar 6, 2026
9a85157
Merge remote-tracking branch 'origin/main' into ui-refinement
dantheuber Mar 6, 2026
cf81ab4
more team detail updates and small fixes
dantheuber Mar 6, 2026
0120d86
1.11.2
dantheuber Mar 6, 2026
ae99398
add pr desc claude instruction
dantheuber Mar 6, 2026
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
100 changes: 94 additions & 6 deletions client/src/components/pages/Teams/TeamDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,38 @@ jest.mock('./AlertMutes', () => {
AlertMutes.displayName = 'AlertMutes';
return AlertMutes;
});
jest.mock('./ManifestStatusCard', () => {
const ManifestStatusCard = () => <div data-testid="manifest-status-card" />;
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 = () => <div data-testid="manifest-config" />;
ManifestConfig.displayName = 'ManifestConfig';
return ManifestConfig;
});
jest.mock('../Manifest/ManifestSyncResult', () => {
const ManifestSyncResult = () => <div data-testid="manifest-sync-result" />;
ManifestSyncResult.displayName = 'ManifestSyncResult';
return ManifestSyncResult;
});
jest.mock('../Manifest/DriftReview', () => {
const DriftReview = () => <div data-testid="manifest-drift-review" />;
DriftReview.displayName = 'DriftReview';
return DriftReview;
});
jest.mock('../Manifest/SyncHistory', () => {
const SyncHistory = () => <div data-testid="manifest-sync-history" />;
SyncHistory.displayName = 'SyncHistory';
return SyncHistory;
});
jest.mock('../Manifest/ServiceKeyLookup', () => {
const ServiceKeyLookup = () => <div data-testid="manifest-service-key-lookup" />;
ServiceKeyLookup.displayName = 'ServiceKeyLookup';
return ServiceKeyLookup;
});

function jsonResponse(data: unknown, status = 200) {
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -703,16 +747,60 @@ 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([]));

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();
});
});

Expand Down
210 changes: 187 additions & 23 deletions client/src/components/pages/Teams/TeamDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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() {
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -146,26 +173,6 @@ function TeamDetail() {
<div className={styles.overviewPanel}>
<div className={styles.teamTitle}>
<h1>{team.name}</h1>
{team.key && (
<code className={styles.teamKey}>{team.key}</code>
)}
{team.description && (
<p className={styles.teamDescription}>{team.description}</p>
)}
{team.contact && (() => {
const contactData = parseContact(team.contact);
if (!contactData) return null;
return (
<div className={styles.contactInfo}>
{Object.entries(contactData).map(([label, value]) => (
<span key={label} className={styles.contactItem}>
<span className={styles.contactLabel}>{label}:</span>{' '}
<span className={styles.contactValue}>{String(value)}</span>
</span>
))}
</div>
);
})()}
</div>
{isAdmin && (
<div className={styles.actions}>
Expand All @@ -186,6 +193,65 @@ function TeamDetail() {
</div>
)}
</div>
<div className={styles.infoCardsGrid}>
{team.key && (
<div className={cardStyles.summaryCardAccent}>
<span className={cardStyles.cardLabel}>Team Key</span>
<code className={styles.infoCardCode}>{team.key}</code>
</div>
)}
{team.description && (
<div className={cardStyles.summaryCardAccent}>
<span className={cardStyles.cardLabel}>Description</span>
<span className={styles.infoCardText}>{team.description}</span>
</div>
)}
{team.contact && (() => {
const contactData = parseContact(team.contact);
if (!contactData) return null;
return (
<div className={cardStyles.summaryCardAccent}>
<span className={cardStyles.cardLabel}>Contact</span>
<div className={styles.infoCardContact}>
{Object.entries(contactData).map(([label, value]) => (
<div key={label} className={styles.contactItem}>
<span className={styles.contactLabel}>{label}:</span>{' '}
<span className={styles.contactValue}>{String(value)}</span>
</div>
))}
</div>
</div>
);
})()}
{!manifestLoading && (
<div className={manifestConfig
? (manifestConfig.is_enabled
? cardStyles.summaryCardHealthy
: cardStyles.summaryCardWarning)
: cardStyles.summaryCardAccent
}>
<span className={cardStyles.cardLabel}>Manifest Sync</span>
{manifestConfig ? (
<>
<span className={styles.infoCardText}>
{manifestConfig.is_enabled ? 'Enabled' : 'Disabled'}
</span>
{manifestConfig.last_sync_at && (
<span className={cardStyles.cardSubtext}>
Last sync: {formatRelativeTime(manifestConfig.last_sync_at)}
{manifestConfig.last_sync_status === 'failed' && ' (failed)'}
</span>
)}
{!manifestConfig.last_sync_at && (
<span className={cardStyles.cardSubtext}>No syncs yet</span>
)}
</>
) : (
<span className={styles.infoCardText}>Not configured</span>
)}
</div>
)}
</div>
<TeamOverviewStats
teamId={id!}
members={team.members}
Expand Down Expand Up @@ -300,7 +366,105 @@ function TeamDetail() {

{/* Manifests Tab */}
<TabPanel value="manifests">
<ManifestStatusCard teamId={id!} canManage={canManageAlerts} />
{manifestLoading ? (
<div className={styles.loading} style={{ padding: '2rem' }}>
<div className={styles.spinner} />
<span>Loading manifest config...</span>
</div>
) : (
<>
{manifestError && (
<div className={manifestStyles.errorBanner}>
{manifestError}
<button
onClick={clearManifestError}
style={{ marginLeft: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', fontWeight: 600 }}
>
&times;
</button>
</div>
)}

{/* No manifest configured — empty state */}
{!manifestConfig && !isManifestCreating && (
<div className={manifestStyles.emptyState}>
<p>
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.
</p>
{canManageAlerts && (
<button className={manifestStyles.configureButton} onClick={() => setIsManifestCreating(true)}>
Configure Manifest
</button>
)}
</div>
)}

{/* Create mode */}
{!manifestConfig && isManifestCreating && (
<div className={manifestStyles.section}>
<div className={manifestStyles.sectionHeader}>
<h2 className={manifestStyles.sectionTitle}>Configuration</h2>
</div>
<ManifestConfig
config={null}
canManage={canManageAlerts}
isSaving={manifestSaving}
isNew
onSave={saveManifestConfig}
onRemove={removeManifestConfig}
onToggleEnabled={toggleManifestEnabled}
onCancelCreate={() => setIsManifestCreating(false)}
/>
</div>
)}

{/* Full configuration when manifest exists */}
{manifestConfig && (
<>
<div className={manifestStyles.section}>
<div className={manifestStyles.sectionHeader}>
<h2 className={manifestStyles.sectionTitle}>Configuration</h2>
</div>
<ManifestConfig
config={manifestConfig}
canManage={canManageAlerts}
isSaving={manifestSaving}
onSave={saveManifestConfig}
onRemove={removeManifestConfig}
onToggleEnabled={toggleManifestEnabled}
/>
</div>

<ServiceKeyLookup />

<div className={manifestStyles.section}>
<ManifestSyncResult
config={manifestConfig}
isSyncing={manifestSyncing}
syncResult={manifestSyncResult}
onSync={triggerManifestSync}
onClearSyncResult={clearManifestSyncResult}
/>
</div>

<div className={manifestStyles.section}>
<div className={manifestStyles.sectionHeader}>
<h2 className={manifestStyles.sectionTitle}>Drift Review</h2>
</div>
<DriftReview teamId={id!} canManage={canManageAlerts} />
</div>

<div className={manifestStyles.section}>
<div className={manifestStyles.sectionHeader}>
<h2 className={manifestStyles.sectionTitle}>Sync History</h2>
</div>
<SyncHistory teamId={id!} />
</div>
</>
)}
</>
)}
</TabPanel>

{/* Services Tab */}
Expand Down
Loading
Loading