feat: add resource management page handler and sync action; rewrite resources template

This commit is contained in:
tian 2026-05-06 12:40:16 +08:00
parent 1ff391f055
commit 4df7af7f05
3 changed files with 194 additions and 22 deletions

View File

@ -32,6 +32,7 @@ type UI struct {
stateRepo *storage.DeviceConfigStateRepo
auditRepo *storage.AuditLogsRepo
dbPath string
resourcesRepo *storage.ResourcesRepo
tpl *template.Template
}
@ -75,6 +76,8 @@ type PageData struct {
TaskDeviceRows []TaskDeviceRow
StandardModels []storage.StandardModelRecord
ModelStatusBoard *service.ModelStatusBoard
StandardResources []storage.StandardResourceRecord
ResourceStatusBoard *service.ResourceStatusBoard
Templates []service.Template
Template *service.Template
AssetTab string
@ -229,6 +232,18 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
return "-"
}
},
"resourceTypeLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "face_gallery":
return "人脸库"
case "dataset":
return "数据集"
case "calibration":
return "标定文件"
default:
return v
}
},
"displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string {
if dev == nil {
return "-"
@ -479,6 +494,13 @@ func (u *UI) SetDBPath(path string) {
u.dbPath = strings.TrimSpace(path)
}
func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) {
if u == nil {
return
}
u.resourcesRepo = repo
}
func tablerIconSVG(name string) string {
icons := map[string]string{
"devices": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="4" width="18" height="12" rx="1"/><path d="M7 20h10"/><path d="M9 16v4"/><path d="M15 16v4"/></svg>`,
@ -627,6 +649,7 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/templates/{name}", u.pageTemplate)
r.Get("/models", u.pageModels)
r.Post("/models/sync", u.actionModelSync)
r.Post("/resources/sync", u.actionResourceSync)
r.Get("/diagnostics", u.pageDiagnostics)
r.Get("/recognition", u.pageRecognition)
r.Get("/logs", u.pageLogs)
@ -1385,7 +1408,75 @@ func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
}
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "resources", PageData{Title: "资源状态", Devices: u.registry.GetDevices()})
u.ensureDevicesLoaded()
data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()}
for _, dev := range data.Devices {
if dev == nil {
continue
}
if dev.Online {
data.OnlineCount++
}
}
board := service.ResourceStatusBoard{}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
resourcesRepo := storage.NewResourcesRepo(store.DB())
if items, err := resourcesRepo.List(); err == nil {
data.StandardResources = items
installed := map[string][]service.InstalledResourceStatus{}
for _, device := range data.Devices {
if device == nil || !device.Online {
continue
}
items, err := service.FetchInstalledResourceStatuses(u.agent, device)
if err == nil {
installed[device.DeviceID] = items
}
}
board = service.BuildResourceStatusBoard(data.StandardResources, data.Devices, installed)
}
_ = store.Close()
}
}
data.ResourceStatusBoard = &board
u.render(w, r, "resources", data)
}
func (u *UI) actionResourceSync(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
action := strings.TrimSpace(r.FormValue("action"))
if action == "" {
action = "resource_sync_all"
}
if action != "resource_sync_one" && action != "resource_sync_all" {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
deviceIDs := make([]string, 0)
for _, id := range r.Form["device_id"] {
id = strings.TrimSpace(id)
if id != "" {
deviceIDs = append(deviceIDs, id)
}
}
if len(deviceIDs) == 0 {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
payload := map[string]any{}
if resourceName := strings.TrimSpace(r.FormValue("resource_name")); resourceName != "" {
payload["resource_name"] = resourceName
}
task, err := u.tasks.CreateTask(action, deviceIDs, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {

View File

@ -1,40 +1,121 @@
{{define "resources"}}
{{$board := .ResourceStatusBoard}}
<div class="card">
<div class="section-title">
<div>
<h2>资源状态</h2>
<div class="muted small">统一维护人脸库与通用资源,设备侧只显示当前版本与同步状态。</div>
<h2 class="title-with-icon">{{icon "assets"}}<span>资源概览</span></h2>
<div class="form-hint">统一维护标准资源,设备侧通过任务同步。当前支持人脸库等资源类型。</div>
</div>
<div class="actions compact">
<form method="post" action="/ui/resources/sync">
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
<input type="hidden" name="action" value="resource_sync_all">
<button class="btn" type="submit">同步全部资源</button>
</form>
</div>
</div>
<div class="model-summary">
<div class="summary-item"><div class="summary-label">人脸库版本</div><div class="summary-value">统一管理</div><div class="summary-hint">由平台集中维护与发布</div></div>
<div class="summary-item"><div class="summary-label">通用资源</div><div class="summary-value">统一管理</div><div class="summary-hint">标定、字典与业务资源统一维护</div></div>
<div class="summary-item"><div class="summary-label">设备资源状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备资源版本覆盖</div></div>
<div class="summary-item"><div class="summary-label">同步方式</div><div class="summary-value">任务下发</div><div class="summary-hint">与设备分配、模型同步一致</div></div>
<div class="stats">
<div class="stat accent-teal">
<div class="k metric-label">{{icon "template"}}<span>标准资源总数</span></div>
<div class="v">{{if $board}}{{$board.Summary.StandardResources}}{{else}}0{{end}}</div>
</div>
<div class="stat accent-green">
<div class="k metric-label">{{icon "devices"}}<span>在线设备数</span></div>
<div class="v">{{.OnlineCount}}</div>
</div>
<div class="stat accent-teal">
<div class="k metric-label">{{icon "assets"}}<span>完整设备数</span></div>
<div class="v">{{if $board}}{{$board.Summary.CompleteDevices}}{{else}}0{{end}}</div>
</div>
<div class="stat accent-amber">
<div class="k metric-label">{{icon "warn"}}<span>缺失设备数</span></div>
<div class="v">{{if $board}}{{$board.Summary.MissingDevices}}{{else}}0{{end}}</div>
</div>
<div class="stat accent-amber">
<div class="k metric-label">{{icon "service"}}<span>不一致设备数</span></div>
<div class="v">{{if $board}}{{$board.Summary.MismatchDevices}}{{else}}0{{end}}</div>
</div>
</div>
</div>
<div class="card">
<h2>设备资源状态</h2>
<div class="table-wrap" style="margin-top:10px">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>标准资源</span></h2>
<div class="form-hint">版本号用于展示,设备一致性以资源哈希校验。</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>节点</th><th>状态</th><th>管理地址</th><th>人脸库版本</th><th>资源状态</th></tr>
<tr><th>资源名</th><th>分类</th><th>版本</th><th>哈希</th><th>大小</th></tr>
</thead>
<tbody>
{{range .Devices}}
{{range .StandardResources}}
<tr>
<td>
<a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
<td class="mono">待上报</td>
<td>{{if .Online}}<span class="pill warn">待同步</span>{{else}}<span class="pill bad">设备离线</span>{{end}}</td>
<td><span class="mono table-key">{{.Name}}</span></td>
<td>{{resourceTypeLabel .ResourceType}}</td>
<td>{{if .Version}}{{.Version}}{{else}}auto{{end}}</td>
<td class="mono">{{shortHash .SHA256}}</td>
<td>{{.SizeBytes}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">暂无设备。请先在“设备”页扫描或手动添加。</td></tr>
<tr><td colspan="5" class="muted">标准资源目录为空,请在 resources/standard_resources/ 下放置资源文件后重启服务。</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>设备资源状态</span></h2>
<div class="form-hint">按标准资源逐项比对设备已安装状态,缺失和不一致会直接标出。</div>
</div>
</div>
<div class="table-wrap">
<table class="models-status-table">
<thead>
<tr>
<th>设备</th>
{{range .StandardResources}}<th class="model-status-col" title="{{.Name}}"><span class="model-status-label">{{resourceTypeLabel .ResourceType}}</span></th>{{end}}
<th class="model-extra-col">非标资源</th>
</tr>
</thead>
<tbody>
{{if and $board (gt (len $board.Rows) 0)}}
{{range $board.Rows}}
<tr>
<td>
<div class="table-key">{{.DeviceName}}</div>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
{{range .Cells}}
<td>
{{if eq .Status "ok"}}<span class="pill ok">一致</span>
{{else if eq .Status "mismatch"}}<span class="pill warn">不一致</span>
{{else}}<span class="pill bad">缺失</span>{{end}}
</td>
{{end}}
<td>
{{if gt .ExtraCount 0}}
<details class="mini-details">
<summary>{{.ExtraCount}} 个 · 更多</summary>
<div class="mini-details-body">
{{range .ExtraResources}}
<div class="mini-details-item mono">{{.Name}}</div>
{{end}}
</div>
</details>
{{else}}
<span class="muted">0</span>
{{end}}
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="99" class="muted">暂无设备资源状态。请先确保设备在线且 agent 实现了 GET /v1/resources/status 端点。</td></tr>
{{end}}
</tbody>
</table>

View File

@ -2045,7 +2045,7 @@ func TestUI_ResourcesPageShowsUnifiedResourceStatus(t *testing.T) {
ui := newTestUI(t)
html := renderPage(t, ui, "/ui/resources")
for _, want := range []string{"资源状态", "人脸库版本", "设备资源状态", "入口识别节点"} {
for _, want := range []string{"资源管理", "资源概览", "标准资源", "设备资源状态"} {
if !strings.Contains(html, want) {
t.Fatalf("expected resources HTML to contain %q", want)
}