feat: live Docker data — containers, services, logs, metrics via dockerode
All checks were successful
continuous-integration/drone/push Build is passing

- Backend: rewrote containers/services/logs/metrics routes to use dockerode
- docker-compose: mount /var/run/docker.sock, run backend as root
- .drone.yml: sync docker-compose.yml from Gitea on deploy
- Frontend: Containers page shows real data with wired start/stop/restart
- Frontend: Services page shows Docker Compose stacks with health status
- Frontend: Metrics page adds disk (docker df) and containers cards + chart legend
- Frontend: Logs page replaces text input with container dropdown + auto-refresh
This commit is contained in:
Ernie Butcher
2026-03-18 18:33:25 -04:00
parent 36c76edb29
commit 4233734759
20 changed files with 1146 additions and 172 deletions

6
frontend/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# OIDC configuration for the server-manager frontend
# Copy to .env.local for local overrides
VITE_OIDC_ISSUER=https://auth.jiosii.com/oidc
VITE_OIDC_CLIENT_ID=server-manager
VITE_OIDC_REDIRECT_URI=https://sm.jiosii.com/callback

View File

@@ -7,11 +7,47 @@ import Services from '@/pages/Services'
import Logs from '@/pages/Logs'
import Metrics from '@/pages/Metrics'
import Settings from '@/pages/Settings'
import Callback from '@/pages/Callback'
import { useAuth } from '@/hooks/useAuth'
function AuthGuard({ children }: { children: React.ReactNode }) {
const { authenticated, loading } = useAuth()
if (loading || !authenticated) {
return (
<div
style={{
background: 'hsl(222, 84%, 5%)',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#94a3b8',
fontFamily: 'system-ui, sans-serif',
fontSize: '0.9375rem',
}}
>
{loading ? 'Authenticating…' : 'Redirecting to login…'}
</div>
)
}
return <>{children}</>
}
export default function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
{/* Public — OIDC callback, no auth required */}
<Route path="/callback" element={<Callback />} />
{/* Protected routes */}
<Route
path="/"
element={
<AuthGuard>
<Layout />
</AuthGuard>
}
>
<Route index element={<Dashboard />} />
<Route path="servers" element={<Servers />} />
<Route path="containers" element={<Containers />} />

View File

