Add server health endpoint and status page
Backend: - New /api/server/health endpoint returning server, paths, disk, mods, steamcmd, and frontend status - Health endpoint checks directory existence/writability, disk usage, server binary, and frontend dist availability Frontend: - New Status page (route /status) with sections for Server, SteamCMD, Mods, Frontend, Directories (with exists/writable badges), and Disk Usage (with size formatting and used-percent coloring) - Paths are clickable to copy and use start-truncation (ellipsis on the left) with native hover tooltip - Health API client integration with 10s auto-refresh Misc: - Update .gitignore and Makefile, minor Layout nav addition
This commit is contained in:
@@ -8,6 +8,7 @@ import Modlists from './pages/Modlists'
|
||||
import ModlistEditor from './pages/ModlistEditor'
|
||||
import Logs from './pages/Logs'
|
||||
import Mods from './pages/Mods'
|
||||
import Status from './pages/Status'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -21,6 +22,7 @@ function App() {
|
||||
<Route path="/modlists/:id" element={<ModlistEditor />} />
|
||||
<Route path="/mods" element={<Mods />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/status" element={<Status />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry, ModInfo } from '../types'
|
||||
import type { ServerSettings, ServerPaths, ServerHealth, ConfigInfo, Modlist, ModlistListItem, ModEntry, ModInfo } from '../types'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
@@ -24,6 +24,7 @@ export const settingsApi = {
|
||||
status: () => request<{ running: boolean }>('/server/status'),
|
||||
steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'),
|
||||
paths: () => request<ServerPaths>('/server/paths'),
|
||||
health: () => request<ServerHealth>('/server/health'),
|
||||
}
|
||||
|
||||
export const configsApi = {
|
||||
|
||||
@@ -8,6 +8,7 @@ const nav = [
|
||||
{ to: '/modlists', label: 'Modlists', icon: '📦' },
|
||||
{ to: '/mods', label: 'Mods', icon: '🗂' },
|
||||
{ to: '/logs', label: 'Logs', icon: '📋' },
|
||||
{ to: '/status', label: 'Status', icon: '📊' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
|
||||
176
frontend/src/pages/Status.tsx
Normal file
176
frontend/src/pages/Status.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { settingsApi } from '../api/client'
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function fmtPercent(p: number): string {
|
||||
return p.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function CopyPath({ path }: { path: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {}
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={copy}
|
||||
title={path}
|
||||
className="cursor-pointer flex items-center max-w-full font-mono text-xs hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
>
|
||||
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>
|
||||
{path}
|
||||
</span>
|
||||
</span>
|
||||
{copied && <span className="ml-0.5 text-green-400 shrink-0">✓</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Status() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['server-health'],
|
||||
queryFn: settingsApi.health,
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
if (isLoading) return <p className="text-neutral-400">Loading status...</p>
|
||||
if (error) return <p className="text-red-400">Failed to load status: {(error as Error).message}</p>
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6">Deploy Status</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Section title="Server">
|
||||
<StatusRow label="Running" value={data.server.running ? 'Yes' : 'No'} ok={data.server.running} />
|
||||
<StatusRow label="Binary installed" value={data.server.installed ? 'Yes' : 'No'} ok={data.server.installed} />
|
||||
{data.server.binary_path && (
|
||||
<StatusRow label="Binary path" value={<CopyPath path={data.server.binary_path} />} />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="SteamCMD">
|
||||
<StatusRow label="Running" value={data.steamcmd.running ? 'Yes' : 'No'} ok={!data.steamcmd.running} warn={data.steamcmd.running} />
|
||||
</Section>
|
||||
|
||||
<Section title="Mods">
|
||||
<StatusRow label="Workshop mods" value={String(data.mods.workshop_count)} />
|
||||
<StatusRow label="Local mods" value={String(data.mods.local_count)} />
|
||||
<StatusRow label="Total" value={String(data.mods.workshop_count + data.mods.local_count)} />
|
||||
</Section>
|
||||
|
||||
<Section title="Frontend">
|
||||
<StatusRow label="Served" value={data.frontend.served ? 'Yes' : 'No'} ok={data.frontend.served} />
|
||||
<StatusRow label="Path" value={data.frontend.path ? <CopyPath path={data.frontend.path} /> : '-'} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Section title="Directories">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<thead>
|
||||
<tr className="text-neutral-500 border-b border-neutral-800">
|
||||
<th className="text-left py-2 pr-4 w-[55%]">Path</th>
|
||||
<th className="text-center py-2 px-4 w-[22%]">Exists</th>
|
||||
<th className="text-center py-2 px-4 w-[23%]">Writable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.paths.map((p, i) => (
|
||||
<tr key={i} className="border-b border-neutral-800/50">
|
||||
<td className="py-2 pr-4 overflow-hidden"><CopyPath path={p.path} /></td>
|
||||
<td className="text-center py-2 px-4">
|
||||
<Badge ok={p.exists} label={p.exists ? 'Yes' : 'No'} />
|
||||
</td>
|
||||
<td className="text-center py-2 px-4">
|
||||
<Badge ok={p.writable} label={p.writable ? 'Yes' : 'No'} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Section title="Disk Usage">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<thead>
|
||||
<tr className="text-neutral-500 border-b border-neutral-800">
|
||||
<th className="text-left py-2 pr-4 w-[40%]">Path</th>
|
||||
<th className="text-right py-2 px-4 w-[20%]">Total</th>
|
||||
<th className="text-right py-2 px-4 w-[20%]">Free</th>
|
||||
<th className="text-right py-2 px-4 w-[20%]">Used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.disk.map((d, i) => (
|
||||
<tr key={i} className="border-b border-neutral-800/50">
|
||||
<td className="py-2 pr-4 overflow-hidden"><CopyPath path={d.path} /></td>
|
||||
<td className="text-right py-2 px-4">{fmtSize(d.total_bytes)}</td>
|
||||
<td className="text-right py-2 px-4">{fmtSize(d.free_bytes)}</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<span className={d.used_percent > 90 ? 'text-red-400' : d.used_percent > 75 ? 'text-yellow-400' : ''}>
|
||||
{fmtPercent(d.used_percent)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<h3 className="font-medium mb-3 text-neutral-300">{title}</h3>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusRow({ label, value, ok, warn }: { label: string; value: React.ReactNode; ok?: boolean; warn?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-1.5 text-sm">
|
||||
<span className="text-neutral-500 shrink-0">{label}</span>
|
||||
<span className="flex items-center gap-2 min-w-0 max-w-[65%]">
|
||||
{ok !== undefined && <Dot color={ok ? 'bg-green-400' : 'bg-red-400'} />}
|
||||
{warn && <Dot color="bg-yellow-400" />}
|
||||
<span className={`min-w-0 overflow-hidden ${ok === false ? 'text-red-400' : warn ? 'text-yellow-400' : ''}`}>{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ok ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Dot({ color }: { color: string }) {
|
||||
return <span className={`inline-block w-2 h-2 rounded-full ${color}`} />
|
||||
}
|
||||
@@ -51,6 +51,36 @@ export interface ServerPaths {
|
||||
profiles_dir: string
|
||||
}
|
||||
|
||||
export interface ServerHealth {
|
||||
server: {
|
||||
running: boolean
|
||||
installed: boolean
|
||||
binary_path: string
|
||||
}
|
||||
paths: {
|
||||
path: string
|
||||
exists: boolean
|
||||
writable: boolean
|
||||
}[]
|
||||
mods: {
|
||||
workshop_count: number
|
||||
local_count: number
|
||||
}
|
||||
disk: {
|
||||
path: string
|
||||
total_bytes: number
|
||||
free_bytes: number
|
||||
used_percent: number
|
||||
}[]
|
||||
steamcmd: {
|
||||
running: boolean
|
||||
}
|
||||
frontend: {
|
||||
served: boolean
|
||||
path: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModInfo {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user