diff --git a/Makefile b/Makefile index 7c5ab528..6a0e7afb 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,7 @@ dist-arm64: build-linux-arm64 web-build @cp packaging/alpine/ip-up.d-minimalrouter-qos build/dist/minimalrouter-linux-arm64/ip-up.d-minimalrouter-qos @cp packaging/alpine/install-console.sh build/dist/minimalrouter-linux-arm64/install.sh @cp packaging/alpine/install-dist.sh build/dist/minimalrouter-linux-arm64/install-core.sh - @chmod +x build/dist/minimalrouter-linux-arm64/install.sh build/dist/minimalrouter-linux-arm64/install-core.sh build/dist/minimalrouter-linux-arm64/slot-exec + @chmod +x build/dist/minimalrouter-linux-arm64/install.sh build/dist/minimalrouter-linux-arm64/install-core.sh build/dist/minimalrouter-linux-arm64/slot-exec build/dist/minimalrouter-linux-arm64/init.d/routerd build/dist/minimalrouter-linux-arm64/init.d/router-applyd build/dist/minimalrouter-linux-arm64/init.d/pppoe-wan build/dist/minimalrouter-linux-arm64/init.d/cloudflared build/dist/minimalrouter-linux-arm64/ip-up.d-minimalrouter-qos @tar czf build/minimalrouter-linux-arm64.tar.gz -C build/dist minimalrouter-linux-arm64 @sh scripts/checksum-file.sh build/minimalrouter-linux-arm64.tar.gz build/minimalrouter-linux-arm64.tar.gz.sha256 @echo "=== Distribution: build/minimalrouter-linux-arm64.tar.gz ===" @@ -163,7 +163,7 @@ dist-amd64: build-linux-amd64 web-build @cp packaging/alpine/ip-up.d-minimalrouter-qos build/dist/minimalrouter-linux-amd64/ip-up.d-minimalrouter-qos @cp packaging/alpine/install-console.sh build/dist/minimalrouter-linux-amd64/install.sh @cp packaging/alpine/install-dist.sh build/dist/minimalrouter-linux-amd64/install-core.sh - @chmod +x build/dist/minimalrouter-linux-amd64/install.sh build/dist/minimalrouter-linux-amd64/install-core.sh build/dist/minimalrouter-linux-amd64/slot-exec + @chmod +x build/dist/minimalrouter-linux-amd64/install.sh build/dist/minimalrouter-linux-amd64/install-core.sh build/dist/minimalrouter-linux-amd64/slot-exec build/dist/minimalrouter-linux-amd64/init.d/routerd build/dist/minimalrouter-linux-amd64/init.d/router-applyd build/dist/minimalrouter-linux-amd64/init.d/pppoe-wan build/dist/minimalrouter-linux-amd64/init.d/cloudflared build/dist/minimalrouter-linux-amd64/ip-up.d-minimalrouter-qos @tar czf build/minimalrouter-linux-amd64.tar.gz -C build/dist minimalrouter-linux-amd64 @sh scripts/checksum-file.sh build/minimalrouter-linux-amd64.tar.gz build/minimalrouter-linux-amd64.tar.gz.sha256 @echo "=== Distribution: build/minimalrouter-linux-amd64.tar.gz ===" diff --git a/internal/api/server.go b/internal/api/server.go index 85ad92d5..bb621a71 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -401,6 +401,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/recovery/reconcile", gate(s.authMiddleware(s.handleRecoveryReconcile))) mux.HandleFunc("GET /api/v1/snapshots", gate(s.authMiddleware(s.handleGetSnapshots))) mux.HandleFunc("POST /api/v1/snapshots", gate(s.authMiddleware(s.handleCreateSnapshot))) + mux.HandleFunc("DELETE /api/v1/snapshots/{id}", gate(s.authMiddleware(s.handleDeleteSnapshot))) mux.HandleFunc("POST /api/v1/snapshots/{id}/restore", gate(s.authMiddleware(s.handleRestoreSnapshot))) mux.HandleFunc("POST /api/v1/import/pfsense/preview", gate(s.authMiddleware(s.handlePfSenseImportPreview))) mux.HandleFunc("POST /api/v1/import/pfsense/{id}/apply", gate(s.authMiddleware(s.handlePfSenseImportApply))) @@ -858,6 +859,27 @@ func (s *Server) handleCreateSnapshot(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) { + snapID := r.PathValue("id") + log.Printf("[API] DELETE /api/v1/snapshots/%s from %s\n", snapID, r.RemoteAddr) + + store := s.engine.GetStore() + if store == nil { + http.Error(w, "Snapshot store unavailable", http.StatusInternalServerError) + return + } + + if err := store.DeleteSnapshot(snapID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} + // managementContinuityErr is the single anti-lockout policy for every // configuration mutation: a candidate whose trusted_networks would exclude // the caller's source address is rejected before it can be applied. All diff --git a/internal/config/store.go b/internal/config/store.go index 4554b6c8..524a63f5 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -379,6 +379,22 @@ func (s *SQLiteStore) ListSnapshots() ([]Snapshot, error) { return snapshots, nil } +// DeleteSnapshot removes a single restore point. Restore points are signed and +// immutable, so deletion is the only mutation besides pruning. +func (s *SQLiteStore) DeleteSnapshot(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + result, err := s.db.Exec("DELETE FROM snapshots WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete snapshot: %w", err) + } + if deleted, _ := result.RowsAffected(); deleted == 0 { + return fmt.Errorf("snapshot not found: %s", id) + } + return nil +} + // GetSnapshot retrieves a specific snapshot by ID including its full config JSON. func (s *SQLiteStore) GetSnapshot(id string) (Snapshot, error) { s.mu.RLock() diff --git a/internal/startup/timeline.go b/internal/startup/timeline.go index 2ff4a643..53f41e62 100644 --- a/internal/startup/timeline.go +++ b/internal/startup/timeline.go @@ -18,7 +18,7 @@ import ( const ( Window = 10 * time.Minute CheckInterval = 1 * time.Second - SampleInterval = 30 * time.Second + SampleInterval = 1 * time.Second MaxBoots = 5 ) diff --git a/internal/telemetry/runtime.go b/internal/telemetry/runtime.go index 5570eb72..64e08485 100644 --- a/internal/telemetry/runtime.go +++ b/internal/telemetry/runtime.go @@ -29,6 +29,7 @@ type RuntimeStatus struct { LoadAverage []float64 `json:"load_average,omitempty"` MemoryUsedBytes uint64 `json:"memory_used_bytes,omitempty"` MemoryTotalBytes uint64 `json:"memory_total_bytes,omitempty"` + AppMemoryBytes uint64 `json:"app_memory_bytes,omitempty"` DiskUsedBytes uint64 `json:"disk_used_bytes,omitempty"` DiskTotalBytes uint64 `json:"disk_total_bytes,omitempty"` Storage storage.Status `json:"storage"` diff --git a/internal/telemetry/runtime_linux.go b/internal/telemetry/runtime_linux.go index 09661ce5..2bef0c28 100644 --- a/internal/telemetry/runtime_linux.go +++ b/internal/telemetry/runtime_linux.go @@ -138,6 +138,7 @@ func RuntimeSnapshot(wanInterface, lanInterface, dataDir string) RuntimeStatus { status.MemoryUsedBytes = (totalKB - availableKB) * 1024 } } + status.AppMemoryBytes = readProcessMemoryBytes() status.Storage = storage.Inspect(dataDir) status.DiskTotalBytes = status.Storage.TotalBytes status.DiskUsedBytes = status.Storage.UsedBytes @@ -381,3 +382,46 @@ func readWireGuardClientStatus() *WireGuardClientStatus { Online: peer.Online, } } + +// readProcessMemoryBytes sums the resident memory of every userspace process +// from /proc//stat (field 24, resident pages). Top-level /proc entries are +// thread-group leaders only, so threads are never counted twice. This separates +// real application footprint from the kernel file cache that inflates the +// MemTotal-minus-MemAvailable figure on a quiet appliance. +func readProcessMemoryBytes() uint64 { + pageSize := uint64(os.Getpagesize()) + entries, err := os.ReadDir("/proc") + if err != nil { + return 0 + } + var total uint64 + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid := entry.Name() + if pid[0] < '0' || pid[0] > '9' { + continue + } + data, err := os.ReadFile(filepath.Join("/proc", pid, "stat")) + if err != nil { + continue + } + // The comm field may contain spaces; everything after the final ')' + // starts at field 3 (state), so RSS is field 24 = offset 21 from there. + closing := strings.LastIndex(string(data), ")") + if closing < 0 { + continue + } + fields := strings.Fields(string(data[closing+1:])) + if len(fields) <= 21 { + continue + } + pages, parseErr := strconv.ParseUint(fields[21], 10, 64) + if parseErr != nil { + continue + } + total += pages * pageSize + } + return total +} diff --git a/packaging/alpine/cloudflared.initd b/packaging/alpine/cloudflared.initd old mode 100644 new mode 100755 diff --git a/packaging/alpine/pppoe-wan.initd b/packaging/alpine/pppoe-wan.initd old mode 100644 new mode 100755 diff --git a/web/src/ClassicDashboard.css b/web/src/ClassicDashboard.css index 39373645..ceec1c2e 100644 --- a/web/src/ClassicDashboard.css +++ b/web/src/ClassicDashboard.css @@ -1520,13 +1520,13 @@ white-space: nowrap; } -.wg-peer-identity strong { font-size: 13px; font-weight: 720; } -.wg-peer-identity code { margin-top: 4px; color: var(--classic-muted); font-size: 8px; } +.wg-peer-identity strong { font-size: 16px; font-weight: 720; } +.wg-peer-identity code { margin-top: 4px; color: var(--classic-muted); font-size: 11px; } .wg-peer-state { display: grid; min-width: 0; gap: 5px; } -.wg-peer-state > span { display: flex; align-items: center; gap: 7px; color: var(--classic-orange); font-size: 9px; font-weight: 730; white-space: nowrap; } -.wg-peer-state > span i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 10%, transparent); } -.wg-peer-state small { overflow: hidden; color: var(--classic-muted); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.wg-peer-state > span { display: flex; align-items: center; gap: 7px; color: var(--classic-orange); font-size: 13px; font-weight: 700; white-space: nowrap; } +.wg-peer-state > span i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 10%, transparent); } +.wg-peer-state small { overflow: hidden; color: var(--classic-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .wg-peer-row.is-connected .wg-peer-state > span { color: var(--classic-green); } .wg-peer-row.is-disabled .wg-peer-state > span { color: var(--classic-muted); } @@ -1539,8 +1539,8 @@ } .wg-peer-details > div { min-width: 0; } -.wg-peer-details dt { color: var(--classic-muted); font-size: 7px; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; } -.wg-peer-details dd { display: flex; min-width: 0; gap: 8px; margin: 5px 0 0; overflow: hidden; color: color-mix(in srgb, var(--classic-text) 85%, var(--classic-muted)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; } +.wg-peer-details dt { color: var(--classic-muted); font-size: 10px; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; } +.wg-peer-details dd { display: flex; min-width: 0; gap: 8px; margin: 5px 0 0; overflow: hidden; color: color-mix(in srgb, var(--classic-text) 85%, var(--classic-muted)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; } .wg-peer-details dd span { white-space: nowrap; } .wg-peer-details dd .is-rx { color: var(--classic-green); } @@ -3235,10 +3235,6 @@ /* DHCP reservations — search, veca slova, kompaktnija tabela */ .static-lease-search{margin:.9rem 0 .3rem;max-width:360px} -.static-leases table{font-size:.92rem} -.static-leases thead th{padding:.55rem .9rem;font-size:.68rem} -.static-leases tbody td{padding:.38rem .9rem} -.static-leases tbody td code{font-size:.85rem} .static-leases .empty-state{padding:1rem} @media(max-width:640px){.static-lease-search{max-width:none}} diff --git a/web/src/DashboardApp.tsx b/web/src/DashboardApp.tsx index c765ebcc..c7489ae3 100644 --- a/web/src/DashboardApp.tsx +++ b/web/src/DashboardApp.tsx @@ -533,6 +533,22 @@ function Dashboard() { } }; + const deleteSnapshot = async (id: string) => { + if (!window.confirm(`Delete snapshot ${id}? This restore point cannot be recovered.`)) return; + setBusy(true); + try { + const response = await apiFetch(`/api/v1/snapshots/${encodeURIComponent(id)}`, { method: "DELETE" }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `Delete failed (${response.status})`); + setNotice("Snapshot deleted."); + await load(); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : "Delete failed"); + } finally { + setBusy(false); + } + }; + const restoreSnapshot = async (id: string) => { if (!window.confirm("Restore this snapshot? A current undo snapshot will be retained.")) return; setBusy(true); @@ -657,6 +673,7 @@ function Dashboard() { leases={leases} load={load} restoreSnapshot={restoreSnapshot} + deleteSnapshot={deleteSnapshot} setError={setError} snapshots={snapshots} submitCloudflare={submitCloudflare} diff --git a/web/src/V015FinalTweaks.css b/web/src/V015FinalTweaks.css index 8b1ca026..bbbf5c75 100644 --- a/web/src/V015FinalTweaks.css +++ b/web/src/V015FinalTweaks.css @@ -40,13 +40,62 @@ letter-spacing: .01em; } -.gateway-service-actions { +.gateway-service-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; + padding: 16px 20px 20px; +} + +.gateway-action-tile { display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 14px; + align-items: center; + gap: 13px; + padding: 14px 16px; + border: 1px solid var(--classic-border); + border-radius: 14px; + background: color-mix(in srgb, var(--classic-soft) 42%, var(--classic-panel)); + color: var(--classic-text); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease, transform 150ms ease, box-shadow 150ms ease; +} + +.gateway-action-tile:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--classic-blue) 45%, var(--classic-border)); + background: color-mix(in srgb, var(--classic-blue) 6%, var(--classic-panel)); + box-shadow: 0 6px 18px color-mix(in srgb, var(--classic-blue) 12%, transparent); + transform: translateY(-1px); +} + +.gateway-action-tile:disabled { + opacity: .55; + cursor: default; +} + +.gateway-action-tile:focus-visible { + outline: 2px solid var(--classic-blue); + outline-offset: 2px; +} + +.gateway-action-icon { + display: grid; + width: 42px; + height: 42px; + flex: 0 0 auto; + place-items: center; + border-radius: 12px; + background: color-mix(in srgb, var(--classic-blue) 10%, var(--classic-panel)); + color: var(--classic-blue); } +.gateway-action-icon svg { width: 20px; height: 20px; } + +.gateway-action-text { display: grid; min-width: 0; gap: 3px; } +.gateway-action-text strong { font-size: 13.5px; font-weight: 720; letter-spacing: -.01em; } +.gateway-action-text small { color: var(--classic-muted); font-size: 11.5px; line-height: 1.35; } + .gateway-service-notice, .gateway-empty-copy { margin: 12px 0 0; @@ -54,30 +103,53 @@ font-size: 12px; } -.gateway-ip-events { - display: grid; - gap: 0; - margin-top: 8px; - border-top: 1px solid var(--classic-border); +.gateway-ip-scroll { + max-height: calc(12 * 46px); + overflow-y: auto; + margin-top: 0; + border-top: 0; + padding: 6px 10px 12px; } .gateway-ip-event { display: grid; - grid-template-columns: minmax(120px, auto) 20px minmax(120px, auto) 1fr; + grid-template-columns: minmax(130px, auto) 24px minmax(130px, auto) 1fr; align-items: center; - gap: 10px; + gap: 12px; min-height: 46px; - border-bottom: 1px solid var(--classic-border); - font-size: 12px; + padding: 0 14px; + border-radius: 10px; + font-size: 13px; } +.gateway-ip-event:hover { background: color-mix(in srgb, var(--classic-blue) 4%, transparent); } + .gateway-ip-event code { - font-size: 11.5px; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.gateway-ip-event code.is-old { + color: var(--classic-muted); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--classic-muted) 55%, transparent); +} + +.gateway-ip-event code.is-new { + font-weight: 700; + color: var(--classic-text); +} + +.gateway-ip-event span[aria-hidden] { + justify-self: center; + color: var(--classic-blue); } .gateway-ip-event time { justify-self: end; color: var(--classic-muted); + font-size: 12px; + white-space: nowrap; } .elegant-device-table tr.is-offline td { diff --git a/web/src/api-types.ts b/web/src/api-types.ts index f29de411..dfb11fdd 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -169,6 +169,7 @@ export type SystemStatus = { load_average?: number[]; memory_used_bytes?: number; memory_total_bytes?: number; + app_memory_bytes?: number; rx_bytes?: number; tx_bytes?: number; disk_used_bytes?: number; diff --git a/web/src/components/ClassicOverviewBase.tsx b/web/src/components/ClassicOverviewBase.tsx index 07835d67..8492e1fd 100644 --- a/web/src/components/ClassicOverviewBase.tsx +++ b/web/src/components/ClassicOverviewBase.tsx @@ -454,7 +454,7 @@ export default function ClassicOverview({

