diff --git a/README.md b/README.md index 75929ca..d4d3435 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ For detailed deployment options (bare Node.js, reverse proxy, backups), see the ## Tech Stack -- **Frontend:** React 18, TypeScript, Vite, CSS Modules, React Flow, Recharts +- **Frontend:** React 18, TypeScript, Vite, CSS Modules (design tokens + `color-mix()`), Lucide React icons, Inter font, React Flow, Recharts - **Backend:** Express.js, TypeScript, SQLite (better-sqlite3) - **Authentication:** OpenID Connect (openid-client) or local auth (bcryptjs) - **Testing:** Jest, React Testing Library diff --git a/client/index.html b/client/index.html index 10496ed..8c46668 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,9 @@ + + + Depsera diff --git a/client/package-lock.json b/client/package-lock.json index 696ad46..76d1c4e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "@xyflow/react": "^12.10.0", "dagre": "^0.8.5", "elkjs": "^0.11.0", + "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", @@ -7395,6 +7396,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/client/package.json b/client/package.json index 7436bb0..e303d87 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "@xyflow/react": "^12.10.0", "dagre": "^0.8.5", "elkjs": "^0.11.0", + "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", diff --git a/client/src/components/Charts/HealthTimeline.module.css b/client/src/components/Charts/HealthTimeline.module.css index e521551..2cde12f 100644 --- a/client/src/components/Charts/HealthTimeline.module.css +++ b/client/src/components/Charts/HealthTimeline.module.css @@ -65,7 +65,7 @@ color: var(--color-accent); font-size: 12px; cursor: pointer; - transition: all 0.15s; + transition: all var(--duration-fast); } .retryButton:hover { @@ -88,7 +88,7 @@ position: relative; min-width: 2px; cursor: pointer; - transition: opacity 0.15s; + transition: opacity var(--duration-fast); } .segment:hover { diff --git a/client/src/components/Charts/LatencyChart.module.css b/client/src/components/Charts/LatencyChart.module.css index 329b7aa..84662cf 100644 --- a/client/src/components/Charts/LatencyChart.module.css +++ b/client/src/components/Charts/LatencyChart.module.css @@ -65,7 +65,7 @@ color: var(--color-accent); font-size: 12px; cursor: pointer; - transition: all 0.15s; + transition: all var(--duration-fast); } .retryButton:hover { diff --git a/client/src/components/Charts/TimeRangeSelector.module.css b/client/src/components/Charts/TimeRangeSelector.module.css index 339c652..09a21e2 100644 --- a/client/src/components/Charts/TimeRangeSelector.module.css +++ b/client/src/components/Charts/TimeRangeSelector.module.css @@ -15,7 +15,7 @@ font-size: 12px; font-weight: 500; cursor: pointer; - transition: all 0.15s; + transition: all var(--duration-fast); line-height: 1.4; } diff --git a/client/src/components/Layout/Footer.module.css b/client/src/components/Layout/Footer.module.css new file mode 100644 index 0000000..e4e2999 --- /dev/null +++ b/client/src/components/Layout/Footer.module.css @@ -0,0 +1,12 @@ +.footer { + height: 28px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-4); + border-top: 1px solid var(--color-border-subtle); + background-color: var(--color-surface); + font-size: var(--font-xs); + color: var(--color-text-muted); + flex-shrink: 0; +} diff --git a/client/src/components/Layout/Footer.tsx b/client/src/components/Layout/Footer.tsx new file mode 100644 index 0000000..0aebb0a --- /dev/null +++ b/client/src/components/Layout/Footer.tsx @@ -0,0 +1,11 @@ +import styles from './Footer.module.css'; + +function Footer() { + return ( + + ); +} + +export default Footer; diff --git a/client/src/components/Layout/Header.module.css b/client/src/components/Layout/Header.module.css new file mode 100644 index 0000000..7d56c01 --- /dev/null +++ b/client/src/components/Layout/Header.module.css @@ -0,0 +1,227 @@ +.header { + height: 48px; + background-color: var(--color-surface); + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 var(--space-4); + position: sticky; + top: 0; + z-index: 100; + flex-shrink: 0; +} + +.headerLeft { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.menuButton { + display: none; + background: none; + border: none; + padding: var(--space-1); + color: var(--color-text-secondary); + transition: color var(--duration-fast) ease; +} + +.menuButton:hover { + color: var(--color-text); +} + +.menuIcon { + display: block; + width: 20px; + height: 2px; + background-color: currentColor; + position: relative; +} + +.menuIcon::before, +.menuIcon::after { + content: ''; + position: absolute; + width: 20px; + height: 2px; + background-color: currentColor; + left: 0; +} + +.menuIcon::before { + top: -6px; +} + +.menuIcon::after { + top: 6px; +} + +.logo { + width: 32px; + height: 32px; +} + +.titleImage { + height: 18px; + width: auto; +} + +.headerRight { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.userInfo { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.userName { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); +} + +.userRole { + font-size: var(--font-xs); + color: var(--color-text-muted); + background-color: var(--color-surface-hover); + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + text-transform: capitalize; +} + +/* Theme Toggle — pill-shaped */ +.themeToggle { + display: flex; + align-items: center; + width: 56px; + height: 28px; + padding: 2px; + background-color: var(--color-surface-hover); + border: 1px solid var(--color-border); + border-radius: 14px; + cursor: pointer; + position: relative; + transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease; +} + +.themeToggle:hover { + border-color: var(--color-accent); +} + +.themeToggleTrack { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + position: relative; +} + +.themeToggleIcon { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + z-index: 1; + color: var(--color-text-muted); + transition: color var(--duration-normal) ease; +} + +.themeToggleIcon.active { + color: var(--color-accent); +} + +.themeToggleIndicator { + position: absolute; + width: 22px; + height: 22px; + border-radius: 50%; + background-color: var(--color-surface); + box-shadow: var(--shadow-sm); + transition: transform var(--duration-normal) ease; + top: 1px; + left: 1px; +} + +.themeToggleIndicator.dark { + transform: translateX(28px); +} + +.themeIcon { + width: 14px; + height: 14px; +} + +/* Logout Button */ +.logoutButton { + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-1) 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; +} + +.logoutButton:hover { + background-color: var(--color-surface-hover); + color: var(--color-text); +} + +/* Responsive */ +@media (max-width: 768px) { + .menuButton { + display: flex; + align-items: center; + justify-content: center; + } + + .userName { + display: none; + } + + .userRole { + display: none; + } + + .headerRight { + gap: var(--space-2); + } + + .logoutButton { + padding: var(--space-1) var(--space-2); + } + + .themeToggle { + width: 48px; + height: 24px; + } + + .themeToggleIcon { + width: 18px; + height: 18px; + } + + .themeToggleIndicator { + width: 18px; + height: 18px; + } + + .themeToggleIndicator.dark { + transform: translateX(24px); + } + + .themeIcon { + width: 12px; + height: 12px; + } +} diff --git a/client/src/components/Layout/Header.tsx b/client/src/components/Layout/Header.tsx new file mode 100644 index 0000000..b68ef8b --- /dev/null +++ b/client/src/components/Layout/Header.tsx @@ -0,0 +1,57 @@ +import { Sun, Moon, LogOut } from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; +import { useTheme } from '../../contexts/ThemeContext'; +import styles from './Header.module.css'; + +interface HeaderProps { + onToggleSidebar: () => void; + onLogout: () => void; +} + +function Header({ onToggleSidebar, onLogout }: HeaderProps) { + const { user } = useAuth(); + const { theme, toggleTheme } = useTheme(); + + return ( +
+
+ + + Depsera +
+
+
+ {user?.name} + {user?.role} +
+ + +
+
+ ); +} + +export default Header; diff --git a/client/src/components/Layout/Layout.module.css b/client/src/components/Layout/Layout.module.css index 238e03d..3e18d28 100644 --- a/client/src/components/Layout/Layout.module.css +++ b/client/src/components/Layout/Layout.module.css @@ -1,138 +1,9 @@ .layout { - --app-header-height: 3.75rem; min-height: 100vh; display: flex; flex-direction: column; } -/* Header */ -.header { - background-color: var(--color-primary); - color: var(--color-text-inverse); - padding: 0.2rem; - box-shadow: var(--shadow-md); - display: flex; - justify-content: space-between; - align-items: center; - position: sticky; - top: 0; - z-index: 100; -} - -.headerLeft { - display: flex; - align-items: center; - gap: 1rem; -} - -.menuButton { - display: none; - background: none; - border: none; - padding: 0.5rem; - cursor: pointer; -} - -.menuIcon { - display: block; - width: 24px; - height: 2px; - background-color: var(--color-text-inverse); - position: relative; -} - -.menuIcon::before, -.menuIcon::after { - content: ''; - position: absolute; - width: 24px; - height: 2px; - background-color: var(--color-text-inverse); - left: 0; -} - -.menuIcon::before { - top: -7px; -} - -.menuIcon::after { - top: 7px; -} - -.logo { - width: 42px; - height: 42px; -} - -.titleImage { - height: 24px; - width: auto; -} - -.title { - font-size: 1.25rem; - font-weight: 600; -} - -.headerRight { - display: flex; - align-items: center; - gap: 1rem; -} - -.userName { - font-weight: 500; -} - -.userRole { - font-size: 0.75rem; - background-color: rgba(255, 255, 255, 0.15); - padding: 0.25rem 0.5rem; - border-radius: 4px; - text-transform: capitalize; -} - -/* Theme Toggle */ -.themeToggle { - display: flex; - align-items: center; - justify-content: center; - width: 2.25rem; - height: 2.25rem; - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 0.375rem; - cursor: pointer; - transition: background-color 0.2s, border-color 0.2s; - color: var(--color-text-inverse); -} - -.themeToggle:hover { - background-color: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.3); -} - -.themeIcon { - width: 1.25rem; - height: 1.25rem; -} - -.logoutButton { - background-color: transparent; - color: var(--color-text-inverse); - border: 1px solid rgba(255, 255, 255, 0.3); - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - font-size: 0.875rem; - transition: background-color 0.2s, border-color 0.2s; -} - -.logoutButton:hover { - background-color: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.5); -} - /* Container with sidebar and main */ .container { flex: 1; @@ -146,7 +17,7 @@ background-color: var(--color-bg-sidebar); border-right: 1px solid var(--color-border-light); flex-shrink: 0; - transition: width 0.2s ease-in-out, background-color 0.2s, border-color 0.2s; + transition: width var(--duration-normal) ease-in-out, background-color var(--duration-normal), border-color var(--duration-normal); position: relative; display: flex; flex-direction: column; @@ -170,7 +41,7 @@ align-items: center; justify-content: center; z-index: 10; - transition: background-color 0.2s, border-color 0.2s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); box-shadow: var(--shadow-sm); } @@ -182,7 +53,7 @@ width: 14px; height: 14px; color: var(--color-text-muted); - transition: transform 0.2s; + transition: transform var(--duration-normal); } .collapseIcon.collapsed { @@ -204,7 +75,7 @@ color: var(--color-text-muted); text-decoration: none; font-weight: 500; - transition: background-color 0.2s, color 0.2s; + transition: background-color var(--duration-fast), color var(--duration-fast); white-space: nowrap; overflow: hidden; } @@ -215,7 +86,7 @@ } .navLinkText { - transition: opacity 0.2s; + transition: opacity var(--duration-fast); } .sidebarCollapsed .navLinkText { @@ -273,20 +144,11 @@ width: 100%; overflow: auto; background-color: var(--color-bg-page); - transition: background-color 0.2s; + transition: background-color var(--duration-normal); display: flex; flex-direction: column; } -/* Footer */ -.footer { - background-color: var(--color-primary); - color: var(--color-text-inverse); - padding: 1rem 2rem; - text-align: center; - font-size: 0.875rem; -} - /* Mobile overlay */ .overlay { display: none; @@ -294,18 +156,6 @@ /* Responsive styles */ @media (max-width: 768px) { - .menuButton { - display: block; - } - - .title { - font-size: 1rem; - } - - .userName { - display: none; - } - .sidebar { position: fixed; top: 0; @@ -313,7 +163,7 @@ height: 100vh; z-index: 200; transform: translateX(-100%); - transition: transform 0.3s ease-in-out; + transition: transform var(--duration-slow) ease-in-out; padding-top: 60px; } @@ -328,37 +178,4 @@ background-color: rgba(0, 0, 0, 0.5); z-index: 150; } - - .main { - padding: 0; - } - - .headerRight { - gap: 0.5rem; - } - - .logoutButton { - padding: 0.375rem 0.75rem; - font-size: 0.8rem; - } - - .themeToggle { - width: 2rem; - height: 2rem; - } - - .themeIcon { - width: 1rem; - height: 1rem; - } -} - -@media (max-width: 480px) { - .header { - padding: 0.75rem 1rem; - } - - .userRole { - display: none; - } } diff --git a/client/src/components/Layout/Layout.test.tsx b/client/src/components/Layout/Layout.test.tsx index 9569faf..5006421 100644 --- a/client/src/components/Layout/Layout.test.tsx +++ b/client/src/components/Layout/Layout.test.tsx @@ -4,6 +4,16 @@ import Layout from './Layout'; import { AuthProvider } from './../../contexts/AuthContext'; import { ThemeProvider } from './../../contexts/ThemeContext'; +// Mock lucide-react icons +jest.mock('lucide-react', () => ({ + Sun: (props: Record) => , + Moon: (props: Record) => , + LogOut: (props: Record) => , +})); + +// Mock __APP_VERSION__ +(globalThis as Record).__APP_VERSION__ = '1.0.0'; + const mockFetch = jest.fn(); global.fetch = mockFetch; @@ -253,7 +263,20 @@ describe('Layout', () => { expect(await screen.findByText('Dashboard Content')).toBeInTheDocument(); }); - it('renders footer with copyright', async () => { + it('renders footer with version', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: '1', name: 'Test User', role: 'user' }), + }); + + renderLayout(); + + await screen.findByText('Test User'); + + expect(screen.getByText('Depsera v1.0.0')).toBeInTheDocument(); + }); + + it('renders theme toggle with sun and moon icons', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: '1', name: 'Test User', role: 'user' }), @@ -263,8 +286,8 @@ describe('Layout', () => { await screen.findByText('Test User'); - const year = new Date().getFullYear(); - expect(screen.getByText(`© ${year} Depsera`)).toBeInTheDocument(); + expect(screen.getByTestId('sun-icon')).toBeInTheDocument(); + expect(screen.getByTestId('moon-icon')).toBeInTheDocument(); }); it('handles logout', async () => { @@ -282,7 +305,8 @@ describe('Layout', () => { await screen.findByText('Test User'); - fireEvent.click(screen.getByText('Logout')); + const logoutButton = screen.getByTestId('logout-icon').closest('button'); + fireEvent.click(logoutButton!); await waitFor(() => { expect(mockLocation.href).toBe('/login'); diff --git a/client/src/components/Layout/Layout.tsx b/client/src/components/Layout/Layout.tsx index 82e19f0..23bf3b6 100644 --- a/client/src/components/Layout/Layout.tsx +++ b/client/src/components/Layout/Layout.tsx @@ -1,14 +1,14 @@ import { useState } from 'react'; import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { useTheme } from '../../contexts/ThemeContext'; +import Header from './Header'; +import Footer from './Footer'; import styles from './Layout.module.css'; const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed'; function Layout() { - const { user, isAdmin, logout } = useAuth(); - const { theme, toggleTheme } = useTheme(); + const { isAdmin, logout } = useAuth(); const navigate = useNavigate(); const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { @@ -37,51 +37,7 @@ function Layout() { return (
-
-
- - - Depsera - {/*

Depsera

*/} -
-
- {user?.name} - {user?.role} - - -
-
+
{sidebarOpen && ( @@ -273,9 +229,7 @@ function Layout() {
-
-

© {new Date().getFullYear()} Depsera

-
+
); } diff --git a/client/src/components/Login/Login.module.css b/client/src/components/Login/Login.module.css index a5e38dd..a0642f0 100644 --- a/client/src/components/Login/Login.module.css +++ b/client/src/components/Login/Login.module.css @@ -3,126 +3,124 @@ display: flex; align-items: center; justify-content: center; - background-color: var(--color-bg-page); - padding: 1rem; + background-color: var(--color-bg); + padding: var(--space-4); } .card { - background: var(--color-bg-card); - border-radius: 8px; - box-shadow: var(--shadow-md); - padding: 2rem; + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); + padding: var(--space-6); width: 100%; max-width: 400px; - border: 1px solid var(--color-border); } .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); text-align: center; - margin-bottom: 0.5rem; + margin: 0 0 var(--space-1) 0; } .subtitle { - color: var(--color-text-secondary); + color: var(--color-text-muted); text-align: center; - margin-bottom: 2rem; + font-size: var(--font-base); + margin: 0 0 var(--space-6) 0; } .form { display: flex; flex-direction: column; - gap: 1.25rem; + gap: var(--space-4); } .field { display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--space-1); } .label { - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); } .input { - padding: 0.75rem; - border: 1px solid var(--color-border-input); - border-radius: 4px; - font-size: 1rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.2s, box-shadow 0.2s; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-base); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease; } .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); } .input:disabled { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); cursor: not-allowed; + opacity: 0.7; } .button { - padding: 0.75rem; - background-color: var(--color-primary); - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-4); + background-color: var(--color-accent); + color: #fff; border: none; - border-radius: 4px; - font-size: 1rem; - font-weight: 500; + border-radius: var(--radius-sm); + font-size: var(--font-base); + font-weight: var(--font-medium); cursor: pointer; - transition: background-color 0.2s; - margin-top: 0.5rem; + transition: background-color var(--duration-fast) ease; + margin-top: var(--space-2); + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); } .button:hover:not(:disabled) { - background-color: var(--color-primary-hover); + background-color: var(--color-accent-hover); } .button:disabled { - background-color: var(--color-text-muted); + opacity: 0.6; cursor: not-allowed; } .error { - background-color: var(--color-error-bg); - border: 1px solid var(--color-error-border); - color: var(--color-error); - padding: 0.75rem; - border-radius: 4px; - font-size: 0.875rem; -} - -.hint { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--color-border); - font-size: 0.75rem; - color: var(--color-text-muted); -} - -.hint p { - margin-bottom: 0.5rem; + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-critical) 30%, transparent); + color: var(--color-critical); + padding: var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--font-sm); + margin-bottom: var(--space-4); } -.hint ul { - list-style: none; - padding-left: 0; +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + color: var(--color-text-muted); + font-size: var(--font-sm); } -.hint li { - padding: 0.25rem 0; +@keyframes spin { + to { transform: rotate(360deg); } } -.hintNote { - font-style: italic; - margin-top: 0.5rem; +.spinner { + animation: spin 1s linear infinite; } diff --git a/client/src/components/Login/Login.tsx b/client/src/components/Login/Login.tsx index ba0d92e..ab05989 100644 --- a/client/src/components/Login/Login.tsx +++ b/client/src/components/Login/Login.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, FormEvent } from 'react'; +import { Loader2 } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { fetchAuthMode, localLogin, AuthMode } from '../../api/auth'; @@ -51,7 +52,10 @@ function Login() { return (
-

