feat: live Docker data — containers, services, logs, metrics via dockerode
All checks were successful
continuous-integration/drone/push Build is passing
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:
6
frontend/.env.example
Normal file
6
frontend/.env.example
Normal 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
|
||||
@@ -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 />} />
|
||||
|
||||
123
frontend/src/hooks/useAuth.ts
Normal file
123
frontend/src/hooks/useAuth.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
54
frontend/src/pages/Callback.tsx
Normal file
54
frontend/src/pages/Callback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user