feat: migrate UI to @cameleer/design-system, add backend endpoints
Some checks failed
CI / build (push) Failing after 47s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Backend:
- Add agent_events table (V5) and lifecycle event recording
- Add route catalog endpoint (GET /routes/catalog)
- Add route metrics endpoint (GET /routes/metrics)
- Add agent events endpoint (GET /agents/events-log)
- Enrich AgentInstanceResponse with tps, errorRate, activeRoutes, uptimeSeconds
- Add TimescaleDB retention/compression policies (V6)

Frontend:
- Replace custom Mission Control UI with @cameleer/design-system components
- Rebuild all pages: Dashboard, ExchangeDetail, RoutesMetrics, AgentHealth,
  AgentInstance, RBAC, AuditLog, OIDC, DatabaseAdmin, OpenSearchAdmin, Swagger
- New LayoutShell with design system AppShell, Sidebar, TopBar, CommandPalette
- Consume design system from Gitea npm registry (@cameleer/design-system@0.0.1)
- Add .npmrc for scoped registry, update Dockerfile with REGISTRY_TOKEN arg

CI:
- Pass REGISTRY_TOKEN build-arg to UI Docker build step

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-19 17:38:39 +01:00
parent 82124c3145
commit 2b111c603c
150 changed files with 2750 additions and 21779 deletions

View File

@@ -1,145 +0,0 @@
.page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
position: relative;
z-index: 1;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 40px;
width: 100%;
max-width: 400px;
animation: fadeIn 0.3s ease-out both;
}
.logo {
font-family: var(--font-mono);
font-weight: 600;
font-size: 20px;
color: var(--amber);
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.subtitle {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 28px;
}
.field {
margin-bottom: 16px;
}
.label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 6px;
}
.input {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.submit {
width: 100%;
margin-top: 8px;
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--amber);
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.submit:hover {
background: var(--amber-hover);
border-color: var(--amber-hover);
}
.submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ssoButton {
width: 100%;
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.ssoButton:hover {
border-color: var(--amber-dim);
background: var(--bg-surface);
}
.ssoButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.divider {
position: relative;
text-align: center;
margin: 20px 0;
border-top: 1px solid var(--border-subtle);
}
.dividerText {
position: relative;
top: -0.65em;
padding: 0 12px;
background: var(--bg-surface);
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.error {
margin-top: 12px;
padding: 10px 12px;
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--rose);
}

View File

@@ -2,7 +2,7 @@ import { type FormEvent, useEffect, useState } from 'react';
import { Navigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { api } from '../api/client';
import styles from './LoginPage.module.css';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
interface OidcInfo {
clientId: string;
@@ -50,62 +50,54 @@ export function LoginPage() {
};
return (
<div className={styles.page}>
<form className={styles.card} onSubmit={handleSubmit}>
<div className={styles.logo}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
<path d="M12 6v6l4 2" />
</svg>
cameleer3
</div>
<div className={styles.subtitle}>Sign in to access the observability dashboard</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
<Card>
<form onSubmit={handleSubmit} style={{ padding: '2rem', minWidth: 360 }}>
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>cameleer3</h1>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
Sign in to access the observability dashboard
</p>
</div>
{oidc && (
<>
<button
className={styles.ssoButton}
type="button"
onClick={handleOidcLogin}
disabled={oidcLoading}
>
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
</button>
<div className={styles.divider}>
<span className={styles.dividerText}>or</span>
</div>
</>
)}
{oidc && (
<>
<Button variant="secondary" onClick={handleOidcLogin} disabled={oidcLoading} style={{ width: '100%', marginBottom: '1rem' }}>
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
</Button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>or</span>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
</div>
</>
)}
<div className={styles.field}>
<label className={styles.label}>Username</label>
<input
className={styles.input}
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</div>
<FormField label="Username">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</FormField>
<div className={styles.field}>
<label className={styles.label}>Password</label>
<input
className={styles.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<FormField label="Password">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</FormField>
<button className={styles.submit} type="submit" disabled={loading || !username || !password}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
<Button variant="primary" disabled={loading || !username || !password} style={{ width: '100%', marginTop: '0.5rem' }}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
{error && <div className={styles.error}>{error}</div>}
</form>
{error && <div style={{ marginTop: '1rem' }}><Alert variant="error">{error}</Alert></div>}
</form>
</Card>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { Navigate, useNavigate } from 'react-router';
import { useAuthStore } from './auth-store';
import styles from './LoginPage.module.css';
import { Card, Spinner, Alert, Button } from '@cameleer/design-system';
export function OidcCallback() {
const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore();
@@ -36,29 +36,21 @@ export function OidcCallback() {
if (isAuthenticated) return <Navigate to="/" replace />;
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.logo}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
<path d="M12 6v6l4 2" />
</svg>
cameleer3
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
<Card>
<div style={{ padding: '2rem', textAlign: 'center', minWidth: 320 }}>
<h2 style={{ marginBottom: '1rem' }}>cameleer3</h2>
{loading && <Spinner />}
{error && (
<>
<Alert variant="error">{error}</Alert>
<Button variant="secondary" onClick={() => navigate('/login')} style={{ marginTop: 16 }}>
Back to Login
</Button>
</>
)}
</div>
{loading && <div className={styles.subtitle}>Completing sign-in...</div>}
{error && (
<>
<div className={styles.error}>{error}</div>
<button
className={styles.submit}
style={{ marginTop: 16 }}
onClick={() => navigate('/login')}
>
Back to Login
</button>
</>
)}
</div>
</Card>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { useAuth } from './use-auth';
export function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// Initialize auth hooks (auto-refresh, API client wiring)
useAuth();
if (!isAuthenticated) return <Navigate to="/login" replace />;

View File

@@ -7,7 +7,6 @@ export function useAuth() {
const { accessToken, isAuthenticated, refresh, logout } = useAuthStore();
const navigate = useNavigate();
// Wire onUnauthorized handler (needs navigate from router context)
useEffect(() => {
configureAuth({
onUnauthorized: async () => {
@@ -20,7 +19,6 @@ export function useAuth() {
});
}, [navigate]);
// Auto-refresh: check token expiry every 30s
useEffect(() => {
if (!isAuthenticated) return;
const interval = setInterval(async () => {
@@ -29,12 +27,11 @@ export function useAuth() {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
// Refresh when less than 5 minutes remaining
if (expiresIn < 5 * 60 * 1000) {
await refresh();
}
} catch {
// Token parse failure — ignore, will fail on next API call
// Token parse failure
}
}, 30_000);
return () => clearInterval(interval);