webui phase2: add native nodes/cron api endpoints and management UI

This commit is contained in:
DBT
2026-02-25 13:11:12 +00:00
parent b2ac3afcf4
commit deaeac8cba
4 changed files with 113 additions and 10 deletions

View File

@@ -189,6 +189,22 @@ func gatewayCmd() {
registryServer.SetConfigAfterHook(func() {
_ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
})
registryServer.SetCronHandler(func(action, id string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(action)) {
case "", "list":
return cronService.ListJobs(true), nil
case "delete":
return map[string]interface{}{"deleted": cronService.RemoveJob(strings.TrimSpace(id)), "id": strings.TrimSpace(id)}, nil
case "enable":
j := cronService.EnableJob(strings.TrimSpace(id), true)
return map[string]interface{}{"ok": j != nil, "id": strings.TrimSpace(id)}, nil
case "disable":
j := cronService.EnableJob(strings.TrimSpace(id), false)
return map[string]interface{}{"ok": j != nil, "id": strings.TrimSpace(id)}, nil
default:
return nil, fmt.Errorf("unsupported cron action: %s", action)
}
})
if err := registryServer.Start(ctx); err != nil {
fmt.Printf("Error starting node registry server: %v\n", err)
} else {

View File

@@ -21,6 +21,7 @@ type RegistryServer struct {
configPath string
onChat func(ctx context.Context, sessionKey, content string) (string, error)
onConfigAfter func()
onCron func(action, id string) (interface{}, error)
webUIDir string
}
@@ -40,7 +41,10 @@ func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey,
s.onChat = fn
}
func (s *RegistryServer) SetConfigAfterHook(fn func()) { s.onConfigAfter = fn }
func (s *RegistryServer) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
func (s *RegistryServer) SetCronHandler(fn func(action, id string) (interface{}, error)) {
s.onCron = fn
}
func (s *RegistryServer) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
func (s *RegistryServer) Start(ctx context.Context) error {
if s.mgr == nil {
@@ -58,6 +62,8 @@ func (s *RegistryServer) Start(ctx context.Context) error {
mux.HandleFunc("/webui/api/config", s.handleWebUIConfig)
mux.HandleFunc("/webui/api/chat", s.handleWebUIChat)
mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload)
mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes)
mux.HandleFunc("/webui/api/cron", s.handleWebUICron)
s.server = &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
@@ -301,6 +307,60 @@ func (s *RegistryServer) handleWebUIChat(w http.ResponseWriter, r *http.Request)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "reply": resp, "session": session})
}
func (s *RegistryServer) handleWebUINodes(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
list := []NodeInfo{}
if s.mgr != nil {
list = s.mgr.List()
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list})
}
func (s *RegistryServer) handleWebUICron(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s.onCron == nil {
http.Error(w, "cron handler not configured", http.StatusInternalServerError)
return
}
if r.Method == http.MethodGet {
res, err := s.onCron("list", "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "jobs": res})
return
}
if r.Method == http.MethodPost {
var body struct {
Action string `json:"action"`
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
res, err := s.onCron(strings.ToLower(strings.TrimSpace(body.Action)), strings.TrimSpace(body.ID))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": res})
return
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func (s *RegistryServer) checkAuth(r *http.Request) bool {
if s.token == "" {
return true

View File

@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
type ChatItem = { role: 'user' | 'assistant'; text: string }
type Session = { key: string; title: string }
type CronJob = { id: string; name: string; enabled: boolean; schedule?: { kind?: string } }
const defaultSessions: Session[] = [{ key: 'webui:default', title: 'Default' }]
@@ -14,6 +14,7 @@ export function App() {
const [chat, setChat] = useState<Record<string, ChatItem[]>>({ 'webui:default': [] })
const [msg, setMsg] = useState('')
const [nodes, setNodes] = useState<string>('[]')
const [cron, setCron] = useState<CronJob[]>([])
const activeChat = useMemo(() => chat[active] || [], [chat, active])
const q = token ? `?token=${encodeURIComponent(token)}` : ''
@@ -34,17 +35,24 @@ export function App() {
}
async function refreshNodes() {
const payload = {
session: active,
message: '调用nodes工具action=status并输出JSON。',
}
const r = await fetch(`/webui/api/chat${q}`, {
const r = await fetch(`/webui/api/nodes${q}`)
const j = await r.json()
setNodes(JSON.stringify(j.nodes || [], null, 2))
}
async function refreshCron() {
const r = await fetch(`/webui/api/cron${q}`)
const j = await r.json()
setCron(j.jobs || [])
}
async function cronAction(action: 'delete' | 'enable' | 'disable', id: string) {
await fetch(`/webui/api/cron${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify({ action, id }),
})
const t = await r.text()
setNodes(t)
await refreshCron()
}
async function send() {
@@ -84,6 +92,8 @@ export function App() {
useEffect(() => {
loadConfig().catch(() => {})
refreshNodes().catch(() => {})
refreshCron().catch(() => {})
}, [])
return (
@@ -122,6 +132,19 @@ export function App() {
<div className="panel-title">Config</div>
<div className="row"><button onClick={loadConfig}>Load</button><button onClick={saveConfig}>Save+Reload</button></div>
<textarea value={cfgText} onChange={(e) => setCfgText(e.target.value)} />
<div className="panel-title">Cron</div>
<div className="row"><button onClick={refreshCron}>Refresh</button></div>
<div className="cron-list">
{cron.map((j) => (
<div key={j.id} className="cron-item">
<div><strong>{j.name || j.id}</strong><div className="muted">{j.id}</div></div>
<div className="row">
<button onClick={() => cronAction(j.enabled ? 'disable' : 'enable', j.id)}>{j.enabled ? 'Disable' : 'Enable'}</button>
<button onClick={() => cronAction('delete', j.id)}>Delete</button>
</div>
</div>
))}
</div>
<div className="panel-title">Nodes</div>
<div className="row"><button onClick={refreshNodes}>Refresh</button></div>
<pre>{nodes}</pre>

View File

@@ -17,5 +17,9 @@ button{cursor:pointer;border:1px solid #374151;background:#0f172a;color:#fff;pad
.row{display:flex;gap:8px;margin-bottom:8px}
textarea{width:100%;min-height:180px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:8px;padding:8px}
pre{flex:1;overflow:auto;background:#0b1220;border:1px solid #374151;border-radius:8px;padding:8px;white-space:pre-wrap}
.cron-list{max-height:180px;overflow:auto;border:1px solid #374151;border-radius:8px;padding:6px;margin-bottom:8px;background:#0b1220}
.cron-item{display:flex;justify-content:space-between;gap:8px;align-items:center;border-bottom:1px solid #1f2937;padding:6px 0}
.cron-item:last-child{border-bottom:none}
.muted{font-size:12px;color:#9ca3af}
@media (max-width: 1024px){.layout{grid-template-columns:200px 1fr}.right{grid-column:1 / span 2;max-height:42vh}}
@media (max-width: 768px){.layout{grid-template-columns:1fr}.sessions{order:2}.chat{order:1;min-height:50vh}.right{order:3}.topbar input{min-width:160px;width:52%}}