Health by Team
@@ -277,19 +305,19 @@ function Dashboard() {
{healthy > 0 && (
-
+
{healthy}
)}
{warning > 0 && (
-
+
{warning}
)}
{critical > 0 && (
-
+
{critical}
@@ -306,60 +334,8 @@ function Dashboard() {
- {/* Recent Activity */}
-
-
-
Recent Activity
-
-
- {recentActivity.length > 0 ? (
-
- {recentActivity.map(event => {
- const status = event.current_healthy ? 'healthy' : 'critical';
- const previousLabel = event.previous_healthy === null
- ? 'new'
- : event.previous_healthy ? 'healthy' : 'critical';
- const currentLabel = event.current_healthy ? 'healthy' : 'critical';
- return (
-
- {/* eslint-disable-next-line security/detect-object-injection */}
-
- {event.current_healthy ? (
-
-
-
- ) : (
-
-
-
-
- )}
-
-
-
-
- {event.service_name}
-
- {' '}{event.dependency_name}: {previousLabel} → {currentLabel}
-
-
- {formatRelativeTime(event.recorded_at)}
-
-
-
- );
- })}
-
- ) : (
-
- No recent status changes
-
- )}
-
-
-
{/* Most Unstable Dependencies */}
-
+
Most Unstable (24h)
@@ -369,10 +345,11 @@ function Dashboard() {
{unstableDependencies.map(dep => {
const maxCount = unstableDependencies[0].change_count;
const barWidth = maxCount > 0 ? (dep.change_count / maxCount) * 100 : 0;
+ const healthClass = dep.current_healthy ? styles.healthy : styles.critical;
return (
-
+
{dep.dependency_name}
@@ -381,10 +358,12 @@ function Dashboard() {
@@ -393,10 +372,45 @@ function Dashboard() {
) : (
-
-
-
-
All dependencies stable
+ All dependencies stable
+
+ )}
+
+
+
+ {/* Polling Issues — always rendered to prevent layout shift */}
+
+
+
Polling Issues
+ {servicesWithPollingIssues.length > 0 && (
+
+ {servicesWithPollingIssues.length}
+
+ )}
+
+
+ {servicesWithPollingIssues.length > 0 ? (
+
+ ) : (
+
+ No polling issues
)}
diff --git a/client/src/components/pages/DependencyGraph/DependencyGraph.module.css b/client/src/components/pages/DependencyGraph/DependencyGraph.module.css
index ead9777..847eb4c 100644
--- a/client/src/components/pages/DependencyGraph/DependencyGraph.module.css
+++ b/client/src/components/pages/DependencyGraph/DependencyGraph.module.css
@@ -12,73 +12,94 @@
.toolbar {
display: flex;
align-items: center;
- gap: 16px;
- padding: 12px 20px;
- background: var(--color-bg-card);
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.toolbarGroup {
display: flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
}
.toolbarLabel {
- font-size: 14px;
+ font-size: var(--font-sm);
color: var(--color-text-muted);
- font-weight: 500;
+ font-weight: var(--font-medium);
}
.select {
- padding: 8px 12px;
- border: 1px solid var(--color-border-input);
- border-radius: 6px;
- font-size: 14px;
- background: var(--color-bg-input);
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
min-width: 150px;
+ transition: border-color var(--duration-fast) ease;
}
-.select:focus {
+.select:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+.searchWrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.searchIcon {
+ position: absolute;
+ left: var(--space-2);
+ color: var(--color-text-muted);
+ pointer-events: none;
}
.searchInput {
- padding: 8px 12px;
- border: 1px solid var(--color-border-input);
- border-radius: 6px;
- font-size: 14px;
+ font-size: var(--font-sm);
+ padding: var(--space-2) var(--space-3);
+ padding-left: calc(var(--space-2) + 16px + var(--space-1));
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
width: 200px;
- background: var(--color-bg-input);
- color: var(--color-text-primary);
+ background: var(--color-surface);
+ color: var(--color-text);
+ transition: border-color var(--duration-fast) ease;
}
-.searchInput:focus {
+.searchInput:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+.searchInput::placeholder {
+ color: var(--color-text-muted);
}
.toolbarButton {
display: flex;
align-items: center;
- gap: 6px;
- padding: 8px 12px;
- border: 1px solid var(--color-border-input);
- border-radius: 6px;
- background: var(--color-bg-card);
- color: var(--color-text-primary);
- font-size: 14px;
+ gap: var(--space-2);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-secondary);
cursor: pointer;
- transition: all 0.15s;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.toolbarButton:hover {
- background: var(--color-bg-hover);
- border-color: var(--color-text-muted);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.toolbarButton svg {
@@ -86,23 +107,25 @@
height: 16px;
}
-/* Exit isolation button */
+/* Exit isolation button — ghost style */
.exitIsolationButton {
display: flex;
align-items: center;
- gap: 6px;
- padding: 8px 12px;
- border: 1px solid var(--color-accent);
- border-radius: 6px;
- background: var(--color-accent);
- color: var(--color-text-inverse);
- font-size: 14px;
+ gap: var(--space-2);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-secondary);
cursor: pointer;
- transition: all 0.15s;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.exitIsolationButton:hover {
- opacity: 0.9;
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.exitIsolationButton svg {
@@ -112,32 +135,32 @@
/* Context menu */
.contextMenu {
- z-index: 1000;
- background: var(--color-bg-card);
+ z-index: var(--z-dropdown);
+ background: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- padding: 4px;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ padding: var(--space-1);
min-width: 160px;
}
.contextMenuItem {
display: flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
width: 100%;
- padding: 8px 12px;
+ padding: var(--space-2) var(--space-3);
border: none;
- border-radius: 6px;
+ border-radius: var(--radius-sm);
background: none;
- color: var(--color-text-primary);
- font-size: 13px;
+ color: var(--color-text);
+ font-size: var(--font-sm);
cursor: pointer;
- transition: background 0.1s;
+ transition: background-color var(--duration-fast) ease;
}
.contextMenuItem:hover {
- background: var(--color-bg-hover);
+ background: var(--color-surface-hover);
}
.contextMenuItem svg {
@@ -148,7 +171,7 @@
.toolbarRight {
display: flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
margin-left: auto;
}
@@ -161,31 +184,30 @@
display: flex;
align-items: center;
justify-content: center;
- padding: 6px;
+ padding: var(--space-1);
background: none;
border: 1px solid transparent;
- border-radius: 6px;
+ border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-muted);
- transition: all 0.15s;
+ transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease;
}
.legendButton:hover {
- color: var(--color-text-primary);
- background: var(--color-bg-hover);
- border-color: var(--color-border-input);
+ color: var(--color-text);
+ background: var(--color-surface-hover);
}
.legendTooltip {
display: none;
position: absolute;
- top: calc(100% + 8px);
+ top: calc(100% + var(--space-2));
right: 0;
- z-index: 50;
- padding: 12px 16px;
- background: var(--color-bg-card);
+ z-index: var(--z-tooltip);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 8px;
+ border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
white-space: nowrap;
}
@@ -194,7 +216,7 @@
.legendButton:focus + .legendTooltip {
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: var(--space-2);
}
/* Settings menu */
@@ -206,54 +228,62 @@
display: flex;
align-items: center;
justify-content: center;
- padding: 6px;
- background: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 6px;
+ padding: var(--space-1);
+ background: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
- color: var(--color-text-primary);
- transition: all 0.15s;
+ color: var(--color-text-secondary);
+ transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease;
}
.settingsButton:hover {
- background: var(--color-bg-hover);
- border-color: var(--color-text-muted);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.settingsMenu {
position: absolute;
- top: calc(100% + 8px);
+ top: calc(100% + var(--space-2));
right: 0;
- z-index: 50;
- padding: 12px;
- background: var(--color-bg-card);
+ z-index: var(--z-dropdown);
+ padding: var(--space-3);
+ background: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 8px;
+ border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
- gap: 12px;
- min-width: 220px;
+ gap: var(--space-3);
+ min-width: 240px;
}
.settingsMenuItem {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 12px;
+ gap: var(--space-3);
}
-.settingsMenuLabel {
- font-size: 13px;
- font-weight: 500;
+.settingsSectionHeader {
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.settingsMenuLabel {
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: var(--color-text-secondary);
white-space: nowrap;
}
.settingsMenuDivider {
height: 1px;
- background: var(--color-border);
- margin: 0 -4px;
+ background: var(--color-border-subtle);
+ margin: 0 calc(-1 * var(--space-1));
}
/* Legend (shared styles for tooltip items) */
@@ -261,17 +291,20 @@
.legendItem {
display: flex;
align-items: center;
- gap: 6px;
+ gap: var(--space-2);
+ font-size: var(--font-xs);
+ color: var(--color-text-secondary);
}
.legendDot {
- width: 10px;
- height: 10px;
+ width: 8px;
+ height: 8px;
border-radius: 50%;
+ flex-shrink: 0;
}
.legendDot.healthy {
- background: var(--color-success);
+ background: var(--color-healthy);
}
.legendDot.warning {
@@ -279,15 +312,15 @@
}
.legendDot.critical {
- background: var(--color-error);
+ background: var(--color-critical);
}
.legendDot.unknown {
- background: var(--color-text-muted);
+ background: var(--color-unknown);
}
.legendDot.skipped {
- background: var(--color-border-input);
+ background: var(--color-border);
border: 1px dashed var(--color-text-muted);
}
@@ -315,8 +348,9 @@
align-items: center;
justify-content: center;
height: 100%;
- gap: 16px;
+ gap: var(--space-4);
color: var(--color-text-muted);
+ font-size: var(--font-base);
}
.spinner {
@@ -335,17 +369,19 @@
}
.error {
- color: var(--color-error);
+ color: var(--color-critical);
}
.retryButton {
- padding: 8px 16px;
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-4);
background: var(--color-accent);
- color: var(--color-text-inverse);
+ color: #fff;
border: none;
- border-radius: 6px;
+ border-radius: var(--radius-sm);
cursor: pointer;
- font-size: 14px;
+ transition: background-color var(--duration-fast) ease;
}
.retryButton:hover {
@@ -362,7 +398,7 @@
min-width: 160px;
max-width: 200px;
box-shadow: var(--shadow-sm);
- transition: all 0.15s;
+ transition: all var(--duration-fast);
}
.serviceNode:hover,
@@ -642,13 +678,15 @@
justify-content: center;
height: 100%;
color: var(--color-text-muted);
- gap: 12px;
+ gap: var(--space-3);
+ font-size: var(--font-base);
}
.emptyState svg {
- width: 64px;
- height: 64px;
- color: var(--color-border-input);
+ width: 48px;
+ height: 48px;
+ color: var(--color-text-muted);
+ opacity: 0.5;
}
/* Controls override */
@@ -682,27 +720,16 @@
.autoRefreshControls {
display: flex;
align-items: center;
- gap: 0.5rem;
-}
-
-.autoRefreshLabel {
- font-size: 0.8125rem;
- font-weight: 500;
- color: var(--color-text-muted);
- white-space: nowrap;
+ gap: var(--space-2);
}
.refreshingIndicator {
display: flex;
align-items: center;
+ color: var(--color-accent);
}
-.spinnerSmall {
- width: 14px;
- height: 14px;
- border: 2px solid var(--color-border);
- border-top-color: var(--color-accent);
- border-radius: 50%;
+.refreshingIndicator svg {
animation: spin 1s linear infinite;
}
@@ -711,20 +738,20 @@
width: 2.5rem;
height: 1.5rem;
padding: 0;
- background-color: var(--color-border-input);
+ background-color: var(--color-border);
border: none;
border-radius: 1rem;
cursor: pointer;
- transition: background-color 0.2s;
+ transition: background-color var(--duration-normal) ease;
}
-.togglePill:focus {
+.togglePill:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.togglePill.toggleActive {
- background-color: var(--color-success);
+ background-color: var(--color-accent);
}
.toggleKnob {
@@ -733,10 +760,10 @@
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
- background-color: var(--color-bg-card);
+ background-color: #fff;
border-radius: 50%;
- transition: transform 0.2s;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+ transition: transform var(--duration-normal) ease;
+ box-shadow: var(--shadow-sm);
}
.togglePill.toggleActive .toggleKnob {
@@ -744,19 +771,20 @@
}
.intervalSelect {
- padding: 0.25rem 0.5rem;
- font-size: 0.8125rem;
- border: 1px solid var(--color-border-input);
- border-radius: 4px;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
+ font-size: var(--font-sm);
+ padding: var(--space-1) var(--space-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
cursor: pointer;
+ transition: border-color var(--duration-fast) ease;
}
-.intervalSelect:focus {
+.intervalSelect:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.intervalSelect:disabled {
@@ -764,11 +792,11 @@
cursor: not-allowed;
}
-/* Direction toggle */
+/* Segmented pill toggle */
.directionToggle {
display: flex;
- border: 1px solid var(--color-border-input);
- border-radius: 6px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
overflow: hidden;
}
@@ -776,25 +804,25 @@
display: flex;
align-items: center;
justify-content: center;
- padding: 6px 10px;
- background: var(--color-bg-card);
+ padding: var(--space-1) var(--space-2);
+ background: var(--color-surface);
border: none;
cursor: pointer;
- transition: all 0.15s;
- color: var(--color-text-primary);
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
+ color: var(--color-text-secondary);
}
.directionButton:first-child {
- border-right: 1px solid var(--color-border-input);
+ border-right: 1px solid var(--color-border);
}
.directionButton:hover {
- background: var(--color-bg-hover);
+ background: var(--color-surface-hover);
}
.directionButton.directionActive {
background: var(--color-accent);
- color: var(--color-text-inverse);
+ color: #fff;
}
.directionButton.directionActive:hover {
@@ -860,14 +888,10 @@
.edgeLabelHighLatency {
color: var(--color-warning) !important;
border-color: var(--color-warning) !important;
- background: #fffbeb !important;
+ background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface)) !important;
animation: pulseLabel 1.5s ease-in-out infinite;
}
-[data-theme="dark"] .edgeLabelHighLatency {
- background: #451a03 !important;
-}
-
/* Pulsing legend dot for high latency */
@keyframes pulseLegendDot {
0%, 100% {
diff --git a/client/src/components/pages/DependencyGraph/DependencyGraph.tsx b/client/src/components/pages/DependencyGraph/DependencyGraph.tsx
index 183cac3..6ba9564 100644
--- a/client/src/components/pages/DependencyGraph/DependencyGraph.tsx
+++ b/client/src/components/pages/DependencyGraph/DependencyGraph.tsx
@@ -14,6 +14,18 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
+import {
+ Search,
+ Maximize2,
+ Loader2,
+ Info,
+ Settings,
+ ArrowDown,
+ ArrowRight,
+ CornerDownRight,
+ RotateCcw,
+ Shrink,
+} from 'lucide-react';
import { getServiceHealthStatus } from '../../../types/graph';
import { ServiceNode } from './ServiceNode';
import { CustomEdge } from './CustomEdge';
@@ -418,13 +430,16 @@ function DependencyGraphInner() {
{isolationTarget && (
@@ -434,9 +449,7 @@ function DependencyGraphInner() {
onClick={exitIsolation}
title="Exit isolated view and show all nodes"
>
-
-
-
+
Show full graph
{isRefreshing && (
)}
@@ -455,10 +468,7 @@ function DependencyGraphInner() {
title="Legend"
aria-label="Show legend"
>
-
-
-
-
+
@@ -496,55 +506,47 @@ function DependencyGraphInner() {
aria-label="Graph settings"
aria-expanded={settingsOpen}
>
-
-
-
-
+
{settingsOpen && (
+
Layout
-
Layout
+
Direction
handleDirectionChange('TB')}
title="Top to Bottom"
>
-
-
-
+
handleDirectionChange('LR')}
title="Left to Right"
>
-
-
-
+
-
Edges
+
Edges
handleEdgeStyleChange('orthogonal')}
title="Orthogonal edges"
>
-
-
-
+
handleEdgeStyleChange('bezier')}
title="Bezier curve edges"
>
-
+
@@ -556,16 +558,15 @@ function DependencyGraphInner() {
className={styles.toolbarButton}
onClick={resetLayout}
title="Reset to auto-layout"
+ style={{ width: '100%' }}
>
-
-
-
-
+
Reset Layout
+
Animation
Dashed edges
@@ -592,9 +593,10 @@ function DependencyGraphInner() {
+
Refresh
-
Auto-refresh
+
Auto-refresh
diff --git a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css
index 06af0f5..cfcd02a 100644
--- a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css
+++ b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css
@@ -1,42 +1,67 @@
+/* Side panel — matches NodeDetailsPanel */
.panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 320px;
- background: var(--color-bg-card);
+ background: var(--color-surface);
border-left: 1px solid var(--color-border);
+ box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
+ animation: slideIn var(--duration-normal) ease;
}
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Slim scrollbar */
.scrollContent {
flex: 1;
min-height: 0;
overflow-y: auto;
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* IE/Edge */
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border) transparent;
}
.scrollContent::-webkit-scrollbar {
- display: none; /* Chrome/Safari/Opera */
+ width: 4px;
+}
+
+.scrollContent::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.scrollContent::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 2px;
}
+/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
- padding: 16px 20px;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
.title {
margin: 0;
- font-size: 18px;
- font-weight: 600;
- color: var(--color-text-primary);
+ font-size: var(--font-lg);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -46,58 +71,49 @@
display: flex;
align-items: center;
justify-content: center;
- width: 32px;
- height: 32px;
+ width: 28px;
+ height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
- border-radius: 6px;
- transition: all 0.15s;
+ border-radius: var(--radius-sm);
+ transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease;
flex-shrink: 0;
}
.closeButton:hover {
- background: var(--color-bg-hover);
- color: var(--color-text-primary);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
+/* Status section */
.statusSection {
display: flex;
flex-wrap: wrap;
- gap: 8px;
- padding: 16px 20px 0 20px;
+ gap: var(--space-2);
+ padding: var(--space-4) var(--space-4) 0 var(--space-4);
}
.statusBadge {
display: inline-flex;
align-items: center;
- gap: 8px;
- padding: 6px 12px;
- border-radius: 20px;
- font-size: 13px;
- font-weight: 500;
+ gap: var(--space-2);
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
}
.statusBadge.healthy {
- background: #ecfdf5;
- color: #059669;
-}
-
-[data-theme="dark"] .statusBadge.healthy {
- background: #064e3b;
- color: #34d399;
+ background: color-mix(in srgb, var(--color-healthy) 15%, transparent);
+ color: var(--color-healthy);
}
.statusBadge.warning {
- background: #fffbeb;
- color: #d97706;
-}
-
-[data-theme="dark"] .statusBadge.warning {
- background: #451a03;
- color: #fbbf24;
+ background: color-mix(in srgb, var(--color-warning) 15%, transparent);
+ color: var(--color-warning);
}
.statusBadge.critical {
@@ -106,48 +122,34 @@
}
.statusBadge.unknown {
- background: var(--color-bg-hover);
+ background: var(--color-surface-hover);
color: var(--color-text-muted);
}
.statusBadge.highLatency {
- background: #fffbeb;
- color: #d97706;
- animation: pulseHighLatency 1.5s ease-in-out infinite;
-}
-
-[data-theme="dark"] .statusBadge.highLatency {
- background: #451a03;
- color: #fbbf24;
-}
-
-@keyframes pulseHighLatency {
- 0%, 100% {
- box-shadow: 0 0 4px rgba(245, 158, 11, 0.3);
- }
- 50% {
- box-shadow: 0 0 10px rgba(245, 158, 11, 0.6);
- }
+ background: color-mix(in srgb, var(--color-warning) 15%, transparent);
+ color: var(--color-warning);
}
.statusDot {
- width: 8px;
- height: 8px;
+ width: 6px;
+ height: 6px;
border-radius: 50%;
background: currentColor;
}
+/* Sections */
.section {
- padding: 16px 20px;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
}
.sectionTitle {
- margin: 0 0 12px 0;
- font-size: 12px;
- font-weight: 600;
+ margin: 0 0 var(--space-2) 0;
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.04em;
color: var(--color-text-muted);
}
@@ -155,31 +157,31 @@
.connectionFlow {
display: flex;
align-items: center;
- gap: 12px;
+ gap: var(--space-2);
}
.connectionNode {
flex: 1;
display: flex;
flex-direction: column;
- gap: 4px;
- padding: 10px;
- background: var(--color-bg-hover);
- border-radius: 8px;
+ gap: 2px;
+ padding: var(--space-2);
+ background: var(--color-surface-hover);
+ border-radius: var(--radius-sm);
}
.connectionLabel {
- font-size: 11px;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.04em;
}
.connectionLink {
- font-size: 13px;
+ font-size: var(--font-sm);
color: var(--color-accent);
text-decoration: none;
- font-weight: 500;
+ font-weight: var(--font-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -190,8 +192,8 @@
}
.connectionText {
- font-size: 13px;
- color: var(--color-text-primary);
+ font-size: var(--font-sm);
+ color: var(--color-text);
}
.connectionArrow {
@@ -205,25 +207,21 @@
padding: 0;
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: var(--space-2);
}
.contactItem {
display: flex;
align-items: baseline;
- gap: 8px;
- padding: 6px 10px;
- background: var(--color-bg-hover);
- border-radius: 6px;
+ gap: var(--space-2);
}
.contactKey {
- font-size: 12px;
- font-weight: 600;
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
color: var(--color-text-muted);
text-transform: capitalize;
flex-shrink: 0;
- min-width: 0;
}
.contactKey::after {
@@ -231,8 +229,8 @@
}
.contactValue {
- font-size: 13px;
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ color: var(--color-text);
margin: 0;
overflow-wrap: break-word;
min-width: 0;
@@ -240,68 +238,74 @@
/* Latency chart section */
.chartSection {
- padding: 16px 20px;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
}
/* Impact */
.impactText {
margin: 0;
- font-size: 13px;
- line-height: 1.5;
- color: var(--color-text-primary);
+ font-size: var(--font-sm);
+ line-height: var(--line-height-normal);
+ color: var(--color-text);
}
/* Details grid */
.detailsGrid {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: var(--space-3);
}
.detailItem {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 2px;
}
.detailLabel {
- font-size: 12px;
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
}
.detailValue {
- font-size: 14px;
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ color: var(--color-text);
text-transform: capitalize;
}
/* Actions */
.actions {
- padding: 16px 20px;
+ padding: var(--space-3) var(--space-4);
margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
}
.isolateButton {
display: flex;
align-items: center;
justify-content: center;
- gap: 8px;
+ gap: var(--space-2);
width: 100%;
- padding: 10px 16px;
- background: var(--color-bg-hover);
- color: var(--color-text-primary);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ background: transparent;
+ color: var(--color-text-secondary);
border: 1px solid var(--color-border);
- border-radius: 8px;
- font-size: 14px;
- font-weight: 500;
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: background 0.15s;
- margin-bottom: 8px;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.isolateButton:hover {
- background: var(--color-border);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.isolateButton svg {
@@ -314,18 +318,18 @@
display: flex;
align-items: center;
justify-content: center;
- gap: 8px;
+ gap: var(--space-2);
width: 100%;
- padding: 10px 16px;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
background: var(--color-accent);
- color: var(--color-text-inverse);
+ color: #fff;
border: none;
- border-radius: 8px;
- font-size: 14px;
- font-weight: 500;
+ border-radius: var(--radius-sm);
text-decoration: none;
cursor: pointer;
- transition: background 0.15s;
+ transition: background-color var(--duration-fast) ease;
}
.viewDetailsButton:hover {
@@ -334,44 +338,45 @@
/* Error Alert Section */
.errorAlert {
- margin: 0 20px 16px;
- padding: 12px;
+ margin: 0 var(--space-4) var(--space-4);
+ padding: var(--space-3);
background: var(--color-error-bg);
- border: 1px solid var(--color-error);
- border-radius: 8px;
+ border: 1px solid var(--color-error-border);
+ border-radius: var(--radius-md);
}
.errorAlertHeader {
display: flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
color: var(--color-error);
- margin-bottom: 8px;
+ margin-bottom: var(--space-2);
}
.errorAlertTitle {
- font-weight: 600;
- font-size: 14px;
+ font-weight: var(--font-semibold);
+ font-size: var(--font-base);
}
.errorMessage {
margin: 0;
- font-size: 13px;
- color: var(--color-text-primary);
- line-height: 1.5;
+ font-size: var(--font-sm);
+ color: var(--color-text);
+ line-height: var(--line-height-normal);
}
.errorToggle {
display: flex;
align-items: center;
- gap: 4px;
- margin-top: 8px;
+ gap: var(--space-1);
+ margin-top: var(--space-2);
padding: 0;
border: none;
background: transparent;
color: var(--color-error);
- font-size: 12px;
+ font-size: var(--font-xs);
cursor: pointer;
+ transition: opacity var(--duration-fast) ease;
opacity: 0.8;
}
@@ -380,12 +385,12 @@
}
.errorDetails {
- margin: 8px 0 0;
- padding: 8px;
- background: rgba(0, 0, 0, 0.1);
- border-radius: 4px;
- font-size: 11px;
- font-family: monospace;
+ margin: var(--space-2) 0 0;
+ padding: var(--space-2);
+ background: color-mix(in srgb, var(--color-text) 8%, transparent);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-xs);
+ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
@@ -393,37 +398,34 @@
overflow-y: auto;
}
-[data-theme="dark"] .errorDetails {
- background: rgba(0, 0, 0, 0.3);
-}
/* Check Details Grid */
.checkDetailsGrid {
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: var(--space-2);
}
.checkDetailItem {
display: flex;
justify-content: space-between;
align-items: flex-start;
- gap: 12px;
- padding: 8px 10px;
- background: var(--color-bg-hover);
- border-radius: 6px;
+ gap: var(--space-3);
+ padding: var(--space-2);
+ background: var(--color-surface-hover);
+ border-radius: var(--radius-sm);
}
.checkDetailKey {
- font-size: 12px;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
flex-shrink: 0;
}
.checkDetailValue {
- font-size: 12px;
- color: var(--color-text-primary);
- font-family: monospace;
+ font-size: var(--font-xs);
+ color: var(--color-text);
+ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
text-align: right;
word-break: break-word;
}
@@ -436,26 +438,22 @@
.viewErrorHistoryButton {
display: flex;
align-items: center;
- justify-content: space-between;
+ gap: var(--space-2);
width: 100%;
- padding: 12px 14px;
- background: var(--color-bg-hover);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ background: transparent;
+ color: var(--color-text-secondary);
border: 1px solid var(--color-border);
- border-radius: 8px;
- font-size: 13px;
- font-weight: 500;
- color: var(--color-text-primary);
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: all 0.15s;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.viewErrorHistoryButton:hover {
- background: var(--color-border);
- border-color: var(--color-text-muted);
-}
-
-.viewErrorHistoryButton svg:first-child {
- margin-right: 8px;
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.viewErrorHistoryButton svg:last-child {
diff --git a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx
index 50f8dbd..5d69439 100644
--- a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx
+++ b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx
@@ -1,6 +1,7 @@
import { memo, useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { type Node } from '@xyflow/react';
+import { X, XCircle, ChevronDown, ArrowRight, Clock, ChevronRight, Shrink } from 'lucide-react';
import { ServiceNodeData, GraphEdgeData, getEdgeHealthStatus, HealthStatus } from '../../../types/graph';
import { LatencyChart } from '../../Charts/LatencyChart';
import { ErrorHistoryPanel } from '../../common/ErrorHistoryPanel';
@@ -86,9 +87,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs
@@ -112,9 +111,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs
{hasError && (
{data.errorMessage && (
@@ -127,17 +124,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs
onClick={() => setShowErrorDetails(!showErrorDetails)}
>
{showErrorDetails ? 'Hide' : 'Show'} error details
-
-
-
+
{showErrorDetails && (
@@ -165,9 +152,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs
)}
To
@@ -252,14 +237,9 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs
className={styles.viewErrorHistoryButton}
onClick={() => setCurrentView('errorHistory')}
>
-
-
-
-
+
View Error History (24h)
-
-
-
+
)}
@@ -271,18 +251,14 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs
className={styles.isolateButton}
onClick={() => onIsolate(data.dependencyId!)}
>
-
-
-
+
Isolate tree
)}
{targetNode && (
View Service Details
-
-
-
+
)}
diff --git a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css
index e93e6e6..45e5f06 100644
--- a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css
+++ b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css
@@ -1,49 +1,74 @@
+/* Side panel */
.panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 320px;
- background: var(--color-bg-card);
+ background: var(--color-surface);
border-left: 1px solid var(--color-border);
+ box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
+ animation: slideIn var(--duration-normal) ease;
}
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Slim scrollbar */
.scrollContent {
flex: 1;
min-height: 0;
overflow-y: auto;
- scrollbar-width: none;
- -ms-overflow-style: none;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border) transparent;
}
.scrollContent::-webkit-scrollbar {
- display: none;
+ width: 4px;
+}
+
+.scrollContent::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.scrollContent::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 2px;
}
+/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
- padding: 16px 20px;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-4) var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
}
.titleRow {
display: flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
min-width: 0;
flex: 1;
}
.title {
margin: 0;
- font-size: 18px;
- font-weight: 600;
- color: var(--color-text-primary);
+ font-size: var(--font-lg);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -52,21 +77,21 @@
.externalBadge {
display: inline-flex;
align-items: center;
- padding: 2px 8px;
- font-size: 11px;
- font-weight: 600;
+ padding: 2px var(--space-2);
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.04em;
color: var(--color-text-muted);
- background: var(--color-bg-hover);
+ background: var(--color-surface-hover);
border: 1px dashed var(--color-border);
- border-radius: 4px;
+ border-radius: var(--radius-sm);
flex-shrink: 0;
}
.externalDescription {
margin: 0;
- font-size: 12px;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
font-style: italic;
}
@@ -75,41 +100,42 @@
display: flex;
align-items: center;
justify-content: center;
- width: 32px;
- height: 32px;
+ width: 28px;
+ height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
- border-radius: 6px;
- transition: all 0.15s;
+ border-radius: var(--radius-sm);
+ transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease;
flex-shrink: 0;
}
.closeButton:hover {
- background: var(--color-bg-hover);
- color: var(--color-text-primary);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
+/* Status section */
.statusSection {
- padding: 16px 20px;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: var(--space-2);
}
.pollFailure {
display: flex;
align-items: center;
- gap: 6px;
- padding: 6px 10px;
+ gap: var(--space-1);
+ padding: var(--space-1) var(--space-2);
background-color: color-mix(in srgb, var(--color-warning) 12%, transparent);
- border-radius: 6px;
- font-size: 0.75rem;
+ border-radius: var(--radius-sm);
+ font-size: var(--font-xs);
color: var(--color-warning);
- line-height: 1.3;
+ line-height: var(--line-height-normal);
}
.pollFailure svg {
@@ -119,31 +145,22 @@
.statusBadge {
display: inline-flex;
align-items: center;
- gap: 8px;
- padding: 6px 12px;
- border-radius: 20px;
- font-size: 13px;
- font-weight: 500;
+ gap: var(--space-2);
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ width: fit-content;
}
.statusBadge.healthy {
- background: #ecfdf5;
- color: #059669;
-}
-
-[data-theme="dark"] .statusBadge.healthy {
- background: #064e3b;
- color: #34d399;
+ background: color-mix(in srgb, var(--color-healthy) 15%, transparent);
+ color: var(--color-healthy);
}
.statusBadge.warning {
- background: #fffbeb;
- color: #d97706;
-}
-
-[data-theme="dark"] .statusBadge.warning {
- background: #451a03;
- color: #fbbf24;
+ background: color-mix(in srgb, var(--color-warning) 15%, transparent);
+ color: var(--color-warning);
}
.statusBadge.critical {
@@ -152,61 +169,66 @@
}
.statusBadge.unknown {
- background: var(--color-bg-hover);
+ background: var(--color-surface-hover);
color: var(--color-text-muted);
}
.statusDot {
- width: 8px;
- height: 8px;
+ width: 6px;
+ height: 6px;
border-radius: 50%;
background: currentColor;
}
+/* Sections */
.section {
- padding: 16px 20px;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
}
.sectionTitle {
- margin: 0 0 4px 0;
- font-size: 12px;
- font-weight: 600;
+ margin: 0 0 var(--space-1) 0;
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.sectionDescription {
- margin: 0 0 12px 0;
- font-size: 12px;
+ margin: 0 0 var(--space-3) 0;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
}
+/* Details grid */
.detailsGrid {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: var(--space-3);
}
.detailItem {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 2px;
}
.detailLabel {
- font-size: 12px;
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
}
.detailValue {
- font-size: 14px;
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ color: var(--color-text);
}
.detailLink {
- font-size: 14px;
+ font-size: var(--font-sm);
color: var(--color-accent);
text-decoration: none;
word-break: break-all;
@@ -216,89 +238,98 @@
text-decoration: underline;
}
+/* Stats grid */
.statsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
- gap: 12px;
- margin-bottom: 12px;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
}
.statItem {
display: flex;
flex-direction: column;
align-items: center;
- gap: 4px;
- padding: 12px 8px;
- background: var(--color-bg-hover);
- border-radius: 8px;
+ gap: 2px;
+ padding: var(--space-2);
+ background: var(--color-surface-hover);
+ border-radius: var(--radius-sm);
}
.statItem.healthy .statValue {
- color: var(--color-success);
+ color: var(--color-healthy);
}
.statItem.critical .statValue {
- color: var(--color-error);
+ color: var(--color-critical);
}
.statValue {
- font-size: 24px;
- font-weight: 600;
- color: var(--color-text-primary);
+ font-size: var(--font-2xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
}
.statLabel {
- font-size: 11px;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.04em;
}
+/* Service / dependency lists */
.serviceList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 1px;
max-height: 200px;
overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border) transparent;
}
.serviceListItem {
display: flex;
align-items: center;
- gap: 8px;
- padding: 8px 10px;
- background: var(--color-bg-hover);
- border-radius: 6px;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-2);
+ border-radius: var(--radius-sm);
+ transition: background-color var(--duration-fast) ease;
+}
+
+.serviceListItem:hover {
+ background: var(--color-surface-hover);
}
.healthDot {
- width: 8px;
- height: 8px;
+ width: 6px;
+ height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.healthDot.healthy {
- background: var(--color-success);
+ background: var(--color-healthy);
}
.healthDot.warning {
- background: #f59e0b;
+ background: var(--color-warning);
}
.healthDot.critical {
- background: var(--color-error);
+ background: var(--color-critical);
}
.healthDot.unknown {
- background: var(--color-text-muted);
+ background: var(--color-unknown);
}
.serviceLink {
- font-size: 13px;
+ font-size: var(--font-sm);
color: var(--color-accent);
text-decoration: none;
white-space: nowrap;
@@ -313,24 +344,17 @@
.dependencyLabel {
display: flex;
align-items: center;
- gap: 4px;
+ gap: 2px;
margin-left: auto;
- font-size: 11px;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
- background: var(--color-bg-card);
- padding: 2px 6px;
- border-radius: 4px;
+ font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.dependencyLabel.highLatency {
color: var(--color-warning);
- font-weight: 600;
- background: #fffbeb;
-}
-
-[data-theme="dark"] .dependencyLabel.highLatency {
- background: #451a03;
+ font-weight: var(--font-semibold);
}
.highLatencyIcon {
@@ -343,25 +367,21 @@
padding: 0;
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: var(--space-2);
}
.contactItem {
display: flex;
align-items: baseline;
- gap: 8px;
- padding: 6px 10px;
- background: var(--color-bg-hover);
- border-radius: 6px;
+ gap: var(--space-2);
}
.contactKey {
- font-size: 12px;
- font-weight: 600;
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
color: var(--color-text-muted);
text-transform: capitalize;
flex-shrink: 0;
- min-width: 0;
}
.contactKey::after {
@@ -369,8 +389,8 @@
}
.contactValue {
- font-size: 13px;
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ color: var(--color-text);
margin: 0;
overflow-wrap: break-word;
min-width: 0;
@@ -378,35 +398,39 @@
/* Latency chart section */
.chartSection {
- padding: 16px 20px;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
}
+/* Actions */
.actions {
- padding: 16px 20px;
+ padding: var(--space-3) var(--space-4);
margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
}
.isolateButton {
display: flex;
align-items: center;
justify-content: center;
- gap: 8px;
+ gap: var(--space-2);
width: 100%;
- padding: 10px 16px;
- background: var(--color-bg-hover);
- color: var(--color-text-primary);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ background: transparent;
+ color: var(--color-text-secondary);
border: 1px solid var(--color-border);
- border-radius: 8px;
- font-size: 14px;
- font-weight: 500;
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: background 0.15s;
- margin-bottom: 8px;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.isolateButton:hover {
- background: var(--color-border);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.isolateButton svg {
@@ -419,18 +443,18 @@
display: flex;
align-items: center;
justify-content: center;
- gap: 8px;
+ gap: var(--space-2);
width: 100%;
- padding: 10px 16px;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
background: var(--color-accent);
- color: var(--color-text-inverse);
+ color: #fff;
border: none;
- border-radius: 8px;
- font-size: 14px;
- font-weight: 500;
+ border-radius: var(--radius-sm);
text-decoration: none;
cursor: pointer;
- transition: background 0.15s;
+ transition: background-color var(--duration-fast) ease;
}
.viewDetailsButton:hover {
diff --git a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx
index d072a93..e8251b0 100644
--- a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx
+++ b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx
@@ -1,6 +1,7 @@
import { memo, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { type Node, type Edge } from '@xyflow/react';
+import { X, AlertTriangle, ChevronRight, Shrink } from 'lucide-react';
import { ServiceNodeData, GraphEdgeData, getServiceHealthStatus, getEdgeHealthStatus, HealthStatus } from '../../../types/graph';
import { AggregateLatencyChart } from '../../Charts/AggregateLatencyChart';
import styles from './NodeDetailsPanel.module.css';
@@ -144,9 +145,7 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol
{isExternal &&
External }
-
-
-
+
@@ -160,10 +159,7 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol
{!isExternal && data.lastPollSuccess === false && (
-
-
-
-
+
Poll failed{data.lastPollError ? `: ${data.lastPollError}` : ''}
)}
@@ -228,9 +224,7 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol
{formatLatency(dep.latencyMs)}
{dep.isHighLatency && (
-
-
-
+
)}
)}
@@ -300,18 +294,14 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol
className={styles.isolateButton}
onClick={() => onIsolate(nodeId)}
>
-
-
-
+
Isolate tree
)}
{!isExternal && (
View Full Details
-
-
-
+
)}
diff --git a/client/src/components/pages/Manifest/DriftReview.module.css b/client/src/components/pages/Manifest/DriftReview.module.css
index 92b4620..c670be8 100644
--- a/client/src/components/pages/Manifest/DriftReview.module.css
+++ b/client/src/components/pages/Manifest/DriftReview.module.css
@@ -1,40 +1,40 @@
/* Sub-navigation toggle */
.viewToggle {
display: inline-flex;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
overflow: hidden;
- margin-bottom: 1rem;
+ margin-bottom: var(--space-4);
}
.viewButton {
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
border: none;
- background-color: var(--color-bg-card);
- color: var(--color-text-primary);
+ background-color: var(--color-surface);
+ color: var(--color-text);
cursor: pointer;
- transition: background-color 0.15s, color 0.15s;
+ transition: background-color var(--duration-fast), color var(--duration-fast);
}
.viewButton:first-child {
- border-right: 1px solid var(--color-border-input);
+ border-right: 1px solid var(--color-border);
}
.viewButtonActive {
background-color: var(--color-accent);
- color: var(--color-text-inverse);
+ color: #fff;
}
.viewButton:hover:not(.viewButtonActive) {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.viewCount {
- font-size: 0.75rem;
- font-weight: 600;
- margin-left: 0.25rem;
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
+ margin-left: var(--space-1);
opacity: 0.8;
}
@@ -42,24 +42,24 @@
.toolbar {
display: flex;
align-items: center;
- gap: 0.75rem;
+ gap: var(--space-3);
flex-wrap: wrap;
- margin-bottom: 1rem;
+ margin-bottom: var(--space-4);
}
.filters {
display: flex;
- gap: 0.5rem;
+ gap: var(--space-2);
flex: 1;
}
.filterSelect {
- padding: 0.375rem 0.625rem;
- font-size: 0.8125rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
+ padding: var(--space-2) 0.625rem;
+ font-size: var(--font-sm);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background-color: var(--color-surface);
+ color: var(--color-text);
}
.filterSelect:focus {
@@ -70,26 +70,26 @@
.bulkActions {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: var(--space-2);
}
.selectedCount {
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
color: var(--color-text-muted);
white-space: nowrap;
}
.bulkButton {
- padding: 0.375rem 0.625rem;
- font-size: 0.8125rem;
- font-weight: 500;
- border-radius: 0.375rem;
+ padding: var(--space-2) 0.625rem;
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.bulkAccept {
- color: var(--color-text-inverse);
+ color: #fff;
background-color: var(--color-accent);
border: none;
}
@@ -99,13 +99,13 @@
}
.bulkDismiss {
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
}
.bulkDismiss:hover:not(:disabled) {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.bulkButton:disabled {
@@ -117,10 +117,10 @@
.selectAllRow {
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 0;
- margin-bottom: 0.5rem;
- font-size: 0.8125rem;
+ gap: var(--space-2);
+ padding: var(--space-2) 0;
+ margin-bottom: var(--space-2);
+ font-size: var(--font-sm);
color: var(--color-text-muted);
}
@@ -133,30 +133,30 @@
.flagsList {
display: flex;
flex-direction: column;
- gap: 0.5rem;
+ gap: var(--space-2);
}
/* Empty state */
.emptyState {
- padding: 2rem;
+ padding: var(--space-6);
text-align: center;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
- border: 1px dashed var(--color-border-input);
- border-radius: 0.5rem;
+ background-color: var(--color-surface-hover);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-lg);
}
.error {
- padding: 0.75rem 1rem;
- color: var(--color-error);
+ padding: var(--space-3) var(--space-4);
+ color: var(--color-critical);
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
- border-radius: 0.375rem;
- font-size: 0.875rem;
+ border-radius: var(--radius-md);
+ font-size: var(--font-base);
}
.loading {
- padding: 2rem;
+ padding: var(--space-6);
text-align: center;
color: var(--color-text-muted);
}
@@ -164,16 +164,16 @@
/* Drift flag card */
.flagCard {
display: flex;
- gap: 0.75rem;
- padding: 1rem;
- background-color: var(--color-bg-card);
+ gap: var(--space-3);
+ padding: var(--space-4);
+ background-color: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 0.5rem;
- transition: border-color 0.15s;
+ border-radius: var(--radius-lg);
+ transition: border-color var(--duration-fast);
}
.flagCard:hover {
- border-color: var(--color-border-input);
+ border-color: var(--color-border);
}
.flagCardDismissed {
@@ -194,7 +194,7 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
- margin-bottom: 0.5rem;
+ margin-bottom: var(--space-2);
}
.flagServiceInfo {
@@ -204,22 +204,22 @@
}
.flagServiceName {
- font-weight: 600;
- font-size: 0.875rem;
- color: var(--color-text-heading);
+ font-weight: var(--font-semibold);
+ font-size: var(--font-base);
+ color: var(--color-text);
}
.flagManifestKey {
- font-size: 0.75rem;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
- font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace);
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
}
.driftTypeBadge {
display: inline-block;
- padding: 0.125rem 0.5rem;
+ padding: 0.125rem var(--space-2);
font-size: 0.6875rem;
- font-weight: 600;
+ font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
border-radius: 3px;
@@ -233,24 +233,24 @@
}
.badgeServiceRemoval {
- color: var(--color-error);
+ color: var(--color-critical);
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
}
/* Field diff */
.fieldName {
- font-size: 0.8125rem;
- font-weight: 500;
- color: var(--color-text-primary);
- margin-bottom: 0.375rem;
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
+ margin-bottom: var(--space-2);
}
.fieldDiff {
display: grid;
grid-template-columns: 1fr 1fr;
- gap: 0.5rem;
- margin-bottom: 0.5rem;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
}
.diffColumn {
@@ -261,72 +261,72 @@
.diffLabel {
font-size: 0.6875rem;
- font-weight: 600;
+ font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.diffValue {
- font-size: 0.8125rem;
- color: var(--color-text-primary);
+ font-size: var(--font-sm);
+ color: var(--color-text);
word-break: break-all;
- padding: 0.375rem 0.5rem;
- background-color: var(--color-bg-hover);
- border-radius: 0.25rem;
- font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace);
- font-size: 0.75rem;
+ padding: var(--space-2) var(--space-2);
+ background-color: var(--color-surface-hover);
+ border-radius: var(--radius-sm);
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
+ font-size: var(--font-xs);
}
.diffValueCurrent {
- border-left: 3px solid var(--color-error);
+ border-left: 3px solid var(--color-critical);
}
.diffValueManifest {
- border-left: 3px solid var(--color-success, #16a34a);
+ border-left: 3px solid var(--color-healthy);
}
/* Service removal message */
.removalMessage {
- font-size: 0.875rem;
+ font-size: var(--font-base);
color: var(--color-text-muted);
- padding: 0.75rem;
- background-color: var(--color-bg-hover);
- border-radius: 0.375rem;
- margin-bottom: 0.5rem;
+ padding: var(--space-3);
+ background-color: var(--color-surface-hover);
+ border-radius: var(--radius-md);
+ margin-bottom: var(--space-2);
}
/* Timestamps */
.flagTimestamps {
- font-size: 0.75rem;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
- margin-bottom: 0.5rem;
+ margin-bottom: var(--space-2);
}
/* Dismissed info */
.dismissedInfo {
- font-size: 0.75rem;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
font-style: italic;
- margin-bottom: 0.5rem;
+ margin-bottom: var(--space-2);
}
/* Actions */
.flagActions {
display: flex;
- gap: 0.5rem;
+ gap: var(--space-2);
}
.acceptButton {
- padding: 0.375rem 0.625rem;
- font-size: 0.8125rem;
- font-weight: 500;
- color: var(--color-text-inverse);
+ padding: var(--space-2) 0.625rem;
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: #fff;
background-color: var(--color-accent);
border: none;
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.acceptButton:hover:not(:disabled) {
@@ -334,31 +334,31 @@
}
.dismissButton {
- padding: 0.375rem 0.625rem;
- font-size: 0.8125rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ padding: var(--space-2) 0.625rem;
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.dismissButton:hover:not(:disabled) {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.reopenButton {
- padding: 0.375rem 0.625rem;
- font-size: 0.8125rem;
- font-weight: 500;
+ padding: var(--space-2) 0.625rem;
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
color: var(--color-accent);
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.reopenButton:hover:not(:disabled) {
@@ -374,33 +374,33 @@
.inlineConfirm {
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.375rem 0.625rem;
- font-size: 0.8125rem;
- color: var(--color-error);
+ gap: var(--space-2);
+ padding: var(--space-2) 0.625rem;
+ font-size: var(--font-sm);
+ color: var(--color-critical);
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
}
.inlineConfirm button {
- padding: 0.25rem 0.5rem;
- font-size: 0.75rem;
- font-weight: 600;
- border-radius: 0.25rem;
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
+ border-radius: var(--radius-sm);
cursor: pointer;
}
.confirmYes {
- color: var(--color-text-inverse);
- background-color: var(--color-error);
+ color: #fff;
+ background-color: var(--color-critical);
border: none;
}
.confirmNo {
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
}
/* Responsive */
diff --git a/client/src/components/pages/Manifest/ManifestPage.module.css b/client/src/components/pages/Manifest/ManifestPage.module.css
index e437ba1..a2b5bae 100644
--- a/client/src/components/pages/Manifest/ManifestPage.module.css
+++ b/client/src/components/pages/Manifest/ManifestPage.module.css
@@ -1,7 +1,7 @@
/* Container & Layout — mirrors Teams.module.css patterns */
.container {
height: 100%;
- padding: 2rem;
+ padding: var(--space-6);
display: flex;
flex-direction: column;
overflow: auto;
@@ -10,41 +10,41 @@
.backLink {
display: inline-flex;
align-items: center;
- gap: 0.375rem;
+ gap: var(--space-2);
color: var(--color-text-muted);
text-decoration: none;
- font-size: 0.875rem;
- margin-bottom: 1rem;
- transition: color 0.15s;
+ font-size: var(--font-base);
+ margin-bottom: var(--space-4);
+ transition: color var(--duration-fast);
}
.backLink:hover {
- color: var(--color-text-primary);
+ color: var(--color-text);
}
.pageTitle {
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--color-text-heading);
- margin: 0 0 1.5rem 0;
+ font-size: var(--font-2xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ margin: 0 0 var(--space-5) 0;
}
/* Section */
.section {
- margin-bottom: 2rem;
+ margin-bottom: var(--space-6);
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 1rem;
+ margin-bottom: var(--space-4);
}
.sectionTitle {
- font-size: 1.125rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
margin: 0;
}
@@ -54,17 +54,13 @@
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 1rem;
- padding: 4rem 2rem;
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-6);
color: var(--color-text-muted);
}
.spinner {
- width: 2rem;
- height: 2rem;
- border: 3px solid var(--color-border);
- border-top-color: var(--color-accent);
- border-radius: 50%;
+ color: var(--color-accent);
animation: spin 1s linear infinite;
}
@@ -78,27 +74,27 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 1rem;
- padding: 4rem 2rem;
- color: var(--color-error);
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-6);
+ color: var(--color-critical);
text-align: center;
}
.retryButton {
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
text-decoration: none;
}
.retryButton:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
/* Empty state (no manifest configured) */
@@ -106,34 +102,34 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 1rem;
- padding: 4rem 2rem;
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-6);
color: var(--color-text-muted);
text-align: center;
- background-color: var(--color-bg-hover);
- border: 1px dashed var(--color-border-input);
- border-radius: 0.5rem;
+ background-color: var(--color-surface-hover);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-lg);
}
.emptyState p {
margin: 0;
max-width: 28rem;
- line-height: 1.5;
+ line-height: var(--line-height-normal);
}
.configureButton {
display: inline-flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-inverse);
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ color: #fff;
background-color: var(--color-accent);
border: none;
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.configureButton:hover {
@@ -142,28 +138,28 @@
/* Inline error banner */
.errorBanner {
- padding: 0.75rem 1rem;
- margin-bottom: 1rem;
- color: var(--color-error);
+ padding: var(--space-3) var(--space-4);
+ margin-bottom: var(--space-4);
+ color: var(--color-critical);
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
- border-radius: 0.375rem;
- font-size: 0.875rem;
+ border-radius: var(--radius-md);
+ font-size: var(--font-base);
}
/* Config display */
.configCard {
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 0.5rem;
- padding: 1.25rem;
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
}
.configRow {
display: flex;
align-items: flex-start;
- gap: 0.5rem;
- margin-bottom: 0.75rem;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
}
.configRow:last-child {
@@ -171,8 +167,8 @@
}
.configLabel {
- font-size: 0.75rem;
- font-weight: 600;
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -181,31 +177,31 @@
}
.configValue {
- font-size: 0.875rem;
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ color: var(--color-text);
word-break: break-all;
}
.configValue code {
- font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace);
- font-size: 0.8125rem;
- padding: 0.125rem 0.375rem;
- background-color: var(--color-bg-hover);
- border-radius: 0.25rem;
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
+ font-size: var(--font-sm);
+ padding: 0.125rem var(--space-2);
+ background-color: var(--color-surface-hover);
+ border-radius: var(--radius-sm);
}
.policyLabel {
display: inline-block;
- padding: 0.125rem 0.5rem;
- font-size: 0.75rem;
- font-weight: 500;
+ padding: 0.125rem var(--space-2);
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
border-radius: 9999px;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.statusEnabled {
- color: var(--color-success, #16a34a);
+ color: var(--color-healthy);
}
.statusDisabled {
@@ -214,54 +210,54 @@
.configActions {
display: flex;
- gap: 0.5rem;
- margin-top: 1rem;
- padding-top: 1rem;
+ gap: var(--space-2);
+ margin-top: var(--space-4);
+ padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.actionButton {
display: inline-flex;
align-items: center;
- gap: 0.375rem;
- padding: 0.5rem 0.75rem;
- font-size: 0.875rem;
- font-weight: 500;
- border-radius: 0.375rem;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
}
.editButton {
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
}
.editButton:hover:not(:disabled) {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
border-color: var(--color-text-muted);
}
.disableButton {
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
}
.disableButton:hover:not(:disabled) {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.removeButton {
- color: var(--color-error);
- background-color: var(--color-bg-card);
+ color: var(--color-critical);
+ background-color: var(--color-surface);
border: 1px solid var(--color-error-border);
}
.removeButton:hover:not(:disabled) {
background-color: var(--color-error-bg);
- border-color: var(--color-error);
+ border-color: var(--color-critical);
}
.actionButton:disabled {
@@ -273,18 +269,18 @@
.configForm {
display: flex;
flex-direction: column;
- gap: 1rem;
+ gap: var(--space-4);
}
.field {
display: flex;
flex-direction: column;
- gap: 0.25rem;
+ gap: var(--space-1);
}
.label {
- font-size: 0.75rem;
- font-weight: 600;
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -292,50 +288,50 @@
.input {
width: 100%;
- padding: 0.5rem 0.75rem;
- font-size: 0.875rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
- transition: border-color 0.15s, box-shadow 0.15s;
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background-color: var(--color-surface);
+ color: var(--color-text);
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
}
.input:focus {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.inputError {
- border-color: var(--color-error);
+ border-color: var(--color-critical);
}
.fieldError {
- color: var(--color-error);
- font-size: 0.75rem;
+ color: var(--color-critical);
+ font-size: var(--font-xs);
}
.select {
width: 100%;
- padding: 0.5rem 0.75rem;
- font-size: 0.875rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background-color: var(--color-surface);
+ color: var(--color-text);
}
.select:focus {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
/* URL input with test button */
.urlInputRow {
display: flex;
- gap: 0.5rem;
+ gap: var(--space-2);
align-items: flex-start;
}
@@ -345,20 +341,20 @@
.testButton {
flex-shrink: 0;
- padding: 0.5rem 0.75rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
white-space: nowrap;
}
.testButton:hover:not(:disabled) {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
border-color: var(--color-text-muted);
}
@@ -370,29 +366,29 @@
/* Test result display */
.testResultBox {
display: flex;
- gap: 0.5rem;
- padding: 0.625rem 0.75rem;
- border-radius: 0.375rem;
- font-size: 0.8125rem;
+ gap: var(--space-2);
+ padding: 0.625rem var(--space-3);
+ border-radius: var(--radius-md);
+ font-size: var(--font-sm);
line-height: 1.4;
- margin-top: 0.25rem;
+ margin-top: var(--space-1);
}
.testResultBox[data-status="success"] {
- color: var(--color-success, #16a34a);
- background-color: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
- border: 1px solid color-mix(in srgb, var(--color-success, #16a34a) 20%, transparent);
+ color: var(--color-healthy);
+ background-color: color-mix(in srgb, var(--color-healthy) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-healthy) 20%, transparent);
}
.testResultBox[data-status="error"] {
- color: var(--color-error);
+ color: var(--color-critical);
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
}
.testResultIcon {
flex-shrink: 0;
- font-size: 0.875rem;
+ font-size: var(--font-base);
line-height: 1.4;
}
@@ -402,23 +398,23 @@
}
.testResultDetail {
- margin: 0.25rem 0 0;
- font-size: 0.75rem;
+ margin: var(--space-1) 0 0;
+ font-size: var(--font-xs);
opacity: 0.85;
}
.testIssueList {
- margin: 0.375rem 0 0;
- padding-left: 1.25rem;
- font-size: 0.75rem;
+ margin: var(--space-2) 0 0;
+ padding-left: var(--space-5);
+ font-size: var(--font-xs);
}
.testIssueList[data-severity="error"] {
- color: var(--color-error);
+ color: var(--color-critical);
}
.testIssueList[data-severity="warning"] {
- color: var(--color-warning, #ca8a04);
+ color: var(--color-warning);
}
.testIssueList li {
@@ -426,40 +422,40 @@
}
.testIssueList code {
- font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace);
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 0.6875rem;
- padding: 0.0625rem 0.25rem;
- background-color: rgba(0, 0, 0, 0.06);
- border-radius: 0.1875rem;
+ padding: 0.0625rem var(--space-1);
+ background-color: var(--color-surface-hover);
+ border-radius: 3px;
}
.fieldHint {
- font-size: 0.75rem;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
}
.deleteWarning {
- color: var(--color-error);
- font-size: 0.75rem;
- font-weight: 500;
+ color: var(--color-critical);
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
}
.formActions {
display: flex;
- gap: 0.5rem;
- margin-top: 0.5rem;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
}
.saveButton {
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-inverse);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ color: #fff;
background-color: var(--color-accent);
border: none;
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.saveButton:hover:not(:disabled) {
@@ -472,49 +468,49 @@
}
.cancelButton {
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.cancelButton:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
/* Sync result */
.syncCard {
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 0.5rem;
- padding: 1.25rem;
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
}
.syncHeader {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 0.75rem;
+ margin-bottom: var(--space-3);
}
.syncButton {
display: inline-flex;
align-items: center;
- gap: 0.375rem;
- padding: 0.5rem 0.75rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-inverse);
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ color: #fff;
background-color: var(--color-accent);
border: none;
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.syncButton:hover:not(:disabled) {
@@ -529,11 +525,11 @@
.syncButtonGroup {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: var(--space-2);
}
.cooldownText {
- font-size: 0.75rem;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
@@ -541,9 +537,9 @@
.syncStatus {
display: flex;
align-items: center;
- gap: 0.5rem;
- font-size: 0.875rem;
- color: var(--color-text-primary);
+ gap: var(--space-2);
+ font-size: var(--font-base);
+ color: var(--color-text);
}
.statusDot {
@@ -554,36 +550,36 @@
}
.statusDotSuccess {
- background-color: var(--color-success, #16a34a);
+ background-color: var(--color-healthy);
}
.statusDotPartial {
- background-color: var(--color-warning, #ca8a04);
+ background-color: var(--color-warning);
}
.statusDotError {
- background-color: var(--color-error);
+ background-color: var(--color-critical);
}
.syncMeta {
color: var(--color-text-muted);
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
}
.syncError {
- color: var(--color-error);
- font-size: 0.875rem;
- margin-top: 0.5rem;
- padding: 0.5rem 0.75rem;
+ color: var(--color-critical);
+ font-size: var(--font-base);
+ margin-top: var(--space-2);
+ padding: var(--space-2) var(--space-3);
background-color: var(--color-error-bg);
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
}
.syncSummaryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
- gap: 0.75rem;
- margin-top: 0.75rem;
+ gap: var(--space-3);
+ margin-top: var(--space-3);
}
.summaryItem {
@@ -593,19 +589,19 @@
}
.summaryValue {
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.summaryLabel {
- font-size: 0.75rem;
+ font-size: var(--font-xs);
color: var(--color-text-muted);
}
.noSyncs {
- padding: 2rem;
+ padding: var(--space-6);
text-align: center;
color: var(--color-text-muted);
}
@@ -614,15 +610,15 @@
.detailsToggle {
display: inline-flex;
align-items: center;
- gap: 0.375rem;
+ gap: var(--space-2);
padding: 0;
- margin-top: 0.75rem;
- font-size: 0.8125rem;
+ margin-top: var(--space-3);
+ font-size: var(--font-sm);
color: var(--color-accent);
background: none;
border: none;
cursor: pointer;
- transition: color 0.15s;
+ transition: color var(--duration-fast);
}
.detailsToggle:hover {
@@ -630,29 +626,29 @@
}
.changeList {
- margin-top: 0.75rem;
+ margin-top: var(--space-3);
border-top: 1px solid var(--color-border);
- padding-top: 0.75rem;
+ padding-top: var(--space-3);
}
.changeItem {
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.375rem 0;
- font-size: 0.8125rem;
- color: var(--color-text-primary);
+ gap: var(--space-2);
+ padding: var(--space-2) 0;
+ font-size: var(--font-sm);
+ color: var(--color-text);
}
.changeIcon {
- font-size: 0.875rem;
+ font-size: var(--font-base);
flex-shrink: 0;
- width: 1.25rem;
+ width: var(--space-5);
text-align: center;
}
.changeCreated {
- color: var(--color-success, #16a34a);
+ color: var(--color-healthy);
}
.changeUpdated {
@@ -660,11 +656,11 @@
}
.changeDrift {
- color: var(--color-warning, #ca8a04);
+ color: var(--color-warning);
}
.changeRemoved {
- color: var(--color-error);
+ color: var(--color-critical);
}
.changeUnchanged {
@@ -673,22 +669,22 @@
.changeFields {
color: var(--color-text-muted);
- font-size: 0.75rem;
+ font-size: var(--font-xs);
}
.warningsList {
- margin-top: 0.5rem;
- padding: 0.5rem 0.75rem;
- background-color: color-mix(in srgb, var(--color-warning, #ca8a04) 8%, transparent);
- border: 1px solid color-mix(in srgb, var(--color-warning, #ca8a04) 20%, transparent);
- border-radius: 0.375rem;
- font-size: 0.8125rem;
- color: var(--color-warning, #ca8a04);
+ margin-top: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ background-color: color-mix(in srgb, var(--color-warning) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-warning) 20%, transparent);
+ border-radius: var(--radius-md);
+ font-size: var(--font-sm);
+ color: var(--color-warning);
}
.warningsList ul {
margin: 0;
- padding-left: 1.25rem;
+ padding-left: var(--space-5);
}
/* Sync result banner (inline after manual sync) */
@@ -696,20 +692,20 @@
display: flex;
align-items: center;
justify-content: space-between;
- padding: 0.5rem 0.75rem;
- margin-bottom: 0.75rem;
- border-radius: 0.375rem;
- font-size: 0.875rem;
+ padding: var(--space-2) var(--space-3);
+ margin-bottom: var(--space-3);
+ border-radius: var(--radius-md);
+ font-size: var(--font-base);
}
.syncResultSuccess {
- color: var(--color-success, #16a34a);
- background-color: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
- border: 1px solid color-mix(in srgb, var(--color-success, #16a34a) 20%, transparent);
+ color: var(--color-healthy);
+ background-color: color-mix(in srgb, var(--color-healthy) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-healthy) 20%, transparent);
}
.syncResultError {
- color: var(--color-error);
+ color: var(--color-critical);
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
}
@@ -717,10 +713,10 @@
.dismissBanner {
background: none;
border: none;
- font-size: 1.125rem;
+ font-size: var(--font-xl);
cursor: pointer;
color: inherit;
- padding: 0 0.25rem;
+ padding: 0 var(--space-1);
line-height: 1;
}
@@ -734,30 +730,30 @@
.historyItem {
display: flex;
align-items: flex-start;
- gap: 0.75rem;
- padding: 0.75rem 1rem;
- background-color: var(--color-bg-card);
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-bottom: none;
- font-size: 0.875rem;
+ font-size: var(--font-base);
}
.historyItem:first-child {
- border-radius: 0.5rem 0.5rem 0 0;
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.historyItem:last-child {
border-bottom: 1px solid var(--color-border);
- border-radius: 0 0 0.5rem 0.5rem;
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.historyItem:only-child {
- border-radius: 0.5rem;
+ border-radius: var(--radius-lg);
border-bottom: 1px solid var(--color-border);
}
.historyDot {
- margin-top: 0.375rem;
+ margin-top: var(--space-2);
}
.historyContent {
@@ -768,60 +764,60 @@
.historyMain {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: var(--space-2);
flex-wrap: wrap;
}
.historyTime {
color: var(--color-text-muted);
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
}
.historyTrigger {
display: inline-block;
- padding: 0.0625rem 0.375rem;
+ padding: 0.0625rem var(--space-2);
font-size: 0.6875rem;
- font-weight: 500;
+ font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
border-radius: 3px;
}
.historyUser {
color: var(--color-text-muted);
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
}
.historySummary {
color: var(--color-text-muted);
- font-size: 0.8125rem;
- margin-top: 0.25rem;
+ font-size: var(--font-sm);
+ margin-top: var(--space-1);
}
.historyDuration {
color: var(--color-text-muted);
- font-size: 0.75rem;
+ font-size: var(--font-xs);
}
.loadMoreButton {
display: block;
width: 100%;
- padding: 0.75rem;
- margin-top: 0.5rem;
- font-size: 0.875rem;
- font-weight: 500;
+ padding: var(--space-3);
+ margin-top: var(--space-2);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
color: var(--color-accent);
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.loadMoreButton:hover:not(:disabled) {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.loadMoreButton:disabled {
@@ -830,63 +826,63 @@
}
.noItems {
- padding: 2rem;
+ padding: var(--space-6);
text-align: center;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
- border: 1px dashed var(--color-border-input);
- border-radius: 0.5rem;
+ background-color: var(--color-surface-hover);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-lg);
}
/* Service Key Lookup */
.lookupSection {
- margin-bottom: 1.5rem;
+ margin-bottom: var(--space-5);
border: 1px solid var(--color-border);
- border-radius: 0.5rem;
- background-color: var(--color-bg-card);
+ border-radius: var(--radius-lg);
+ background-color: var(--color-surface);
overflow: hidden;
}
.lookupToggle {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: var(--space-2);
width: 100%;
- padding: 0.75rem 1rem;
+ padding: var(--space-3) var(--space-4);
border: none;
background: none;
cursor: pointer;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
text-align: left;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.lookupToggle:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.lookupHint {
- font-weight: 400;
- font-size: 0.8125rem;
+ font-weight: var(--font-normal);
+ font-size: var(--font-sm);
color: var(--color-text-muted);
margin-left: auto;
}
.lookupContent {
border-top: 1px solid var(--color-border);
- padding: 0.75rem 1rem;
+ padding: var(--space-3) var(--space-4);
}
.lookupSearch {
position: relative;
- margin-bottom: 0.75rem;
+ margin-bottom: var(--space-3);
}
.lookupSearchIcon {
position: absolute;
- left: 0.5rem;
+ left: var(--space-2);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
@@ -895,19 +891,19 @@
.lookupSearchInput {
width: 100%;
- padding: 0.375rem 0.5rem 0.375rem 2rem;
- font-size: 0.8125rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
- transition: border-color 0.15s;
+ padding: var(--space-2) var(--space-2) var(--space-2) var(--space-6);
+ font-size: var(--font-sm);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background-color: var(--color-surface);
+ color: var(--color-text);
+ transition: border-color var(--duration-fast);
}
.lookupSearchInput:focus {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.lookupTable {
@@ -922,22 +918,22 @@
.lookupTable th {
text-align: left;
- padding: 0.5rem 0.75rem;
+ padding: var(--space-2) var(--space-3);
font-size: 0.6875rem;
- font-weight: 600;
+ font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
}
.lookupTable td {
- padding: 0.375rem 0.75rem;
- font-size: 0.8125rem;
- color: var(--color-text-primary);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-sm);
+ color: var(--color-text);
border-bottom: 1px solid var(--color-border);
}
@@ -946,22 +942,22 @@
}
.lookupTable tbody tr:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.lookupKey {
display: inline-flex;
align-items: center;
- gap: 0.25rem;
+ gap: var(--space-1);
}
.lookupKey code {
- font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
- font-size: 0.75rem;
- padding: 0.0625rem 0.25rem;
- background-color: var(--color-bg-hover);
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
+ font-size: var(--font-xs);
+ padding: 0.0625rem var(--space-1);
+ background-color: var(--color-surface-hover);
border: 1px solid var(--color-border);
- border-radius: 0.25rem;
+ border-radius: var(--radius-sm);
}
.lookupCopy {
@@ -972,27 +968,27 @@
height: 20px;
padding: 0;
border: 1px solid var(--color-border);
- background: var(--color-bg-card);
+ background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
border-radius: 3px;
- transition: all 0.15s;
+ transition: all var(--duration-fast);
flex-shrink: 0;
}
.lookupCopy:hover {
- background: var(--color-bg-hover);
- color: var(--color-text-primary);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.lookupCopied {
- color: var(--color-success);
- border-color: var(--color-success);
+ color: var(--color-healthy);
+ border-color: var(--color-healthy);
}
.lookupNoKey {
color: var(--color-text-muted);
- font-size: 0.75rem;
+ font-size: var(--font-xs);
}
.lookupTeam {
@@ -1002,59 +998,55 @@
.lookupLoading {
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 1rem;
+ gap: var(--space-2);
+ padding: var(--space-4);
color: var(--color-text-muted);
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
}
.spinnerSmall {
- width: 1rem;
- height: 1rem;
- border: 2px solid var(--color-border);
- border-top-color: var(--color-accent);
- border-radius: 50%;
+ color: var(--color-accent);
animation: spin 1s linear infinite;
}
.lookupError {
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.75rem;
- font-size: 0.8125rem;
- color: var(--color-error);
+ gap: var(--space-2);
+ padding: var(--space-3);
+ font-size: var(--font-sm);
+ color: var(--color-critical);
background-color: var(--color-error-bg, rgba(220, 53, 69, 0.08));
- border-radius: 0.375rem;
+ border-radius: var(--radius-md);
}
.lookupRetry {
- padding: 0.25rem 0.5rem;
- font-size: 0.75rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 0.25rem;
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
margin-left: auto;
}
.lookupRetry:hover {
- background: var(--color-bg-hover);
+ background: var(--color-surface-hover);
}
.lookupEmpty {
- padding: 1.5rem;
+ padding: var(--space-5);
text-align: center;
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
color: var(--color-text-muted);
}
/* Responsive */
@media (max-width: 640px) {
.container {
- padding: 1rem;
+ padding: var(--space-4);
}
.configActions {
@@ -1064,7 +1056,7 @@
.syncHeader {
flex-direction: column;
align-items: flex-start;
- gap: 0.75rem;
+ gap: var(--space-3);
}
.syncSummaryGrid {
diff --git a/client/src/components/pages/Manifest/ManifestPage.tsx b/client/src/components/pages/Manifest/ManifestPage.tsx
index 555c96f..4084834 100644
--- a/client/src/components/pages/Manifest/ManifestPage.tsx
+++ b/client/src/components/pages/Manifest/ManifestPage.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
+import { ChevronLeft, Loader2 } from 'lucide-react';
import { useParams, Link } from 'react-router-dom';
import { useAuth } from '../../../contexts/AuthContext';
import { useManifestConfig } from '../../../hooks/useManifestConfig';
@@ -69,7 +70,7 @@ function ManifestPage() {
return (
-
+
Loading manifest configuration...
@@ -96,16 +97,7 @@ function ManifestPage() {
return (
-
-
-
+
Back to {teamName || 'Team'}
diff --git a/client/src/components/pages/Manifest/ManifestSyncResult.tsx b/client/src/components/pages/Manifest/ManifestSyncResult.tsx
index c125981..104384a 100644
--- a/client/src/components/pages/Manifest/ManifestSyncResult.tsx
+++ b/client/src/components/pages/Manifest/ManifestSyncResult.tsx
@@ -1,4 +1,5 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, type ReactNode } from 'react';
+import { Plus, Minus, Equal, AlertTriangle, RefreshCw } from 'lucide-react';
import type {
TeamManifestConfig,
ManifestSyncResult as SyncResult,
@@ -30,20 +31,20 @@ function formatSyncSummaryText(summary: ManifestSyncSummary): string {
return parts.join(', ');
}
-const ACTION_ICONS: Record
= {
- created: { icon: '+', className: styles.changeCreated },
- updated: { icon: '~', className: styles.changeUpdated },
- unchanged: { icon: '=', className: styles.changeUnchanged },
- drift_flagged: { icon: '⚠', className: styles.changeDrift },
- deactivated: { icon: '×', className: styles.changeRemoved },
- deleted: { icon: '×', className: styles.changeRemoved },
+const ACTION_ICONS: Record = {
+ created: { icon: , className: styles.changeCreated },
+ updated: { icon: , className: styles.changeUpdated },
+ unchanged: { icon: , className: styles.changeUnchanged },
+ drift_flagged: { icon: , className: styles.changeDrift },
+ deactivated: { icon: , className: styles.changeRemoved },
+ deleted: { icon: , className: styles.changeRemoved },
};
function ChangeList({ changes }: { changes: ManifestSyncChange[] }) {
return (
{changes.map((change, i) => {
- const { icon, className } = ACTION_ICONS[change.action] || { icon: '?', className: '' };
+ const { icon, className } = ACTION_ICONS[change.action] || { icon: null, className: '' };
return (
{icon}
diff --git a/client/src/components/pages/Manifest/ServiceKeyLookup.tsx b/client/src/components/pages/Manifest/ServiceKeyLookup.tsx
index 03db68a..3fa93e8 100644
--- a/client/src/components/pages/Manifest/ServiceKeyLookup.tsx
+++ b/client/src/components/pages/Manifest/ServiceKeyLookup.tsx
@@ -1,4 +1,5 @@
import { useState, useCallback, useMemo } from 'react';
+import { ChevronRight, Search, Copy, Check, Loader2 } from 'lucide-react';
import { fetchServiceCatalog } from '../../../api/services';
import type { CatalogEntry } from '../../../types/service';
import styles from './ManifestPage.module.css';
@@ -69,20 +70,13 @@ function ServiceKeyLookup() {
onClick={handleToggle}
aria-expanded={isExpanded}
>
-
-
-
+ />
Service Key Lookup
Find manifest keys from other teams
@@ -93,7 +87,7 @@ function ServiceKeyLookup() {
{isLoading && (
)}
@@ -113,18 +107,7 @@ function ServiceKeyLookup() {
{loaded && !error && (
<>
-
-
-
-
+
{copiedId === entry.id ? (
-
-
-
+
) : (
-
-
-
-
+
)}
diff --git a/client/src/components/pages/Manifest/SyncHistory.tsx b/client/src/components/pages/Manifest/SyncHistory.tsx
index 75b3b06..09cf440 100644
--- a/client/src/components/pages/Manifest/SyncHistory.tsx
+++ b/client/src/components/pages/Manifest/SyncHistory.tsx
@@ -23,7 +23,7 @@ function formatEntrySummary(entry: ManifestSyncHistoryEntry): string {
if (summary.services.updated > 0) parts.push(`~${summary.services.updated}`);
if (summary.services.deactivated > 0) parts.push(`-${summary.services.deactivated}`);
if (summary.services.deleted > 0) parts.push(`×${summary.services.deleted}`);
- if (summary.services.drift_flagged > 0) parts.push(`⚠${summary.services.drift_flagged}`);
+ if (summary.services.drift_flagged > 0) parts.push(`!${summary.services.drift_flagged}`);
if (summary.services.unchanged > 0) parts.push(`=${summary.services.unchanged}`);
return parts.join(' ');
} catch {
@@ -83,14 +83,14 @@ function HistoryEntry({ entry }: { entry: ManifestSyncHistoryEntry }) {
{summaryText}
)}
{errors.length > 0 && (
-
+
{errors.map((e, i) => (
{e}
))}
)}
{warnings.length > 0 && (
-
+
Warnings:
{warnings.map((w, i) => (
diff --git a/client/src/components/pages/Services/DependencyDetailModal.module.css b/client/src/components/pages/Services/DependencyDetailModal.module.css
new file mode 100644
index 0000000..004ae61
--- /dev/null
+++ b/client/src/components/pages/Services/DependencyDetailModal.module.css
@@ -0,0 +1,163 @@
+/* Dependency Detail Modal */
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ margin-bottom: var(--space-4);
+}
+
+.headerInfo {
+ flex: 1;
+ min-width: 0;
+}
+
+.depName {
+ font-size: var(--font-lg);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ margin: 0;
+ line-height: var(--line-height-tight);
+}
+
+.targetService {
+ font-size: var(--font-sm);
+ color: var(--color-text-muted);
+ margin-top: var(--space-1);
+}
+
+/* Sections */
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.section + .section {
+ margin-top: var(--space-5);
+ padding-top: var(--space-5);
+ border-top: 1px solid var(--color-border-subtle);
+}
+
+.sectionTitle {
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--color-text-muted);
+ margin: 0;
+}
+
+/* Metadata grid */
+.metadataGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-4);
+}
+
+.metadataItem {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.metadataLabel {
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.metadataValue {
+ font-size: var(--font-base);
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Contact info */
+.contactList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.contactItem {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.contactKey {
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
+ color: var(--color-text-muted);
+ text-transform: capitalize;
+}
+
+.contactValue {
+ font-size: var(--font-base);
+ color: var(--color-text);
+ word-break: break-all;
+}
+
+/* Override indicator */
+.overrideBadge {
+ display: inline-block;
+ padding: 0.0625rem var(--space-2);
+ font-size: 0.625rem;
+ font-weight: var(--font-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--color-accent);
+ background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
+ border-radius: 9999px;
+ margin-left: var(--space-2);
+}
+
+/* Chart container */
+.chartContainer {
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-md);
+ padding: var(--space-3);
+ background: var(--color-surface);
+}
+
+/* Empty contact state */
+.emptyText {
+ font-size: var(--font-sm);
+ color: var(--color-text-muted);
+}
+
+/* Edit button at bottom */
+.editAction {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--color-border-subtle);
+ margin-top: var(--space-5);
+}
+
+.editButton {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: var(--color-text-secondary);
+ background: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
+}
+
+.editButton:hover {
+ background: var(--color-surface-hover);
+ color: var(--color-text);
+}
diff --git a/client/src/components/pages/Services/DependencyDetailModal.tsx b/client/src/components/pages/Services/DependencyDetailModal.tsx
new file mode 100644
index 0000000..fe0c365
--- /dev/null
+++ b/client/src/components/pages/Services/DependencyDetailModal.tsx
@@ -0,0 +1,152 @@
+import { Pencil } from 'lucide-react';
+import type { Dependency } from '../../../types/service';
+import { parseContact, hasActiveOverride } from '../../../utils/dependency';
+import { getHealthStateBadgeStatus } from '../../../utils/statusMapping';
+import { formatRelativeTime } from '../../../utils/formatting';
+import StatusBadge from '../../common/StatusBadge';
+import Modal from '../../common/Modal';
+import { LatencyChart } from '../../Charts';
+import styles from './DependencyDetailModal.module.css';
+
+interface DependencyDetailModalProps {
+ dep: Dependency | null;
+ serviceName: string;
+ serviceId: string;
+ onClose: () => void;
+ onEdit?: () => void;
+}
+
+function DependencyDetailModal({
+ dep,
+ serviceName,
+ serviceId,
+ onClose,
+ onEdit,
+}: DependencyDetailModalProps) {
+ if (!dep) return null;
+
+ const displayName = dep.canonical_name || dep.name;
+ const contact = parseContact(dep.effective_contact);
+ const overrideActive = hasActiveOverride(dep);
+
+ return (
+
+ {/* Header with status */}
+
+
+
+ Dependency of {serviceName}
+
+
+
+
+
+ {/* Metadata */}
+
+
Details
+
+
+ Name
+ {dep.name}
+
+ {dep.canonical_name && (
+
+ Canonical Name
+ {dep.canonical_name}
+
+ )}
+
+ Latency
+
+ {dep.latency_ms !== null ? `${Math.round(dep.latency_ms)}ms` : '-'}
+
+
+
+ Last Checked
+
+ {dep.last_checked ? formatRelativeTime(dep.last_checked) : '-'}
+
+
+ {dep.description && (
+
+ Description
+ {dep.description}
+
+ )}
+ {dep.effective_impact && (
+
+
+ Impact
+ {overrideActive && dep.impact_override && (
+ override
+ )}
+
+ {dep.effective_impact}
+
+ )}
+
+
+
+ {/* Latency Chart */}
+
+
+ {/* Contact Info */}
+
+
+ Contact
+ {overrideActive && dep.contact_override && (
+ override
+ )}
+
+ {contact ? (
+
+ {Object.entries(contact).map(([key, value]) => (
+
+ {key}
+ {String(value)}
+
+ ))}
+
+ ) : (
+
No contact information available.
+ )}
+
+
+ {/* Edit action for lead/admin */}
+ {onEdit && (
+
+
{
+ onClose();
+ onEdit();
+ }}
+ >
+
+ Edit Overrides
+
+
+ )}
+
+ );
+}
+
+export default DependencyDetailModal;
diff --git a/client/src/components/pages/Services/DependencyEditModal.module.css b/client/src/components/pages/Services/DependencyEditModal.module.css
index 33ad1c6..e8faeab 100644
--- a/client/src/components/pages/Services/DependencyEditModal.module.css
+++ b/client/src/components/pages/Services/DependencyEditModal.module.css
@@ -53,7 +53,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s;
+ transition: border-color var(--duration-fast);
}
.input:focus {
@@ -76,7 +76,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s;
+ transition: border-color var(--duration-fast);
}
.contactKeyInput:focus {
@@ -93,7 +93,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s;
+ transition: border-color var(--duration-fast);
}
.contactValueInput:focus {
@@ -115,7 +115,7 @@
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
- transition: all 0.15s;
+ transition: all var(--duration-fast);
}
.contactRemoveButton:hover {
@@ -136,7 +136,7 @@
border: 1px dashed var(--color-border-input);
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
align-self: flex-start;
}
@@ -174,7 +174,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s;
+ transition: border-color var(--duration-fast);
}
.aliasInput:focus {
@@ -242,7 +242,7 @@
color: var(--color-text-muted);
cursor: pointer;
border-radius: 4px;
- transition: all 0.15s;
+ transition: all var(--duration-fast);
}
.removeButton:hover {
@@ -262,7 +262,7 @@
border: 1px dashed var(--color-border-input);
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
}
.addAssocButton:hover {
@@ -298,7 +298,7 @@
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.btnPrimary:hover:not(:disabled) {
@@ -322,7 +322,7 @@
border: 1px solid var(--color-border-input);
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.btnSecondary:hover:not(:disabled) {
@@ -346,7 +346,7 @@
border: 1px solid var(--color-error-border, var(--color-error));
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.btnDanger:hover:not(:disabled) {
diff --git a/client/src/components/pages/Services/DependencyEditModal.tsx b/client/src/components/pages/Services/DependencyEditModal.tsx
index 852d5a5..cc5d1e2 100644
--- a/client/src/components/pages/Services/DependencyEditModal.tsx
+++ b/client/src/components/pages/Services/DependencyEditModal.tsx
@@ -165,7 +165,7 @@ function DependencyEditModal({
isOpen={dep !== null}
onClose={onClose}
title={`Edit — ${dep.canonical_name || dep.name}`}
- size="large"
+ size="lg"
>
{/* Overrides Section */}
diff --git a/client/src/components/pages/Services/DependencyList.module.css b/client/src/components/pages/Services/DependencyList.module.css
index a627656..92b9d35 100644
--- a/client/src/components/pages/Services/DependencyList.module.css
+++ b/client/src/components/pages/Services/DependencyList.module.css
@@ -66,7 +66,7 @@
background: none;
cursor: pointer;
text-align: left;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
gap: 1rem;
}
@@ -87,7 +87,13 @@
.nameMain {
font-weight: 500;
font-size: 0.875rem;
- color: var(--color-text-primary);
+ color: var(--color-accent);
+ cursor: pointer;
+ transition: color var(--duration-fast) ease;
+}
+
+.nameMain:hover {
+ text-decoration: underline;
}
.nameSub {
@@ -110,15 +116,11 @@
font-size: 0.625rem;
font-weight: 500;
border-radius: 9999px;
- background-color: rgba(239, 68, 68, 0.1);
- color: var(--color-error, #dc3545);
+ background-color: color-mix(in srgb, var(--color-critical) 12%, transparent);
+ color: var(--color-critical);
white-space: nowrap;
}
-[data-theme="dark"] .mutedBadge {
- background-color: rgba(239, 68, 68, 0.15);
-}
-
.aliasBadge {
display: inline-block;
padding: 0.0625rem 0.375rem;
@@ -235,7 +237,7 @@
color: var(--color-text-muted);
cursor: pointer;
border-radius: 6px;
- transition: all 0.15s;
+ transition: all var(--duration-fast);
}
.editButton:hover {
@@ -245,7 +247,7 @@
}
.chevron {
- transition: transform 0.2s ease;
+ transition: transform var(--duration-normal) ease;
flex-shrink: 0;
color: var(--color-text-muted);
}
diff --git a/client/src/components/pages/Services/DependencyList.tsx b/client/src/components/pages/Services/DependencyList.tsx
index d3c44e5..afc34ef 100644
--- a/client/src/components/pages/Services/DependencyList.tsx
+++ b/client/src/components/pages/Services/DependencyList.tsx
@@ -9,18 +9,21 @@ import type { Dependency } from '../../../types/service';
import type { Association } from '../../../types/association';
import DependencyRow from './DependencyRow';
import DependencyEditModal from './DependencyEditModal';
+import DependencyDetailModal from './DependencyDetailModal';
import styles from './DependencyList.module.css';
interface DependencyListProps {
serviceId: string;
+ serviceName: string;
dependencies: Dependency[];
canEditOverrides: boolean;
onServiceReload: () => Promise
;
}
-function DependencyList({ serviceId, dependencies, canEditOverrides, onServiceReload }: DependencyListProps) {
+function DependencyList({ serviceId, serviceName, dependencies, canEditOverrides, onServiceReload }: DependencyListProps) {
const [expandedRows, setExpandedRows] = useState>(new Set());
const [editingDep, setEditingDep] = useState(null);
+ const [viewingDep, setViewingDep] = useState(null);
const [assocMap, setAssocMap] = useState>({});
const {
@@ -155,6 +158,7 @@ function DependencyList({ serviceId, dependencies, canEditOverrides, onServiceRe
isExpanded={expandedRows.has(dep.id)}
onToggleExpand={() => toggleRow(dep.id)}
onEdit={() => setEditingDep(dep)}
+ onViewDetail={() => setViewingDep(dep)}
canEdit={canEditOverrides}
associations={assocMap[dep.id] || []}
alias={findAliasForDep(dep.name)}
@@ -176,6 +180,18 @@ function DependencyList({ serviceId, dependencies, canEditOverrides, onServiceRe
onRemoveAssociation={handleRemoveAssociation}
onAssociationAdded={handleAssociationAdded}
/>
+
+ setViewingDep(null)}
+ onEdit={canEditOverrides ? () => {
+ const dep = viewingDep;
+ setViewingDep(null);
+ setEditingDep(dep);
+ } : undefined}
+ />
>
);
}
diff --git a/client/src/components/pages/Services/DependencyRow.tsx b/client/src/components/pages/Services/DependencyRow.tsx
index 576c94a..1fbe1af 100644
--- a/client/src/components/pages/Services/DependencyRow.tsx
+++ b/client/src/components/pages/Services/DependencyRow.tsx
@@ -14,6 +14,7 @@ interface DependencyRowProps {
isExpanded: boolean;
onToggleExpand: () => void;
onEdit: () => void;
+ onViewDetail: () => void;
canEdit: boolean;
associations: Association[];
alias: DependencyAlias | undefined;
@@ -25,6 +26,7 @@ function DependencyRow({
isExpanded,
onToggleExpand,
onEdit,
+ onViewDetail,
canEdit,
associations,
alias,
@@ -48,7 +50,15 @@ function DependencyRow({
}}
>
-
+ {
+ e.stopPropagation();
+ onViewDetail();
+ }}
+ >
{dep.canonical_name || dep.name}
{dep.canonical_name && (
diff --git a/client/src/components/pages/Services/PollIssuesSection.module.css b/client/src/components/pages/Services/PollIssuesSection.module.css
index bad1569..e691c34 100644
--- a/client/src/components/pages/Services/PollIssuesSection.module.css
+++ b/client/src/components/pages/Services/PollIssuesSection.module.css
@@ -13,7 +13,7 @@
border: 1px solid var(--color-border);
border-radius: 0.5rem;
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.sectionToggle:hover {
@@ -54,8 +54,8 @@
}
.badgeWarning {
- background-color: var(--color-warning-bg, #fef3c7);
- color: var(--color-warning, #d97706);
+ background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
+ color: var(--color-warning);
}
.badgeNeutral {
@@ -64,7 +64,7 @@
}
.chevron {
- transition: transform 0.2s ease;
+ transition: transform var(--duration-normal) ease;
flex-shrink: 0;
color: var(--color-text-muted);
}
@@ -129,7 +129,7 @@
border: 1px solid var(--color-border-input);
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.retryButton:hover {
@@ -147,7 +147,7 @@
}
.emptyState svg {
- color: #10b981;
+ color: var(--color-healthy);
flex-shrink: 0;
}
@@ -179,18 +179,14 @@
font-size: 0.8125rem;
color: var(--color-text-primary);
padding: 0.5rem 0.75rem;
- background: var(--color-warning-bg, #fef3c7);
+ background: color-mix(in srgb, var(--color-warning) 15%, transparent);
border-radius: 0.375rem;
}
-[data-theme="dark"] .warningItem {
- background: #78350f33;
-}
-
.warningIcon {
flex-shrink: 0;
margin-top: 0.125rem;
- color: var(--color-warning, #d97706);
+ color: var(--color-warning);
}
/* Timeline */
@@ -240,7 +236,7 @@
}
.timelineItem.recovery .timelineDot {
- background: #10b981;
+ background: var(--color-healthy);
}
.timelineContent {
@@ -276,13 +272,8 @@
}
.recoveryStatus {
- background: #ecfdf5;
- color: #059669;
-}
-
-[data-theme="dark"] .recoveryStatus {
- background: #064e3b;
- color: #34d399;
+ background: color-mix(in srgb, var(--color-healthy) 15%, transparent);
+ color: var(--color-healthy);
}
.timelineMessage {
diff --git a/client/src/components/pages/Services/SchemaConfigEditor.module.css b/client/src/components/pages/Services/SchemaConfigEditor.module.css
index 8ff81dc..a5abdc0 100644
--- a/client/src/components/pages/Services/SchemaConfigEditor.module.css
+++ b/client/src/components/pages/Services/SchemaConfigEditor.module.css
@@ -32,7 +32,7 @@
border: 1px solid var(--color-border-input);
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
background-color: var(--color-bg-input);
color: var(--color-text-primary);
}
@@ -85,7 +85,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s, box-shadow 0.15s;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
}
.input:focus {
@@ -164,7 +164,7 @@
background-color: transparent;
color: var(--color-accent);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.testButton:hover:not(:disabled) {
diff --git a/client/src/components/pages/Services/ServiceDetail.test.tsx b/client/src/components/pages/Services/ServiceDetail.test.tsx
index 8f9ad65..bd9094b 100644
--- a/client/src/components/pages/Services/ServiceDetail.test.tsx
+++ b/client/src/components/pages/Services/ServiceDetail.test.tsx
@@ -118,10 +118,6 @@ const mockService = {
],
};
-/**
- * Build a default mock implementation that handles the initial service + teams load,
- * plus the additional DependencyList API calls (aliases, canonical names, associations, suggestions).
- */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setupDefaultMocks(service: any = mockService) {
mockFetch.mockImplementation((url: string) => {
@@ -134,11 +130,16 @@ function setupDefaultMocks(service: any = mockService) {
});
}
-function renderServiceDetail(id = 's1', authOverrides: { isAdmin?: boolean; user?: Record } = {}) {
+function renderServiceDetail(
+ id = 's1',
+ authOverrides: { isAdmin?: boolean; user?: Record | null } = {},
+ initialTab?: string,
+) {
const { isAdmin = false, user = null } = authOverrides;
mockUseAuth.mockReturnValue({ isAdmin, user });
+ const path = initialTab ? `/services/${id}?tab=${initialTab}` : `/services/${id}`;
return render(
-
+
} />
Services List
} />
@@ -147,10 +148,17 @@ function renderServiceDetail(id = 's1', authOverrides: { isAdmin?: boolean; user
);
}
+/** Helper to click a tab by its role */
+async function switchTab(name: string) {
+ const tab = screen.getByRole('tab', { name: new RegExp(name) });
+ fireEvent.click(tab);
+}
+
beforeEach(() => {
mockFetch.mockReset();
mockUseAuth.mockReset();
mockNavigate.mockReset();
+ localStorage.clear();
});
describe('ServiceDetail', () => {
@@ -177,7 +185,6 @@ describe('ServiceDetail', () => {
});
it('displays error state and allows retry', async () => {
- // Both fetches fail on first attempt (Promise.all)
mockFetch
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'));
@@ -188,7 +195,6 @@ describe('ServiceDetail', () => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
- // Setup success for retry
setupDefaultMocks();
fireEvent.click(screen.getByText('Retry'));
@@ -212,15 +218,17 @@ describe('ServiceDetail', () => {
expect(screen.getByText('Back to Services')).toBeInTheDocument();
});
- it('displays dependencies section', async () => {
+ it('displays dependencies section via tab', async () => {
setupDefaultMocks();
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
expect(screen.getAllByText('PostgreSQL').length).toBeGreaterThan(0);
expect(screen.getByText('Main DB')).toBeInTheDocument();
expect(screen.getByText('Critical')).toBeInTheDocument();
@@ -228,15 +236,17 @@ describe('ServiceDetail', () => {
expect(screen.getAllByText('cache').length).toBeGreaterThan(0);
});
- it('displays dependent reports table', async () => {
+ it('displays dependent reports table via tab', async () => {
setupDefaultMocks();
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('Dependent Reports')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependent Reports');
+
expect(screen.getByText('API Gateway')).toBeInTheDocument();
expect(screen.getByText('test-service')).toBeInTheDocument();
expect(screen.getByText('10ms')).toBeInTheDocument();
@@ -249,8 +259,12 @@ describe('ServiceDetail', () => {
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('No dependencies registered for this service.')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+
+ await switchTab('Dependencies');
+
+ expect(screen.getByText('No dependencies registered for this service.')).toBeInTheDocument();
});
it('shows empty state for no dependent reports', async () => {
@@ -260,8 +274,12 @@ describe('ServiceDetail', () => {
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('No services report depending on this service.')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+
+ await switchTab('Dependent Reports');
+
+ expect(screen.getByText('No services report depending on this service.')).toBeInTheDocument();
});
it('shows inactive badge for inactive service', async () => {
@@ -361,17 +379,21 @@ describe('ServiceDetail', () => {
});
});
- it('displays dependency without canonical name', async () => {
+ it('displays dependency without canonical name via tab', async () => {
setupDefaultMocks();
renderServiceDetail();
await waitFor(() => {
- expect(screen.getAllByText('cache').length).toBeGreaterThan(0);
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+
+ await switchTab('Dependencies');
+
+ expect(screen.getAllByText('cache').length).toBeGreaterThan(0);
});
- it('displays dash for null latency', async () => {
+ it('displays dash for null latency via tab', async () => {
setupDefaultMocks();
renderServiceDetail();
@@ -380,6 +402,8 @@ describe('ServiceDetail', () => {
expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
// The cache dependency has null latency
expect(screen.getAllByText('-').length).toBeGreaterThan(0);
});
@@ -395,6 +419,78 @@ describe('ServiceDetail', () => {
});
});
+ describe('Tabs', () => {
+ it('renders all tabs', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Dependencies/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Dependent Reports/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Poll Issues/ })).toBeInTheDocument();
+ });
+
+ it('shows overview tab by default', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('tab', { name: /Overview/ })).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('displays tab counts for dependencies and reports', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('tab', { name: /Dependencies \(2\)/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Dependent Reports \(1\)/ })).toBeInTheDocument();
+ });
+
+ it('shows empty state for external services on poll issues tab', async () => {
+ const externalService = { ...mockService, is_external: 1 };
+ setupDefaultMocks(externalService);
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Poll Issues');
+
+ expect(screen.getByText('Not applicable for external services.')).toBeInTheDocument();
+ });
+
+ it('shows empty state for inactive services on poll issues tab', async () => {
+ const inactiveService = { ...mockService, is_active: 0 };
+ setupDefaultMocks(inactiveService);
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Poll Issues');
+
+ expect(screen.getByText('Not applicable for inactive services.')).toBeInTheDocument();
+ });
+ });
+
describe('Contact and Override Display', () => {
it('displays effective_contact as key-value pairs', async () => {
const serviceWithContact = {
@@ -410,6 +506,12 @@ describe('ServiceDetail', () => {
renderServiceDetail();
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Dependencies');
+
await waitFor(() => {
expect(screen.getByText('db-team@example.com')).toBeInTheDocument();
});
@@ -435,11 +537,16 @@ describe('ServiceDetail', () => {
renderServiceDetail();
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Dependencies');
+
await waitFor(() => {
expect(screen.getByText('Overridden impact value')).toBeInTheDocument();
});
- // Raw impact should not be displayed
expect(screen.queryByText('Original impact')).not.toBeInTheDocument();
});
@@ -458,6 +565,12 @@ describe('ServiceDetail', () => {
renderServiceDetail();
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Dependencies');
+
await waitFor(() => {
expect(screen.getByText('Custom impact')).toBeInTheDocument();
});
@@ -482,6 +595,12 @@ describe('ServiceDetail', () => {
renderServiceDetail();
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Dependencies');
+
await waitFor(() => {
expect(screen.getByText('override@example.com')).toBeInTheDocument();
});
@@ -495,6 +614,12 @@ describe('ServiceDetail', () => {
renderServiceDetail();
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Dependencies');
+
await waitFor(() => {
expect(screen.getByText('Dependencies')).toBeInTheDocument();
});
@@ -516,11 +641,16 @@ describe('ServiceDetail', () => {
renderServiceDetail();
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await switchTab('Dependencies');
+
await waitFor(() => {
expect(screen.getByText('Dependencies')).toBeInTheDocument();
});
- // Should show dash instead of crashing
expect(screen.queryByText('not-valid-json')).not.toBeInTheDocument();
});
});
@@ -532,10 +662,11 @@ describe('ServiceDetail', () => {
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
- // Rows use aria-expanded
+ await switchTab('Dependencies');
+
const rowButtons = screen.getAllByRole('button', { expanded: false });
const depRows = rowButtons.filter(
btn => btn.textContent?.includes('PostgreSQL') || btn.textContent?.includes('cache')
@@ -549,18 +680,17 @@ describe('ServiceDetail', () => {
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
- // Charts should not be visible initially
+ await switchTab('Dependencies');
+
expect(screen.queryByTestId('latency-chart-d1')).not.toBeInTheDocument();
- // Click PostgreSQL row to expand
const rowButtons = screen.getAllByRole('button', { expanded: false });
const postgresRow = rowButtons.find(btn => btn.textContent?.includes('PostgreSQL'));
fireEvent.click(postgresRow!);
- // Charts and error history should now be visible
expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument();
expect(screen.getByTestId('health-timeline-d1')).toBeInTheDocument();
expect(screen.getByTestId('error-history-panel')).toBeInTheDocument();
@@ -572,17 +702,17 @@ describe('ServiceDetail', () => {
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
- // Expand PostgreSQL row
+ await switchTab('Dependencies');
+
const rowButtons = screen.getAllByRole('button', { expanded: false });
const postgresRow = rowButtons.find(btn => btn.textContent?.includes('PostgreSQL'));
fireEvent.click(postgresRow!);
expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument();
- // Click again to collapse
const expandedButton = screen.getByRole('button', { expanded: true });
fireEvent.click(expandedButton);
@@ -595,10 +725,11 @@ describe('ServiceDetail', () => {
renderServiceDetail();
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
- // Expand both rows
+ await switchTab('Dependencies');
+
const rowButtons = screen.getAllByRole('button', { expanded: false });
const postgresRow = rowButtons.find(btn => btn.textContent?.includes('PostgreSQL'));
const cacheRow = rowButtons.find(btn => btn.textContent?.includes('cache'));
@@ -606,7 +737,6 @@ describe('ServiceDetail', () => {
fireEvent.click(postgresRow!);
fireEvent.click(cacheRow!);
- // Both charts should be visible
expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument();
expect(screen.getByTestId('health-timeline-d1')).toBeInTheDocument();
expect(screen.getByTestId('latency-chart-d2')).toBeInTheDocument();
@@ -653,11 +783,13 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
- expect(editButtons.length).toBe(2); // one per dependency
+ expect(editButtons.length).toBe(2);
});
it('shows edit buttons for team leads of the service team', async () => {
@@ -666,9 +798,11 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: false, user: teamLeadUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
expect(editButtons.length).toBe(2);
});
@@ -679,9 +813,11 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: false, user: memberUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
expect(screen.queryByTitle('Edit dependency')).not.toBeInTheDocument();
});
@@ -691,9 +827,11 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: false, user: noTeamUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
expect(screen.queryByTitle('Edit dependency')).not.toBeInTheDocument();
});
@@ -703,13 +841,14 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
- // Modal title includes the dependency name
expect(screen.getByText(/Edit — PostgreSQL/)).toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g. Critical — primary database')).toBeInTheDocument();
});
@@ -730,17 +869,17 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
- // Impact should be pre-populated
const impactInput = screen.getByPlaceholderText('e.g. Critical — primary database') as HTMLInputElement;
expect(impactInput.value).toBe('Critical override');
- // Contact entries should be pre-populated
const keyInputs = screen.getAllByPlaceholderText('Key (e.g. email)') as HTMLInputElement[];
expect(keyInputs.length).toBe(2);
expect(keyInputs[0].value).toBe('email');
@@ -757,24 +896,22 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
- // No contact entries initially
expect(screen.queryByPlaceholderText('Key (e.g. email)')).not.toBeInTheDocument();
- // Add a field
fireEvent.click(screen.getByText('+ Add Field'));
expect(screen.getByPlaceholderText('Key (e.g. email)')).toBeInTheDocument();
- // Add another field
fireEvent.click(screen.getByText('+ Add Field'));
expect(screen.getAllByPlaceholderText('Key (e.g. email)').length).toBe(2);
- // Remove the first field
const removeButtons = screen.getAllByTitle('Remove entry');
fireEvent.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('Key (e.g. email)').length).toBe(1);
@@ -786,17 +923,17 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
- // Fill in impact override
const impactInput = screen.getByPlaceholderText('e.g. Critical — primary database');
fireEvent.change(impactInput, { target: { value: 'New impact value' } });
- // Add a contact field
fireEvent.click(screen.getByText('+ Add Field'));
const keyInput = screen.getByPlaceholderText('Key (e.g. email)');
const valueInput = screen.getByPlaceholderText('Value');
@@ -806,7 +943,6 @@ describe('ServiceDetail', () => {
fireEvent.click(screen.getByText('Save Overrides'));
await waitFor(() => {
- // PUT was called with correct body
const putCall = mockFetch.mock.calls.find(
(call: [string, RequestInit]) => call[1]?.method === 'PUT' && call[0].includes('/overrides')
);
@@ -823,13 +959,14 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
- // Try to save without any overrides
fireEvent.click(screen.getByText('Save Overrides'));
await waitFor(() => {
@@ -838,15 +975,16 @@ describe('ServiceDetail', () => {
});
it('shows clear button only when existing overrides are active', async () => {
- // No active overrides
setupDefaultMocks();
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
@@ -868,9 +1006,11 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
@@ -902,9 +1042,11 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
@@ -918,9 +1060,11 @@ describe('ServiceDetail', () => {
renderServiceDetail('s1', { isAdmin: true, user: adminUser });
await waitFor(() => {
- expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
});
+ await switchTab('Dependencies');
+
const editButtons = screen.getAllByTitle('Edit dependency');
fireEvent.click(editButtons[0]);
@@ -995,4 +1139,179 @@ describe('ServiceDetail', () => {
expect(screen.queryByText(/Key:/)).not.toBeInTheDocument();
});
});
+
+ describe('URL param tab selection', () => {
+ it('respects URL param for initial tab', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail('s1', {}, 'dependencies');
+
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Dependencies/ })).toHaveAttribute('aria-selected', 'true');
+ });
+
+ expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ });
+
+ it('respects URL param for reports tab', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail('s1', {}, 'reports');
+
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Dependent Reports/ })).toHaveAttribute('aria-selected', 'true');
+ });
+
+ expect(screen.getByText('API Gateway')).toBeInTheDocument();
+ });
+
+ it('respects URL param for poll-issues tab', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail('s1', {}, 'poll-issues');
+
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Poll Issues/ })).toHaveAttribute('aria-selected', 'true');
+ });
+ });
+ });
+
+ describe('Dependency detail modal', () => {
+ /** Click the dependency name span (role="link") to open the detail modal */
+ async function openDepDetailModal(name: string) {
+ await switchTab('Dependencies');
+ const nameSpan = screen.getAllByRole('link', { hidden: true }).find(el => el.textContent === name);
+ fireEvent.click(nameSpan!);
+ await waitFor(() => {
+ expect(screen.getByText(`Dependency of Test Service`)).toBeInTheDocument();
+ });
+ }
+
+ it('opens dependency detail modal when dependency name is clicked', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await openDepDetailModal('PostgreSQL');
+
+ expect(screen.getByText('Details')).toBeInTheDocument();
+ // Latency section heading and chart present
+ const latencyHeadings = screen.getAllByText('Latency');
+ expect(latencyHeadings.length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('Contact')).toBeInTheDocument();
+ expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument();
+ });
+
+ it('shows latency value in detail modal', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await openDepDetailModal('PostgreSQL');
+
+ // 15ms appears in both the row and modal; just confirm it's present
+ expect(screen.getAllByText('15ms').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('shows contact info in detail modal', async () => {
+ const serviceWithContact = {
+ ...mockService,
+ dependencies: [
+ {
+ ...mockService.dependencies[0],
+ effective_contact: '{"email":"detail@example.com","slack":"#detail-support"}',
+ },
+ ],
+ };
+ setupDefaultMocks(serviceWithContact);
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await openDepDetailModal('PostgreSQL');
+
+ // Contact values rendered in the modal
+ expect(screen.getAllByText('detail@example.com').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('#detail-support').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('shows "No contact information" when no contact exists', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await openDepDetailModal('PostgreSQL');
+
+ expect(screen.getByText('No contact information available.')).toBeInTheDocument();
+ });
+
+ it('shows edit overrides button for admin users', async () => {
+ const adminUser = { id: 'u1', email: 'admin@test.com', name: 'Admin', role: 'admin', teams: [] };
+ setupDefaultMocks();
+
+ renderServiceDetail('s1', { isAdmin: true, user: adminUser });
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await openDepDetailModal('PostgreSQL');
+
+ expect(screen.getByText('Edit Overrides')).toBeInTheDocument();
+ });
+
+ it('hides edit overrides button for non-privileged users', async () => {
+ setupDefaultMocks();
+
+ renderServiceDetail('s1', { isAdmin: false, user: null });
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await openDepDetailModal('PostgreSQL');
+
+ expect(screen.queryByText('Edit Overrides')).not.toBeInTheDocument();
+ });
+
+ it('shows override badge in detail modal when impact override active', async () => {
+ const serviceWithOverride = {
+ ...mockService,
+ dependencies: [
+ {
+ ...mockService.dependencies[0],
+ impact_override: 'Custom modal impact',
+ effective_impact: 'Custom modal impact',
+ },
+ ],
+ };
+ setupDefaultMocks(serviceWithOverride);
+
+ renderServiceDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
+ });
+
+ await openDepDetailModal('PostgreSQL');
+
+ // The modal shows the effective impact value
+ expect(screen.getAllByText('Custom modal impact').length).toBeGreaterThanOrEqual(1);
+ });
+ });
});
diff --git a/client/src/components/pages/Services/ServiceDetail.tsx b/client/src/components/pages/Services/ServiceDetail.tsx
index d3a599d..8b37bd7 100644
--- a/client/src/components/pages/Services/ServiceDetail.tsx
+++ b/client/src/components/pages/Services/ServiceDetail.tsx
@@ -1,10 +1,20 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
+import {
+ ChevronLeft,
+ Maximize2,
+ RefreshCw,
+ Pencil,
+ Trash2,
+ FileCode2,
+ Loader2,
+} from 'lucide-react';
import { useAuth } from '../../../contexts/AuthContext';
import { useServiceDetail } from '../../../hooks/useServiceDetail';
import StatusBadge from '../../common/StatusBadge';
import Modal from '../../common/Modal';
import ConfirmDialog from '../../common/ConfirmDialog';
+import { Tabs, TabList, Tab, TabPanel } from '../../common/Tabs';
import ServiceForm from './ServiceForm';
import DependencyList from './DependencyList';
import PollIssuesSection from './PollIssuesSection';
@@ -31,10 +41,6 @@ function ServiceDetail() {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- /**
- * Check if the current user can edit overrides for this service.
- * Admin or team lead of the service's owning team.
- */
const canEditOverrides = useCallback((): boolean => {
if (isAdmin) return true;
if (!user || !service) return false;
@@ -46,18 +52,13 @@ function ServiceDetail() {
loadService();
}, [loadService]);
- /* istanbul ignore next -- @preserve
- handleEditSuccess is triggered by ServiceForm onSuccess inside a Modal. Testing this
- requires mocking HTMLDialogElement.showModal/close and form submission flows.
- Integration tests with Cypress/Playwright are more appropriate. */
+ /* istanbul ignore next -- @preserve */
const handleEditSuccess = () => {
setIsEditModalOpen(false);
loadService();
};
- /* istanbul ignore next -- @preserve
- handleDeleteConfirm is triggered by ConfirmDialog onConfirm callback.
- Integration tests are more appropriate for testing dialog flows. */
+ /* istanbul ignore next -- @preserve */
const handleDeleteConfirm = async () => {
await handleDelete();
setIsDeleteDialogOpen(false);
@@ -103,16 +104,7 @@ function ServiceDetail() {
return (
-
-
-
+
Back to Services
@@ -122,224 +114,211 @@ function ServiceDetail() {
)}
-
-
-
{service.name}
-
- {service.manifest_managed === 1 && (
- M
- )}
- {!service.is_active && Inactive }
-
-
-
-
-
-
- View in Graph
-
-
- {isPolling ? (
- <>
-
- Refreshing...
- >
- ) : (
- <>
-
-
-
-
- Refresh
- >
- )}
-
- {isAdmin && (
- <>
-
setIsEditModalOpen(true)}
- className={`${styles.actionButton} ${styles.editButton}`}
+
+
+ Overview
+
+ Dependencies ({service.dependencies.length})
+
+
+ Dependent Reports ({service.dependent_reports.length})
+
+ Poll Issues
+
+
+ {/* Overview Tab */}
+
+
+
+
{service.name}
+
+ {service.manifest_managed === 1 && (
+ M
+ )}
+ {!service.is_active && Inactive }
+
+
+
-
-
-
- Edit
-
+
+ View in Graph
+
setIsDeleteDialogOpen(true)}
- className={`${styles.actionButton} ${styles.deleteButton}`}
+ onClick={handlePoll}
+ disabled={isPolling}
+ className={`${styles.actionButton} ${styles.pollButton}`}
>
-
-
-
- Delete
+ {isPolling ? (
+ <>
+
+ Refreshing...
+ >
+ ) : (
+ <>
+
+ Refresh
+ >
+ )}
- >
- )}
-
-
+ {isAdmin && (
+ <>
+ setIsEditModalOpen(true)}
+ className={`${styles.actionButton} ${styles.editButton}`}
+ >
+
+ Edit
+
+ setIsDeleteDialogOpen(true)}
+ className={`${styles.actionButton} ${styles.deleteButton}`}
+ >
+
+ Delete
+
+ >
+ )}
+
+
-
-
-
-
Team
-
{service.team.name}
+
+
+
+ Team
+ {service.team.name}
+
+
+ {service.metrics_endpoint && (
+
+ )}
+ {service.last_poll_success === 0 && (
+
+ Poll Status
+
+ Failed: {service.last_poll_error || 'Unknown error'}
+
+
+ )}
+
+ Last Updated
+ {formatRelativeTime(service.updated_at)}
+
+ {service.manifest_managed === 1 && (
+
+ Manifest
+
+
+ Managed by manifest{service.manifest_key ? ` · Key: ${service.manifest_key}` : ''}
+
+
+ )}
+
-
-
Health Endpoint
-
-
- {service.health_endpoint}
-
+
+
+ {/* Dependencies Tab */}
+
+
+
+
+ {/* Dependent Reports Tab */}
+
+
+
Dependent Reports
+
+ {service.health.healthy_reports}/{service.health.total_reports} healthy reports from {service.health.dependent_count} service{service.health.dependent_count !== 1 ? 's' : ''}
- {service.metrics_endpoint && (
-
-
Metrics Endpoint
-
-
- {service.metrics_endpoint}
-
-
+
+ {service.dependent_reports.length === 0 ? (
+
+
No services report depending on this service.
- )}
- {service.last_poll_success === 0 && (
-
-
Poll Status
-
- Failed: {service.last_poll_error || 'Unknown error'}
-
+ ) : (
+
+
+
+
+ Reporting Service
+ Dependency Name
+ Status
+ Latency
+ Last Checked
+
+
+
+ {service.dependent_reports.map((report) => (
+
+
+
+ {report.reporting_service_name}
+
+
+ {report.dependency_name}
+
+
+
+
+ {report.latency_ms !== null ? `${Math.round(report.latency_ms)}ms` : '-'}
+
+
+ {formatRelativeTime(report.last_checked)}
+
+
+ ))}
+
+
)}
-
- Last Updated
- {formatRelativeTime(service.updated_at)}
-
- {service.manifest_managed === 1 && (
-
-
Manifest
-
-
-
-
- Managed by manifest{service.manifest_key ? ` · Key: ${service.manifest_key}` : ''}
-
+
+
+ {/* Poll Issues Tab */}
+
+ {service.is_active && !service.is_external ? (
+
+ ) : (
+
+
+ {service.is_external
+ ? 'Not applicable for external services.'
+ : 'Not applicable for inactive services.'}
+
)}
-
-
-
-
-
Dependent Reports
-
- {service.health.healthy_reports}/{service.health.total_reports} healthy reports from {service.health.dependent_count} service{service.health.dependent_count !== 1 ? 's' : ''}
-
-
-
- {service.dependent_reports.length === 0 ? (
-
-
No services report depending on this service.
-
- ) : (
-
-
-
-
- Reporting Service
- Dependency Name
- Status
- Latency
- Last Checked
-
-
-
- {service.dependent_reports.map((report) => (
-
-
-
- {report.reporting_service_name}
-
-
- {report.dependency_name}
-
-
-
-
- {report.latency_ms !== null ? `${Math.round(report.latency_ms)}ms` : '-'}
-
-
- {formatRelativeTime(report.last_checked)}
-
-
- ))}
-
-
-
- )}
-
-
-
- {service.is_active && !service.is_external && (
-
- )}
+
+
setIsEditModalOpen(false)}
title="Edit Service"
- size="medium"
+ size="md"
>
-
);
}
diff --git a/client/src/components/pages/Services/ServiceForm.module.css b/client/src/components/pages/Services/ServiceForm.module.css
index 9803e36..f890771 100644
--- a/client/src/components/pages/Services/ServiceForm.module.css
+++ b/client/src/components/pages/Services/ServiceForm.module.css
@@ -10,7 +10,7 @@
gap: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
- color: var(--color-warning-text, #92400e);
+ color: var(--color-warning);
background-color: var(--color-warning-bg, rgba(245, 158, 11, 0.08));
border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
border-radius: 0.375rem;
@@ -20,7 +20,7 @@
.warningIcon {
flex-shrink: 0;
margin-top: 0.125rem;
- color: var(--color-warning-text, #92400e);
+ color: var(--color-warning);
}
.error {
@@ -56,7 +56,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s, box-shadow 0.15s;
+ transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
}
.input:focus,
@@ -144,7 +144,7 @@
border: 1px solid var(--color-border-input);
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.cancelButton:hover:not(:disabled) {
@@ -165,7 +165,7 @@
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
}
.submitButton:hover:not(:disabled) {
diff --git a/client/src/components/pages/Services/Services.module.css b/client/src/components/pages/Services/Services.module.css
index c1df102..06e3e22 100644
--- a/client/src/components/pages/Services/Services.module.css
+++ b/client/src/components/pages/Services/Services.module.css
@@ -1,7 +1,7 @@
/* Container & Layout */
.container {
height: 100%;
- padding: 2rem;
+ padding: var(--space-5);
display: flex;
flex-direction: column;
overflow: auto;
@@ -11,39 +11,33 @@
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 1.5rem;
+ margin-bottom: var(--space-5);
}
.titleRow {
display: flex;
align-items: center;
- gap: 0.75rem;
+ gap: var(--space-3);
}
.title {
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-2xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
margin: 0;
+ line-height: var(--line-height-tight);
}
.headerActions {
display: flex;
align-items: center;
- gap: 0.75rem;
+ gap: var(--space-3);
}
.refreshingIndicator {
display: flex;
align-items: center;
-}
-
-.spinnerSmall {
- width: 1rem;
- height: 1rem;
- border: 2px solid var(--color-border);
- border-top-color: var(--color-accent);
- border-radius: 50%;
+ color: var(--color-accent);
animation: spin 1s linear infinite;
}
@@ -51,16 +45,16 @@
.autoRefreshControls {
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.375rem 0.75rem;
- background-color: var(--color-bg-hover);
+ gap: var(--space-2);
+ padding: var(--space-1) var(--space-3);
+ background-color: var(--color-surface-hover);
border: 1px solid var(--color-border);
- border-radius: 0.5rem;
+ border-radius: var(--radius-md);
}
.autoRefreshLabel {
- font-size: 0.8125rem;
- font-weight: 500;
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
color: var(--color-text-muted);
white-space: nowrap;
}
@@ -71,21 +65,16 @@
width: 2.5rem;
height: 1.5rem;
padding: 0;
- background-color: var(--color-border-input);
+ background-color: var(--color-border);
border: none;
border-radius: 9999px;
cursor: pointer;
- transition: background-color 0.2s ease;
+ transition: background-color var(--duration-fast) ease;
flex-shrink: 0;
}
-.togglePill:focus {
- outline: 2px solid var(--color-accent);
- outline-offset: 2px;
-}
-
.togglePill.toggleActive {
- background-color: var(--color-success);
+ background-color: var(--color-healthy);
}
.toggleKnob {
@@ -94,10 +83,10 @@
left: 2px;
width: 1.25rem;
height: 1.25rem;
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border-radius: 50%;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
- transition: transform 0.2s ease;
+ box-shadow: var(--shadow-sm);
+ transition: transform var(--duration-fast) ease;
}
.togglePill.toggleActive .toggleKnob {
@@ -106,25 +95,26 @@
/* Interval dropdown */
.intervalSelect {
- padding: 0.25rem 1.5rem 0.25rem 0.5rem;
- font-size: 0.8125rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background-color: var(--color-bg-input);
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ padding: var(--space-1) var(--space-5) var(--space-1) var(--space-2);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: var(--color-text);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
- background-position: right 0.25rem center;
+ background-position: right var(--space-1) center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
- transition: border-color 0.15s, opacity 0.15s;
+ transition: border-color var(--duration-fast) ease, opacity var(--duration-fast) ease;
}
-.intervalSelect:focus {
+.intervalSelect:focus-visible {
outline: none;
border-color: var(--color-accent);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.intervalSelect:disabled {
@@ -136,48 +126,44 @@
.addButton {
display: inline-flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-inverse);
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: #fff;
background-color: var(--color-accent);
border: none;
- border-radius: 0.375rem;
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast) ease;
}
.addButton:hover {
background-color: var(--color-accent-hover);
}
-.addButton:focus {
- outline: 2px solid var(--color-accent);
- outline-offset: 2px;
-}
-
.retryButton {
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: var(--color-text-secondary);
+ background-color: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.retryButton:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
+ color: var(--color-text);
}
/* Filters */
.filters {
display: flex;
- gap: 1rem;
- margin-bottom: 1.5rem;
+ gap: var(--space-3);
+ margin-bottom: var(--space-5);
}
.searchWrapper {
@@ -188,7 +174,7 @@
.searchIcon {
position: absolute;
- left: 0.75rem;
+ left: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
@@ -197,47 +183,48 @@
.searchInput {
width: 100%;
- padding: 0.5rem 0.75rem 0.5rem 2.5rem;
- font-size: 0.875rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
- transition: border-color 0.15s, box-shadow 0.15s;
+ padding: var(--space-2) var(--space-3) var(--space-2) 2.5rem;
+ font-size: var(--font-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background-color: var(--color-surface);
+ color: var(--color-text);
+ transition: border-color var(--duration-fast) ease;
}
-.searchInput:focus {
+.searchInput:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.teamSelect {
- padding: 0.5rem 2rem 0.5rem 0.75rem;
- font-size: 0.875rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
+ padding: var(--space-2) 2rem var(--space-2) var(--space-3);
+ font-size: var(--font-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background-color: var(--color-surface);
+ color: var(--color-text);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
- background-position: right 0.5rem center;
+ background-position: right var(--space-2) center;
background-repeat: no-repeat;
background-size: 1.5rem 1.5rem;
+ transition: border-color var(--duration-fast) ease;
}
-.teamSelect:focus {
+.teamSelect:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
/* Table */
.tableWrapper {
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 0.5rem;
+ border-radius: var(--radius-md);
overflow: hidden;
}
@@ -248,38 +235,38 @@
.table th {
text-align: left;
- padding: 0.75rem 1rem;
- font-size: 0.75rem;
- font-weight: 600;
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: uppercase;
- letter-spacing: 0.05em;
+ letter-spacing: 0.04em;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
border-bottom: 1px solid var(--color-border);
}
.sortableHeader {
cursor: pointer;
user-select: none;
- transition: color 0.15s;
+ transition: color var(--duration-fast) ease;
}
.sortableHeader:hover {
- color: var(--color-text-primary);
+ color: var(--color-text);
}
.sortIndicator {
- margin-left: 0.375rem;
+ margin-left: var(--space-1);
font-size: 0.625rem;
display: inline-block;
width: 0.75rem;
}
.table td {
- padding: 0.75rem 1rem;
- font-size: 0.875rem;
- color: var(--color-text-primary);
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--font-base);
+ color: var(--color-text);
+ border-bottom: 1px solid var(--color-border-subtle);
vertical-align: middle;
}
@@ -287,17 +274,23 @@
border-bottom: none;
}
+.table tbody tr {
+ transition: background-color var(--duration-fast) ease;
+}
+
.table tbody tr:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.serviceLink {
color: var(--color-accent);
text-decoration: none;
- font-weight: 500;
+ font-weight: var(--font-medium);
+ transition: color var(--duration-fast) ease;
}
.serviceLink:hover {
+ color: var(--color-accent-hover);
text-decoration: underline;
}
@@ -308,22 +301,22 @@
.depsCell {
display: inline-flex;
align-items: center;
- gap: 0.375rem;
+ gap: var(--space-1);
}
.depsCount {
- font-weight: 500;
+ font-weight: var(--font-medium);
font-variant-numeric: tabular-nums;
}
.depsLabel {
color: var(--color-text-muted);
- font-size: 0.75rem;
+ font-size: var(--font-xs);
}
.timeCell {
color: var(--color-text-muted);
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
}
/* States */
@@ -332,17 +325,13 @@
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 1rem;
- padding: 4rem 2rem;
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-5);
color: var(--color-text-muted);
}
.spinner {
- width: 2rem;
- height: 2rem;
- border: 3px solid var(--color-border);
- border-top-color: var(--color-accent);
- border-radius: 50%;
+ color: var(--color-accent);
animation: spin 1s linear infinite;
}
@@ -356,9 +345,9 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 1rem;
- padding: 4rem 2rem;
- color: var(--color-error);
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-5);
+ color: var(--color-critical);
text-align: center;
}
@@ -366,13 +355,13 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 1rem;
- padding: 4rem 2rem;
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-5);
color: var(--color-text-muted);
text-align: center;
- background-color: var(--color-bg-hover);
- border: 1px dashed var(--color-border-input);
- border-radius: 0.5rem;
+ background-color: var(--color-surface-hover);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-md);
}
/* Service Detail Page */
@@ -384,7 +373,7 @@
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 1rem;
- transition: color 0.15s;
+ transition: color var(--duration-fast);
}
.backLink:hover {
@@ -425,7 +414,7 @@
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
}
.graphLink {
@@ -727,7 +716,7 @@
color: var(--color-text-muted);
cursor: pointer;
border-radius: 6px;
- transition: all 0.15s;
+ transition: all var(--duration-fast);
}
.historyButton:hover {
@@ -757,7 +746,7 @@
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast);
text-align: left;
}
@@ -776,7 +765,7 @@
}
.chevron {
- transition: transform 0.2s ease;
+ transition: transform var(--duration-normal) ease;
flex-shrink: 0;
color: var(--color-text-muted);
}
@@ -846,7 +835,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s;
+ transition: border-color var(--duration-fast);
}
.overrideInput:focus {
@@ -869,7 +858,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s;
+ transition: border-color var(--duration-fast);
}
.contactKeyInput:focus {
@@ -886,7 +875,7 @@
border-radius: 0.375rem;
background-color: var(--color-bg-input);
color: var(--color-text-primary);
- transition: border-color 0.15s;
+ transition: border-color var(--duration-fast);
}
.contactValueInput:focus {
@@ -908,7 +897,7 @@
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
- transition: all 0.15s;
+ transition: all var(--duration-fast);
}
.contactRemoveButton:hover {
@@ -929,7 +918,7 @@
border: 1px dashed var(--color-border-input);
border-radius: 0.375rem;
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
align-self: flex-start;
}
diff --git a/client/src/components/pages/Services/ServicesList.tsx b/client/src/components/pages/Services/ServicesList.tsx
index e279806..d2afc35 100644
--- a/client/src/components/pages/Services/ServicesList.tsx
+++ b/client/src/components/pages/Services/ServicesList.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
+import { Search, Plus, Loader2 } from 'lucide-react';
import { useAuth } from '../../../contexts/AuthContext';
import { useServicesList, type SortColumn } from '../../../hooks/useServicesList';
import StatusBadge from '../../common/StatusBadge';
@@ -77,7 +78,7 @@ function ServicesList() {
return (
@@ -104,7 +105,7 @@ function ServicesList() {
Services
{isRefreshing && (
)}
@@ -138,16 +139,7 @@ function ServicesList() {
onClick={() => setIsAddModalOpen(true)}
className={styles.addButton}
>
-
-
-
+
Add Service
)}
@@ -156,18 +148,7 @@ function ServicesList() {
-
-
-
-
+
setIsAddModalOpen(false)}
title="Add Service"
- size="medium"
+ size="md"
>
({
useNavigate: () => mockNavigate,
}));
-// Mock AlertChannels, AlertRules, and AlertHistory to isolate TeamDetail tests
+// Mock AlertChannels, AlertRules, AlertHistory, AlertMutes, ManifestStatusCard
jest.mock('./AlertChannels', () => {
const AlertChannels = () =>
;
AlertChannels.displayName = 'AlertChannels';
@@ -80,10 +80,11 @@ const mockTeam = {
updated_at: '2024-01-01',
};
-function renderTeamDetail(id = 't1', isAdmin = false) {
+function renderTeamDetail(id = 't1', isAdmin = false, initialTab?: string) {
mockUseAuth.mockReturnValue({ isAdmin });
+ const path = initialTab ? `/teams/${id}?tab=${initialTab}` : `/teams/${id}`;
return render(
-
+
} />
Teams List } />
@@ -96,6 +97,7 @@ beforeEach(() => {
mockFetch.mockReset();
mockUseAuth.mockReset();
mockNavigate.mockReset();
+ localStorage.clear();
});
describe('TeamDetail', () => {
@@ -119,8 +121,6 @@ describe('TeamDetail', () => {
});
expect(screen.getByText('Test description')).toBeInTheDocument();
- expect(screen.getByText('User One')).toBeInTheDocument();
- expect(screen.getByText('User Two')).toBeInTheDocument();
});
it('displays error state and allows retry', async () => {
@@ -154,65 +154,6 @@ describe('TeamDetail', () => {
expect(screen.getByText('Back to Teams')).toBeInTheDocument();
});
- it('displays members with roles', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse(mockUsers));
-
- renderTeamDetail();
-
- await waitFor(() => {
- expect(screen.getByText('Members')).toBeInTheDocument();
- });
-
- expect(screen.getByText('User One')).toBeInTheDocument();
- expect(screen.getByText('user1@example.com')).toBeInTheDocument();
- expect(screen.getByText('Lead')).toBeInTheDocument();
- expect(screen.getByText('Member')).toBeInTheDocument();
- });
-
- it('displays services list', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse(mockUsers));
-
- renderTeamDetail();
-
- await waitFor(() => {
- expect(screen.getByText('Services')).toBeInTheDocument();
- });
-
- expect(screen.getByText('Service A')).toBeInTheDocument();
- expect(screen.getByText('Service B')).toBeInTheDocument();
- expect(screen.getByText('Inactive')).toBeInTheDocument();
- });
-
- it('shows empty state for no members', async () => {
- const teamNoMembers = { ...mockTeam, members: [] };
- mockFetch
- .mockResolvedValueOnce(jsonResponse(teamNoMembers))
- .mockResolvedValueOnce(jsonResponse(mockUsers));
-
- renderTeamDetail();
-
- await waitFor(() => {
- expect(screen.getByText('No members in this team yet.')).toBeInTheDocument();
- });
- });
-
- it('shows empty state for no services', async () => {
- const teamNoServices = { ...mockTeam, services: [] };
- mockFetch
- .mockResolvedValueOnce(jsonResponse(teamNoServices))
- .mockResolvedValueOnce(jsonResponse(mockUsers));
-
- renderTeamDetail();
-
- await waitFor(() => {
- expect(screen.getByText('No services assigned to this team yet.')).toBeInTheDocument();
- });
- });
-
it('shows admin actions for admin users', async () => {
mockFetch
.mockResolvedValueOnce(jsonResponse(mockTeam))
@@ -274,382 +215,454 @@ describe('TeamDetail', () => {
expect(screen.getByText(/Are you sure you want to delete/)).toBeInTheDocument();
});
- it('displays add member form for admin with available users', async () => {
- const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
+ it('displays back link to teams list', async () => {
mockFetch
.mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse(availableUsers));
+ .mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail('t1', true);
+ renderTeamDetail();
await waitFor(() => {
- expect(screen.getByText('User')).toBeInTheDocument();
+ expect(screen.getByText('Back to Teams')).toBeInTheDocument();
});
-
- expect(screen.getByText('Add Member')).toBeInTheDocument();
- expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
});
- it('hides add member form when no available users', async () => {
+ it('shows team without description', async () => {
+ const teamNoDesc = { ...mockTeam, description: null };
mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(teamNoDesc))
.mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail('t1', true);
+ renderTeamDetail();
await waitFor(() => {
expect(screen.getByText('Test Team')).toBeInTheDocument();
});
- expect(screen.queryByText('Add Member')).not.toBeInTheDocument();
+ expect(screen.queryByText('Test description')).not.toBeInTheDocument();
});
- it('adds member to team', async () => {
- const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse(availableUsers))
- .mockResolvedValueOnce(jsonResponse([])) // alert channels
- .mockResolvedValueOnce(jsonResponse({ success: true }))
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ describe('tabs', () => {
+ it('renders all tab buttons', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail('t1', true);
+ renderTeamDetail();
- await waitFor(() => {
- expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
+ });
- // Select user
- fireEvent.change(screen.getByDisplayValue('Select a user...'), {
- target: { value: 'u3' },
+ expect(screen.getByRole('tab', { name: /Members/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Manifests/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Services/ })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /Alerts Config/ })).toBeInTheDocument();
});
- fireEvent.click(screen.getByText('Add Member'));
+ it('defaults to overview tab', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail();
- await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- '/api/teams/t1/members',
- expect.objectContaining({
- method: 'POST',
- body: JSON.stringify({ user_id: 'u3', role: 'member' }),
- })
- );
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Overview/ })).toHaveAttribute('aria-selected', 'true');
+ });
});
- });
- it('adds member as lead', async () => {
- const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse(availableUsers))
- .mockResolvedValueOnce(jsonResponse([])) // alert channels
- .mockResolvedValueOnce(jsonResponse({ success: true }))
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ it('shows overview content by default and hides other tab content', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail('t1', true);
+ renderTeamDetail();
- await waitFor(() => {
- expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByText('Test Team')).toBeInTheDocument();
+ });
- // Select user
- fireEvent.change(screen.getByDisplayValue('Select a user...'), {
- target: { value: 'u3' },
+ // Members content should not be visible
+ expect(screen.queryByText('user1@example.com')).not.toBeInTheDocument();
});
- // Select lead role
- fireEvent.change(screen.getByDisplayValue('Member'), {
- target: { value: 'lead' },
+ it('switches tab content when clicking a tab', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Team')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('tab', { name: /Members/ }));
+
+ expect(screen.getByText('User One')).toBeInTheDocument();
+ expect(screen.getByText('user1@example.com')).toBeInTheDocument();
});
- fireEvent.click(screen.getByText('Add Member'));
+ it('respects URL param for initial tab', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- '/api/teams/t1/members',
- expect.objectContaining({
- body: JSON.stringify({ user_id: 'u3', role: 'lead' }),
- })
- );
+ renderTeamDetail('t1', false, 'services');
+
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Services/ })).toHaveAttribute('aria-selected', 'true');
+ });
+
+ expect(screen.getByText('Service A')).toBeInTheDocument();
});
- });
- it('promotes member to lead', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]))
- .mockResolvedValueOnce(jsonResponse([])) // alert channels
- .mockResolvedValueOnce(jsonResponse({ success: true }))
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ it('shows member count in tab label', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail('t1', true);
+ renderTeamDetail();
- await waitFor(() => {
- expect(screen.getByText('User Two')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Members \(2\)/ })).toBeInTheDocument();
+ });
});
- const promoteButton = screen.getByText('Promote');
- fireEvent.click(promoteButton);
+ it('shows service count in tab label', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- '/api/teams/t1/members/u2',
- expect.objectContaining({
- method: 'PUT',
- body: JSON.stringify({ role: 'lead' }),
- })
- );
+ renderTeamDetail();
+
+ await waitFor(() => {
+ expect(screen.getByRole('tab', { name: /Services \(2\)/ })).toBeInTheDocument();
+ });
});
});
- it('demotes lead to member', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]))
- .mockResolvedValueOnce(jsonResponse([])) // alert channels
- .mockResolvedValueOnce(jsonResponse({ success: true }))
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ describe('members tab', () => {
+ it('displays members with roles', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(mockUsers));
- renderTeamDetail('t1', true);
+ renderTeamDetail('t1', false, 'members');
- await waitFor(() => {
- expect(screen.getByText('User One')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('User One')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('user1@example.com')).toBeInTheDocument();
+ expect(screen.getByText('Lead')).toBeInTheDocument();
+ expect(screen.getByText('Member')).toBeInTheDocument();
});
- const demoteButton = screen.getByText('Demote');
- fireEvent.click(demoteButton);
+ it('shows empty state for no members', async () => {
+ const teamNoMembers = { ...mockTeam, members: [] };
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(teamNoMembers))
+ .mockResolvedValueOnce(jsonResponse(mockUsers));
- await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- '/api/teams/t1/members/u1',
- expect.objectContaining({
- method: 'PUT',
- body: JSON.stringify({ role: 'member' }),
- })
- );
+ renderTeamDetail('t1', false, 'members');
+
+ await waitFor(() => {
+ expect(screen.getByText('No members in this team yet.')).toBeInTheDocument();
+ });
});
- });
- it('removes member from team', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]))
- .mockResolvedValueOnce(jsonResponse([])) // alert channels
- .mockResolvedValueOnce(jsonResponse({ success: true }))
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ it('displays add member form for admin with available users', async () => {
+ const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(availableUsers));
- renderTeamDetail('t1', true);
+ renderTeamDetail('t1', true, 'members');
- await waitFor(() => {
- expect(screen.getByText('User Two')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('User')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Add Member')).toBeInTheDocument();
+ expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
});
- const removeButtons = screen.getAllByText('Remove');
- fireEvent.click(removeButtons[0]);
+ it('hides add member form when no available users', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail('t1', true, 'members');
- await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- '/api/teams/t1/members/u1',
- expect.objectContaining({ method: 'DELETE' })
- );
+ await waitFor(() => {
+ expect(screen.getByText('User One')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('Add Member')).not.toBeInTheDocument();
});
- });
- it('displays member count correctly', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ it('adds member to team', async () => {
+ const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(availableUsers))
+ .mockResolvedValueOnce(jsonResponse([])) // alert channels
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail();
+ renderTeamDetail('t1', true, 'members');
- await waitFor(() => {
- expect(screen.getByText('2 members')).toBeInTheDocument();
- });
- });
+ await waitFor(() => {
+ expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
+ });
- it('displays singular member count', async () => {
- const teamOneMember = { ...mockTeam, members: [mockTeam.members[0]] };
- mockFetch
- .mockResolvedValueOnce(jsonResponse(teamOneMember))
- .mockResolvedValueOnce(jsonResponse([]));
+ fireEvent.change(screen.getByDisplayValue('Select a user...'), {
+ target: { value: 'u3' },
+ });
- renderTeamDetail();
+ fireEvent.click(screen.getByText('Add Member'));
- await waitFor(() => {
- expect(screen.getByText('1 member')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/teams/t1/members',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ user_id: 'u3', role: 'member' }),
+ })
+ );
+ });
});
- });
- it('displays service count correctly', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ it('adds member as lead', async () => {
+ const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(availableUsers))
+ .mockResolvedValueOnce(jsonResponse([])) // alert channels
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail();
+ renderTeamDetail('t1', true, 'members');
- await waitFor(() => {
- expect(screen.getByText('2 services')).toBeInTheDocument();
- });
- });
+ await waitFor(() => {
+ expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
+ });
- it('displays singular service count', async () => {
- const teamOneService = { ...mockTeam, services: [mockTeam.services[0]] };
- mockFetch
- .mockResolvedValueOnce(jsonResponse(teamOneService))
- .mockResolvedValueOnce(jsonResponse([]));
+ fireEvent.change(screen.getByDisplayValue('Select a user...'), {
+ target: { value: 'u3' },
+ });
- renderTeamDetail();
+ fireEvent.change(screen.getByDisplayValue('Member'), {
+ target: { value: 'lead' },
+ });
- await waitFor(() => {
- expect(screen.getByText('1 service')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('Add Member'));
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/teams/t1/members',
+ expect.objectContaining({
+ body: JSON.stringify({ user_id: 'u3', role: 'lead' }),
+ })
+ );
+ });
});
- });
- it('hides member actions for non-admin users', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ it('promotes member to lead', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse([])) // alert channels
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail('t1', false);
+ renderTeamDetail('t1', true, 'members');
- await waitFor(() => {
- expect(screen.getByText('User One')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('User Two')).toBeInTheDocument();
+ });
+
+ const promoteButton = screen.getByText('Promote');
+ fireEvent.click(promoteButton);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/teams/t1/members/u2',
+ expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({ role: 'lead' }),
+ })
+ );
+ });
});
- expect(screen.queryByText('Promote')).not.toBeInTheDocument();
- expect(screen.queryByText('Demote')).not.toBeInTheDocument();
- expect(screen.queryByText('Remove')).not.toBeInTheDocument();
- });
+ it('demotes lead to member', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse([])) // alert channels
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- it('shows team without description', async () => {
- const teamNoDesc = { ...mockTeam, description: null };
- mockFetch
- .mockResolvedValueOnce(jsonResponse(teamNoDesc))
- .mockResolvedValueOnce(jsonResponse([]));
+ renderTeamDetail('t1', true, 'members');
- renderTeamDetail();
+ await waitFor(() => {
+ expect(screen.getByText('User One')).toBeInTheDocument();
+ });
- await waitFor(() => {
- expect(screen.getByText('Test Team')).toBeInTheDocument();
+ const demoteButton = screen.getByText('Demote');
+ fireEvent.click(demoteButton);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/teams/t1/members/u1',
+ expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({ role: 'member' }),
+ })
+ );
+ });
});
- expect(screen.queryByText('Test description')).not.toBeInTheDocument();
- });
+ it('removes member from team', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse([])) // alert channels
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
- it('displays add member error', async () => {
- const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse(availableUsers))
- .mockResolvedValueOnce(jsonResponse([])) // alert channels
- .mockRejectedValueOnce(new Error('Failed to add member'));
+ renderTeamDetail('t1', true, 'members');
- renderTeamDetail('t1', true);
+ await waitFor(() => {
+ expect(screen.getByText('User Two')).toBeInTheDocument();
+ });
- await waitFor(() => {
- expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
- });
+ const removeButtons = screen.getAllByText('Remove');
+ fireEvent.click(removeButtons[0]);
- fireEvent.change(screen.getByDisplayValue('Select a user...'), {
- target: { value: 'u3' },
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/teams/t1/members/u1',
+ expect.objectContaining({ method: 'DELETE' })
+ );
+ });
});
- fireEvent.click(screen.getByText('Add Member'));
+ it('hides member actions for non-admin users', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail('t1', false, 'members');
- await waitFor(() => {
- expect(screen.getByText('Failed to add member')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('User One')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('Promote')).not.toBeInTheDocument();
+ expect(screen.queryByText('Demote')).not.toBeInTheDocument();
+ expect(screen.queryByText('Remove')).not.toBeInTheDocument();
});
- });
- it('displays back link to teams list', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]));
+ it('displays add member error', async () => {
+ const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(availableUsers))
+ .mockResolvedValueOnce(jsonResponse([])) // alert channels
+ .mockRejectedValueOnce(new Error('Failed to add member'));
- renderTeamDetail();
+ renderTeamDetail('t1', true, 'members');
- await waitFor(() => {
- expect(screen.getByText('Back to Teams')).toBeInTheDocument();
- });
- });
+ await waitFor(() => {
+ expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument();
+ });
- it('disables add member button when no user selected', async () => {
- const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse(availableUsers));
+ fireEvent.change(screen.getByDisplayValue('Select a user...'), {
+ target: { value: 'u3' },
+ });
- renderTeamDetail('t1', true);
+ fireEvent.click(screen.getByText('Add Member'));
- await waitFor(() => {
- expect(screen.getByText('Add Member')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('Failed to add member')).toBeInTheDocument();
+ });
});
- expect(screen.getByText('Add Member')).toBeDisabled();
- });
+ it('disables add member button when no user selected', async () => {
+ const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }];
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(availableUsers));
- it('shows error inline after team is loaded', async () => {
- mockFetch
- .mockResolvedValueOnce(jsonResponse(mockTeam))
- .mockResolvedValueOnce(jsonResponse([]))
- .mockResolvedValueOnce(jsonResponse([])) // alert channels
- .mockRejectedValueOnce(new Error('Action failed'));
+ renderTeamDetail('t1', true, 'members');
- renderTeamDetail('t1', true);
+ await waitFor(() => {
+ expect(screen.getByText('Add Member')).toBeInTheDocument();
+ });
- await waitFor(() => {
- expect(screen.getByText('User One')).toBeInTheDocument();
+ expect(screen.getByText('Add Member')).toBeDisabled();
});
- const removeButtons = screen.getAllByText('Remove');
- fireEvent.click(removeButtons[0]);
+ it('shows error inline after team is loaded', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]))
+ .mockResolvedValueOnce(jsonResponse([])) // alert channels
+ .mockRejectedValueOnce(new Error('Action failed'));
+
+ renderTeamDetail('t1', true, 'members');
- await waitFor(() => {
- expect(screen.getByText('Action failed')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('User One')).toBeInTheDocument();
+ });
+
+ const removeButtons = screen.getAllByText('Remove');
+ fireEvent.click(removeButtons[0]);
+
+ await waitFor(() => {
+ expect(screen.getByText('Action failed')).toBeInTheDocument();
+ });
});
});
- describe('team key badge', () => {
- it('displays key badge when team has a key', async () => {
- const teamWithKey = { ...mockTeam, key: 'platform-team' };
+ describe('services tab', () => {
+ it('displays services list', async () => {
mockFetch
- .mockResolvedValueOnce(jsonResponse(teamWithKey))
- .mockResolvedValueOnce(jsonResponse([]));
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse(mockUsers));
- renderTeamDetail();
+ renderTeamDetail('t1', false, 'services');
await waitFor(() => {
- expect(screen.getByText('platform-team')).toBeInTheDocument();
+ expect(screen.getByText('Service A')).toBeInTheDocument();
});
- expect(screen.getByText('platform-team').tagName).toBe('CODE');
+ expect(screen.getByText('Service B')).toBeInTheDocument();
+ expect(screen.getByText('Inactive')).toBeInTheDocument();
});
- it('does not render key badge when team key is null', async () => {
- const teamNoKey = { ...mockTeam, key: null };
+ it('shows empty state for no services', async () => {
+ const teamNoServices = { ...mockTeam, services: [] };
mockFetch
- .mockResolvedValueOnce(jsonResponse(teamNoKey))
- .mockResolvedValueOnce(jsonResponse([]));
+ .mockResolvedValueOnce(jsonResponse(teamNoServices))
+ .mockResolvedValueOnce(jsonResponse(mockUsers));
- renderTeamDetail();
+ renderTeamDetail('t1', false, 'services');
await waitFor(() => {
- expect(screen.getByText('Test Team')).toBeInTheDocument();
- });
-
- const codeElements = document.querySelectorAll('code');
- codeElements.forEach((el) => {
- expect(el.textContent).not.toBe('');
+ expect(screen.getByText('No services assigned to this team yet.')).toBeInTheDocument();
});
});
- });
- describe('manifest badges', () => {
it('shows [M] badge for manifest-managed services', async () => {
const teamWithManifest = {
...mockTeam,
@@ -663,7 +676,7 @@ describe('TeamDetail', () => {
.mockResolvedValueOnce(jsonResponse(teamWithManifest))
.mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail();
+ renderTeamDetail('t1', false, 'services');
await waitFor(() => {
expect(screen.getByText('Manifest Service')).toBeInTheDocument();
@@ -679,7 +692,7 @@ describe('TeamDetail', () => {
.mockResolvedValueOnce(jsonResponse(mockTeam))
.mockResolvedValueOnce(jsonResponse([]));
- renderTeamDetail();
+ renderTeamDetail('t1', false, 'services');
await waitFor(() => {
expect(screen.getByText('Service A')).toBeInTheDocument();
@@ -688,4 +701,71 @@ describe('TeamDetail', () => {
expect(screen.queryByTitle('Managed by manifest')).not.toBeInTheDocument();
});
});
+
+ describe('manifests tab', () => {
+ it('renders ManifestStatusCard', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail('t1', false, 'manifests');
+
+ await waitFor(() => {
+ expect(screen.getByTestId('manifest-status-card')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('alerts tab', () => {
+ it('renders all alert sub-sections', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(mockTeam))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail('t1', false, 'alerts');
+
+ await waitFor(() => {
+ expect(screen.getByTestId('alert-channels')).toBeInTheDocument();
+ });
+
+ expect(screen.getByTestId('alert-rules')).toBeInTheDocument();
+ expect(screen.getByTestId('alert-mutes')).toBeInTheDocument();
+ expect(screen.getByTestId('alert-history')).toBeInTheDocument();
+ });
+ });
+
+ describe('team key badge', () => {
+ it('displays key badge when team has a key', async () => {
+ const teamWithKey = { ...mockTeam, key: 'platform-team' };
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(teamWithKey))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('platform-team')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('platform-team').tagName).toBe('CODE');
+ });
+
+ it('does not render key badge when team key is null', async () => {
+ const teamNoKey = { ...mockTeam, key: null };
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(teamNoKey))
+ .mockResolvedValueOnce(jsonResponse([]));
+
+ renderTeamDetail();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Team')).toBeInTheDocument();
+ });
+
+ const codeElements = document.querySelectorAll('code');
+ codeElements.forEach((el) => {
+ expect(el.textContent).not.toBe('');
+ });
+ });
+ });
});
diff --git a/client/src/components/pages/Teams/TeamDetail.tsx b/client/src/components/pages/Teams/TeamDetail.tsx
index cecf901..1b9bd72 100644
--- a/client/src/components/pages/Teams/TeamDetail.tsx
+++ b/client/src/components/pages/Teams/TeamDetail.tsx
@@ -1,15 +1,18 @@
import { useState, useEffect, useMemo } from 'react';
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 { parseContact } from '../../../utils/dependency';
import Modal from '../../common/Modal';
import ConfirmDialog from '../../common/ConfirmDialog';
+import { Tabs, TabList, Tab, TabPanel } from '../../common/Tabs';
import TeamForm from './TeamForm';
import AlertChannels from './AlertChannels';
import AlertRules from './AlertRules';
import AlertHistory from './AlertHistory';
import AlertMutes from './AlertMutes';
+import TeamOverviewStats from './TeamOverviewStats';
import ManifestStatusCard from './ManifestStatusCard';
import { useAlertChannels } from '../../../hooks/useAlertChannels';
import styles from './Teams.module.css';
@@ -115,16 +118,7 @@ function TeamDetail() {
return (
-
-
-
+
Back to Teams
@@ -134,244 +128,234 @@ 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)}
-
- ))}
+
+
+ Overview
+
+ Members ({team.members.length})
+
+ Manifests
+
+ Services ({team.services.length})
+
+ Alerts Config
+
+
+ {/* Overview Tab */}
+
+
+
+
{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 && (
+
+
setIsEditModalOpen(true)}
+ className={styles.ghostButton}
+ >
+
+ Edit
+
+
setIsDeleteDialogOpen(true)}
+ className={styles.dangerButton}
+ >
+
+ Delete
+
- );
- })()}
-
- {isAdmin && (
-
-
setIsEditModalOpen(true)}
- className={`${styles.actionButton} ${styles.editButton}`}
- >
-
-
-
- Edit
-
-
setIsDeleteDialogOpen(true)}
- className={`${styles.actionButton} ${styles.deleteButton}`}
- >
-
-
-
- Delete
-
+ )}
- )}
-
-
- {/* Members Section */}
-
-
-
Members
-
- {team.members.length} {team.members.length === 1 ? 'member' : 'members'}
-
-
+
+
- {isAdmin && availableUsers.length > 0 && (
-
-
- User
- setSelectedUserId(e.target.value)}
- className={styles.select}
- disabled={isAddingMember}
- >
- Select a user...
- {availableUsers.map((user) => (
-
- {user.name} ({user.email})
-
- ))}
-
-
-
-
Role
-
setSelectedRole(e.target.value as 'lead' | 'member')}
- className={styles.select}
- disabled={isAddingMember}
+ {/* Members Tab */}
+
+ {isAdmin && availableUsers.length > 0 && (
+
+
+ User
+ setSelectedUserId(e.target.value)}
+ className={styles.select}
+ disabled={isAddingMember}
+ >
+ Select a user...
+ {availableUsers.map((u) => (
+
+ {u.name} ({u.email})
+
+ ))}
+
+
+
+ Role
+ setSelectedRole(e.target.value as 'lead' | 'member')}
+ className={styles.select}
+ disabled={isAddingMember}
+ >
+ Member
+ Lead
+
+
+
- Member
- Lead
-
+ {isAddingMember ? 'Adding...' : 'Add Member'}
+
-
- {isAddingMember ? 'Adding...' : 'Add Member'}
-
-
- )}
+ )}
- {addMemberError && (
-
- {addMemberError}
-
- )}
+ {addMemberError && (
+
+ {addMemberError}
+
+ )}
- {team.members.length === 0 ? (
-
-
No members in this team yet.
-
- ) : (
-
-
-
-
- Name
- Email
- Role
- {isAdmin && Actions }
-
-
-
- {team.members.map((member) => (
-
- {member.user.name}
- {member.user.email}
-
-
- {member.role === 'lead' ? 'Lead' : 'Member'}
-
-
- {isAdmin && (
+ {team.members.length === 0 ? (
+
+
No members in this team yet.
+
+ ) : (
+
+
+
+
+ Name
+ Email
+ Role
+ {isAdmin && Actions }
+
+
+
+ {team.members.map((member) => (
+
+ {member.user.name}
+ {member.user.email}
-
- handleToggleRole(member)}
- disabled={actionInProgress === member.user_id}
- className={`${styles.smallButton} ${styles.roleButton}`}
- >
- {member.role === 'lead' ? 'Demote' : 'Promote'}
-
- handleRemoveMember(member.user_id)}
- disabled={actionInProgress === member.user_id}
- className={`${styles.smallButton} ${styles.removeButton}`}
- >
- Remove
-
-
+
+ {member.role === 'lead' ? 'Lead' : 'Member'}
+
- )}
-
- ))}
-
-
-
- )}
-
-
- {/* Manifest Sync Section */}
-
+ {isAdmin && (
+
+
+ handleToggleRole(member)}
+ disabled={actionInProgress === member.user_id}
+ className={`${styles.smallButton} ${styles.roleButton}`}
+ >
+ {member.role === 'lead' ? 'Demote' : 'Promote'}
+
+ handleRemoveMember(member.user_id)}
+ disabled={actionInProgress === member.user_id}
+ className={`${styles.smallButton} ${styles.removeButton}`}
+ >
+ Remove
+
+
+
+ )}
+
+ ))}
+
+
+
+ )}
+
- {/* Services Section */}
-
-
-
Services
-
- {team.services.length} {team.services.length === 1 ? 'service' : 'services'}
-
-
+ {/* Manifests Tab */}
+
+
+
- {team.services.length === 0 ? (
-
-
No services assigned to this team yet.
-
- ) : (
-
- {team.services.map((service) => (
-
-
-
- {service.name}
-
- {service.manifest_managed === 1 && (
-
M
- )}
- {!service.is_active && (
-
- Inactive
-
- )}
+ {/* Services Tab */}
+
+ {team.services.length === 0 ? (
+
+
No services assigned to this team yet.
+
+ ) : (
+
+ {team.services.map((service) => (
+
+
+
+ {service.name}
+
+ {service.manifest_managed === 1 && (
+ M
+ )}
+ {!service.is_active && (
+
+ Inactive
+
+ )}
+
-
- ))}
-
- )}
-
-
- {/* Alert Channels & Rules (side-by-side on wider screens) */}
-
-
- {/* Alert Mutes Section */}
-
+ ))}
+
+ )}
+
- {/* Alert History Section */}
-
+ {/* Alerts Config Tab */}
+
+
+
+
setIsEditModalOpen(false)}
title="Edit Team"
- size="medium"
+ size="md"
>
= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(data),
+ };
+}
+
+const mockMembers = [
+ { team_id: 't1', user_id: 'u1', role: 'lead' as const, created_at: '', user: { id: 'u1', email: 'a@test.com', name: 'Alice', role: 'admin', is_active: 1 } },
+ { team_id: 't1', user_id: 'u2', role: 'member' as const, created_at: '', user: { id: 'u2', email: 'b@test.com', name: 'Bob', role: 'user', is_active: 1 } },
+ { team_id: 't1', user_id: 'u3', role: 'member' as const, created_at: '', user: { id: 'u3', email: 'c@test.com', name: 'Carol', role: 'user', is_active: 1 } },
+];
+
+const mockTeamServices = [
+ { id: 's1', name: 'Svc A', team_id: 't1', health_endpoint: '/health', metrics_endpoint: null, is_active: 1, manifest_managed: 1, created_at: '', updated_at: '' },
+ { id: 's2', name: 'Svc B', team_id: 't1', health_endpoint: '/health', metrics_endpoint: null, is_active: 1, manifest_managed: 0, created_at: '', updated_at: '' },
+ { id: 's3', name: 'Svc C', team_id: 't1', health_endpoint: '/health', metrics_endpoint: null, is_active: 0, manifest_managed: 0, created_at: '', updated_at: '' },
+];
+
+const mockServicesWithHealth = [
+ {
+ id: 's1', name: 'Svc A', team_id: 't1',
+ health: { status: 'healthy', healthy_reports: 5, warning_reports: 0, critical_reports: 0, total_reports: 5, dependent_count: 1 },
+ dependencies: [{ id: 'd1' }, { id: 'd2' }],
+ },
+ {
+ id: 's2', name: 'Svc B', team_id: 't1',
+ health: { status: 'warning', healthy_reports: 3, warning_reports: 2, critical_reports: 0, total_reports: 5, dependent_count: 0 },
+ dependencies: [{ id: 'd3' }],
+ },
+ {
+ id: 's3', name: 'Svc C', team_id: 't1',
+ health: { status: 'critical', healthy_reports: 0, warning_reports: 0, critical_reports: 5, total_reports: 5, dependent_count: 0 },
+ dependencies: [],
+ },
+];
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe('TeamOverviewStats', () => {
+ it('renders member count and role breakdown', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth));
+
+ render(
+
+ );
+
+ expect(screen.getByText('Members')).toBeInTheDocument();
+ expect(screen.getByText('1 lead · 2 members')).toBeInTheDocument();
+ // Verify member count appears in the Members card
+ const membersLabel = screen.getByText('Members');
+ const membersCard = membersLabel.closest('div');
+ expect(membersCard?.querySelector('[class*="cardValue"]')?.textContent).toBe('3');
+ });
+
+ it('renders service count with active/inactive and manifest breakdown', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth));
+
+ render(
+
+ );
+
+ expect(screen.getByText('Services')).toBeInTheDocument();
+ expect(screen.getByText('2 active · 1 inactive · 1 manifest-managed')).toBeInTheDocument();
+ });
+
+ it('renders health stats after loading', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Service Health')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('1 healthy · 1 warning · 1 critical')).toBeInTheDocument();
+ });
+
+ it('renders dependency count after loading', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('across 3 services')).toBeInTheDocument();
+ });
+
+ it('renders health bar when services have health data', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Health Overview')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('img', { name: /team health distribution/i })).toBeInTheDocument();
+ expect(screen.getByText(/Healthy \(1\)/)).toBeInTheDocument();
+ expect(screen.getByText(/Warning \(1\)/)).toBeInTheDocument();
+ expect(screen.getByText(/Critical \(1\)/)).toBeInTheDocument();
+ });
+
+ it('shows error message on fetch failure', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to load health data')).toBeInTheDocument();
+ });
+ });
+
+ it('does not render health bar when no services', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([]));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Dependencies')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('Health Overview')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/pages/Teams/TeamOverviewStats.tsx b/client/src/components/pages/Teams/TeamOverviewStats.tsx
new file mode 100644
index 0000000..3929a37
--- /dev/null
+++ b/client/src/components/pages/Teams/TeamOverviewStats.tsx
@@ -0,0 +1,157 @@
+import { useEffect, useMemo } from 'react';
+import { useTeamServiceHealth } from '../../../hooks/useTeamServiceHealth';
+import type { TeamMember, TeamService } from '../../../types/team';
+import cardStyles from '../../common/SummaryCards.module.css';
+import styles from './Teams.module.css';
+
+interface TeamOverviewStatsProps {
+ teamId: string;
+ members: TeamMember[];
+ services: TeamService[];
+}
+
+function TeamOverviewStats({ teamId, members, services }: TeamOverviewStatsProps) {
+ const { stats, isLoading, error, reload } = useTeamServiceHealth(teamId);
+
+ useEffect(() => {
+ reload();
+ }, [reload]);
+
+ const memberBreakdown = useMemo(() => {
+ const leads = members.filter(m => m.role === 'lead').length;
+ const regular = members.length - leads;
+ const parts: string[] = [];
+ if (leads > 0) parts.push(`${leads} lead${leads !== 1 ? 's' : ''}`);
+ if (regular > 0) parts.push(`${regular} member${regular !== 1 ? 's' : ''}`);
+ return parts.join(' · ') || 'no members';
+ }, [members]);
+
+ const serviceBreakdown = useMemo(() => {
+ const active = services.filter(s => s.is_active).length;
+ const inactive = services.length - active;
+ const manifest = services.filter(s => s.manifest_managed === 1).length;
+ const parts: string[] = [];
+ parts.push(`${active} active`);
+ if (inactive > 0) parts.push(`${inactive} inactive`);
+ if (manifest > 0) parts.push(`${manifest} manifest-managed`);
+ return parts.join(' · ');
+ }, [services]);
+
+ const healthPercent = stats.total > 0
+ ? Math.round((stats.healthy / stats.total) * 100)
+ : 0;
+
+ return (
+
+
+ {/* Members Card */}
+
+ Members
+ {members.length}
+ {memberBreakdown}
+
+
+ {/* Services Card */}
+
+ Services
+ {services.length}
+ {serviceBreakdown}
+
+
+ {/* Health Card */}
+ {isLoading ? (
+
+ ) : (
+
+ Service Health
+ {stats.healthy}
+
+ {stats.healthy} healthy
+ {stats.warning > 0 && ` · ${stats.warning} warning`}
+ {stats.critical > 0 && ` · ${stats.critical} critical`}
+
+
+ )}
+
+ {/* Dependencies Card */}
+ {isLoading ? (
+
+ ) : (
+
+ Dependencies
+ {stats.totalDependencies}
+ across {stats.total} services
+
+ )}
+
+
+ {/* Health Bar */}
+ {!isLoading && stats.total > 0 && (
+
+
+
Health Overview
+
+ {healthPercent}% healthy
+
+
+
+ {stats.healthy > 0 && (
+
+ )}
+ {stats.warning > 0 && (
+
+ )}
+ {stats.critical > 0 && (
+
+ )}
+ {stats.unknown > 0 && (
+
+ )}
+
+
+
+
+ Healthy ({stats.healthy})
+
+ {stats.warning > 0 && (
+
+
+ Warning ({stats.warning})
+
+ )}
+ {stats.critical > 0 && (
+
+
+ Critical ({stats.critical})
+
+ )}
+
+
+ )}
+
+ {error && (
+
+ Failed to load health data
+
+ )}
+
+ );
+}
+
+export default TeamOverviewStats;
diff --git a/client/src/components/pages/Teams/Teams.module.css b/client/src/components/pages/Teams/Teams.module.css
index 4752efa..917dbc3 100644
--- a/client/src/components/pages/Teams/Teams.module.css
+++ b/client/src/components/pages/Teams/Teams.module.css
@@ -1,7 +1,7 @@
/* Container & Layout */
.container {
height: 100%;
- padding: 2rem;
+ padding: var(--space-5);
display: flex;
flex-direction: column;
overflow: auto;
@@ -11,62 +11,59 @@
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 1.5rem;
+ margin-bottom: var(--space-5);
}
.title {
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-2xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
margin: 0;
+ line-height: var(--line-height-tight);
}
/* Buttons */
.addButton {
display: inline-flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-inverse);
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: #fff;
background-color: var(--color-accent);
border: none;
- border-radius: 0.375rem;
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast) ease;
}
.addButton:hover {
background-color: var(--color-accent-hover);
}
-.addButton:focus {
- outline: 2px solid var(--color-accent);
- outline-offset: 2px;
-}
-
.retryButton {
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: var(--color-text-secondary);
+ background-color: transparent;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.retryButton:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
+ color: var(--color-text);
}
/* Filters */
.filters {
display: flex;
- gap: 1rem;
- margin-bottom: 1.5rem;
+ gap: var(--space-3);
+ margin-bottom: var(--space-5);
}
.searchWrapper {
@@ -77,7 +74,7 @@
.searchIcon {
position: absolute;
- left: 0.75rem;
+ left: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
@@ -86,26 +83,26 @@
.searchInput {
width: 100%;
- padding: 0.5rem 0.75rem 0.5rem 2.5rem;
- font-size: 0.875rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
- transition: border-color 0.15s, box-shadow 0.15s;
+ padding: var(--space-2) var(--space-3) var(--space-2) 2.5rem;
+ font-size: var(--font-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background-color: var(--color-surface);
+ color: var(--color-text);
+ transition: border-color var(--duration-fast) ease;
}
-.searchInput:focus {
+.searchInput:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
/* Table */
.tableWrapper {
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 0.5rem;
+ border-radius: var(--radius-md);
overflow: hidden;
}
@@ -116,21 +113,21 @@
.table th {
text-align: left;
- padding: 0.75rem 1rem;
- font-size: 0.75rem;
- font-weight: 600;
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: uppercase;
- letter-spacing: 0.05em;
+ letter-spacing: 0.04em;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
border-bottom: 1px solid var(--color-border);
}
.table td {
- padding: 0.75rem 1rem;
- font-size: 0.875rem;
- color: var(--color-text-primary);
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--font-base);
+ color: var(--color-text);
+ border-bottom: 1px solid var(--color-border-subtle);
vertical-align: middle;
}
@@ -138,17 +135,23 @@
border-bottom: none;
}
+.table tbody tr {
+ transition: background-color var(--duration-fast) ease;
+}
+
.table tbody tr:hover {
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
}
.teamLink {
color: var(--color-accent);
text-decoration: none;
- font-weight: 500;
+ font-weight: var(--font-medium);
+ transition: color var(--duration-fast) ease;
}
.teamLink:hover {
+ color: var(--color-accent-hover);
text-decoration: underline;
}
@@ -163,17 +166,17 @@
.countCell {
display: inline-flex;
align-items: center;
- gap: 0.375rem;
+ gap: var(--space-1);
}
.countValue {
- font-weight: 500;
+ font-weight: var(--font-medium);
font-variant-numeric: tabular-nums;
}
.countLabel {
color: var(--color-text-muted);
- font-size: 0.75rem;
+ font-size: var(--font-xs);
}
/* States */
@@ -182,17 +185,13 @@
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 1rem;
- padding: 4rem 2rem;
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-5);
color: var(--color-text-muted);
}
.spinner {
- width: 2rem;
- height: 2rem;
- border: 3px solid var(--color-border);
- border-top-color: var(--color-accent);
- border-radius: 50%;
+ color: var(--color-accent);
animation: spin 1s linear infinite;
}
@@ -206,9 +205,9 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 1rem;
- padding: 4rem 2rem;
- color: var(--color-error);
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-5);
+ color: var(--color-critical);
text-align: center;
}
@@ -216,13 +215,13 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 1rem;
- padding: 4rem 2rem;
+ gap: var(--space-4);
+ padding: var(--space-8) var(--space-5);
color: var(--color-text-muted);
text-align: center;
- background-color: var(--color-bg-hover);
- border: 1px dashed var(--color-border-input);
- border-radius: 0.5rem;
+ background-color: var(--color-surface-hover);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-md);
}
/* Team Detail Page */
@@ -232,20 +231,33 @@
gap: 0.375rem;
color: var(--color-text-muted);
text-decoration: none;
- font-size: 0.875rem;
- margin-bottom: 1rem;
- transition: color 0.15s;
+ font-size: var(--font-sm);
+ margin-bottom: var(--space-4);
+ transition: color var(--duration-fast) ease;
}
.backLink:hover {
color: var(--color-text-primary);
}
-.detailHeader {
+/* Overview tab panel */
+.overviewPanel {
display: flex;
justify-content: space-between;
align-items: flex-start;
- margin-bottom: 1.5rem;
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+
+.overviewStatsWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.healthError {
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
}
.teamTitle {
@@ -255,37 +267,37 @@
}
.teamTitle h1 {
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-2xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
margin: 0;
}
.teamKey {
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
- font-size: 0.75rem;
+ font-size: var(--font-xs);
padding: 0.0625rem 0.375rem;
- background-color: var(--color-bg-hover);
+ background-color: var(--color-surface-hover);
border: 1px solid var(--color-border);
- border-radius: 0.25rem;
+ border-radius: var(--radius-sm);
color: var(--color-text-muted);
}
.teamDescription {
color: var(--color-text-muted);
- font-size: 0.875rem;
+ font-size: var(--font-base);
margin: 0;
}
.contactInfo {
display: flex;
flex-wrap: wrap;
- gap: 0.5rem 1rem;
- margin-top: 0.25rem;
+ gap: var(--space-2) var(--space-4);
+ margin-top: var(--space-1);
}
.contactItem {
- font-size: 0.8125rem;
+ font-size: var(--font-sm);
color: var(--color-text-muted);
}
@@ -301,69 +313,80 @@
.actions {
display: flex;
- gap: 0.5rem;
+ gap: var(--space-2);
+ flex-shrink: 0;
}
-.actionButton {
+.ghostButton {
display: inline-flex;
align-items: center;
- gap: 0.375rem;
- padding: 0.5rem 0.75rem;
- font-size: 0.875rem;
- font-weight: 500;
- border-radius: 0.375rem;
+ gap: var(--space-2);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border);
cursor: pointer;
- transition: background-color 0.15s, border-color 0.15s;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
-.editButton {
- color: var(--color-text-primary);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
+.ghostButton:hover:not(:disabled) {
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
-.editButton:hover:not(:disabled) {
- background-color: var(--color-bg-hover);
- border-color: var(--color-text-muted);
+.ghostButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
}
-.deleteButton {
- color: var(--color-error);
- background-color: var(--color-bg-card);
- border: 1px solid var(--color-error-border);
+.dangerButton {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ background: var(--color-critical);
+ color: #fff;
+ border: none;
+ cursor: pointer;
+ transition: background-color var(--duration-fast) ease;
}
-.deleteButton:hover:not(:disabled) {
- background-color: var(--color-error-bg);
- border-color: var(--color-error);
+.dangerButton:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--color-critical) 80%, black);
}
-.actionButton:disabled {
+.dangerButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Section */
.section {
- margin-bottom: 2rem;
+ margin-bottom: var(--space-6);
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 1rem;
+ margin-bottom: var(--space-4);
}
.sectionTitle {
- font-size: 1.125rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
margin: 0;
}
.sectionSubtitle {
- font-size: 0.875rem;
+ font-size: var(--font-base);
color: var(--color-text-muted);
}
@@ -379,25 +402,20 @@
.roleCell {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: var(--space-2);
}
.roleBadge {
display: inline-flex;
padding: 0.125rem 0.5rem;
- font-size: 0.75rem;
- font-weight: 500;
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
border-radius: 9999px;
}
.roleLead {
- color: #1e40af;
- background-color: #dbeafe;
-}
-
-[data-theme="dark"] .roleLead {
- color: #93c5fd;
- background-color: #1e3a5f;
+ color: var(--color-accent);
+ background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.roleMember {
@@ -407,16 +425,16 @@
.memberActions {
display: flex;
- gap: 0.5rem;
+ gap: var(--space-2);
}
.smallButton {
- padding: 0.25rem 0.5rem;
- font-size: 0.75rem;
- font-weight: 500;
- border-radius: 0.25rem;
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: background-color 0.15s;
+ transition: background-color var(--duration-fast) ease;
}
.roleButton {
@@ -450,27 +468,34 @@
display: flex;
align-items: center;
justify-content: space-between;
- padding: 0.75rem 1rem;
- border-bottom: 1px solid var(--color-border);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-subtle);
+ transition: background-color var(--duration-fast) ease;
}
.serviceItem:last-child {
border-bottom: none;
}
+.serviceItem:hover {
+ background-color: var(--color-surface-hover);
+}
+
.serviceInfo {
display: flex;
align-items: center;
- gap: 0.75rem;
+ gap: var(--space-3);
}
.serviceName {
color: var(--color-accent);
text-decoration: none;
- font-weight: 500;
+ font-weight: var(--font-medium);
+ transition: color var(--duration-fast) ease;
}
.serviceName:hover {
+ color: var(--color-accent-hover);
text-decoration: underline;
}
@@ -489,18 +514,18 @@
}
.noItems {
- padding: 2rem;
+ padding: var(--space-6);
text-align: center;
color: var(--color-text-muted);
- background-color: var(--color-bg-hover);
- border: 1px dashed var(--color-border-input);
- border-radius: 0.5rem;
+ background-color: var(--color-surface-hover);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-md);
}
/* Add Member Form */
.addMemberForm {
display: flex;
- gap: 0.75rem;
+ gap: var(--space-3);
align-items: flex-end;
}
@@ -510,38 +535,40 @@
.addMemberForm .label {
display: block;
- font-size: 0.75rem;
- font-weight: 500;
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
color: var(--color-text-muted);
- margin-bottom: 0.25rem;
+ margin-bottom: var(--space-1);
}
.addMemberForm .select {
width: 100%;
- padding: 0.5rem 0.75rem;
- font-size: 0.875rem;
- border: 1px solid var(--color-border-input);
- border-radius: 0.375rem;
- background-color: var(--color-bg-input);
- color: var(--color-text-primary);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background-color: var(--color-surface);
+ color: var(--color-text);
+ transition: border-color var(--duration-fast) ease;
}
-.addMemberForm .select:focus {
+.addMemberForm .select:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.addMemberButton {
- padding: 0.5rem 1rem;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-inverse);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ color: #fff;
background-color: var(--color-accent);
border: none;
- border-radius: 0.375rem;
+ border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
+ transition: background-color var(--duration-fast) ease;
}
.addMemberButton:hover:not(:disabled) {
@@ -553,24 +580,33 @@
cursor: not-allowed;
}
-/* Alert Grid (side-by-side on wider screens) */
-.alertGrid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 2rem;
- margin-bottom: 2rem;
+/* Alerts tab panel */
+.alertsPanel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
}
-@media (max-width: 768px) {
- .alertGrid {
- grid-template-columns: 1fr;
- }
+.alertCard {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+}
+
+/* Inactive badge for services */
+.inactiveBadge {
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+ background-color: var(--color-surface-hover);
+ padding: 0.125rem 0.5rem;
+ border-radius: var(--radius-sm);
}
/* Responsive */
@media (max-width: 640px) {
.container {
- padding: 1rem;
+ padding: var(--space-4);
}
.filters {
@@ -584,12 +620,12 @@
.header {
flex-direction: column;
align-items: flex-start;
- gap: 1rem;
+ gap: var(--space-4);
}
- .detailHeader {
+ .overviewPanel {
flex-direction: column;
- gap: 1rem;
+ gap: var(--space-4);
}
.actions {
@@ -597,7 +633,8 @@
flex-wrap: wrap;
}
- .actionButton {
+ .ghostButton,
+ .dangerButton {
flex: 1;
justify-content: center;
}
diff --git a/client/src/components/pages/Teams/TeamsList.tsx b/client/src/components/pages/Teams/TeamsList.tsx
index 587ce57..4d6dff0 100644
--- a/client/src/components/pages/Teams/TeamsList.tsx
+++ b/client/src/components/pages/Teams/TeamsList.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
+import { Search, Plus, Loader2 } from 'lucide-react';
import { useAuth } from '../../../contexts/AuthContext';
import { fetchTeams } from '../../../api/teams';
import type { TeamWithCounts } from '../../../types/team';
@@ -50,7 +51,7 @@ function TeamsList() {
return (
@@ -79,16 +80,7 @@ function TeamsList() {
onClick={() => setIsAddModalOpen(true)}
className={styles.addButton}
>
-
-
-
+
Add Team
)}
@@ -96,18 +88,7 @@ function TeamsList() {
-
-
-
-
+
setIsAddModalOpen(false)}
title="Create Team"
- size="medium"
+ size="md"
>
{dependency.canonical_name}
-
-
-
+
@@ -215,9 +214,7 @@ function DependencyDetailPanelComponent({ dependency, onClose }: DependencyDetai
className={styles.actionLink}
>
View in Graph
-
-
-
+
{dependency.linked_service && (
Loading...
-
-
-
+
@@ -89,9 +88,7 @@ function ServiceDetailPanelComponent({ serviceId, onClose }: ServiceDetailPanelP
{error || 'Service not found'}
@@ -120,10 +117,7 @@ function ServiceDetailPanelComponent({ serviceId, onClose }: ServiceDetailPanelP
{service.is_external !== 1 && service.last_poll_success === 0 && (
-
-
-
-
+
Poll failed{service.last_poll_error ? `: ${service.last_poll_error}` : ''}
)}
@@ -267,9 +261,7 @@ function ServiceDetailPanelComponent({ serviceId, onClose }: ServiceDetailPanelP
View Full Details
-
-
-
+
diff --git a/client/src/components/pages/Wallboard/Wallboard.module.css b/client/src/components/pages/Wallboard/Wallboard.module.css
index 5ee079b..8d7d0da 100644
--- a/client/src/components/pages/Wallboard/Wallboard.module.css
+++ b/client/src/components/pages/Wallboard/Wallboard.module.css
@@ -7,7 +7,7 @@
}
.container {
- padding: 1.5rem;
+ padding: var(--space-5);
flex: 1;
overflow-y: auto;
}
@@ -16,34 +16,28 @@
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 1.5rem;
- gap: 1rem;
+ margin-bottom: var(--space-5);
+ gap: var(--space-4);
}
.title {
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-2xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ line-height: var(--line-height-tight);
}
/* Header right section */
.headerRight {
display: flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
}
.refreshingIndicator {
display: flex;
align-items: center;
-}
-
-.spinnerSmall {
- width: 14px;
- height: 14px;
- border: 2px solid var(--color-border);
- border-top-color: var(--color-accent);
- border-radius: 50%;
+ color: var(--color-accent);
animation: spin 1s linear infinite;
}
@@ -56,33 +50,33 @@
display: flex;
align-items: center;
justify-content: center;
- padding: 6px;
- background: var(--color-bg-card);
- border: 1px solid var(--color-border-input);
- border-radius: 6px;
+ padding: var(--space-2);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
- color: var(--color-text-primary);
- transition: all 0.15s;
+ color: var(--color-text-secondary);
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
}
.settingsButton:hover {
- background: var(--color-bg-hover);
- border-color: var(--color-text-muted);
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.settingsMenu {
position: absolute;
- top: calc(100% + 8px);
+ top: calc(100% + var(--space-2));
right: 0;
- z-index: 50;
- padding: 12px;
- background: var(--color-bg-card);
+ z-index: var(--z-dropdown);
+ padding: var(--space-3);
+ background: var(--color-surface);
border: 1px solid var(--color-border);
- border-radius: 8px;
+ border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: var(--space-3);
min-width: 240px;
}
@@ -90,27 +84,29 @@
display: flex;
align-items: center;
justify-content: space-between;
- gap: 12px;
+ gap: var(--space-3);
}
.settingsMenuLabel {
- font-size: 13px;
- font-weight: 500;
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
white-space: nowrap;
}
.settingsMenuDivider {
height: 1px;
- background: var(--color-border);
- margin: 0 -4px;
+ background: var(--color-border-subtle);
+ margin: 0 calc(var(--space-1) * -1);
}
.filterToggle {
display: flex;
align-items: center;
- gap: 0.5rem;
- font-size: 0.875rem;
+ gap: var(--space-2);
+ font-size: var(--font-base);
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
@@ -123,7 +119,7 @@
.pollingControls {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: var(--space-2);
}
.togglePill {
@@ -131,21 +127,21 @@
width: 2.5rem;
height: 1.5rem;
padding: 0;
- background-color: var(--color-border-input);
+ background-color: var(--color-border);
border: none;
border-radius: 1rem;
cursor: pointer;
- transition: background-color 0.2s;
+ transition: background-color var(--duration-normal) ease;
flex-shrink: 0;
}
-.togglePill:focus {
+.togglePill:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.togglePill.toggleActive {
- background-color: var(--color-success);
+ background-color: var(--color-healthy);
}
.toggleKnob {
@@ -154,10 +150,10 @@
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
- background-color: var(--color-bg-card);
+ background-color: var(--color-surface);
border-radius: 50%;
- transition: transform 0.2s;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+ transition: transform var(--duration-normal) ease;
+ box-shadow: var(--shadow-sm);
}
.togglePill.toggleActive .toggleKnob {
@@ -165,13 +161,20 @@
}
.intervalSelect {
- padding: 0.375rem 0.5rem;
- font-size: 0.8rem;
+ font-size: var(--font-sm);
+ padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
- border-radius: 4px;
- background-color: var(--color-bg);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
+ transition: border-color var(--duration-fast) ease;
+}
+
+.intervalSelect:focus-visible {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.intervalSelect:disabled {
@@ -180,57 +183,57 @@
}
.teamSelect {
- padding: 8px 12px;
- border: 1px solid var(--color-border-input);
- border-radius: 6px;
- font-size: 14px;
- background: var(--color-bg-input);
- color: var(--color-text-primary);
+ font-size: var(--font-base);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
min-width: 150px;
cursor: pointer;
+ transition: border-color var(--duration-fast) ease;
}
-.teamSelect:focus {
+.teamSelect:focus-visible {
outline: none;
border-color: var(--color-accent);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: 1rem;
+ gap: var(--space-4);
}
/* Card */
.card {
- background-color: var(--color-bg-active);
- border: 1px solid var(--color-border-light);
- border-radius: 8px;
- padding: 1.25rem;
- border-left: 4px solid transparent;
- transition: box-shadow 0.2s, border-color 0.2s, background-color 0.2s;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+ border-left: 3px solid transparent;
+ transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease;
cursor: pointer;
outline: none;
}
.card:hover {
- box-shadow: var(--shadow-md);
- background-color: var(--color-bg-hover);
+ background: var(--color-surface-hover);
}
.card:focus-visible {
- box-shadow: 0 0 0 2px var(--color-primary);
+ box-shadow: 0 0 0 2px var(--color-accent);
}
.cardSelected {
- box-shadow: 0 0 0 2px var(--color-primary);
- background-color: var(--color-bg-hover);
+ box-shadow: 0 0 0 2px var(--color-accent);
+ background: var(--color-surface-hover);
}
.cardHealthy {
- border-left-color: var(--color-success);
+ border-left-color: var(--color-healthy);
}
.cardWarning {
@@ -238,46 +241,47 @@
}
.cardCritical {
- border-left-color: var(--color-error);
+ border-left-color: var(--color-critical);
}
.cardUnknown {
- border-left-color: var(--color-text-muted);
+ border-left-color: var(--color-unknown);
}
.cardSkipped {
- border-left-color: var(--color-border-input);
+ border-left-color: var(--color-border);
border-left-style: dashed;
opacity: 0.75;
}
.cardName {
- font-size: 1rem;
- font-weight: 600;
- color: var(--color-text-heading);
+ font-size: var(--font-lg);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
display: flex;
align-items: center;
- gap: 0.5rem;
- margin-bottom: 0.75rem;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+ line-height: var(--line-height-tight);
}
.typeBadge {
display: inline-block;
- padding: 0.125rem 0.5rem;
- border-radius: 9999px;
- font-size: 0.6875rem;
- font-weight: 500;
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
text-transform: capitalize;
- background-color: var(--color-bg-hover);
- color: var(--color-text-primary);
+ background: var(--color-surface-hover);
+ color: var(--color-text-secondary);
flex-shrink: 0;
}
.cardMeta {
display: flex;
flex-direction: column;
- gap: 0.375rem;
- font-size: 0.8125rem;
+ gap: var(--space-1);
+ font-size: var(--font-sm);
color: var(--color-text-secondary);
}
@@ -287,6 +291,10 @@
align-items: center;
}
+.cardMetaRow span:last-child {
+ font-variant-numeric: tabular-nums;
+}
+
.reporterNames {
text-align: right;
max-width: 60%;
@@ -301,17 +309,26 @@
/* Status badge inline */
.statusBadge {
- display: inline-block;
- padding: 0.125rem 0.5rem;
- border-radius: 12px;
- font-size: 0.75rem;
- font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
text-transform: capitalize;
}
+.statusDot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
.statusHealthy {
- background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
- color: var(--color-success);
+ background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent);
+ color: var(--color-healthy);
}
.statusWarning {
@@ -320,30 +337,30 @@
}
.statusCritical {
- background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
- color: var(--color-error);
+ background-color: color-mix(in srgb, var(--color-critical) 15%, transparent);
+ color: var(--color-critical);
}
.statusUnknown {
- background-color: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
- color: var(--color-text-muted);
+ background-color: color-mix(in srgb, var(--color-unknown) 15%, transparent);
+ color: var(--color-unknown);
}
.statusSkipped {
- background-color: color-mix(in srgb, var(--color-border-input) 20%, transparent);
+ background-color: color-mix(in srgb, var(--color-border) 20%, transparent);
color: var(--color-text-muted);
font-style: italic;
}
/* Error row */
.errorRow {
- margin-top: 0.5rem;
- padding: 0.375rem 0.5rem;
- background-color: color-mix(in srgb, var(--color-error) 8%, transparent);
- border-radius: 4px;
- font-size: 0.75rem;
- color: var(--color-error);
- line-height: 1.4;
+ margin-top: var(--space-2);
+ padding: var(--space-1) var(--space-2);
+ background-color: color-mix(in srgb, var(--color-critical) 8%, transparent);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-xs);
+ color: var(--color-critical);
+ line-height: var(--line-height-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -354,16 +371,17 @@
display: flex;
align-items: center;
justify-content: center;
- gap: 0.75rem;
- padding: 3rem;
+ gap: var(--space-3);
+ padding: var(--space-7);
color: var(--color-text-muted);
+ font-size: var(--font-sm);
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid var(--color-border);
- border-top-color: var(--color-primary);
+ border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@ -373,20 +391,40 @@
}
.error {
- padding: 2rem;
+ padding: var(--space-6);
text-align: center;
- color: var(--color-error);
+ color: var(--color-critical);
+ font-size: var(--font-base);
+}
+
+.error button {
+ margin-top: var(--space-3);
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border);
+ cursor: pointer;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
+}
+
+.error button:hover {
+ background: var(--color-surface-hover);
+ color: var(--color-text);
}
.emptyState {
- padding: 3rem;
+ padding: var(--space-7);
text-align: center;
color: var(--color-text-muted);
+ font-size: var(--font-base);
}
@media (max-width: 768px) {
.container {
- padding: 1rem;
+ padding: var(--space-4);
}
.header {
diff --git a/client/src/components/pages/Wallboard/Wallboard.tsx b/client/src/components/pages/Wallboard/Wallboard.tsx
index ee3aa76..0e77ffb 100644
--- a/client/src/components/pages/Wallboard/Wallboard.tsx
+++ b/client/src/components/pages/Wallboard/Wallboard.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import { Settings, Loader2 } from 'lucide-react';
import { fetchWallboardData } from '../../../api/wallboard';
import { formatRelativeTime } from '../../../utils/formatting';
import { usePolling, INTERVAL_OPTIONS } from '../../../hooks/usePolling';
@@ -161,7 +162,7 @@ function Wallboard() {
{isRefreshing && (
)}
@@ -172,10 +173,7 @@ function Wallboard() {
aria-label="Wallboard settings"
aria-expanded={settingsOpen}
>
-
-
-
-
+
{settingsOpen && (
@@ -272,6 +270,7 @@ function Wallboard() {
Status
+
{dep.health_status}
diff --git a/client/src/hooks/useTeamServiceHealth.test.ts b/client/src/hooks/useTeamServiceHealth.test.ts
new file mode 100644
index 0000000..5cdf3e7
--- /dev/null
+++ b/client/src/hooks/useTeamServiceHealth.test.ts
@@ -0,0 +1,155 @@
+import { renderHook, act } from '@testing-library/react';
+import { useTeamServiceHealth } from './useTeamServiceHealth';
+
+const mockFetch = jest.fn();
+global.fetch = mockFetch;
+
+function jsonResponse(data: unknown, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(data),
+ };
+}
+
+const mockServices = [
+ {
+ id: 's1',
+ name: 'Service A',
+ health: { status: 'healthy', healthy_reports: 5, warning_reports: 0, critical_reports: 0, total_reports: 5, dependent_count: 2 },
+ dependencies: [{ id: 'd1' }, { id: 'd2' }],
+ },
+ {
+ id: 's2',
+ name: 'Service B',
+ health: { status: 'warning', healthy_reports: 3, warning_reports: 2, critical_reports: 0, total_reports: 5, dependent_count: 1 },
+ dependencies: [{ id: 'd3' }],
+ },
+ {
+ id: 's3',
+ name: 'Service C',
+ health: { status: 'critical', healthy_reports: 1, warning_reports: 1, critical_reports: 3, total_reports: 5, dependent_count: 0 },
+ dependencies: [],
+ },
+ {
+ id: 's4',
+ name: 'Service D',
+ health: { status: 'unknown', healthy_reports: 0, warning_reports: 0, critical_reports: 0, total_reports: 0, dependent_count: 0 },
+ dependencies: [{ id: 'd4' }, { id: 'd5' }, { id: 'd6' }],
+ },
+];
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe('useTeamServiceHealth', () => {
+ it('starts in loading state', () => {
+ mockFetch.mockImplementation(() => new Promise(() => {}));
+
+ const { result } = renderHook(() => useTeamServiceHealth('t1'));
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBeNull();
+ expect(result.current.stats).toEqual({
+ total: 0,
+ healthy: 0,
+ warning: 0,
+ critical: 0,
+ unknown: 0,
+ totalDependencies: 0,
+ });
+ });
+
+ it('computes health stats and dependency count after loading', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockServices));
+
+ const { result } = renderHook(() => useTeamServiceHealth('t1'));
+
+ await act(async () => {
+ await result.current.reload();
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining('/api/services?team_id=t1'),
+ expect.any(Object)
+ );
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(result.current.stats).toEqual({
+ total: 4,
+ healthy: 1,
+ warning: 1,
+ critical: 1,
+ unknown: 1,
+ totalDependencies: 6,
+ });
+ });
+
+ it('handles fetch errors', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'Not found' }, 404));
+
+ const { result } = renderHook(() => useTeamServiceHealth('t1'));
+
+ await act(async () => {
+ await result.current.reload();
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeTruthy();
+ });
+
+ it('handles network errors', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('Network failure'));
+
+ const { result } = renderHook(() => useTeamServiceHealth('t1'));
+
+ await act(async () => {
+ await result.current.reload();
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe('Network failure');
+ });
+
+ it('handles services with no dependencies array', async () => {
+ const servicesNoDeps = [
+ {
+ id: 's1',
+ name: 'Service A',
+ health: { status: 'healthy', healthy_reports: 5, warning_reports: 0, critical_reports: 0, total_reports: 5, dependent_count: 0 },
+ },
+ ];
+ mockFetch.mockResolvedValueOnce(jsonResponse(servicesNoDeps));
+
+ const { result } = renderHook(() => useTeamServiceHealth('t1'));
+
+ await act(async () => {
+ await result.current.reload();
+ });
+
+ expect(result.current.stats.totalDependencies).toBe(0);
+ });
+
+ it('reloads data on subsequent calls', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockServices));
+
+ const { result } = renderHook(() => useTeamServiceHealth('t1'));
+
+ await act(async () => {
+ await result.current.reload();
+ });
+
+ expect(result.current.stats.total).toBe(4);
+
+ const updatedServices = [mockServices[0]];
+ mockFetch.mockResolvedValueOnce(jsonResponse(updatedServices));
+
+ await act(async () => {
+ await result.current.reload();
+ });
+
+ expect(result.current.stats.total).toBe(1);
+ expect(result.current.stats.healthy).toBe(1);
+ });
+});
diff --git a/client/src/hooks/useTeamServiceHealth.ts b/client/src/hooks/useTeamServiceHealth.ts
new file mode 100644
index 0000000..cab2841
--- /dev/null
+++ b/client/src/hooks/useTeamServiceHealth.ts
@@ -0,0 +1,60 @@
+import { useState, useCallback, useMemo } from 'react';
+import { fetchServices } from '../api/services';
+import type { ServiceWithDependencies } from '../types/service';
+
+export interface TeamServiceHealthStats {
+ total: number;
+ healthy: number;
+ warning: number;
+ critical: number;
+ unknown: number;
+ totalDependencies: number;
+}
+
+export interface UseTeamServiceHealthReturn {
+ stats: TeamServiceHealthStats;
+ isLoading: boolean;
+ error: string | null;
+ reload: () => Promise
;
+}
+
+export function useTeamServiceHealth(teamId: string): UseTeamServiceHealthReturn {
+ const [services, setServices] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const reload = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const data = await fetchServices(teamId);
+ setServices(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load service health');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [teamId]);
+
+ const stats = useMemo((): TeamServiceHealthStats => {
+ const healthy = services.filter(s => s.health.status === 'healthy').length;
+ const warning = services.filter(s => s.health.status === 'warning').length;
+ const critical = services.filter(s => s.health.status === 'critical').length;
+ const unknown = services.length - healthy - warning - critical;
+ const totalDependencies = services.reduce(
+ (sum, s) => sum + (s.dependencies?.length ?? 0),
+ 0
+ );
+
+ return {
+ total: services.length,
+ healthy,
+ warning,
+ critical,
+ unknown,
+ totalDependencies,
+ };
+ }, [services]);
+
+ return { stats, isLoading, error, reload };
+}
diff --git a/client/src/index.css b/client/src/index.css
index c250cea..16078c8 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,106 +1,150 @@
-/* Theme Variables */
+/* =============================================================
+ Design System Tokens
+ Swiss Modernism 2.0 — minimal, flat, crisp, data-forward
+ ============================================================= */
+
+/* --- Theme-independent tokens --- */
:root {
- /* Primary colors */
+ /* Spacing (8px base grid) */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+ --space-6: 32px;
+ --space-7: 48px;
+ --space-8: 64px;
+
+ /* Typography */
+ --font-xs: 0.75rem; /* 12px */
+ --font-sm: 0.8125rem; /* 13px */
+ --font-base: 0.875rem; /* 14px */
+ --font-lg: 1rem; /* 16px */
+ --font-xl: 1.125rem; /* 18px */
+ --font-2xl: 1.5rem; /* 24px */
+ --font-normal: 400;
+ --font-medium: 500;
+ --font-semibold: 600;
+ --line-height-tight: 1.25;
+ --line-height-normal: 1.5;
+
+ /* Border radius */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+
+ /* Z-index scale */
+ --z-dropdown: 10;
+ --z-sticky: 20;
+ --z-modal: 30;
+ --z-tooltip: 50;
+
+ /* Transition durations */
+ --duration-fast: 150ms;
+ --duration-normal: 200ms;
+ --duration-slow: 300ms;
+
+ /* Accent + status (same for both themes) */
+ --color-accent: #3b82f6;
+ --color-accent-hover: #2563eb;
+ --color-healthy: #22c55e;
+ --color-warning: #f59e0b;
+ --color-critical: #dc2626;
+ --color-unknown: #6b7280;
+}
+
+/* --- Light theme (default) --- */
+:root {
+ /* Surfaces */
+ --color-bg: #f8fafc;
+ --color-surface: #ffffff;
+ --color-surface-hover: #f1f5f9;
+ --color-border: #e2e8f0;
+ --color-border-subtle: #f1f5f9;
+
+ /* Text */
+ --color-text: #0f172a;
+ --color-text-secondary: #475569;
+ --color-text-muted: #64748b;
+
+ /* Shadows (minimal, for elevated elements only) */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
+
+ /* --- Legacy aliases (existing components reference these) --- */
--color-primary: #1a1a2e;
--color-primary-hover: #2d2d4a;
-
- /* Background colors */
- --color-bg-page: #f5f5f5;
- --color-bg-card: #ffffff;
+ --color-bg-page: var(--color-bg);
+ --color-bg-card: var(--color-surface);
--color-bg-sidebar: #f8f9fa;
- --color-bg-input: #ffffff;
- --color-bg-hover: #f9fafb;
+ --color-bg-input: var(--color-surface);
+ --color-bg-hover: var(--color-surface-hover);
--color-bg-active: #e9ecef;
-
- /* Text colors */
- --color-text-primary: #333333;
- --color-text-secondary: #666666;
- --color-text-muted: #6b7280;
- --color-text-heading: #1a1a2e;
+ --color-text-primary: var(--color-text);
+ --color-text-heading: var(--color-text);
--color-text-inverse: #ffffff;
-
- /* Border colors */
- --color-border: #e5e7eb;
- --color-border-light: #e9ecef;
+ --color-border-light: var(--color-border-subtle);
--color-border-input: #d1d5db;
-
- /* Accent colors */
- --color-accent: #3b82f6;
- --color-accent-hover: #2563eb;
-
- /* Status colors (remain consistent) */
- --color-success: #22c55e;
- --color-warning: #f59e0b;
- --color-error: #dc2626;
+ --color-success: var(--color-healthy);
+ --color-error: var(--color-critical);
--color-error-bg: #fef2f2;
--color-error-border: #fecaca;
-
- /* Spinner */
--color-spinner-track: #e9ecef;
--color-spinner-fill: #1a1a2e;
-
- /* Chart colors */
- --color-chart-min: #22c55e;
- --color-chart-avg: #3b82f6;
+ --color-chart-min: var(--color-healthy);
+ --color-chart-avg: var(--color-accent);
--color-chart-max: #ef4444;
-
- /* Shadow */
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
- --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
- --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
+/* --- Dark theme --- */
[data-theme="dark"] {
- /* Primary colors */
+ /* Surfaces */
+ --color-bg: #0f1117;
+ --color-surface: #1a1d2e;
+ --color-surface-hover: #252840;
+ --color-border: #2d3148;
+ --color-border-subtle: #1e2235;
+
+ /* Text */
+ --color-text: #e2e8f0;
+ --color-text-secondary: #94a3b8;
+ --color-text-muted: #64748b;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
+
+ /* --- Legacy aliases --- */
--color-primary: #2d2d4a;
--color-primary-hover: #3d3d5a;
-
- /* Background colors */
- --color-bg-page: #121218;
- --color-bg-card: #1e1e2e;
+ --color-bg-page: var(--color-bg);
+ --color-bg-card: var(--color-surface);
--color-bg-sidebar: #1a1a28;
--color-bg-input: #2a2a3a;
- --color-bg-hover: #2a2a3a;
+ --color-bg-hover: var(--color-surface-hover);
--color-bg-active: #3a3a4a;
-
- /* Text colors */
- --color-text-primary: #e5e5e5;
- --color-text-secondary: #a0a0a0;
- --color-text-muted: #8b8b9b;
- --color-text-heading: #f0f0f0;
+ --color-text-primary: var(--color-text);
+ --color-text-heading: var(--color-text);
--color-text-inverse: #ffffff;
-
- /* Border colors */
- --color-border: #3a3a4a;
- --color-border-light: #2a2a3a;
+ --color-border-light: var(--color-border-subtle);
--color-border-input: #4a4a5a;
-
- /* Accent colors */
- --color-accent: #60a5fa;
- --color-accent-hover: #3b82f6;
-
- /* Status colors (remain consistent but slightly adjusted for dark) */
--color-success: #34d399;
- --color-warning: #fbbf24;
--color-error: #f87171;
--color-error-bg: #2d1f1f;
--color-error-border: #5c2a2a;
-
- /* Spinner */
--color-spinner-track: #3a3a4a;
--color-spinner-fill: #60a5fa;
-
- /* Chart colors */
--color-chart-min: #34d399;
--color-chart-avg: #60a5fa;
--color-chart-max: #f87171;
-
- /* Shadow */
- --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
- --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
- --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
+/* =============================================================
+ Global Resets & Defaults
+ ============================================================= */
+
* {
box-sizing: border-box;
margin: 0;
@@ -108,13 +152,18 @@
}
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
- Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ font-size: var(--font-base);
+ line-height: var(--line-height-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- background-color: var(--color-bg-page);
- color: var(--color-text-primary);
- transition: background-color 0.2s, color 0.2s;
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ transition: background-color var(--duration-normal) ease, color var(--duration-normal) ease;
+}
+
+code, pre, kbd, samp {
+ font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
}
#root {
@@ -123,14 +172,43 @@ body {
flex-direction: column;
}
-/* Global loading states */
+/* --- Interactive element defaults --- */
+
+button, a, [role="button"], select, label[for],
+input[type="checkbox"], input[type="radio"] {
+ cursor: pointer;
+}
+
+button, input, select, textarea {
+ font-family: inherit;
+ font-size: inherit;
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+/* --- Reduced motion --- */
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* =============================================================
+ Global Loading States
+ ============================================================= */
+
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
- gap: 1rem;
+ gap: var(--space-4);
color: var(--color-text-muted);
}
diff --git a/client/src/styles/shared.module.css b/client/src/styles/shared.module.css
new file mode 100644
index 0000000..abce61a
--- /dev/null
+++ b/client/src/styles/shared.module.css
@@ -0,0 +1,137 @@
+/* =============================================================
+ Shared CSS Module Classes
+ Composable building blocks for consistent UI patterns.
+ Components use these via CSS Modules `composes:` syntax.
+ ============================================================= */
+
+/* --- Cards --- */
+
+.card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+}
+
+.cardInteractive {
+ composes: card;
+ cursor: pointer;
+ transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease;
+}
+.cardInteractive:hover {
+ background: var(--color-surface-hover);
+}
+
+/* --- Typography --- */
+
+.sectionHeader {
+ font-size: var(--font-lg);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ line-height: var(--line-height-tight);
+}
+
+.label {
+ font-size: var(--font-xs);
+ font-weight: var(--font-medium);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.dataValue {
+ font-size: var(--font-base);
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- Tables --- */
+
+.tableRow {
+ height: 36px;
+ border-bottom: 1px solid var(--color-border-subtle);
+ transition: background-color var(--duration-fast) ease;
+}
+.tableRow:hover {
+ background: var(--color-surface-hover);
+}
+
+.tableHeader {
+ font-size: var(--font-xs);
+ font-weight: var(--font-semibold);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--color-border);
+}
+
+/* --- Inputs --- */
+
+.input {
+ font-size: var(--font-base);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
+ transition: border-color var(--duration-fast) ease;
+}
+.input:focus-visible {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+/* --- Buttons --- */
+
+.buttonPrimary {
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-sm);
+ background: var(--color-accent);
+ color: #fff;
+ border: none;
+ cursor: pointer;
+ transition: background-color var(--duration-fast) ease;
+}
+.buttonPrimary:hover {
+ background: var(--color-accent-hover);
+}
+
+.buttonGhost {
+ font-size: var(--font-sm);
+ font-weight: var(--font-medium);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border);
+ cursor: pointer;
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
+}
+.buttonGhost:hover {
+ background: var(--color-surface-hover);
+ color: var(--color-text);
+}
+
+.buttonDanger {
+ composes: buttonPrimary;
+ background: var(--color-critical);
+}
+.buttonDanger:hover {
+ background: color-mix(in srgb, var(--color-critical) 85%, black);
+}
+
+/* --- Loading --- */
+
+.skeleton {
+ background: var(--color-border-subtle);
+ border-radius: var(--radius-sm);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
index 40abd8c..9b084e2 100644
--- a/client/src/vite-env.d.ts
+++ b/client/src/vite-env.d.ts
@@ -1,5 +1,7 @@
///
+declare const __APP_VERSION__: string;
+
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
index 97ede7e..35fa7d2 100644
--- a/client/tsconfig.node.json
+++ b/client/tsconfig.node.json
@@ -5,6 +5,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
"strict": true
},
"include": ["vite.config.ts"]
diff --git a/client/vite.config.js b/client/vite.config.js
index 25ba9b0..38d32a6 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -1,7 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+import pkg from '../package.json';
export default defineConfig({
plugins: [react()],
+ define: {
+ __APP_VERSION__: JSON.stringify(pkg.version),
+ },
server: {
port: 3000,
proxy: {
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 981bb83..3a877b0 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -1,8 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+import pkg from '../package.json';
export default defineConfig({
plugins: [react()],
+ define: {
+ __APP_VERSION__: JSON.stringify(pkg.version),
+ },
server: {
port: 3000,
proxy: {
diff --git a/docs/depsera-logo.svg b/docs/depsera-logo.svg
index 485a437..ab8c22f 100644
--- a/docs/depsera-logo.svg
+++ b/docs/depsera-logo.svg
@@ -1001,7 +1001,7 @@
all-encompassing visibility across your service world
- v1.11.0
+ v1.11.1
apache 2.0
diff --git a/docs/spec/10-client-architecture.md b/docs/spec/10-client-architecture.md
index 1dd5125..5528268 100644
--- a/docs/spec/10-client-architecture.md
+++ b/docs/spec/10-client-architecture.md
@@ -9,9 +9,9 @@
| `/login` | Login | Public | OIDC redirect or local auth form |
| `/` | Dashboard | Protected | Health summary overview |
| `/services` | ServicesList | Protected | Searchable, filterable service list |
-| `/services/:id` | ServiceDetail | Protected | Dependencies, latency, errors, contact info, override indicators, inline override editing (team lead+/admin), manual poll, inline alias management (admin) |
+| `/services/:id` | ServiceDetail | Protected | Tabbed view (`?tab=`): Overview (metadata, actions), Dependencies (list + detail modal), Dependent Reports (table), Poll Issues. Inline override editing (team lead+/admin), manual poll, inline alias management (admin) |
| `/teams` | TeamsList | Protected | Team listing with counts |
-| `/teams/:id` | TeamDetail | Protected | Members, roles, owned services |
+| `/teams/:id` | TeamDetail | Protected | Tabbed view (`?tab=`): Overview (info, edit/delete), Members (add/remove/promote), Manifests (ManifestStatusCard), Services (list), Alerts Config (channels, rules, mutes, history) |
| `/graph` | DependencyGraph | Protected | Interactive React Flow visualization |
| `/associations` | Associations | Protected | Manual association creation, aliases, canonical override management (team lead+/admin) |
| `/wallboard` | Wallboard | Protected | Full-screen status board with dependency detail panel showing resolved contact info and impact with override indicators |
@@ -71,8 +71,66 @@ All API modules follow a consistent pattern:
| `useAliases` | Global alias management — CRUD + canonical names list. |
| `useCanonicalOverrides` | Canonical override CRUD — load all, save (upsert), remove, lookup by canonical name. |
| `useAlertRules` | Loads alert rules for a team. Handles save (upsert) with dirty tracking. Exposes `rules`, `save`, `error`, `isSaving`. |
+| `useManifestConfig` | Loads/saves manifest configuration for a team. Handles toggle, sync trigger, sync result state. |
+| `useSyncHistory` | Loads paginated sync history for a team manifest. Supports load-more. |
+| `useDriftReview` | Loads drift flags for a team. Handles accept, dismiss, reopen, and bulk actions. |
-## 10.5 Client-Side Storage
+## 10.5 Common Components
+
+### Tabs
+
+Reusable tabbed navigation component (`client/src/components/common/Tabs.tsx`).
+
+```tsx
+
+
+ Overview
+ Members
+
+ ...
+ ...
+
+```
+
+- Tab state is reflected in URL search params (`?tab=members`) for linkability
+- Falls back to `localStorage` persistence via `storageKey` prop
+- Active tab indicator uses a 2px accent-colored bottom border
+- Used by TeamDetail and ServiceDetail pages
+
+### DependencyDetailModal
+
+Modal for viewing dependency details from the ServiceDetail Dependencies tab (`client/src/components/pages/Services/DependencyDetailModal.tsx`). Shows health status, latency chart, contact info, and override section.
+
+## 10.6 Design Token System
+
+The client uses a structured CSS custom property system defined in `client/src/index.css`. All component styles reference these tokens — no hardcoded color, spacing, or timing values.
+
+**Typography:** Inter (body + headings) loaded from Google Fonts with `font-display: swap`. Monospace stack for code/data values.
+
+**Token categories:**
+- **Spacing** (theme-independent): `--space-1` (4px) through `--space-8` (64px), 8px base grid
+- **Typography** (theme-independent): `--font-xs` through `--font-2xl`, weight tokens (`--font-normal`, `--font-medium`, `--font-semibold`)
+- **Border radius** (theme-independent): `--radius-sm` (4px), `--radius-md` (6px), `--radius-lg` (8px)
+- **Transitions** (theme-independent): `--duration-fast` (150ms), `--duration-normal` (200ms), `--duration-slow` (300ms)
+- **Surface colors** (theme-dependent): `--color-bg`, `--color-surface`, `--color-surface-hover`, `--color-border`, `--color-border-subtle`
+- **Text colors** (theme-dependent): `--color-text`, `--color-text-secondary`, `--color-text-muted`
+- **Status colors** (same for both themes): `--color-healthy`, `--color-warning`, `--color-critical`, `--color-unknown`
+- **Accent** (same for both themes): `--color-accent`, `--color-accent-hover`
+- **Shadows** (theme-dependent): `--shadow-sm`, `--shadow-md`, `--shadow-lg`
+
+**Status badge pattern:** Uses `color-mix()` for theme-adaptive tinted backgrounds:
+```css
+.healthy {
+ background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent);
+ color: var(--color-healthy);
+}
+```
+
+**Shared CSS module classes:** `client/src/styles/shared.module.css` provides composable base classes (`.card`, `.buttonPrimary`, `.buttonGhost`, `.input`, `.tableRow`, etc.) used via CSS Modules `composes:` syntax.
+
+**Icons:** All icons use Lucide React SVG components — no emoji or inline SVG.
+
+## 10.7 Client-Side Storage
| Key | Scope | Content |
|---|---|---|
@@ -84,8 +142,10 @@ All API modules follow a consistent pattern:
| `{page}-refresh-interval` | Per page | Interval in ms |
| `wallboard-team-filter` | Wallboard | Selected team ID |
| `wallboard-unhealthy-only` | Wallboard | `'true'` or `'false'` |
+| `team-{id}-tab` | Per team | Last active tab on TeamDetail |
+| `service-{id}-tab` | Per service | Last active tab on ServiceDetail |
-## 10.6 High Latency Detection
+## 10.8 High Latency Detection
The dependency graph automatically flags edges as "high latency" using an adaptive threshold with an absolute floor. No user configuration is required.
diff --git a/package-lock.json b/package-lock.json
index 83c53a5..288219f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "depsera",
- "version": "1.11.0",
+ "version": "1.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "depsera",
- "version": "1.11.0",
+ "version": "1.11.1",
"license": "MIT",
"devDependencies": {
"concurrently": "^8.2.2",
diff --git a/package.json b/package.json
index bcdcc14..5982975 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "depsera",
- "version": "1.11.0",
+ "version": "1.11.1",
"description": "Dependency monitoring and service health dashboard",
"scripts": {
"dev": "concurrently --kill-others \"npm run dev:server\" \"npm run dev:client\"",