Loading...

+
+ + Loading... +
); @@ -110,6 +114,7 @@ function Login() { type="submit" disabled={isSubmitting} > + {isSubmitting && } {isSubmitting ? 'Signing in...' : 'Sign In'} diff --git a/client/src/components/NotFound/NotFound.module.css b/client/src/components/NotFound/NotFound.module.css index ad88e49..0d2cd7f 100644 --- a/client/src/components/NotFound/NotFound.module.css +++ b/client/src/components/NotFound/NotFound.module.css @@ -3,7 +3,7 @@ align-items: center; justify-content: center; min-height: 60vh; - padding: 2rem; + padding: var(--space-6); } .content { @@ -13,35 +13,41 @@ .code { font-size: 6rem; font-weight: 700; - color: var(--color-text-heading); - margin-bottom: 0.5rem; + color: var(--color-text); + margin: 0 0 var(--space-2) 0; line-height: 1; } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-primary); - margin-bottom: 1rem; + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0 0 var(--space-3) 0; } .message { - color: var(--color-text-secondary); - margin-bottom: 2rem; + color: var(--color-text-muted); + font-size: var(--font-base); + margin: 0 auto var(--space-6) auto; max-width: 400px; } .link { - display: inline-block; - padding: 0.75rem 1.5rem; - background-color: var(--color-primary); - color: var(--color-text-inverse); + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + background: transparent; + color: var(--color-text-secondary); text-decoration: none; - border-radius: 4px; - font-weight: 500; - transition: background-color 0.2s; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-sm); + font-weight: var(--font-medium); + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .link:hover { - background-color: var(--color-primary-hover); + background-color: var(--color-surface-hover); + color: var(--color-text); } diff --git a/client/src/components/NotFound/NotFound.tsx b/client/src/components/NotFound/NotFound.tsx index fa0f14d..f132168 100644 --- a/client/src/components/NotFound/NotFound.tsx +++ b/client/src/components/NotFound/NotFound.tsx @@ -1,3 +1,4 @@ +import { ArrowLeft } from 'lucide-react'; import { Link } from 'react-router-dom'; import styles from './NotFound.module.css'; @@ -11,6 +12,7 @@ function NotFound() { The page you are looking for does not exist or has been moved.

+ Go to Dashboard diff --git a/client/src/components/common/ConfirmDialog.module.css b/client/src/components/common/ConfirmDialog.module.css index e8c16f4..1096beb 100644 --- a/client/src/components/common/ConfirmDialog.module.css +++ b/client/src/components/common/ConfirmDialog.module.css @@ -1,36 +1,37 @@ .content { display: flex; flex-direction: column; - gap: 1.5rem; + gap: var(--space-5); } .message { color: var(--color-text-secondary); margin: 0; - line-height: 1.5; + line-height: var(--line-height-normal); + font-size: var(--font-base); } .actions { display: flex; justify-content: flex-end; - gap: 0.75rem; + gap: var(--space-3); } .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; + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + color: var(--color-text-secondary); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .cancelButton:hover:not(:disabled) { - background-color: var(--color-bg-hover); - border-color: var(--color-text-muted); + background-color: var(--color-surface-hover); + color: var(--color-text); } .cancelButton:disabled { @@ -39,19 +40,19 @@ } .confirmButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); - background-color: var(--color-accent); - border: 1px solid transparent; - border-radius: 0.375rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + color: #fff; + background: var(--color-accent); + border: none; + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease; } .confirmButton:hover:not(:disabled) { - background-color: var(--color-accent-hover); + background: var(--color-accent-hover); } .confirmButton:disabled { @@ -60,13 +61,9 @@ } .confirmButton.destructive { - background-color: var(--color-error); + background: var(--color-critical); } .confirmButton.destructive:hover:not(:disabled) { - background-color: #dc2626; -} - -[data-theme="dark"] .confirmButton.destructive:hover:not(:disabled) { - background-color: #b91c1c; + background: color-mix(in srgb, var(--color-critical) 85%, black); } diff --git a/client/src/components/common/ConfirmDialog.tsx b/client/src/components/common/ConfirmDialog.tsx index 1cda42e..d86a2fa 100644 --- a/client/src/components/common/ConfirmDialog.tsx +++ b/client/src/components/common/ConfirmDialog.tsx @@ -25,7 +25,7 @@ function ConfirmDialog({ isLoading = false, }: ConfirmDialogProps) { return ( - +

{message}

