feat: migrate UI to @cameleer/design-system, add backend endpoints
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:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user