diff --git a/agent/README.md b/agent/README.md index cbc87cc..9e4cede 100644 --- a/agent/README.md +++ b/agent/README.md @@ -118,6 +118,24 @@ vi /etc/rmm-agent-go.conf The side-by-side service uses `/etc/rmm-agent-go.conf`, `/tmp/rmm-agent-go.lock`, `/tmp/rmm-agent-go-results`, `/tmp/rmm-agent-go-backups`, and `/tmp/rmm-agent-go-tunnels` so it does not collide with the shell agent. +Production switch from the shell agent to the Go agent: + +```sh +/etc/init.d/rmm-agent stop +/etc/init.d/rmm-agent disable +/etc/init.d/rmm-agent-go stop 2>/dev/null || true +cp /usr/bin/rmm-agent-go /usr/bin/rmm-agent +chmod +x /usr/bin/rmm-agent +cp ./agent/openwrt/rmm-agent-go-production.init /etc/init.d/rmm-agent +chmod +x /etc/init.d/rmm-agent +sed -i '/^HOSTNAME_SUFFIX=/d;/^HOSTNAME_OVERRIDE=/d;/^LOCK_FILE=/d;/^SPOOL_DIR=/d;/^BACKUP_DIR=/d;/^TUNNEL_STATE_DIR=/d' /etc/rmm-agent.conf +rm -rf /tmp/rmm-agent.lock +/etc/init.d/rmm-agent enable +/etc/init.d/rmm-agent start +``` + +This keeps the existing `/etc/rmm-agent.conf`, including `DEVICE_ID` and `DEVICE_TOKEN`, so the router continues as the same RMM object instead of enrolling as a duplicate `-go` device. Keep the shell script backup until the Go service has been stable for at least one maintenance window. + ## OpenWrt Package Package skeleton: diff --git a/agent/openwrt/rmm-agent-go-production.init b/agent/openwrt/rmm-agent-go-production.init new file mode 100644 index 0000000..d66f254 --- /dev/null +++ b/agent/openwrt/rmm-agent-go-production.init @@ -0,0 +1,23 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +PROG=/usr/bin/rmm-agent +CONFIG_FILE=/etc/rmm-agent.conf + +start_service() { + procd_open_instance + procd_set_param command "$PROG" -config "$CONFIG_FILE" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param term_timeout 15 + procd_set_param respawn 3600 5 5 + procd_close_instance +} + +stop_service() { + service_stop "$PROG" + rm -rf /tmp/rmm-agent.lock +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 176e5b2..03f31cd 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -31,14 +31,17 @@ type Store interface { ListDevices(ctx context.Context) ([]model.Device, error) GetDevice(ctx context.Context, deviceID string) (model.Device, bool, error) UpdateDeviceFleet(ctx context.Context, deviceID, group string, tags []string) (model.Device, bool, error) + DeleteDevice(ctx context.Context, deviceID string) (bool, error) ListMetricSamples(ctx context.Context, deviceID string, opts store.MetricHistoryOptions) ([]model.MetricSample, bool, error) SyncDeviceAlerts(ctx context.Context, deviceID string, active []model.Alert) ([]model.Alert, bool, error) ListAlerts(ctx context.Context, opts store.AlertListOptions) ([]model.Alert, error) AcknowledgeAlert(ctx context.Context, deviceID, alertID, actor string) (model.Alert, bool, error) + PurgeAlerts(ctx context.Context, opts store.PurgeOptions) (int64, error) CreateCommand(ctx context.Context, deviceID, commandType string, args json.RawMessage) (model.Command, bool, error) ListCommands(ctx context.Context, deviceID string, opts store.CommandListOptions) ([]model.Command, bool, error) GetCommand(ctx context.Context, deviceID, commandID string) (model.Command, bool, error) CancelCommand(ctx context.Context, deviceID, commandID string) (model.Command, bool, error) + PurgeCommands(ctx context.Context, opts store.PurgeOptions) (int64, error) CreateRemoteSession(ctx context.Context, session model.RemoteSession) (model.RemoteSession, bool, error) ListRemoteSessions(ctx context.Context, deviceID string, opts store.RemoteSessionListOptions) ([]model.RemoteSession, bool, error) GetRemoteSession(ctx context.Context, deviceID, sessionID string) (model.RemoteSession, bool, error) @@ -47,6 +50,7 @@ type Store interface { ExpireRemoteSessions(ctx context.Context) error AddAuditEvent(ctx context.Context, actor, action, deviceID, commandID string, details json.RawMessage) (model.AuditEvent, error) ListAuditEvents(ctx context.Context, opts store.AuditListOptions) ([]model.AuditEvent, error) + PurgeAuditEvents(ctx context.Context, opts store.PurgeOptions) (int64, error) } type Config struct { @@ -177,7 +181,9 @@ func NewHandler(s Store, cfg Config) http.Handler { mux.Handle("GET /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) mux.Handle("POST /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) mux.Handle("PATCH /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) + mux.Handle("DELETE /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) mux.Handle("GET /api/audit-events", a.operatorAuth(http.HandlerFunc(a.handleListAuditEvents))) + mux.Handle("DELETE /api/audit-events", a.operatorAuth(http.HandlerFunc(a.handlePurgeAuditEvents))) mux.Handle("GET /luci/", a.operatorAuth(http.HandlerFunc(a.handleLuCIProxy))) mux.Handle("POST /luci/", a.operatorAuth(http.HandlerFunc(a.handleLuCIProxy))) for _, path := range []string{"/cgi-bin/", "/luci-static/", "/ubus/", "/ubus"} { @@ -355,12 +361,37 @@ func (a *App) handleGetDevice(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, d) } +func (a *App) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { + id, ok := deviceIDFromPath(r.URL.Path) + if !ok { + writeError(w, http.StatusNotFound, "not found") + return + } + deleted, err := a.store.DeleteDevice(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete device") + return + } + if !deleted { + writeError(w, http.StatusNotFound, "device not found") + return + } + _, _ = a.store.AddAuditEvent(r.Context(), "operator", "device.delete", id, "", mustJSON(map[string]string{ + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) +} + func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) == 3 && r.Method == http.MethodGet { a.handleGetDevice(w, r) return } + if len(parts) == 3 && r.Method == http.MethodDelete { + a.handleDeleteDevice(w, r) + return + } if len(parts) == 4 && parts[3] == "fleet" && r.Method == http.MethodPatch { a.handleUpdateDeviceFleet(w, r) return @@ -373,6 +404,10 @@ func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) { a.handleListAlerts(w, r) return } + if len(parts) == 4 && parts[3] == "alerts" && r.Method == http.MethodDelete { + a.handlePurgeDeviceAlerts(w, r) + return + } if len(parts) == 6 && parts[3] == "alerts" && parts[5] == "acknowledge" && r.Method == http.MethodPost { a.handleAcknowledgeAlert(w, r) return @@ -381,6 +416,10 @@ func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) { a.handleListCommands(w, r) return } + if len(parts) == 4 && parts[3] == "commands" && r.Method == http.MethodDelete { + a.handlePurgeDeviceCommands(w, r) + return + } if len(parts) == 4 && parts[3] == "commands" && r.Method == http.MethodPost { a.handleCreateCommand(w, r) return @@ -496,6 +535,32 @@ func (a *App) handleAcknowledgeAlert(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, alert) } +func (a *App) handlePurgeDeviceAlerts(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) != 4 || parts[0] != "api" || parts[1] != "devices" || parts[3] != "alerts" { + writeError(w, http.StatusNotFound, "not found") + return + } + deviceID := parts[2] + if _, found, err := a.store.GetDevice(r.Context(), deviceID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to load device") + return + } else if !found { + writeError(w, http.StatusNotFound, "device not found") + return + } + deleted, err := a.store.PurgeAlerts(r.Context(), store.PurgeOptions{DeviceID: deviceID}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to purge alerts") + return + } + _, _ = a.store.AddAuditEvent(r.Context(), "operator", "alerts.purge", deviceID, "", mustJSON(map[string]any{ + "deleted": deleted, + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted}) +} + func (a *App) refreshDeviceAlerts(ctx context.Context, deviceID string) ([]model.Alert, bool, error) { active, found, err := a.computeDeviceAlerts(ctx, deviceID) if err != nil || !found { @@ -896,6 +961,32 @@ func (a *App) handleCancelCommand(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, c) } +func (a *App) handlePurgeDeviceCommands(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) != 4 || parts[0] != "api" || parts[1] != "devices" || parts[3] != "commands" { + writeError(w, http.StatusNotFound, "not found") + return + } + deviceID := parts[2] + if _, found, err := a.store.GetDevice(r.Context(), deviceID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to load device") + return + } else if !found { + writeError(w, http.StatusNotFound, "device not found") + return + } + deleted, err := a.store.PurgeCommands(r.Context(), store.PurgeOptions{DeviceID: deviceID}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to purge command history") + return + } + _, _ = a.store.AddAuditEvent(r.Context(), "operator", "commands.purge", deviceID, "", mustJSON(map[string]any{ + "deleted": deleted, + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted}) +} + func (a *App) handleListRemoteSessions(w http.ResponseWriter, r *http.Request) { deviceID, ok := remoteSessionListDeviceIDFromPath(r.URL.Path) if !ok { @@ -1258,6 +1349,24 @@ func (a *App) handleListAuditEvents(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"audit_events": events}) } +func (a *App) handlePurgeAuditEvents(w http.ResponseWriter, r *http.Request) { + deviceID := r.URL.Query().Get("device_id") + deleted, err := a.store.PurgeAuditEvents(r.Context(), store.PurgeOptions{DeviceID: deviceID}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to purge audit events") + return + } + action := "audit.purge" + if strings.TrimSpace(deviceID) != "" { + action = "device_audit.purge" + } + _, _ = a.store.AddAuditEvent(r.Context(), "operator", action, deviceID, "", mustJSON(map[string]any{ + "deleted": deleted, + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]any{"deleted": deleted}) +} + func (a *App) authorizeDevice(r *http.Request, deviceID string) error { if strings.TrimSpace(deviceID) == "" { return errors.New("device_id is required") diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go index f237eb8..0aa2044 100644 --- a/server/internal/httpapi/server_test.go +++ b/server/internal/httpapi/server_test.go @@ -405,6 +405,72 @@ func TestAgentOperatorSmokeFlow(t *testing.T) { } } +func TestOperatorCanPurgeAndDeleteDeviceData(t *testing.T) { + st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ + EnrollmentToken: "enroll-test", + OperatorToken: "operator-test", + })) + defer srv.Close() + + var enrolled struct { + DeviceID string `json:"device_id"` + DeviceToken string `json:"device_token"` + } + requestJSON(t, http.MethodPost, srv.URL+"/api/agent/enroll", "", map[string]any{ + "enrollment_token": "enroll-test", + "hostname": "delete-me", + "openwrt_version": "OpenWrt test", + }, http.StatusCreated, &enrolled) + + requestJSON(t, http.MethodPost, srv.URL+"/api/agent/heartbeat", enrolled.DeviceToken, map[string]any{ + "device_id": enrolled.DeviceID, + "inventory": map[string]any{"hostname": "delete-me"}, + "metrics": map[string]any{ + "memory": map[string]any{"used_kb": 90, "total_kb": 100}, + }, + }, http.StatusOK, nil) + + requestJSON(t, http.MethodPost, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", map[string]any{ + "type": "ping", + "args": map[string]string{"target": "1.1.1.1"}, + }, http.StatusCreated, nil) + next := requestText(t, http.MethodPost, srv.URL+"/api/agent/commands/next", enrolled.DeviceToken, map[string]string{ + "device_id": enrolled.DeviceID, + }, http.StatusOK) + commandID := strings.Split(next, "\t")[0] + requestJSON(t, http.MethodPost, srv.URL+"/api/agent/commands/"+commandID+"/result", enrolled.DeviceToken, map[string]any{ + "device_id": enrolled.DeviceID, + "status": "completed", + "exit_code": 0, + "output": "ok", + }, http.StatusOK, nil) + + var purge struct { + Deleted int64 `json:"deleted"` + } + requestJSON(t, http.MethodDelete, srv.URL+"/api/devices/"+enrolled.DeviceID+"/alerts", "operator-test", nil, http.StatusOK, &purge) + if purge.Deleted < 1 { + t.Fatalf("expected at least one purged alert, got %d", purge.Deleted) + } + requestJSON(t, http.MethodDelete, srv.URL+"/api/devices/"+enrolled.DeviceID+"/commands", "operator-test", nil, http.StatusOK, &purge) + if purge.Deleted < 1 { + t.Fatalf("expected at least one purged command, got %d", purge.Deleted) + } + requestJSON(t, http.MethodDelete, srv.URL+"/api/audit-events?device_id="+enrolled.DeviceID, "operator-test", nil, http.StatusOK, &purge) + if purge.Deleted < 1 { + t.Fatalf("expected at least one purged audit event, got %d", purge.Deleted) + } + + requestJSON(t, http.MethodDelete, srv.URL+"/api/devices/"+enrolled.DeviceID, "operator-test", nil, http.StatusOK, nil) + requestJSON(t, http.MethodGet, srv.URL+"/api/devices/"+enrolled.DeviceID, "operator-test", nil, http.StatusNotFound, nil) +} + func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { var upstreamPath string var authenticated bool diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index c1bae21..f4b2d97 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -41,6 +41,10 @@ type AlertListOptions struct { Limit int } +type PurgeOptions struct { + DeviceID string +} + type RemoteSessionListOptions struct { Limit int } @@ -496,6 +500,38 @@ WHERE id = ? return d, found, err } +func (s *Store) DeleteDevice(ctx context.Context, deviceID string) (bool, error) { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + return false, nil + } + var exists bool + if err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM devices WHERE id = ?)`, deviceID).Scan(&exists); err != nil { + return false, err + } + if !exists { + return false, nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + for _, stmt := range []string{ + `DELETE FROM remote_sessions WHERE device_id = ?`, + `DELETE FROM commands WHERE device_id = ?`, + `DELETE FROM metric_samples WHERE device_id = ?`, + `DELETE FROM alerts WHERE device_id = ?`, + `DELETE FROM audit_events WHERE device_id = ?`, + `DELETE FROM devices WHERE id = ?`, + } { + if _, err := tx.ExecContext(ctx, stmt, deviceID); err != nil { + return false, err + } + } + return true, tx.Commit() +} + func (s *Store) AddMetricSample(ctx context.Context, deviceID string, inventory, metrics json.RawMessage, createdAt string) error { id, err := randomID("met") if err != nil { @@ -692,6 +728,21 @@ WHERE device_id = ? AND id = ? return alert, true, nil } +func (s *Store) PurgeAlerts(ctx context.Context, opts PurgeOptions) (int64, error) { + deviceID := strings.TrimSpace(opts.DeviceID) + query := `DELETE FROM alerts` + args := []any{} + if deviceID != "" { + query += ` WHERE device_id = ?` + args = append(args, deviceID) + } + res, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (s *Store) CreateCommand(ctx context.Context, deviceID, commandType string, args json.RawMessage) (model.Command, bool, error) { var exists bool if err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM devices WHERE id = ?)`, deviceID).Scan(&exists); err != nil { @@ -809,6 +860,21 @@ WHERE device_id = ? AND id = ? AND status IN ('queued', 'claimed') return s.GetCommand(ctx, deviceID, commandID) } +func (s *Store) PurgeCommands(ctx context.Context, opts PurgeOptions) (int64, error) { + deviceID := strings.TrimSpace(opts.DeviceID) + query := `DELETE FROM commands WHERE status NOT IN ('queued', 'claimed')` + args := []any{} + if deviceID != "" { + query += ` AND device_id = ?` + args = append(args, deviceID) + } + res, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (s *Store) CreateRemoteSession(ctx context.Context, session model.RemoteSession) (model.RemoteSession, bool, error) { var exists bool if err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM devices WHERE id = ?)`, session.DeviceID).Scan(&exists); err != nil { @@ -1032,6 +1098,21 @@ FROM audit_events return events, rows.Err() } +func (s *Store) PurgeAuditEvents(ctx context.Context, opts PurgeOptions) (int64, error) { + deviceID := strings.TrimSpace(opts.DeviceID) + query := `DELETE FROM audit_events` + args := []any{} + if deviceID != "" { + query += ` WHERE device_id = ?` + args = append(args, deviceID) + } + res, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + type scanner interface { Scan(dest ...any) error } diff --git a/web/app.js b/web/app.js index 8138cf9..87caa29 100644 --- a/web/app.js +++ b/web/app.js @@ -148,6 +148,10 @@ const els = { commandDetailOutput: document.querySelector("#commandDetailOutput"), copyCommandOutputBtn: document.querySelector("#copyCommandOutputBtn"), auditList: document.querySelector("#auditList"), + clearAlertsBtn: document.querySelector("#clearAlertsBtn"), + clearCommandsBtn: document.querySelector("#clearCommandsBtn"), + clearAuditBtn: document.querySelector("#clearAuditBtn"), + deleteDeviceBtn: document.querySelector("#deleteDeviceBtn"), }; if (els.remoteServerHost) { @@ -339,6 +343,11 @@ function confirmDanger(type) { return window.confirm(`Подтвердите действие: ${label}. Продолжить?`); } +function confirmTyped(message, expected) { + const value = window.prompt(`${message}\n\nВведите ${expected}, чтобы подтвердить.`); + return value === expected; +} + function commandArgs() { const type = els.commandType.value; if (type === "pkg_list_installed" || type === "route_show" || type === "interfaces_show" || type === "reboot") return {}; @@ -1360,6 +1369,55 @@ async function acknowledgeAlert(alertId) { setStatus("Alert acknowledged"); } +async function clearDeviceAlerts() { + if (!state.selectedDeviceId) return; + if (!confirmTyped("Очистить все алерты выбранного устройства?", "CLEAR")) return; + setStatus("Очищаю алерты"); + await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/alerts`, { method: "DELETE" }); + await Promise.all([loadAlerts(), loadAudit(), loadDevices()]); + setStatus("Алерты очищены"); +} + +async function clearDeviceCommands() { + if (!state.selectedDeviceId) return; + if (!confirmTyped("Очистить историю команд выбранного устройства?", "CLEAR")) return; + setStatus("Очищаю историю команд"); + await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands`, { method: "DELETE" }); + state.commands = []; + state.commandOffset = 0; + state.selectedCommand = null; + await Promise.all([loadCommands(), loadAudit(), loadDevices()]); + renderCommandDetail(); + setStatus("История команд очищена"); +} + +async function clearDeviceAudit() { + if (!state.selectedDeviceId) return; + if (!confirmTyped("Очистить аудит выбранного устройства?", "CLEAR")) return; + setStatus("Очищаю аудит"); + await api(`/api/audit-events?device_id=${encodeURIComponent(state.selectedDeviceId)}`, { method: "DELETE" }); + state.auditEvents = []; + state.auditOffset = 0; + await loadAudit(); + setStatus("Аудит устройства очищен"); +} + +async function deleteSelectedDevice() { + const device = currentDevice(); + if (!device) return; + const name = deviceDisplayName(device); + if (!confirmTyped(`Удалить устройство "${name}" из RMM? Это удалит метрики, команды, алерты и remote sessions.`, name)) return; + setStatus("Удаляю устройство"); + await api(`/api/devices/${encodeURIComponent(device.id)}`, { method: "DELETE" }); + state.selectedDeviceId = null; + state.selectedCommand = null; + state.commands = []; + state.auditEvents = []; + await loadDevices(); + render(); + setStatus("Устройство удалено"); +} + function diagnosticCommand(name) { const serverHost = window.location.hostname || "10.10.10.2"; switch (name) { @@ -1468,6 +1526,10 @@ els.loadMoreAuditBtn.addEventListener("click", () => loadAudit({ append: true }) els.sendCommandBtn.addEventListener("click", () => sendCommand().catch((error) => setStatus(error.message))); els.sendBulkCommandBtn.addEventListener("click", () => sendBulkCommand().catch((error) => setStatus(error.message))); els.saveFleetBtn.addEventListener("click", () => saveFleetMetadata().catch((error) => setStatus(error.message))); +els.clearAlertsBtn.addEventListener("click", () => clearDeviceAlerts().catch((error) => setStatus(error.message))); +els.clearCommandsBtn.addEventListener("click", () => clearDeviceCommands().catch((error) => setStatus(error.message))); +els.clearAuditBtn.addEventListener("click", () => clearDeviceAudit().catch((error) => setStatus(error.message))); +els.deleteDeviceBtn.addEventListener("click", () => deleteSelectedDevice().catch((error) => setStatus(error.message))); els.sendPackageCommandBtn.addEventListener("click", () => sendPackageCommand().catch((error) => setStatus(error.message))); els.createRemoteSessionBtn.addEventListener("click", () => createRemoteSession().catch((error) => setStatus(error.message))); els.uciBackupBtn.addEventListener("click", () => sendUciCommand("uci_backup").catch((error) => setStatus(error.message))); diff --git a/web/index.html b/web/index.html index 6e49d68..d01eff2 100644 --- a/web/index.html +++ b/web/index.html @@ -647,10 +647,25 @@
+ +Очистка локальной истории RMM. Настройки роутера не меняются.
+