feat(control): 版本机制打通,UI 展示 agent/引擎/control 真实版本

- ui.go: 版本号由硬编码常量改为可注入变量( -X ...web.Version )
- 新增 service.FetchDeviceVersions: 调用 edge /v1/versions 采集
  agent 版本/build_id/git_sha + media 引擎版本 + 设备模型数
- 设备详情页: 新增引擎版本/构建信息/模型数量展示
- 设备列表页: 新增版本列(含 build/git 悬停提示)
- 构建脚本(3处): 注入 control 版本号(git describe)
- 新增模板语法测试(43个模板防回归)
This commit is contained in:
tian 2026-08-01 16:54:10 +08:00
parent a1064961cc
commit c6da0a6c6d
8 changed files with 184 additions and 9 deletions

View File

@ -0,0 +1,78 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"safesight-control/internal/models"
)
// DeviceVersionInfo 汇总设备上 agent /v1/versions 返回的版本信息
// agent 构建注入的版本 + C++ media 引擎版本 + 设备上模型数量)
type DeviceVersionInfo struct {
AgentVersion string `json:"agent_version"`
AgentBuildID string `json:"agent_build_id"`
AgentBuildType string `json:"agent_build_type"`
AgentGitSHA string `json:"agent_git_sha"`
AgentBinary string `json:"agent_binary"`
MediaSupported bool `json:"media_supported"`
MediaVersion string `json:"media_version"`
MediaBinary string `json:"media_binary"`
ModelCount int `json:"model_count"`
}
// FetchDeviceVersions 调用 edge agent 的 /v1/versions 接口,获取
// agent / media 引擎 / 模型清单的版本信息。设备离线或接口不可用时返回 error。
func FetchDeviceVersions(agent *AgentClient, device *models.Device) (*DeviceVersionInfo, error) {
if agent == nil || device == nil || strings.TrimSpace(device.IP) == "" || device.AgentPort <= 0 {
return nil, nil
}
body, status, err := agent.Do("GET", device.IP, device.AgentPort, "/v1/versions", nil)
if err != nil {
return nil, err
}
if status != 200 {
return nil, fmt.Errorf("agent returned status %d", status)
}
var raw struct {
Agent map[string]any `json:"agent"`
MediaServer map[string]any `json:"media_server"`
Models struct {
Count int `json:"count"`
} `json:"models"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
info := &DeviceVersionInfo{}
if v, ok := raw.Agent["version"].(string); ok {
info.AgentVersion = v
}
if v, ok := raw.Agent["build_id"].(string); ok {
info.AgentBuildID = v
}
if v, ok := raw.Agent["build_type"].(string); ok {
info.AgentBuildType = v
}
if v, ok := raw.Agent["git_sha"].(string); ok {
info.AgentGitSHA = v
}
if v, ok := raw.Agent["binary"].(string); ok {
info.AgentBinary = v
}
if v, ok := raw.MediaServer["supported"].(bool); ok {
info.MediaSupported = v
}
if v, ok := raw.MediaServer["version"].(string); ok {
info.MediaVersion = v
}
if v, ok := raw.MediaServer["binary"].(string); ok {
info.MediaBinary = v
}
info.ModelCount = raw.Models.Count
return info, nil
}

View File

@ -0,0 +1,70 @@
package web
import (
"embed"
"fmt"
"testing"
"text/template"
)
//go:embed ui/templates/*.html
var testTemplateFS embed.FS
// 与 ui.go 相同的函数名集合dummy 实现,仅用于语法验证)
var testFuncs = template.FuncMap{
"add": func(a, b int) int { return a + b },
"ago": func(v any) string { return "" },
"alarmChannelSource": func(v any) any { return v },
"auditActionLabel": func(v any) any { return v },
"auditField": func(v any) any { return v },
"auditStatusLabel": func(v any) any { return v },
"displayDeviceName": func(v ...any) string { return "" },
"displayDeviceTechnicalName": func(v ...any) any { return "" },
"div": func(a, b int) int { return a / b },
"formatTime": func(v any) any { return v },
"formatTuneValue": func(v, s float64) string { return "" },
"hasString": func(a []string, b string) bool { return false },
"icon": func(name string) string { return "" },
"inputBindingRef": func(v any) any { return v },
"json": func(v any) any { return v },
"loop": func(n int) []int { return nil },
"modelTypeLabel": func(v any) any { return v },
"mul": func(a, b int) int { return a * b },
"outputBindingValue": func(v ...any) any { return "" },
"rawHTML": func(v any) any { return v },
"resourceTypeLabel": func(v any) any { return v },
"ruleLabel": func(v any) any { return v },
"serviceBindingRef": func(v any) any { return v },
"severityClass": func(v any) any { return v },
"severityLabel": func(v any) any { return v },
"shortHash": func(v any) any { return v },
"shortID": func(v any) any { return v },
"slotTypeLabel": func(v any) any { return v },
"statusClass": func(v any) any { return v },
"statusLabel": func(v any) any { return v },
"sub": func(a, b int) int { return a - b },
"taskActionLabel": func(v any) any { return v },
"taskGroupClass": func(v any) any { return v },
"taskGroupLabel": func(v any) any { return v },
"taskStatusClass": func(v any) any { return v },
"taskStatusLabel": func(v any) any { return v },
}
func TestTemplateSyntaxAll(t *testing.T) {
entries, err := testTemplateFS.ReadDir("ui/templates")
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
name := "ui/templates/" + e.Name()
data, err := testTemplateFS.ReadFile(name)
if err != nil {
t.Fatalf("读取 %s: %v", name, err)
}
tmpl := template.New(e.Name()).Funcs(testFuncs)
if _, err := tmpl.Parse(string(data)); err != nil {
t.Fatalf("模板语法错误 %s: %v", name, err)
}
}
fmt.Printf("全部 %d 个模板语法通过\n", len(entries))
}

View File

@ -56,7 +56,8 @@ const (
deviceAssignmentPreviewDeviceCount = 8
)
const version = "1.0"
// control 端版本号,构建时通过 -ldflags "-X safesight-control/internal/web.Version=<版本>" 注入
var Version = "dev"
type PageData struct {
Title string
@ -134,6 +135,8 @@ type PageData struct {
FaceGalleryQuality map[string]string
DeviceModelStatuses []service.InstalledModelStatus
DeviceResourceStatuses []service.InstalledResourceStatus
DeviceVersion *service.DeviceVersionInfo
DeviceVersionErr string
Templates []service.Template
Template *service.Template
AssetTab string
@ -901,7 +904,7 @@ func (u *UI) Routes() (chi.Router, error) {
}
func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) {
data.Version = version
data.Version = Version
data.Year = time.Now().Year()
if u.cfg != nil {
if data.SystemCompanyName == "" {
@ -2008,6 +2011,11 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
}
// Load device model and resource status vs management standards.
if u.agent != nil {
if info, err := service.FetchDeviceVersions(u.agent, dev); err == nil {
data.DeviceVersion = info
} else {
data.DeviceVersionErr = err.Error()
}
if items, err := service.FetchInstalledModelStatuses(u.agent, dev); err == nil {
data.DeviceModelStatuses = items
}

View File

@ -54,7 +54,14 @@
<div><span>管理地址</span><strong class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</strong></div>
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
<div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div>
<div><span>版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
<div><span>Agent 版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
{{if .DeviceVersion}}
<div><span>引擎版本</span><strong class="mono">{{if .DeviceVersion.MediaSupported}}{{if .DeviceVersion.MediaVersion}}{{.DeviceVersion.MediaVersion}}{{else}}运行中(版本未知){{end}}{{else}}未启用{{end}}</strong></div>
<div><span>构建信息</span><strong class="mono" style="font-size:12px">{{if .DeviceVersion.AgentBuildID}}{{.DeviceVersion.AgentBuildID}}{{else}}-{{end}}{{if .DeviceVersion.AgentGitSHA}} <span class="muted">({{.DeviceVersion.AgentGitSHA}})</span>{{end}}</strong></div>
<div><span>模型</span><strong class="mono">{{.DeviceVersion.ModelCount}} 个</strong></div>
{{else if .DeviceVersionErr}}
<div><span>引擎版本</span><strong class="mono" style="color:var(--danger)">获取失败</strong></div>
{{end}}
<div><span>当前场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>通道名</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}</strong></div>
</div>

View File

@ -61,6 +61,7 @@
<th>设备</th>
<th>状态</th>
<th>配置模板</th>
<th>版本</th>
<th>操作</th>
</tr>
</thead>
@ -112,6 +113,11 @@
{{end}}
</div>
</td>
<td>
<div class="mono small" {{if .Device.BuildID}}title="build {{.Device.BuildID}}{{if .Device.GitSha}} · {{.Device.GitSha}}{{end}}"{{end}}>
{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}
</div>
</td>
<td>
<div class="actions">
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
@ -120,7 +126,7 @@
</tr>
{{else}}
<tr>
<td colspan="5">
<td colspan="6">
<div class="empty-state">
<div class="empty-title">还没有设备</div>
<div class="muted">当前后台还没有发现或录入任何设备。</div>

View File

@ -4,5 +4,6 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o safesightd-linux-arm64 ./cmd/safesightd/
echo "$(ls -lh safesightd-linux-arm64 | awk '{print $5,$NF}')"
CTL_VERSION="$(git describe --tags --always 2>/dev/null || echo "dev")"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X safesight-control/internal/web.Version=${CTL_VERSION}" -o safesightd-linux-arm64 ./cmd/safesightd/
echo "$(ls -lh safesightd-linux-arm64 | awk '{print $5,$NF}') (version ${CTL_VERSION})"

View File

@ -50,7 +50,9 @@ echo "[3/7] 编译项目..."
cd "$PROJECT_DIR"
echo " 编译 Linux AMD64 版本..."
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$PACKAGE_DIR/bin/safesightd" ./cmd/safesightd
CTL_VERSION="$(git describe --tags --always 2>/dev/null || echo "dev")"
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X safesight-control/internal/web.Version=${CTL_VERSION}" -o "$PACKAGE_DIR/bin/safesightd" ./cmd/safesightd
echo " control 版本: ${CTL_VERSION}"
echo " 验证编译结果..."
if [ ! -f "$PACKAGE_DIR/bin/safesightd" ]; then

View File

@ -18,8 +18,11 @@ echo ""
echo "[1/7] 交叉编译..."
cd "$PROJECT_DIR"
mkdir -p "$BUILD_DIR"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$BUILD_DIR/safesightd-linux-arm64" ./cmd/safesightd/ 2>/dev/null && echo " ✓ ARM64" || echo " ✗ ARM64 failed"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$BUILD_DIR/safesightd-linux-amd64" ./cmd/safesightd/ 2>/dev/null && echo " ✓ AMD64" || echo " ✗ AMD64 failed"
CTL_VERSION="$(git describe --tags --always 2>/dev/null || echo "dev")"
LDFLAGS="-s -w -X safesight-control/internal/web.Version=${CTL_VERSION}"
echo " control 版本: ${CTL_VERSION}"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o "$BUILD_DIR/safesightd-linux-arm64" ./cmd/safesightd/ 2>/dev/null && echo " ✓ ARM64" || echo " ✗ ARM64 failed"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o "$BUILD_DIR/safesightd-linux-amd64" ./cmd/safesightd/ 2>/dev/null && echo " ✓ AMD64" || echo " ✗ AMD64 failed"
# ── 部署文件 ──
echo "[2/7] 部署文件..."