diff --git a/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css b/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css index fe777f1..b8b9f0d 100644 --- a/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css +++ b/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css @@ -20,7 +20,7 @@ color: var(--color-text-primary); cursor: pointer; border-radius: 8px; - transition: all 0.15s; + transition: all var(--duration-fast); flex-shrink: 0; } @@ -100,7 +100,7 @@ font-size: 13px; font-weight: 500; cursor: pointer; - transition: background 0.15s; + transition: background var(--duration-fast); } .retryButton:hover { @@ -108,7 +108,7 @@ } .emptyState svg { - color: #10b981; + color: var(--color-healthy); } .emptyState p { @@ -194,7 +194,7 @@ } .timelineItem.recovery .timelineDot { - background: #10b981; + background: var(--color-healthy); } .timelineContent { @@ -230,13 +230,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/common/Modal.module.css b/client/src/components/common/Modal.module.css index 61d0dfa..19b5de3 100644 --- a/client/src/components/common/Modal.module.css +++ b/client/src/components/common/Modal.module.css @@ -1,6 +1,8 @@ +/* --- Modal dialog --- */ + .modal { border: none; - border-radius: 0.5rem; + border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); max-height: 85vh; @@ -8,30 +10,55 @@ position: fixed; top: 50%; left: 50%; - transform: translate(-50%, -50%); margin: 0; - background-color: var(--color-bg-card); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + z-index: var(--z-modal); + + /* Open animation */ + animation: modalIn var(--duration-normal) ease; +} + +@keyframes modalIn { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 8px)); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } +} + +/* Final resting position (animation fill-mode not needed — these are the defaults) */ +.modal { + transform: translate(-50%, -50%); } .modal::backdrop { background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); } -.small { - width: 24rem; +/* --- Size variants --- */ + +.sm { + width: 400px; max-width: 90vw; } -.medium { - width: 32rem; +.md { + width: 560px; max-width: 90vw; } -.large { - width: 48rem; +.lg { + width: 720px; max-width: 90vw; } +/* --- Content layout --- */ + .content { display: flex; flex-direction: column; @@ -42,43 +69,52 @@ display: flex; align-items: center; justify-content: space-between; - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4) var(--space-5); } .title { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; + line-height: var(--line-height-tight); } .closeButton { display: flex; align-items: center; justify-content: center; - width: 2rem; - height: 2rem; + width: 28px; + height: 28px; border: none; background: none; - border-radius: 0.375rem; + border-radius: var(--radius-sm); color: var(--color-text-muted); cursor: pointer; - transition: background-color 0.15s, color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; + flex-shrink: 0; } .closeButton:hover { - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background-color: var(--color-surface-hover); + color: var(--color-text); } -.closeButton:focus { +.closeButton:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } .body { - padding: 1.5rem; + padding: 0 var(--space-5) var(--space-5); overflow-y: auto; - color: var(--color-text-primary); + color: var(--color-text); +} + +/* --- Reduced motion --- */ + +@media (prefers-reduced-motion: reduce) { + .modal { + animation: none; + } } diff --git a/client/src/components/common/Modal.test.tsx b/client/src/components/common/Modal.test.tsx index 6b22c9d..f05b736 100644 --- a/client/src/components/common/Modal.test.tsx +++ b/client/src/components/common/Modal.test.tsx @@ -103,15 +103,26 @@ describe('Modal', () => { expect(onClose).toHaveBeenCalled(); }); - it('applies size class', () => { + it('defaults to md size class', () => { render( - {}} title="Test Modal" size="large"> + {}} title="Test Modal"> +

Modal content

+
+ ); + + const dialog = screen.getByRole('dialog', { hidden: true }); + expect(dialog.className).toContain('md'); + }); + + it.each(['sm', 'md', 'lg'] as const)('applies %s size class', (size) => { + render( + {}} title="Test Modal" size={size}>

Modal content

); const dialog = screen.getByRole('dialog', { hidden: true }); - expect(dialog.className).toContain('large'); + expect(dialog.className).toContain(size); }); it('calls close when transitioning from open to closed', () => { diff --git a/client/src/components/common/Modal.tsx b/client/src/components/common/Modal.tsx index 6d2d799..8ad32d5 100644 --- a/client/src/components/common/Modal.tsx +++ b/client/src/components/common/Modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, type ReactNode } from 'react'; +import { X } from 'lucide-react'; import styles from './Modal.module.css'; interface ModalProps { @@ -6,10 +7,10 @@ interface ModalProps { onClose: () => void; title: string; children: ReactNode; - size?: 'small' | 'medium' | 'large'; + size?: 'sm' | 'md' | 'lg'; } -function Modal({ isOpen, onClose, title, children, size = 'medium' }: ModalProps) { +function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) { const dialogRef = useRef(null); const previousFocusRef = useRef(null); @@ -53,7 +54,6 @@ function Modal({ isOpen, onClose, title, children, size = 'medium' }: ModalProps return ( - - - +
{children}
diff --git a/client/src/components/common/SearchableSelect.module.css b/client/src/components/common/SearchableSelect.module.css index 2c30abd..69ab72f 100644 --- a/client/src/components/common/SearchableSelect.module.css +++ b/client/src/components/common/SearchableSelect.module.css @@ -2,12 +2,12 @@ position: relative; display: flex; flex-direction: column; - gap: 0.375rem; + gap: var(--space-1); } .label { - font-size: 0.8125rem; - font-weight: 600; + font-size: var(--font-sm); + font-weight: var(--font-semibold); color: var(--color-text-muted); } @@ -16,21 +16,21 @@ align-items: center; justify-content: space-between; width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - background-color: var(--color-bg-input); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - color: var(--color-text-primary); + color: var(--color-text); text-align: left; - transition: border-color 0.15s; + transition: border-color var(--duration-fast) ease; } .trigger: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); } .selectedText { @@ -44,8 +44,8 @@ } .chevron { - width: 1.25rem; - height: 1.25rem; + width: 1rem; + height: 1rem; flex-shrink: 0; color: var(--color-text-muted); } @@ -55,30 +55,31 @@ top: 100%; left: 0; right: 0; - z-index: 50; - margin-top: 0.25rem; - background-color: var(--color-bg-card); + z-index: var(--z-dropdown); + margin-top: var(--space-1); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); max-height: 300px; display: flex; flex-direction: column; } .searchWrapper { - padding: 0.5rem; - border-bottom: 1px solid var(--color-border); + padding: var(--space-2); + border-bottom: 1px solid var(--color-border-subtle); } .searchInput { width: 100%; - padding: 0.375rem 0.5rem; - font-size: 0.8125rem; - border: 1px solid var(--color-border-input); - border-radius: 0.25rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-1) var(--space-2); + font-size: var(--font-sm); + 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 { @@ -89,19 +90,20 @@ .clearButton { display: block; width: 100%; - padding: 0.375rem 0.75rem; - font-size: 0.75rem; + padding: var(--space-1) var(--space-3); + font-size: var(--font-xs); color: var(--color-text-muted); background: none; border: none; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border-subtle); cursor: pointer; text-align: left; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .clearButton:hover { - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background-color: var(--color-surface-hover); + color: var(--color-text); } .optionsList { @@ -110,39 +112,40 @@ } .groupLabel { - padding: 0.375rem 0.75rem; - font-size: 0.6875rem; - font-weight: 600; + padding: var(--space-1) var(--space-3); + 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); } .option { display: block; width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); text-align: left; background: none; border: none; cursor: pointer; - color: var(--color-text-primary); + color: var(--color-text); + transition: background-color var(--duration-fast) ease; } .option:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .optionSelected { - background-color: rgba(59, 130, 246, 0.08); - font-weight: 500; + background-color: color-mix(in srgb, var(--color-accent) 8%, transparent); + font-weight: var(--font-medium); } .noResults { - padding: 1rem; + padding: var(--space-4); text-align: center; color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } diff --git a/client/src/components/common/SearchableSelect.tsx b/client/src/components/common/SearchableSelect.tsx index 3636a02..6fbb37e 100644 --- a/client/src/components/common/SearchableSelect.tsx +++ b/client/src/components/common/SearchableSelect.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +import { ChevronDown } from 'lucide-react'; import styles from './SearchableSelect.module.css'; export interface SelectOption { @@ -96,9 +97,7 @@ function SearchableSelect({ {value ? selectedLabel : placeholder} - - - + {isOpen && (
diff --git a/client/src/components/common/StatusBadge.module.css b/client/src/components/common/StatusBadge.module.css index 143a814..4b5f295 100644 --- a/client/src/components/common/StatusBadge.module.css +++ b/client/src/components/common/StatusBadge.module.css @@ -1,21 +1,21 @@ .badge { display: inline-flex; align-items: center; - gap: 0.375rem; - font-weight: 500; + gap: var(--space-1); + font-weight: var(--font-medium); border-radius: 9999px; white-space: nowrap; } .medium { - padding: 0.25rem 0.75rem; - font-size: 0.875rem; + padding: var(--space-1) var(--space-3); + font-size: var(--font-base); } .small { - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - gap: 0.25rem; + padding: 2px var(--space-2); + font-size: var(--font-xs); + gap: 3px; } .dot { @@ -31,61 +31,34 @@ } .healthy { - background-color: #dcfce7; - color: #166534; -} - -[data-theme="dark"] .healthy { - background-color: #14532d; - color: #86efac; + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); } .healthy .dot { - background-color: #22c55e; -} - -[data-theme="dark"] .healthy .dot { - background-color: #34d399; + background-color: var(--color-healthy); } .warning { - background-color: #fef3c7; - color: #92400e; -} - -[data-theme="dark"] .warning { - background-color: #451a03; - color: #fde68a; + background-color: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); } .warning .dot { - background-color: #f59e0b; -} - -[data-theme="dark"] .warning .dot { - background-color: #fbbf24; + background-color: var(--color-warning); } .critical { - background-color: #fee2e2; - color: #991b1b; -} - -[data-theme="dark"] .critical { - background-color: #450a0a; - color: #fca5a5; + background-color: color-mix(in srgb, var(--color-critical) 15%, transparent); + color: var(--color-critical); } .critical .dot { - background-color: #ef4444; -} - -[data-theme="dark"] .critical .dot { - background-color: #f87171; + background-color: var(--color-critical); } .unknown { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); color: var(--color-text-muted); } diff --git a/client/src/components/common/SummaryCards.module.css b/client/src/components/common/SummaryCards.module.css new file mode 100644 index 0000000..2667020 --- /dev/null +++ b/client/src/components/common/SummaryCards.module.css @@ -0,0 +1,174 @@ +/* ============================================================= + SummaryCards — Shared summary card & health bar primitives + Used by Dashboard, TeamOverviewStats, and other stat displays + ============================================================= */ + +/* --- Summary Cards Grid --- */ + +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-4); +} + +.summaryCard { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); + border-left: 3px solid var(--color-unknown); + transition: background-color var(--duration-fast) ease; +} + +.summaryCard:hover { + background: var(--color-surface-hover); +} + +.summaryCardAccent { + composes: summaryCard; + border-left-color: var(--color-accent); +} + +.summaryCardHealthy { + composes: summaryCard; + border-left-color: var(--color-healthy); +} + +.summaryCardWarning { + composes: summaryCard; + border-left-color: var(--color-warning); +} + +.summaryCardCritical { + composes: summaryCard; + border-left-color: var(--color-critical); +} + +.cardLabel { + font-size: var(--font-xs); + font-weight: var(--font-medium); + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted); +} + +.cardValue { + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + font-variant-numeric: tabular-nums; + color: var(--color-text); + line-height: var(--line-height-tight); +} + +.cardSubtext { + font-size: var(--font-xs); + color: var(--color-text-muted); +} + +/* --- Health Overview Bar --- */ + +.healthOverview { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); +} + +.healthOverviewHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-3); +} + +.healthOverviewTitle { + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0; +} + +.healthOverviewSubtitle { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-muted); +} + +.healthBar { + display: flex; + height: 1rem; + overflow: hidden; + background-color: var(--color-border-subtle); +} + +.healthSegment { + transition: width var(--duration-slow) ease; + min-width: 2px; +} + +.segmentHealthy { + background-color: var(--color-healthy); +} + +.segmentWarning { + background-color: var(--color-warning); +} + +.segmentCritical { + background-color: var(--color-critical); +} + +.segmentUnknown { + background-color: var(--color-unknown); + opacity: 0.4; +} + +.healthLegend { + display: flex; + gap: var(--space-4); + margin-top: var(--space-2); +} + +.healthLegendItem { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-xs); + color: var(--color-text-muted); +} + +.healthLegendDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* --- Skeleton for loading state --- */ + +.skeletonCard { + background: var(--color-border-subtle); + border-radius: var(--radius-sm); + animation: summaryPulse 1.5s ease-in-out infinite; +} + +.skeletonSummaryCard { + composes: skeletonCard; + height: 96px; +} + +@keyframes summaryPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* --- Responsive --- */ + +@media (max-width: 640px) { + .cardValue { + font-size: var(--font-xl); + } +} diff --git a/client/src/components/common/Tabs.module.css b/client/src/components/common/Tabs.module.css new file mode 100644 index 0000000..b50d032 --- /dev/null +++ b/client/src/components/common/Tabs.module.css @@ -0,0 +1,46 @@ +/* ============================================================= + Tabs — Minimal tab bar with crisp active indicator + ============================================================= */ + +.tabList { + display: flex; + border-bottom: 1px solid var(--color-border); + position: relative; + gap: 0; +} + +.tab { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-muted); + padding: var(--space-2) var(--space-4); + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + transition: color var(--duration-fast) ease, + border-color var(--duration-fast) ease; + white-space: nowrap; +} + +.tab:hover { + color: var(--color-text-secondary); +} + +.tab:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} + +.tabActive { + composes: tab; + color: var(--color-text); + font-weight: var(--font-semibold); + border-bottom-color: var(--color-accent); +} + +.tabPanel { + padding-top: var(--space-5); +} diff --git a/client/src/components/common/Tabs.test.tsx b/client/src/components/common/Tabs.test.tsx new file mode 100644 index 0000000..fecac9e --- /dev/null +++ b/client/src/components/common/Tabs.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { Tabs, TabList, Tab, TabPanel } from './Tabs'; + +function renderTabs( + initialEntries = ['/'], + props: { defaultTab?: string; storageKey?: string; urlParam?: string } = {} +) { + const { defaultTab = 'one', storageKey, urlParam } = props; + return render( + + + + Tab One + Tab Two + Tab Three + + Content One + Content Two + Content Three + + + ); +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe('Tabs', () => { + it('renders all tab buttons', () => { + renderTabs(); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab Three' })).toBeInTheDocument(); + }); + + it('shows default tab content', () => { + renderTabs(); + + expect(screen.getByText('Content One')).toBeInTheDocument(); + expect(screen.queryByText('Content Two')).not.toBeInTheDocument(); + }); + + it('switches tab on click', () => { + renderTabs(); + + fireEvent.click(screen.getByRole('tab', { name: 'Tab Two' })); + + expect(screen.queryByText('Content One')).not.toBeInTheDocument(); + expect(screen.getByText('Content Two')).toBeInTheDocument(); + }); + + it('sets aria-selected on active tab', () => { + renderTabs(); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Tab Two' })); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('renders tabpanel with correct ARIA attributes', () => { + renderTabs(); + + const panel = screen.getByRole('tabpanel'); + expect(panel).toHaveAttribute('id', 'tabpanel-one'); + expect(panel).toHaveAttribute('aria-labelledby', 'tab-one'); + }); + + it('renders tablist with aria-label', () => { + renderTabs(); + + expect(screen.getByRole('tablist')).toHaveAttribute( + 'aria-label', + 'Test tabs' + ); + }); + + it('reads initial tab from URL search params', () => { + renderTabs(['/?tab=two']); + + expect(screen.queryByText('Content One')).not.toBeInTheDocument(); + expect(screen.getByText('Content Two')).toBeInTheDocument(); + }); + + it('reads initial tab from custom URL param', () => { + renderTabs(['/?section=three'], { defaultTab: 'one', urlParam: 'section' }); + + expect(screen.getByText('Content Three')).toBeInTheDocument(); + }); + + it('persists tab to localStorage when storageKey is provided', () => { + renderTabs(undefined, { storageKey: 'test-tab' }); + + fireEvent.click(screen.getByRole('tab', { name: 'Tab Two' })); + + expect(localStorage.getItem('test-tab')).toBe('two'); + }); + + it('reads initial tab from localStorage when URL has no param', () => { + localStorage.setItem('test-tab', 'three'); + renderTabs(['/'], { storageKey: 'test-tab' }); + + expect(screen.getByText('Content Three')).toBeInTheDocument(); + }); + + it('URL param takes precedence over localStorage', () => { + localStorage.setItem('test-tab', 'three'); + renderTabs(['/?tab=two'], { storageKey: 'test-tab' }); + + expect(screen.getByText('Content Two')).toBeInTheDocument(); + }); + + it('applies active CSS class to active tab', () => { + renderTabs(); + + const activeTab = screen.getByRole('tab', { name: 'Tab One' }); + const inactiveTab = screen.getByRole('tab', { name: 'Tab Two' }); + + expect(activeTab.className).toContain('tabActive'); + expect(inactiveTab.className).not.toContain('tabActive'); + }); + + it('sets tabIndex 0 on active tab and -1 on inactive', () => { + renderTabs(); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute( + 'tabindex', + '0' + ); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute( + 'tabindex', + '-1' + ); + }); +}); diff --git a/client/src/components/common/Tabs.tsx b/client/src/components/common/Tabs.tsx new file mode 100644 index 0000000..5e0e39c --- /dev/null +++ b/client/src/components/common/Tabs.tsx @@ -0,0 +1,128 @@ +import { + createContext, + useContext, + useCallback, + useMemo, + type ReactNode, +} from 'react'; +import { useSearchParams } from 'react-router-dom'; +import styles from './Tabs.module.css'; + +interface TabsContextType { + activeTab: string; + setActiveTab: (value: string) => void; +} + +const TabsContext = createContext(null); + +function useTabsContext() { + const ctx = useContext(TabsContext); + if (!ctx) throw new Error('Tab components must be used within '); + return ctx; +} + +interface TabsProps { + defaultTab: string; + urlParam?: string; + storageKey?: string; + children: ReactNode; +} + +function Tabs({ defaultTab, urlParam = 'tab', storageKey, children }: TabsProps) { + const [searchParams, setSearchParams] = useSearchParams(); + + const activeTab = useMemo(() => { + const fromUrl = searchParams.get(urlParam); + if (fromUrl) return fromUrl; + if (storageKey) { + const stored = localStorage.getItem(storageKey); + if (stored) return stored; + } + return defaultTab; + }, [searchParams, urlParam, storageKey, defaultTab]); + + const setActiveTab = useCallback( + (value: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set(urlParam, value); + return next; + }, + { replace: true } + ); + if (storageKey) { + localStorage.setItem(storageKey, value); + } + }, + [setSearchParams, urlParam, storageKey] + ); + + const ctx = useMemo( + () => ({ activeTab, setActiveTab }), + [activeTab, setActiveTab] + ); + + return {children}; +} + +interface TabListProps { + children: ReactNode; + 'aria-label'?: string; +} + +function TabList({ children, 'aria-label': ariaLabel }: TabListProps) { + return ( +
+ {children} +
+ ); +} + +interface TabProps { + value: string; + children: ReactNode; +} + +function Tab({ value, children }: TabProps) { + const { activeTab, setActiveTab } = useTabsContext(); + const isActive = activeTab === value; + + return ( + + ); +} + +interface TabPanelProps { + value: string; + children: ReactNode; +} + +function TabPanel({ value, children }: TabPanelProps) { + const { activeTab } = useTabsContext(); + if (activeTab !== value) return null; + + return ( +
+ {children} +
+ ); +} + +export { Tabs, TabList, Tab, TabPanel }; diff --git a/client/src/components/pages/Admin/Admin.module.css b/client/src/components/pages/Admin/Admin.module.css index 7431d67..8ff28a2 100644 --- a/client/src/components/pages/Admin/Admin.module.css +++ b/client/src/components/pages/Admin/Admin.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; @@ -11,21 +11,21 @@ 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; } /* Filters */ .filters { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-4); + margin-bottom: var(--space-5); } .searchWrapper { @@ -36,7 +36,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -45,32 +45,32 @@ .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-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .searchInput: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); } .statusSelect { - 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-md); + 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; } @@ -78,7 +78,7 @@ .statusSelect: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); } /* Action Error */ @@ -86,23 +86,23 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + 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); } .dismissButton { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; - color: var(--color-error); + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); + color: var(--color-critical); background: none; border: 1px solid var(--color-error-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; } @@ -112,9 +112,9 @@ /* 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-lg); overflow: hidden; } @@ -125,20 +125,20 @@ .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; 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); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-text); border-bottom: 1px solid var(--color-border); vertical-align: middle; } @@ -148,7 +148,7 @@ } .table tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .inactiveRow { @@ -156,7 +156,7 @@ } .nameCell { - font-weight: 500; + font-weight: var(--font-medium); } .emailCell { @@ -167,101 +167,76 @@ .roleBadge, .statusBadge { display: inline-flex; - 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; } .roleAdmin { - color: #7c3aed; - background-color: #ede9fe; -} - -[data-theme="dark"] .roleAdmin { - color: #c4b5fd; - background-color: #3b2e5a; + color: var(--color-accent); + background-color: color-mix(in srgb, var(--color-accent) 10%, transparent); } .roleUser { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .statusActive { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .statusActive { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .statusInactive { - color: #991b1b; - background-color: #fee2e2; -} - -[data-theme="dark"] .statusInactive { - color: #fca5a5; - background-color: #450a0a; + color: var(--color-critical); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); } /* Actions */ .actions { display: flex; - gap: 0.5rem; + gap: var(--space-2); } .actionButton { - 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); } .roleButton { - color: var(--color-text-primary); - background-color: var(--color-bg-hover); - border: 1px solid var(--color-border-input); + color: var(--color-text); + background-color: var(--color-surface-hover); + border: 1px solid var(--color-border); } .roleButton:hover:not(:disabled) { - background-color: var(--color-bg-active); + background-color: var(--color-surface-hover); } .deactivateButton { - color: var(--color-error); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); } .deactivateButton:hover:not(:disabled) { background-color: var(--color-error-bg); - border-color: var(--color-error); + border-color: var(--color-critical); } .reactivateButton { - color: #166534; - background-color: #f0fdf4; - border: 1px solid #bbf7d0; -} - -[data-theme="dark"] .reactivateButton { - color: #86efac; - background-color: #14532d; - border-color: #22c55e; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); } .reactivateButton:hover:not(:disabled) { - background-color: #dcfce7; -} - -[data-theme="dark"] .reactivateButton:hover:not(:disabled) { - background-color: #166534; + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); } .actionButton:disabled { @@ -275,17 +250,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; } @@ -299,39 +270,39 @@ 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); } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .emptyState { 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); } /* Create Button */ @@ -339,8 +310,8 @@ color: #fff; background-color: var(--color-accent); border: 1px solid var(--color-accent); - padding: 0.5rem 1rem; - font-size: 0.875rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); } .createButton:hover:not(:disabled) { @@ -352,94 +323,83 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: #166534; - background-color: #f0fdf4; - border: 1px solid #bbf7d0; - border-radius: 0.375rem; -} - -[data-theme="dark"] .successMessage { - color: #86efac; - background-color: #14532d; - border-color: #22c55e; + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); + border-radius: var(--radius-md); } .successMessage .dismissButton { - color: #166534; - border-color: #bbf7d0; -} - -[data-theme="dark"] .successMessage .dismissButton { - color: #86efac; - border-color: #22c55e; + color: var(--color-healthy); + border-color: color-mix(in srgb, var(--color-healthy) 30%, transparent); } /* Form Card */ .formCard { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.5rem; - margin-bottom: 1.5rem; + border-radius: var(--radius-lg); + padding: var(--space-5); + margin-bottom: var(--space-5); } .formTitle { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text-heading); - margin: 0 0 1rem 0; + font-size: var(--font-xl); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0 0 var(--space-4) 0; } .form { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-4); } .formField { display: flex; flex-direction: column; - gap: 0.25rem; + gap: var(--space-1); } .formLabel { - font-size: 0.8125rem; - font-weight: 500; + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); } .formInput { - 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); } .formInput: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); } .formActions { display: flex; - gap: 0.5rem; - margin-top: 0.5rem; + gap: var(--space-2); + margin-top: var(--space-2); } .formError { - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; - color: var(--color-error); + padding: var(--space-2) var(--space-3); + 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.25rem; + border-radius: var(--radius-sm); } /* Modal Overlay */ @@ -454,19 +414,19 @@ } .modalContent { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.5rem; + border-radius: var(--radius-lg); + padding: var(--space-5); width: 100%; max-width: 28rem; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-lg); } /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .filters { @@ -479,6 +439,6 @@ .actions { flex-direction: column; - gap: 0.25rem; + gap: var(--space-1); } } diff --git a/client/src/components/pages/Admin/AdminSettings.module.css b/client/src/components/pages/Admin/AdminSettings.module.css index e0cb8ea..b0a133e 100644 --- a/client/src/components/pages/Admin/AdminSettings.module.css +++ b/client/src/components/pages/Admin/AdminSettings.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; @@ -11,13 +11,13 @@ 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; } @@ -26,42 +26,36 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: #166534; - background-color: #dcfce7; - border: 1px solid #bbf7d0; - border-radius: 0.375rem; -} - -[data-theme="dark"] .successBanner { - color: #86efac; - background-color: #14532d; - border-color: #22c55e; + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); + border-radius: var(--radius-md); } .errorBanner { display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + 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); } .dismissButton { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); color: inherit; background: none; border: 1px solid currentColor; - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; opacity: 0.7; } @@ -74,13 +68,13 @@ .sections { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-4); } .section { - 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-lg); overflow: hidden; } @@ -89,22 +83,22 @@ justify-content: space-between; align-items: center; width: 100%; - padding: 1rem 1.25rem; + padding: var(--space-4) var(--space-5); background: none; border: none; cursor: pointer; text-align: left; - color: var(--color-text-heading); - transition: background-color 0.15s; + color: var(--color-text); + transition: background-color var(--duration-fast); } .sectionHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .sectionTitle { - font-size: 1rem; - font-weight: 600; + font-size: var(--font-lg); + font-weight: var(--font-semibold); margin: 0; } @@ -113,7 +107,7 @@ height: 20px; flex-shrink: 0; color: var(--color-text-muted); - transition: transform 0.2s; + transition: transform var(--duration-normal); transform: rotate(-90deg); } @@ -122,23 +116,23 @@ } .sectionBody { - padding: 0 1.25rem 1.25rem; + padding: 0 var(--space-5) var(--space-5); border-top: 1px solid var(--color-border); - padding-top: 1.25rem; + padding-top: var(--space-5); } .sectionDescription { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); - margin: 0 0 1.25rem 0; + margin: 0 0 var(--space-5) 0; } .subsectionTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-primary); - margin: 1.25rem 0 0.75rem 0; - padding-top: 1rem; + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: var(--space-5) 0 var(--space-3) 0; + padding-top: var(--space-4); border-top: 1px solid var(--color-border); } @@ -146,7 +140,7 @@ .fieldGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 1.25rem; + gap: var(--space-5); } .field { @@ -156,25 +150,25 @@ } .label { - 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); } .input { - 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 { @@ -182,35 +176,35 @@ } .inputError:focus { - border-color: var(--color-error); - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); + border-color: var(--color-critical); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15); } .textarea { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); resize: vertical; - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .textarea: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); } .fieldError { - font-size: 0.75rem; - color: var(--color-error); + font-size: var(--font-xs); + color: var(--color-critical); } .hint { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } @@ -218,21 +212,21 @@ .actions { display: flex; justify-content: flex-end; - margin-top: 1.5rem; - padding-top: 1rem; + margin-top: var(--space-5); + padding-top: var(--space-4); border-top: 1px solid var(--color-border); } .saveButton { - padding: 0.5rem 1.25rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-5); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: 1px solid transparent; - 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) { @@ -250,17 +244,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; } @@ -274,32 +264,32 @@ 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); } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .fieldGrid { diff --git a/client/src/components/pages/Admin/AdminSettings.tsx b/client/src/components/pages/Admin/AdminSettings.tsx index 76eeffd..e2759ea 100644 --- a/client/src/components/pages/Admin/AdminSettings.tsx +++ b/client/src/components/pages/Admin/AdminSettings.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { ChevronDown, Loader2 } from 'lucide-react'; import { fetchSettings, updateSettings } from '../../../api/settings'; import type { SettingValue } from '../../../api/settings'; import styles from './AdminSettings.module.css'; @@ -212,7 +213,7 @@ function AdminSettings() { return (
-
+ Loading settings...
@@ -265,15 +266,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('retention')} >