@@ -0,0 +1,123 @@
/**
* OIDC Authentication hook.
*
* Flow:
* 1. On mount, check sessionStorage for a valid access_token.
* 2. If none, build an OIDC auth URL with PKCE and redirect.
* 3. On /callback, exchange code + verifier via the backend (keeps client_secret server-side).
* 4. Store the token in sessionStorage and set it on axios.
*/
import { useEffect, useState, useCallback } from 'react'
import { api } from '@/lib/api'
const ISSUER = import.meta.env.VITE_OIDC_ISSUER ?? 'https://auth.jiosii.com/oidc'
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID ?? 'server-manager'
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI ?? `${window.location.origin}/callback`
const TOKEN_KEY = 'sm_access_token'
const VERIFIER_KEY = 'sm_code_verifier'
const STATE_KEY = 'sm_oidc_state'
// PKCE helpers
async function generateCodeVerifier(): Promise<string> {
const array = crypto.getRandomValues(new Uint8Array(32))
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function generateState(): string {
const array = crypto.getRandomValues(new Uint8Array(16))
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
export function getStoredToken(): string | null {
return sessionStorage.getItem(TOKEN_KEY)
}
export function clearAuth() {
sessionStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(VERIFIER_KEY)
sessionStorage.removeItem(STATE_KEY)
delete api.defaults.headers.common['Authorization']
}
export function useAuth() {
const [authenticated, setAuthenticated] = useState<boolean>(() => {
const token = getStoredToken()
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
return true
}
return false
})
const [loading, setLoading] = useState(false)
const startLogin = useCallback(async () => {
const verifier = await generateCodeVerifier()
const challenge = await generateCodeChallenge(verifier)
const state = generateState()
sessionStorage.setItem(VERIFIER_KEY, verifier)
sessionStorage.setItem(STATE_KEY, state)
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email offline_access',
code_challenge: challenge,
code_challenge_method: 'S256',
state,
})
window.location.href = `${ISSUER}/auth?${params}`
}, [])
// Exchange the code on callback
const handleCallback = useCallback(async (code: string, state: string) => {
const storedState = sessionStorage.getItem(STATE_KEY)
const verifier = sessionStorage.getItem(VERIFIER_KEY)
if (!verifier || state !== storedState) {
throw new Error('State mismatch — possible CSRF attack')
}
setLoading(true)
try {
const res = await api.post<{ access_token: string }>('/auth/exchange', {
code,
code_verifier: verifier,
})
const token = res.data.access_token
sessionStorage.setItem(TOKEN_KEY, token)
sessionStorage.removeItem(VERIFIER_KEY)
sessionStorage.removeItem(STATE_KEY)
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
setAuthenticated(true)
} finally {
setLoading(false)
}
}, [])
// Auto-redirect to login if not authenticated and not on callback
useEffect(() => {
if (!authenticated && !loading) {
const isCallback = window.location.pathname === '/callback'
if (!isCallback) {
startLogin()
}
}
}, [authenticated, loading, startLogin])
return { authenticated, loading, startLogin, handleCallback }
}

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import { clearAuth } from '@/hooks/useAuth'
export const api = axios.create({
baseURL: '/api',
@@ -11,6 +12,10 @@ export const api = axios.create({
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear stale token — the useAuth hook will redirect to login on next render
clearAuth()
}
console.error('API Error:', error.response?.data || error.message)
return Promise.reject(error)
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/hooks/useAuth'
export default function Callback() {
const navigate = useNavigate()
const { handleCallback } = useAuth()
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const error = params.get('error')
if (error) {
console.error('OIDC error:', error, params.get('error_description'))
navigate('/', { replace: true })
return
}
if (!code || !state) {
navigate('/', { replace: true })
return
}
handleCallback(code, state)
.then(() => navigate('/', { replace: true }))
.catch((err) => {
console.error('Auth callback failed:', err)
navigate('/', { replace: true })
})
}, [handleCallback, navigate])
return (
<div
style={{
background: 'hsl(222, 84%, 5%)',
color: '#94a3b8',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'system-ui, sans-serif',
fontSize: '0.9375rem',
}}
>
Completing sign in
</div>
)
}

View File

@@ -1,60 +1,120 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { Box, Play, Square, RefreshCw } from 'lucide-react'
interface Container {
id: string
full_id: string
name: string
image: string
status: string
state: string
ports: { private: number; public: number; type: string }[]
created: string
compose_project: string | null
compose_service: string | null
}
function StateChip({ state }: { state: string }) {
const cfg: Record<string, string> = {
running: 'bg-green-500/10 text-green-400',
exited: 'bg-red-500/10 text-red-400',
paused: 'bg-amber-500/10 text-amber-400',
restarting: 'bg-blue-500/10 text-blue-400',
created: 'bg-zinc-500/10 text-zinc-400',
}
return (
<span className={`text-xs px-2 py-1 rounded ${cfg[state] ?? 'bg-zinc-500/10 text-zinc-400'}`}>
{state}
</span>
)
}
export default function Containers() {
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['containers'],
queryFn: () => api.get('/containers').then(r => r.data),
refetchInterval: 15000,
refetchInterval: 10000,
})
const containers = data?.containers ?? []
const action = useMutation({
mutationFn: ({ id, act }: { id: string; act: string }) =>
api.post(`/containers/${id}/${act}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['containers'] }),
})
const containers: Container[] = data?.containers ?? []
const running = containers.filter(c => c.state === 'running').length
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">{containers.length} containers</p>
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">
{running} running / {containers.length} total
</p>
<button
onClick={() => qc.invalidateQueries({ queryKey: ['containers'] })}
className="p-2 rounded-lg text-[hsl(215_20.2%_65.1%)] hover:bg-[hsl(217.2_32.6%_17.5%)] hover:text-white transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-[hsl(217.2_32.6%_17.5%)]">
{['Container', 'Image', 'Status', 'Actions'].map(h => (
{['Container', 'Image', 'Project', 'Ports', 'State', ''].map(h => (
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-[hsl(215_20.2%_65.1%)] uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={4} className="text-center py-10 text-[hsl(215_20.2%_65.1%)] text-sm">Loading...</td></tr>
<tr><td colSpan={6} className="text-center py-10 text-[hsl(215_20.2%_65.1%)] text-sm">Loading...</td></tr>
) : containers.length === 0 ? (
<tr>
<td colSpan={4} className="py-12 text-center">
<td colSpan={6} className="py-12 text-center">
<Box className="w-10 h-10 mx-auto mb-3 text-[hsl(215_20.2%_40%)]" />
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No containers data yet.</p>
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-1">Connect a server to see its containers.</p>
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No containers found.</p>
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-1">Docker socket may not be mounted.</p>
</td>
</tr>
) : containers.map((c: any) => (
<tr key={c.id} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)]">
) : containers.map(c => (
<tr key={c.full_id} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)] transition-colors">
<td className="px-5 py-4 text-sm font-medium text-white">{c.name}</td>
<td className="px-5 py-4 text-sm text-[hsl(215_20.2%_65.1%)] font-mono">{c.image}</td>
<td className="px-5 py-4">
<span className={`text-xs px-2 py-1 rounded ${c.status === 'running' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{c.status}
</span>
<td className="px-5 py-4 text-xs text-[hsl(215_20.2%_65.1%)] font-mono max-w-[180px] truncate" title={c.image}>{c.image}</td>
<td className="px-5 py-4 text-xs text-[hsl(215_20.2%_65.1%)]">{c.compose_project ?? '—'}</td>
<td className="px-5 py-4 text-xs text-[hsl(215_20.2%_65.1%)] font-mono">
{c.ports.length > 0 ? c.ports.map(p => `${p.public}${p.private}`).join(', ') : '—'}
</td>
<td className="px-5 py-4"><StateChip state={c.state} /></td>
<td className="px-5 py-4">
<div className="flex items-center gap-2">
<button className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-green-400 hover:bg-green-500/10 transition-colors" title="Start">
<div className="flex items-center gap-1">
<button
onClick={() => action.mutate({ id: c.name, act: 'start' })}
disabled={c.state === 'running' || action.isPending}
className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-green-400 hover:bg-green-500/10 disabled:opacity-30 transition-colors"
title="Start"
>
<Play className="w-3.5 h-3.5" />
</button>
<button className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-red-400 hover:bg-red-500/10 transition-colors" title="Stop">
<button
onClick={() => action.mutate({ id: c.name, act: 'stop' })}
disabled={c.state !== 'running' || action.isPending}
className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 transition-colors"
title="Stop"
>
<Square className="w-3.5 h-3.5" />
</button>
<button className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-amber-400 hover:bg-amber-500/10 transition-colors" title="Restart">
<button
onClick={() => action.mutate({ id: c.name, act: 'restart' })}
disabled={action.isPending}
className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-amber-400 hover:bg-amber-500/10 disabled:opacity-30 transition-colors"
title="Restart"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>

View File

@@ -1,38 +1,40 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { FileText, Search } from 'lucide-react'
import { FileText, RefreshCw } from 'lucide-react'
export default function Logs() {
const [resourceType, setResourceType] = useState('container')
const [resourceId, setResourceId] = useState('')
const [lines, setLines] = useState('100')
const [selected, setSelected] = useState('')
const [lines, setLines] = useState('100')
const [autoRefresh, setAutoRefresh] = useState(false)
const { data: containerData } = useQuery({
queryKey: ['containers'],
queryFn: () => api.get('/containers').then(r => r.data),
})
const containers = containerData?.containers ?? []
const { data, isLoading, refetch } = useQuery({
queryKey: ['logs', resourceType, resourceId, lines],
queryFn: () => api.get(`/logs/${resourceType}/${resourceId}?lines=${lines}`).then(r => r.data),
enabled: !!resourceId,
queryKey: ['logs', selected, lines],
queryFn: () => api.get(`/logs/container/${selected}?lines=${lines}`).then(r => r.data),
enabled: !!selected,
refetchInterval: autoRefresh ? 5000 : false,
})
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={resourceType}
onChange={e => setResourceType(e.target.value)}
className="px-3 py-2 rounded-lg bg-[hsl(222.2_84%_6%)] border border-[hsl(217.2_32.6%_17.5%)] text-white text-sm focus:outline-none focus:border-blue-500"
value={selected}
onChange={e => setSelected(e.target.value)}
className="px-3 py-2 rounded-lg bg-[hsl(222.2_84%_6%)] border border-[hsl(217.2_32.6%_17.5%)] text-white text-sm focus:outline-none focus:border-blue-500 min-w-[220px]"
>
<option value="container">Container</option>
<option value="service">Service</option>
<option value="system">System</option>
<option value="">Select container...</option>
{containers.map((c: any) => (
<option key={c.full_id} value={c.name}>{c.name} ({c.state})</option>
))}
</select>
<input
value={resourceId}
onChange={e => setResourceId(e.target.value)}
placeholder="Container / service name..."
className="flex-1 px-3 py-2 rounded-lg bg-[hsl(222.2_84%_6%)] border border-[hsl(217.2_32.6%_17.5%)] text-white text-sm placeholder-[hsl(215_20.2%_40%)] focus:outline-none focus:border-blue-500"
/>
<select
value={lines}
onChange={e => setLines(e.target.value)}
@@ -40,27 +42,38 @@ export default function Logs() {
>
{['50', '100', '200', '500'].map(n => <option key={n} value={n}>{n} lines</option>)}
</select>
<label className="flex items-center gap-2 text-sm text-[hsl(215_20.2%_65.1%)] cursor-pointer select-none">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="rounded"
/>
Auto-refresh (5s)
</label>
<button
onClick={() => refetch()}
disabled={!resourceId || isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
disabled={!selected || isLoading}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-[hsl(215_20.2%_65.1%)] hover:bg-[hsl(217.2_32.6%_17.5%)] disabled:opacity-50 transition-colors"
title="Refresh"
>
<Search className="w-4 h-4" /> Fetch Logs
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Log Output */}
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(217.2_32.6%_5%)] min-h-[400px] font-mono text-sm overflow-auto">
{!resourceId ? (
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(217.2_32.6%_5%)] min-h-[400px] max-h-[600px] font-mono text-sm overflow-auto">
{!selected ? (
<div className="flex flex-col items-center justify-center h-64 text-[hsl(215_20.2%_40%)]">
<FileText className="w-10 h-10 mb-3" />
<p>Enter a resource name and click Fetch Logs</p>
<p>Select a container to view logs</p>
</div>
) : isLoading ? (
<div className="flex items-center justify-center h-64 text-[hsl(215_20.2%_65.1%)]">Loading...</div>
<div className="flex items-center justify-center h-64 text-[hsl(215_20.2%_65.1%)]">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Loading...
</div>
) : (
<pre className="p-4 text-green-400 whitespace-pre-wrap text-xs leading-5">
{data?.logs?.join('\n') || `No logs found for ${resourceType}/${resourceId}`}
{data?.logs?.join('\n') || `No logs found for ${selected}`}
</pre>
)}
</div>

View File

@@ -2,12 +2,12 @@ import { useQuery } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { api } from '@/lib/api'
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
import { Cpu, HardDrive, MemoryStick, Network } from 'lucide-react'
import { Cpu, HardDrive, MemoryStick, Box } from 'lucide-react'
function MetricCard({ label, value, max, unit, icon: Icon, color }: {
label: string, value: number, max: number, unit: string, icon: any, color: string
function MetricCard({ label, value, max, icon: Icon, color, subtitle }: {
label: string; value: number; max: number; icon: any; color: string; subtitle?: string
}) {
const pct = max > 0 ? Math.round((value / max) * 100) : 0
const pct = max > 0 ? Math.min(Math.round((value / max) * 100), 100) : 0
return (
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] p-5">
<div className="flex items-center justify-between mb-3">
@@ -23,7 +23,7 @@ function MetricCard({ label, value, max, unit, icon: Icon, color }: {
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-2">{value} / {max} {unit}</p>
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-2">{subtitle}</p>
</div>
)
}
@@ -49,19 +49,32 @@ export default function Metrics() {
})
}, [data])
const cpu = data?.cpu ?? { usage: 0, cores: 2 }
const mem = data?.memory ?? { used: 0, total: 3800 }
const disk = data?.disk ?? { used: 0, total: 116000 }
const cpu = data?.cpu ?? { usage: 0, cores: 2, load1: '0.00' }
const mem = data?.memory ?? { used: 0, total: 3800, percentage: 0 }
const disk = data?.disk ?? { used: 0, total: 116, unit: 'GB' }
const ctrs = data?.containers ?? { running: 0, total: 0 }
const chartData = history.length > 1 ? history : [{ t: 0, cpu: 0, mem: 0 }, { t: 1, cpu: 0, mem: 0 }]
return (
<div className="space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="CPU" value={cpu.usage} max={100} unit="%" icon={Cpu} color="text-blue-400" />
<MetricCard label="Memory" value={mem.used} max={mem.total} unit="MB" icon={MemoryStick} color="text-violet-400" />
<MetricCard label="Disk" value={disk.used} max={disk.total} unit="MB" icon={HardDrive} color="text-emerald-400" />
<MetricCard label="Network" value={0} max={1000} unit="Mbps" icon={Network} color="text-amber-400" />
<MetricCard
label="CPU Load" value={cpu.usage} max={100} icon={Cpu} color="text-blue-400"
subtitle={`${cpu.cores} cores · load avg ${cpu.load1}`}
/>
<MetricCard
label="Memory" value={mem.used} max={mem.total} icon={MemoryStick} color="text-violet-400"
subtitle={`${mem.used} MB / ${mem.total} MB`}
/>
<MetricCard
label="Docker Disk" value={disk.used} max={disk.total} icon={HardDrive} color="text-emerald-400"
subtitle={`${disk.used} ${disk.unit} used by Docker`}
/>
<MetricCard
label="Containers" value={ctrs.running} max={ctrs.total || 1} icon={Box} color="text-amber-400"
subtitle={`${ctrs.running} running / ${ctrs.total} total`}
/>
</div>
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] p-5">
@@ -83,11 +96,16 @@ export default function Metrics() {
<Tooltip
contentStyle={{ background: 'hsl(222.2 84% 6%)', border: '1px solid hsl(217.2 32.6% 17.5%)', borderRadius: '8px', fontSize: 12 }}
labelStyle={{ display: 'none' }}
formatter={(v: any) => [`${Math.round(v)}%`]}
/>
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#cpu)" strokeWidth={2} name="CPU %" />
<Area type="monotone" dataKey="mem" stroke="#8b5cf6" fill="url(#mem)" strokeWidth={2} name="Mem %" />
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#cpu)" strokeWidth={2} name="CPU" />
<Area type="monotone" dataKey="mem" stroke="#8b5cf6" fill="url(#mem)" strokeWidth={2} name="Mem" />
</AreaChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-3">
<span className="flex items-center gap-2 text-xs text-[hsl(215_20.2%_65.1%)]"><span className="w-3 h-0.5 bg-blue-500 inline-block" /> CPU</span>
<span className="flex items-center gap-2 text-xs text-[hsl(215_20.2%_65.1%)]"><span className="w-3 h-0.5 bg-violet-500 inline-block" /> Memory</span>
</div>
</div>
</div>
)

View File

@@ -1,50 +1,93 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { Layers } from 'lucide-react'
import { Layers, CheckCircle, AlertTriangle, XCircle, RefreshCw } from 'lucide-react'
function StackStatus({ status }: { status: string }) {
const cfg: Record<string, { icon: any; color: string; bg: string }> = {
healthy: { icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-500/10' },
degraded: { icon: AlertTriangle, color: 'text-amber-400', bg: 'bg-amber-500/10' },
stopped: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/10' },
unknown: { icon: Layers, color: 'text-zinc-400', bg: 'bg-zinc-500/10' },
}
const { icon: Icon, color, bg } = cfg[status] ?? cfg.unknown
return (
<span className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded w-fit ${bg} ${color}`}>
<Icon className="w-3 h-3" /> {status}
</span>
)
}
export default function Services() {
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['services'],
queryFn: () => api.get('/services').then(r => r.data),
refetchInterval: 15000,
})
const services = data?.services ?? []
const stacks = data?.services ?? []
return (
<div className="space-y-4">
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-[hsl(217.2_32.6%_17.5%)]">
{['Service', 'Type', 'Port', 'Status'].map(h => (
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-[hsl(215_20.2%_65.1%)] uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={4} className="text-center py-10 text-sm text-[hsl(215_20.2%_65.1%)]">Loading...</td></tr>
) : services.length === 0 ? (
<tr>
<td colSpan={4} className="py-12 text-center">
<Layers className="w-10 h-10 mx-auto mb-3 text-[hsl(215_20.2%_40%)]" />
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No services configured yet.</p>
</td>
</tr>
) : services.map((s: any) => (
<tr key={s.id} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)]">
<td className="px-5 py-4 text-sm font-medium text-white">{s.name}</td>
<td className="px-5 py-4 text-sm text-[hsl(215_20.2%_65.1%)]">{s.type}</td>
<td className="px-5 py-4 text-sm text-[hsl(215_20.2%_65.1%)]">{s.port ?? '—'}</td>
<td className="px-5 py-4">
<span className={`text-xs px-2 py-1 rounded ${s.status === 'active' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{s.status ?? 'unknown'}
</span>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-between">
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">{stacks.length} stack{stacks.length !== 1 ? 's' : ''}</p>
<button
onClick={() => qc.invalidateQueries({ queryKey: ['services'] })}
className="p-2 rounded-lg text-[hsl(215_20.2%_65.1%)] hover:bg-[hsl(217.2_32.6%_17.5%)] hover:text-white transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
{isLoading && (
<div className="text-center py-10 text-[hsl(215_20.2%_65.1%)] text-sm">Loading...</div>
)}
{!isLoading && stacks.length === 0 && (
<div className="py-12 text-center">
<Layers className="w-10 h-10 mx-auto mb-3 text-[hsl(215_20.2%_40%)]" />
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No services found.</p>
</div>
)}
{stacks.map((stack: any) => (
<div key={stack.id} className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-[hsl(217.2_32.6%_17.5%)]">
<div className="flex items-center gap-3">
<Layers className="w-4 h-4 text-blue-400" />
<span className="text-sm font-semibold text-white">{stack.name}</span>
<span className="text-xs text-[hsl(215_20.2%_65.1%)] px-2 py-0.5 rounded bg-[hsl(217.2_32.6%_17.5%)]">{stack.type}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[hsl(215_20.2%_65.1%)]">{stack.running}/{stack.total} running</span>
<StackStatus status={stack.status} />
</div>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[hsl(217.2_32.6%_17.5%)]">
{['Service', 'Container', 'Image', 'State'].map(h => (
<th key={h} className="text-left px-5 py-2 text-xs font-medium text-[hsl(215_20.2%_40%)] uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody>
{stack.services.map((svc: any) => (
<tr key={svc.name} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)]">
<td className="px-5 py-3 text-sm font-medium text-white">{svc.name}</td>
<td className="px-5 py-3 text-xs text-[hsl(215_20.2%_65.1%)] font-mono">{svc.container}</td>
<td className="px-5 py-3 text-xs text-[hsl(215_20.2%_65.1%)] font-mono max-w-[200px] truncate" title={svc.image}>{svc.image}</td>
<td className="px-5 py-3">
<span className={`text-xs px-2 py-1 rounded ${svc.state === 'running' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{svc.state}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)
}