Compare commits

..

28 Commits

Author SHA1 Message Date
Zhi
00d824c71c feat: add repo field display and editing in project detail 2026-03-12 13:51:44 +00:00
c1cfff7baf Merge pull request 'feat/task-type-hierarchy' (#3) from feat/task-type-hierarchy into main
Reviewed-on: #3
2026-03-12 13:05:21 +00:00
Zhi
5e8d4ada0a fix: double confirm for project deletion 2026-03-12 12:55:16 +00:00
Zhi
dceb836385 fix: prevent deleting project owner 2026-03-12 12:47:17 +00:00
Zhi
efd1e53f14 fix: owner dropdown in edit form 2026-03-12 12:43:06 +00:00
Zhi
b9c9d39d7d fix: display owner_name in project detail 2026-03-12 12:36:36 +00:00
Zhi
eb6de37261 fix: display owner_name in project cards 2026-03-12 12:33:49 +00:00
Zhi
3ee1c9bc10 feat: add remove member button 2026-03-12 12:13:28 +00:00
Zhi
98ba64e35c feat: add repo field to Project 2026-03-12 12:06:46 +00:00
Zhi
529ceafde4 feat: add Add Member and New Milestone buttons in ProjectDetail 2026-03-12 11:54:41 +00:00
Zhi
f6460e2d70 feat: add Role Editor page 2026-03-12 11:45:30 +00:00
Zhi
bfaf9469e1 fix: project form - owner dropdown, sub/related projects multi-select 2026-03-12 10:52:54 +00:00
zhi
7099e5cf77 feat: remove issues/milestones from nav, show project code 2026-03-12 09:25:35 +00:00
zhi
5b37daed9b feat: add task type hierarchy with subtypes in UI 2026-03-11 23:59:12 +00:00
37fe4a4cf3 Merge pull request 'feat/public-monitor-and-agent-telemetry' (#2) from feat/public-monitor-and-agent-telemetry into main
Reviewed-on: #2
2026-03-11 22:13:52 +00:00
zhi
3d83eeb273 ui: remove provider monitoring section 2026-03-11 22:11:08 +00:00
zhi
c114beb245 ui: show login button when logged out and logout when logged in 2026-03-11 21:28:07 +00:00
zhi
0ab1d2f380 i18n: translate frontend UI strings to English 2026-03-11 21:19:54 +00:00
zhi
34ab80e50d style: align monitor page with dashboard look and add monitor styles 2026-03-11 17:49:44 +00:00
zhi
ddcbd28967 fix: repair monitor sidebar and alert rendering 2026-03-11 17:26:37 +00:00
zhi
d574258c8e feat: show provider window and last update in monitor view 2026-03-11 13:15:20 +00:00
zhi
18b1a51f3f feat: add monitor admin panel for provider/server management and challenge generation 2026-03-11 12:41:35 +00:00
zhi
bc1714281c feat: allow public monitor route without login and add monitor page 2026-03-11 11:59:56 +00:00
d3562582b4 Merge pull request 'fix/wizard-init-flow' (#1) from fix/wizard-init-flow into main
Reviewed-on: #1
2026-03-11 10:34:54 +00:00
zhi
dbb7d9013a fix: guard dashboard recent_issues when backend payload omits field 2026-03-11 10:32:01 +00:00
zhi
bd4a206e76 fix: use configured backend_url for API base instead of /api path
- Read HF_BACKEND_BASE_URL from localStorage in api client
- Refresh baseURL on each request interceptor
- Persist backend_url from wizard config during app bootstrap
- Persist backend_base_url after setup save
2026-03-11 10:31:25 +00:00
zhi
d3ca13108b feat: add backend base URL field in setup config 2026-03-11 10:26:23 +00:00
zhi
c92e399218 fix: check wizard config for initialized flag instead of backend health
- App checks wizard API for harborforge.json config with initialized=true
- If not initialized, show embedded setup wizard (talks to wizard API via CORS)
- Setup saves config with initialized:true to wizard config volume
- After restart, backend reads config and starts, frontend sees initialized=true
- Remove VITE_API_BASE build arg (not needed, api.ts uses /api relative path)
- Fix Object.entries null crash in DashboardPage
2026-03-11 10:09:33 +00:00
20 changed files with 2846 additions and 211 deletions

View File

@@ -4,9 +4,7 @@ WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
ARG VITE_API_BASE=/api
ARG VITE_WIZARD_PORT=18080
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_WIZARD_PORT=$VITE_WIZARD_PORT
RUN npm run build

2058
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,12 @@ import ProjectDetailPage from '@/pages/ProjectDetailPage'
import MilestonesPage from '@/pages/MilestonesPage'
import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
import NotificationsPage from '@/pages/NotificationsPage'
import api from '@/services/api'
import RoleEditorPage from '@/pages/RoleEditorPage'
import MonitorPage from '@/pages/MonitorPage'
import axios from 'axios'
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
const WIZARD_BASE = `http://127.0.0.1:${WIZARD_PORT}`
type AppState = 'checking' | 'setup' | 'ready'
@@ -23,35 +26,54 @@ export default function App() {
const [appState, setAppState] = useState<AppState>('checking')
const { user, loading, login, logout } = useAuth()
const checkBackend = async () => {
useEffect(() => {
checkInitialized()
}, [])
const checkInitialized = async () => {
try {
await api.get('/health')
setAppState('ready')
const res = await axios.get(`${WIZARD_BASE}/api/v1/config/harborforge.json`, {
timeout: 5000,
})
const cfg = res.data || {}
if (cfg.backend_url) {
localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url)
}
if (cfg.initialized === true) {
setAppState('ready')
} else {
setAppState('setup')
}
} catch {
// Wizard unreachable or config doesn't exist → setup needed
setAppState('setup')
}
}
useEffect(() => { checkBackend() }, [])
// Checking backend availability
if (appState === 'checking') {
return <div className="loading">...</div>
return <div className="loading">Checking configuration status...</div>
}
// Backend not ready — show setup wizard
if (appState === 'setup') {
return <SetupWizardPage wizardPort={WIZARD_PORT} onComplete={checkBackend} />
return <SetupWizardPage wizardBase={WIZARD_BASE} onComplete={checkInitialized} />
}
// Backend ready but auth loading
if (loading) return <div className="loading">...</div>
if (loading) return <div className="loading">Loading...</div>
// Not logged in
if (!user) {
return (
<BrowserRouter>
<LoginPage onLogin={login} />
<div className="app-layout">
<Sidebar user={null} onLogout={logout} />
<main className="main-content">
<Routes>
<Route path="/roles" element={<RoleEditorPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="/login" element={<LoginPage onLogin={login} />} />
<Route path="*" element={<Navigate to="/monitor" />} />
</Routes>
</main>
</div>
</BrowserRouter>
)
}
@@ -71,6 +93,8 @@ export default function App() {
<Route path="/milestones" element={<MilestonesPage />} />
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/roles" element={<RoleEditorPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</main>

View File

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

View File

@@ -183,3 +183,21 @@ dd { font-size: .9rem; }
.setup-done { text-align: center; }
.setup-done h2 { color: var(--success); margin-bottom: 12px; }
@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

@@ -3,12 +3,25 @@ import { useNavigate } from 'react-router-dom'
import api from '@/services/api'
import type { Project } from '@/types'
const ISSUE_TYPES = [
{ value: 'story', label: 'Story', subtypes: ['feature', 'improvement', 'refactor'] },
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
{ value: 'task', label: 'Task', subtypes: ['defect'] },
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
{ value: 'maintenance', label: 'Maintenance', subtypes: ['deploy', 'release'] },
{ value: 'research', label: 'Research', subtypes: [] },
{ value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] },
{ value: 'support', label: 'Support', subtypes: ['access', 'information'] },
{ value: 'meeting', label: 'Meeting', subtypes: ['conference', 'handover', 'recap'] },
{ value: 'resolution', label: 'Resolution', subtypes: [] },
]
export default function CreateIssuePage() {
const navigate = useNavigate()
const [projects, setProjects] = useState<Project[]>([])
const [form, setForm] = useState({
title: '', description: '', project_id: 0, issue_type: 'task',
priority: 'medium', tags: '', reporter_id: 1,
title: '', description: '', project_id: 0, issue_type: 'issue',
issue_subtype: '', priority: 'medium', tags: '', reporter_id: 1,
})
useEffect(() => {
@@ -18,33 +31,46 @@ export default function CreateIssuePage() {
})
}, [])
const currentType = ISSUE_TYPES.find(t => t.value === form.issue_type) || ISSUE_TYPES[1]
const subtypes = currentType.subtypes || []
const handleTypeChange = (newType: string) => {
setForm(f => ({ ...f, issue_type: newType, issue_subtype: '' }))
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const payload = { ...form, tags: form.tags || null }
const payload: any = { ...form, tags: form.tags || null }
if (!form.issue_subtype) delete payload.issue_subtype
await api.post('/issues', payload)
navigate('/issues')
}
return (
<div className="create-issue">
<h2> Issue</h2>
<h2>Create Issue</h2>
<form onSubmit={submit}>
<label> <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>
<label>Title <input required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></label>
<label>Description <textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<label>Projects
<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>)}
</select>
</label>
<label>
<select value={form.issue_type} onChange={(e) => setForm({ ...form, issue_type: e.target.value })}>
<option value="task">Task</option>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="resolution">Resolution</option>
<label>Type
<select value={form.issue_type} onChange={(e) => handleTypeChange(e.target.value)}>
{ISSUE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</label>
<label>
{subtypes.length > 0 && (
<label>Subtype
<select value={form.issue_subtype} onChange={(e) => setForm({ ...form, issue_subtype: e.target.value })}>
<option value="">Select subtype</option>
{subtypes.map((s) => <option key={s} value={s}>{s.replace('_', ' ')}</option>)}
</select>
</label>
)}
<label>Priority
<select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}>
<option value="low">Low</option>
<option value="medium">Medium</option>
@@ -52,8 +78,8 @@ export default function CreateIssuePage() {
<option value="critical">Critical</option>
</select>
</label>
<label> <input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="逗号分隔" /></label>
<button type="submit" className="btn-primary"></button>
<label>Tags <input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="Comma separated" /></label>
<button type="submit" className="btn-primary">Create</button>
</form>
</div>
)

View File

@@ -9,7 +9,7 @@ export default function DashboardPage() {
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> = {
open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
@@ -21,14 +21,14 @@ export default function DashboardPage() {
return (
<div className="dashboard">
<h2>📊 </h2>
<h2>📊 Dashboard</h2>
<div className="stats-grid">
<div className="stat-card total">
<span className="stat-number">{stats.total_issues}</span>
<span className="stat-label"> Issues</span>
<span className="stat-label">Total Issues</span>
</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' }}>
<span className="stat-number">{v}</span>
<span className="stat-label">{k}</span>
@@ -37,9 +37,9 @@ export default function DashboardPage() {
</div>
<div className="section">
<h3></h3>
<h3>By Priority</h3>
<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}>
<span className="bar-label">{k}</span>
<div className="bar" style={{
@@ -52,19 +52,19 @@ export default function DashboardPage() {
</div>
<div className="section">
<h3> Issues</h3>
<h3>Recent Issues</h3>
<table>
<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><th>Subtype</th></tr>
</thead>
<tbody>
{stats.recent_issues.map((i) => (
{(stats.recent_issues || []).map((i) => (
<tr key={i.id}>
<td>#{i.id}</td>
<td><a href={`/issues/${i.id}`}>{i.title}</a></td>
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
<td>{i.issue_type}</td>
<td>{i.issue_type}</td><td>{i.issue_subtype || "-"}</td>
</tr>
))}
</tbody>

View File

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

View File

@@ -33,12 +33,12 @@ export default function IssuesPage() {
<div className="issues-page">
<div className="page-header">
<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 className="filters">
<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="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
@@ -49,7 +49,7 @@ export default function IssuesPage() {
<table className="issues-table">
<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>Subtype</th><th>Tags</th><th>Created</th></tr>
</thead>
<tbody>
{issues.map((i) => (
@@ -58,7 +58,7 @@ export default function IssuesPage() {
<td className="issue-title">{i.title}</td>
<td><span className="badge" style={{ backgroundColor: statusColors[i.status] || '#ccc' }}>{i.status}</span></td>
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
<td>{i.issue_type}</td>
<td>{i.issue_type}</td><td>{i.issue_subtype || "-"}</td>
<td>{i.tags || '-'}</td>
<td>{new Date(i.created_at).toLocaleDateString()}</td>
</tr>
@@ -68,9 +68,9 @@ export default function IssuesPage() {
{totalPages > 1 && (
<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>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}></button>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>Next</button>
</div>
)}
</div>

View File

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

View File

@@ -17,37 +17,37 @@ export default function MilestoneDetailPage() {
api.get<Issue[]>(`/milestones/${id}/issues`).then(({ data }) => setIssues(data)).catch(() => {})
}, [id])
if (!milestone) return <div className="loading">...</div>
if (!milestone) return <div className="loading">Loading...</div>
return (
<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">
<h2>🏁 {milestone.title}</h2>
<div className="issue-meta">
<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>
{milestone.description && (
<div className="section">
<h3></h3>
<h3>Description</h3>
<p>{milestone.description}</p>
</div>
)}
{progress && (
<div className="section">
<h3></h3>
<h3>Progress</h3>
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress.progress_percent}%` }}>
{progress.progress_percent.toFixed(0)}%
</div>
</div>
<p className="text-dim" style={{ marginTop: 8 }}>
{progress.completed_issues} / {progress.total_issues} issues
{progress.completed_issues} / {progress.total_issues} issues completed
</p>
</div>
)}
@@ -56,7 +56,7 @@ export default function MilestoneDetailPage() {
<h3>Issues ({issues.length})</h3>
<table>
<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>
<tbody>
{issues.map((i) => (
@@ -67,7 +67,7 @@ export default function MilestoneDetailPage() {
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
</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>
</table>
</div>

View File

@@ -38,31 +38,31 @@ export default function MilestonesPage() {
return (
<div className="milestones-page">
<div className="page-header">
<h2>🏁 ({milestones.length})</h2>
<h2>🏁 Milestones ({milestones.length})</h2>
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? '取消' : '+ 新建里程碑'}
{showCreate ? 'Cancel' : '+ NewMilestones'}
</button>
</div>
<div className="filters">
<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>)}
</select>
</div>
{showCreate && (
<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 })} />
<input placeholder="描述(可选)" value={form.description}
<input placeholder="Description (optional)" value={form.description}
onChange={(e) => setForm({ ...form, description: 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>)}
</select>
<input type="date" value={form.due_date}
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>
)}
@@ -73,14 +73,14 @@ export default function MilestonesPage() {
<span className={`badge status-${ms.status === 'active' ? 'open' : 'closed'}`}>{ms.status}</span>
<h3>{ms.title}</h3>
</div>
<p className="project-desc">{ms.description || '暂无描述'}</p>
<p className="project-desc">{ms.description || 'No description'}</p>
<div className="project-meta">
{ms.due_date && <span> {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>}
<span> {dayjs(ms.created_at).format('YYYY-MM-DD')}</span>
{ms.due_date && <span>Due {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>}
<span>Created {dayjs(ms.created_at).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>
)

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 (
<div className="notifications-page">
<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 && (
<button className="btn-primary" onClick={markAllRead}></button>
<button className="btn-primary" onClick={markAllRead}>Mark all read</button>
)}
</div>
<div className="filters">
<label className="filter-check">
<input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} />
Show unread only
</label>
</div>
@@ -62,7 +62,7 @@ export default function NotificationsPage() {
</div>
</div>
))}
{notifications.length === 0 && <p className="empty"></p>}
{notifications.length === 0 && <p className="empty">No notifications</p>}
</div>
</div>
)

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '@/services/api'
import type { Project, ProjectMember, Issue, Milestone, PaginatedResponse } from '@/types'
import type { Project, ProjectMember, Milestone } from '@/types'
import dayjs from 'dayjs'
export default function ProjectDetailPage() {
@@ -9,21 +9,42 @@ export default function ProjectDetailPage() {
const navigate = useNavigate()
const [project, setProject] = useState<Project | null>(null)
const [members, setMembers] = useState<ProjectMember[]>([])
const [issues, setIssues] = useState<Issue[]>([])
const [milestones, setMilestones] = useState<Milestone[]>([])
const [allProjects, setAllProjects] = useState<Project[]>([])
const [showAddMember, setShowAddMember] = useState(false)
const [showAddMilestone, setShowAddMilestone] = useState(false)
const [newMemberUserId, setNewMemberUserId] = useState(1)
const [newMemberRole, setNewMemberRole] = useState('developer')
const [newMilestoneTitle, setNewMilestoneTitle] = useState('')
const [users, setUsers] = useState<any[]>([])
const [roles, setRoles] = useState<any[]>([])
const [editing, setEditing] = useState(false)
const [editForm, setEditForm] = useState({ name: '', description: '' })
const [editForm, setEditForm] = useState({ owner: '', repo: '', description: '', sub_projects: [] as string[], related_projects: [] as string[] })
useEffect(() => {
api.get<Project>(`/projects/${id}`).then(({ data }) => {
setProject(data)
setEditForm({ name: data.name, description: data.description || '' })
setEditForm({
owner: data.owner_name || data.owner || '',
repo: data.repo || '',
description: data.description || '',
sub_projects: data.sub_projects || [],
related_projects: data.related_projects || [],
})
})
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
api.get<PaginatedResponse<Issue>>(`/issues?project_id=${id}&page_size=10`).then(({ data }) => setIssues(data.items))
api.get<Milestone[]>(`/milestones?project_id=${id}`).then(({ data }) => setMilestones(data))
api.get<Project[]>('/projects').then(({ data }) => setAllProjects(data))
api.get('/users').then(r => setUsers(r.data)).catch(() => {})
api.get('/roles').then(r => setRoles(r.data)).catch(() => {})
api.get('/users').then(r => setUsers(r.data)).catch(() => {})
}, [id])
const handleMulti = (e: React.ChangeEvent<HTMLSelectElement>, field: 'sub_projects' | 'related_projects') => {
const values = Array.from(e.target.selectedOptions).map((o) => o.value)
setEditForm({ ...editForm, [field]: values })
}
const updateProject = async (e: React.FormEvent) => {
e.preventDefault()
const { data } = await api.patch<Project>(`/projects/${id}`, editForm)
@@ -31,75 +52,147 @@ export default function ProjectDetailPage() {
setEditing(false)
}
if (!project) return <div className="loading">...</div>
const addMember = async () => {
if (!newMemberUserId) return
await api.post(`/projects/${id}/members`, { user_id: newMemberUserId, role: newMemberRole })
setShowAddMember(false)
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
}
const removeMember = async (userId: number, role: string) => {
// Prevent removing owner
if (role === 'admin') {
alert('Cannot remove project owner (admin)')
return
}
if (!confirm('Remove this member?')) return
await api.delete(`/projects/${id}/members/${userId}`)
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
}
const addMilestone = async () => {
if (!newMilestoneTitle.trim()) return
await api.post(`/projects/${id}/milestones`, { title: newMilestoneTitle, status: 'open' })
setShowAddMilestone(false)
setNewMilestoneTitle('')
api.get<Milestone[]>(`/projects/${id}/milestones`).then(({ data }) => setMilestones(data)).catch(() => {})
}
const deleteProject = async () => {
const confirmName = prompt(`Type the project name "${project?.name}" to confirm deletion:`)
if (confirmName !== project?.name) {
alert('Project name does not match. Deletion cancelled.')
return
}
await api.delete(`/projects/${id}`)
navigate('/projects')
}
if (!project) return <div className="loading">Loading...</div>
const selectableProjects = allProjects.filter((p) => p.id !== project.id && p.project_code)
return (
<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">
{editing ? (
<form className="inline-form" onSubmit={updateProject}>
<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="描述" />
<button type="submit" className="btn-primary"></button>
<button type="button" className="btn-back" onClick={() => setEditing(false)}></button>
<div style={{ fontWeight: 600 }}>{project.name}</div>
{project.project_code && <span className="badge">{project.project_code}</span>}
<select value={editForm.owner} onChange={(e) => setEditForm({ ...editForm, owner: e.target.value })}>
{users.map((u: any) => <option key={u.id} value={u.username}>{u.username} ({u.full_name})</option>)}
</select>
<input value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} placeholder="Description" />
<input value={editForm.repo} onChange={(e) => setEditForm({ ...editForm, repo: e.target.value })} placeholder="Repository URL" />
<label>Sub-projects
<select multiple value={editForm.sub_projects} onChange={(e) => handleMulti(e, 'sub_projects')}>
{selectableProjects.map((p) => (
<option key={p.id} value={p.project_code || ''}>{p.project_code || p.name}</option>
))}
</select>
</label>
<label>Related projects
<select multiple value={editForm.related_projects} onChange={(e) => handleMulti(e, 'related_projects')}>
{selectableProjects.map((p) => (
<option key={p.id} value={p.project_code || ''}>{p.project_code || p.name}</option>
))}
</select>
</label>
<button type="submit" className="btn-primary">Save</button>
<button type="button" className="btn-back" onClick={() => setEditing(false)}>Cancel</button>
</form>
) : (
<>
<h2>📁 {project.name}</h2>
<p style={{ color: 'var(--text-dim)', marginTop: 4 }}>{project.description || '暂无描述'}</p>
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setEditing(true)}></button>
<h2>📁 {project.name} {project.project_code && <span className="badge">{project.project_code}</span>}</h2>
<p style={{ color: 'var(--text-dim)', marginTop: 4 }}>{project.description || 'No description'}</p>
{project.repo && <p style={{ color: 'var(--text-dim)', marginTop: 4 }}>📦 {project.repo}</p>}
<div className="text-dim">Owner: {project.owner_name || project.owner || "Unknown"}</div>
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setEditing(true)}>Edit</button>
<button className="btn-danger" style={{ marginLeft: 8 }} onClick={deleteProject}>Delete</button>
</>
)}
</div>
<div className="section">
<h3> ({members.length})</h3>
<h3>Members ({members.length}) <button className="btn-sm" onClick={() => setShowAddMember(true)}>+ Add</button></h3>
{members.length > 0 ? (
<div className="member-list">
{members.map((m) => (
<span key={m.id} className="badge">{`用户 #${m.user_id} (${m.role})`}</span>
<span key={m.id} className="badge" style={{marginRight: 8}}>
{`User #${m.user_id} (${m.role})`}
<button onClick={(e) => { e.stopPropagation(); removeMember(m.user_id, m.role) }} style={{marginLeft: 8, background: 'none', border: 'none', color: 'red', cursor: 'pointer'}}>×</button>
</span>
))}
</div>
) : (
<p className="empty"></p>
<p className="empty">No members</p>
)}
</div>
<div className="section">
<h3> ({milestones.length})</h3>
<h3>Milestones ({milestones.length}) <button className="btn-sm" onClick={() => setShowAddMilestone(true)}>+ New</button></h3>
{milestones.map((ms) => (
<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="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>
))}
{milestones.length === 0 && <p className="empty"></p>}
{milestones.length === 0 && <p className="empty">No milestones</p>}
</div>
<div className="section">
<div className="page-header">
<h3> Issues</h3>
<button className="btn-primary" onClick={() => navigate('/issues/new')}>+ </button>
{showAddMember && (
<div className="modal-overlay" onClick={() => setShowAddMember(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h3>Add Member</h3>
<select value={newMemberUserId} onChange={e => setNewMemberUserId(Number(e.target.value))}>
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.full_name})</option>)}
</select>
<select value={newMemberRole} onChange={e => setNewMemberRole(e.target.value)}>
{roles.map(r => <option key={r.id} value={r.name}>{r.name}</option>)}
</select>
<div style={{marginTop: 10}}>
<button className="btn-primary" onClick={addMember}>Add</button>
<button className="btn-back" onClick={() => setShowAddMember(false)}>Cancel</button>
</div>
</div>
</div>
<table>
<thead>
<tr><th>#</th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{issues.map((i) => (
<tr key={i.id} className="clickable" onClick={() => navigate(`/issues/${i.id}`)}>
<td>{i.id}</td>
<td className="issue-title">{i.title}</td>
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showAddMilestone && (
<div className="modal-overlay" onClick={() => setShowAddMilestone(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h3>New Milestone</h3>
<input value={newMilestoneTitle} onChange={e => setNewMilestoneTitle(e.target.value)} placeholder="Milestone title" />
<div style={{marginTop: 10}}>
<button className="btn-primary" onClick={addMilestone}>Create</button>
<button className="btn-back" onClick={() => setShowAddMilestone(false)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '@/services/api'
import type { Project } from '@/types'
@@ -6,8 +6,9 @@ import dayjs from 'dayjs'
export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([])
const [users, setUsers] = useState<any[]>([])
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({ name: '', description: '', owner_id: 1 })
const [form, setForm] = useState({ name: '', description: '', owner_id: 1, repo: '', sub_projects: [] as string[], related_projects: [] as string[] })
const navigate = useNavigate()
const fetchProjects = () => {
@@ -15,11 +16,21 @@ export default function ProjectsPage() {
}
useEffect(() => { fetchProjects() }, [])
useEffect(() => {
api.get('/users').then(({ data }) => setUsers(data)).catch(console.error)
}, [])
const projectOptions = useMemo(() => projects.filter(p => p.project_code), [projects])
const handleMulti = (e: React.ChangeEvent<HTMLSelectElement>, field: 'sub_projects' | 'related_projects') => {
const values = Array.from(e.target.selectedOptions).map((o) => o.value)
setForm({ ...form, [field]: values })
}
const createProject = async (e: React.FormEvent) => {
e.preventDefault()
await api.post('/projects', form)
setForm({ name: '', description: '', owner_id: 1 })
setForm({ name: '', description: '', owner_id: 1, repo: '', sub_projects: [], related_projects: [] })
setShowCreate(false)
fetchProjects()
}
@@ -27,37 +38,61 @@ export default function ProjectsPage() {
return (
<div className="projects-page">
<div className="page-header">
<h2>📁 ({projects.length})</h2>
<h2>📁 Projects ({projects.length})</h2>
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? '取消' : '+ 新建项目'}
{showCreate ? 'Cancel' : '+ New'}
</button>
</div>
{showCreate && (
<form className="inline-form" onSubmit={createProject}>
<input
required placeholder="项目名称" value={form.name}
required placeholder="Project name" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<select
value={form.owner_id}
onChange={(e) => setForm({ ...form, owner_id: Number(e.target.value) })}
style={{width:'100%',padding:'8px',marginBottom:'8px'}}
>
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.full_name})</option>)}
</select>
<input
placeholder="项目描述(可选)" value={form.description}
placeholder="Description (optional)" value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
<button type="submit" className="btn-primary"></button>
<input
placeholder="Repository URL (optional)" value={form.repo}
onChange={(e) => setForm({ ...form, repo: e.target.value })}
/>
<label>Sub-projects (Ctrl+Click to select multiple)</label>
<select multiple value={form.sub_projects} onChange={(e) => handleMulti(e, 'sub_projects')} style={{height:80}}>
{projectOptions.map((p) => (
<option key={p.id} value={p.project_code || ''}>{p.project_code} - {p.name}</option>
))}
</select>
<label>Related Projects (Ctrl+Click to select multiple)</label>
<select multiple value={form.related_projects} onChange={(e) => handleMulti(e, 'related_projects')} style={{height:80}}>
{projectOptions.map((p) => (
<option key={p.id} value={p.project_code || ''}>{p.project_code} - {p.name}</option>
))}
</select>
<button type="submit" className="btn-primary">Create</button>
</form>
)}
<div className="project-grid">
{projects.map((p) => (
<div key={p.id} className="project-card" onClick={() => navigate(`/projects/${p.id}`)}>
<h3>{p.name}</h3>
<p className="project-desc">{p.description || '暂无描述'}</p>
<h3>{p.name}</h3>{p.project_code && <span className="badge" style={{ marginLeft: 8 }}>{p.project_code}</span>}
<p className="project-desc">{p.description || 'No description'}</p>
<div className="project-meta">
<span> {dayjs(p.created_at).format('YYYY-MM-DD')}</span>
<span>👤 {p.owner_name || 'Unknown'}</span>
<span> · Created {dayjs(p.created_at).format('YYYY-MM-DD')}</span>
</div>
</div>
))}
{projects.length === 0 && <p className="empty"></p>}
{projects.length === 0 && <p className="empty">No projects yet. Create one above.</p>}
</div>
</div>
)

View File

@@ -0,0 +1,184 @@
import { useState, useEffect } from 'react'
import api from '@/services/api'
interface Permission {
id: number
name: string
description: string
category: string
}
interface Role {
id: number
name: string
description: string
is_global: boolean
permission_ids: number[]
}
export default function RoleEditorPage() {
const [roles, setRoles] = useState<Role[]>([])
const [permissions, setPermissions] = useState<Permission[]>([])
const [selectedRole, setSelectedRole] = useState<Role | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const [rolesRes, permsRes] = await Promise.all([
api.get('/roles'),
api.get('/roles/permissions')
])
setRoles(rolesRes.data)
setPermissions(permsRes.data)
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
const handlePermissionToggle = (permId: number) => {
if (!selectedRole) return
const newPermIds = selectedRole.permission_ids.includes(permId)
? selectedRole.permission_ids.filter(id => id !== permId)
: [...selectedRole.permission_ids, permId]
setSelectedRole({ ...selectedRole, permission_ids: newPermIds })
}
const handleSave = async () => {
if (!selectedRole) return
setSaving(true)
setMessage('')
try {
await api.post(`/roles/${selectedRole.id}/permissions`, {
permission_ids: selectedRole.permission_ids
})
setMessage('Saved successfully!')
fetchData()
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to save')
} finally {
setSaving(false)
}
}
const groupedPermissions = permissions.reduce((acc, p) => {
if (!acc[p.category]) acc[p.category] = []
acc[p.category].push(p)
return acc
}, {} as Record<string, Permission[]>)
if (loading) return <div className="p-4">Loading...</div>
return (
<div className="role-editor-page" style={{ padding: '20px' }}>
<h2>🔐 Role Editor</h2>
<p style={{ color: '#888', marginBottom: '20px' }}>
Configure permissions for each role. Only admins can edit roles.
</p>
{message && (
<div style={{
padding: '10px',
marginBottom: '20px',
backgroundColor: message.includes('success') ? '#d4edda' : '#f8d7da',
borderRadius: '4px'
}}>
{message}
</div>
)}
<div style={{ display: 'flex', gap: '20px' }}>
{/* Role List */}
<div style={{ width: '250px' }}>
<h3>Roles</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{roles.map(role => (
<div
key={role.id}
onClick={() => setSelectedRole({ ...role })}
style={{
padding: '12px',
border: selectedRole?.id === role.id ? '2px solid #007bff' : '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
backgroundColor: selectedRole?.id === role.id ? '#f0f8ff' : 'white'
}}
>
<strong>{role.name}</strong>
{role.is_global && <span style={{ fontSize: '12px', marginLeft: '8px', color: '#ff6b6b' }}>🌟</span>}
<div style={{ fontSize: '12px', color: '#666' }}>{role.description}</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
{role.permission_ids.length} permissions
</div>
</div>
))}
</div>
</div>
{/* Permission Editor */}
<div style={{ flex: 1 }}>
{selectedRole ? (
<>
<h3>Permissions for: {selectedRole.name}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} style={{
border: '1px solid #eee',
borderRadius: '8px',
padding: '12px'
}}>
<h4 style={{ margin: '0 0 10px 0', textTransform: 'capitalize' }}>
{category}
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '8px' }}>
{perms.map(perm => (
<label key={perm.id} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
backgroundColor: selectedRole.permission_ids.includes(perm.id) ? '#e8f5e9' : 'transparent'
}}>
<input
type="checkbox"
checked={selectedRole.permission_ids.includes(perm.id)}
onChange={() => handlePermissionToggle(perm.id)}
/>
<div>
<div style={{ fontWeight: 500 }}>{perm.name}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{perm.description}</div>
</div>
</label>
))}
</div>
</div>
))}
</div>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary"
style={{ marginTop: '20px' }}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</>
) : (
<div style={{ color: '#888', textAlign: 'center', marginTop: '40px' }}>
Select a role to edit its permissions
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -2,32 +2,28 @@ import { useState } from 'react'
import axios from 'axios'
interface Props {
wizardPort: number
wizardBase: string
onComplete: () => void
}
interface SetupForm {
// Admin
admin_username: string
admin_password: string
admin_email: string
admin_full_name: string
// Database
db_host: string
db_port: number
db_user: string
db_password: string
db_database: string
// Backend
backend_url: string
// Default project
backend_base_url: string
project_name: string
project_description: string
}
const STEPS = ['欢迎', '数据库', '管理员', '项目', '完成']
const STEPS = ['Welcome', 'Database', 'Admin', 'Projects', 'Finish']
export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
const [step, setStep] = useState(0)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
@@ -42,13 +38,13 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
db_user: 'harborforge',
db_password: 'harborforge_pass',
db_database: 'harborforge',
backend_url: '',
backend_base_url: 'http://127.0.0.1:8000',
project_name: 'Default',
project_description: '默认项目',
project_description: 'Default project',
})
const wizardApi = axios.create({
baseURL: `http://127.0.0.1:${wizardPort}`,
baseURL: wizardBase,
timeout: 5000,
})
@@ -63,7 +59,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
setStep(1)
} catch {
setWizardOk(false)
setError(`无法连接 AbstractWizard (127.0.0.1:${wizardPort})。请确认已通过 SSH 隧道映射端口:\nssh -L ${wizardPort}:127.0.0.1:${wizardPort} 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`)
}
}
@@ -72,6 +68,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
setSaving(true)
try {
const config = {
initialized: true,
admin: {
username: form.admin_username,
password: form.admin_password,
@@ -85,7 +82,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
password: form.db_password,
database: form.db_database,
},
backend_url: form.backend_url || undefined,
backend_url: form.backend_base_url || undefined,
default_project: form.project_name
? { name: form.project_name, description: form.project_description }
: undefined,
@@ -95,12 +92,13 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
headers: { 'Content-Type': 'application/json' },
})
// Switch wizard to readonly after setup
await wizardApi.put('/api/v1/mode', { mode: 'readonly' }).catch(() => {})
if (form.backend_base_url) {
localStorage.setItem('HF_BACKEND_BASE_URL', form.backend_base_url)
}
setStep(4)
} catch (err: any) {
setError(`保存配置失败: ${err.message}`)
setError(`Failed to save configuration: ${err.message}`)
} finally {
setSaving(false)
}
@@ -110,7 +108,7 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
<div className="setup-wizard">
<div className="setup-container">
<div className="setup-header">
<h1> HarborForge </h1>
<h1> HarborForge Setup Wizard</h1>
<div className="setup-steps">
{STEPS.map((s, i) => (
<span key={i} className={`setup-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
@@ -125,17 +123,17 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
{/* Step 0: Welcome */}
{step === 0 && (
<div className="setup-step-content">
<h2>使 HarborForge</h2>
<p>Agent/</p>
<h2>Welcome to HarborForge</h2>
<p>Agent/Human collaborative task management platform</p>
<div className="setup-info">
<p> SSH AbstractWizard</p>
<code>ssh -L {wizardPort}:127.0.0.1:{wizardPort} user@your-server</code>
<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>
</div>
<button className="btn-primary" onClick={checkWizard}>
Wizard
Connect to Wizard
</button>
{wizardOk === false && (
<p className="setup-hint"> SSH </p>
<p className="setup-hint">Connection failed. Check the SSH tunnel.</p>
)}
</div>
)}
@@ -143,18 +141,18 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
{/* Step 1: Database */}
{step === 1 && (
<div className="setup-step-content">
<h2></h2>
<p className="text-dim"> MySQL 使 docker-compose MySQL </p>
<h2>Database configuration</h2>
<p className="text-dim">Configure MySQL connection (docker-compose defaults are fine if using the bundled MySQL).</p>
<div className="setup-form">
<label> <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> <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> <input value={form.db_database} onChange={(e) => set('db_database', e.target.value)} /></label>
<label>Host <input value={form.db_host} onChange={(e) => set('db_host', e.target.value)} /></label>
<label>Port <input type="number" value={form.db_port} onChange={(e) => set('db_port', Number(e.target.value))} /></label>
<label>Username <input value={form.db_user} onChange={(e) => set('db_user', e.target.value)} /></label>
<label>Password <input type="password" value={form.db_password} onChange={(e) => set('db_password', e.target.value)} /></label>
<label>Database <input value={form.db_database} onChange={(e) => set('db_database', e.target.value)} /></label>
</div>
<div className="setup-nav">
<button className="btn-back" onClick={() => setStep(0)}></button>
<button className="btn-primary" onClick={() => setStep(2)}></button>
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
<button className="btn-primary" onClick={() => setStep(2)}>Next</button>
</div>
</div>
)}
@@ -162,21 +160,21 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
{/* Step 2: Admin */}
{step === 2 && (
<div className="setup-step-content">
<h2></h2>
<p className="text-dim"></p>
<h2>Admin account</h2>
<p className="text-dim">Create the first admin user</p>
<div className="setup-form">
<label> <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> <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>Username <input value={form.admin_username} onChange={(e) => set('admin_username', e.target.value)} required /></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>Email <input type="email" value={form.admin_email} onChange={(e) => set('admin_email', e.target.value)} placeholder="admin@example.com" /></label>
<label>Full name <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label>
</div>
<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={() => {
if (!form.admin_password) { setError('请设置管理员密码'); return }
if (!form.admin_password) { setError('Please set an admin password'); return }
setError('')
setStep(3)
}}></button>
}}>Next</button>
</div>
</div>
)}
@@ -184,16 +182,17 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
{/* Step 3: Project */}
{step === 3 && (
<div className="setup-step-content">
<h2></h2>
<p className="text-dim"></p>
<h2>Default project (optional)</h2>
<p className="text-dim">Create an initial project or skip</p>
<div className="setup-form">
<label> <input value={form.project_name} onChange={(e) => set('project_name', e.target.value)} placeholder="留空则跳过" /></label>
<label> <input value={form.project_description} onChange={(e) => set('project_description', e.target.value)} /></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>Project name <input value={form.project_name} onChange={(e) => set('project_name', e.target.value)} placeholder="Leave blank to skip" /></label>
<label>ProjectsDescription <input value={form.project_description} onChange={(e) => set('project_description', e.target.value)} /></label>
</div>
<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}>
{saving ? '保存中...' : '完成配置'}
{saving ? 'Saving...' : 'Finish setup'}
</button>
</div>
</div>
@@ -203,15 +202,16 @@ export default function SetupWizardPage({ wizardPort, onComplete }: Props) {
{step === 4 && (
<div className="setup-step-content">
<div className="setup-done">
<h2> </h2>
<p> AbstractWizardWizard </p>
<p></p>
<h2> Setup complete!</h2>
<p>Configuration saved to AbstractWizard.</p>
<div className="setup-info">
<p></p>
<p>: <strong>{form.admin_username}</strong></p>
<p>Restart services on the server:</p>
<code>docker compose restart</code>
<p style={{ marginTop: '1rem' }}>After the backend starts, refresh this page to go to login.</p>
<p>Admin account: <strong>{form.admin_username}</strong></p>
</div>
<button className="btn-primary" onClick={onComplete}>
Refresh to check
</button>
</div>
</div>

View File

@@ -1,10 +1,15 @@
import axios from 'axios'
const getApiBase = () => {
return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000'
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '/api',
baseURL: getApiBase(),
})
api.interceptors.request.use((config) => {
config.baseURL = getApiBase()
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
@@ -17,7 +22,9 @@ api.interceptors.response.use(
(err) => {
if (err.response?.status === 401) {
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'
}
}

View File

@@ -11,8 +11,14 @@ export interface User {
export interface Project {
id: number
name: string
owner: string
description: string | null
owner_id: number
owner_name: string | null
project_code: string | null
repo: string | null
sub_projects: string[] | null
related_projects: string[] | null
created_at: string
}
@@ -27,7 +33,8 @@ export interface Issue {
id: number
title: string
description: string | null
issue_type: 'task' | 'story' | 'test' | 'resolution'
issue_type: 'meeting' | 'support' | 'issue' | 'maintenance' | 'research' | 'review' | 'story' | 'test' | 'resolution' | 'task'
issue_subtype: string | null
status: 'open' | 'in_progress' | 'resolved' | 'closed' | 'blocked'
priority: 'low' | 'medium' | 'high' | 'critical'
project_id: number