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
|
Task *models.Task
|
||||||
Templates []service.Template
|
Templates []service.Template
|
||||||
Template *service.Template
|
Template *service.Template
|
||||||
|
SelectedDeviceIDs []string
|
||||||
|
SelectedQuery string
|
||||||
|
|
||||||
RawJSON string
|
RawJSON string
|
||||||
RawText 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) {
|
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
u.ensureDevicesLoaded()
|
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, ""))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) {
|
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"))
|
action := strings.TrimSpace(r.FormValue("action"))
|
||||||
deviceIDs := r.Form["device_id"]
|
deviceIDs := r.Form["device_id"]
|
||||||
if len(deviceIDs) == 0 {
|
if len(deviceIDs) == 0 {
|
||||||
devices := u.registry.GetDevices()
|
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备"))
|
||||||
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: "请先选择设备"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,14 +410,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "media_start", "media_restart", "media_stop", "reload", "rollback":
|
case "media_start", "media_restart", "media_stop", "reload", "rollback":
|
||||||
typeStr = action
|
typeStr = action
|
||||||
default:
|
default:
|
||||||
devices := u.registry.GetDevices()
|
u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action))
|
||||||
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})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -480,14 +429,7 @@ func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
|
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
devices := u.registry.GetDevices()
|
u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error()))
|
||||||
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()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1139,6 +1081,123 @@ func cleanFormList(values []string) []string {
|
|||||||
return out
|
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 {
|
func previewResultFromJSON(raw string) *service.ConfigPreviewResult {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@ -41,10 +41,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="table-wrap">
|
||||||
<table id="device-list">
|
<table id="device-list">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:52px">选中</th>
|
||||||
<th>设备</th>
|
<th>设备</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>当前配置</th>
|
<th>当前配置</th>
|
||||||
@ -54,6 +73,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .DeviceRows}}
|
{{range .DeviceRows}}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td style="text-align:center">
|
||||||
|
<input type="checkbox" name="device_id" value="{{.Device.DeviceID}}" {{if hasString $.SelectedDeviceIDs .Device.DeviceID}}checked{{end}} />
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="device-cell">
|
<div class="device-cell">
|
||||||
<div class="device-avatar">{{icon "device"}}</div>
|
<div class="device-avatar">{{icon "device"}}</div>
|
||||||
@ -106,7 +128,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4">
|
<td colspan="5">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-title">还没有设备</div>
|
<div class="empty-title">还没有设备</div>
|
||||||
<div class="muted">当前后台还没有发现或录入任何设备。</div>
|
<div class="muted">当前后台还没有发现或录入任何设备。</div>
|
||||||
@ -117,12 +139,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const input = document.getElementById('device-filter');
|
const input = document.getElementById('device-filter');
|
||||||
const table = document.getElementById('device-list');
|
const table = document.getElementById('device-list');
|
||||||
|
const selectedBoxes = table ? table.querySelectorAll('input[type="checkbox"][name="device_id"]') : [];
|
||||||
if (!input || !table) return;
|
if (!input || !table) return;
|
||||||
input.addEventListener('input', () => {
|
input.addEventListener('input', () => {
|
||||||
const q = (input.value || '').trim().toLowerCase();
|
const q = (input.value || '').trim().toLowerCase();
|
||||||
@ -131,6 +155,20 @@
|
|||||||
row.style.display = (!q || text.includes(q)) ? '' : 'none';
|
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>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -117,6 +117,68 @@ func newTestUI(t *testing.T) *UI {
|
|||||||
return 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) {
|
func TestUI_DeviceOverviewRendersFleetOverview(t *testing.T) {
|
||||||
ui := newTestUI(t)
|
ui := newTestUI(t)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
req := httptest.NewRequest(http.MethodGet, "/ui/devices", nil)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user