Merge pull request 'feat/public-monitor-and-agent-telemetry' (#2) from feat/public-monitor-and-agent-telemetry into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
h z
2026-03-11 22:13:52 +00:00
16 changed files with 358 additions and 141 deletions

View File

@@ -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
@@ -49,19 +50,28 @@ export default function App() {
} }
if (appState === 'checking') { if (appState === 'checking') {
return <div className="loading">...</div> return <div className="loading">Checking configuration status...</div>
} }
if (appState === 'setup') { if (appState === 'setup') {
return <SetupWizardPage wizardBase={WIZARD_BASE} onComplete={checkInitialized} /> return <SetupWizardPage wizardBase={WIZARD_BASE} onComplete={checkInitialized} />
} }
if (loading) return <div className="loading">...</div> if (loading) return <div className="loading">Loading...</div>
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>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import api from '@/services/api' import api from '@/services/api'
import type { User } from '@/types' import type { User } from '@/types'
@@ -10,9 +10,14 @@ interface Props {
export default function Sidebar({ user, onLogout }: Props) { export default function Sidebar({ user, onLogout }: Props) {
const { pathname } = useLocation() const { pathname } = useLocation()
const navigate = useNavigate()
const [unreadCount, setUnreadCount] = useState(0) const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => { useEffect(() => {
if (!user) {
setUnreadCount(0)
return
}
api.get<{ count: number }>('/notifications/count') api.get<{ count: number }>('/notifications/count')
.then(({ data }) => setUnreadCount(data.count)) .then(({ data }) => setUnreadCount(data.count))
.catch(() => {}) .catch(() => {})
@@ -22,14 +27,17 @@ export default function Sidebar({ user, onLogout }: Props) {
.catch(() => {}) .catch(() => {})
}, 30000) }, 30000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, []) }, [user])
const links = [ const links = user ? [
{ to: '/', icon: '📊', label: '仪表盘' }, { to: '/', icon: '📊', label: 'Dashboard' },
{ to: '/issues', icon: '📋', label: 'Issues' }, { to: '/issues', icon: '📋', label: 'Issues' },
{ to: '/projects', icon: '📁', label: '项目' }, { to: '/projects', icon: '📁', label: 'Projects' },
{ to: '/milestones', icon: '🏁', label: '里程碑' }, { to: '/milestones', icon: '🏁', label: 'Milestones' },
{ to: '/notifications', icon: '🔔', label: `通知${unreadCount > 0 ? ` (${unreadCount})` : ''}` }, { to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
{ to: '/monitor', icon: '📡', label: 'Monitor' },
] : [
{ to: '/monitor', icon: '📡', label: 'Monitor' },
] ]
return ( return (
@@ -44,12 +52,14 @@ export default function Sidebar({ user, onLogout }: Props) {
</li> </li>
))} ))}
</ul> </ul>
{user && ( <div className="sidebar-footer">
<div className="sidebar-footer"> <span>👤 {user ? user.username : 'Guest'}</span>
<span>👤 {user.username}</span> {user ? (
<button onClick={onLogout}>退</button> <button onClick={onLogout}>Log out</button>
</div> ) : (
)} <button onClick={() => navigate('/login')}>Log in</button>
)}
</div>
</nav> </nav>
) )
} }

View File

