feat: Authentik login + switchable GPU prod target

Add OIDC auth for Command Center and runtime GPU endpoint selection
pointed at atc-gpu-prod (10.0.10.106), matching what is currently deployed.
This commit is contained in:
mo
2026-07-21 23:20:24 +00:00
parent f36c8906bc
commit 9008fbd512
31 changed files with 4667 additions and 139 deletions
+2868
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -1,4 +1,6 @@
import { useRef, useState } from 'react'
import { useAuth } from './hooks/useAuth'
import { LoginView } from './components/features/LoginView'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useClock } from './hooks/useClock'
import { useCommandCenter } from './hooks/useCommandCenter'
@@ -27,6 +29,7 @@ import { resolveInfraNode } from './lib/infraCatalog'
import { cn } from './lib/utils'
export default function App() {
const auth = useAuth()
const clock = useClock()
const cc = useCommandCenter()
const [gpuChatActive, setGpuChatActive] = useState(false)
@@ -53,6 +56,19 @@ export default function App() {
return 'Lab'
})()
if (auth.status === 'loading') {
return (
<div className="flex h-full min-h-screen items-center justify-center bg-surface text-sm text-foreground-muted">
Checking session
</div>
)
}
if (auth.status === 'anon') {
return <LoginView />
}
const userLabel = auth.user?.name || auth.user?.preferred_username || auth.user?.email || 'Signed in'
return (
<div className="flex h-full flex-col overflow-hidden bg-surface">
<TopBar
@@ -62,6 +78,8 @@ export default function App() {
agents={cc.agents}
approvals={cc.approvals}
onApprovalsClick={openApprovals}
userLabel={userLabel}
onLogout={() => { window.location.href = '/auth/logout' }}
/>
<div className="flex min-h-0 flex-1 overflow-hidden">
+213 -5
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Zap } from 'lucide-react'
import { fetchGpu } from '../../lib/api'
import type { GpuDevice, GpuStatus } from '../../types'
import { Activity, ChevronDown, ChevronUp, Cpu, ExternalLink, Settings2, Zap } from 'lucide-react'
import { fetchGpu, fetchGpuConfig, resetGpuConfig, saveGpuConfig, testGpuConfig } from '../../lib/api'
import type { GpuConfigPayload, GpuConfigTestResult, GpuDevice, GpuStatus } from '../../types'
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
import { cn } from '../../lib/utils'
@@ -53,6 +53,13 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
const [localGpu, setLocalGpu] = useState<GpuStatus | null>(gpu)
const [lastPoll, setLastPoll] = useState<Date | null>(null)
const [expanded, setExpanded] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [gpuConfig, setGpuConfig] = useState<GpuConfigPayload | null>(null)
const [selectedPreset, setSelectedPreset] = useState('gpu-prod')
const [customHost, setCustomHost] = useState('')
const [configBusy, setConfigBusy] = useState(false)
const [testResult, setTestResult] = useState<GpuConfigTestResult | null>(null)
const [configMsg, setConfigMsg] = useState<string | null>(null)
useEffect(() => {
setLocalGpu(gpu)
@@ -62,6 +69,19 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
if (boost) setExpanded(true)
}, [boost])
useEffect(() => {
if (!showSettings) return
fetchGpuConfig().then((cfg) => {
if (!cfg) return
setGpuConfig(cfg)
const active = cfg.active
setSelectedPreset(active.preset_id === 'env' ? 'gpu-prod' : active.preset_id)
if (active.preset_id === 'custom' || active.source === 'override') {
setCustomHost(active.host)
}
})
}, [showSettings])
useEffect(() => {
const poll = async () => {
const g = await fetchGpu()
@@ -76,6 +96,49 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
return () => clearInterval(iv)
}, [boost])
const handleTestTarget = async () => {
setConfigBusy(true)
setTestResult(null)
setConfigMsg(null)
const body =
selectedPreset === 'custom'
? { preset_id: 'custom', host: customHost.trim() }
: { preset_id: selectedPreset }
const result = await testGpuConfig(body)
setTestResult(result)
setConfigBusy(false)
}
const handleSaveTarget = async () => {
setConfigBusy(true)
setConfigMsg(null)
const body =
selectedPreset === 'custom'
? { preset_id: 'custom', host: customHost.trim() }
: { preset_id: selectedPreset }
const res = await saveGpuConfig(body)
setConfigBusy(false)
if (res?.active) {
setConfigMsg(`Saved → ${res.active.label}`)
const g = await fetchGpu()
if (g) setLocalGpu(g)
} else {
setConfigMsg(res?.detail || 'Save failed')
}
}
const handleResetTarget = async () => {
setConfigBusy(true)
await resetGpuConfig()
setSelectedPreset('gpu-prod')
setCustomHost('')
setTestResult(null)
setConfigMsg('Reset to environment default')
const g = await fetchGpu()
if (g) setLocalGpu(g)
setConfigBusy(false)
}
const g = localGpu
const devices = g?.gpus || []
const inferenceOn = g?.ok && g.inference_active
@@ -102,7 +165,31 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
<h2 className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-widest text-foreground-faint">
<Cpu className="h-3 w-3" /> GPU Matrix
</h2>
<p className="mt-1 text-[9px] text-foreground-faint">GPU Lab offline</p>
<p className="mt-1 text-[9px] text-foreground-faint">
GPU Lab offline{g?.host ? ` · ${g.ip || g.host}` : ''}
</p>
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className="mt-1 flex items-center gap-1 text-[8px] text-docker hover:underline"
>
<Settings2 className="h-2.5 w-2.5" /> GPU target
</button>
{showSettings && gpuConfig && (
<GpuTargetSettings
gpuConfig={gpuConfig}
selectedPreset={selectedPreset}
customHost={customHost}
configBusy={configBusy}
testResult={testResult}
configMsg={configMsg}
onPresetChange={setSelectedPreset}
onCustomHostChange={setCustomHost}
onTest={handleTestTarget}
onSave={handleSaveTarget}
onReset={handleResetTarget}
/>
)}
</section>
)
}
@@ -130,6 +217,17 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
)}
</button>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => setShowSettings((v) => !v)}
className={cn(
'rounded p-1 hover:bg-surface-overlay',
showSettings ? 'text-docker' : 'text-foreground-muted hover:text-foreground',
)}
title="GPU target settings"
>
<Settings2 className="h-3 w-3" />
</button>
{g.ui_url && (
<a
href={g.ui_url}
@@ -207,11 +305,121 @@ export function GpuMatrixPanel({ gpu, live, boost = false, onSelectGpu }: Props)
</div>
<p className="font-mono text-[7px] text-foreground-faint">
{g.gpu_count ?? devices.length}× V100 · {g.host} · poll {boost ? '1s' : '3s'}
{g.gpu_count ?? devices.length}× V100 · {g.ip || g.host}
{g.config_label && g.config_source === 'override' ? ` · ${g.config_label}` : ''}
{' · poll '}{boost ? '1s' : '3s'}
{lastPoll && ` · ${lastPoll.toLocaleTimeString()}`}
</p>
{showSettings && gpuConfig && (
<GpuTargetSettings
gpuConfig={gpuConfig}
selectedPreset={selectedPreset}
customHost={customHost}
configBusy={configBusy}
testResult={testResult}
configMsg={configMsg}
onPresetChange={setSelectedPreset}
onCustomHostChange={setCustomHost}
onTest={handleTestTarget}
onSave={handleSaveTarget}
onReset={handleResetTarget}
/>
)}
</div>
)}
</section>
)
}
type GpuTargetSettingsProps = {
gpuConfig: GpuConfigPayload
selectedPreset: string
customHost: string
configBusy: boolean
testResult: GpuConfigTestResult | null
configMsg: string | null
onPresetChange: (id: string) => void
onCustomHostChange: (host: string) => void
onTest: () => void
onSave: () => void
onReset: () => void
}
function GpuTargetSettings({
gpuConfig,
selectedPreset,
customHost,
configBusy,
testResult,
configMsg,
onPresetChange,
onCustomHostChange,
onTest,
onSave,
onReset,
}: GpuTargetSettingsProps) {
return (
<div className="mt-1.5 rounded border border-border/80 bg-surface-overlay/40 p-2 space-y-1.5">
<p className="text-[8px] font-semibold uppercase tracking-wider text-foreground-faint">GPU Target</p>
<select
value={selectedPreset}
onChange={(e) => onPresetChange(e.target.value)}
className="w-full rounded border border-border bg-surface px-1.5 py-1 font-mono text-[9px] text-foreground"
>
{gpuConfig.presets.map((p) => (
<option key={p.id} value={p.id}>
{p.label} ({p.host})
</option>
))}
<option value="custom">Custom IP</option>
</select>
{selectedPreset === 'custom' && (
<input
type="text"
value={customHost}
onChange={(e) => onCustomHostChange(e.target.value)}
placeholder="10.0.x.x"
className="w-full rounded border border-border bg-surface px-1.5 py-1 font-mono text-[9px] text-foreground"
/>
)}
<div className="flex flex-wrap gap-1">
<button
type="button"
disabled={configBusy}
onClick={onTest}
className="rounded border border-border px-2 py-0.5 font-mono text-[8px] text-foreground-muted hover:bg-surface-overlay disabled:opacity-50"
>
Test
</button>
<button
type="button"
disabled={configBusy}
onClick={onSave}
className="rounded border border-docker/40 bg-docker/10 px-2 py-0.5 font-mono text-[8px] text-docker hover:bg-docker/20 disabled:opacity-50"
>
Save
</button>
<button
type="button"
disabled={configBusy}
onClick={onReset}
className="rounded border border-border px-2 py-0.5 font-mono text-[8px] text-foreground-faint hover:bg-surface-overlay disabled:opacity-50"
>
Reset
</button>
</div>
{testResult && (
<p className={cn('font-mono text-[8px]', testResult.ok ? 'text-success' : 'text-warning')}>
{testResult.ok
? `OK · ${testResult.gpu_count} GPU(s) · ${testResult.active_model || 'no model'}`
: `Failed · ${testResult.errors.join('; ') || 'unreachable'}`}
</p>
)}
{configMsg && <p className="font-mono text-[8px] text-foreground-muted">{configMsg}</p>}
<p className="font-mono text-[7px] text-foreground-faint">
Active: {gpuConfig.active.label} ({gpuConfig.active.host})
</p>
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Box, LogIn } from 'lucide-react'
import { useMemo } from 'react'
export function LoginView() {
const error = useMemo(() => {
try {
const p = new URLSearchParams(window.location.search)
const err = p.get('error')
if (!err) return null
if (err === 'login_failed') return 'Sign-in failed — try again.'
return err
} catch {
return null
}
}, [])
return (
<div className="flex h-full min-h-screen flex-col items-center justify-center bg-surface px-4">
<div className="w-full max-w-md border border-border bg-surface-raised/90 p-8 shadow-panel backdrop-blur-sm">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-docker to-blue-600 shadow-docker">
<Box className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-foreground-muted">ATC Lab</p>
<h1 className="text-lg font-semibold text-foreground">Data & AI Command Center</h1>
</div>
</div>
<p className="mb-6 text-sm text-foreground-muted">
Sign in with Authentik to open the ops glass. Lab environment not an official Dell product.
</p>
<a
href="/auth/login"
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-docker px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90"
>
<LogIn className="h-4 w-4" />
Continue with Authentik
</a>
{error ? (
<p className="mt-4 text-sm text-warning" role="alert">
{error}
</p>
) : null}
</div>
</div>
)
}
+20 -2
View File
@@ -1,4 +1,4 @@
import { Activity, Bot, Box, Clock, ShieldAlert } from 'lucide-react'
import { Activity, Bot, Box, Clock, LogOut, ShieldAlert } from 'lucide-react'
import type { Agent, Approval, StatusData, WorkloadData } from '../../types'
import { Badge } from '../ui/Badge'
import { ThemeToggle } from './ThemeToggle'
@@ -11,9 +11,11 @@ type Props = {
agents: Agent[]
approvals: Approval[]
onApprovalsClick: () => void
userLabel?: string
onLogout?: () => void
}
export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }: Props) {
export function TopBar({ clock, workload, agents, approvals, onApprovalsClick, userLabel, onLogout }: Props) {
const pipelineOk = workload?.totals?.pipeline_active ?? false
const running = workload?.totals?.apps_running ?? 0
const total = workload?.totals?.apps_total ?? 0
@@ -53,6 +55,22 @@ export function TopBar({ clock, workload, agents, approvals, onApprovalsClick }:
</div>
<div className="flex items-center gap-2">
{userLabel ? (
<span className="hidden max-w-[10rem] truncate text-[11px] text-foreground-muted sm:inline" title={userLabel}>
{userLabel}
</span>
) : null}
{onLogout ? (
<button
type="button"
onClick={onLogout}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[10px] text-foreground-muted hover:bg-surface hover:text-foreground"
title="Logout"
>
<LogOut className="h-3 w-3" />
Logout
</button>
) : null}
<ThemeToggle />
<div className="flex items-center gap-1.5 font-mono text-[10px] text-foreground-muted">
<Clock className="h-3 w-3" />
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react'
export type AuthUser = {
user?: string
email?: string
name?: string
preferred_username?: string
auth_enabled?: boolean
}
type AuthState =
| { status: 'loading'; user: null }
| { status: 'anon'; user: null }
| { status: 'authed'; user: AuthUser }
export function useAuth(): AuthState & { refresh: () => Promise<void> } {
const [state, setState] = useState<AuthState>({ status: 'loading', user: null })
const refresh = useCallback(async () => {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' })
if (r.status === 401) {
setState({ status: 'anon', user: null })
return
}
if (!r.ok) {
setState({ status: 'anon', user: null })
return
}
const me = (await r.json()) as AuthUser
setState({ status: 'authed', user: me })
} catch {
setState({ status: 'anon', user: null })
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
return { ...state, refresh }
}
+58 -8
View File
@@ -6,6 +6,8 @@ import type {
CdcStats,
DataflowGraph,
FeedEntry,
GpuConfigPayload,
GpuConfigTestResult,
GpuStatus,
Movement,
PiiDataset,
@@ -21,7 +23,7 @@ async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T | null> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
try {
const r = await fetch(url, { signal: ctrl.signal })
const r = await fetch(url, { signal: ctrl.signal, credentials: 'same-origin' })
if (!r.ok) return null
return (await r.json()) as T
} catch {
@@ -62,6 +64,54 @@ export async function fetchGpu(): Promise<GpuStatus | null> {
return fetchJson<GpuStatus>('/api/gpu', 8000)
}
export async function fetchGpuConfig(): Promise<GpuConfigPayload | null> {
return fetchJson<GpuConfigPayload>('/api/gpu/config', 8000)
}
export async function saveGpuConfig(body: {
preset_id?: string
host?: string
gpu_ui_port?: number
llm_port?: number
}) {
const r = await fetch('/api/gpu/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return r.json()
}
export async function testGpuConfig(body: {
preset_id?: string
host?: string
gpu_ui_port?: number
llm_port?: number
}): Promise<GpuConfigTestResult | null> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 12000)
try {
const r = await fetch('/api/gpu/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: ctrl.signal,
})
if (!r.ok) return null
return (await r.json()) as GpuConfigTestResult
} catch {
return null
} finally {
clearTimeout(timer)
}
}
export async function resetGpuConfig() {
const r = await fetch('/api/gpu/config', { method: 'DELETE' , credentials: 'same-origin' })
return r.json()
}
export async function fetchTerminals(): Promise<Record<string, TerminalLine[]>> {
const j = await fetchJson<{ terminals?: Record<string, TerminalLine[]> }>('/api/terminals', 8000)
return j?.terminals || {}
@@ -77,7 +127,7 @@ export async function fetchNodeDetail(nodeId: string) {
}
export function probeNode(nodeId: string) {
return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' })
return fetch(`/api/nodes/${nodeId}/probe`, { method: 'POST' , credentials: 'same-origin' })
}
export function askNode(nodeId: string, message: string) {
@@ -207,19 +257,19 @@ export function triggerStreamingJob(jobId: string, conf?: Record<string, unknown
}
export function restartKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/restart`, { method: 'POST' })
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/restart`, { method: 'POST' , credentials: 'same-origin' })
}
export function pauseKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/pause`, { method: 'POST' })
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/pause`, { method: 'POST' , credentials: 'same-origin' })
}
export function resumeKafkaConnector(name: string) {
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/resume`, { method: 'POST' })
return fetch(`/api/pipeline/streaming/kafka/connectors/${encodeURIComponent(name)}/resume`, { method: 'POST' , credentials: 'same-origin' })
}
export function triggerStreamingPipeline(pipelineId: string) {
return fetch(`/api/pipeline/streaming/pipeline/${encodeURIComponent(pipelineId)}`, { method: 'POST' })
return fetch(`/api/pipeline/streaming/pipeline/${encodeURIComponent(pipelineId)}`, { method: 'POST' , credentials: 'same-origin' })
}
export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?: number }) {
@@ -231,7 +281,7 @@ export function exportHdfsToKafka(body?: { path?: string; topic?: string; limit?
}
export function setStreamingFlow(action: 'pause' | 'resume' | 'stop') {
return fetch(`/api/pipeline/streaming/flow/${action}`, { method: 'POST' })
return fetch(`/api/pipeline/streaming/flow/${action}`, { method: 'POST' , credentials: 'same-origin' })
}
// ── Spark Workbench ──────────────────────────────────────────────
@@ -271,7 +321,7 @@ export async function fetchSparkRun(runId: string) {
}
export function cancelSparkRun(runId: string) {
return fetch(`/api/spark/run/${runId}/cancel`, { method: 'POST' })
return fetch(`/api/spark/run/${runId}/cancel`, { method: 'POST' , credentials: 'same-origin' })
}
export async function fetchSparkLive() {
+5 -5
View File
@@ -158,17 +158,17 @@ export const INFRA_CATALOG: InfraNode[] = [
{
id: 'gpu',
label: 'GPU Lab',
vm: 'atc-gpu-dev',
ip: '10.0.20.106',
vm: 'atc-gpu-prod',
ip: '10.0.10.106',
zone: 'gpu',
agentId: 'infra-sentinel',
icon: Sparkles,
accent: '#3fb950',
description: 'vLLM inference — Llama 3 70B on 4× V100',
ssh: 'ssh root@10.0.20.106',
ssh: 'ssh root@10.0.10.106',
apps: [
{ label: 'GPU Lab UI', url: 'http://10.0.20.106:9000', port: '9000' },
{ label: 'vLLM API', url: 'http://10.0.20.106:8001/v1', port: '8001' },
{ label: 'GPU Lab UI', url: 'http://10.0.10.106:9000', port: '9000' },
{ label: 'vLLM API', url: 'http://10.0.10.106:8001/v1', port: '8001' },
],
topoIds: ['llm', 'cons-ml'],
},
+48
View File
@@ -217,15 +217,63 @@ export type GpuDevice = {
export type GpuStatus = {
ok: boolean
host: string
ip?: string
ui_url: string
inference_active?: boolean
active_model?: string | null
vllm_url?: string | null
gpu_count?: number
gpus?: GpuDevice[]
config_source?: string
preset_id?: string
config_label?: string
error?: string
}
export type GpuPreset = {
id: string
label: string
vm: string
vmid: number
host: string
gpu_ui_port: number
llm_port: number
description: string
}
export type GpuTargetConfig = {
source: string
preset_id: string
label: string
host: string
gpu_url: string
gpu_ui_url: string
llm_url: string
env_gpu_url?: string
env_llm_url?: string
updated_at?: string
}
export type GpuConfigPayload = {
active: GpuTargetConfig
presets: GpuPreset[]
defaults: GpuTargetConfig
}
export type GpuConfigTestResult = {
ok: boolean
host: string
gpu_url: string
llm_url: string
metrics_ok: boolean
llm_ok: boolean
gpu_count: number
inference_active: boolean
active_model: string | null
errors: string[]
}
export type AgentState = 'idle' | 'walk' | 'fetch' | 'return'
export type AgentAnim = {