'use client' // src/app/services/page.tsx — 服务管理页面(CRUD + 容器操作) import { useState, useEffect } from 'react' import { Plus, RotateCw, Square, ScrollText, X, Save, Trash2, Edit3 } from 'lucide-react' interface Check { type: string; containerName?: string; url?: string } interface Service { id: number; name: string; category: string; alert_level: string; checks: Check[]; check_interval: number; enabled: number; current_status: string } const emptyService = { name: '', category: 'endpoint', alert_level: 'warning', checks: [{ type: 'http', url: '' }] as Check[], check_interval: 30, check_timeout: 10, enabled: 1 } export default function ServicesPage() { const [services, setServices] = useState([]) const [loading, setLoading] = useState(true) const [opLoading, setOpLoading] = useState(null) const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null) const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null) const [editing, setEditing] = useState | null>(null) const [isNew, setIsNew] = useState(false) const [saving, setSaving] = useState(false) useEffect(() => { fetchServices() }, []) async function fetchServices() { const res = await fetch('/api/services') setServices(await res.json()) setLoading(false) } const showToast = (type: 'ok' | 'err', msg: string) => { setToast({ type, msg }); setTimeout(() => setToast(null), 3000) } const hasDocker = (s: Service) => s.checks?.some(c => c.type === 'docker' && c.containerName) async function containerAction(s: Service, action: 'restart' | 'stop' | 'start' | 'logs') { const confirmMsg = { restart: `确定重启 ${s.name}?`, stop: `确定停止 ${s.name}?`, start: `确定启动 ${s.name}?`, logs: '' } if (action !== 'logs' && !confirm(confirmMsg[action])) return setOpLoading(s.id) try { const res = await fetch(`/api/services/${s.id}/ops`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, lines: 100 }), }) const data = await res.json() if (data.success) { if (action === 'logs') setLogs({ serviceId: s.id, output: data.output || '(空)' }) else { showToast('ok', `${s.name} 操作成功`); fetchServices() } } else showToast('err', data.error || '操作失败') } catch { showToast('err', '网络错误') } setOpLoading(null) } const update = (f: string, v: unknown) => setEditing(prev => prev ? { ...prev, [f]: v } : null) const addCheck = () => { const checks = (editing?.checks as Check[]) || [] setEditing(prev => prev ? { ...prev, checks: [...checks, { type: 'http', url: '' }] } : null) } const updateCheck = (i: number, f: string, v: string) => { const checks = [...((editing?.checks as Check[]) || [])] if (checks[i]) (checks[i] as unknown as Record)[f] = v update('checks', checks) } const removeCheck = (i: number) => { const checks = [...((editing?.checks as Check[]) || [])] if (checks.length <= 1) return // 至少保留一条检查方式 checks.splice(i, 1) update('checks', checks) } async function handleSave() { if (!editing?.name) { showToast('err', '请输入服务名称'); return } setSaving(true) try { const url = isNew ? '/api/services' : `/api/services/${editing.id}` const method = isNew ? 'POST' : 'PUT' const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editing) }) if (res.ok) { showToast('ok', isNew ? '服务已添加' : '服务已更新'); setEditing(null); fetchServices() } else { const d = await res.json(); showToast('err', d.error || '保存失败') } } catch { showToast('err', '网络错误') } setSaving(false) } async function handleDelete(id: number) { if (!confirm('确定删除此服务?')) return try { const res = await fetch(`/api/services/${id}`, { method: 'DELETE' }) if (res.ok) { showToast('ok', '服务已删除'); fetchServices() } else { const d = await res.json(); showToast('err', d.error || '删除失败') } } catch { showToast('err', '网络错误') } } const startNew = () => { setEditing({ ...emptyService }); setIsNew(true) } const startEdit = (s: Service) => { setEditing({ ...s }); setIsNew(false) } if (loading) return
加载中...
return (
{toast && (
{toast.msg}
)}

服务管理

{services.map(s => ( ))}
名称 类别 告警级别 检查方式 状态 容器操作 编辑
{s.name} {s.category} {s.alert_level} {s.checks?.map(c => c.type).join(' + ') || '—'} {s.current_status === 'normal' ? '正常' : s.current_status === 'abnormal' ? '异常' : '未知'} {hasDocker(s) ? (
) : ( )}
{services.length === 0 &&

暂无服务,请添加被监控服务

}
{/* 日志查看 Dialog */} {logs && (
setLogs(null)}>
e.stopPropagation()}>

容器日志 — {services.find(s => s.id === logs.serviceId)?.name}

{logs.output}
)} {/* 添加/编辑服务 Dialog */} {editing && (
e.stopPropagation()}>

{isNew ? '添加服务' : '编辑服务'}

update('name', e.target.value)} placeholder="如:Authelia SSO" className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
update('check_interval', Number(e.target.value))} className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm" />
update('check_timeout', Number(e.target.value))} className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm" />
{((editing.checks as Check[]) || []).map((c, i) => (
{c.type === 'http' ? ( updateCheck(i, 'url', e.target.value)} placeholder="https://example.com/api/health" className="flex-1 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs" /> ) : ( updateCheck(i, 'containerName', e.target.value)} placeholder="容器名,如 authelia" className="flex-1 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs" /> )} {((editing.checks as Check[]) || []).length > 1 && ( )}
))}
)}
) }