monitor-ai/src/app/services/page.tsx

271 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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">&times;</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>
)
}