- Rename files: IssuesPage → TasksPage, IssueDetailPage → TaskDetailPage, CreateIssuePage → CreateTaskPage - Rename TypeScript interface: Issue → Task (keep backend field names) - Update routes: /issues → /tasks, /issues/new → /tasks/new, /issues/:id → /tasks/:id - Update CSS class names: issue-* → task-*, create-issue → create-task - Update UI text: 'Issues' → 'Tasks', 'Create Issue' → 'Create Task' - Keep 'issue' as a task subtype value in TASK_TYPES dropdown - Keep all backend API endpoint paths unchanged (/issues, /comments, etc.) - Rename local Task interface in MilestoneDetailPage to MilestoneTask to avoid conflict with the global Task type
177 lines
5.9 KiB
TypeScript
177 lines
5.9 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import api from '@/services/api'
|
|
|
|
interface ServerRow {
|
|
server_id: number
|
|
identifier: string
|
|
display_name: string
|
|
online: boolean
|
|
openclaw_version?: string | null
|
|
cpu_pct?: number | null
|
|
mem_pct?: number | null
|
|
disk_pct?: number | null
|
|
swap_pct?: number | null
|
|
agents: Array<{ id?: string; name?: string; status?: string }>
|
|
last_seen_at?: string | null
|
|
}
|
|
|
|
interface OverviewData {
|
|
issues: {
|
|
total_issues: number
|
|
new_issues_24h: number
|
|
processed_issues_24h: number
|
|
computed_at: string
|
|
}
|
|
servers: ServerRow[]
|
|
generated_at: string
|
|
}
|
|
|
|
interface AdminUser {
|
|
id: number
|
|
is_admin: boolean
|
|
}
|
|
|
|
interface ServerItem {
|
|
server_id: number
|
|
identifier: string
|
|
display_name: string
|
|
online: boolean
|
|
}
|
|
|
|
export default function MonitorPage() {
|
|
const [data, setData] = useState<OverviewData | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [isAdmin, setIsAdmin] = useState(false)
|
|
const [servers, setServers] = useState<ServerItem[]>([])
|
|
|
|
const [serverForm, setServerForm] = useState({ identifier: '', display_name: '' })
|
|
|
|
const canAdmin = useMemo(() => !!localStorage.getItem('token') && isAdmin, [isAdmin])
|
|
|
|
const load = async () => {
|
|
try {
|
|
const res = await api.get<OverviewData>('/monitor/public/overview')
|
|
setData(res.data)
|
|
const token = localStorage.getItem('token')
|
|
if (token) {
|
|
try {
|
|
const me = await api.get<AdminUser>('/auth/me')
|
|
setIsAdmin(!!me.data.is_admin)
|
|
} catch {
|
|
setIsAdmin(false)
|
|
}
|
|
} else {
|
|
setIsAdmin(false)
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadAdminData = async () => {
|
|
if (!canAdmin) return
|
|
const s = await api.get<ServerItem[]>('/monitor/admin/servers')
|
|
setServers(s.data)
|
|
}
|
|
|
|
useEffect(() => {
|
|
load()
|
|
const t = setInterval(load, 30000)
|
|
return () => clearInterval(t)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadAdminData()
|
|
}, [canAdmin])
|
|
|
|
const addServer = async () => {
|
|
await api.post('/monitor/admin/servers', serverForm)
|
|
setServerForm({ identifier: '', display_name: '' })
|
|
await loadAdminData()
|
|
}
|
|
|
|
const deleteServer = async (id: number) => {
|
|
await api.delete('/monitor/admin/servers/' + id)
|
|
await loadAdminData()
|
|
}
|
|
|
|
const createChallenge = async (id: number) => {
|
|
const r = await api.post<{ identifier: string; challenge_uuid: string; expires_at: string }>('/monitor/admin/servers/' + id + '/challenge')
|
|
alert('identifier=' + r.data.identifier + ' | challenge_uuid=' + r.data.challenge_uuid + ' | expires_at=' + r.data.expires_at)
|
|
}
|
|
|
|
if (loading) return <div className="loading">Monitor loading...</div>
|
|
if (!data) return <div className="loading">Monitor load failed</div>
|
|
|
|
return (
|
|
<div className="dashboard monitor-page">
|
|
<h2>📡 Monitor</h2>
|
|
|
|
<div className="stats-grid">
|
|
<div className="stat-card total">
|
|
<span className="stat-number">{data.issues.total_issues}</span>
|
|
<span className="stat-label">Total Tasks</span>
|
|
</div>
|
|
<div className="stat-card" style={{ borderLeftColor: 'var(--accent)' }}>
|
|
<span className="stat-number">{data.issues.new_issues_24h}</span>
|
|
<span className="stat-label">New (24h)</span>
|
|
</div>
|
|
<div className="stat-card" style={{ borderLeftColor: 'var(--success)' }}>
|
|
<span className="stat-number">{data.issues.processed_issues_24h}</span>
|
|
<span className="stat-label">Processed (24h)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="section">
|
|
<h3>Server Monitoring</h3>
|
|
{data.servers.length === 0 ? <p className="empty">No monitored servers</p> : (
|
|
<div className="monitor-grid">
|
|
{data.servers.map((s) => (
|
|
<div key={s.server_id} className="monitor-card">
|
|
<div className="monitor-card-header">
|
|
<div>
|
|
<strong>{s.display_name}</strong>
|
|
<div className="text-dim">{s.identifier}</div>
|
|
</div>
|
|
<span className={'badge ' + (s.online ? 'status-online' : 'status-offline')}>{s.online ? 'online' : 'offline'}</span>
|
|
</div>
|
|
<div className="monitor-metrics">
|
|
CPU {s.cpu_pct ?? '-'}% · MEM {s.mem_pct ?? '-'}% · DISK {s.disk_pct ?? '-'}% · SWAP {s.swap_pct ?? '-'}%
|
|
</div>
|
|
<div className="text-dim">OpenClaw: {s.openclaw_version || '-'}</div>
|
|
<div className="text-dim">Agents: {s.agents?.length || 0}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{canAdmin && (
|
|
<div className="section">
|
|
<h3>Admin</h3>
|
|
|
|
<div className="monitor-admin">
|
|
<div className="monitor-card">
|
|
<h4>Servers</h4>
|
|
<div className="inline-form">
|
|
<input placeholder='identifier' value={serverForm.identifier} onChange={(e) => setServerForm({ ...serverForm, identifier: e.target.value })} />
|
|
<input placeholder='display_name' value={serverForm.display_name} onChange={(e) => setServerForm({ ...serverForm, display_name: e.target.value })} />
|
|
<button className="btn-primary" onClick={addServer}>Add Server</button>
|
|
</div>
|
|
<ul>
|
|
{servers.map((s) => (
|
|
<li key={s.server_id}>
|
|
{s.display_name} ({s.identifier})
|
|
<button className="btn-secondary" onClick={() => createChallenge(s.server_id)} style={{ marginLeft: 8 }}>Generate Challenge</button>
|
|
<button className="btn-danger" onClick={() => deleteServer(s.server_id)} style={{ marginLeft: 8 }}>Delete</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|