diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index ae1340b..d1aa4a8 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -1,9 +1,12 @@ 'use client' -// src/app/services/page.tsx — 服务管理页面(含容器操作) +// src/app/services/page.tsx — 服务管理页面(CRUD + 容器操作) import { useState, useEffect } from 'react' -import { Server, Plus, RotateCw, Square, ScrollText } from 'lucide-react' +import { Plus, RotateCw, Square, ScrollText, X, Save, Trash2, Edit3 } from 'lucide-react' -interface Service { id: number; name: string; category: string; alert_level: string; checks: Array<{type: string; containerName?: string}>; check_interval: number; enabled: number; current_status: string } +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: [] as Check[], check_interval: 30, check_timeout: 10, enabled: 1 } export default function ServicesPage() { const [services, setServices] = useState([]) @@ -11,6 +14,9 @@ export default function ServicesPage() { 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() }, []) @@ -29,7 +35,6 @@ export default function ServicesPage() { 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`, { @@ -38,19 +43,55 @@ export default function ServicesPage() { }) const data = await res.json() if (data.success) { - if (action === 'logs') { - setLogs({ serviceId: s.id, output: data.output || '(空)' }) - } else { - showToast('ok', `${s.name} ${action === 'restart' ? '重启' : action === 'stop' ? '停止' : '启动'}成功`) - fetchServices() - } - } else { - showToast('err', data.error || '操作失败') - } + 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 Record)[f] = v + update('checks', checks) + } + const removeCheck = (i: number) => { + const checks = [...((editing?.checks as Check[]) || [])] + 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 ( @@ -63,8 +104,7 @@ export default function ServicesPage() {

服务管理

-
@@ -79,6 +119,7 @@ export default function ServicesPage() { 检查方式 状态 容器操作 + 编辑 @@ -91,7 +132,7 @@ export default function ServicesPage() { {s.alert_level} - {s.checks.map(c => c.type).join(' + ')} + {s.checks?.map(c => c.type).join(' + ') || '—'} @@ -118,6 +159,14 @@ export default function ServicesPage() { )} + + + + ))} @@ -133,9 +182,83 @@ export default function ServicesPage() {

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

-
-              {logs.output}
-            
+
{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" /> + )} + +
+ ))} +
+
+ + +
+
)}