feat: allow public monitor route without login and add monitor page
This commit is contained in:
13
src/App.tsx
13
src/App.tsx
@@ -13,6 +13,7 @@ import ProjectDetailPage from '@/pages/ProjectDetailPage'
|
|||||||
import MilestonesPage from '@/pages/MilestonesPage'
|
import MilestonesPage from '@/pages/MilestonesPage'
|
||||||
import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
|
import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
|
||||||
import NotificationsPage from '@/pages/NotificationsPage'
|
import NotificationsPage from '@/pages/NotificationsPage'
|
||||||
|
import MonitorPage from '@/pages/MonitorPage'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
||||||
@@ -61,7 +62,16 @@ export default function App() {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<LoginPage onLogin={login} />
|
<div className="app-layout">
|
||||||
|
<Sidebar user={null} onLogout={logout} />
|
||||||
|
<main className="main-content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/monitor" element={<MonitorPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage onLogin={login} />} />
|
||||||
|
<Route path="*" element={<Navigate to="/monitor" />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -81,6 +91,7 @@ export default function App() {
|
|||||||
<Route path="/milestones" element={<MilestonesPage />} />
|
<Route path="/milestones" element={<MilestonesPage />} />
|
||||||
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
|
<Route path="/monitor" element={<MonitorPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from react
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from react-router-dom
|
||||||
import api from '@/services/api'
|
import api from @/services/api
|
||||||
import type { User } from '@/types'
|
import type { User } from @/types
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User | null
|
user: User | null
|
||||||
@@ -13,23 +13,30 @@ export default function Sidebar({ user, onLogout }: Props) {
|
|||||||
const [unreadCount, setUnreadCount] = useState(0)
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<{ count: number }>('/notifications/count')
|
if (!user) {
|
||||||
|
setUnreadCount(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.get<{ count: number }>(/notifications/count)
|
||||||
.then(({ data }) => setUnreadCount(data.count))
|
.then(({ data }) => setUnreadCount(data.count))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
api.get<{ count: number }>('/notifications/count')
|
api.get<{ count: number }>(/notifications/count)
|
||||||
.then(({ data }) => setUnreadCount(data.count))
|
.then(({ data }) => setUnreadCount(data.count))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, 30000)
|
}, 30000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [user])
|
||||||
|
|
||||||
const links = [
|
const links = user ? [
|
||||||
{ to: '/', icon: '📊', label: '仪表盘' },
|
{ to: /, icon: 📊, label: 仪表盘 },
|
||||||
{ to: '/issues', icon: '📋', label: 'Issues' },
|
{ to: /issues, icon: 📋, label: Issues },
|
||||||
{ to: '/projects', icon: '📁', label: '项目' },
|
{ to: /projects, icon: 📁, label: 项目 },
|
||||||
{ to: '/milestones', icon: '🏁', label: '里程碑' },
|
{ to: /milestones, icon: 🏁, label: 里程碑 },
|
||||||
{ to: '/notifications', icon: '🔔', label: `通知${unreadCount > 0 ? ` (${unreadCount})` : ''}` },
|
{ to: /notifications, icon: 🔔, label: `通知${unreadCount > 0 ? ` (${unreadCount})` : }` },
|
||||||
|
{ to: /monitor, icon: 📡, label: Monitor },
|
||||||
|
] : [
|
||||||
|
{ to: /monitor, icon: 📡, label: Monitor },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +46,7 @@ export default function Sidebar({ user, onLogout }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<ul className="nav-links">
|
<ul className="nav-links">
|
||||||
{links.map((l) => (
|
{links.map((l) => (
|
||||||
<li key={l.to} className={pathname === l.to || (l.to !== '/' && pathname.startsWith(l.to)) ? 'active' : ''}>
|
<li key={l.to} className={pathname === l.to || (l.to !== / && pathname.startsWith(l.to)) ? active : }>
|
||||||
<Link to={l.to}>{l.icon} {l.label}</Link>
|
<Link to={l.to}>{l.icon} {l.label}</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
113
src/pages/MonitorPage.tsx
Normal file
113
src/pages/MonitorPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import api from '@/services/api'
|
||||||
|
|
||||||
|
interface ProviderRow {
|
||||||
|
account_id: number
|
||||||
|
provider: string
|
||||||
|
label: string
|
||||||
|
usage_pct: number | null
|
||||||
|
status: string
|
||||||
|
error?: string | null
|
||||||
|
fetched_at?: string | null
|
||||||
|
reset_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
providers: ProviderRow[]
|
||||||
|
servers: ServerRow[]
|
||||||
|
generated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitorPage() {
|
||||||
|
const [data, setData] = useState<OverviewData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<OverviewData>('/monitor/public/overview')
|
||||||
|
setData(res.data)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
const t = setInterval(load, 30000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <div className='loading'>Monitor loading...</div>
|
||||||
|
if (!data) return <div className='loading'>Monitor load failed</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>📡 Monitor</h2>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Issue 概览(24小时窗口)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>所有项目总 Issue:{data.issues.total_issues}</li>
|
||||||
|
<li>24小时新增:{data.issues.new_issues_24h}</li>
|
||||||
|
<li>24小时已处理(resolved/closed):{data.issues.processed_issues_24h}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Provider Usage</h3>
|
||||||
|
{data.providers.length === 0 ? <p>暂无 provider 账号</p> : (
|
||||||
|
<ul>
|
||||||
|
{data.providers.map((p) => (
|
||||||
|
<li key={p.account_id}>
|
||||||
|
<strong>{p.provider}</strong> / {p.label} · status: {p.status}
|
||||||
|
{p.usage_pct !== null ? : ''}
|
||||||
|
{p.reset_at ? : ''}
|
||||||
|
{p.error ? : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>服务器监测</h3>
|
||||||
|
{data.servers.length === 0 ? <p>暂无监测服务器</p> : (
|
||||||
|
<div>
|
||||||
|
{data.servers.map((s) => (
|
||||||
|
<div key={s.server_id} style={{ marginBottom: 12, padding: 12, border: '1px solid #ddd', borderRadius: 8 }}>
|
||||||
|
<div>
|
||||||
|
<strong>{s.display_name}</strong> ({s.identifier}) · {s.online ? '🟢 在线' : '🔴 离线'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
CPU: {s.cpu_pct ?? '-'}% · MEM: {s.mem_pct ?? '-'}% · DISK: {s.disk_pct ?? '-'}% · SWAP: {s.swap_pct ?? '-'}%
|
||||||
|
</div>
|
||||||
|
<div>OpenClaw: {s.openclaw_version || '-'}</div>
|
||||||
|
<div>Agents: {s.agents?.length || 0}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,9 @@ api.interceptors.response.use(
|
|||||||
(err) => {
|
(err) => {
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
if (window.location.pathname !== '/login') {
|
const path = window.location.pathname
|
||||||
|
const publicPaths = ['/monitor', '/login']
|
||||||
|
if (!publicPaths.some((p) => path.startsWith(p))) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user