Compare commits
6 Commits
9f4a910594
...
2a994b7220
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a994b7220 | |||
| 2eca56e59a | |||
| 17240ac7bd | |||
| 513062f08e | |||
| c8836991c5 | |||
| 997a819f20 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -28,3 +28,4 @@ coverage.out
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
.worktrees/
|
||||
|
||||
@ -0,0 +1,298 @@
|
||||
# 设备页批量操作设计方案
|
||||
|
||||
## 目标
|
||||
|
||||
在新的后台信息架构下,为多设备统一运维提供一个清晰、低干扰、可追踪的入口。
|
||||
|
||||
这次设计不新增一级导航,不再引入新的独立“批量操作页面”,而是把多设备统一操作收纳到 `设备` 模块内部,作为设备总览页的一种工作模式。
|
||||
|
||||
设计目标有三条:
|
||||
|
||||
1. 让用户在设备总览页就能完成常见的多设备统一操作
|
||||
2. 不破坏当前已经收敛好的四个一级模块结构
|
||||
3. 保证“发起操作”和“查看执行结果”仍然职责分离
|
||||
|
||||
## 为什么不放进主菜单
|
||||
|
||||
多设备统一操作当然重要,但它不是一个独立业务域。
|
||||
|
||||
它本质上仍然属于“设备运维动作”,只是作用对象从一台设备变成多台设备。如果把它单独提成一级菜单,会立刻带来两个问题:
|
||||
|
||||
- 主菜单又开始按“动作类型”膨胀,而不是按“业务对象”组织
|
||||
- 用户会在“设备”“批量操作”“操作审计”之间反复跳转,心智重新变乱
|
||||
|
||||
因此,这个能力不应该进入主菜单。
|
||||
|
||||
最合理的位置,是把它作为 `设备总览` 页中的批量工作模式:先看设备,再选设备,再执行统一操作。
|
||||
|
||||
## 入口定位
|
||||
|
||||
入口放在 `设备` 页主列表上方,以“按需浮现”的方式出现。
|
||||
|
||||
默认状态下:
|
||||
|
||||
- 设备总览保持简洁
|
||||
- 不显示任何批量操作条
|
||||
- 用户只看到设备状态和单设备快捷入口
|
||||
|
||||
当用户勾选 1 台或多台设备后:
|
||||
|
||||
- 页面顶部出现批量操作条
|
||||
- 操作条显示已选设备数量
|
||||
- 只展示当前阶段最重要的批量动作
|
||||
|
||||
这种模式的核心价值是:
|
||||
|
||||
- 不勾选时,页面完全是总览页
|
||||
- 勾选后,页面自然切换到“批量运维模式”
|
||||
- 入口不冗余,也不会占据主导航资源
|
||||
|
||||
## 页面结构
|
||||
|
||||
`设备` 页继续保持现有主结构:
|
||||
|
||||
1. 顶部摘要区
|
||||
2. 设备列表
|
||||
|
||||
在此基础上,加入一个新的中间层:
|
||||
|
||||
3. 批量操作条
|
||||
|
||||
最终结构如下:
|
||||
|
||||
1. 顶部摘要区
|
||||
用于整体状态判断和快速筛选
|
||||
|
||||
2. 批量操作条
|
||||
仅在用户选择设备后出现
|
||||
|
||||
3. 设备列表
|
||||
负责设备选择、状态查看、进入单设备详情和控制页
|
||||
|
||||
这样可以保证页面仍然只回答一个核心问题:
|
||||
“当前有哪些设备,我要不要对其中几台一起做操作”
|
||||
|
||||
## 批量操作条设计
|
||||
|
||||
批量操作条应该是一条紧凑、稳定、图标优先的操作带,而不是新的大页面。
|
||||
|
||||
它至少包含以下元素:
|
||||
|
||||
- 已选设备数量
|
||||
- 清空选择
|
||||
- 服务操作组
|
||||
- 配置操作组
|
||||
- 发起后查看任务结果的入口
|
||||
|
||||
### 服务操作组
|
||||
|
||||
第一版只放最常用、风险最可控的统一操作:
|
||||
|
||||
- 启动服务
|
||||
- 重启服务
|
||||
- 停止服务
|
||||
- 重载服务
|
||||
|
||||
这些动作直接对应 agent 已有的多设备任务能力,概念清楚,用户也容易理解。
|
||||
|
||||
### 配置操作组
|
||||
|
||||
配置相关动作也放在这条操作带里,但第一版要严格收边界,不把它做成复杂的发布系统。
|
||||
|
||||
第一版建议只支持:
|
||||
|
||||
- 统一下发候选配置
|
||||
- 统一应用候选配置
|
||||
- 统一回滚到上一份配置
|
||||
|
||||
这里的“统一下发候选配置”不是让用户在设备总览页直接编辑大段 JSON,而是进入一个轻量的批量配置面板,在里面完成配置选择与确认。
|
||||
|
||||
## 批量配置面板
|
||||
|
||||
从设备总览页点击“统一下发候选配置”后,打开批量配置面板。
|
||||
|
||||
这个面板不是一级页面,也不是长期停留的工作区,它更适合作为抽屉或模态层,承接一次明确的配置发布动作。
|
||||
|
||||
面板只回答三个问题:
|
||||
|
||||
1. 要发给哪些设备
|
||||
2. 这次配置从哪里来
|
||||
3. 是否立即创建任务
|
||||
|
||||
### 第一版支持的配置来源
|
||||
|
||||
为了和当前的模板化配置体系保持一致,第一版只允许以下来源:
|
||||
|
||||
- 选择模板
|
||||
- 选择 profile
|
||||
- 选择一个或多个 overlay
|
||||
|
||||
必要时允许展示生成后的配置摘要,但不在这个面板里默认展开完整 JSON。
|
||||
|
||||
### 面板默认展示内容
|
||||
|
||||
默认只显示运维真正需要确认的摘要:
|
||||
|
||||
- 目标设备数量
|
||||
- 目标设备名称列表摘要
|
||||
- 模板名
|
||||
- profile 名
|
||||
- overlay 列表
|
||||
- 生成后的 `config_id`
|
||||
- 生成后的 `config_version`
|
||||
- 配置摘要 SHA
|
||||
|
||||
### 面板收纳规则
|
||||
|
||||
以下内容不在主视图大面积展开:
|
||||
|
||||
- 完整 JSON
|
||||
- 原始文件路径
|
||||
- 渲染细节
|
||||
- 原始响应体
|
||||
|
||||
这些内容如果要看,应进入折叠区或技术详情抽屉。
|
||||
|
||||
## 结果呈现与职责分离
|
||||
|
||||
批量操作发起后,不在设备总览页里展开每台设备的完整执行日志。
|
||||
|
||||
职责划分必须明确:
|
||||
|
||||
- `设备` 页负责发起批量动作
|
||||
- `操作审计` 负责查看任务执行过程和结果
|
||||
|
||||
因此,批量操作后的结果呈现只分两层:
|
||||
|
||||
### 第一层:设备页即时反馈
|
||||
|
||||
用户发起操作后,设备页顶部给出简洁反馈:
|
||||
|
||||
- 任务已创建
|
||||
- 任务类型
|
||||
- 目标设备数
|
||||
- 当前任务状态摘要
|
||||
|
||||
并提供一个明确入口进入任务详情或操作审计页。
|
||||
|
||||
### 第二层:审计页详细结果
|
||||
|
||||
详细结果统一在任务详情页或操作审计页中查看,包括:
|
||||
|
||||
- 每台设备是否执行成功
|
||||
- 调用了哪个 agent 接口
|
||||
- 返回了什么结果
|
||||
- 哪台设备失败
|
||||
- 失败原因是什么
|
||||
|
||||
这样可以避免总览页被“执行细节”淹没。
|
||||
|
||||
## 与现有能力接口的关系
|
||||
|
||||
这次设计建立在当前已经存在的多设备任务能力之上,不需要重新发明一套后端模型。
|
||||
|
||||
当前能力层已具备的统一操作包括:
|
||||
|
||||
- `media_start`
|
||||
- `media_restart`
|
||||
- `media_stop`
|
||||
- `reload`
|
||||
- `rollback`
|
||||
- `config_apply`
|
||||
|
||||
因此这次实现重点不是重写任务系统,而是把这些能力纳入新的主线 UI:
|
||||
|
||||
- 让批量服务操作成为设备页的正式入口
|
||||
- 让批量配置下发有明确的配置选择面板
|
||||
- 让任务结果自然落到操作审计中
|
||||
|
||||
## 状态与可用性规则
|
||||
|
||||
批量操作必须遵守一套简单、稳定的可用性规则,避免页面显得“什么都能点”。
|
||||
|
||||
### 设备选择规则
|
||||
|
||||
- 未选择设备时,不显示批量操作条
|
||||
- 只选择 1 台设备时,也允许使用批量操作条,但视觉上不强调“批量”,避免用户困惑
|
||||
- 选择多台设备时,明确显示“已选 N 台”
|
||||
|
||||
### 动作可用性规则
|
||||
|
||||
- 服务类动作始终可见,但在无设备选中时不显示
|
||||
- 配置“应用候选配置”和“回滚到上一份配置”允许直接发起
|
||||
- “统一下发候选配置”必须先完成配置选择与确认
|
||||
|
||||
### 反馈规则
|
||||
|
||||
- 任务创建成功后,立即给出任务摘要
|
||||
- 任务创建失败时,给出简洁错误,不直接把原始技术响应堆在主页面
|
||||
- 技术细节进入折叠区或审计页
|
||||
|
||||
## 不做的事情
|
||||
|
||||
为了控制范围,第一版明确不做下面这些内容:
|
||||
|
||||
- 不新增一级菜单“批量操作”
|
||||
- 不做独立的“大屏式任务调度中心”
|
||||
- 不在设备总览页直接编辑完整 JSON
|
||||
- 不在设备总览页展示每台设备的详细执行日志
|
||||
- 不在第一版中加入复杂的发布审批、分批灰度、自动回滚策略
|
||||
|
||||
这些都可能以后再做,但不属于当前最短路径。
|
||||
|
||||
## 实现边界
|
||||
|
||||
这次实现建议分两步:
|
||||
|
||||
### 第一步:设备页批量服务操作正式化
|
||||
|
||||
包括:
|
||||
|
||||
- 设备列表支持稳定多选
|
||||
- 出现批量操作条
|
||||
- 接入统一的服务操作任务创建
|
||||
- 操作后跳到任务详情或审计入口
|
||||
|
||||
### 第二步:批量配置下发正式化
|
||||
|
||||
包括:
|
||||
|
||||
- 新增批量配置面板
|
||||
- 以模板 + profile + overlay 生成配置摘要
|
||||
- 创建 `config_apply` 任务
|
||||
- 在任务视图中查看各设备结果
|
||||
|
||||
这样能先把最稳的能力做出来,再接配置下发,不会把第一版拖得过大。
|
||||
|
||||
## 验证方式
|
||||
|
||||
本设计的验证分为两类:
|
||||
|
||||
### 本地验证
|
||||
|
||||
- `设备` 页在未选择设备时保持干净
|
||||
- 选择设备后出现批量操作条
|
||||
- 批量服务操作能成功创建任务
|
||||
- 批量配置面板能正确展示模板 / profile / overlay 摘要
|
||||
- 成功和失败反馈不会在总览页造成信息堆积
|
||||
|
||||
### 运行侧验证
|
||||
|
||||
需要在真实 agent 环境下确认:
|
||||
|
||||
- 多设备服务操作是否按预期逐台执行
|
||||
- 批量配置下发到不同设备时是否正确到达 agent
|
||||
- 任务结果与审计记录是否一致
|
||||
- UI 摘要是否能正确反映各设备执行状态
|
||||
|
||||
## 最终结论
|
||||
|
||||
多设备统一操作应该是 `设备` 页里的批量工作模式,而不是主菜单一级页面。
|
||||
|
||||
这条设计路径有三个优点:
|
||||
|
||||
1. 不破坏已经稳定下来的四个一级模块
|
||||
2. 让用户的操作路径保持自然:看设备、选设备、做操作
|
||||
3. 保证总览、操作、审计三者边界清楚,不再到处重复显示类似信息
|
||||
|
||||
这也是当前最符合本项目规模和运维目标的方案。
|
||||
@ -40,26 +40,30 @@ type PageData struct {
|
||||
OfflineCount int
|
||||
FoundCount int
|
||||
|
||||
Devices []*models.Device
|
||||
DeviceRows []DeviceOverviewRow
|
||||
AttentionDevices []*models.Device
|
||||
Found []*models.Device
|
||||
Device *models.Device
|
||||
ConfigStatus *ConfigStatusView
|
||||
ConfigStatusText string
|
||||
ConfigStatusErr string
|
||||
ConfigSources service.ConfigPreviewSources
|
||||
ConfigPreview *service.ConfigPreviewResult
|
||||
ResultTitle string
|
||||
SelectedTemplate string
|
||||
SelectedProfile string
|
||||
SelectedOverlays []string
|
||||
SelectedConfigID string
|
||||
SelectedVersion string
|
||||
Tasks []models.Task
|
||||
Task *models.Task
|
||||
Templates []service.Template
|
||||
Template *service.Template
|
||||
Devices []*models.Device
|
||||
DeviceRows []DeviceOverviewRow
|
||||
AttentionDevices []*models.Device
|
||||
Found []*models.Device
|
||||
Device *models.Device
|
||||
ConfigStatus *ConfigStatusView
|
||||
ConfigStatusText string
|
||||
ConfigStatusErr string
|
||||
ConfigSources service.ConfigPreviewSources
|
||||
ConfigPreview *service.ConfigPreviewResult
|
||||
ResultTitle string
|
||||
SelectedTemplate string
|
||||
SelectedProfile string
|
||||
SelectedOverlays []string
|
||||
SelectedConfigID string
|
||||
SelectedVersion string
|
||||
Tasks []models.Task
|
||||
Task *models.Task
|
||||
TaskDeviceRows []TaskDeviceRow
|
||||
Templates []service.Template
|
||||
Template *service.Template
|
||||
SelectedDeviceIDs []string
|
||||
SelectedDevices []*models.Device
|
||||
SelectedQuery string
|
||||
|
||||
RawJSON string
|
||||
RawText string
|
||||
@ -79,6 +83,13 @@ type DeviceOverviewRow struct {
|
||||
ConfigStatusErr string
|
||||
}
|
||||
|
||||
type TaskDeviceRow struct {
|
||||
Device *models.Device
|
||||
Status models.TaskStatus
|
||||
Progress float64
|
||||
Error string
|
||||
}
|
||||
|
||||
type ConfigStatusView struct {
|
||||
OK bool `json:"ok"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
@ -136,6 +147,72 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
||||
}
|
||||
return v
|
||||
},
|
||||
"taskGroupLabel": func(v any) string {
|
||||
switch fmt.Sprint(v) {
|
||||
case "config_apply":
|
||||
return "批量配置"
|
||||
case "media_start", "media_restart", "media_stop":
|
||||
return "批量服务"
|
||||
case "reload", "rollback":
|
||||
return "设备操作"
|
||||
default:
|
||||
return "其他任务"
|
||||
}
|
||||
},
|
||||
"taskActionLabel": func(v any) string {
|
||||
switch fmt.Sprint(v) {
|
||||
case "config_apply":
|
||||
return "下发识别配置"
|
||||
case "reload":
|
||||
return "重载识别服务"
|
||||
case "rollback":
|
||||
return "回滚识别配置"
|
||||
case "media_start":
|
||||
return "启动视频分析服务"
|
||||
case "media_restart":
|
||||
return "重启视频分析服务"
|
||||
case "media_stop":
|
||||
return "停止视频分析服务"
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
},
|
||||
"taskGroupClass": func(v any) string {
|
||||
switch fmt.Sprint(v) {
|
||||
case "config_apply":
|
||||
return "pill run"
|
||||
case "media_start", "media_restart", "media_stop":
|
||||
return "pill ok"
|
||||
case "reload", "rollback":
|
||||
return "pill warn"
|
||||
default:
|
||||
return "pill"
|
||||
}
|
||||
},
|
||||
"taskStatusLabel": func(v any) string {
|
||||
switch fmt.Sprint(v) {
|
||||
case "success":
|
||||
return "成功"
|
||||
case "failed":
|
||||
return "失败"
|
||||
case "running":
|
||||
return "执行中"
|
||||
default:
|
||||
return "待执行"
|
||||
}
|
||||
},
|
||||
"taskStatusClass": func(v any) string {
|
||||
switch fmt.Sprint(v) {
|
||||
case "success":
|
||||
return "pill ok"
|
||||
case "failed":
|
||||
return "pill bad"
|
||||
case "running":
|
||||
return "pill run"
|
||||
default:
|
||||
return "pill"
|
||||
}
|
||||
},
|
||||
"ago": func(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return "-"
|
||||
@ -177,29 +254,29 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
||||
|
||||
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>`,
|
||||
"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>`,
|
||||
"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 {
|
||||
@ -243,6 +320,8 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Get("/devices-add", u.pageDeviceAdd)
|
||||
r.Post("/devices-add", u.actionDeviceAdd)
|
||||
r.Post("/devices/batch-action", u.actionDevicesBatchAction)
|
||||
r.Get("/devices/batch-config", u.pageDeviceBatchConfig)
|
||||
r.Post("/devices/batch-config", u.actionDeviceBatchConfig)
|
||||
r.Post("/discovery/search", u.actionDiscoverySearch)
|
||||
r.Get("/devices/{id}", u.pageDevice)
|
||||
r.Post("/devices/{id}/action", u.actionDeviceAction)
|
||||
@ -329,46 +408,7 @@ func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
|
||||
u.ensureDevicesLoaded()
|
||||
devices := u.registry.GetDevices()
|
||||
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
|
||||
attention := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
online++
|
||||
} else {
|
||||
attention++
|
||||
}
|
||||
}
|
||||
failedTasks := 0
|
||||
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,
|
||||
DeviceRows: rows,
|
||||
DeviceCount: len(devices),
|
||||
OnlineCount: online,
|
||||
OfflineCount: len(devices) - online,
|
||||
RunningTaskCount: 0,
|
||||
FailedTaskCount: failedTasks,
|
||||
FoundCount: attention,
|
||||
})
|
||||
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, ""))
|
||||
}
|
||||
|
||||
func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@ -436,16 +476,9 @@ func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) {
|
||||
func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
action := strings.TrimSpace(r.FormValue("action"))
|
||||
deviceIDs := r.Form["device_id"]
|
||||
deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
|
||||
if len(deviceIDs) == 0 {
|
||||
devices := u.registry.GetDevices()
|
||||
online := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
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", u.deviceOverviewPageData(r, nil, "请先选择设备"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -454,14 +487,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
case "media_start", "media_restart", "media_stop", "reload", "rollback":
|
||||
typeStr = action
|
||||
default:
|
||||
devices := u.registry.GetDevices()
|
||||
online := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
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", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action))
|
||||
return
|
||||
}
|
||||
|
||||
@ -480,14 +506,77 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
|
||||
if err != nil {
|
||||
devices := u.registry.GetDevices()
|
||||
online := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
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", u.deviceOverviewPageData(r, deviceIDs, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
|
||||
}
|
||||
|
||||
func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"]))
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
}
|
||||
|
||||
func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
|
||||
req := service.ConfigPreviewRequest{
|
||||
Template: strings.TrimSpace(r.FormValue("template")),
|
||||
Profile: strings.TrimSpace(r.FormValue("profile")),
|
||||
Overlays: cleanFormList(r.Form["overlay"]),
|
||||
ConfigID: strings.TrimSpace(r.FormValue("config_id")),
|
||||
ConfigVersion: strings.TrimSpace(r.FormValue("config_version")),
|
||||
}
|
||||
data := u.deviceBatchConfigPageData(r, selectedIDs)
|
||||
if req.Template != "" {
|
||||
data.SelectedTemplate = req.Template
|
||||
}
|
||||
if req.Profile != "" {
|
||||
data.SelectedProfile = req.Profile
|
||||
}
|
||||
data.SelectedOverlays = append([]string(nil), req.Overlays...)
|
||||
data.SelectedConfigID = req.ConfigID
|
||||
if req.ConfigVersion != "" {
|
||||
data.SelectedVersion = req.ConfigVersion
|
||||
}
|
||||
|
||||
if len(selectedIDs) == 0 {
|
||||
data.Error = "请先选择需要下发配置的设备"
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
if req.Template == "" {
|
||||
req.Template = data.SelectedTemplate
|
||||
}
|
||||
if req.Profile == "" {
|
||||
req.Profile = data.SelectedProfile
|
||||
}
|
||||
if u.tasks == nil {
|
||||
data.Error = "task service not initialized"
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := u.preview.Render(req)
|
||||
data.ConfigPreview = preview
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
|
||||
var configDoc any
|
||||
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
|
||||
data.Error = "生成配置 JSON 无效: " + err.Error()
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"config": configDoc})
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
u.render(w, r, "device_batch_config", data)
|
||||
return
|
||||
}
|
||||
|
||||
@ -756,7 +845,42 @@ func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *
|
||||
}
|
||||
|
||||
func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()})
|
||||
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()})
|
||||
}
|
||||
|
||||
func (u *UI) taskPageData(task *models.Task) PageData {
|
||||
data := PageData{Title: "任务详情", Task: task}
|
||||
if task == nil {
|
||||
return data
|
||||
}
|
||||
|
||||
devices := make(map[string]*models.Device)
|
||||
if u.registry != nil {
|
||||
for _, dev := range u.registry.GetDevices() {
|
||||
if dev == nil {
|
||||
continue
|
||||
}
|
||||
devices[dev.DeviceID] = dev
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs))
|
||||
for _, did := range task.DeviceIDs {
|
||||
row := TaskDeviceRow{}
|
||||
if dev := devices[did]; dev != nil {
|
||||
row.Device = dev
|
||||
} else {
|
||||
row.Device = &models.Device{DeviceID: did}
|
||||
}
|
||||
if ds := task.Devices[did]; ds != nil {
|
||||
row.Status = ds.Status
|
||||
row.Progress = ds.Progress
|
||||
row.Error = ds.Error
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
data.TaskDeviceRows = rows
|
||||
return data
|
||||
}
|
||||
|
||||
func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
@ -779,13 +903,13 @@ func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
return
|
||||
}
|
||||
|
||||
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
|
||||
if err != nil {
|
||||
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
|
||||
@ -806,7 +930,9 @@ func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
u.render(w, r, "task", PageData{Title: "任务详情", Task: task, TaskID: id})
|
||||
data := u.taskPageData(task)
|
||||
data.TaskID = id
|
||||
u.render(w, r, "task", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
@ -1139,6 +1265,158 @@ func cleanFormList(values []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedIDsFromQuery(values []string) []string {
|
||||
values = cleanFormList(values)
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterSelectedDeviceIDs(devices []*models.Device, candidates []string) []string {
|
||||
if len(candidates) == 0 || len(devices) == 0 {
|
||||
return nil
|
||||
}
|
||||
known := make(map[string]struct{}, len(devices))
|
||||
for _, dev := range devices {
|
||||
if dev == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(dev.DeviceID)
|
||||
if id != "" {
|
||||
known[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, id := range candidates {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := known[id]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedQueryString(ids []string) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
values := url.Values{}
|
||||
for _, id := range ids {
|
||||
values.Add("selected", id)
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMsg string) PageData {
|
||||
u.ensureDevicesLoaded()
|
||||
devices := u.registry.GetDevices()
|
||||
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
|
||||
attention := 0
|
||||
for _, d := range devices {
|
||||
if d.Online {
|
||||
online++
|
||||
} else {
|
||||
attention++
|
||||
}
|
||||
}
|
||||
failedTasks := 0
|
||||
if u.tasks != nil {
|
||||
for _, t := range u.tasks.ListTasks() {
|
||||
if t.Status == models.TaskFailed {
|
||||
failedTasks++
|
||||
}
|
||||
}
|
||||
}
|
||||
if selectedIDs == nil {
|
||||
selectedIDs = selectedIDsFromQuery(r.URL.Query()["selected"])
|
||||
}
|
||||
selectedIDs = filterSelectedDeviceIDs(devices, selectedIDs)
|
||||
data := PageData{
|
||||
Title: "设备",
|
||||
Devices: devices,
|
||||
DeviceRows: rows,
|
||||
DeviceCount: len(devices),
|
||||
OnlineCount: online,
|
||||
OfflineCount: len(devices) - online,
|
||||
RunningTaskCount: 0,
|
||||
FailedTaskCount: failedTasks,
|
||||
FoundCount: attention,
|
||||
SelectedDeviceIDs: selectedIDs,
|
||||
SelectedQuery: selectedQueryString(selectedIDs),
|
||||
}
|
||||
if errMsg != "" {
|
||||
data.Error = errMsg
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData {
|
||||
data := u.deviceOverviewPageData(r, selectedIDs, "")
|
||||
sources, err := u.preview.ListSources()
|
||||
data.Title = "批量配置"
|
||||
data.ConfigSources = sources
|
||||
data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs)
|
||||
data.SelectedTemplate = "workshop_face_shoe_alarm"
|
||||
data.SelectedProfile = "local_3588_test"
|
||||
data.SelectedOverlays = []string{"face_debug"}
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func selectedDevicesFromIDs(devices []*models.Device, ids []string) []*models.Device {
|
||||
if len(devices) == 0 || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
byID := make(map[string]*models.Device, len(devices))
|
||||
for _, dev := range devices {
|
||||
if dev == nil {
|
||||
continue
|
||||
}
|
||||
byID[strings.TrimSpace(dev.DeviceID)] = dev
|
||||
}
|
||||
selected := make([]*models.Device, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if dev := byID[strings.TrimSpace(id)]; dev != nil {
|
||||
selected = append(selected, dev)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func previewResultFromJSON(raw string) *service.ConfigPreviewResult {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@ -104,9 +104,12 @@ tbody tr:hover{background:#f9fafb}
|
||||
.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.run{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
|
||||
.pill.warn{background:#fffbeb;border-color:#fde68a;color:#92400e}
|
||||
|
||||
.actions{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.actions.compact{gap:6px}
|
||||
.actions.compact .btn,.actions.compact button{padding:5px 9px;font-size:11px}
|
||||
.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}
|
||||
@ -151,6 +154,13 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back
|
||||
.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}
|
||||
|
||||
.batch-toolbar{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:14px 16px;border:1px solid var(--border);border-radius:8px;background:var(--surface-soft);margin:0 0 12px}
|
||||
.batch-toolbar-count{font-size:13px;font-weight:600}
|
||||
.batch-toolbar .actions{justify-content:flex-end}
|
||||
.batch-toolbar .actions .btn,.batch-toolbar .actions button{white-space:nowrap}
|
||||
.select-cell{width:52px;text-align:center;vertical-align:middle}
|
||||
.select-cell input[type=checkbox]{width:16px;height:16px;margin:0;accent-color:var(--primary)}
|
||||
|
||||
@media (max-width:1024px){
|
||||
.app-shell{grid-template-columns:1fr}
|
||||
.sidebar{position:relative;height:auto}
|
||||
@ -158,4 +168,5 @@ pre{margin-top:12px;padding:12px;border-radius:8px;border:1px solid #1f2937;back
|
||||
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}
|
||||
.batch-toolbar{flex-direction:column}
|
||||
}
|
||||
|
||||
@ -18,9 +18,9 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>任务</th>
|
||||
<th>动作</th>
|
||||
<th>目标设备</th>
|
||||
<th>目标设备数</th>
|
||||
<th>结果</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
@ -28,10 +28,15 @@
|
||||
<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>
|
||||
<span class="{{taskGroupClass .Type}}">{{taskGroupLabel .Type}}</span>
|
||||
<div class="muted small" style="margin-top:4px">{{taskActionLabel .Type}}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="mono">{{.ID}}</div>
|
||||
</td>
|
||||
<td>{{len .DeviceIDs}} 台</td>
|
||||
<td><span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span></td>
|
||||
<td class="muted small">{{if .Payload}}已记录任务参数{{else}}无附加参数{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
|
||||
110
internal/web/ui/templates/device_batch_config.html
Normal file
110
internal/web/ui/templates/device_batch_config.html
Normal file
@ -0,0 +1,110 @@
|
||||
{{define "device_batch_config"}}
|
||||
<section class="hero-band">
|
||||
<div>
|
||||
<div class="eyebrow">批量配置</div>
|
||||
<h2>用模板化配置驱动一批设备</h2>
|
||||
<div class="muted">先确认目标设备,再选择模板、Profile 和 Overlay,生成后直接进入批量下发任务。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "devices"}}<span>已选设备</span></h2>
|
||||
<div class="muted small">已选 {{len .SelectedDeviceIDs}} 台设备,将按当前选择顺序创建任务。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn ghost" href="/ui/devices?{{.SelectedQuery}}#batch-config">返回设备列表</a>
|
||||
<a class="btn ghost" href="/ui/devices">重新选择</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
{{range .SelectedDevices}}
|
||||
<div>
|
||||
<span>{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</span>
|
||||
<strong class="mono">{{.DeviceID}}</strong>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="full">
|
||||
<span>目标设备</span>
|
||||
<strong>还没有选中设备</strong>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "config"}}<span>批量配置</span></h2>
|
||||
<div class="muted small">保持模板化配置路线,不在这里直接维护完整 JSON。</div>
|
||||
</div>
|
||||
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-config">
|
||||
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
|
||||
<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>
|
||||
</label>
|
||||
<label><span>Profile</span>
|
||||
<select name="profile">
|
||||
{{range .ConfigSources.Profiles}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" placeholder="留空自动生成" /></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 class="actions">
|
||||
<button type="submit">创建批量下发任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "preview"}}<span>预览摘要</span></h2>
|
||||
<div class="muted small">{{if .ConfigPreview}}默认只展示配置生成关键信息。完整 JSON 在下方折叠区。{{else}}先选择模板化参数并提交,页面会在这里展示配置生成关键信息。{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>模板</span><strong>{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "template"}}{{else}}{{.SelectedTemplate}}{{end}}</strong></div>
|
||||
<div><span>Profile</span><strong>{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "profile"}}{{else}}{{.SelectedProfile}}{{end}}</strong></div>
|
||||
<div><span>Overlay</span><strong class="mono">{{if .ConfigPreview}}{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{else}}{{if .SelectedOverlays}}{{range $i, $name := .SelectedOverlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}{{end}}</strong></div>
|
||||
<div><span>目标设备</span><strong>{{len .SelectedDeviceIDs}} 台</strong></div>
|
||||
<div><span>config_id</span><strong class="mono">{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_id"}}{{else}}{{if .SelectedConfigID}}{{.SelectedConfigID}}{{else}}自动生成{{end}}{{end}}</strong></div>
|
||||
<div><span>config_version</span><strong class="mono">{{if .ConfigPreview}}{{index .ConfigPreview.Metadata "config_version"}}{{else}}{{if .SelectedVersion}}{{.SelectedVersion}}{{else}}自动生成{{end}}{{end}}</strong></div>
|
||||
{{if .ConfigPreview}}
|
||||
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
|
||||
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ConfigPreview}}
|
||||
<details class="card collapsible">
|
||||
<summary class="title-with-icon">{{icon "tech"}}<span>完整 JSON</span></summary>
|
||||
<pre>{{.ConfigPreview.JSON}}</pre>
|
||||
</details>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@ -41,88 +41,112 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="device-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备</th>
|
||||
<th>状态</th>
|
||||
<th>当前配置</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .DeviceRows}}
|
||||
<tr>
|
||||
<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}}
|
||||
<form method="post" action="/ui/devices/batch-action">
|
||||
{{if .SelectedDeviceIDs}}
|
||||
<div class="batch-toolbar" id="batch-config">
|
||||
<div>
|
||||
<div class="batch-toolbar-count">已选 {{len .SelectedDeviceIDs}} 台</div>
|
||||
<div class="muted small">选择后可以对这批设备统一执行服务操作,或进入模板化批量配置。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button type="submit" name="action" value="media_restart">重启服务</button>
|
||||
<button type="submit" name="action" value="media_start">启动服务</button>
|
||||
<button type="submit" name="action" value="media_stop">停止服务</button>
|
||||
<button type="submit" name="action" value="reload">重载服务</button>
|
||||
<a class="btn ghost" href="/ui/devices/batch-config?{{.SelectedQuery}}">批量配置</a>
|
||||
<a class="btn ghost" href="/ui/devices">清空选择</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="device-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:52px">选中</th>
|
||||
<th>设备</th>
|
||||
<th>状态</th>
|
||||
<th>当前配置</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .DeviceRows}}
|
||||
<tr>
|
||||
<td class="select-cell">
|
||||
<input type="checkbox" name="device_id" value="{{.Device.DeviceID}}" {{if hasString $.SelectedDeviceIDs .Device.DeviceID}}checked{{end}} />
|
||||
</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>
|
||||
</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>
|
||||
</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}}
|
||||
<span class="pill bad">未知</span>
|
||||
<div class="muted small">暂无配置摘要</div>
|
||||
{{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 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="5">
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">还没有设备</div>
|
||||
<div class="muted">当前后台还没有发现或录入任何设备。</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const input = document.getElementById('device-filter');
|
||||
const table = document.getElementById('device-list');
|
||||
const selectedBoxes = table ? table.querySelectorAll('input[type="checkbox"][name="device_id"]') : [];
|
||||
if (!input || !table) return;
|
||||
input.addEventListener('input', () => {
|
||||
const q = (input.value || '').trim().toLowerCase();
|
||||
@ -131,6 +155,20 @@
|
||||
row.style.display = (!q || text.includes(q)) ? '' : 'none';
|
||||
}
|
||||
});
|
||||
const syncSelected = () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('selected');
|
||||
for (const box of selectedBoxes) {
|
||||
if (box.checked) {
|
||||
url.searchParams.append('selected', box.value);
|
||||
}
|
||||
}
|
||||
const next = `${url.pathname}${url.searchParams.toString() ? `?${url.searchParams.toString()}` : ''}`;
|
||||
window.location.assign(next);
|
||||
};
|
||||
for (const box of selectedBoxes) {
|
||||
box.addEventListener('change', syncSelected);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@ -1,58 +1,64 @@
|
||||
{{define "task"}}
|
||||
<div class="card">
|
||||
<div><a href="/ui/tasks">返回任务中心</a></div>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>任务执行详情</h2>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div>
|
||||
<div class="muted small">任务标识</div>
|
||||
<div class="mono"><b>{{.Task.ID}}</b></div>
|
||||
<h2>任务详情</h2>
|
||||
<div class="summary-strip control-summary" style="margin-top:10px">
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">任务标识</div>
|
||||
<div class="summary-chip-value mono">{{.Task.ID}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">操作</div>
|
||||
<div>
|
||||
{{if eq .Task.Type "config_apply"}}下发识别配置
|
||||
{{else if eq .Task.Type "reload"}}重载识别服务
|
||||
{{else if eq .Task.Type "rollback"}}回滚识别配置
|
||||
{{else if eq .Task.Type "media_start"}}启动视频分析服务
|
||||
{{else if eq .Task.Type "media_restart"}}重启视频分析服务
|
||||
{{else if eq .Task.Type "media_stop"}}停止视频分析服务
|
||||
{{else}}<span class="mono">{{.Task.Type}}</span>{{end}}
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">任务类型</div>
|
||||
<div class="summary-chip-value">
|
||||
<span class="{{taskGroupClass .Task.Type}}">{{taskGroupLabel .Task.Type}}</span>
|
||||
<div class="muted small" style="margin-top:4px">{{taskActionLabel .Task.Type}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">状态</div>
|
||||
<div>
|
||||
{{if eq .Task.Status "success"}}<span class="pill ok">成功</span>
|
||||
{{else if eq .Task.Status "failed"}}<span class="pill bad">失败</span>
|
||||
{{else if eq .Task.Status "running"}}<span class="pill run">执行中</span>
|
||||
{{else}}<span class="pill">待执行</span>{{end}}
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">目标设备数</div>
|
||||
<div class="summary-chip-value">{{len .Task.DeviceIDs}} 台</div>
|
||||
</div>
|
||||
<div class="summary-chip">
|
||||
<div class="summary-chip-label">当前状态</div>
|
||||
<div class="summary-chip-value" id="task-status-value" data-task-status="{{.Task.Status}}">
|
||||
<span class="{{taskStatusClass .Task.Status}}">{{taskStatusLabel .Task.Status}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>节点执行情况</h2>
|
||||
<h2>设备结果表</h2>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>节点标识</th><th>状态</th><th>进度</th><th>失败原因</th></tr>
|
||||
<tr><th>设备</th><th>状态</th><th>进度</th><th>失败原因</th></tr>
|
||||
</thead>
|
||||
<tbody id="devices-body">
|
||||
{{range $id, $st := .Task.Devices}}
|
||||
<tr data-device-id="{{$id}}">
|
||||
<td class="mono">{{$id}}</td>
|
||||
<td class="st">
|
||||
{{if eq $st.Status "success"}}<span class="pill ok">成功</span>
|
||||
{{else if eq $st.Status "failed"}}<span class="pill bad">失败</span>
|
||||
{{else if eq $st.Status "running"}}<span class="pill run">执行中</span>
|
||||
{{else}}<span class="pill">待执行</span>{{end}}
|
||||
{{range .TaskDeviceRows}}
|
||||
<tr data-device-id="{{.Device.DeviceID}}" data-status="{{.Status}}">
|
||||
<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">
|
||||
<span class="mono">{{.Device.DeviceID}}</span>
|
||||
{{if .Device.IP}}<span class="mono">{{.Device.IP}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="pg mono">{{$st.Progress}}</td>
|
||||
<td class="er">{{$st.Error}}</td>
|
||||
<td class="st">
|
||||
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
|
||||
</td>
|
||||
<td class="pg mono">{{.Progress}}</td>
|
||||
<td class="er">{{.Error}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
@ -62,8 +68,8 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>实时进度</h2>
|
||||
<div class="muted small">任务执行过程中会持续推送每台节点的状态变化。</div>
|
||||
<div class="actions" style="margin-top:8px">
|
||||
<div class="muted small">任务执行过程中会持续推送每台设备的状态变化。</div>
|
||||
<div class="actions compact" style="margin-top:8px">
|
||||
<button type="button" onclick="startSSE()">连接</button>
|
||||
<button type="button" onclick="stopSSE()">断开</button>
|
||||
<span class="muted small" id="sse-status">未连接</span>
|
||||
@ -73,12 +79,38 @@
|
||||
|
||||
<script>
|
||||
let es;
|
||||
function pill(status){
|
||||
function statusSummary(status){
|
||||
if(status === 'success') return '<span class="pill ok">成功</span>';
|
||||
if(status === 'failed') return '<span class="pill bad">失败</span>';
|
||||
if(status === 'running') return '<span class="pill run">执行中</span>';
|
||||
return '<span class="pill">待执行</span>';
|
||||
}
|
||||
function syncTaskStatus(){
|
||||
const rows = Array.from(document.querySelectorAll('#devices-body tr[data-device-id]'));
|
||||
if(!rows.length) return;
|
||||
const statuses = rows.map((row) => row.getAttribute('data-status') || 'pending');
|
||||
const initial = document.getElementById('task-status-value')?.getAttribute('data-task-status') || 'pending';
|
||||
let next = 'pending';
|
||||
if (statuses.some((s) => s === 'failed')) {
|
||||
next = 'failed';
|
||||
} else if (statuses.every((s) => s === 'success')) {
|
||||
next = 'success';
|
||||
} else if (statuses.some((s) => s === 'running')) {
|
||||
next = 'running';
|
||||
} else if (statuses.some((s) => s === 'success')) {
|
||||
next = initial === 'running' ? 'running' : 'pending';
|
||||
} else {
|
||||
next = initial;
|
||||
}
|
||||
const target = document.getElementById('task-status-value');
|
||||
if (target) {
|
||||
target.setAttribute('data-task-status', next);
|
||||
target.innerHTML = statusSummary(next);
|
||||
}
|
||||
}
|
||||
function pill(status){
|
||||
return statusSummary(status);
|
||||
}
|
||||
function startSSE(){
|
||||
stopSSE();
|
||||
const url = `/api/tasks/{{.TaskID}}/events`;
|
||||
@ -95,9 +127,11 @@ function startSSE(){
|
||||
const u = JSON.parse(line);
|
||||
const tr = document.querySelector(`tr[data-device-id="${cssEscape(u.device_id)}"]`);
|
||||
if(tr){
|
||||
tr.setAttribute('data-status', u.status || 'pending');
|
||||
tr.querySelector('.st').innerHTML = pill(u.status);
|
||||
tr.querySelector('.pg').textContent = u.progress;
|
||||
tr.querySelector('.er').textContent = u.error || '';
|
||||
syncTaskStatus();
|
||||
}
|
||||
} catch(e){}
|
||||
});
|
||||
@ -109,5 +143,6 @@ function stopSSE(){
|
||||
function cssEscape(s){
|
||||
return String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => "\\"+c);
|
||||
}
|
||||
syncTaskStatus();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{{define "tasks"}}
|
||||
<div class="card">
|
||||
<h2>创建批量运维任务</h2>
|
||||
<h2>创建任务</h2>
|
||||
<div class="muted small">
|
||||
用于向多台边缘节点下发识别配置、重载服务、回滚配置或控制视频分析服务。
|
||||
</div>
|
||||
@ -33,32 +33,26 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>任务中心</h2>
|
||||
<h2>任务列表</h2>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>任务标识</th><th>操作</th><th>状态</th><th>节点数</th></tr>
|
||||
<tr><th>任务</th><th>类型</th><th>目标设备数</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Tasks}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>
|
||||
{{if eq .Type "config_apply"}}下发识别配置
|
||||
{{else if eq .Type "reload"}}重载识别服务
|
||||
{{else if eq .Type "rollback"}}回滚识别配置
|
||||
{{else if eq .Type "media_start"}}启动视频分析服务
|
||||
{{else if eq .Type "media_restart"}}重启视频分析服务
|
||||
{{else if eq .Type "media_stop"}}停止视频分析服务
|
||||
{{else}}<span class="mono">{{.Type}}</span>{{end}}
|
||||
<div><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></div>
|
||||
<div class="muted small">{{taskActionLabel .Type}}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Status "success"}}<span class="pill ok">成功</span>
|
||||
{{else if eq .Status "failed"}}<span class="pill bad">失败</span>
|
||||
{{else if eq .Status "running"}}<span class="pill run">执行中</span>
|
||||
{{else}}<span class="pill">待执行</span>{{end}}
|
||||
<span class="{{taskGroupClass .Type}}">{{taskGroupLabel .Type}}</span>
|
||||
</td>
|
||||
<td>{{len .DeviceIDs}} 台</td>
|
||||
<td>
|
||||
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
|
||||
</td>
|
||||
<td>{{len .DeviceIDs}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -50,6 +51,61 @@ func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionDevicesBatchActionDeduplicatesKnownDevices(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "reload")
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-02")
|
||||
form.Add("device_id", "missing")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionDevicesBatchAction(rr, req)
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/ui/tasks/") {
|
||||
t.Fatalf("expected redirect to task page, got %q", loc)
|
||||
}
|
||||
|
||||
taskID := strings.TrimPrefix(loc, "/ui/tasks/")
|
||||
items := ui.tasks.ListTasks()
|
||||
var task *models.Task
|
||||
for i := range items {
|
||||
if items[i].ID == taskID {
|
||||
t := items[i]
|
||||
task = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
if task == nil {
|
||||
t.Fatalf("expected task %s to exist", taskID)
|
||||
}
|
||||
if got := len(task.DeviceIDs); got != 2 {
|
||||
t.Fatalf("expected deduplicated device count 2, got %d: %#v", got, task.DeviceIDs)
|
||||
}
|
||||
if task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" {
|
||||
t.Fatalf("expected selection order preserved, got %#v", task.DeviceIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_SelectedDeviceQueryHelpersStayStable(t *testing.T) {
|
||||
ids := selectedIDsFromQuery([]string{" edge-02 ", "edge-01", "edge-02", ""})
|
||||
if len(ids) != 2 || ids[0] != "edge-02" || ids[1] != "edge-01" {
|
||||
t.Fatalf("selectedIDsFromQuery normalized to %#v", ids)
|
||||
}
|
||||
if got := selectedQueryString(ids); got != "selected=edge-02&selected=edge-01" {
|
||||
t.Fatalf("selectedQueryString returned %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DevicePageUsesEdgeVisionConsoleShell(t *testing.T) {
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
reg := service.NewRegistryService(cfg, nil)
|
||||
@ -117,6 +173,321 @@ func newTestUI(t *testing.T) *UI {
|
||||
return ui
|
||||
}
|
||||
|
||||
func TestUI_DeviceOverviewHidesBatchBarWithoutSelection(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
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 _, forbidden := range []string{"batch-toolbar", "已选", "批量配置", "重启服务", "启动服务", "停止服务", "重载服务", "清空选择"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("device overview should not show batch controls without selection, found %q in:\n%s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceOverviewShowsBatchBarWhenDevicesSelected(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices?selected=edge-01&selected=edge-02", 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{"batch-toolbar", "已选 2 台", "重启服务", "启动服务", "停止服务", "重载服务", "批量配置", "清空选择"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected batch controls HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceBatchConfigPageShowsSelectedSummaryAndSources(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices/batch-config?selected=edge-01&selected=edge-02", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.pageDeviceBatchConfig(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{
|
||||
"批量配置",
|
||||
"模板",
|
||||
"Profile",
|
||||
"Overlay",
|
||||
"已选设备",
|
||||
"已选 2 台设备",
|
||||
"入口识别节点",
|
||||
"辅助节点",
|
||||
"预览摘要",
|
||||
"workshop_face_shoe_alarm",
|
||||
"local_3588_test",
|
||||
"face_debug",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected batch config page to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionDeviceBatchConfigCreatesTaskAndRedirects(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)})
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-02")
|
||||
form.Set("template", "workshop_face_shoe_alarm")
|
||||
form.Set("profile", "local_3588_test")
|
||||
form.Add("overlay", "face_debug")
|
||||
form.Set("config_id", "batch_edge")
|
||||
form.Set("config_version", "20260420.090000")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionDeviceBatchConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/ui/tasks/") {
|
||||
t.Fatalf("expected redirect to task page, got %q", loc)
|
||||
}
|
||||
|
||||
taskID := strings.TrimPrefix(loc, "/ui/tasks/")
|
||||
items := ui.tasks.ListTasks()
|
||||
var task *models.Task
|
||||
for i := range items {
|
||||
if items[i].ID == taskID {
|
||||
t := items[i]
|
||||
task = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
if task == nil {
|
||||
t.Fatalf("expected task %s to exist", taskID)
|
||||
}
|
||||
if task.Type != "config_apply" {
|
||||
t.Fatalf("expected task type config_apply, got %q", task.Type)
|
||||
}
|
||||
if len(task.DeviceIDs) != 2 || task.DeviceIDs[0] != "edge-01" || task.DeviceIDs[1] != "edge-02" {
|
||||
t.Fatalf("expected selected devices preserved, got %#v", task.DeviceIDs)
|
||||
}
|
||||
payload, ok := task.Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected payload map, got %#v", task.Payload)
|
||||
}
|
||||
configDoc, ok := payload["config"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected payload.config object, got %#v", payload["config"])
|
||||
}
|
||||
metadata, ok := configDoc["metadata"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata object, got %#v", configDoc["metadata"])
|
||||
}
|
||||
if metadata["template"] != "workshop_face_shoe_alarm" {
|
||||
t.Fatalf("expected template metadata, got %#v", metadata["template"])
|
||||
}
|
||||
if metadata["profile"] != "local_3588_test" {
|
||||
t.Fatalf("expected profile metadata, got %#v", metadata["profile"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionDeviceBatchConfigRenderFailurePreservesUserInput(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigBrokenMediaRepo(t)})
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-02")
|
||||
form.Set("template", "workshop_face_shoe_alarm")
|
||||
form.Set("profile", "local_3588_test")
|
||||
form.Set("config_id", "")
|
||||
form.Set("config_version", "")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionDeviceBatchConfig(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{
|
||||
`name="device_id" value="edge-01"`,
|
||||
`name="device_id" value="edge-02"`,
|
||||
"入口识别节点",
|
||||
"辅助节点",
|
||||
`name="config_id" value=""`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected failure refill HTML to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `name="overlay" value="face_debug" checked`) {
|
||||
t.Fatalf("expected empty overlay selection to stay empty, got:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "完整 JSON 放在折叠区") {
|
||||
t.Fatalf("expected no JSON foldout hint on render failure, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_ActionDevicesBatchActionKeepsDevicesOnError(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "nope")
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-02")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionDevicesBatchAction(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{"不支持的操作: nope", "入口识别节点", "辅助节点", "已选 2 台", `value="edge-01" checked`, `value="edge-02" checked`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected error render to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createBatchConfigMediaRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
|
||||
writeTestFile(t, filepath.Join(root, "tools", "render_config.py"), `import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--template", required=True)
|
||||
parser.add_argument("--profile", required=True)
|
||||
parser.add_argument("--out", required=True)
|
||||
parser.add_argument("--config-id", required=True)
|
||||
parser.add_argument("--config-version", required=True)
|
||||
parser.add_argument("--rendered-at", required=True)
|
||||
parser.add_argument("--overlay", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
doc = {
|
||||
"metadata": {
|
||||
"config_id": args.config_id,
|
||||
"config_version": args.config_version,
|
||||
"template": os.path.splitext(os.path.basename(args.template))[0],
|
||||
"profile": os.path.splitext(os.path.basename(args.profile))[0],
|
||||
"overlays": [os.path.splitext(os.path.basename(item))[0] for item in args.overlay],
|
||||
"rendered_at": args.rendered_at,
|
||||
},
|
||||
"pipelines": [],
|
||||
}
|
||||
|
||||
with open(args.out, "w", encoding="utf-8") as fh:
|
||||
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
||||
`)
|
||||
return root
|
||||
}
|
||||
|
||||
func createBatchConfigBrokenMediaRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "configs", "templates", "workshop_face_shoe_alarm.json"), `{"name":"template"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{"name":"profile"}`)
|
||||
writeTestFile(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{"name":"overlay"}`)
|
||||
return root
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path string, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_TaskPageRendersBatchSummaryAndDeviceResults(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
ui.registry.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true})
|
||||
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: createBatchConfigMediaRepo(t)})
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("device_id", "edge-01")
|
||||
form.Add("device_id", "edge-02")
|
||||
form.Set("template", "workshop_face_shoe_alarm")
|
||||
form.Set("profile", "local_3588_test")
|
||||
form.Set("config_id", "batch_edge")
|
||||
form.Set("config_version", "20260420.090000")
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-config", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionDeviceBatchConfig(rr, req)
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected redirect, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/ui/tasks/") {
|
||||
t.Fatalf("expected redirect to task page, got %q", loc)
|
||||
}
|
||||
|
||||
rrTask := httptest.NewRecorder()
|
||||
reqTask := httptest.NewRequest(http.MethodGet, loc, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", strings.TrimPrefix(loc, "/ui/tasks/"))
|
||||
reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
|
||||
ui.pageTask(rrTask, reqTask)
|
||||
|
||||
if rrTask.Code != http.StatusOK {
|
||||
t.Fatalf("expected task page 200, got %d: %s", rrTask.Code, rrTask.Body.String())
|
||||
}
|
||||
body := rrTask.Body.String()
|
||||
for _, want := range []string{"任务详情", "返回任务列表", "设备结果表", "任务类型", "目标设备数", "批量配置", "下发识别配置", "2 台", "入口识别节点", "辅助节点", "edge-01", "edge-02", `id="task-status-value"`, "syncTaskStatus()", `href="/ui/tasks"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected task page to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"任务中心", "节点执行情况"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("task page should not contain %q, got:\n%s", forbidden, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "返回操作审计") {
|
||||
t.Fatalf("task page should not point back to audit, got:\n%s", body)
|
||||
}
|
||||
if strings.Index(body, "edge-01") > strings.Index(body, "edge-02") {
|
||||
t.Fatalf("expected task devices to keep selection order, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||
@ -666,7 +1037,7 @@ func TestUI_ConfigPreviewShowsApplySummaryAfterApplyResult(t *testing.T) {
|
||||
"overlays": []any{"face_test_sensitive", "production_quiet"},
|
||||
},
|
||||
Sha256: "eecdf8d422705f3affa0f892199604f037f60ea8fe578fe2a65527e1800044c5",
|
||||
Size: 64,
|
||||
Size: 64,
|
||||
},
|
||||
ConfigStatus: &ConfigStatusView{
|
||||
OK: true,
|
||||
@ -1162,17 +1533,32 @@ 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"})
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "edge-02", DeviceName: "辅助节点", IP: "127.0.0.2", AgentPort: 9100, MediaPort: 9000, Online: true, Version: "1.0.1", GitSha: "8eaf213"})
|
||||
tasks := service.NewTaskService(cfg, nil, reg)
|
||||
task, err := tasks.CreateTask("reload", []string{"edge-01"}, nil)
|
||||
taskConfig, err := tasks.CreateTask("config_apply", []string{"edge-01", "edge-02"}, map[string]any{"config": map[string]any{}})
|
||||
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
|
||||
taskService, err := tasks.CreateTask("media_restart", []string{"edge-01", "edge-02"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
task.Mu.Unlock()
|
||||
taskConfig.Mu.Lock()
|
||||
taskConfig.Status = models.TaskSuccess
|
||||
for _, did := range taskConfig.DeviceIDs {
|
||||
if ds, ok := taskConfig.Devices[did]; ok && ds != nil {
|
||||
ds.Status = models.TaskSuccess
|
||||
}
|
||||
}
|
||||
taskConfig.Mu.Unlock()
|
||||
taskService.Mu.Lock()
|
||||
taskService.Status = models.TaskSuccess
|
||||
for _, did := range taskService.DeviceIDs {
|
||||
if ds, ok := taskService.Devices[did]; ok && ds != nil {
|
||||
ds.Status = models.TaskSuccess
|
||||
}
|
||||
}
|
||||
taskService.Mu.Unlock()
|
||||
ui, err := NewUI(nil, reg, nil, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
@ -1180,16 +1566,51 @@ func TestUI_AuditAndSystemPagesDefineNewScopes(t *testing.T) {
|
||||
|
||||
rrAudit := httptest.NewRecorder()
|
||||
ui.pageAudit(rrAudit, httptest.NewRequest(http.MethodGet, "/ui/audit", nil))
|
||||
for _, want := range []string{"操作审计", "审计记录", "谁做了什么、对哪台设备做的、结果如何", "reload", "edge-01", task.ID} {
|
||||
for _, want := range []string{"操作审计", "审计记录", "批量配置", "批量服务", "目标设备数", "2 台", "下发识别配置", "重启视频分析服务"} {
|
||||
if !strings.Contains(rrAudit.Body.String(), want) {
|
||||
t.Fatalf("expected audit HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"框架版", "后续", `disabled`} {
|
||||
for _, forbidden := range []string{"框架版", "后续", "任务中心", "节点执行情况", `disabled`} {
|
||||
if strings.Contains(rrAudit.Body.String(), forbidden) {
|
||||
t.Fatalf("audit HTML should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"success", "failed", "running"} {
|
||||
if strings.Contains(rrAudit.Body.String(), forbidden) {
|
||||
t.Fatalf("audit HTML should not leak raw status enum %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
rrTasks := httptest.NewRecorder()
|
||||
ui.pageTasks(rrTasks, httptest.NewRequest(http.MethodGet, "/ui/tasks", nil))
|
||||
for _, want := range []string{"任务列表", "批量配置", "批量服务", "目标设备数"} {
|
||||
if !strings.Contains(rrTasks.Body.String(), want) {
|
||||
t.Fatalf("expected tasks HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"任务中心", "节点执行情况"} {
|
||||
if strings.Contains(rrTasks.Body.String(), forbidden) {
|
||||
t.Fatalf("tasks HTML should not contain placeholder marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
rrTaskConfig := httptest.NewRecorder()
|
||||
reqTask := httptest.NewRequest(http.MethodGet, "/ui/tasks/"+taskConfig.ID, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", taskConfig.ID)
|
||||
reqTask = reqTask.WithContext(context.WithValue(reqTask.Context(), chi.RouteCtxKey, rctx))
|
||||
ui.pageTask(rrTaskConfig, reqTask)
|
||||
for _, want := range []string{"批量配置", "下发识别配置", "返回任务列表"} {
|
||||
if !strings.Contains(rrTaskConfig.Body.String(), want) {
|
||||
t.Fatalf("expected task detail HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"任务中心", "返回操作审计"} {
|
||||
if strings.Contains(rrTaskConfig.Body.String(), forbidden) {
|
||||
t.Fatalf("task detail HTML should not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
rrSystem := httptest.NewRecorder()
|
||||
ui.pageSystem(rrSystem, httptest.NewRequest(http.MethodGet, "/ui/system", nil))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user