@@ -183,3 +183,21 @@ dd { font-size: .9rem; }
.setup-done { text-align: center; } .setup-done { text-align: center; }
.setup-done h2 { color: var(--success); margin-bottom: 12px; } .setup-done h2 { color: var(--success); margin-bottom: 12px; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* Monitor */
.monitor-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 12px; }
.monitor-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
.monitor-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
.monitor-metrics { margin: 8px 0; font-size: .9rem; }
.monitor-admin { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.status-ok { background: var(--success); }
.status-error { background: var(--danger); }
.status-pending { background: var(--warning); }
.status-online { background: var(--success); }
.status-offline { background: var(--danger); }
.btn-secondary { background: none; border: 1px solid var(--border); color: var(--text); padding: 6px 12px; border-radius: 6px; cursor: pointer; }
.btn-danger { background: var(--danger); color: #fff; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; }
.btn-danger:hover { opacity: .9; }

View File

@@ -27,16 +27,16 @@ export default function CreateIssuePage() {
return ( return (
<div className="create-issue"> <div className="create-issue">
<h2> Issue</h2> <h2>Create Issue</h2>
<form onSubmit={submit}> <form onSubmit={submit}>
<label> <input required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></label> <label>Title <input required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></label>
<label> <textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label> <label>Description <textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<label> <label>Projects
<select value={form.project_id} onChange={(e) => setForm({ ...form, project_id: Number(e.target.value) })}> <select value={form.project_id} onChange={(e) => setForm({ ...form, project_id: Number(e.target.value) })}>
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)} {projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select> </select>
</label> </label>
<label> <label>Type
<select value={form.issue_type} onChange={(e) => setForm({ ...form, issue_type: e.target.value })}> <select value={form.issue_type} onChange={(e) => setForm({ ...form, issue_type: e.target.value })}>
<option value="task">Task</option> <option value="task">Task</option>
<option value="bug">Bug</option> <option value="bug">Bug</option>
@@ -44,7 +44,7 @@ export default function CreateIssuePage() {
<option value="resolution">Resolution</option> <option value="resolution">Resolution</option>
</select> </select>
</label> </label>
<label> <label>Priority
<select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}> <select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}>
<option value="low">Low</option> <option value="low">Low</option>
<option value="medium">Medium</option> <option value="medium">Medium</option>
@@ -52,8 +52,8 @@ export default function CreateIssuePage() {
<option value="critical">Critical</option> <option value="critical">Critical</option>
</select> </select>
</label> </label>
<label> <input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="逗号分隔" /></label> <label>Tags <input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="Comma separated" /></label>
<button type="submit" className="btn-primary"></button> <button type="submit" className="btn-primary">Create</button>
</form> </form>
</div> </div>
) )

View File

@@ -9,7 +9,7 @@ export default function DashboardPage() {
api.get<DashboardStats>('/dashboard/stats').then(({ data }) => setStats(data)) api.get<DashboardStats>('/dashboard/stats').then(({ data }) => setStats(data))
}, []) }, [])
if (!stats) return <div className="loading">...</div> if (!stats) return <div className="loading">Loading...</div>
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981', open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
@@ -21,12 +21,12 @@ export default function DashboardPage() {
return ( return (
<div className="dashboard"> <div className="dashboard">
<h2>📊 </h2> <h2>📊 Dashboard</h2>
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-card total"> <div className="stat-card total">
<span className="stat-number">{stats.total_issues}</span> <span className="stat-number">{stats.total_issues}</span>
<span className="stat-label"> Issues</span> <span className="stat-label">Total Issues</span>
</div> </div>
{Object.entries(stats.by_status || {}).map(([k, v]) => ( {Object.entries(stats.by_status || {}).map(([k, v]) => (
<div className="stat-card" key={k} style={{ borderLeftColor: statusColors[k] || '#ccc' }}> <div className="stat-card" key={k} style={{ borderLeftColor: statusColors[k] || '#ccc' }}>
@@ -37,7 +37,7 @@ export default function DashboardPage() {
</div> </div>
<div className="section"> <div className="section">
<h3></h3> <h3>By Priority</h3>
<div className="bar-chart"> <div className="bar-chart">
{Object.entries(stats.by_priority || {}).map(([k, v]) => ( {Object.entries(stats.by_priority || {}).map(([k, v]) => (
<div className="bar-row" key={k}> <div className="bar-row" key={k}>
@@ -52,10 +52,10 @@ export default function DashboardPage() {
</div> </div>
<div className="section"> <div className="section">
<h3> Issues</h3> <h3>Recent Issues</h3>
<table> <table>
<thead> <thead>
<tr><th>ID</th><th></th><th></th><th></th><th></th></tr> <tr><th>ID</th><th>Title</th><th>Status</th><th>Priority</th><th>Type</th></tr>
</thead> </thead>
<tbody> <tbody>
{(stats.recent_issues || []).map((i) => ( {(stats.recent_issues || []).map((i) => (

View File

@@ -30,7 +30,7 @@ export default function IssueDetailPage() {
setIssue(data) setIssue(data)
} }
if (!issue) return <div className="loading">...</div> if (!issue) return <div className="loading">Loading...</div>
const statusActions: Record<string, string[]> = { const statusActions: Record<string, string[]> = {
open: ['in_progress', 'blocked'], open: ['in_progress', 'blocked'],
@@ -42,7 +42,7 @@ export default function IssueDetailPage() {
return ( return (
<div className="issue-detail"> <div className="issue-detail">
<button className="btn-back" onClick={() => navigate('/issues')}> </button> <button className="btn-back" onClick={() => navigate('/issues')}> Back</button>
<div className="issue-header"> <div className="issue-header">
<h2>#{issue.id} {issue.title}</h2> <h2>#{issue.id} {issue.title}</h2>
@@ -56,21 +56,21 @@ export default function IssueDetailPage() {
<div className="issue-body"> <div className="issue-body">
<div className="section"> <div className="section">
<h3></h3> <h3>Description</h3>
<p>{issue.description || '暂无描述'}</p> <p>{issue.description || 'No description'}</p>
</div> </div>
<div className="section"> <div className="section">
<h3></h3> <h3>Details</h3>
<dl> <dl>
<dt></dt><dd>{dayjs(issue.created_at).format('YYYY-MM-DD HH:mm')}</dd> <dt>Created</dt><dd>{dayjs(issue.created_at).format('YYYY-MM-DD HH:mm')}</dd>
{issue.due_date && <><dt></dt><dd>{dayjs(issue.due_date).format('YYYY-MM-DD')}</dd></>} {issue.due_date && <><dt>Due date</dt><dd>{dayjs(issue.due_date).format('YYYY-MM-DD')}</dd></>}
{issue.updated_at && <><dt></dt><dd>{dayjs(issue.updated_at).format('YYYY-MM-DD HH:mm')}</dd></>} {issue.updated_at && <><dt>Updated</dt><dd>{dayjs(issue.updated_at).format('YYYY-MM-DD HH:mm')}</dd></>}
</dl> </dl>
</div> </div>
<div className="section"> <div className="section">
<h3></h3> <h3>Status changes</h3>
<div className="actions"> <div className="actions">
{(statusActions[issue.status] || []).map((s) => ( {(statusActions[issue.status] || []).map((s) => (
<button key={s} className="btn-transition" onClick={() => transition(s)}>{s}</button> <button key={s} className="btn-transition" onClick={() => transition(s)}>{s}</button>
@@ -79,16 +79,16 @@ export default function IssueDetailPage() {
</div> </div>
<div className="section"> <div className="section">
<h3> ({comments.length})</h3> <h3>Comments ({comments.length})</h3>
{comments.map((c) => ( {comments.map((c) => (
<div className="comment" key={c.id}> <div className="comment" key={c.id}>
<div className="comment-meta"> #{c.author_id} · {dayjs(c.created_at).format('MM-DD HH:mm')}</div> <div className="comment-meta">User #{c.author_id} · {dayjs(c.created_at).format('MM-DD HH:mm')}</div>
<p>{c.content}</p> <p>{c.content}</p>
</div> </div>
))} ))}
<div className="comment-form"> <div className="comment-form">
<textarea value={newComment} onChange={(e) => setNewComment(e.target.value)} placeholder="添加评论..." /> <textarea value={newComment} onChange={(e) => setNewComment(e.target.value)} placeholder="Add a comment..." />
<button onClick={addComment} disabled={!newComment.trim()}></button> <button onClick={addComment} disabled={!newComment.trim()}>Submit comment</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -33,12 +33,12 @@ export default function IssuesPage() {
<div className="issues-page"> <div className="issues-page">
<div className="page-header"> <div className="page-header">
<h2>📋 Issues ({total})</h2> <h2>📋 Issues ({total})</h2>
<button className="btn-primary" onClick={() => navigate('/issues/new')}>+ Issue</button> <button className="btn-primary" onClick={() => navigate('/issues/new')}>+ Create Issue</button>
</div> </div>
<div className="filters"> <div className="filters">
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}> <select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}>
<option value=""></option> <option value="">All statuses</option>
<option value="open">Open</option> <option value="open">Open</option>
<option value="in_progress">In Progress</option> <option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
@@ -49,7 +49,7 @@ export default function IssuesPage() {
<table className="issues-table"> <table className="issues-table">
<thead> <thead>
<tr><th>#</th><th></th><th></th><th></th><th></th><th></th><th></th></tr> <tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th><th>Type</th><th>Tags</th><th>Created</th></tr>
</thead> </thead>
<tbody> <tbody>
{issues.map((i) => ( {issues.map((i) => (
@@ -68,9 +68,9 @@ export default function IssuesPage() {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="pagination"> <div className="pagination">
<button disabled={page <= 1} onClick={() => setPage(page - 1)}></button> <button disabled={page <= 1} onClick={() => setPage(page - 1)}>Prev</button>
<span>{page} / {totalPages}</span> <span>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}></button> <button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>Next</button>
</div> </div>
)} )}
</div> </div>

View File

@@ -17,7 +17,7 @@ export default function LoginPage({ onLogin }: Props) {
try { try {
await onLogin(username, password) await onLogin(username, password)
} catch { } catch {
setError('登录失败,请检查用户名和密码') setError('Login failed. Check username and password.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -27,25 +27,25 @@ export default function LoginPage({ onLogin }: Props) {
<div className="login-page"> <div className="login-page">
<div className="login-card"> <div className="login-card">
<h1> HarborForge</h1> <h1> HarborForge</h1>
<p className="subtitle">Agent/</p> <p className="subtitle">Agent/Human collaborative task management platform</p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<input <input
type="text" type="text"
placeholder="用户名" placeholder="Username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
/> />
<input <input
type="password" type="password"
placeholder="密码" placeholder="Password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}> <button type="submit" disabled={loading}>
{loading ? '登录中...' : '登录'} {loading ? 'Signing in...' : 'Sign in'}
</button> </button>
</form> </form>
</div> </div>

View File

@@ -17,37 +17,37 @@ export default function MilestoneDetailPage() {
api.get<Issue[]>(`/milestones/${id}/issues`).then(({ data }) => setIssues(data)).catch(() => {}) api.get<Issue[]>(`/milestones/${id}/issues`).then(({ data }) => setIssues(data)).catch(() => {})
}, [id]) }, [id])
if (!milestone) return <div className="loading">...</div> if (!milestone) return <div className="loading">Loading...</div>
return ( return (
<div className="milestone-detail"> <div className="milestone-detail">
<button className="btn-back" onClick={() => navigate('/milestones')}> </button> <button className="btn-back" onClick={() => navigate('/milestones')}> Back to milestones</button>
<div className="issue-header"> <div className="issue-header">
<h2>🏁 {milestone.title}</h2> <h2>🏁 {milestone.title}</h2>
<div className="issue-meta"> <div className="issue-meta">
<span className={`badge status-${milestone.status === 'active' ? 'open' : 'closed'}`}>{milestone.status}</span> <span className={`badge status-${milestone.status === 'active' ? 'open' : 'closed'}`}>{milestone.status}</span>
{milestone.due_date && <span className="text-dim"> {dayjs(milestone.due_date).format('YYYY-MM-DD')}</span>} {milestone.due_date && <span className="text-dim">Due {dayjs(milestone.due_date).format('YYYY-MM-DD')}</span>}
</div> </div>
</div> </div>
{milestone.description && ( {milestone.description && (
<div className="section"> <div className="section">
<h3></h3> <h3>Description</h3>
<p>{milestone.description}</p> <p>{milestone.description}</p>
</div> </div>
)} )}
{progress && ( {progress && (
<div className="section"> <div className="section">
<h3></h3> <h3>Progress</h3>
<div className="progress-bar-container"> <div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress.progress_percent}%` }}> <div className="progress-bar" style={{ width: `${progress.progress_percent}%` }}>
{progress.progress_percent.toFixed(0)}% {progress.progress_percent.toFixed(0)}%
</div> </div>
</div> </div>
<p className="text-dim" style={{ marginTop: 8 }}> <p className="text-dim" style={{ marginTop: 8 }}>
{progress.completed_issues} / {progress.total_issues} issues {progress.completed_issues} / {progress.total_issues} issues completed
</p> </p>
</div> </div>
)} )}
@@ -56,7 +56,7 @@ export default function MilestoneDetailPage() {
<h3>Issues ({issues.length})</h3> <h3>Issues ({issues.length})</h3>
<table> <table>
<thead> <thead>
<tr><th>#</th><th></th><th></th><th></th></tr> <tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr>
</thead> </thead>
<tbody> <tbody>
{issues.map((i) => ( {issues.map((i) => (
@@ -67,7 +67,7 @@ export default function MilestoneDetailPage() {
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td> <td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
</tr> </tr>
))} ))}
{issues.length === 0 && <tr><td colSpan={4} className="empty"> issue</td></tr>} {issues.length === 0 && <tr><td colSpan={4} className="empty">No linked issues</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -38,31 +38,31 @@ export default function MilestonesPage() {
return ( return (
<div className="milestones-page"> <div className="milestones-page">
<div className="page-header"> <div className="page-header">
<h2>🏁 ({milestones.length})</h2> <h2>🏁 Milestones ({milestones.length})</h2>
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? '取消' : '+ 新建里程碑'} {showCreate ? 'Cancel' : '+ NewMilestones'}
</button> </button>
</div> </div>
<div className="filters"> <div className="filters">
<select value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}> <select value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}>
<option value=""></option> <option value="">All projects</option>
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)} {projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select> </select>
</div> </div>
{showCreate && ( {showCreate && (
<form className="inline-form" onSubmit={createMilestone}> <form className="inline-form" onSubmit={createMilestone}>
<input required placeholder="里程碑标题" value={form.title} <input required placeholder="MilestonesTitle" value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })} /> onChange={(e) => setForm({ ...form, title: e.target.value })} />
<input placeholder="描述(可选)" value={form.description} <input placeholder="Description (optional)" value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} /> onChange={(e) => setForm({ ...form, description: e.target.value })} />
<select value={form.project_id} onChange={(e) => setForm({ ...form, project_id: Number(e.target.value) })}> <select value={form.project_id} onChange={(e) => setForm({ ...form, project_id: Number(e.target.value) })}>
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)} {projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select> </select>
<input type="date" value={form.due_date} <input type="date" value={form.due_date}
onChange={(e) => setForm({ ...form, due_date: e.target.value })} /> onChange={(e) => setForm({ ...form, due_date: e.target.value })} />
<button type="submit" className="btn-primary"></button> <button type="submit" className="btn-primary">Create</button>
</form> </form>
)} )}
@@ -73,14 +73,14 @@ export default function MilestonesPage() {
<span className={`badge status-${ms.status === 'active' ? 'open' : 'closed'}`}>{ms.status}</span> <span className={`badge status-${ms.status === 'active' ? 'open' : 'closed'}`}>{ms.status}</span>
<h3>{ms.title}</h3> <h3>{ms.title}</h3>
</div> </div>
<p className="project-desc">{ms.description || '暂无描述'}</p> <p className="project-desc">{ms.description || 'No description'}</p>
<div className="project-meta"> <div className="project-meta">
{ms.due_date && <span> {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>} {ms.due_date && <span>Due {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>}
<span> {dayjs(ms.created_at).format('YYYY-MM-DD')}</span> <span>Created {dayjs(ms.created_at).format('YYYY-MM-DD')}</span>
</div> </div>
</div> </div>
))} ))}
{milestones.length === 0 && <p className="empty"></p>} {milestones.length === 0 && <p className="empty">No milestones</p>}
</div> </div>
</div> </div>
) )

176
src/pages/MonitorPage.tsx Normal file
View File

@@ -0,0 +1,176 @@
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 Issues</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>
)
}

View File

@@ -32,16 +32,16 @@ export default function NotificationsPage() {
return ( return (
<div className="notifications-page"> <div className="notifications-page">
<div className="page-header"> <div className="page-header">
<h2>🔔 {unreadCount > 0 && <span className="badge" style={{ background: 'var(--danger)' }}>{unreadCount}</span>}</h2> <h2>🔔 Notifications {unreadCount > 0 && <span className="badge" style={{ background: 'var(--danger)' }}>{unreadCount}</span>}</h2>
{unreadCount > 0 && ( {unreadCount > 0 && (
<button className="btn-primary" onClick={markAllRead}></button> <button className="btn-primary" onClick={markAllRead}>Mark all read</button>
)} )}
</div> </div>
<div className="filters"> <div className="filters">
<label className="filter-check"> <label className="filter-check">
<input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} /> <input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} />
Show unread only
</label> </label>
</div> </div>
@@ -62,7 +62,7 @@ export default function NotificationsPage() {
</div> </div>
</div> </div>
))} ))}
{notifications.length === 0 && <p className="empty"></p>} {notifications.length === 0 && <p className="empty">No notifications</p>}
</div> </div>
</div> </div>
) )

View File

@@ -31,62 +31,62 @@ export default function ProjectDetailPage() {
setEditing(false) setEditing(false)
} }
if (!project) return <div className="loading">...</div> if (!project) return <div className="loading">Loading...</div>
return ( return (
<div className="project-detail"> <div className="project-detail">
<button className="btn-back" onClick={() => navigate('/projects')}> </button> <button className="btn-back" onClick={() => navigate('/projects')}> Back to projects</button>
<div className="issue-header"> <div className="issue-header">
{editing ? ( {editing ? (
<form className="inline-form" onSubmit={updateProject}> <form className="inline-form" onSubmit={updateProject}>
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required /> <input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required />
<input value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} placeholder="描述" /> <input value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} placeholder="Description" />
<button type="submit" className="btn-primary"></button> <button type="submit" className="btn-primary">Save</button>
<button type="button" className="btn-back" onClick={() => setEditing(false)}></button> <button type="button" className="btn-back" onClick={() => setEditing(false)}>Cancel</button>
</form> </form>
) : ( ) : (
<> <>
<h2>📁 {project.name}</h2> <h2>📁 {project.name}</h2>
<p style={{ color: 'var(--text-dim)', marginTop: 4 }}>{project.description || '暂无描述'}</p> <p style={{ color: 'var(--text-dim)', marginTop: 4 }}>{project.description || 'No description'}</p>
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setEditing(true)}></button> <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setEditing(true)}>Edit</button>
</> </>
)} )}
</div> </div>
<div className="section"> <div className="section">
<h3> ({members.length})</h3> <h3>Members ({members.length})</h3>
{members.length > 0 ? ( {members.length > 0 ? (
<div className="member-list"> <div className="member-list">
{members.map((m) => ( {members.map((m) => (
<span key={m.id} className="badge">{`用户 #${m.user_id} (${m.role})`}</span> <span key={m.id} className="badge">{`User #${m.user_id} (${m.role})`}</span>
))} ))}
</div> </div>
) : ( ) : (
<p className="empty"></p> <p className="empty">No members</p>
)} )}
</div> </div>
<div className="section"> <div className="section">
<h3> ({milestones.length})</h3> <h3>Milestones ({milestones.length})</h3>
{milestones.map((ms) => ( {milestones.map((ms) => (
<div key={ms.id} className="milestone-item" onClick={() => navigate(`/milestones/${ms.id}`)}> <div key={ms.id} className="milestone-item" onClick={() => navigate(`/milestones/${ms.id}`)}>
<span className={`badge status-${ms.status === 'active' ? 'open' : ms.status === 'closed' ? 'closed' : 'in_progress'}`}>{ms.status}</span> <span className={`badge status-${ms.status === 'active' ? 'open' : ms.status === 'closed' ? 'closed' : 'in_progress'}`}>{ms.status}</span>
<span className="milestone-title">{ms.title}</span> <span className="milestone-title">{ms.title}</span>
{ms.due_date && <span className="text-dim"> · {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>} {ms.due_date && <span className="text-dim"> · Due {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>}
</div> </div>
))} ))}
{milestones.length === 0 && <p className="empty"></p>} {milestones.length === 0 && <p className="empty">No milestones</p>}
</div> </div>
<div className="section"> <div className="section">
<div className="page-header"> <div className="page-header">
<h3> Issues</h3> <h3>Recent Issues</h3>
<button className="btn-primary" onClick={() => navigate('/issues/new')}>+ </button> <button className="btn-primary" onClick={() => navigate('/issues/new')}>+ New</button>
</div> </div>
<table> <table>
<thead> <thead>
<tr><th>#</th><th></th><th></th><th></th></tr> <tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr>
</thead> </thead>
<tbody> <tbody>
{issues.map((i) => ( {issues.map((i) => (

View File

@@ -27,23 +27,23 @@ export default function ProjectsPage() {
return ( return (
<div className="projects-page"> <div className="projects-page">
<div className="page-header"> <div className="page-header">
<h2>📁 ({projects.length})</h2> <h2>📁 Projects ({projects.length})</h2>
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? '取消' : '+ 新建项目'} {showCreate ? 'Cancel' : '+ NewProjects'}
</button> </button>
</div> </div>
{showCreate && ( {showCreate && (
<form className="inline-form" onSubmit={createProject}> <form className="inline-form" onSubmit={createProject}>
<input <input
required placeholder="项目名称" value={form.name} required placeholder="Project name" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
/> />
<input <input
placeholder="项目描述(可选)" value={form.description} placeholder="ProjectsDescription (optional)" value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} onChange={(e) => setForm({ ...form, description: e.target.value })}
/> />
<button type="submit" className="btn-primary"></button> <button type="submit" className="btn-primary">Create</button>
</form> </form>
)} )}
@@ -51,13 +51,13 @@ export default function ProjectsPage() {
{projects.map((p) => ( {projects.map((p) => (
<div key={p.id} className="project-card" onClick={() => navigate(`/projects/${p.id}`)}> <div key={p.id} className="project-card" onClick={() => navigate(`/projects/${p.id}`)}>
<h3>{p.name}</h3> <h3>{p.name}</h3>
<p className="project-desc">{p.description || '暂无描述'}</p> <p className="project-desc">{p.description || 'No description'}</p>
<div className="project-meta"> <div className="project-meta">
<span> {dayjs(p.created_at).format('YYYY-MM-DD')}</span> <span>Created {dayjs(p.created_at).format('YYYY-MM-DD')}</span>
</div> </div>
</div> </div>
))} ))}
{projects.length === 0 && <p className="empty"></p>} {projects.length === 0 && <p className="empty">No projects yet. Create one above.</p>}
</div> </div>
</div> </div>
) )

View File

@@ -21,7 +21,7 @@ interface SetupForm {
project_description: string project_description: string
} }
const STEPS = ['欢迎', '数据库', '管理员', '项目', '完成'] const STEPS = ['Welcome', 'Database', 'Admin', 'Projects', 'Finish']
export default function SetupWizardPage({ wizardBase, onComplete }: Props) { export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
@@ -40,7 +40,7 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
db_database: 'harborforge', db_database: 'harborforge',
backend_base_url: 'http://127.0.0.1:8000', backend_base_url: 'http://127.0.0.1:8000',
project_name: 'Default', project_name: 'Default',
project_description: '默认项目', project_description: 'Default project',
}) })
const wizardApi = axios.create({ const wizardApi = axios.create({
@@ -59,7 +59,7 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
setStep(1) setStep(1)
} catch { } catch {
setWizardOk(false) setWizardOk(false)
setError(`无法连接 AbstractWizard (${wizardBase})\n请确认已通过 SSH 隧道映射端口:\nssh -L <wizard_port>:127.0.0.1:<wizard_port> user@server`) setError(`Unable to connect to AbstractWizard (${wizardBase}).\nPlease ensure the SSH tunnel is configured:\nssh -L <wizard_port>:127.0.0.1:<wizard_port> user@server`)
} }
} }
@@ -98,7 +98,7 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
setStep(4) setStep(4)
} catch (err: any) { } catch (err: any) {
setError(`保存配置失败: ${err.message}`) setError(`Failed to save configuration: ${err.message}`)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -108,7 +108,7 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
<div className="setup-wizard"> <div className="setup-wizard">
<div className="setup-container"> <div className="setup-container">
<div className="setup-header"> <div className="setup-header">
<h1> HarborForge </h1> <h1> HarborForge Setup Wizard</h1>
<div className="setup-steps"> <div className="setup-steps">
{STEPS.map((s, i) => ( {STEPS.map((s, i) => (
<span key={i} className={`setup-step ${i === step ? 'active' : i < step ? 'done' : ''}`}> <span key={i} className={`setup-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
@@ -123,17 +123,17 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
{/* Step 0: Welcome */} {/* Step 0: Welcome */}
{step === 0 && ( {step === 0 && (
<div className="setup-step-content"> <div className="setup-step-content">
<h2>使 HarborForge</h2> <h2>Welcome to HarborForge</h2>
<p>Agent/</p> <p>Agent/Human collaborative task management platform</p>
<div className="setup-info"> <div className="setup-info">
<p> SSH AbstractWizard</p> <p> The setup wizard connects to AbstractWizard via SSH tunnel. Ensure the port is forwarded:</p>
<code>ssh -L &lt;wizard_port&gt;:127.0.0.1:&lt;wizard_port&gt; user@your-server</code> <code>ssh -L &lt;wizard_port&gt;:127.0.0.1:&lt;wizard_port&gt; user@your-server</code>
</div> </div>
<button className="btn-primary" onClick={checkWizard}> <button className="btn-primary" onClick={checkWizard}>
Wizard Connect to Wizard
</button> </button>
{wizardOk === false && ( {wizardOk === false && (
<p className="setup-hint"> SSH </p> <p className="setup-hint">Connection failed. Check the SSH tunnel.</p>
)} )}
</div> </div>
)} )}
@@ -141,18 +141,18 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
{/* Step 1: Database */} {/* Step 1: Database */}
{step === 1 && ( {step === 1 && (
<div className="setup-step-content"> <div className="setup-step-content">
<h2></h2> <h2>Database configuration</h2>
<p className="text-dim"> MySQL 使 docker-compose MySQL </p> <p className="text-dim">Configure MySQL connection (docker-compose defaults are fine if using the bundled MySQL).</p>
<div className="setup-form"> <div className="setup-form">
<label> <input value={form.db_host} onChange={(e) => set('db_host', e.target.value)} /></label> <label>Host <input value={form.db_host} onChange={(e) => set('db_host', e.target.value)} /></label>
<label> <input type="number" value={form.db_port} onChange={(e) => set('db_port', Number(e.target.value))} /></label> <label>Port <input type="number" value={form.db_port} onChange={(e) => set('db_port', Number(e.target.value))} /></label>
<label> <input value={form.db_user} onChange={(e) => set('db_user', e.target.value)} /></label> <label>Username <input value={form.db_user} onChange={(e) => set('db_user', e.target.value)} /></label>
<label> <input type="password" value={form.db_password} onChange={(e) => set('db_password', e.target.value)} /></label> <label>Password <input type="password" value={form.db_password} onChange={(e) => set('db_password', e.target.value)} /></label>
<label> <input value={form.db_database} onChange={(e) => set('db_database', e.target.value)} /></label> <label>Database <input value={form.db_database} onChange={(e) => set('db_database', e.target.value)} /></label>
</div> </div>
<div className="setup-nav"> <div className="setup-nav">
<button className="btn-back" onClick={() => setStep(0)}></button> <button className="btn-back" onClick={() => setStep(0)}>Back</button>
<button className="btn-primary" onClick={() => setStep(2)}></button> <button className="btn-primary" onClick={() => setStep(2)}>Next</button>
</div> </div>
</div> </div>
)} )}
@@ -160,21 +160,21 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
{/* Step 2: Admin */} {/* Step 2: Admin */}
{step === 2 && ( {step === 2 && (
<div className="setup-step-content"> <div className="setup-step-content">
<h2></h2> <h2>Admin account</h2>
<p className="text-dim"></p> <p className="text-dim">Create the first admin user</p>
<div className="setup-form"> <div className="setup-form">
<label> <input value={form.admin_username} onChange={(e) => set('admin_username', e.target.value)} required /></label> <label>Username <input value={form.admin_username} onChange={(e) => set('admin_username', e.target.value)} required /></label>
<label> <input type="password" value={form.admin_password} onChange={(e) => set('admin_password', e.target.value)} required placeholder="设置管理员密码" /></label> <label>Password <input type="password" value={form.admin_password} onChange={(e) => set('admin_password', e.target.value)} required placeholder="Set admin password" /></label>
<label> <input type="email" value={form.admin_email} onChange={(e) => set('admin_email', e.target.value)} placeholder="admin@example.com" /></label> <label>Email <input type="email" value={form.admin_email} onChange={(e) => set('admin_email', e.target.value)} placeholder="admin@example.com" /></label>
<label> <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label> <label>Full name <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label>
</div> </div>
<div className="setup-nav"> <div className="setup-nav">
<button className="btn-back" onClick={() => setStep(1)}></button> <button className="btn-back" onClick={() => setStep(1)}>Back</button>
<button className="btn-primary" onClick={() => { <button className="btn-primary" onClick={() => {
if (!form.admin_password) { setError('请设置管理员密码'); return } if (!form.admin_password) { setError('Please set an admin password'); return }
setError('') setError('')
setStep(3) setStep(3)
}}></button> }}>Next</button>
</div> </div>
</div> </div>
)} )}
@@ -182,17 +182,17 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
{/* Step 3: Project */} {/* Step 3: Project */}
{step === 3 && ( {step === 3 && (
<div className="setup-step-content"> <div className="setup-step-content">
<h2></h2> <h2>Default project (optional)</h2>
<p className="text-dim"></p> <p className="text-dim">Create an initial project or skip</p>
<div className="setup-form"> <div className="setup-form">
<label> Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://127.0.0.1:8000" /></label> <label>Backend Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://127.0.0.1:8000" /></label>
<label> <input value={form.project_name} onChange={(e) => set('project_name', e.target.value)} placeholder="留空则跳过" /></label> <label>Project name <input value={form.project_name} onChange={(e) => set('project_name', e.target.value)} placeholder="Leave blank to skip" /></label>
<label> <input value={form.project_description} onChange={(e) => set('project_description', e.target.value)} /></label> <label>ProjectsDescription <input value={form.project_description} onChange={(e) => set('project_description', e.target.value)} /></label>
</div> </div>
<div className="setup-nav"> <div className="setup-nav">
<button className="btn-back" onClick={() => setStep(2)}></button> <button className="btn-back" onClick={() => setStep(2)}>Back</button>
<button className="btn-primary" onClick={saveConfig} disabled={saving}> <button className="btn-primary" onClick={saveConfig} disabled={saving}>
{saving ? '保存中...' : '完成配置'} {saving ? 'Saving...' : 'Finish setup'}
</button> </button>
</div> </div>
</div> </div>
@@ -202,16 +202,16 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
{step === 4 && ( {step === 4 && (
<div className="setup-step-content"> <div className="setup-step-content">
<div className="setup-done"> <div className="setup-done">
<h2> </h2> <h2> Setup complete!</h2>
<p> AbstractWizard</p> <p>Configuration saved to AbstractWizard.</p>
<div className="setup-info"> <div className="setup-info">
<p></p> <p>Restart services on the server:</p>
<code>docker compose restart</code> <code>docker compose restart</code>
<p style={{ marginTop: '1rem' }}></p> <p style={{ marginTop: '1rem' }}>After the backend starts, refresh this page to go to login.</p>
<p>: <strong>{form.admin_username}</strong></p> <p>Admin account: <strong>{form.admin_username}</strong></p>
</div> </div>
<button className="btn-primary" onClick={onComplete}> <button className="btn-primary" onClick={onComplete}>
Refresh to check
</button> </button>
</div> </div>
</div> </div>

View File

@@ -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'
} }
} }