Appliance resources

Live system utilization

CPU{runtime.cpu_count || 0} logical cores
{(runtime.cpu_load_percent || 0).toFixed(2)}%
-
Memory{formatBytes(runtime.memory_used_bytes)} of {formatBytes(runtime.memory_total_bytes)}
{formatBytes(runtime.memory_used_bytes)}
+
Memory{formatBytes(runtime.memory_used_bytes)} of {formatBytes(runtime.memory_total_bytes)}{runtime.app_memory_bytes ? ` · apps ${formatBytes(runtime.app_memory_bytes)}` : ""}
{formatBytes(runtime.memory_used_bytes)}
Disk{formatBytes(runtime.disk_used_bytes)} of {formatBytes(runtime.disk_total_bytes)}
{formatBytes(runtime.disk_used_bytes)}
{resourceNote.label}
diff --git a/web/src/components/DNSFilterPanel.tsx b/web/src/components/DNSFilterPanel.tsx index 924252fd..986ae212 100644 --- a/web/src/components/DNSFilterPanel.tsx +++ b/web/src/components/DNSFilterPanel.tsx @@ -9,6 +9,7 @@ import { gridToDayWindows, HourGrid, managedServices, + normalizeDayWindows, scheduleDays, ScheduleDay, } from "../lib/deviceProfiles"; @@ -27,8 +28,22 @@ export default function DNSFilterPanel({ apiConnected, onError }: Props) { const [addresses, setAddresses] = useState(""); const [services, setServices] = useState(["youtube", "steam", "wiki"]); const [grid, setGrid] = useState(() => createDefaultKidsGrid()); + const [editingId, setEditingId] = useState(null); const dragValue = useRef(null); + const gridFromProfile = (profile: DeviceProfile): HourGrid => { + const windows = normalizeDayWindows(profile.schedule); + return Object.fromEntries(scheduleDays.map(([day]) => { + const slots = Array(24).fill(false); + for (const item of windows[day] ?? []) { + const from = Number(item.start.slice(0, 2)); + const to = item.end === "23:59" ? 24 : Number(item.end.slice(0, 2)); + for (let hour = from; hour < Math.min(to, 24); hour += 1) slots[hour] = true; + } + return [day, slots]; + })) as unknown as HourGrid; + }; + useEffect(() => { if (!apiConnected) return; void apiFetch("/api/v1/config") @@ -73,10 +88,29 @@ export default function DNSFilterPanel({ apiConnected, onError }: Props) { const closeModal = () => { setModalOpen(false); + setEditingId(null); + setName("Kids"); + setAddresses(""); + setServices(["youtube", "steam", "wiki"]); + setGrid(createDefaultKidsGrid()); + }; + + const openAdd = () => { + setEditingId(null); setName("Kids"); setAddresses(""); setServices(["youtube", "steam", "wiki"]); setGrid(createDefaultKidsGrid()); + setModalOpen(true); + }; + + const startEditProfile = (profile: DeviceProfile) => { + setEditingId(profile.id); + setName(profile.name); + setAddresses(profile.ip_addresses.join(", ")); + setServices([...profile.services]); + setGrid(gridFromProfile(profile)); + setModalOpen(true); }; const toggleGlobal = async () => { @@ -96,12 +130,20 @@ export default function DNSFilterPanel({ apiConnected, onError }: Props) { setSaving(true); try { const profile = createKidsProfile({ + id: editingId ?? undefined, name, addresses: addresses.split(","), services, dayWindows: gridToDayWindows(grid), }); - await persist(true, [...profiles, profile]); + if (editingId) { + const existing = profiles.find((item) => item.id === editingId); + await persist(true, profiles.map((item) => ( + item.id === editingId ? { ...profile, enabled: existing?.enabled ?? true } : item + ))); + } else { + await persist(true, [...profiles, profile]); + } closeModal(); onError(""); } catch (error) { @@ -166,7 +208,7 @@ export default function DNSFilterPanel({ apiConnected, onError }: Props) { return (
-

DNS Filter & Device Profiles

Scheduled service access

Devices use static LAN addresses. DNS answers populate nftables sets, and the firewall applies service schedules per device.

+

DNS Filter & Device Profiles

Scheduled service access

Devices use static LAN addresses. DNS answers populate nftables sets, and the firewall applies service schedules per device.

Filtering
{enabled ? "Active" : "Disabled"}
DNS and firewall policy
Profiles
{profiles.length}
configured devices
Active profiles
{profiles.filter((profile) => profile.enabled).length}
scheduled policies
Services
{new Set(profiles.flatMap((profile) => profile.services)).size}
unique service groups
@@ -197,7 +239,7 @@ export default function DNSFilterPanel({ apiConnected, onError }: Props) { {profile.enabled ? "Active" : "Paused"} - + ))} @@ -209,7 +251,7 @@ export default function DNSFilterPanel({ apiConnected, onError }: Props) {
-

Parental control

Add device profile

Choose the devices, managed services and the hours when access is allowed.

+

Parental control

{editingId ? "Edit device profile" : "Add device profile"}

Choose the devices, managed services and the hours when access is allowed.

diff --git a/web/src/components/DashboardSections.tsx b/web/src/components/DashboardSections.tsx index 0f0ffe2b..46497b95 100644 --- a/web/src/components/DashboardSections.tsx +++ b/web/src/components/DashboardSections.tsx @@ -53,6 +53,7 @@ type Props = { speedTesting: boolean; createSnapshot: () => Promise; restoreSnapshot: (id: string) => Promise; + deleteSnapshot: (id: string) => Promise; setError: (message: string) => void; onNavigate: (id: SectionID) => void; }; @@ -324,7 +325,7 @@ function StaticDNSRecordsEditor({ records, disabled }: { records: DNSRecordRow[] export default function DashboardSections({ active, config, gatewaySummary, gatewaySettings, runtime, leases, snapshots, busy, load, applyConfig, applyGatewayMonitoring, submitNetwork, submitCloudflare, submitSquid, - submitWiFi, submitQoS, submitWireGuardClient, runSpeedTest, toggleQoS, toggleWAN, toggleDHCP, toggleCloudflare, toggleSquid, toggleWiFi, toggleWGClient, speedTest, speedTesting, createSnapshot, restoreSnapshot, setError, onNavigate }: Props) { + submitWiFi, submitQoS, submitWireGuardClient, runSpeedTest, toggleQoS, toggleWAN, toggleDHCP, toggleCloudflare, toggleSquid, toggleWiFi, toggleWGClient, speedTest, speedTesting, createSnapshot, restoreSnapshot, deleteSnapshot, setError, onNavigate }: Props) { const [staticPrefill, setStaticPrefill] = useState<{ mac?: string; ip?: string; hostname?: string } | null>(null); const [ddnsTab, setDdnsTab] = useState(config.cloudflare.ddns_provider || "noip"); // The status card reports the provider the router is actually running, which @@ -337,8 +338,19 @@ export default function DashboardSections({ const [confirmDeletePeer, setConfirmDeletePeer] = useState<{ id: string, name: string } | null>(null); const [peerActionID, setPeerActionID] = useState(null); const [peerActionError, setPeerActionError] = useState(""); + const [renamingPeer, setRenamingPeer] = useState<{ id: string; name: string } | null>(null); const [wgPreview, setWgPreview] = useState<{ client_ip: string, server_endpoint: string } | null>(null); + const submitPeerRename = () => { + if (!renamingPeer) return; + const name = renamingPeer.name.trim(); + applyConfig((next) => { + const selected = next.wireguard.peers?.find((item: WireGuardPeer) => item.id === renamingPeer.id); + if (selected && name) selected.name = name; + }, `Peer renamed to ${name}.`); + setRenamingPeer(null); + }; + // Authoritative allocation preview from the backend (MR-AUD-005): the UI // never re-implements next-free-IP or endpoint resolution. React.useEffect(() => { @@ -576,7 +588,7 @@ export default function DashboardSections({
-
{peer.name}{peer.public_key.slice(0, 18)}…
+
{renamingPeer?.id === peer.id ? { event.preventDefault(); submitPeerRename(); }} style={{ display: "flex", gap: 6, alignItems: "center" }}> setRenamingPeer({ id: peer.id, name: event.target.value })} value={renamingPeer.name} /> : {peer.name}}{peer.public_key.slice(0, 18)}…