271 lines
16 KiB
TypeScript
271 lines
16 KiB
TypeScript
'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<Service[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [opLoading, setOpLoading] = useState<number | null>(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<Record<string, unknown> | 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<string, string>)[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 <div className="p-12 text-center text-slate-400">加载中...</div>
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{toast && (
|
||
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg text-sm font-medium shadow-lg ${toast.type === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
|
||
{toast.msg}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">服务管理</h1>
|
||
<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">
|
||
<Plus size={16} /> 添加服务
|
||
</button>
|
||
</div>
|
||
|
||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||
<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-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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{services.map(s => (
|
||
<tr key={s.id} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||
<td className="px-4 py-3 font-medium">{s.name}</td>
|
||
<td className="px-4 py-3 text-slate-500">{s.category}</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.alert_level === 'critical' ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' : 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400'}`}>
|
||
{s.alert_level}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-500">{s.checks?.map(c => c.type).join(' + ') || '—'}</td>
|
||
<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="w-2 h-2 rounded-full bg-current" />
|
||
{s.current_status === 'normal' ? '正常' : s.current_status === 'abnormal' ? '异常' : '未知'}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
{hasDocker(s) ? (
|
||
<div className="flex items-center justify-center gap-1">
|
||
<button onClick={() => containerAction(s, 'restart')} disabled={opLoading === s.id}
|
||
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded text-indigo-500 disabled:opacity-40" title="重启">
|
||
<RotateCw size={15} className={opLoading === s.id ? 'animate-spin' : ''} />
|
||
</button>
|
||
<button onClick={() => containerAction(s, 'stop')} disabled={opLoading === s.id}
|
||
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-500 disabled:opacity-40" title="停止">
|
||
<Square size={15} />
|
||
</button>
|
||
<button onClick={() => containerAction(s, 'logs')} disabled={opLoading === s.id}
|
||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-500 disabled:opacity-40" title="查看日志">
|
||
<ScrollText size={15} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-slate-400">—</span>
|
||
)}
|
||
</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>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{services.length === 0 && <p className="p-12 text-center text-slate-400">暂无服务,请添加被监控服务</p>}
|
||
</div>
|
||
|
||
{/* 日志查看 Dialog */}
|
||
{logs && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setLogs(null)}>
|
||
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 w-full max-w-3xl shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<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>
|
||
</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">{logs.output}</pre>
|
||
</div>
|
||
</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" />
|
||
)}
|
||
{((editing.checks as Check[]) || []).length > 1 && (
|
||
<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>
|
||
)
|
||
}
|