Add device overview batch selection mode
This commit is contained in:
parent
c8836991c5
commit
513062f08e
@ -60,6 +60,8 @@ type PageData struct {
|
||||
Task *models.Task
|
||||
Templates []service.Template
|
||||
Template *service.Template
|
||||
SelectedDeviceIDs []string
|
||||
SelectedQuery string
|
||||
|
||||
RawJSON string
|
||||
RawText string
|
||||
@ -329,46 +331,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) {
|
||||
@ -438,14 +401,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
action := strings.TrimSpace(r.FormValue("action"))
|
||||
deviceIDs := 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 +410,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 +429,7 @@ 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
|
||||
}
|
||||
|
||||
@ -1139,6 +1081,123 @@ 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 previewResultFromJSON(raw string) *service.ConfigPreviewResult {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@ -41,10 +41,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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?{{.SelectedQuery}}#batch-config">批量配置</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>
|
||||
@ -54,6 +73,9 @@
|
||||
<tbody>
|
||||
{{range .DeviceRows}}
|
||||
<tr>
|
||||
<td style="text-align:center">
|
||||
<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>
|
||||
@ -106,7 +128,7 @@
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<td colspan="5">
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">还没有设备</div>
|
||||
<div class="muted">当前后台还没有发现或录入任何设备。</div>
|
||||
@ -117,12 +139,14 @@
|
||||
</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}}
|
||||
|
||||
@ -117,6 +117,68 @@ 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_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 台"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected error render to contain %q, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
|
||||
ui := newTestUI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user