Refine backend device operations UI
This commit is contained in:
parent
5f2b327e46
commit
9f4a910594
@ -51,6 +51,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) ListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
h.ensureDevicesLoaded()
|
||||
items := h.registry.GetDevices()
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"items": items,
|
||||
@ -104,15 +105,35 @@ func (h *Handler) GetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) findDevice(id string) (*models.Device, bool) {
|
||||
h.ensureDevicesLoaded()
|
||||
devices := h.registry.GetDevices()
|
||||
for _, d := range devices {
|
||||
if d.DeviceID == id {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
if h.discovery != nil {
|
||||
_, _ = h.discovery.SearchDefault()
|
||||
devices = h.registry.GetDevices()
|
||||
for _, d := range devices {
|
||||
if d.DeviceID == id {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (h *Handler) ensureDevicesLoaded() {
|
||||
if h.registry == nil || h.discovery == nil {
|
||||
return
|
||||
}
|
||||
if len(h.registry.GetDevices()) > 0 {
|
||||
return
|
||||
}
|
||||
_, _ = h.discovery.SearchDefault()
|
||||
}
|
||||
|
||||
func (h *Handler) ProxyAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
@ -25,6 +25,17 @@ func NewDiscoveryService(cfg *config.Config, registry *RegistryService) *Discove
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DiscoveryService) SearchDefault() ([]*models.Device, error) {
|
||||
if s == nil || s.cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
timeoutMs := s.cfg.DiscoveryTimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = 1200
|
||||
}
|
||||
return s.Search(timeoutMs)
|
||||
}
|
||||
|
||||
func (s *DiscoveryService) Search(timeoutMs int) ([]*models.Device, error) {
|
||||
reqID := uuid.NewString()
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
|
||||
@ -41,6 +41,7 @@ type PageData struct {
|
||||
FoundCount int
|
||||
|
||||
Devices []*models.Device
|
||||
DeviceRows []DeviceOverviewRow
|
||||
AttentionDevices []*models.Device
|
||||
Found []*models.Device
|
||||
Device *models.Device
|
||||
@ -72,6 +73,12 @@ type PageData struct {
|
||||
SuccessTaskCount int
|
||||
}
|
||||
|
||||
type DeviceOverviewRow struct {
|
||||
Device *models.Device
|
||||
ConfigStatus *ConfigStatusView
|
||||
ConfigStatusErr string
|
||||
}
|
||||
|
||||
type ConfigStatusView struct {
|
||||
OK bool `json:"ok"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
@ -149,6 +156,9 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
||||
return fmt.Sprintf("%d天前", s/86400)
|
||||
}
|
||||
},
|
||||
"icon": func(name string) template.HTML {
|
||||
return template.HTML(tablerIconSVG(name))
|
||||
},
|
||||
}).ParseFS(uiFS, "ui/templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -165,6 +175,39 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
||||
}, nil
|
||||
}
|
||||
|
||||
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>`,
|
||||
"assets": `<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"/><path d="M4 19l4 -14"/><path d="M16 5l4 14"/><path d="M12 5v14"/><path d="M6 15h12"/></svg>`,
|
||||
"audit": `<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"/><path d="M12 8l0 4l2 2"/><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5"/></svg>`,
|
||||
"system": `<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"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c.996 .608 2.296 .07 2.572 -1.065z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||||
"online": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5"/></svg>`,
|
||||
"detail": `<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"/><path d="M15 12h.01"/><path d="M12 12h.01"/><path d="M9 12h.01"/><path d="M5 12a7 7 0 1 0 14 0a7 7 0 0 0 -14 0"/></svg>`,
|
||||
"control": `<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"/><path d="M7 4v16l13 -8z"/></svg>`,
|
||||
"device": `<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="5" y="3" width="14" height="18" rx="2"/><path d="M11 4h2"/><path d="M12 17v.01"/></svg>`,
|
||||
"status": `<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"/><path d="M3 12h4l3 8l4 -16l3 8h4"/></svg>`,
|
||||
"config": `<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"/><path d="M4 6l16 0"/><path d="M4 12l16 0"/><path d="M4 18l16 0"/><path d="M8 6l0 .01"/><path d="M8 12l0 .01"/><path d="M8 18l0 .01"/></svg>`,
|
||||
"overview": `<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"/><path d="M4 4h6v8h-6z"/><path d="M14 4h6v5h-6z"/><path d="M14 13h6v7h-6z"/><path d="M4 16h6v4h-6z"/></svg>`,
|
||||
"tech": `<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"/><path d="M7 8l-4 4l4 4"/><path d="M17 8l4 4l-4 4"/><path d="M14 4l-4 16"/></svg>`,
|
||||
"preview": `<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"/><path d="M12 5c-7.633 0 -9 7 -9 7s1.367 7 9 7s9 -7 9 -7s-1.367 -7 -9 -7"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/></svg>`,
|
||||
"apply": `<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"/><path d="M7 12l3 3l7 -7"/><path d="M21 12c0 4.97 -4.03 9 -9 9s-9 -4.03 -9 -9s4.03 -9 9 -9"/></svg>`,
|
||||
"service": `<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"/><path d="M4 13h5"/><path d="M12 16v5"/><path d="M16 4l0 5"/><path d="M20 8h-5"/><path d="M4 9h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/><path d="M9 4h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 15h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M9 13h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 4h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/></svg>`,
|
||||
"result": `<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"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
|
||||
"meta": `<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"/><path d="M5 4m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M7 6h10"/><path d="M5 8v9"/><path d="M7 19h10"/><path d="M17 8v9"/></svg>`,
|
||||
"template": `<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"/><path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/></svg>`,
|
||||
"profile": `<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"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M12 3c2.755 0 5.455 .638 7.407 1.758a2 2 0 0 1 1.002 1.737v11.01a2 2 0 0 1 -1.002 1.737c-1.952 1.12 -4.652 1.758 -7.407 1.758s-5.455 -.638 -7.407 -1.758a2 2 0 0 1 -1.002 -1.737v-11.01a2 2 0 0 1 1.002 -1.737c1.952 -1.12 4.652 -1.758 7.407 -1.758z"/></svg>`,
|
||||
"overlay": `<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"/><path d="M7 3.34l10 5.66v10l-10 -5.66z"/><path d="M17 9l4 -2.26l-10 -5.74l-4 2.26z"/><path d="M7 13l-4 -2.26v-6.74l4 -2.26"/></svg>`,
|
||||
"release": `<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"/><path d="M7 7h10v10h-10z"/><path d="M3 7l3 0"/><path d="M18 7l3 0"/><path d="M7 3l0 3"/><path d="M7 18l0 3"/><path d="M17 18l0 3"/><path d="M17 3l0 3"/></svg>`,
|
||||
"discovery": `<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"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M12 3v4"/><path d="M12 17v4"/><circle cx="12" cy="12" r="3"/><path d="M5.636 5.636l2.828 2.828"/><path d="M15.536 15.536l2.828 2.828"/><path d="M5.636 18.364l2.828 -2.828"/><path d="M15.536 8.464l2.828 -2.828"/></svg>`,
|
||||
"shield": `<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"/><path d="M12 3l8 4v5c0 5 -3.5 9.5 -8 11c-4.5 -1.5 -8 -6 -8 -11v-5l8 -4"/></svg>`,
|
||||
"heartbeat": `<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"/><path d="M3 12h4l2 -3l4 6l2 -3h6"/></svg>`,
|
||||
}
|
||||
if svg, ok := icons[name]; ok {
|
||||
return svg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u *UI) Routes() (chi.Router, error) {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@ -185,11 +228,17 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
}))
|
||||
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/dashboard", http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/devices", http.StatusFound)
|
||||
})
|
||||
|
||||
r.Get("/dashboard", u.pageDashboard)
|
||||
r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/devices", http.StatusFound)
|
||||
})
|
||||
r.Get("/devices", u.pageDevices)
|
||||
r.Get("/devices/{id}/control", u.pageDeviceControl)
|
||||
r.Get("/assets", u.pageAssets)
|
||||
r.Get("/audit", u.pageAudit)
|
||||
r.Get("/system", u.pageSystem)
|
||||
r.Get("/device-config", u.pageDeviceConfig)
|
||||
r.Get("/devices-add", u.pageDeviceAdd)
|
||||
r.Post("/devices-add", u.actionDeviceAdd)
|
||||
@ -246,68 +295,79 @@ func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data
|
||||
}
|
||||
|
||||
func (u *UI) findDevice(id string) (*models.Device, bool) {
|
||||
u.ensureDevicesLoaded()
|
||||
devices := u.registry.GetDevices()
|
||||
for _, d := range devices {
|
||||
if d.DeviceID == id {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
if u.discovery != nil {
|
||||
_, _ = u.discovery.SearchDefault()
|
||||
devices = u.registry.GetDevices()
|
||||
for _, d := range devices {
|
||||
if d.DeviceID == id {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (u *UI) ensureDevicesLoaded() {
|
||||
if u.registry == nil || u.discovery == nil {
|
||||
return
|
||||
}
|
||||
if len(u.registry.GetDevices()) > 0 {
|
||||
return
|
||||
}
|
||||
_, _ = u.discovery.SearchDefault()
|
||||
}
|
||||
|
||||
func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/devices", http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
|
||||
u.ensureDevicesLoaded()
|
||||
devices := u.registry.GetDevices()
|
||||
tasks := u.tasks.ListTasks()
|
||||
rows := make([]DeviceOverviewRow, 0, len(devices))
|
||||
for _, dev := range devices {
|
||||
row := DeviceOverviewRow{Device: dev}
|
||||
status, _, err := u.loadConfigStatus(dev)
|
||||
row.ConfigStatus = status
|
||||
if err != nil {
|
||||
row.ConfigStatusErr = err.Error()
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
online := 0
|
||||
attentionDevices := make([]*models.Device, 0)
|
||||
attention := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
online++
|
||||
} else {
|
||||
attentionDevices = append(attentionDevices, d)
|
||||
attention++
|
||||
}
|
||||
}
|
||||
runningTasks := 0
|
||||
failedTasks := 0
|
||||
successTasks := 0
|
||||
for _, t := range tasks {
|
||||
switch t.Status {
|
||||
case models.TaskRunning:
|
||||
runningTasks++
|
||||
case models.TaskFailed:
|
||||
failedTasks++
|
||||
case models.TaskSuccess:
|
||||
successTasks++
|
||||
}
|
||||
}
|
||||
u.render(w, r, "dashboard", PageData{
|
||||
Title: "总览",
|
||||
Devices: devices,
|
||||
AttentionDevices: attentionDevices,
|
||||
Tasks: tasks,
|
||||
DeviceCount: len(devices),
|
||||
OnlineCount: online,
|
||||
OfflineCount: len(devices) - online,
|
||||
RunningTaskCount: runningTasks,
|
||||
FailedTaskCount: failedTasks,
|
||||
SuccessTaskCount: successTasks,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
|
||||
devices := u.registry.GetDevices()
|
||||
online := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
online++
|
||||
if u.tasks != nil {
|
||||
for _, t := range u.tasks.ListTasks() {
|
||||
if t.Status == models.TaskFailed {
|
||||
failedTasks++
|
||||
}
|
||||
}
|
||||
}
|
||||
u.render(w, r, "devices", PageData{
|
||||
Title: "设备列表",
|
||||
Devices: devices,
|
||||
DeviceCount: len(devices),
|
||||
OnlineCount: online,
|
||||
OfflineCount: len(devices) - online,
|
||||
Title: "设备",
|
||||
Devices: devices,
|
||||
DeviceRows: rows,
|
||||
DeviceCount: len(devices),
|
||||
OnlineCount: online,
|
||||
OfflineCount: len(devices) - online,
|
||||
RunningTaskCount: 0,
|
||||
FailedTaskCount: failedTasks,
|
||||
FoundCount: attention,
|
||||
})
|
||||
}
|
||||
|
||||
@ -316,7 +376,7 @@ func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "device_config", PageData{Title: "配置管理", Devices: u.registry.GetDevices()})
|
||||
http.Redirect(w, r, "/ui/assets", http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@ -366,7 +426,7 @@ func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) {
|
||||
online++
|
||||
}
|
||||
}
|
||||
data := PageData{Title: "设备列表", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online}
|
||||
data := PageData{Title: "设备", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
@ -385,7 +445,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
online++
|
||||
}
|
||||
}
|
||||
u.render(w, r, "devices", PageData{Title: "设备列表", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "请先选择设备"})
|
||||
u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "请先选择设备"})
|
||||
return
|
||||
}
|
||||
|
||||
@ -401,7 +461,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
online++
|
||||
}
|
||||
}
|
||||
u.render(w, r, "devices", PageData{Title: "设备列表", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "不支持的操作: " + action})
|
||||
u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "不支持的操作: " + action})
|
||||
return
|
||||
}
|
||||
|
||||
@ -427,7 +487,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
online++
|
||||
}
|
||||
}
|
||||
u.render(w, r, "devices", PageData{Title: "设备列表", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: err.Error()})
|
||||
u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@ -442,13 +502,23 @@ func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
status, raw, statusErr := u.loadConfigStatus(dev)
|
||||
data := PageData{Title: "节点详情", Device: dev, ConfigStatus: status, ConfigStatusText: raw}
|
||||
data := PageData{Title: "设备详情", Device: dev, ConfigStatus: status, ConfigStatusText: raw}
|
||||
if statusErr != nil {
|
||||
data.ConfigStatusErr = statusErr.Error()
|
||||
}
|
||||
u.render(w, r, "device", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
dev, ok := u.findDevice(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
u.render(w, r, "device_control", u.deviceControlPageData(dev))
|
||||
}
|
||||
|
||||
func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
dev, ok := u.findDevice(id)
|
||||
@ -485,7 +555,19 @@ func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil)
|
||||
msg := fmt.Sprintf("%s %s -> %d", method, path, code)
|
||||
data := PageData{Title: "节点详情", Device: dev, Message: msg, RawText: string(body)}
|
||||
returnTo := strings.TrimSpace(r.FormValue("return_to"))
|
||||
if returnTo == "control" {
|
||||
data := u.deviceControlPageData(dev)
|
||||
data.Message = msg
|
||||
data.RawText = string(body)
|
||||
data.ResultTitle = "执行结果摘要"
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
u.render(w, r, "device_control", data)
|
||||
return
|
||||
}
|
||||
data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
@ -542,7 +624,7 @@ func (u *UI) actionDeviceConfigApply(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw))
|
||||
data := PageData{Title: "节点详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw}
|
||||
data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
@ -574,7 +656,7 @@ func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
path := fmt.Sprintf("/v1/models/%s", name)
|
||||
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size)
|
||||
out := PageData{Title: "节点详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
|
||||
out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
|
||||
if derr != nil {
|
||||
out.Error = derr.Error()
|
||||
}
|
||||
@ -607,7 +689,7 @@ func (u *UI) actionDeviceMediaServerConfigUpload(w http.ResponseWriter, r *http.
|
||||
|
||||
path := "/v1/media-server/configs/" + url.PathEscape(name)
|
||||
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size)
|
||||
data := PageData{Title: "节点详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
|
||||
data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
|
||||
if derr != nil {
|
||||
data.Error = derr.Error()
|
||||
}
|
||||
@ -666,7 +748,7 @@ func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *
|
||||
}
|
||||
}
|
||||
|
||||
data := PageData{Title: "节点详情", Device: dev, Message: "批量上传完成", RawText: sb.String()}
|
||||
data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()}
|
||||
if errCount > 0 {
|
||||
data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files))
|
||||
}
|
||||
@ -755,20 +837,36 @@ func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := u.templates.ListTemplates()
|
||||
data := PageData{Title: "识别配置", Devices: u.registry.GetDevices(), Templates: list}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
u.render(w, r, "recognition", data)
|
||||
http.Redirect(w, r, "/ui/assets", http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "logs", PageData{Title: "日志分析", Devices: u.registry.GetDevices()})
|
||||
http.Redirect(w, r, "/ui/audit", http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "api", PageData{Title: "高级调试"})
|
||||
http.Redirect(w, r, "/ui/system", http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := u.listTemplatesSafe()
|
||||
data := PageData{Title: "配置资产", Templates: list}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
u.render(w, r, "assets", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
|
||||
tasks := []models.Task(nil)
|
||||
if u.tasks != nil {
|
||||
tasks = u.tasks.ListTasks()
|
||||
}
|
||||
u.render(w, r, "audit", PageData{Title: "操作审计", Tasks: tasks})
|
||||
}
|
||||
|
||||
func (u *UI) pageSystem(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "system", PageData{Title: "系统", Devices: u.registry.GetDevices()})
|
||||
}
|
||||
|
||||
func urlQueryEscape(s string) string {
|
||||
@ -805,11 +903,15 @@ func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, er
|
||||
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil)
|
||||
raw := fmt.Sprintf("GET /v1/config/status -> %d\n%s", code, prettyJSON(body))
|
||||
if err != nil {
|
||||
dev.Online = false
|
||||
return nil, raw, err
|
||||
}
|
||||
if code < 200 || code >= 300 {
|
||||
dev.Online = false
|
||||
return nil, raw, fmt.Errorf("GET /v1/config/status -> %d", code)
|
||||
}
|
||||
dev.Online = true
|
||||
dev.LastSeenMs = time.Now().UnixMilli()
|
||||
var status ConfigStatusView
|
||||
if err := json.Unmarshal(body, &status); err != nil {
|
||||
return nil, raw, err
|
||||
@ -945,7 +1047,13 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := u.configPreviewPageData(dev)
|
||||
returnTo := strings.TrimSpace(r.FormValue("return_to"))
|
||||
var data PageData
|
||||
if returnTo == "control" {
|
||||
data = u.deviceControlPageData(dev)
|
||||
} else {
|
||||
data = u.configPreviewPageData(dev)
|
||||
}
|
||||
raw := strings.TrimSpace(r.FormValue("json"))
|
||||
if raw != "" {
|
||||
data.ConfigPreview = previewResultFromJSON(raw)
|
||||
@ -954,7 +1062,11 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req
|
||||
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`))
|
||||
data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code)
|
||||
data.RawText = prettyJSON(body)
|
||||
data.ResultTitle = "应用候选配置结果"
|
||||
if returnTo == "control" {
|
||||
data.ResultTitle = "应用候选配置结果"
|
||||
} else {
|
||||
data.ResultTitle = "应用候选配置结果"
|
||||
}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
} else {
|
||||
@ -966,6 +1078,10 @@ func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Req
|
||||
data.ConfigStatusErr = ""
|
||||
}
|
||||
}
|
||||
if returnTo == "control" {
|
||||
u.render(w, r, "device_control", data)
|
||||
return
|
||||
}
|
||||
u.render(w, r, "config_preview", data)
|
||||
}
|
||||
|
||||
@ -991,6 +1107,27 @@ func (u *UI) configPreviewPageData(dev *models.Device) PageData {
|
||||
return data
|
||||
}
|
||||
|
||||
func (u *UI) deviceControlPageData(dev *models.Device) PageData {
|
||||
data := PageData{
|
||||
Title: "设备控制",
|
||||
Device: dev,
|
||||
}
|
||||
status, raw, statusErr := u.loadConfigStatus(dev)
|
||||
data.ConfigStatus = status
|
||||
data.ConfigStatusText = raw
|
||||
if statusErr != nil {
|
||||
data.ConfigStatusErr = statusErr.Error()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (u *UI) listTemplatesSafe() ([]service.Template, error) {
|
||||
if u.templates == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return u.templates.ListTemplates()
|
||||
}
|
||||
|
||||
func cleanFormList(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
|
||||
@ -1,128 +1,161 @@
|
||||
*{box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#f4f7fb;
|
||||
--bg:#f5f7fa;
|
||||
--surface:#ffffff;
|
||||
--surface2:#f8fafc;
|
||||
--sidebar:#17202b;
|
||||
--sidebar2:#202b38;
|
||||
--border:#d9e2ec;
|
||||
--border2:#b8c7d9;
|
||||
--text:#17202b;
|
||||
--muted:#627386;
|
||||
--link:#087ea4;
|
||||
--primary:#0f8b8d;
|
||||
--primary2:#0b7285;
|
||||
--blue:#2563eb;
|
||||
--green:#12805c;
|
||||
--yellow:#a16207;
|
||||
--red:#b42318;
|
||||
--shadow:0 8px 22px rgba(15,23,42,.08);
|
||||
--surface-soft:#fafbfc;
|
||||
--sidebar:#1f2937;
|
||||
--sidebar-hover:#2b3646;
|
||||
--border:#e5e7eb;
|
||||
--border-strong:#d1d5db;
|
||||
--text:#111827;
|
||||
--muted:#6b7280;
|
||||
--primary:#2563eb;
|
||||
--teal:#0f766e;
|
||||
--green:#15803d;
|
||||
--amber:#b45309;
|
||||
--red:#b91c1c;
|
||||
--shadow:0 10px 30px rgba(15,23,42,.06);
|
||||
}
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:var(--bg);color:var(--text)}
|
||||
a{color:var(--link);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:13px/1.5 var(--tblr-font-sans-serif,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif)}
|
||||
a{color:var(--primary);text-decoration:none}
|
||||
a:hover{text-decoration:none}
|
||||
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
|
||||
.app-shell{display:grid;grid-template-columns:256px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar{background:var(--sidebar);color:#e8eef5;display:flex;flex-direction:column;padding:18px 14px;position:sticky;top:0;height:100vh}
|
||||
.brand-block{display:flex;gap:12px;align-items:center;padding:6px 8px 20px}
|
||||
.brand-mark{width:38px;height:38px;border-radius:8px;background:#0f8b8d;color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px}
|
||||
.brand-title{font-weight:600;line-height:1.25}
|
||||
.brand-subtitle{font-size:11px;color:#9fb0c3;margin-top:2px}
|
||||
.app-shell{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar{background:var(--sidebar);color:#f3f4f6;display:flex;flex-direction:column;padding:18px 14px;position:sticky;top:0;height:100vh}
|
||||
.brand-block{display:flex;gap:12px;align-items:center;padding:4px 8px 20px}
|
||||
.brand-mark{width:40px;height:40px;border-radius:8px;background:#111827;border:1px solid rgba(255,255,255,.08);display:grid;place-items:center;font-size:13px;font-weight:700}
|
||||
.brand-title{font-size:14px;font-weight:600}
|
||||
.brand-subtitle{font-size:11px;color:#9ca3af;margin-top:2px}
|
||||
.side-nav{display:flex;flex-direction:column;gap:4px}
|
||||
.nav-section{font-size:11px;color:#8ea2b7;padding:15px 10px 5px}
|
||||
.side-nav a{color:#dce6f1;display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;font-size:13px;font-weight:500}
|
||||
.side-nav a:hover{background:var(--sidebar2);text-decoration:none}
|
||||
.nav-icon{width:28px;height:24px;border:1px solid rgba(255,255,255,.14);border-radius:6px;display:grid;place-items:center;font-size:10px;color:#a7f3d0;background:rgba(15,139,141,.18)}
|
||||
.sidebar-footer{margin-top:auto;border-top:1px solid rgba(255,255,255,.1);padding:12px 8px 0;display:flex;gap:12px;flex-wrap:wrap}
|
||||
.sidebar-footer a{color:#b7c7d8;font-size:12px}
|
||||
.nav-section{padding:14px 10px 6px;font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.06em}
|
||||
.side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;color:#e5e7eb;font-size:12px;font-weight:500}
|
||||
.side-nav a:hover{background:var(--sidebar-hover)}
|
||||
.nav-icon{width:28px;height:24px;border-radius:6px;border:1px solid rgba(255,255,255,.1);display:grid;place-items:center;font-size:10px;color:#d1d5db}
|
||||
.nav-icon .ui-icon{width:14px;height:14px;stroke-width:1.75}
|
||||
.sidebar-footer{margin-top:auto;padding:14px 8px 0;border-top:1px solid rgba(255,255,255,.08)}
|
||||
.sidebar-note{font-size:12px;color:#9ca3af;line-height:1.5}
|
||||
|
||||
.workspace{min-width:0}
|
||||
.topbar{height:82px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:10}
|
||||
.topbar h1{margin:3px 0 0;font-size:18px;letter-spacing:0;font-weight:600}
|
||||
.crumb{font-size:11px;color:var(--muted)}
|
||||
.topbar-actions{display:flex;gap:8px;align-items:center}
|
||||
main{padding:22px 28px 40px;max-width:1440px}
|
||||
.topbar{height:72px;background:rgba(255,255,255,.92);backdrop-filter:blur(10px);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:10}
|
||||
.topbar h1{margin:4px 0 0;font-size:18px;font-weight:600}
|
||||
.crumb,.eyebrow{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
|
||||
.topbar-actions{display:flex;gap:8px}
|
||||
main{padding:24px 28px 36px;max-width:1440px}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px 18px;margin:16px 0;box-shadow:var(--shadow)}
|
||||
.card h2{margin:0 0 10px;font-size:17px}
|
||||
.card h3{margin:0 0 10px;font-size:15px}
|
||||
.section-title{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}
|
||||
.row{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.row>*{flex:1 1 260px}
|
||||
.hero-band{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:18px;padding:8px 2px}
|
||||
.hero-band h2{margin:4px 0 6px;font-size:21px;font-weight:600;line-height:1.25}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:18px 20px;margin:16px 0;box-shadow:var(--shadow)}
|
||||
.card h2{margin:0 0 8px;font-size:16px;font-weight:600}
|
||||
.card h3{margin:0 0 6px;font-size:14px;font-weight:600}
|
||||
.section-title{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:12px}
|
||||
.title-with-icon{display:flex;align-items:center;gap:8px}
|
||||
.title-with-icon .ui-icon{width:16px;height:16px;color:#475569}
|
||||
.muted{color:var(--muted)}
|
||||
.small{font-size:12px}
|
||||
|
||||
.error{background:#fff1f0;border:1px solid #ffccc7;color:#a8071a;padding:11px 13px;border-radius:8px;margin:12px 0;white-space:pre-wrap}
|
||||
.msg{background:#ecfdf3;border:1px solid #abefc6;color:#067647;padding:11px 13px;border-radius:8px;margin:12px 0;white-space:pre-wrap}
|
||||
.btn,button,input,select,textarea{font:inherit}
|
||||
.btn,button{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px 11px;border-radius:8px;border:1px solid var(--border-strong);background:#fff;color:var(--text);cursor:pointer;font-size:12px;font-weight:500}
|
||||
.btn.primary,button[type=submit]{background:var(--primary);border-color:var(--primary);color:#fff}
|
||||
.btn.ghost{background:#fff;color:#374151}
|
||||
button:disabled,.btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
input,select,textarea{width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--border-strong);background:#fff;color:var(--text);font-size:12px}
|
||||
textarea{min-height:140px;line-height:1.55}
|
||||
|
||||
.table-wrap{overflow:auto;border-radius:8px;border:1px solid var(--border);background:var(--surface)}
|
||||
table{width:100%;border-collapse:collapse;min-width:820px}
|
||||
th,td{border-bottom:1px solid var(--border);padding:11px 10px;text-align:left;vertical-align:top}
|
||||
th{color:#40546a;font-size:13px;font-weight:600;background:var(--surface2);position:sticky;top:0}
|
||||
tbody tr:hover{background:#f2f7fb}
|
||||
|
||||
input,select,textarea,button,.btn{background:#fff;color:var(--text);border:1px solid var(--border2);border-radius:7px;padding:8px 10px;outline:none;font:inherit;font-size:13px}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(15,139,141,.12)}
|
||||
textarea{width:100%;min-height:160px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;line-height:1.45}
|
||||
button,.btn{cursor:pointer;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||
button:hover,.btn:hover{border-color:var(--primary);text-decoration:none}
|
||||
button[type=submit],.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
|
||||
.btn.ghost{background:#fff;color:var(--primary2)}
|
||||
|
||||
pre{background:#0f1720;color:#d7e2ee;border:1px solid #263445;border-radius:8px;padding:12px;overflow:auto;white-space:pre-wrap}
|
||||
|
||||
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.compact-form{gap:6px;margin-top:8px}
|
||||
.compact-form>*{flex:0 1 auto;max-width:190px}
|
||||
.compact-form input[type=file]{max-width:220px}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:999px;border:1px solid var(--border);background:#eef4f8;color:#40546a;font-size:12px;font-weight:700;white-space:nowrap}
|
||||
.pill.ok{background:#ecfdf3;border-color:#abefc6;color:var(--green)}
|
||||
.pill.bad{background:#f2f4f7;border-color:#d0d5dd;color:#475467}
|
||||
.pill.warn{background:#fffaeb;border-color:#fedf89;color:var(--yellow)}
|
||||
.pill.run{background:#eff6ff;border-color:#bfdbfe;color:var(--blue)}
|
||||
.msg,.error{padding:12px 14px;border-radius:8px;margin:12px 0;white-space:pre-wrap}
|
||||
.msg{background:#ecfdf5;border:1px solid #bbf7d0;color:#166534}
|
||||
.error{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
|
||||
|
||||
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
|
||||
.stat{padding:14px;border-radius:8px;border:1px solid var(--border);background:linear-gradient(180deg,#fff,#f8fafc)}
|
||||
.stat{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface);box-shadow:var(--shadow)}
|
||||
.stat .k{font-size:12px;color:var(--muted)}
|
||||
.stat .v{font-size:24px;font-weight:800;margin-top:7px;color:#102331}
|
||||
.stat .hint{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.model-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:12px}
|
||||
.summary-item{border:1px solid var(--border);border-radius:8px;background:#fbfcfe;padding:11px 12px}
|
||||
.summary-label{font-size:12px;color:var(--muted)}
|
||||
.summary-value{font-size:15px;font-weight:600;color:#17202b;margin-top:5px}
|
||||
.summary-hint{font-size:12px;color:var(--muted);margin-top:5px}
|
||||
.subnav{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0}
|
||||
.subnav a{padding:7px 10px;border:1px solid var(--border);border-radius:7px;background:#fff;color:#40546a;font-size:13px;font-weight:600}
|
||||
.subnav a:hover{border-color:var(--primary);text-decoration:none}
|
||||
.advanced{border-style:dashed;background:#fbfcfe}
|
||||
.confirm-panel{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-top:12px;padding:12px;border:1px solid #fedf89;background:#fffaeb;border-radius:8px}
|
||||
.confirm-panel[hidden]{display:none}
|
||||
.confirm-panel h3{margin:0 0 6px;font-size:14px}
|
||||
.confirm-list{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
|
||||
.drawer{position:fixed;inset:0;z-index:30;pointer-events:none;background:rgba(15,23,42,0);transition:background .18s ease}
|
||||
.drawer.open{pointer-events:auto;background:rgba(15,23,42,.26)}
|
||||
.drawer-panel{position:absolute;right:0;top:0;height:100%;width:min(420px,92vw);background:#fff;border-left:1px solid var(--border);box-shadow:-14px 0 30px rgba(15,23,42,.16);padding:18px;transform:translateX(100%);transition:transform .18s ease;overflow:auto}
|
||||
.drawer.open .drawer-panel{transform:translateX(0)}
|
||||
.drawer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
.drawer-grid>div{border:1px solid var(--border);border-radius:8px;padding:10px;background:#fbfcfe}
|
||||
.wizard-steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||||
.wizard-step{background:#fff;color:#40546a;border-color:var(--border)}
|
||||
.wizard-step.active{background:var(--primary);border-color:var(--primary);color:#fff}
|
||||
.wizard-panel{display:none;margin-top:14px;border:1px solid var(--border);border-radius:8px;background:#fbfcfe;padding:12px}
|
||||
.wizard-panel.active{display:block}
|
||||
.wizard-panel h2{font-size:15px}
|
||||
.metric-label{display:flex;align-items:center;gap:6px}
|
||||
.metric-label .ui-icon{width:14px;height:14px}
|
||||
.stat .v{margin-top:8px;font-size:22px;font-weight:700;line-height:1}
|
||||
.stat .hint{margin-top:6px;font-size:12px;color:var(--muted)}
|
||||
.accent-teal .v{color:var(--teal)}
|
||||
.accent-green .v{color:var(--green)}
|
||||
.accent-slate .v{color:#475569}
|
||||
.accent-amber .v{color:var(--amber)}
|
||||
|
||||
@media (max-width:980px){
|
||||
.table-tools{display:flex;gap:8px;align-items:center}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--border);border-radius:8px;background:#fff}
|
||||
table{width:100%;border-collapse:collapse;min-width:900px}
|
||||
th,td{padding:13px 12px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
|
||||
th{background:var(--surface-soft);font-size:12px;font-weight:600;color:#4b5563}
|
||||
tbody tr:hover{background:#f9fafb}
|
||||
|
||||
.device-cell{display:flex;align-items:center;gap:12px;min-width:220px}
|
||||
.device-avatar{width:34px;height:34px;border-radius:8px;background:#e5eefc;color:#1d4ed8;display:grid;place-items:center}
|
||||
.device-avatar .ui-icon{width:18px;height:18px}
|
||||
.device-name{font-size:13px;font-weight:600}
|
||||
.device-meta-line{display:flex;flex-wrap:wrap;gap:8px;margin-top:3px;font-size:11px;color:var(--muted)}
|
||||
.status-dot{display:inline-block;width:8px;height:8px;border-radius:999px;margin-right:6px;vertical-align:middle}
|
||||
.status-dot.ok{background:var(--green)}
|
||||
.status-dot.bad{background:var(--red)}
|
||||
.status-text{font-size:12px;font-weight:500}
|
||||
.config-inline{display:flex;flex-direction:column;gap:4px}
|
||||
.device-id-inline{margin-top:6px}
|
||||
.state-stack{display:flex;flex-direction:column;gap:6px}
|
||||
.state-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
|
||||
|
||||
.pill{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;border:1px solid var(--border);background:#f3f4f6;color:#374151;font-size:11px;font-weight:600}
|
||||
.pill.ok{background:#ecfdf5;border-color:#bbf7d0;color:#166534}
|
||||
.pill.bad{background:#fef2f2;border-color:#fecaca;color:#991b1b}
|
||||
.pill.warn{background:#fffbeb;border-color:#fde68a;color:#92400e}
|
||||
|
||||
.actions{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.btn .ui-icon{width:14px;height:14px}
|
||||
.stack{flex-direction:column;align-items:flex-start}
|
||||
.device-context-head{display:flex;align-items:center;gap:12px}
|
||||
.device-context-icon{width:34px;height:34px;border-radius:8px;background:#e5eefc;color:#1d4ed8;display:grid;place-items:center}
|
||||
.device-context-icon .ui-icon{width:18px;height:18px}
|
||||
|
||||
.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||
.quad-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||
.control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||
.panel-block{border:1px solid var(--border);border-radius:8px;background:var(--surface-soft);padding:16px}
|
||||
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
|
||||
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
|
||||
.field-grid label span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
|
||||
.field-grid .full{grid-column:1/-1}
|
||||
|
||||
.info-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
.info-list>div{padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft)}
|
||||
.info-list span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)}
|
||||
.info-list strong{display:block;font-size:13px;font-weight:600;line-height:1.45}
|
||||
.compact-list{grid-template-columns:1fr}
|
||||
|
||||
.summary-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
|
||||
.control-summary{margin-bottom:16px}
|
||||
.summary-chip{padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft)}
|
||||
.summary-chip-label{font-size:12px;color:var(--muted)}
|
||||
.summary-chip-value{margin-top:6px;font-size:13px;font-weight:600}
|
||||
|
||||
.asset-stat{margin-top:12px;font-size:20px;font-weight:700;color:#111827}
|
||||
.asset-list{margin-top:14px;border-top:1px solid var(--border)}
|
||||
.asset-row{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)}
|
||||
|
||||
.empty-state{padding:18px;border:1px dashed var(--border-strong);border-radius:8px;background:var(--surface-soft)}
|
||||
.empty-state.compact{padding:14px}
|
||||
.empty-title{font-size:13px;font-weight:600;margin-bottom:6px}
|
||||
|
||||
.collapsible summary,.collapsible-inline summary{cursor:pointer;font-weight:600}
|
||||
pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;background:#111827;color:#e5e7eb;overflow:auto;white-space:pre-wrap}
|
||||
|
||||
.row{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.row>*{flex:1 1 220px}
|
||||
.subnav{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.subnav a{display:inline-flex;align-items:center;padding:7px 10px;border:1px solid var(--border);border-radius:999px;background:#fff;color:#374151;font-size:12px;font-weight:500}
|
||||
.ui-icon{display:block;flex:0 0 auto}
|
||||
|
||||
@media (max-width:1024px){
|
||||
.app-shell{grid-template-columns:1fr}
|
||||
.sidebar{position:relative;height:auto}
|
||||
.side-nav{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.nav-section{grid-column:1/-1}
|
||||
.topbar{position:relative;height:auto;padding:16px;align-items:flex-start;gap:12px;flex-direction:column}
|
||||
main{padding:16px}
|
||||
.stats{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.model-summary{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.wizard-steps{grid-template-columns:1fr}
|
||||
.confirm-panel{flex-direction:column}
|
||||
table{min-width:720px}
|
||||
.topbar{position:relative;height:auto;padding:18px;flex-direction:column;align-items:flex-start;gap:12px}
|
||||
main{padding:18px}
|
||||
.stats,.detail-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr}
|
||||
.hero-band{flex-direction:column;align-items:flex-start}
|
||||
}
|
||||
|
||||
9
internal/web/ui/assets/vendor/tabler.min.css
vendored
Normal file
9
internal/web/ui/assets/vendor/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/web/ui/assets/vendor/tabler.min.js
vendored
Normal file
13
internal/web/ui/assets/vendor/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
57
internal/web/ui/templates/assets.html
Normal file
57
internal/web/ui/templates/assets.html
Normal file
@ -0,0 +1,57 @@
|
||||
{{define "assets"}}
|
||||
<section class="hero-band">
|
||||
<div>
|
||||
<div class="eyebrow">配置资产</div>
|
||||
<h2>统一管理模板、环境参数、覆盖项和发布记录</h2>
|
||||
<div class="muted">这类信息只在这里集中维护,不与设备运行状态混在一起。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="quad-grid">
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>模板</span></h2>
|
||||
<div class="muted small">定义主结构,是配置生成的核心来源。</div>
|
||||
<div class="asset-stat">{{len .Templates}}</div>
|
||||
<div class="asset-list">
|
||||
{{range .Templates}}
|
||||
<div class="asset-row"><span class="mono">{{.Name}}</span><span class="muted small">已纳入模板库</span></div>
|
||||
{{else}}
|
||||
<div class="muted">当前没有模板。</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>环境参数</span></h2>
|
||||
<div class="muted small">设备或站点差异集中管理,不再维护多份完整 JSON。</div>
|
||||
<div class="asset-stat">Profiles</div>
|
||||
<div class="asset-list">
|
||||
<div class="asset-row"><span>视频源</span><span class="muted small">地址与通道差异</span></div>
|
||||
<div class="asset-row"><span>后台地址</span><span class="muted small">与站点环境相关</span></div>
|
||||
<div class="asset-row"><span>认证与存储</span><span class="muted small">token API / MinIO</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>覆盖项</span></h2>
|
||||
<div class="muted small">表达调试、敏感度、生产静默等变化,不暴露内部算法细节。</div>
|
||||
<div class="asset-stat">Overlays</div>
|
||||
<div class="asset-list">
|
||||
<div class="asset-row"><span>调试</span><span class="muted small">face_debug / shoe_debug</span></div>
|
||||
<div class="asset-row"><span>敏感度</span><span class="muted small">test_sensitive</span></div>
|
||||
<div class="asset-row"><span>生产静默</span><span class="muted small">production_quiet</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "release"}}<span>发布记录</span></h2>
|
||||
<div class="muted small">记录配置是如何生成、发布到哪些设备、结果怎样。</div>
|
||||
<div class="asset-stat">Releases</div>
|
||||
<div class="asset-list">
|
||||
<div class="asset-row"><span>模板 + 环境参数</span><span class="muted small">形成基础版本</span></div>
|
||||
<div class="asset-row"><span>叠加覆盖项</span><span class="muted small">形成候选配置</span></div>
|
||||
<div class="asset-row"><span>应用结果</span><span class="muted small">设备级回执与状态</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
51
internal/web/ui/templates/audit.html
Normal file
51
internal/web/ui/templates/audit.html
Normal file
@ -0,0 +1,51 @@
|
||||
{{define "audit"}}
|
||||
<section class="hero-band">
|
||||
<div>
|
||||
<div class="eyebrow">操作审计</div>
|
||||
<h2>所有关键操作的统一留痕入口</h2>
|
||||
<div class="muted">这里只看谁做了什么、对哪台设备做的、结果如何。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "audit"}}<span>审计记录</span></h2>
|
||||
<div class="muted small">当前展示后台已记录的任务与设备操作结果。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>动作</th>
|
||||
<th>目标设备</th>
|
||||
<th>结果</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Tasks}}
|
||||
<tr>
|
||||
<td class="mono">{{.ID}}</td>
|
||||
<td>{{.Type}}</td>
|
||||
<td class="mono">{{range $i, $id := .DeviceIDs}}{{if $i}}, {{end}}{{$id}}{{end}}</td>
|
||||
<td><span class="pill">{{.Status}}</span></td>
|
||||
<td class="muted small">{{if .Payload}}已记录任务参数{{else}}无附加参数{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">暂无审计记录</div>
|
||||
<div class="muted">当前后台还没有记录到任何任务或设备操作。</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -1,112 +1,85 @@
|
||||
{{define "config_preview"}}
|
||||
{{template "device_nav" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>配置预览</h2>
|
||||
<div class="muted small">基于模板、Profile 和 Overlay 生成完整配置预览;此页面不会下发到设备。</div>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>配置预览</span></h2>
|
||||
<div class="muted small">选择模板、Profile 和 Overlay,生成可上传的候选配置。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">返回节点详情</a>
|
||||
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>生成配置预览</h2>
|
||||
{{if .ConfigSources.Root}}<div class="muted small">Media 仓库:<span class="mono">{{.ConfigSources.Root}}</span></div>{{end}}
|
||||
<div class="muted small" style="margin-top:8px">
|
||||
<div class="muted small" style="margin-bottom:12px">
|
||||
<span class="mono">config_id</span> 是配置名,默认会带上 <span class="mono">device_id</span>(当前设备:<span class="mono">{{.Device.DeviceID}}</span>);<span class="mono">config_version</span> 表示本次生成版本;<span class="mono">SHA256</span> 是最终文件内容指纹。
|
||||
</div>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview" style="margin-top:12px">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="muted small">模板</div>
|
||||
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview">
|
||||
<div class="field-grid">
|
||||
<label><span>模板</span>
|
||||
<select name="template">
|
||||
{{range .ConfigSources.Templates}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedTemplate}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">Profile</div>
|
||||
</label>
|
||||
<label><span>Profile</span>
|
||||
<select name="profile">
|
||||
{{range .ConfigSources.Profiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">config_id</div>
|
||||
<input name="config_id" value="{{.SelectedConfigID}}" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">config_version</div>
|
||||
<input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" />
|
||||
</label>
|
||||
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" /></label>
|
||||
<label><span>config_version</span><input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" /></label>
|
||||
<div class="full">
|
||||
<span class="muted small">Overlay</span>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
{{range .ConfigSources.Overlays}}
|
||||
<label class="btn ghost">
|
||||
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<div class="muted small">Overlay</div>
|
||||
<div class="actions" style="margin-top:6px">
|
||||
{{range .ConfigSources.Overlays}}
|
||||
<label class="btn ghost">
|
||||
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">生成预览</button>
|
||||
<button type="button" disabled>上传为候选配置</button>
|
||||
{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}
|
||||
{{if .ConfigPreview}}
|
||||
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate">上传为候选配置</button>
|
||||
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">应用候选配置</button>
|
||||
{{else}}
|
||||
<button type="button" disabled>应用候选配置</button>
|
||||
<input type="hidden" name="json" value="{{.ConfigPreview.JSON}}" />
|
||||
{{end}}
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">查看当前运行配置</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/control">返回设备控制</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if .ConfigPreview}}
|
||||
<div class="card">
|
||||
<h2>预览摘要</h2>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div><div class="muted small">配置 ID</div><div class="mono">{{index .ConfigPreview.Metadata "config_id"}}</div></div>
|
||||
<div><div class="muted small">版本</div><div class="mono">{{index .ConfigPreview.Metadata "config_version"}}</div></div>
|
||||
<div><div class="muted small">模板</div><div class="mono">{{index .ConfigPreview.Metadata "template"}}</div></div>
|
||||
<div><div class="muted small">Profile</div><div class="mono">{{index .ConfigPreview.Metadata "profile"}}</div></div>
|
||||
<div><div class="muted small">大小</div><div class="mono">{{.ConfigPreview.Size}} bytes</div></div>
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>预览摘要</span></h2>
|
||||
<div class="muted small">这份预览还没有写入设备,上传后才会成为候选配置。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<div class="muted small">Overlay</div>
|
||||
<div class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<div class="muted small">SHA256</div>
|
||||
<div class="mono small">{{.ConfigPreview.Sha256}}</div>
|
||||
<div class="info-list">
|
||||
<div><span>配置 ID</span><strong class="mono">{{index .ConfigPreview.Metadata "config_id"}}</strong></div>
|
||||
<div><span>版本</span><strong class="mono">{{index .ConfigPreview.Metadata "config_version"}}</strong></div>
|
||||
<div><span>模板</span><strong>{{index .ConfigPreview.Metadata "template"}}</strong></div>
|
||||
<div><span>Profile</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div>
|
||||
<div><span>Overlay</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
|
||||
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>完整 JSON</h2>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate" class="actions" style="margin-bottom:10px">
|
||||
<input type="hidden" name="json" value="{{.ConfigPreview.JSON}}" />
|
||||
<button type="submit">上传为候选配置</button>
|
||||
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">应用候选配置</button>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">查看当前运行配置</a>
|
||||
</form>
|
||||
{{if .RawText}}
|
||||
<details>
|
||||
<summary class="muted small">展开完整 JSON</summary>
|
||||
<pre>{{.ConfigPreview.JSON}}</pre>
|
||||
</details>
|
||||
{{else}}
|
||||
<details open>
|
||||
<summary class="muted small">展开完整 JSON</summary>
|
||||
<pre>{{.ConfigPreview.JSON}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
<details class="card collapsible" {{if not .RawText}}open{{end}}>
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>完整 JSON</span></summary>
|
||||
<pre>{{.ConfigPreview.JSON}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{if and (eq .ResultTitle "应用候选配置结果") .ConfigStatus}}
|
||||
|
||||
@ -1,121 +1,82 @@
|
||||
{{define "device"}}
|
||||
{{template "device_nav" .}}
|
||||
<div class="card">
|
||||
<h2>视觉识别节点详情</h2>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div>
|
||||
<div class="muted small">节点名称</div>
|
||||
<div><b>{{.Device.DeviceName}}</b></div>
|
||||
<div class="muted small mono" style="margin-top:6px">{{.Device.DeviceID}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">状态</div>
|
||||
<div>{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</div>
|
||||
<div class="muted small" style="margin-top:6px">最后在线:{{ago .Device.LastSeenMs}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">管理地址</div>
|
||||
<div class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">视频服务端口:{{.Device.MediaPort}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">版本</div>
|
||||
<div class="mono">{{.Device.Version}}</div>
|
||||
{{if .Device.GitSha}}<div class="muted small mono" style="margin-top:6px">git: {{.Device.GitSha}}</div>{{end}}
|
||||
{{if .Device.Hostname}}<div class="muted small" style="margin-top:6px">hostname: <span class="mono">{{.Device.Hostname}}</span></div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">快速查看</div>
|
||||
<div style="margin-top:6px">
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/graphs">运行指标</a> |
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a> |
|
||||
<a href="/ui/device-config">配置管理</a>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "status"}}<span>设备状态</span></h2>
|
||||
<div class="muted small">当前设备连接、版本与服务入口。</div>
|
||||
</div>
|
||||
{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>设备名</span><strong>{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</strong></div>
|
||||
<div><span>设备 ID</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
|
||||
<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>Git SHA</span><strong class="mono">{{if .Device.GitSha}}{{.Device.GitSha}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>主机名</span><strong>{{if .Device.Hostname}}{{.Device.Hostname}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>当前运行配置</span></h2>
|
||||
<div class="muted small">当前服务正在使用的配置摘要。</div>
|
||||
</div>
|
||||
{{if .ConfigStatus}}{{if .ConfigStatus.OK}}<span class="pill ok">已读取</span>{{else}}<span class="pill warn">异常</span>{{end}}{{else}}<span class="pill bad">未读取</span>{{end}}
|
||||
</div>
|
||||
{{if .ConfigStatus}}
|
||||
<div class="info-list">
|
||||
<div><span>配置 ID</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>配置版本</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
|
||||
<div><span>模板</span><strong>{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>环境参数</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>覆盖项</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>服务状态</span><strong>{{if .ConfigStatus.MediaServer.Running}}运行中{{else}}未运行{{end}}</strong></div>
|
||||
<div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div>
|
||||
<div><span>SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">暂未读到配置状态</div>
|
||||
<div class="muted">{{if .ConfigStatusErr}}{{.ConfigStatusErr}}{{else}}设备未返回配置摘要。{{end}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>当前运行配置</h2>
|
||||
<div class="muted small">来自 agent 的 /v1/config/status,用于确认设备当前加载的配置版本。</div>
|
||||
</div>
|
||||
{{if .ConfigStatus}}
|
||||
{{if .ConfigStatus.OK}}<span class="pill ok">已读取</span>{{else}}<span class="pill bad">状态异常</span>{{end}}
|
||||
{{else if .ConfigStatusErr}}
|
||||
<span class="pill bad">读取失败</span>
|
||||
{{else}}
|
||||
<span class="pill">未连接</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .ConfigStatus}}
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div>
|
||||
<div class="muted small">配置 ID</div>
|
||||
<div class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</div>
|
||||
<div class="muted small" style="margin-top:6px">版本:<span class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">模板 / Profile</div>
|
||||
<div class="mono">{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">Overlay</div>
|
||||
<div class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">{{.ConfigStatus.Metadata.RenderedBy}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">配置路径</div>
|
||||
<div class="mono">{{.ConfigStatus.ConfigPath}}</div>
|
||||
<div class="muted small mono" style="margin-top:6px">{{if .ConfigStatus.Exists}}文件存在{{else}}文件不存在{{end}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">视频服务</div>
|
||||
<div>{{if .ConfigStatus.MediaServer.Running}}<span class="pill ok">运行中</span>{{else}}<span class="pill bad">未运行</span>{{end}}</div>
|
||||
{{if .ConfigStatus.MediaServer.PID}}<div class="muted small mono" style="margin-top:6px">pid: {{.ConfigStatus.MediaServer.PID}}</div>{{end}}
|
||||
<h2 class="title-with-icon">{{icon "overview"}}<span>最近状态摘要</span></h2>
|
||||
<div class="muted small">放常用参考信息,不把技术细节堆在主区域。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<div class="muted small">SHA256</div>
|
||||
<div class="mono small">{{.ConfigStatus.Sha256}}</div>
|
||||
</div>
|
||||
{{if .ConfigStatus.Metadata.RenderedAt}}
|
||||
<div class="muted small" style="margin-top:8px">生成时间:<span class="mono">{{.ConfigStatus.Metadata.RenderedAt}}</span></div>
|
||||
{{end}}
|
||||
{{else if .ConfigStatusErr}}
|
||||
<div class="muted small" style="margin-top:10px">暂时无法读取设备配置状态:{{.ConfigStatusErr}}</div>
|
||||
{{else}}
|
||||
<div class="muted small" style="margin-top:10px">未配置 agent 客户端时不会请求设备。</div>
|
||||
{{end}}
|
||||
{{if .ConfigStatusText}}
|
||||
<details style="margin-top:10px">
|
||||
<summary class="muted small">原始响应</summary>
|
||||
<pre>{{.ConfigStatusText}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 id="overview">只读详情</h2>
|
||||
<div class="muted small">此页面只用于查看设备身份、在线状态、地址、版本和诊断入口。修改配置或控制服务请进入配置管理。</div>
|
||||
<div class="actions" style="margin-top:10px">
|
||||
<a class="btn ghost" href="/ui/device-config">进入配置管理</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">查看诊断日志</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/graphs">查看运行指标</a>
|
||||
<div class="summary-strip">
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">服务</div>
|
||||
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}</div>
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">配置</div>
|
||||
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}待读取{{end}}</div>
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">版本</div>
|
||||
<div class="summary-chip-value">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="technical">
|
||||
<h2>技术信息</h2>
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>技术信息</span></summary>
|
||||
<div class="muted small">低频技术信息默认收起。</div>
|
||||
<pre>{{json .Device}}</pre>
|
||||
</div>
|
||||
|
||||
{{if .RawText}}
|
||||
<div class="card">
|
||||
<h2>最近响应</h2>
|
||||
<pre>{{.RawText}}</pre>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .ConfigStatusText}}<pre>{{.ConfigStatusText}}</pre>{{end}}
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
127
internal/web/ui/templates/device_control.html
Normal file
127
internal/web/ui/templates/device_control.html
Normal file
@ -0,0 +1,127 @@
|
||||
{{define "device_control"}}
|
||||
{{template "device_nav" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "control"}}<span>设备控制</span></h2>
|
||||
<div class="muted small">服务动作、候选配置应用和回滚都在这里完成。</div>
|
||||
</div>
|
||||
{{if .Device.Online}}<span class="pill ok">可操作</span>{{else}}<span class="pill bad">设备离线</span>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="summary-strip control-summary">
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">当前配置</div>
|
||||
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}待读取{{end}}</div>
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">上一份配置</div>
|
||||
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{else}}-{{end}}</div>
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">服务状态</div>
|
||||
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-grid">
|
||||
<section class="panel-block">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "preview"}}<span>配置预览</span></h3>
|
||||
<div class="muted small">进入预览器生成、上传候选配置。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开预览器</span></a>
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>当前模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>Profile</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
|
||||
<div><span>候选配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}} / {{.ConfigStatus.Candidate.Metadata.ConfigVersion}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-block">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "apply"}}<span>配置应用</span></h3>
|
||||
<div class="muted small">应用候选配置,或回滚到上一份配置回滚点。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>当前配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}待读取{{end}}</strong></div>
|
||||
<div><span>上一份配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<button type="submit">应用候选配置</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="rollback" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<button type="submit" class="btn ghost">回滚到上一份</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-block">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "service"}}<span>服务控制</span></h3>
|
||||
<div class="muted small">启动、停止、重启和重载当前服务。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions stack">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_start" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<button type="submit">启动服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_restart" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<button type="submit">重启服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_stop" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<button type="submit" class="btn ghost">停止服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="reload" />
|
||||
<input type="hidden" name="return_to" value="control" />
|
||||
<button type="submit" class="btn ghost">重载服务</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-block">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "result"}}<span>执行结果摘要</span></h3>
|
||||
<div class="muted small">操作结果只在这里汇总,不在多个信息窗重复显示。</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .ResultTitle}}<div class="muted small" style="margin-bottom:8px">{{.ResultTitle}}</div>{{end}}
|
||||
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{if .RawText}}<pre>{{.RawText}}</pre>{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">暂无最近操作</div>
|
||||
<div class="muted">从上面的配置应用或服务控制开始执行。</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "meta"}}<span>次要参考信息</span></summary>
|
||||
<div class="info-list">
|
||||
<div><span>设备 ID</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
|
||||
<div><span>管理地址</span><strong class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</strong></div>
|
||||
<div><span>当前服务</span><strong>{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}</strong></div>
|
||||
<div><span>当前版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
@ -1,24 +1,18 @@
|
||||
{{define "device_nav"}}
|
||||
<div class="card device-context">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<div class="crumb">当前节点</div>
|
||||
<h2>{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</h2>
|
||||
<div class="muted small mono">{{.Device.DeviceID}} · {{.Device.IP}}:{{.Device.AgentPort}}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">节点详情</a>
|
||||
<div class="device-context-head">
|
||||
<div class="device-context-icon">{{icon "device"}}</div>
|
||||
<div class="crumb">当前设备</div>
|
||||
<div>
|
||||
<h2>{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</h2>
|
||||
<div class="muted small mono">{{.Device.DeviceID}} · {{.Device.IP}}:{{.Device.AgentPort}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subnav device-tabs">
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}">概览</a>
|
||||
<a href="/ui/device-config">配置管理</a>
|
||||
<a href="/ui/models">模型管理</a>
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/config-preview">配置预览</a>
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/graphs">运行指标</a>
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}#technical">技术信息</a>
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}">设备详情</a>
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/control">设备控制</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -1,368 +1,136 @@
|
||||
{{define "devices"}}
|
||||
<section class="hero-band">
|
||||
<div>
|
||||
<div class="eyebrow">设备总览</div>
|
||||
<h2>多台视觉识别设备的统一运维视图</h2>
|
||||
<div class="muted">先看状态,再决定操作。默认只展示值班和维护最需要的信息。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat accent-teal">
|
||||
<div class="k metric-label">{{icon "devices"}}<span>设备总数</span></div>
|
||||
<div class="v">{{.DeviceCount}}</div>
|
||||
<div class="hint">当前纳管设备</div>
|
||||
</div>
|
||||
<div class="stat accent-green">
|
||||
<div class="k metric-label">{{icon "online"}}<span>在线设备</span></div>
|
||||
<div class="v">{{.OnlineCount}}</div>
|
||||
<div class="hint">可接收状态与控制</div>
|
||||
</div>
|
||||
<div class="stat accent-slate">
|
||||
<div class="k">离线设备</div>
|
||||
<div class="v">{{.OfflineCount}}</div>
|
||||
<div class="hint">需要检查网络或进程</div>
|
||||
</div>
|
||||
<div class="stat accent-amber">
|
||||
<div class="k">失败操作</div>
|
||||
<div class="v">{{.FailedTaskCount}}</div>
|
||||
<div class="hint">最近任务失败数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>局域网扫描</h2>
|
||||
<div class="muted small">扫描同网段内的视觉识别边缘设备,发现结果会自动加入设备列表。</div>
|
||||
<h2>设备列表</h2>
|
||||
<div class="muted small">设备名优先,技术字段降级显示。默认只展示状态判断需要的信息。</div>
|
||||
</div>
|
||||
<div class="table-tools">
|
||||
<input id="device-filter" placeholder="搜索设备名、ID 或 IP" />
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/devices-add">新增设备</a>
|
||||
</div>
|
||||
<div class="row" style="gap:16px">
|
||||
<form method="post" action="/ui/discovery/search" class="row">
|
||||
<div>
|
||||
<div class="muted small">扫描等待时间(毫秒)</div>
|
||||
<input name="timeout_ms" value="1200" />
|
||||
</div>
|
||||
<div style="align-self:end">
|
||||
<button type="submit">开始扫描</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>节点态势</h2>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="k">设备总数</div><div class="v">{{.DeviceCount}}</div><div class="hint">已纳管的边缘设备</div></div>
|
||||
<div class="stat"><div class="k">在线节点</div><div class="v">{{.OnlineCount}}</div><div class="hint">可接收配置和任务</div></div>
|
||||
<div class="stat"><div class="k">离线节点</div><div class="v">{{.OfflineCount}}</div><div class="hint">超过心跳窗口未更新</div></div>
|
||||
<div class="stat"><div class="k">本次扫描</div><div class="v">{{.FoundCount}}</div><div class="hint">刚发现的节点</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Found}}
|
||||
<div class="card">
|
||||
<h2>本次扫描结果({{len .Found}})</h2>
|
||||
<div class="muted small">发现结果已同步到下方设备列表。</div>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
<div class="table-wrap">
|
||||
<table id="device-list">
|
||||
<thead>
|
||||
<tr><th>节点标识</th><th>节点名称</th><th>管理地址</th><th>视频服务端口</th><th>版本</th></tr>
|
||||
<tr>
|
||||
<th>设备</th>
|
||||
<th>状态</th>
|
||||
<th>当前配置</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Found}}
|
||||
{{range .DeviceRows}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a></td>
|
||||
<td>{{.DeviceName}}</td>
|
||||
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
|
||||
<td class="mono">{{.MediaPort}}</td>
|
||||
<td class="mono">{{.Version}}</td>
|
||||
<td>
|
||||
<div class="device-cell">
|
||||
<div class="device-avatar">{{icon "device"}}</div>
|
||||
<div>
|
||||
<div class="device-name">{{if .Device.DeviceName}}{{.Device.DeviceName}}{{else}}{{.Device.DeviceID}}{{end}}</div>
|
||||
<div class="device-meta-line">
|
||||
{{if .Device.Hostname}}<span>{{.Device.Hostname}}</span>{{end}}
|
||||
<span class="mono">{{.Device.IP}}</span>
|
||||
{{if .Device.Version}}<span class="mono">{{.Device.Version}}</span>{{end}}
|
||||
{{if .Device.GitSha}}<span class="mono">#{{shortHash .Device.GitSha}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="state-stack">
|
||||
<div class="state-row">
|
||||
{{if .Device.Online}}<span class="status-dot ok"></span><span class="status-text">在线</span>{{else}}<span class="status-dot bad"></span><span class="status-text">离线</span>{{end}}
|
||||
{{if .ConfigStatus}}
|
||||
{{if .ConfigStatus.MediaServer.Running}}<span class="pill ok">运行中</span>{{else}}<span class="pill bad">未运行</span>{{end}}
|
||||
{{else if .Device.Online}}
|
||||
<span class="pill warn">待确认</span>
|
||||
{{else}}
|
||||
<span class="pill bad">未知</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="muted small">心跳 {{ago .Device.LastSeenMs}}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="config-inline">
|
||||
{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}
|
||||
<div class="mono">{{.ConfigStatus.Metadata.ConfigID}}</div>
|
||||
<div class="muted small mono">{{.ConfigStatus.Metadata.ConfigVersion}}</div>
|
||||
{{if .ConfigStatus.Metadata.Overlays}}<div class="muted small">{{range $i, $overlay := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$overlay}}{{end}}</div>{{end}}
|
||||
{{else if .ConfigStatusErr}}
|
||||
<div class="muted small">未取到配置摘要</div>
|
||||
{{else}}
|
||||
<div class="muted small">暂无配置摘要</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="device-id-inline muted small mono" title="{{.Device.DeviceID}}">{{shortHash .Device.DeviceID}}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/control">{{icon "control"}}<span>控制</span></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">还没有设备</div>
|
||||
<div class="muted">当前后台还没有发现或录入任何设备。</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>设备列表</h2>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div>
|
||||
<div class="muted small">搜索节点、名称或 IP</div>
|
||||
<input id="filter" placeholder="例如 edge-01 / 入口 / 192.168" />
|
||||
</div>
|
||||
<div class="muted small" style="align-self:end">在线状态由发现心跳和设备访问自动更新</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-action" style="margin-top:10px">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="muted small">批量运维操作</div>
|
||||
<select name="action">
|
||||
<option value="media_start">启动视频分析服务</option>
|
||||
<option value="media_restart">重启视频分析服务</option>
|
||||
<option value="media_stop">停止视频分析服务</option>
|
||||
<option value="reload">重载识别服务</option>
|
||||
<option value="rollback">回滚识别配置</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">识别方案(可选,仅启动/重启生效)</div>
|
||||
<input name="config" placeholder="例如 cam1" />
|
||||
</div>
|
||||
<div style="align-self:end">
|
||||
<button type="submit" onclick="return confirmBatchAction(event)">执行操作(已选 <span id="selectedCount">0</span>)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="batch-confirm-panel" class="confirm-panel" hidden>
|
||||
<div>
|
||||
<h3>确认批量操作</h3>
|
||||
<div class="muted small" id="batch-confirm-summary">请确认目标节点和操作类型。</div>
|
||||
<div class="confirm-list" id="batch-confirm-list"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn ghost" onclick="cancelBatchAction()">取消</button>
|
||||
<button type="button" onclick="submitBatchAction()">确认执行</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table id="devices-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:48px"><input id="selectAll" type="checkbox" title="全选(仅作用于当前筛选可见行)" /></th>
|
||||
<th>节点标识</th><th>节点名称</th><th>管理地址</th><th>状态</th><th>视频分析服务</th><th>最后心跳</th><th>版本</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr data-device-id="{{.DeviceID}}" data-device-name="{{.DeviceName}}" data-device-ip="{{.IP}}" data-device-agent-port="{{.AgentPort}}" data-device-media-port="{{.MediaPort}}" data-device-online="{{.Online}}" data-device-version="{{.Version}}" data-device-last-seen="{{ago .LastSeenMs}}">
|
||||
<td><input class="dev-check" type="checkbox" name="device_id" value="{{.DeviceID}}" /></td>
|
||||
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a></td>
|
||||
<td>{{.DeviceName}}</td>
|
||||
<td class="mono">
|
||||
{{.IP}}:{{.AgentPort}}
|
||||
<div class="muted small">视频端口:{{.MediaPort}}</div>
|
||||
</td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td>
|
||||
{{if .Online}}
|
||||
<span class="pill warn" data-media-status="{{.DeviceID}}">待查询</span>
|
||||
{{else}}
|
||||
<span class="muted">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ago .LastSeenMs}}</div>
|
||||
<div class="muted small mono">{{.LastSeenMs}}</div>
|
||||
</td>
|
||||
<td class="mono">{{.Version}}</td>
|
||||
<td><button type="button" class="btn ghost" onclick="openDeviceDrawer(this)">快速查看</button></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside id="device-drawer" class="drawer" aria-hidden="true">
|
||||
<div class="drawer-panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 id="drawer-title">节点摘要</h2>
|
||||
<div class="muted small" id="drawer-subtitle"></div>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" onclick="closeDeviceDrawer()">关闭</button>
|
||||
</div>
|
||||
<div class="drawer-grid">
|
||||
<div><div class="muted small">在线状态</div><div id="drawer-online"></div></div>
|
||||
<div><div class="muted small">管理地址</div><div class="mono" id="drawer-address"></div></div>
|
||||
<div><div class="muted small">视频端口</div><div class="mono" id="drawer-media"></div></div>
|
||||
<div><div class="muted small">最后心跳</div><div id="drawer-last-seen"></div></div>
|
||||
<div><div class="muted small">版本</div><div class="mono" id="drawer-version"></div></div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:14px">
|
||||
<a class="btn ghost" id="drawer-detail" href="#">打开详情</a>
|
||||
<a class="btn ghost" id="drawer-config" href="#">识别配置</a>
|
||||
<a class="btn ghost" id="drawer-logs" href="#">诊断日志</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
let pendingBatchForm = null;
|
||||
|
||||
(() => {
|
||||
const input = document.getElementById('filter');
|
||||
const table = document.getElementById('devices-table');
|
||||
if(!input || !table) return;
|
||||
const input = document.getElementById('device-filter');
|
||||
const table = document.getElementById('device-list');
|
||||
if (!input || !table) return;
|
||||
input.addEventListener('input', () => {
|
||||
const q = (input.value || '').trim().toLowerCase();
|
||||
const rows = table.tBodies[0].rows;
|
||||
for(const tr of rows){
|
||||
const txt = tr.innerText.toLowerCase();
|
||||
tr.style.display = (!q || txt.includes(q)) ? '' : 'none';
|
||||
for (const row of table.tBodies[0].rows) {
|
||||
const text = row.innerText.toLowerCase();
|
||||
row.style.display = (!q || text.includes(q)) ? '' : 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
function selectedDeviceRows(){
|
||||
const table = document.getElementById('devices-table');
|
||||
if(!table) return [];
|
||||
return Array.from(table.querySelectorAll('tbody tr')).filter(tr => {
|
||||
const c = tr.querySelector('input.dev-check');
|
||||
return c && c.checked;
|
||||
});
|
||||
}
|
||||
|
||||
function actionLabel(value){
|
||||
const labels = {
|
||||
media_start: '启动视频分析服务',
|
||||
media_restart: '重启视频分析服务',
|
||||
media_stop: '停止视频分析服务',
|
||||
reload: '重载识别服务',
|
||||
rollback: '回滚识别配置'
|
||||
};
|
||||
return labels[value] || value || '批量操作';
|
||||
}
|
||||
|
||||
function confirmBatchAction(ev){
|
||||
ev.preventDefault();
|
||||
const form = ev.target.closest('form');
|
||||
const rows = selectedDeviceRows();
|
||||
if(!rows.length){
|
||||
alert('请先选择设备');
|
||||
return false;
|
||||
}
|
||||
pendingBatchForm = form;
|
||||
const action = form.querySelector('select[name="action"]');
|
||||
const panel = document.getElementById('batch-confirm-panel');
|
||||
const summary = document.getElementById('batch-confirm-summary');
|
||||
const list = document.getElementById('batch-confirm-list');
|
||||
summary.textContent = `即将对 ${rows.length} 台节点执行:${actionLabel(action ? action.value : '')}`;
|
||||
list.innerHTML = rows.map(tr => {
|
||||
const id = tr.getAttribute('data-device-id') || '';
|
||||
const name = tr.getAttribute('data-device-name') || '-';
|
||||
const ip = tr.getAttribute('data-device-ip') || '-';
|
||||
return `<span class="pill">${name} / ${ip} / ${id}</span>`;
|
||||
}).join('');
|
||||
panel.hidden = false;
|
||||
panel.scrollIntoView({block:'nearest'});
|
||||
return false;
|
||||
}
|
||||
|
||||
function cancelBatchAction(){
|
||||
pendingBatchForm = null;
|
||||
const panel = document.getElementById('batch-confirm-panel');
|
||||
if(panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
function submitBatchAction(){
|
||||
if(pendingBatchForm) pendingBatchForm.submit();
|
||||
}
|
||||
|
||||
function openDeviceDrawer(button){
|
||||
const tr = button.closest('tr');
|
||||
if(!tr) return;
|
||||
const id = tr.getAttribute('data-device-id') || '';
|
||||
const name = tr.getAttribute('data-device-name') || id;
|
||||
const ip = tr.getAttribute('data-device-ip') || '';
|
||||
const agent = tr.getAttribute('data-device-agent-port') || '';
|
||||
const media = tr.getAttribute('data-device-media-port') || '';
|
||||
const online = tr.getAttribute('data-device-online') === 'true';
|
||||
const version = tr.getAttribute('data-device-version') || '-';
|
||||
const lastSeen = tr.getAttribute('data-device-last-seen') || '-';
|
||||
document.getElementById('drawer-title').textContent = name || '节点摘要';
|
||||
document.getElementById('drawer-subtitle').textContent = id;
|
||||
document.getElementById('drawer-online').innerHTML = online ? '<span class="pill ok">在线</span>' : '<span class="pill bad">离线</span>';
|
||||
document.getElementById('drawer-address').textContent = `${ip}:${agent}`;
|
||||
document.getElementById('drawer-media').textContent = media;
|
||||
document.getElementById('drawer-last-seen').textContent = lastSeen;
|
||||
document.getElementById('drawer-version').textContent = version;
|
||||
document.getElementById('drawer-detail').href = `/ui/devices/${encodeURIComponent(id)}`;
|
||||
document.getElementById('drawer-config').href = `/ui/devices/${encodeURIComponent(id)}/config-friendly`;
|
||||
document.getElementById('drawer-logs').href = `/ui/devices/${encodeURIComponent(id)}/logs?limit=200`;
|
||||
const drawer = document.getElementById('device-drawer');
|
||||
drawer.classList.add('open');
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function closeDeviceDrawer(){
|
||||
const drawer = document.getElementById('device-drawer');
|
||||
drawer.classList.remove('open');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
(() => {
|
||||
const table = document.getElementById('devices-table');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const out = document.getElementById('selectedCount');
|
||||
if(!table || !selectAll || !out) return;
|
||||
|
||||
const visibleRows = () => Array.from(table.tBodies[0].rows).filter(tr => tr.style.display !== 'none');
|
||||
const getChecks = (rows) => rows.map(tr => tr.querySelector('input.dev-check')).filter(Boolean);
|
||||
|
||||
const updateCount = () => {
|
||||
const checks = getChecks(Array.from(table.tBodies[0].rows));
|
||||
const n = checks.filter(c => c.checked).length;
|
||||
out.textContent = String(n);
|
||||
};
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
const rows = visibleRows();
|
||||
const checks = getChecks(rows);
|
||||
for(const c of checks) c.checked = selectAll.checked;
|
||||
updateCount();
|
||||
});
|
||||
|
||||
table.addEventListener('change', (e) => {
|
||||
const el = e.target;
|
||||
if(el && el.classList && el.classList.contains('dev-check')) updateCount();
|
||||
});
|
||||
|
||||
updateCount();
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const els = Array.from(document.querySelectorAll('[data-media-status]'));
|
||||
if(!els.length) return;
|
||||
|
||||
const labelFromHttp = (status, txt) => {
|
||||
if(status === 501) return {cls:'warn', text:'未启用', title:txt};
|
||||
if(status === 401) return {cls:'warn', text:'未授权', title:txt};
|
||||
if(status === 404) return {cls:'warn', text:'未实现', title:txt};
|
||||
return {cls:'warn', text:`HTTP ${status}`, title:txt};
|
||||
};
|
||||
|
||||
async function fetchOne(el){
|
||||
const id = el.getAttribute('data-media-status');
|
||||
el.textContent = '查询中...';
|
||||
el.className = 'pill warn';
|
||||
el.title = '';
|
||||
let res;
|
||||
let txt = '';
|
||||
try{
|
||||
res = await fetch(`/api/devices/${encodeURIComponent(id)}/media-server/status`, { method: 'GET' });
|
||||
txt = await res.text();
|
||||
}catch(e){
|
||||
el.className = 'pill warn';
|
||||
el.textContent = '请求失败';
|
||||
el.title = String(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!res.ok){
|
||||
const v = labelFromHttp(res.status, txt);
|
||||
el.className = 'pill ' + v.cls;
|
||||
el.textContent = v.text;
|
||||
el.title = v.title || '';
|
||||
return;
|
||||
}
|
||||
|
||||
let obj;
|
||||
try{ obj = JSON.parse(txt); }catch(e){}
|
||||
if(!obj || typeof obj.running !== 'boolean'){
|
||||
el.className = 'pill warn';
|
||||
el.textContent = '未知';
|
||||
el.title = txt;
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = (typeof obj.pid === 'number' && obj.pid > 0) ? obj.pid : 0;
|
||||
const cfg = (typeof obj.config_path === 'string') ? obj.config_path : '';
|
||||
if(obj.running){
|
||||
el.className = 'pill ok';
|
||||
el.textContent = pid ? `运行中 PID ${pid}` : '运行中';
|
||||
}else{
|
||||
el.className = 'pill bad';
|
||||
el.textContent = pid ? `未运行 PID ${pid}` : '未运行';
|
||||
}
|
||||
el.title = cfg;
|
||||
}
|
||||
|
||||
const limit = 6;
|
||||
let idx = 0;
|
||||
const workers = Array.from({length: Math.min(limit, els.length)}, async () => {
|
||||
while(true){
|
||||
const i = idx++;
|
||||
if(i >= els.length) return;
|
||||
await fetchOne(els[i]);
|
||||
}
|
||||
});
|
||||
Promise.all(workers);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@ -5,46 +5,36 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="/ui/assets/vendor/tabler.min.css" />
|
||||
<link rel="stylesheet" href="/ui/assets/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="theme-light">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">EV</div>
|
||||
<div class="brand-mark">AI</div>
|
||||
<div>
|
||||
<div class="brand-title">视觉识别边缘控制台</div>
|
||||
<div class="brand-subtitle">Edge Vision Fleet</div>
|
||||
<div class="brand-title">视觉识别运维台</div>
|
||||
<div class="brand-subtitle">Fleet Operations Console</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="side-nav" aria-label="主导航">
|
||||
<div class="nav-section">运维</div>
|
||||
<a href="/ui/dashboard"><span class="nav-icon">OV</span><span>总览</span></a>
|
||||
<a href="/ui/devices"><span class="nav-icon">DL</span><span>设备列表</span></a>
|
||||
<a href="/ui/device-config"><span class="nav-icon">CM</span><span>配置管理</span></a>
|
||||
<a href="/ui/devices-add"><span class="nav-icon">AD</span><span>新增设备</span></a>
|
||||
<div class="nav-section">视觉 AI</div>
|
||||
<a href="/ui/models"><span class="nav-icon">MM</span><span>模型管理</span></a>
|
||||
<a href="/ui/recognition"><span class="nav-icon">RC</span><span>识别配置</span></a>
|
||||
<div class="nav-section">诊断</div>
|
||||
<a href="/ui/logs"><span class="nav-icon">LA</span><span>日志分析</span></a>
|
||||
<a href="/ui/api"><span class="nav-icon">AD</span><span>高级调试</span></a>
|
||||
<div class="nav-section">主模块</div>
|
||||
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
|
||||
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>配置资产</span></a>
|
||||
<a href="/ui/audit"><span class="nav-icon">{{icon "audit"}}</span><span>操作审计</span></a>
|
||||
<a href="/ui/system"><span class="nav-icon">{{icon "system"}}</span><span>系统</span></a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a href="/health">服务健康</a>
|
||||
<a href="/openapi.json">OpenAPI</a>
|
||||
<div class="sidebar-note">统一设备管理、配置资产与操作留痕</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="crumb">多节点视觉分析管理</div>
|
||||
<div class="crumb">多设备视觉识别运维平台</div>
|
||||
<h1>{{.Title}}</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<a class="btn ghost" href="/ui/devices">刷新视图</a>
|
||||
<a class="btn ghost" href="/health">健康检查</a>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
@ -53,6 +43,7 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/assets/vendor/tabler.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
55
internal/web/ui/templates/system.html
Normal file
55
internal/web/ui/templates/system.html
Normal file
@ -0,0 +1,55 @@
|
||||
{{define "system"}}
|
||||
<section class="hero-band">
|
||||
<div>
|
||||
<div class="eyebrow">系统</div>
|
||||
<h2>平台状态与访问入口</h2>
|
||||
<div class="muted">只放发现、认证、健康和接口入口,不承载日常设备操作。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "discovery"}}<span>设备发现与注册</span></h2>
|
||||
<div class="muted small">当前后台通过 UDP 广播发现设备,也支持手动纳管。</div>
|
||||
<div class="info-list">
|
||||
<div><span>当前设备数</span><strong>{{len .Devices}}</strong></div>
|
||||
<div><span>发现方式</span><strong>UDP 广播发现</strong></div>
|
||||
<div><span>注册方式</span><strong>自动发现 / 手动录入</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "shield"}}<span>Agent 访问策略</span></h2>
|
||||
<div class="muted small">后台通过统一令牌访问设备,页面默认不展示敏感值。</div>
|
||||
<div class="info-list">
|
||||
<div><span>访问模型</span><strong>统一令牌</strong></div>
|
||||
<div><span>更新方式</span><strong>部署脚本托管</strong></div>
|
||||
<div><span>页面策略</span><strong>默认不暴露敏感值</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "heartbeat"}}<span>后台健康</span></h2>
|
||||
<div class="muted small">后台自身的健康检查与接口说明入口。</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>健康检查</span><strong class="mono">/health</strong></div>
|
||||
<div><span>接口文档</span><strong class="mono">/openapi.json</strong></div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<a class="btn ghost" href="/health">健康检查</a>
|
||||
<a class="btn ghost" href="/openapi.json">OpenAPI</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "tech"}}<span>当前纳管设备</span></h2>
|
||||
<div class="muted small">只展示系统层需要的设备接入信息。</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>在线设备</span><strong>{{len .Devices}}</strong></div>
|
||||
<div><span>接入端口</span><strong class="mono">9100 / 9000</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -71,12 +71,11 @@ func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"视觉识别边缘控制台",
|
||||
"设备列表",
|
||||
"识别配置",
|
||||
"模型管理",
|
||||
"高级调试",
|
||||
"<h1>设备列表</h1>",
|
||||
"视觉识别运维台",
|
||||
"配置资产",
|
||||
"操作审计",
|
||||
"系统",
|
||||
"<h1>设备</h1>",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected rendered HTML to contain %q, got:\n%s", want, body)
|
||||
@ -91,14 +90,13 @@ func TestUI_ConsoleTypographyStaysModerate(t *testing.T) {
|
||||
}
|
||||
text := string(css)
|
||||
for _, want := range []string{
|
||||
".brand-title{font-weight:600",
|
||||
".nav-section{font-size:11px",
|
||||
".side-nav a{color:#dce6f1;display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;font-size:13px;font-weight:500",
|
||||
".topbar h1{margin:3px 0 0;font-size:18px;letter-spacing:0;font-weight:600",
|
||||
".crumb{font-size:11px",
|
||||
"input,select,textarea,button,.btn{background:#fff;color:var(--text);border:1px solid var(--border2);border-radius:7px;padding:8px 10px;outline:none;font:inherit;font-size:13px",
|
||||
"button,.btn{cursor:pointer;font-size:13px;font-weight:600",
|
||||
"th{color:#40546a;font-size:13px;font-weight:600",
|
||||
".brand-title{font-size:14px;font-weight:600",
|
||||
".nav-section{padding:14px 10px 6px;font-size:11px",
|
||||
".topbar h1{margin:4px 0 0;font-size:18px;font-weight:600",
|
||||
".crumb,.eyebrow{font-size:11px",
|
||||
".btn,button{display:inline-flex",
|
||||
".stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}",
|
||||
".card{background:var(--surface);border:1px solid var(--border);border-radius:8px",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("expected stylesheet to contain %q", want)
|
||||
@ -119,25 +117,25 @@ func newTestUI(t *testing.T) *UI {
|
||||
return ui
|
||||
}
|
||||
|
||||
func TestUI_DashboardRendersFleetOverview(t *testing.T) {
|
||||
func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/dashboard", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageDashboard(rr, req)
|
||||
ui.pageDevices(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"总览", "视觉节点态势", "视频分析概览", "最近任务", "快速入口", "执行中任务", "失败任务", "需要关注的节点", "运维工作流"} {
|
||||
for _, want := range []string{"设备", "设备总览", "多台视觉识别设备的统一运维视图", "设备总数", "在线设备", "离线设备", "失败操作", "设备列表"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected dashboard HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DevicePageIncludesDrawerAndBatchConfirm(t *testing.T) {
|
||||
func TestUI_DevicePageIncludesFilterAndControlEntry(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@ -145,11 +143,130 @@ func TestUI_DevicePageIncludesDrawerAndBatchConfirm(t *testing.T) {
|
||||
ui.pageDevices(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"device-drawer", "batch-confirm-panel", "openDeviceDrawer", "confirmBatchAction"} {
|
||||
for _, want := range []string{"device-filter", "详情", "控制"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected device page HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"查看配置资产", "查看操作审计"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device overview should not contain redundant link %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceOverviewShowsLiveStatusAndConfigSummary(t *testing.T) {
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/config/status" {
|
||||
t.Fatalf("unexpected agent path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"ok": true,
|
||||
"config_path": "/opt/rk3588-media-server/etc/media-server.json",
|
||||
"exists": true,
|
||||
"sha256": "8d935c9d366637ff853c69d25e9c6644c2fbff3b8d3aa15ff99ef32847fb947c",
|
||||
"metadata": {
|
||||
"config_id": "local_3588_face_debug",
|
||||
"config_version": "20260419.104457",
|
||||
"template": "workshop_face_shoe_alarm",
|
||||
"profile": "local_3588_test",
|
||||
"overlays": ["face_debug", "production_quiet"]
|
||||
},
|
||||
"media_server": {"running": true, "pid": 1706009}
|
||||
}`))
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server address: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server port: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
agent := service.NewAgentClient(cfg)
|
||||
reg := service.NewRegistryService(cfg, agent)
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: false, Version: "1.0.0", GitSha: "5c04681"})
|
||||
tasks := service.NewTaskService(cfg, agent, reg)
|
||||
ui, err := NewUI(nil, reg, agent, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.pageDevices(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"在线", "运行中", "local_3588_face_debug", "20260419.104457", "face_debug, production_quiet"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected device overview HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "待在详情页查看") {
|
||||
t.Fatalf("device overview should show live config summary instead of placeholder text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceOverviewUsesCompactColumns(t *testing.T) {
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/config/status" {
|
||||
t.Fatalf("unexpected agent path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"ok": true,
|
||||
"metadata": {
|
||||
"config_id": "local_3588_face_debug",
|
||||
"config_version": "20260419.104457",
|
||||
"overlays": ["face_debug"]
|
||||
},
|
||||
"media_server": {"running": true}
|
||||
}`))
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server address: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server port: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
agent := service.NewAgentClient(cfg)
|
||||
reg := service.NewRegistryService(cfg, agent)
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "d12a4719c91641df91b76ab271280797", DeviceName: "rk3588_orangepi5plus", Hostname: "orangepi5plus", IP: host, AgentPort: port, MediaPort: 9000, Online: true, Version: "0.1.0", GitSha: "5c04681"})
|
||||
tasks := service.NewTaskService(cfg, agent, reg)
|
||||
ui, err := NewUI(nil, reg, agent, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.pageDevices(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"设备", "状态", "当前配置", "操作", "orangepi5plus", "0.1.0"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected compact overview HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"最后心跳", "版本</th>", "在线</th>", "服务</th>"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device overview should not keep verbose column header %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceDetailIncludesTabs(t *testing.T) {
|
||||
@ -163,11 +280,16 @@ func TestUI_DeviceDetailIncludesTabs(t *testing.T) {
|
||||
routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"device-tabs", "返回设备列表", "节点详情", "概览", "识别配置", "模型管理", "诊断日志", "运行指标", "技术信息"} {
|
||||
for _, want := range []string{"device-tabs", "设备详情", "设备控制"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected device detail HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"返回设备总览"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device detail should not contain redundant nav %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceDetailIsReadOnly(t *testing.T) {
|
||||
@ -181,11 +303,19 @@ func TestUI_DeviceDetailIsReadOnly(t *testing.T) {
|
||||
routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01", nil))
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"视觉识别节点详情", "只读详情", "配置管理", "技术信息", "诊断日志", "运行指标"} {
|
||||
for _, want := range []string{"设备详情", "设备状态", "当前运行配置", "最近状态摘要", "技术信息"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected read-only device detail HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "去设备控制") {
|
||||
t.Fatalf("device detail should not contain redundant cross-link to control page")
|
||||
}
|
||||
for _, forbidden := range []string{"只读查看页", "权威摘要位置", "当前框架版"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device detail should not contain placeholder copy %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
`method="post"`,
|
||||
`type="file"`,
|
||||
@ -283,7 +413,7 @@ func TestUI_DeviceSubpagesIncludeContextNavigation(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
ui.render(rr, req, content, PageData{Title: "节点子页面", Device: dev})
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"device-tabs", "返回设备列表", "节点详情", "识别配置", "诊断日志", "运行指标"} {
|
||||
for _, want := range []string{"device-tabs", "设备详情", "设备控制"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected %s HTML to contain %q", content, want)
|
||||
}
|
||||
@ -334,13 +464,21 @@ func TestUI_ConfigPreviewPageShowsTemplateProfileOverlayForm(t *testing.T) {
|
||||
"workshop_face_shoe_alarm",
|
||||
"local_3588_test",
|
||||
"face_debug",
|
||||
"上传为候选配置",
|
||||
"应用候选配置",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected config preview page to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"返回节点详情", "完整 JSON</h2>"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("config preview page should not contain redundant element %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"上传为候选配置", "应用候选配置"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("config preview page should not show action %q before preview is generated", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ConfigPreviewPageKeepsApplyActionAfterUploadResult(t *testing.T) {
|
||||
@ -379,6 +517,9 @@ func TestUI_ConfigPreviewPageKeepsApplyActionAfterUploadResult(t *testing.T) {
|
||||
t.Fatalf("expected config preview upload result HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<h2>执行结果</h2>") {
|
||||
t.Fatalf("config preview page should not render duplicate result card, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionDeviceConfigCandidateKeepsPreviewApplyAction(t *testing.T) {
|
||||
@ -802,80 +943,264 @@ func TestUI_DiagnosticsPageRendersLogAndMetricLinks(t *testing.T) {
|
||||
|
||||
func TestUI_SidebarMatchesInformationArchitecture(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/dashboard", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageDashboard(rr, req)
|
||||
ui.pageDevices(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"总览",
|
||||
"设备列表",
|
||||
"配置管理",
|
||||
"新增设备",
|
||||
"模型管理",
|
||||
"识别配置",
|
||||
"日志分析",
|
||||
"高级调试",
|
||||
`href="/ui/device-config"`,
|
||||
`href="/ui/recognition"`,
|
||||
`href="/ui/logs"`,
|
||||
"设备",
|
||||
"配置资产",
|
||||
"操作审计",
|
||||
"系统",
|
||||
`href="/ui/devices"`,
|
||||
`href="/ui/assets"`,
|
||||
`href="/ui/audit"`,
|
||||
`href="/ui/system"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected sidebar to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, old := range []string{"设备节点", "添加节点", "模型部署", "日志指标", "任务中心"} {
|
||||
for _, old := range []string{"配置管理", "识别配置", "模型管理", "高级调试", "日志分析"} {
|
||||
if strings.Contains(body, old) {
|
||||
t.Fatalf("sidebar should not contain old label %q", old)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceConfigPageDefinesOpsConfigScope(t *testing.T) {
|
||||
func TestUI_LegacyDeviceConfigRedirectsToAssets(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/device-config", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageDeviceConfig(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"配置管理", "设备运行配置", "服务状态", "服务管理", "配置查看", "配置上传", "入口识别节点", "启动视频分析", "重启视频分析", "停止视频分析", "重载识别服务", "回滚识别配置"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected device config HTML to contain %q", want)
|
||||
}
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d", rr.Code)
|
||||
}
|
||||
if !strings.Contains(body, `method="post"`) {
|
||||
t.Fatalf("expected device config page to contain service operation forms")
|
||||
if got := rr.Header().Get("Location"); got != "/ui/assets" {
|
||||
t.Fatalf("expected redirect to /ui/assets, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_RecognitionPageDefinesAIScope(t *testing.T) {
|
||||
func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/recognition", nil)
|
||||
routes, err := ui.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("Routes: %v", err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageRecognition(rr, req)
|
||||
|
||||
routes.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/devices/edge-01/control", nil))
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"识别配置", "识别方案", "视频通道", "模板参数", "预览变更", "入口识别节点"} {
|
||||
for _, want := range []string{
|
||||
"设备控制",
|
||||
"配置预览",
|
||||
"配置应用",
|
||||
"服务控制",
|
||||
"执行结果摘要",
|
||||
"次要参考信息",
|
||||
`action="/ui/devices/edge-01/action"`,
|
||||
`action="/ui/devices/edge-01/config-candidate/apply"`,
|
||||
`href="/ui/devices/edge-01/config-preview"`,
|
||||
"重载服务",
|
||||
"回滚到上一份",
|
||||
"打开预览器",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected recognition HTML to contain %q", want)
|
||||
t.Fatalf("expected device control HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"框架版", "结构样机", "待接入", `disabled>`, `value="workshop_face_shoe_alarm" disabled`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device control page should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_LogsPageDefinesDiagnosticsScope(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/logs", nil)
|
||||
func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) {
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/media-server/reload":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"ok": true,
|
||||
"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
|
||||
"previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
|
||||
"media_server": {"running": true, "pid": 1810489}
|
||||
}`))
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server address: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server port: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
agent := service.NewAgentClient(cfg)
|
||||
reg := service.NewRegistryService(cfg, agent)
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
|
||||
tasks := service.NewTaskService(cfg, agent, reg)
|
||||
ui, err := NewUI(nil, reg, agent, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "reload")
|
||||
form.Set("return_to", "control")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/action", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "edge-01")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageLogs(rr, req)
|
||||
ui.actionDeviceAction(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"日志分析", "日志筛选", "诊断日志", "运行指标", "高级调试", "入口识别节点"} {
|
||||
for _, want := range []string{"设备控制", "POST /v1/media-server/reload", "执行结果摘要"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected logs HTML to contain %q", want)
|
||||
t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionDeviceConfigCandidateApplyCanRenderControlPage(t *testing.T) {
|
||||
statusCalls := 0
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
|
||||
statusCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"ok": true,
|
||||
"metadata": {"config_id": "preview_edge-01", "config_version": "v2"},
|
||||
"candidate": {"exists": false, "path": "/opt/rk3588-media-server/etc/media-server.json.candidate.json"},
|
||||
"previous_config": {"exists": true, "path": "/opt/rk3588-media-server/etc/media-server.json.last_good.json", "metadata": {"config_id": "local_3588_face_debug", "config_version": "20260419.120246"}},
|
||||
"media_server": {"running": true, "pid": 1810489}
|
||||
}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/config/candidate/apply":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
host, portText, err := net.SplitHostPort(strings.TrimPrefix(agentServer.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server address: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server port: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
agent := service.NewAgentClient(cfg)
|
||||
reg := service.NewRegistryService(cfg, agent)
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: host, AgentPort: port, MediaPort: 9000, Online: true})
|
||||
tasks := service.NewTaskService(cfg, agent, reg)
|
||||
ui, err := NewUI(nil, reg, agent, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("return_to", "control")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/edge-01/config-candidate/apply", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "edge-01")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionDeviceConfigCandidateApply(rr, req)
|
||||
|
||||
if statusCalls < 2 {
|
||||
t.Fatalf("expected status to be refreshed for control page, got %d calls", statusCalls)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"设备控制", "应用候选配置结果", "preview_edge-01", "local_3588_face_debug"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected control page apply result HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_AssetsPageDefinesConfigAssetScope(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/assets", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageAssets(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"配置资产", "模板", "环境参数", "覆盖项", "发布记录"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected assets HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
reg := service.NewRegistryService(cfg, nil)
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-01", DeviceName: "入口识别节点", IP: "127.0.0.1", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.0", GitSha: "5c04681"})
|
||||
tasks := service.NewTaskService(cfg, nil, reg)
|
||||
task, err := tasks.CreateTask("reload", []string{"edge-01"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
task.Mu.Lock()
|
||||
task.Status = models.TaskSuccess
|
||||
if ds, ok := task.Devices["edge-01"]; ok && ds != nil {
|
||||
ds.Status = models.TaskSuccess
|
||||
}
|
||||
task.Mu.Unlock()
|
||||
ui, err := NewUI(nil, reg, nil, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
|
||||
rrAudit := httptest.NewRecorder()
|
||||
ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil))
|
||||
for _, want := range []string{"操作审计", "审计记录", "谁做了什么、对哪台设备做的、结果如何", "reload", "edge-01", task.ID} {
|
||||
if !strings.Contains(rrAudit.Body.String(), want) {
|
||||
t.Fatalf("expected audit HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"框架版", "后续", `disabled`} {
|
||||
if strings.Contains(rrAudit.Body.String(), forbidden) {
|
||||
t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
rrSystem := httptest.NewRecorder()
|
||||
ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil))
|
||||
for _, want := range []string{"系统", "平台状态与访问入口", "设备发现与注册", "Agent 访问策略", "后台健康", "当前设备数", "UDP 广播发现", "/health", "/openapi.json"} {
|
||||
if !strings.Contains(rrSystem.Body.String(), want) {
|
||||
t.Fatalf("expected system HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"框架版", "后续"} {
|
||||
if strings.Contains(rrSystem.Body.String(), forbidden) {
|
||||
t.Fatalf("system HTML should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user