Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==="
Expand Down Expand Up @@ -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 ==="
Expand Down
22 changes: 22 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/config/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/startup/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
const (
Window = 10 * time.Minute
CheckInterval = 1 * time.Second
SampleInterval = 30 * time.Second
SampleInterval = 1 * time.Second
MaxBoots = 5
)

Expand Down
1 change: 1 addition & 0 deletions internal/telemetry/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
44 changes: 44 additions & 0 deletions internal/telemetry/runtime_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -381,3 +382,46 @@ func readWireGuardClientStatus() *WireGuardClientStatus {
Online: peer.Online,
}
}

// readProcessMemoryBytes sums the resident memory of every userspace process
// from /proc/<pid>/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
}
Empty file modified packaging/alpine/cloudflared.initd
100644 → 100755
Empty file.
Empty file modified packaging/alpine/pppoe-wan.initd
100644 → 100755
Empty file.
18 changes: 7 additions & 11 deletions web/src/ClassicDashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }

Expand All @@ -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); }

Expand Down Expand Up @@ -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}}

Expand Down
17 changes: 17 additions & 0 deletions web/src/DashboardApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -657,6 +673,7 @@ function Dashboard() {
leases={leases}
load={load}
restoreSnapshot={restoreSnapshot}
deleteSnapshot={deleteSnapshot}
setError={setError}
snapshots={snapshots}
submitCloudflare={submitCloudflare}
Expand Down
100 changes: 86 additions & 14 deletions web/src/V015FinalTweaks.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,44 +40,116 @@
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;
color: var(--classic-muted);
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 {
Expand Down
1 change: 1 addition & 0 deletions web/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/ClassicOverviewBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ export default function ClassicOverview({
<header className="overview-panel-header"><div><h2 id="resources-title">Appliance resources</h2><p>Live system utilization</p></div></header>
<div className="overview-resource-list">
<article><div><span>CPU</span><small>{runtime.cpu_count || 0} logical cores</small></div><strong>{(runtime.cpu_load_percent || 0).toFixed(2)}%</strong><progress max="100" value={Math.min(100, runtime.cpu_load_percent || 0)} /></article>
<article><div><span>Memory</span><small>{formatBytes(runtime.memory_used_bytes)} of {formatBytes(runtime.memory_total_bytes)}</small></div><strong>{formatBytes(runtime.memory_used_bytes)}</strong><progress max="100" value={Math.min(100, memoryPercent)} /></article>
<article><div><span>Memory</span><small title="Used includes reclaimable kernel file cache">{formatBytes(runtime.memory_used_bytes)} of {formatBytes(runtime.memory_total_bytes)}{runtime.app_memory_bytes ? ` · apps ${formatBytes(runtime.app_memory_bytes)}` : ""}</small></div><strong>{formatBytes(runtime.memory_used_bytes)}</strong><progress max="100" value={Math.min(100, memoryPercent)} /></article>
<article><div><span>Disk</span><small>{formatBytes(runtime.disk_used_bytes)} of {formatBytes(runtime.disk_total_bytes)}</small></div><strong>{formatBytes(runtime.disk_used_bytes)}</strong><progress max="100" value={Math.min(100, diskPercent)} /></article>
</div>
<div className={`overview-resource-note ${resourceNote.className}`}><OverviewIcon name="check" /><span>{resourceNote.label}</span></div>
Expand Down
Loading
Loading