Data Retention

- - - + /> {expandedSections.has('retention') && (
@@ -328,15 +324,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('polling')} >

Polling Defaults

- - - + /> {expandedSections.has('polling') && (
@@ -376,15 +367,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('security')} >

Security

- - - + /> {expandedSections.has('security') && (
@@ -500,15 +486,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('alerts')} >

Alerts

- - - + /> {expandedSections.has('alerts') && (
diff --git a/client/src/components/pages/Admin/AlertMutesAdmin.module.css b/client/src/components/pages/Admin/AlertMutesAdmin.module.css index 773f444..4cefb78 100644 --- a/client/src/components/pages/Admin/AlertMutesAdmin.module.css +++ b/client/src/components/pages/Admin/AlertMutesAdmin.module.css @@ -2,63 +2,63 @@ .container { max-width: 1200px; margin: 0 auto; - padding: 1.5rem; + padding: var(--space-5); } .header { 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-primary); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); } .filterRow { display: flex; - gap: 0.75rem; - margin-bottom: 1rem; + gap: var(--space-3); + margin-bottom: var(--space-4); align-items: center; } .filterSelect { - 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); } .muteTable { width: 100%; border-collapse: collapse; - font-size: 0.875rem; - background-color: var(--color-bg-card); + font-size: var(--font-base); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-lg); overflow: hidden; } .muteTable th { text-align: left; - padding: 0.75rem; - font-weight: 500; + padding: var(--space-3); + font-weight: var(--font-medium); color: var(--color-text-muted); - font-size: 0.75rem; + font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.025em; border-bottom: 1px solid var(--color-border); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .muteTable td { - padding: 0.75rem; - color: var(--color-text-primary); + padding: var(--space-3); + color: var(--color-text); border-bottom: 1px solid var(--color-border); } @@ -68,32 +68,27 @@ .muteType { display: inline-flex; - 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; - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); color: var(--color-text-muted); } .noMutes { text-align: center; - padding: 2rem; + padding: var(--space-6); color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); } .error { - color: var(--color-error, #dc3545); - font-size: 0.875rem; - margin-bottom: 0.75rem; - padding: 0.5rem 0.75rem; - background-color: #fef2f2; - border: 1px solid #fecaca; - border-radius: 0.375rem; -} - -[data-theme="dark"] .error { - background-color: #450a0a; - border-color: #7f1d1d; + color: var(--color-critical); + font-size: var(--font-base); + margin-bottom: var(--space-3); + padding: var(--space-2) var(--space-3); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-critical) 30%, transparent); + border-radius: var(--radius-md); } diff --git a/client/src/components/pages/Admin/ManifestAdmin.module.css b/client/src/components/pages/Admin/ManifestAdmin.module.css index bc45d92..ed2235a 100644 --- a/client/src/components/pages/Admin/ManifestAdmin.module.css +++ b/client/src/components/pages/Admin/ManifestAdmin.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; @@ -11,21 +11,21 @@ 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; } /* Filters & Actions Bar */ .toolbar { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-4); + margin-bottom: var(--space-5); align-items: center; } @@ -37,7 +37,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -46,35 +46,35 @@ .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-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .searchInput: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); } .syncAllButton { 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; white-space: nowrap; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .syncAllButton:hover:not(:disabled) { @@ -88,9 +88,9 @@ /* 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-lg); overflow: auto; } @@ -102,21 +102,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; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); white-space: nowrap; } .table td { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-text-primary); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-text); border-bottom: 1px solid var(--color-border); vertical-align: middle; } @@ -126,13 +126,13 @@ } .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); } .teamLink:hover { @@ -145,32 +145,27 @@ text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } /* Badges */ .badge { display: inline-flex; - 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; white-space: nowrap; } .badgeEnabled { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .badgeEnabled { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .badgeDisabled { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .badgeNone { @@ -179,51 +174,36 @@ } .badgeSuccess { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .badgeSuccess { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .badgeFailed { - color: #991b1b; - background-color: #fee2e2; -} - -[data-theme="dark"] .badgeFailed { - color: #fca5a5; - background-color: #450a0a; + color: var(--color-critical); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); } .badgePartial { - color: #92400e; - background-color: #fef3c7; -} - -[data-theme="dark"] .badgePartial { - color: #fcd34d; - background-color: #451a03; + color: var(--color-warning); + background-color: color-mix(in srgb, var(--color-warning) 10%, transparent); } .badgeNever { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .driftCount { - font-weight: 600; + font-weight: var(--font-semibold); font-variant-numeric: tabular-nums; } .driftCountPositive { - color: #dc2626; + color: var(--color-critical); } .contactCell { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); max-width: 150px; overflow: hidden; @@ -233,47 +213,47 @@ /* Sync Results */ .syncResults { - margin-bottom: 1.5rem; - padding: 1rem; - background-color: var(--color-bg-card); + margin-bottom: var(--space-5); + padding: var(--space-4); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-lg); } .syncResultsHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .syncResultsTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); } .dismissButton { padding: 0.125rem 0.375rem; - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); background: none; - border: 1px solid var(--color-border-input); - border-radius: 0.25rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; } .dismissButton:hover { - color: var(--color-text-primary); + color: var(--color-text); border-color: var(--color-text-muted); } .syncResultItem { display: flex; align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; - padding: 0.25rem 0; + gap: var(--space-2); + font-size: var(--font-sm); + padding: var(--space-1) 0; } /* States */ @@ -282,17 +262,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; } @@ -306,34 +282,34 @@ 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; } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .emptyState { - padding: 4rem 2rem; + padding: var(--space-8) var(--space-6); text-align: center; color: var(--color-text-muted); } .relativeTime { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); } diff --git a/client/src/components/pages/Admin/ManifestAdmin.tsx b/client/src/components/pages/Admin/ManifestAdmin.tsx index 5ff8741..9a61de9 100644 --- a/client/src/components/pages/Admin/ManifestAdmin.tsx +++ b/client/src/components/pages/Admin/ManifestAdmin.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, Loader2 } from 'lucide-react'; import { Link } from 'react-router-dom'; import { fetchAdminManifests, @@ -71,7 +72,7 @@ function ManifestAdmin() { return (
-
+ Loading manifest configurations...
@@ -99,18 +100,7 @@ function ManifestAdmin() {
- - - - +
-
+ Loading users...
@@ -353,18 +354,7 @@ function UserManagement() {
- - - - + removeAlias(a.id)} title="Delete alias" > - - - +
)} diff --git a/client/src/components/pages/Associations/AssociationForm.module.css b/client/src/components/pages/Associations/AssociationForm.module.css index ddbefaf..4cd528c 100644 --- a/client/src/components/pages/Associations/AssociationForm.module.css +++ b/client/src/components/pages/Associations/AssociationForm.module.css @@ -1,7 +1,7 @@ .form { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-4); } .field { @@ -11,22 +11,22 @@ } .fieldLabel { - font-size: 0.8125rem; - font-weight: 600; + font-size: var(--font-sm); + font-weight: var(--font-semibold); color: var(--color-text-muted); } .select { - 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) var(--space-6) 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); 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; } @@ -34,26 +34,26 @@ .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); } .actions { display: flex; justify-content: flex-end; - gap: 0.5rem; - margin-top: 0.5rem; + gap: var(--space-2); + margin-top: var(--space-2); } .submitButton { - 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); } .submitButton:hover:not(:disabled) { @@ -66,32 +66,32 @@ } .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); } .error { - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; - color: var(--color-error); + padding: var(--space-2) var(--space-3); + 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); } .loading { - padding: 2rem; + padding: var(--space-6); text-align: center; color: var(--color-text-muted); } diff --git a/client/src/components/pages/Associations/AssociationsPage.module.css b/client/src/components/pages/Associations/AssociationsPage.module.css index 090a006..88b0017 100644 --- a/client/src/components/pages/Associations/AssociationsPage.module.css +++ b/client/src/components/pages/Associations/AssociationsPage.module.css @@ -1,19 +1,19 @@ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; } .header { - 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; } @@ -21,27 +21,27 @@ display: flex; gap: 0; border-bottom: 2px solid var(--color-border); - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .tab { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.25rem; - font-size: 0.875rem; - font-weight: 500; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + font-size: var(--font-base); + font-weight: var(--font-medium); color: var(--color-text-muted); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -2px; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast), border-color var(--duration-fast); } .tab:hover { - color: var(--color-text-primary); + color: var(--color-text); } .tabActive { @@ -57,8 +57,8 @@ height: 1.25rem; padding: 0 0.375rem; font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-inverse); + font-weight: var(--font-semibold); + color: #fff; background-color: var(--color-accent); border-radius: 9999px; } @@ -69,7 +69,7 @@ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .tabs { diff --git a/client/src/components/pages/Associations/ExternalServicesManager.module.css b/client/src/components/pages/Associations/ExternalServicesManager.module.css index b5df13f..9930a91 100644 --- a/client/src/components/pages/Associations/ExternalServicesManager.module.css +++ b/client/src/components/pages/Associations/ExternalServicesManager.module.css @@ -1,43 +1,43 @@ .container { display: flex; flex-direction: column; - gap: 1.5rem; + gap: var(--space-5); } .description { - font-size: 0.875rem; + font-size: var(--font-base); color: var(--color-text-muted); margin: 0; } .form { display: flex; - gap: 0.75rem; + gap: var(--space-3); align-items: flex-end; } .field { display: flex; flex-direction: column; - gap: 0.25rem; + gap: var(--space-1); flex: 1; } .fieldLabel { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.025em; } .input { - 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); } .input::placeholder { @@ -45,31 +45,31 @@ } .select { - 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) var(--space-6) 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); 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; } .addButton { - 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; white-space: nowrap; - transition: opacity 0.15s; + transition: opacity var(--duration-fast); } .addButton:hover { @@ -82,23 +82,23 @@ } .error { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-error, #dc3545); - background-color: var(--color-bg-error, rgba(220, 53, 69, 0.1)); - border-radius: 0.375rem; + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-critical); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); + border-radius: var(--radius-md); } .empty { text-align: center; - padding: 2rem; + padding: var(--space-6); color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); } .loading { text-align: center; - padding: 2rem; + padding: var(--space-6); color: var(--color-text-muted); } @@ -109,28 +109,28 @@ .table { width: 100%; border-collapse: collapse; - font-size: 0.875rem; + font-size: var(--font-base); } .table th { text-align: left; - padding: 0.75rem; - font-weight: 600; + padding: var(--space-3); + font-weight: var(--font-semibold); color: var(--color-text-muted); border-bottom: 2px solid var(--color-border); - font-size: 0.75rem; + font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.025em; } .table td { - padding: 0.75rem; + padding: var(--space-3); border-bottom: 1px solid var(--color-border); - color: var(--color-text-primary); + color: var(--color-text); } .nameCell { - font-weight: 500; + font-weight: var(--font-medium); } .descCell { @@ -146,7 +146,7 @@ .actionsCell { display: flex; - gap: 0.5rem; + gap: var(--space-2); white-space: nowrap; } @@ -158,42 +158,42 @@ height: 1.75rem; background: none; border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); color: var(--color-text-muted); cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast), border-color var(--duration-fast); } .iconButton:hover { - color: var(--color-text-primary); + color: var(--color-text); border-color: var(--color-text-muted); } .deleteButton:hover { - color: var(--color-error, #dc3545); - border-color: var(--color-error, #dc3545); + color: var(--color-critical); + border-color: var(--color-critical); } .editInput { - padding: 0.375rem 0.5rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.25rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: 0.375rem var(--space-2); + 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); width: 100%; } .saveButton { - padding: 0.25rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-1) var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; - transition: opacity 0.15s; + transition: opacity var(--duration-fast); } .saveButton:hover { @@ -206,19 +206,19 @@ } .cancelButton { - padding: 0.25rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; + padding: var(--space-1) var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); background: none; border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast), border-color var(--duration-fast); } .cancelButton:hover { - color: var(--color-text-primary); + color: var(--color-text); border-color: var(--color-text-muted); } diff --git a/client/src/components/pages/Associations/ExternalServicesManager.tsx b/client/src/components/pages/Associations/ExternalServicesManager.tsx index 71b3899..3087dc1 100644 --- a/client/src/components/pages/Associations/ExternalServicesManager.tsx +++ b/client/src/components/pages/Associations/ExternalServicesManager.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, FormEvent } from 'react'; +import { Pencil, Trash2 } from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { fetchTeams } from '../../../api/teams'; import { @@ -257,18 +258,14 @@ function ExternalServicesManager() { onClick={() => startEdit(svc)} title="Edit" > - - - + )} diff --git a/client/src/components/pages/Associations/ManageAssociations.module.css b/client/src/components/pages/Associations/ManageAssociations.module.css index 1b60ddf..2433fc6 100644 --- a/client/src/components/pages/Associations/ManageAssociations.module.css +++ b/client/src/components/pages/Associations/ManageAssociations.module.css @@ -1,7 +1,7 @@ .searchBar { display: flex; - gap: 0.75rem; - margin-bottom: 1.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } .searchWrapper { @@ -12,7 +12,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -21,12 +21,12 @@ .searchInput { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.25rem; - 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) var(--space-2) 2.25rem; + 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); } .searchInput::placeholder { @@ -36,20 +36,20 @@ .searchInput: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); } .filterSelect { - 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) var(--space-6) 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); 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; } @@ -57,41 +57,41 @@ .filterSelect: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); } .serviceGroup { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - margin-bottom: 0.75rem; + border-radius: var(--radius-lg); + margin-bottom: var(--space-3); overflow: hidden; } .serviceHeader { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 100%; - padding: 0.75rem 1rem; - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); background: none; border: none; cursor: pointer; text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .serviceHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .chevron { display: inline-flex; color: var(--color-text-muted); - transition: transform 0.15s; + transition: transform var(--duration-fast); flex-shrink: 0; } @@ -108,8 +108,8 @@ } .depCount { - font-size: 0.75rem; - font-weight: 500; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); white-space: nowrap; } @@ -129,25 +129,25 @@ .depHeader { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 100%; - padding: 0.625rem 1rem 0.625rem 2rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + padding: 0.625rem var(--space-4) 0.625rem var(--space-6); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); background: none; border: none; cursor: pointer; text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .depHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .depHeaderExpanded { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .depName { @@ -166,8 +166,8 @@ height: 1.25rem; padding: 0 0.375rem; font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-inverse); + font-weight: var(--font-semibold); + color: #fff; background-color: var(--color-accent); border-radius: 9999px; } @@ -177,8 +177,8 @@ } .depPanel { - padding: 0.75rem 1rem 0.75rem 2rem; - background-color: var(--color-bg-hover); + padding: var(--space-3) var(--space-4) var(--space-3) var(--space-6); + background-color: var(--color-surface-hover); border-top: 1px solid var(--color-border); } @@ -186,24 +186,24 @@ display: flex; flex-direction: column; gap: 0.375rem; - margin-bottom: 0.75rem; + margin-bottom: var(--space-3); } .assocItem { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: var(--color-bg-card); + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; - font-size: 0.875rem; + border-radius: var(--radius-md); + font-size: var(--font-base); } .assocServiceName { flex: 1; - font-weight: 500; - color: var(--color-text-primary); + font-weight: var(--font-medium); + color: var(--color-text); min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -212,12 +212,12 @@ .typeBadge { 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; - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background-color: var(--color-surface-hover); + color: var(--color-text); white-space: nowrap; } @@ -229,17 +229,17 @@ height: 1.75rem; 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: 0.25rem; + border-radius: var(--radius-sm); flex-shrink: 0; - transition: all 0.15s; + transition: all var(--duration-fast); } .deleteButton:hover { background: var(--color-error-bg); - color: var(--color-error); + color: var(--color-critical); border-color: var(--color-error-border); } @@ -247,72 +247,72 @@ display: inline-flex; align-items: center; gap: 0.375rem; - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; + padding: 0.375rem var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-accent); background: none; border: 1px dashed var(--color-accent); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .addButton:hover { - background-color: rgba(59, 130, 246, 0.05); + background-color: color-mix(in srgb, var(--color-accent) 5%, transparent); } .formWrapper { - margin-top: 0.75rem; - padding: 0.75rem; - background-color: var(--color-bg-card); + margin-top: var(--space-3); + padding: var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .noAssociations { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); - padding: 0.25rem 0; - margin-bottom: 0.75rem; + padding: var(--space-1) 0; + margin-bottom: var(--space-3); } .loading { - padding: 3rem; + padding: var(--space-7); text-align: center; color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); } .error { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; - margin-bottom: 1rem; + border-radius: var(--radius-md); + margin-bottom: var(--space-4); } .empty { - padding: 3rem; + padding: var(--space-7); text-align: center; color: var(--color-text-muted); - font-size: 0.875rem; - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + font-size: var(--font-base); + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); } .aliasSection { - margin-top: 0.75rem; - padding-top: 0.75rem; + margin-top: var(--space-3); + padding-top: var(--space-3); border-top: 1px solid var(--color-border); } .aliasSectionHeader { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; @@ -323,24 +323,24 @@ display: flex; flex-direction: column; gap: 0.375rem; - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .aliasItem { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: var(--color-bg-card); + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; - font-size: 0.875rem; + border-radius: var(--radius-md); + font-size: var(--font-base); } .aliasCanonical { flex: 1; - font-weight: 500; - color: var(--color-text-primary); + font-weight: var(--font-medium); + color: var(--color-text); min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -351,35 +351,35 @@ display: inline-flex; align-items: center; gap: 0.375rem; - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; + padding: 0.375rem var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-accent); background: none; border: 1px dashed var(--color-accent); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .addAliasButton:hover { - background-color: rgba(59, 130, 246, 0.05); + background-color: color-mix(in srgb, var(--color-accent) 5%, transparent); } .aliasForm { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .aliasInput { flex: 1; - 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); } .aliasInput::placeholder { @@ -389,61 +389,61 @@ .aliasInput: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); } .aliasError { - font-size: 0.75rem; - color: var(--color-error); - margin-top: 0.25rem; + font-size: var(--font-xs); + color: var(--color-critical); + margin-top: var(--space-1); } /* Canonical Override Section */ .canonicalOverrideSection { - margin-top: 0.75rem; - padding-top: 0.75rem; + margin-top: var(--space-3); + padding-top: var(--space-3); border-top: 1px solid var(--color-border); } .canonicalOverrideNote { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); font-style: italic; - padding: 0.25rem 0; + padding: var(--space-1) 0; } .canonicalOverrideDisplay { - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .overrideIndicator { 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; - background-color: rgba(59, 130, 246, 0.1); + background-color: color-mix(in srgb, var(--color-accent) 10%, transparent); color: var(--color-accent); - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .overrideFieldGroup { display: flex; align-items: baseline; gap: 0.375rem; - font-size: 0.8125rem; - margin-bottom: 0.25rem; + font-size: var(--font-sm); + margin-bottom: var(--space-1); } .overrideFieldLabel { - font-weight: 600; + font-weight: var(--font-semibold); color: var(--color-text-muted); flex-shrink: 0; } .overrideFieldValue { - color: var(--color-text-primary); + color: var(--color-text); } .overrideContactList { @@ -454,30 +454,30 @@ .overrideContactItem { display: inline-block; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - background-color: var(--color-bg-card); + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.25rem; - color: var(--color-text-primary); + border-radius: var(--radius-sm); + color: var(--color-text); } .overrideForm { - margin-top: 0.5rem; - padding: 0.75rem; - background-color: var(--color-bg-card); + margin-top: var(--space-2); + padding: var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .overrideFormGroup { - margin-bottom: 0.75rem; + margin-bottom: var(--space-3); } .overrideFormLabel { display: block; - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; @@ -487,29 +487,29 @@ .overrideContactEntryRow { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); margin-bottom: 0.375rem; } .overrideFormActions { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .overrideClearButton { display: inline-flex; align-items: center; gap: 0.375rem; - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-error); + padding: 0.375rem var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-critical); background: none; border: 1px dashed var(--color-error-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .overrideClearButton:hover { @@ -531,10 +531,10 @@ } .depHeader { - padding-left: 1.25rem; + padding-left: var(--space-5); } .depPanel { - padding-left: 1.25rem; + padding-left: var(--space-5); } } diff --git a/client/src/components/pages/Associations/ManageAssociations.tsx b/client/src/components/pages/Associations/ManageAssociations.tsx index ff5a943..ec203f2 100644 --- a/client/src/components/pages/Associations/ManageAssociations.tsx +++ b/client/src/components/pages/Associations/ManageAssociations.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { Search, ChevronRight, X } from 'lucide-react'; import { useManageAssociations } from '../../../hooks/useManageAssociations'; import { useAliases } from '../../../hooks/useAliases'; import { useCanonicalOverrides } from '../../../hooks/useCanonicalOverrides'; @@ -286,9 +287,7 @@ function ManageAssociations() { onClick={() => removeContactEntry(i)} title="Remove entry" > - - - +
))} @@ -364,9 +363,7 @@ function ManageAssociations() { onClick={() => handleAliasDelete(a.id)} title="Delete alias" > - - - + )}
@@ -442,18 +439,7 @@ function ManageAssociations() {
- - - - + - - - + {service.name} @@ -519,9 +503,7 @@ function ManageAssociations() { aria-expanded={isDepExpanded} > - - - + {dep.name} {assocs !== undefined && ( @@ -554,9 +536,7 @@ function ManageAssociations() { onClick={() => setDeleteTarget({ depId: dep.id, assoc })} title="Delete association" > - - - +
))} diff --git a/client/src/components/pages/Catalog/ExternalDependencies.tsx b/client/src/components/pages/Catalog/ExternalDependencies.tsx index 7da4b9f..3144ae3 100644 --- a/client/src/components/pages/Catalog/ExternalDependencies.tsx +++ b/client/src/components/pages/Catalog/ExternalDependencies.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, Copy, Check, Loader2 } from 'lucide-react'; import { useExternalDependencies } from '../../../hooks/useExternalDependencies'; import styles from './ServiceCatalog.module.css'; @@ -35,7 +36,7 @@ function ExternalDependencies() { if (isLoading) { return (
-
+ Loading external dependencies...
); @@ -56,18 +57,7 @@ function ExternalDependencies() { <>
- - - - + {copiedName === entry.canonical_name ? ( - - - + ) : ( - - - - + )}
diff --git a/client/src/components/pages/Catalog/ServiceCatalog.module.css b/client/src/components/pages/Catalog/ServiceCatalog.module.css index f982792..87f26da 100644 --- a/client/src/components/pages/Catalog/ServiceCatalog.module.css +++ b/client/src/components/pages/Catalog/ServiceCatalog.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,14 +11,15 @@ 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); } /* Tab Bar */ @@ -26,35 +27,36 @@ display: flex; gap: 0; border-bottom: 1px solid var(--color-border); - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .tab { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease; } .tab:hover { - color: var(--color-text-primary); + color: var(--color-text-secondary); } .tabActive { - color: var(--color-accent); + color: var(--color-text); + font-weight: var(--font-semibold); border-bottom-color: var(--color-accent); } /* Filters */ .filters { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } .searchWrapper { @@ -65,7 +67,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -74,78 +76,79 @@ .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); } /* Team Sections */ .teamSections { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-3); } .teamSection { - 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; } .teamHeader { 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); background: none; border: none; cursor: pointer; - font-size: 0.875rem; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease; } .teamHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .chevron { flex-shrink: 0; - transition: transform 0.2s ease; + transition: transform var(--duration-normal) ease; color: var(--color-text-muted); } @@ -154,47 +157,48 @@ } .teamName { - font-weight: 600; - color: var(--color-text-heading); + font-weight: var(--font-semibold); + color: var(--color-text); } .teamKeyBadge { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; - padding: 0.0625rem 0.375rem; - background-color: var(--color-bg-hover); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); + padding: 1px var(--space-2); + 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); } .serviceCount { margin-left: auto; - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); - font-weight: 400; + font-weight: var(--font-normal); } /* Service Grid */ .serviceGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 0.75rem; - padding: 0 1rem 1rem; + gap: var(--space-3); + padding: 0 var(--space-4) var(--space-4); } .serviceCard { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; - padding: 0.75rem; + border-radius: var(--radius-md); + padding: var(--space-3); display: flex; flex-direction: column; - gap: 0.375rem; - transition: border-color 0.15s; + gap: var(--space-1); + transition: border-color var(--duration-fast) ease, background-color var(--duration-fast) ease; } .serviceCard:hover { + background-color: var(--color-surface-hover); border-color: var(--color-text-muted); } @@ -202,13 +206,13 @@ display: flex; align-items: center; justify-content: space-between; - gap: 0.5rem; + gap: var(--space-2); } .cardName { - font-weight: 500; - font-size: 0.875rem; - color: var(--color-text-heading); + font-weight: var(--font-medium); + font-size: var(--font-base); + color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -217,17 +221,17 @@ .cardKey { display: flex; align-items: center; - gap: 0.375rem; + gap: var(--space-1); min-height: 1.5rem; } .manifestKeyCode { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; - padding: 0.125rem 0.375rem; - background-color: var(--color-bg-hover); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); + padding: 1px var(--space-2); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -241,57 +245,57 @@ height: 22px; 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: 4px; - transition: all 0.15s; + border-radius: var(--radius-sm); + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease, border-color var(--duration-fast) ease; flex-shrink: 0; } .copyButton:hover { - background: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text); border-color: var(--color-text-muted); } .copyButtonCopied { - color: var(--color-success); - border-color: var(--color-success); + color: var(--color-healthy); + border-color: var(--color-healthy); } .cardDescription { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); - line-height: 1.4; + line-height: var(--line-height-normal); } .noKey { color: var(--color-text-muted); font-style: italic; - font-size: 0.75rem; + font-size: var(--font-xs); } /* Status */ .statusBadge { display: inline-flex; align-items: center; - gap: 0.25rem; - padding: 0.0625rem 0.375rem; - font-size: 0.6875rem; - font-weight: 500; + gap: var(--space-1); + padding: 1px var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; flex-shrink: 0; } .statusActive { - color: var(--color-success); - background-color: color-mix(in srgb, var(--color-success) 10%, transparent); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .statusInactive { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .statusDot { @@ -302,7 +306,7 @@ } .statusDotActive { - background-color: var(--color-success); + background-color: var(--color-healthy); } .statusDotInactive { @@ -315,17 +319,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; } @@ -339,69 +339,72 @@ 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; } .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); } .emptyState { 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); } /* External Dependencies 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; } .depTable { width: 100%; border-collapse: collapse; - font-size: 0.875rem; + font-size: var(--font-base); } .depTable th { text-align: left; - padding: 0.5rem 0.75rem; - font-weight: 600; - font-size: 0.8125rem; - color: var(--color-text-heading); + padding: var(--space-3) var(--space-4); + font-size: var(--font-xs); + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); - background-color: var(--color-bg-hover); } .depTable td { - padding: 0.625rem 0.75rem; - border-bottom: 1px solid var(--color-border); - color: var(--color-text-primary); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + color: var(--color-text); vertical-align: top; } @@ -409,63 +412,67 @@ border-bottom: none; } +.depTable tbody tr { + transition: background-color var(--duration-fast) ease; +} + .depTable tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .canonicalCell { display: flex; align-items: center; - gap: 0.375rem; + gap: var(--space-1); } .canonicalCode { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-heading); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); } .noDescription { color: var(--color-text-muted); font-style: italic; - font-size: 0.8125rem; + font-size: var(--font-sm); } .teamChips { display: flex; flex-wrap: wrap; - gap: 0.25rem; + gap: var(--space-1); } .teamChip { display: inline-block; - padding: 0.0625rem 0.375rem; - font-size: 0.75rem; - background-color: var(--color-bg-hover); + padding: 1px var(--space-2); + font-size: var(--font-xs); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); } .aliasBadges { display: flex; flex-wrap: wrap; - gap: 0.25rem; + gap: var(--space-1); } .aliasCode { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; - padding: 0.0625rem 0.375rem; - background-color: var(--color-bg-hover); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); + padding: 1px var(--space-2); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); } /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .filters { @@ -479,7 +486,7 @@ .header { flex-direction: column; align-items: flex-start; - gap: 1rem; + gap: var(--space-4); } .serviceGrid { diff --git a/client/src/components/pages/Catalog/ServiceCatalog.tsx b/client/src/components/pages/Catalog/ServiceCatalog.tsx index 04f6d31..d63b93c 100644 --- a/client/src/components/pages/Catalog/ServiceCatalog.tsx +++ b/client/src/components/pages/Catalog/ServiceCatalog.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, ChevronRight, Copy, Check, Loader2 } from 'lucide-react'; import { fetchServiceCatalog, fetchTeams } from '../../../api/services'; import type { CatalogEntry, TeamWithCounts } from '../../../types/service'; import ExternalDependencies from './ExternalDependencies'; @@ -125,7 +126,7 @@ function ServiceCatalog() { return (
-
+ Loading service catalog...
@@ -172,18 +173,7 @@ function ServiceCatalog() { <>
- - - - + toggleTeam(group.teamId)} aria-expanded={!isCollapsed} > - - - + /> {group.teamName} {group.teamKey && ( {group.teamKey} @@ -280,14 +263,9 @@ function ServiceCatalog() { aria-label={`Copy ${namespacedKey}`} > {copiedId === entry.id ? ( - - - + ) : ( - - - - + )}
diff --git a/client/src/components/pages/Dashboard/Dashboard.module.css b/client/src/components/pages/Dashboard/Dashboard.module.css index f8d44d6..5471a18 100644 --- a/client/src/components/pages/Dashboard/Dashboard.module.css +++ b/client/src/components/pages/Dashboard/Dashboard.module.css @@ -1,36 +1,50 @@ +/* ============================================================= + Dashboard — Swiss Modernism 2.0 + Stable CSS Grid layout with named areas, design tokens + ============================================================= */ + /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-5); display: flex; flex-direction: column; overflow: auto; } +/* Refreshing state — opacity fade for background refreshes */ +.refreshing { + opacity: 0.6; + transition: opacity var(--duration-normal) ease; +} + +/* --- Header --- */ + .header { 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); + line-height: var(--line-height-tight); margin: 0; } .headerActions { display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); } .refreshingIndicator { @@ -47,20 +61,21 @@ animation: spin 1s linear infinite; } -/* Auto-refresh controls */ +/* --- Auto-refresh controls --- */ + .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); 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 +86,21 @@ 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-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 { @@ -94,10 +109,10 @@ left: 2px; width: 1.25rem; height: 1.25rem; - background-color: var(--color-bg-card); + background-color: #fff; border-radius: 50%; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: transform 0.2s ease; + transition: transform var(--duration-normal) ease; } .togglePill.toggleActive .toggleKnob { @@ -106,25 +121,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-repeat: no-repeat; background-size: 1.25rem 1.25rem; - transition: border-color 0.15s, opacity 0.15s; + 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.15); } .intervalSelect:disabled { @@ -132,198 +148,79 @@ cursor: not-allowed; } -/* Summary Cards Grid */ -.summaryGrid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - margin-bottom: 1.5rem; -} - -.summaryCard { - background-color: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.25rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.summaryCard.clickable { - cursor: pointer; - transition: border-color 0.15s, box-shadow 0.15s; -} - -.summaryCard.clickable:hover { - border-color: var(--color-accent); - box-shadow: var(--shadow-sm); -} - -.cardLabel { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-muted); -} - -.cardValue { - font-size: 2rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - color: var(--color-text-heading); - line-height: 1; -} - -.cardValue.healthy { - color: var(--color-success); -} - -.cardValue.warning { - color: var(--color-warning); -} - -.cardValue.critical { - color: var(--color-error); -} - -.cardSubtext { - font-size: 0.75rem; - color: var(--color-text-muted); -} - -/* Health Overview */ -.healthOverview { - background-color: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.25rem; - margin-bottom: 1.5rem; -} - -.healthOverviewHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; -} - -.healthOverviewTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); - margin: 0; -} - -.healthOverviewSubtitle { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-muted); -} +/* --- Dashboard Grid --- */ -.healthBar { - display: flex; - height: 1.25rem; - border-radius: 0.375rem; - overflow: hidden; - background-color: var(--color-bg-hover); -} - -.healthSegment { - transition: width 0.3s ease; - min-width: 2px; -} - -.segmentHealthy { - background-color: var(--color-success); -} - -.segmentWarning { - background-color: var(--color-warning); -} - -.segmentCritical { - background-color: var(--color-error); -} - -.segmentUnknown { - background-color: var(--color-text-muted); - opacity: 0.3; -} - -.healthLegend { - display: flex; - gap: 1rem; - margin-top: 0.625rem; -} - -.healthLegendItem { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.75rem; - color: var(--color-text-muted); -} - -.healthLegendDot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - flex-shrink: 0; -} - -/* Section Layout */ -.sectionsGrid { +.dashboard { display: grid; grid-template-columns: 1fr 1fr; - gap: 1.5rem; - margin-bottom: 1.5rem; + grid-template-areas: + "summary summary" + "health health" + "issues activity" + "teams unstable" + "polling polling"; + gap: var(--space-4); +} + +.areaSummary { grid-area: summary; } +.areaHealth { grid-area: health; } +.areaIssues { grid-area: issues; } +.areaActivity { grid-area: activity; } +.areaTeams { grid-area: teams; } +.areaUnstable { grid-area: unstable; } +.areaPolling { grid-area: polling; } + +/* --- Summary Cards (shared styles in SummaryCards.module.css) --- */ + +.summaryCardClickable { + cursor: pointer; } +/* --- Section Cards --- */ + .section { - background-color: var(--color-bg-card); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); overflow: hidden; -} - -.sectionFullWidth { - grid-column: 1 / -1; + display: flex; + flex-direction: column; } .sectionHeader { display: flex; justify-content: space-between; align-items: center; - padding: 1rem 1.25rem; - border-bottom: 1px solid var(--color-border); - background-color: var(--color-bg-hover); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } .sectionTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } .sectionLink { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); + transition: color var(--duration-fast) ease; } .sectionLink:hover { - text-decoration: underline; + color: var(--color-accent-hover); } .sectionContent { padding: 0; + flex: 1; } -/* Issues List */ +/* --- Issues List (Services with Issues) --- */ + .issuesList { list-style: none; margin: 0; @@ -333,10 +230,10 @@ .issueItem { display: flex; align-items: center; - gap: 0.75rem; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); - transition: background-color 0.15s; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; } .issueItem:last-child { @@ -344,38 +241,38 @@ } .issueItem:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .issueLink { flex: 1; display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); text-decoration: none; color: inherit; } .issueName { - 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); } .issueTeam { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } .issueStats { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.75rem; + font-size: var(--font-xs); + font-variant-numeric: tabular-nums; color: var(--color-text-muted); + white-space: nowrap; } -/* Team Health List */ +/* --- Team Health List --- */ + .teamList { list-style: none; margin: 0; @@ -386,9 +283,9 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); - transition: background-color 0.15s; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; } .teamItem:last-child { @@ -396,7 +293,7 @@ } .teamItem:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .teamLink { @@ -406,45 +303,35 @@ } .teamName { - 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); } .teamStats { display: flex; align-items: center; - gap: 1rem; + gap: var(--space-3); } .teamStat { display: flex; align-items: center; - gap: 0.25rem; - font-size: 0.75rem; + gap: var(--space-1); + font-size: var(--font-xs); font-variant-numeric: tabular-nums; -} - -.teamStat.healthy { - color: var(--color-success); -} - -.teamStat.warning { - color: var(--color-warning); -} - -.teamStat.critical { - color: var(--color-error); + color: var(--color-text-muted); } .teamStatDot { - width: 0.5rem; - height: 0.5rem; + width: 8px; + height: 8px; border-radius: 50%; + flex-shrink: 0; } .teamStatDot.healthy { - background-color: var(--color-success); + background-color: var(--color-healthy); } .teamStatDot.warning { @@ -452,10 +339,11 @@ } .teamStatDot.critical { - background-color: var(--color-error); + background-color: var(--color-critical); } -/* Recent Activity List */ +/* --- Recent Activity --- */ + .activityList { list-style: none; margin: 0; @@ -464,39 +352,30 @@ .activityItem { display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } .activityItem:last-child { border-bottom: none; } -.activityIcon { - width: 2rem; - height: 2rem; +.activityDot { + width: 8px; + height: 8px; border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; flex-shrink: 0; + margin-top: 6px; } -.activityIcon.healthy { - background-color: rgba(34, 197, 94, 0.1); - color: var(--color-success); +.activityDot.healthy { + background-color: var(--color-healthy); } -.activityIcon.warning { - background-color: rgba(245, 158, 11, 0.1); - color: var(--color-warning); -} - -.activityIcon.critical { - background-color: rgba(220, 38, 38, 0.1); - color: var(--color-error); +.activityDot.critical { + background-color: var(--color-critical); } .activityContent { @@ -505,27 +384,29 @@ } .activityText { - font-size: 0.875rem; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); + line-height: var(--line-height-normal); } .activityLink { color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); } .activityLink:hover { - text-decoration: underline; + color: var(--color-accent-hover); } .activityTime { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); - margin-top: 0.125rem; + margin-top: 2px; } -/* Unstable Dependencies */ +/* --- Unstable Dependencies --- */ + .unstableList { list-style: none; margin: 0; @@ -536,9 +417,9 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); - transition: background-color 0.15s; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; } .unstableItem:last-child { @@ -546,30 +427,30 @@ } .unstableItem:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .unstableInfo { display: flex; align-items: center; - gap: 0.625rem; + gap: var(--space-2); flex: 1; min-width: 0; } .unstableDot { - width: 0.5rem; - height: 0.5rem; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; } .unstableDot.healthy { - background-color: var(--color-success); + background-color: var(--color-healthy); } .unstableDot.critical { - background-color: var(--color-error); + background-color: var(--color-critical); } .unstableText { @@ -579,8 +460,8 @@ } .unstableName { - font-size: 0.875rem; - font-weight: 500; + font-size: var(--font-base); + font-weight: var(--font-medium); color: var(--color-accent); text-decoration: none; white-space: nowrap; @@ -589,57 +470,76 @@ } .unstableName:hover { - text-decoration: underline; + color: var(--color-accent-hover); } .unstableService { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } .unstableBar { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 40%; flex-shrink: 0; } +.unstableBarTrack { + flex: 1; + height: 6px; + background-color: var(--color-border-subtle); + border-radius: var(--radius-sm); + overflow: hidden; +} + .unstableBarFill { - height: 0.375rem; + height: 100%; + border-radius: var(--radius-sm); + transition: width var(--duration-slow) ease; +} + +.unstableBarFill.healthy { + background-color: var(--color-healthy); +} + +.unstableBarFill.warning { background-color: var(--color-warning); - border-radius: 0.1875rem; - flex: 1; - transition: width 0.3s ease; +} + +.unstableBarFill.critical { + background-color: var(--color-critical); } .unstableCount { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-sm); + font-weight: var(--font-semibold); font-variant-numeric: tabular-nums; color: var(--color-text-muted); min-width: 1.5rem; text-align: right; } -/* Polling Issues */ +/* --- Polling Issues --- */ + .pollingIssuesBadge { display: inline-flex; align-items: center; justify-content: center; min-width: 1.25rem; height: 1.25rem; - padding: 0 0.375rem; - font-size: 0.6875rem; - font-weight: 700; + padding: 0 var(--space-1); + font-size: var(--font-xs); + font-weight: var(--font-semibold); border-radius: 9999px; background-color: var(--color-warning); color: #fff; } .pollingDot { - width: 0.5rem; - height: 0.5rem; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; } @@ -649,31 +549,37 @@ } .pollingDot.critical { - background-color: var(--color-error); + background-color: var(--color-critical); } .pollingIssueDetail { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); white-space: nowrap; } -/* Empty State */ +/* --- Empty State --- */ + .emptySection { - padding: 2rem 1.25rem; + padding: var(--space-6) var(--space-4); text-align: center; color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); } -/* Loading State */ +/* --- Loading / Skeleton States --- */ + .loading { display: flex; 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); } @@ -692,65 +598,119 @@ } } -/* Error State */ +.skeletonCard { + background: var(--color-border-subtle); + border-radius: var(--radius-sm); + animation: pulse 1.5s ease-in-out infinite; +} + +.skeletonLine { + height: 12px; + 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; } +} + +/* Skeleton grid layout for initial load */ +.skeletonDashboard { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: + "summary summary" + "health health" + "issues activity" + "teams unstable" + "polling polling"; + gap: var(--space-4); +} + +.skeletonSummaryGrid { + grid-area: summary; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); +} + +.skeletonSummaryCard { + composes: skeletonCard; + height: 96px; +} + +.skeletonSection { + composes: skeletonCard; + height: 200px; +} + +.skeletonHealthBar { + composes: skeletonCard; + grid-area: health; + height: 80px; +} + +/* --- Error State --- */ + .error { 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; + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + color: var(--color-text-secondary); + background-color: var(--color-surface); + 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); } -/* Responsive */ -@media (max-width: 1024px) { - .summaryGrid { - grid-template-columns: repeat(2, 1fr); - } +/* --- Responsive --- */ - .sectionsGrid { +@media (max-width: 1024px) { + .dashboard, + .skeletonDashboard { grid-template-columns: 1fr; + grid-template-areas: + "summary" + "health" + "issues" + "activity" + "teams" + "unstable" + "polling"; } } @media (max-width: 640px) { .container { - padding: 1rem; - } - - .summaryGrid { - grid-template-columns: 1fr; + padding: var(--space-4); } .header { flex-direction: column; align-items: flex-start; - gap: 1rem; + gap: var(--space-3); } .headerActions { width: 100%; justify-content: flex-end; } - - .cardValue { - font-size: 1.5rem; - } } diff --git a/client/src/components/pages/Dashboard/Dashboard.test.tsx b/client/src/components/pages/Dashboard/Dashboard.test.tsx index 24bdf45..ef3a650 100644 --- a/client/src/components/pages/Dashboard/Dashboard.test.tsx +++ b/client/src/components/pages/Dashboard/Dashboard.test.tsx @@ -87,12 +87,14 @@ beforeEach(() => { }); describe('Dashboard', () => { - it('shows loading state initially', () => { + it('shows loading skeleton initially', () => { mockFetch.mockImplementation(() => new Promise(() => {})); - renderDashboard(); + const { container } = renderDashboard(); - expect(screen.getByText('Loading dashboard...')).toBeInTheDocument(); + // Skeleton dashboard is rendered during loading + expect(container.querySelector('[class*="skeletonDashboard"]')).toBeInTheDocument(); + expect(container.querySelectorAll('[class*="skeletonSummaryCard"]').length).toBe(4); }); it('renders dashboard content after loading', async () => { @@ -101,11 +103,10 @@ describe('Dashboard', () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Total Services')).toBeInTheDocument(); }); // Summary stats - expect(screen.getByText('Total Services')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); // Total expect(screen.getByText('2 teams')).toBeInTheDocument(); }); @@ -133,7 +134,7 @@ describe('Dashboard', () => { fireEvent.click(screen.getByText('Retry')); await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Total Services')).toBeInTheDocument(); }); }); @@ -232,8 +233,8 @@ describe('Dashboard', () => { expect(screen.getByText('Total Services')).toBeInTheDocument(); }); - // Find and click the clickable card - const card = screen.getByText('Total Services').closest('[class*="summaryCard"]'); + // Find and click the clickable total card + const card = screen.getByText('Total Services').closest('[class*="summaryCardClickable"]'); fireEvent.click(card!); expect(mockNavigate).toHaveBeenCalledWith('/services'); @@ -245,8 +246,10 @@ describe('Dashboard', () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText('No teams with services')).toBeInTheDocument(); + expect(screen.getByText('Health by Team')).toBeInTheDocument(); }); + + expect(screen.getByText('No teams with services')).toBeInTheDocument(); }); it('shows no activity message when empty', async () => { @@ -282,6 +285,18 @@ describe('Dashboard', () => { expect(screen.getAllByText('Service B').length).toBeGreaterThan(0); }); + it('always renders polling issues section with empty state', async () => { + mockDashboardFetches(mockServices, mockTeams); + + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText('Polling Issues')).toBeInTheDocument(); + }); + + expect(screen.getByText('No polling issues')).toBeInTheDocument(); + }); + describe('Health Overview', () => { it('displays health overview bar when services exist', async () => { mockDashboardFetches(mockServices, mockTeams); @@ -321,16 +336,17 @@ describe('Dashboard', () => { expect(screen.getByText('Critical (1)')).toBeInTheDocument(); }); - it('does not show health overview when no services', async () => { + it('shows empty state in health overview when no services', async () => { mockDashboardFetches([], []); renderDashboard(); await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Health Overview')).toBeInTheDocument(); }); - expect(screen.queryByText('Health Overview')).not.toBeInTheDocument(); + expect(screen.getByText('No services registered')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Health distribution bar' })).not.toBeInTheDocument(); }); it('shows 100% healthy when all services are healthy', async () => { diff --git a/client/src/components/pages/Dashboard/Dashboard.tsx b/client/src/components/pages/Dashboard/Dashboard.tsx index d59cfb7..828e2db 100644 --- a/client/src/components/pages/Dashboard/Dashboard.tsx +++ b/client/src/components/pages/Dashboard/Dashboard.tsx @@ -5,6 +5,7 @@ import { formatRelativeTime } from '../../../utils/formatting'; import { getHealthBadgeStatus } from '../../../utils/statusMapping'; import { usePolling, INTERVAL_OPTIONS } from '../../../hooks/usePolling'; import { useDashboard } from '../../../hooks/useDashboard'; +import cardStyles from '../../common/SummaryCards.module.css'; import styles from './Dashboard.module.css'; function Dashboard() { @@ -38,9 +39,24 @@ function Dashboard() { if (isLoading) { return (
-
-
- Loading dashboard... +
+
+

Dashboard

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
); @@ -99,99 +115,103 @@ function Dashboard() {
- {/* Summary Cards */} -
-
navigate('/services')} - > - Total Services - {stats.total} - {teams.length} teams -
-
- Healthy - {stats.healthy} - - {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% of services - -
-
- Warning - {stats.warning} - need attention -
-
- Critical - {stats.critical} - require action -
-
- - {/* Health Overview Bar */} - {stats.total > 0 && ( -
-
-

Health Overview

- - {Math.round((stats.healthy / stats.total) * 100)}% healthy + {/* Dashboard Grid */} +
+ {/* Summary Cards */} +
+
navigate('/services')} + > + Total Services + {stats.total} + {teams.length} teams +
+
+ Healthy + {stats.healthy} + + {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% of services
-
- {stats.healthy > 0 && ( -
- )} - {stats.warning > 0 && ( -
- )} - {stats.critical > 0 && ( -
- )} - {stats.total - stats.healthy - stats.warning - stats.critical > 0 && ( -
- )} +
+ Warning + {stats.warning} + need attention +
+
+ Critical + {stats.critical} + require action
-
- - - Healthy ({stats.healthy}) +
+ + {/* Health Overview Bar */} +
+
+

Health Overview

+ + {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% healthy - {stats.warning > 0 && ( - - - Warning ({stats.warning}) - - )} - {stats.critical > 0 && ( - - - Critical ({stats.critical}) - - )}
+ {stats.total > 0 ? ( + <> +
+ {stats.healthy > 0 && ( +
+ )} + {stats.warning > 0 && ( +
+ )} + {stats.critical > 0 && ( +
+ )} + {stats.total - stats.healthy - stats.warning - stats.critical > 0 && ( +
+ )} +
+
+ + + Healthy ({stats.healthy}) + + {stats.warning > 0 && ( + + + Warning ({stats.warning}) + + )} + {stats.critical > 0 && ( + + + Critical ({stats.critical}) + + )} +
+ + ) : ( +
No services registered
+ )}
- )} - {/* Main Content Grid */} -
{/* Services with Issues */} -
+

Services with Issues

@@ -227,40 +247,48 @@ function Dashboard() {
- {/* Polling Issues */} - {servicesWithPollingIssues.length > 0 && ( -
-
-

Polling Issues

- - {servicesWithPollingIssues.length} - -
-
-
    - {servicesWithPollingIssues.map(svc => ( -
  • - - -
    -
    {svc.name}
    -
    {svc.teamName}
    + {/* 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 ( +
    • +
      +
      +
      + + {event.service_name} + + {' '}{event.dependency_name}: {previousLabel} → {currentLabel} +
      +
      + {formatRelativeTime(event.recorded_at)} +
      - -
      - {svc.pollError - ? 'Poll failed' - : `${svc.warningCount} warning${svc.warningCount !== 1 ? 's' : ''}`} -
      -
    • - ))} + + ); + })}
    -
    + ) : ( +
    + No recent status changes +
    + )}
    - )} +
    {/* Team Health Summary */} -
    +

    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() {
    -
    +
    +
    +
    {dep.change_count}
  • @@ -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 ? ( +
    + {servicesWithPollingIssues.map(svc => ( +
  • + + +
    +
    {svc.name}
    +
    {svc.teamName}
    +
    + +
    + {svc.pollError + ? 'Poll failed' + : `${svc.warningCount} warning${svc.warningCount !== 1 ? 's' : ''}`} +
    +
  • + ))} +
+ ) : ( +
+ 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() {
- setSearchQuery(e.target.value)} - /> +
+ + setSearchQuery(e.target.value)} + /> +
{isolationTarget && ( @@ -434,9 +449,7 @@ function DependencyGraphInner() { onClick={exitIsolation} title="Exit isolated view and show all nodes" > - - - + Show full graph
@@ -445,7 +458,7 @@ function DependencyGraphInner() {
{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
- + Direction
- + 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

{displayName}

@@ -112,9 +111,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs {hasError && (
- - - + Error Detected
{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 && (
-
+ Loading catalog...
)} @@ -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 */} +
    +

    Latency

    +
    + +
    +
    + + {/* 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 && ( +
    + +
    + )} +
    + ); +} + +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 - - - {isAdmin && ( - <> - + + View in Graph + - - )} -
    -
    + {isAdmin && ( + <> + + + + )} +
    +
-
-
-
- Team - {service.team.name} +
+
+
+ Team + {service.team.name} +
+
+ Health Endpoint + + + {service.health_endpoint} + + +
+ {service.metrics_endpoint && ( +
+ Metrics Endpoint + + + {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'} - + ) : ( +
+ + + + + + + + + + + + {service.dependent_reports.map((report) => ( + + + + + + + + ))} + +
Reporting ServiceDependency NameStatusLatencyLast Checked
+ + {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.

-
- ) : ( -
- - - - - - - - - - - - {service.dependent_reports.map((report) => ( - - - - - - - - ))} - -
Reporting ServiceDependency NameStatusLatencyLast Checked
- - {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 (
-
+ Loading services...
@@ -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 && ( +
+ +
- ); - })()} -
- {isAdmin && ( -
- - + )}
- )} -
- - {/* Members Section */} -
-
-

Members

- - {team.members.length} {team.members.length === 1 ? 'member' : 'members'} - -
+ + - {isAdmin && availableUsers.length > 0 && ( -
-
- - -
-
- - setSelectedUserId(e.target.value)} + className={styles.select} + disabled={isAddingMember} + > + + {availableUsers.map((u) => ( + + ))} + +
+
+ + +
+
- -
- )} + )} - {addMemberError && ( -
- {addMemberError} -
- )} + {addMemberError && ( +
+ {addMemberError} +
+ )} - {team.members.length === 0 ? ( -
-

No members in this team yet.

-
- ) : ( -
- - - - - - - {isAdmin && } - - - - {team.members.map((member) => ( - - - - - {isAdmin && ( + {team.members.length === 0 ? ( +
+

No members in this team yet.

+
+ ) : ( +
+
NameEmailRoleActions
{member.user.name}{member.user.email} - - {member.role === 'lead' ? 'Lead' : 'Member'} - -
+ + + + + + {isAdmin && } + + + + {team.members.map((member) => ( + + + - )} - - ))} - -
NameEmailRoleActions
{member.user.name}{member.user.email} -
- - -
+ + {member.role === 'lead' ? 'Lead' : 'Member'} +
-
- )} -
- - {/* Manifest Sync Section */} - + {isAdmin && ( + +
+ + +
+ + )} + + ))} + + +
+ )} + - {/* 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 (
-
+ Loading teams...
@@ -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

{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\"",