feat: 完善服务 CRUD 表单(添加/编辑/删除服务)
This commit is contained in:
parent
51cfcab11e
commit
57174fd26a
|
|
@ -1,9 +1,12 @@
|
||||||
'use client'
|
'use client'
|
||||||
// src/app/services/page.tsx — 服务管理页面(含容器操作)
|
// src/app/services/page.tsx — 服务管理页面(CRUD + 容器操作)
|
||||||
import { useState, useEffect } from 'react'
|
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() {
|
export default function ServicesPage() {
|
||||||
const [services, setServices] = useState<Service[]>([])
|
const [services, setServices] = useState<Service[]>([])
|
||||||
|
|
@ -11,6 +14,9 @@ export default function ServicesPage() {
|
||||||
const [opLoading, setOpLoading] = useState<number | null>(null)
|
const [opLoading, setOpLoading] = useState<number | null>(null)
|
||||||
const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null)
|
const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null)
|
||||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||||
|
const [editing, setEditing] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [isNew, setIsNew] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => { fetchServices() }, [])
|
useEffect(() => { fetchServices() }, [])
|
||||||
|
|
||||||
|
|
@ -29,7 +35,6 @@ export default function ServicesPage() {
|
||||||
async function containerAction(s: Service, action: 'restart' | 'stop' | 'start' | 'logs') {
|
async function containerAction(s: Service, action: 'restart' | 'stop' | 'start' | 'logs') {
|
||||||
const confirmMsg = { restart: `确定重启 ${s.name}?`, stop: `确定停止 ${s.name}?`, start: `确定启动 ${s.name}?`, logs: '' }
|
const confirmMsg = { restart: `确定重启 ${s.name}?`, stop: `确定停止 ${s.name}?`, start: `确定启动 ${s.name}?`, logs: '' }
|
||||||
if (action !== 'logs' && !confirm(confirmMsg[action])) return
|
if (action !== 'logs' && !confirm(confirmMsg[action])) return
|
||||||
|
|
||||||
setOpLoading(s.id)
|
setOpLoading(s.id)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/services/${s.id}/ops`, {
|
const res = await fetch(`/api/services/${s.id}/ops`, {
|
||||||
|
|
@ -38,19 +43,55 @@ export default function ServicesPage() {
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (action === 'logs') {
|
if (action === 'logs') setLogs({ serviceId: s.id, output: data.output || '(空)' })
|
||||||
setLogs({ serviceId: s.id, output: data.output || '(空)' })
|
else { showToast('ok', `${s.name} 操作成功`); fetchServices() }
|
||||||
} else {
|
} else showToast('err', data.error || '操作失败')
|
||||||
showToast('ok', `${s.name} ${action === 'restart' ? '重启' : action === 'stop' ? '停止' : '启动'}成功`)
|
|
||||||
fetchServices()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showToast('err', data.error || '操作失败')
|
|
||||||
}
|
|
||||||
} catch { showToast('err', '网络错误') }
|
} catch { showToast('err', '网络错误') }
|
||||||
setOpLoading(null)
|
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<string, string>)[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 <div className="p-12 text-center text-slate-400">加载中...</div>
|
if (loading) return <div className="p-12 text-center text-slate-400">加载中...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -63,8 +104,7 @@ export default function ServicesPage() {
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">服务管理</h1>
|
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">服务管理</h1>
|
||||||
<button className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium"
|
<button onClick={startNew} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||||
onClick={() => showToast('err', '添加服务功能即将上线')}>
|
|
||||||
<Plus size={16} /> 添加服务
|
<Plus size={16} /> 添加服务
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -79,6 +119,7 @@ export default function ServicesPage() {
|
||||||
<th className="text-left px-4 py-3 font-medium text-slate-500">检查方式</th>
|
<th className="text-left px-4 py-3 font-medium text-slate-500">检查方式</th>
|
||||||
<th className="text-left px-4 py-3 font-medium text-slate-500">状态</th>
|
<th className="text-left px-4 py-3 font-medium text-slate-500">状态</th>
|
||||||
<th className="text-center px-4 py-3 font-medium text-slate-500">容器操作</th>
|
<th className="text-center px-4 py-3 font-medium text-slate-500">容器操作</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-slate-500">编辑</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -91,7 +132,7 @@ export default function ServicesPage() {
|
||||||
{s.alert_level}
|
{s.alert_level}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-500">{s.checks.map(c => c.type).join(' + ')}</td>
|
<td className="px-4 py-3 text-slate-500">{s.checks?.map(c => c.type).join(' + ') || '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`inline-flex items-center gap-1.5 ${s.current_status === 'normal' ? 'text-green-600' : s.current_status === 'abnormal' ? 'text-red-600' : 'text-amber-600'}`}>
|
<span className={`inline-flex items-center gap-1.5 ${s.current_status === 'normal' ? 'text-green-600' : s.current_status === 'abnormal' ? 'text-red-600' : 'text-amber-600'}`}>
|
||||||
<span className="w-2 h-2 rounded-full bg-current" />
|
<span className="w-2 h-2 rounded-full bg-current" />
|
||||||
|
|
@ -118,6 +159,14 @@ export default function ServicesPage() {
|
||||||
<span className="text-xs text-slate-400">—</span>
|
<span className="text-xs text-slate-400">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button onClick={() => startEdit(s)} className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="编辑">
|
||||||
|
<Edit3 size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(s.id)} className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-400 ml-1" title="删除">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -133,9 +182,83 @@ export default function ServicesPage() {
|
||||||
<h2 className="font-semibold">容器日志 — {services.find(s => s.id === logs.serviceId)?.name}</h2>
|
<h2 className="font-semibold">容器日志 — {services.find(s => s.id === logs.serviceId)?.name}</h2>
|
||||||
<button onClick={() => setLogs(null)} className="text-slate-400 hover:text-slate-600 text-lg">×</button>
|
<button onClick={() => setLogs(null)} className="text-slate-400 hover:text-slate-600 text-lg">×</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs font-mono text-slate-300 bg-slate-950 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap max-h-96">
|
<pre className="text-xs font-mono text-slate-300 bg-slate-950 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap max-h-96">{logs.output}</pre>
|
||||||
{logs.output}
|
</div>
|
||||||
</pre>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 添加/编辑服务 Dialog */}
|
||||||
|
{editing && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">{isNew ? '添加服务' : '编辑服务'}</h2>
|
||||||
|
<button onClick={() => setEditing(null)} className="p-1 text-slate-400 hover:text-slate-600"><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">名称 *</label>
|
||||||
|
<input value={String(editing.name || '')} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">类别</label>
|
||||||
|
<select value={String(editing.category || 'endpoint')} onChange={e => update('category', 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">
|
||||||
|
<option value="endpoint">端点</option><option value="container">容器</option><option value="custom">自定义</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">告警级别</label>
|
||||||
|
<select value={String(editing.alert_level || 'warning')} onChange={e => update('alert_level', 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">
|
||||||
|
<option value="critical">Critical</option><option value="warning">Warning</option><option value="info">Info</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">检查间隔(秒)</label>
|
||||||
|
<input type="number" value={Number(editing.check_interval) || 30} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">超时(秒)</label>
|
||||||
|
<input type="number" value={Number(editing.check_timeout) || 10} onChange={e => 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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">检查方式</label>
|
||||||
|
<button onClick={addCheck} className="text-xs text-indigo-600 hover:text-indigo-700">+ 添加检查</button>
|
||||||
|
</div>
|
||||||
|
{((editing.checks as Check[]) || []).map((c, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 mb-2">
|
||||||
|
<select value={c.type} onChange={e => updateCheck(i, 'type', e.target.value)}
|
||||||
|
className="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs w-20">
|
||||||
|
<option value="http">HTTP</option><option value="docker">Docker</option>
|
||||||
|
</select>
|
||||||
|
{c.type === 'http' ? (
|
||||||
|
<input value={c.url || ''} onChange={e => 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" />
|
||||||
|
) : (
|
||||||
|
<input value={c.containerName || ''} onChange={e => 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" />
|
||||||
|
)}
|
||||||
|
<button onClick={() => removeCheck(i)} className="text-red-400 hover:text-red-600 p-1"><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button onClick={() => setEditing(null)} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">取消</button>
|
||||||
|
<button onClick={handleSave} disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-lg text-sm font-medium">
|
||||||
|
<Save size={15} /> {saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue