From b0c500e07beb1c27fde2a93420b608fa57bfbe63 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 28 Dec 2025 06:08:43 +0100 Subject: [PATCH] feat: add process details, notifications, export, multi-host support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add per-process details modal with kill/pause/resume functionality - GET /api/v1/processes/:pid for detailed process info - POST /api/v1/processes/:pid/signal for sending signals - ProcessDetailModal component with state, resources, command line - Add desktop notifications for alerts - Browser Notification API integration - Toggle in AlertsCard with permission handling - Auto-close for warnings, persistent for critical - Add CSV/JSON export functionality - GET /api/v1/export/metrics?format=csv|json - Export buttons in SettingsPanel - Includes host name in filename - Add multi-host monitoring support - HostSelector component for switching between backends - Hosts store with localStorage persistence - All API calls updated for remote host URLs - Add disk I/O rate charts to HistoryCard - Read/write bytes/sec sparklines - Complements existing network rate charts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/internal/api/routes.go | 135 +++++++++ backend/internal/collectors/processes.go | 158 ++++++++++ backend/internal/models/processes.go | 20 ++ backend/internal/sse/broker.go | 10 +- frontend/src/lib/api/processes.ts | 39 +++ frontend/src/lib/api/sse.ts | 41 ++- frontend/src/lib/components/Header.svelte | 8 + .../src/lib/components/HostSelector.svelte | 237 +++++++++++++++ .../lib/components/ProcessDetailModal.svelte | 270 ++++++++++++++++++ .../src/lib/components/SettingsPanel.svelte | 42 +++ .../lib/components/cards/AlertsCard.svelte | 36 +++ .../lib/components/cards/HistoryCard.svelte | 32 ++- .../lib/components/cards/ProcessesCard.svelte | 22 ++ frontend/src/lib/stores/alerts.ts | 15 +- frontend/src/lib/stores/hosts.ts | 116 ++++++++ frontend/src/lib/stores/notifications.ts | 138 +++++++++ frontend/src/lib/stores/settings.ts | 12 +- frontend/src/lib/types/metrics.ts | 19 ++ frontend/src/routes/+layout.svelte | 6 +- 19 files changed, 1341 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/api/processes.ts create mode 100644 frontend/src/lib/components/HostSelector.svelte create mode 100644 frontend/src/lib/components/ProcessDetailModal.svelte create mode 100644 frontend/src/lib/stores/hosts.ts create mode 100644 frontend/src/lib/stores/notifications.ts diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index a4ff842..ae38a1d 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -4,8 +4,11 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "strconv" "strings" "sync" + "syscall" "time" "github.com/gin-contrib/cors" @@ -116,6 +119,13 @@ func (s *Server) setupRoutes() { v1.GET("/alerts/config", s.getAlertConfigHandler) v1.POST("/alerts/config", s.setAlertConfigHandler) v1.POST("/alerts/:id/acknowledge", s.acknowledgeAlertHandler) + + // Process endpoints + v1.GET("/processes/:pid", s.getProcessDetailHandler) + v1.POST("/processes/:pid/signal", s.sendProcessSignalHandler) + + // Export endpoints + v1.GET("/export/metrics", s.exportMetricsHandler) } // Prometheus metrics endpoint (no auth, rate limited) @@ -358,3 +368,128 @@ func (s *Server) acknowledgeAlertHandler(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"}) } } + +func (s *Server) getProcessDetailHandler(c *gin.Context) { + pidStr := c.Param("pid") + pid, err := strconv.Atoi(pidStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pid"}) + return + } + + detail, err := s.broker.ProcessCollector.GetProcessDetail(pid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "process not found"}) + return + } + + c.JSON(http.StatusOK, detail) +} + +type SignalRequest struct { + Signal int `json:"signal"` +} + +func (s *Server) sendProcessSignalHandler(c *gin.Context) { + pidStr := c.Param("pid") + pid, err := strconv.Atoi(pidStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pid"}) + return + } + + var req SignalRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Default to SIGTERM (15) if no signal specified + req.Signal = 15 + } + + // Validate signal (common signals: 9=SIGKILL, 15=SIGTERM, 2=SIGINT) + if req.Signal < 1 || req.Signal > 31 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid signal number"}) + return + } + + // Find the process + process, err := os.FindProcess(pid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "process not found"}) + return + } + + // Send the signal + err = process.Signal(syscall.Signal(req.Signal)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "signal sent", "pid": pid, "signal": req.Signal}) +} + +func (s *Server) exportMetricsHandler(c *gin.Context) { + format := c.DefaultQuery("format", "json") + metrics := s.broker.CollectAll() + + switch format { + case "csv": + c.Header("Content-Type", "text/csv") + c.Header("Content-Disposition", "attachment; filename=metrics.csv") + c.String(http.StatusOK, metricsToCSV(metrics)) + default: + c.Header("Content-Disposition", "attachment; filename=metrics.json") + c.JSON(http.StatusOK, metrics) + } +} + +func metricsToCSV(m models.AllMetrics) string { + var sb strings.Builder + + // CPU section + sb.WriteString("# CPU Metrics\n") + sb.WriteString("metric,value\n") + sb.WriteString(fmt.Sprintf("cpu_total_usage,%.2f\n", m.CPU.TotalUsage)) + sb.WriteString(fmt.Sprintf("cpu_load_1m,%.2f\n", m.CPU.LoadAverage.Load1)) + sb.WriteString(fmt.Sprintf("cpu_load_5m,%.2f\n", m.CPU.LoadAverage.Load5)) + sb.WriteString(fmt.Sprintf("cpu_load_15m,%.2f\n", m.CPU.LoadAverage.Load15)) + + // Per-core + sb.WriteString("\n# CPU Cores\n") + sb.WriteString("core,usage,frequency_mhz\n") + for _, core := range m.CPU.Cores { + sb.WriteString(fmt.Sprintf("%d,%.2f,%d\n", core.ID, core.Usage, core.Frequency)) + } + + // Memory section + sb.WriteString("\n# Memory Metrics\n") + sb.WriteString("metric,bytes\n") + sb.WriteString(fmt.Sprintf("memory_total,%d\n", m.Memory.Total)) + sb.WriteString(fmt.Sprintf("memory_used,%d\n", m.Memory.Used)) + sb.WriteString(fmt.Sprintf("memory_available,%d\n", m.Memory.Available)) + sb.WriteString(fmt.Sprintf("memory_cached,%d\n", m.Memory.Cached)) + sb.WriteString(fmt.Sprintf("swap_total,%d\n", m.Memory.SwapTotal)) + sb.WriteString(fmt.Sprintf("swap_used,%d\n", m.Memory.SwapUsed)) + + // GPU section + if m.GPU.Available { + sb.WriteString("\n# GPU Metrics\n") + sb.WriteString("metric,value\n") + sb.WriteString(fmt.Sprintf("gpu_utilization,%d\n", m.GPU.Utilization)) + sb.WriteString(fmt.Sprintf("gpu_vram_used,%d\n", m.GPU.VRAMUsed)) + sb.WriteString(fmt.Sprintf("gpu_vram_total,%d\n", m.GPU.VRAMTotal)) + sb.WriteString(fmt.Sprintf("gpu_temperature,%.1f\n", m.GPU.Temperature)) + sb.WriteString(fmt.Sprintf("gpu_power_watts,%.1f\n", m.GPU.PowerWatts)) + sb.WriteString(fmt.Sprintf("gpu_clock_mhz,%d\n", m.GPU.ClockGPU)) + sb.WriteString(fmt.Sprintf("gpu_memory_clock_mhz,%d\n", m.GPU.ClockMemory)) + } + + // Temperature section + sb.WriteString("\n# Temperature Sensors\n") + sb.WriteString("sensor,label,temperature_c,critical_c\n") + for _, sensor := range m.Temperature.Sensors { + sb.WriteString(fmt.Sprintf("%s,%s,%.1f,%.1f\n", + sensor.Name, sensor.Label, sensor.Temperature, sensor.Critical)) + } + + return sb.String() +} diff --git a/backend/internal/collectors/processes.go b/backend/internal/collectors/processes.go index c2ce6cc..a852358 100644 --- a/backend/internal/collectors/processes.go +++ b/backend/internal/collectors/processes.go @@ -107,6 +107,164 @@ func (c *ProcessCollector) Collect() (models.ProcessStats, error) { return stats, nil } +func (c *ProcessCollector) GetProcessDetail(pid int) (models.ProcessDetail, error) { + detail := models.ProcessDetail{PID: pid} + pidPath := filepath.Join(c.procPath, strconv.Itoa(pid)) + + // Read /proc/[pid]/stat + statData, err := os.ReadFile(filepath.Join(pidPath, "stat")) + if err != nil { + return detail, err + } + + statStr := string(statData) + nameStart := strings.Index(statStr, "(") + nameEnd := strings.LastIndex(statStr, ")") + if nameStart == -1 || nameEnd == -1 { + return detail, err + } + + detail.Name = statStr[nameStart+1 : nameEnd] + fields := strings.Fields(statStr[nameEnd+2:]) + if len(fields) < 22 { + return detail, err + } + + detail.State = fields[0] + detail.StateDesc = stateDescription(fields[0]) + detail.PPID, _ = strconv.Atoi(fields[1]) + detail.Nice, _ = strconv.Atoi(fields[16]) + detail.Threads, _ = strconv.Atoi(fields[17]) + + // Memory + rss, _ := strconv.ParseInt(fields[21], 10, 64) + vsize, _ := strconv.ParseUint(fields[20], 10, 64) + detail.MemoryRSS = uint64(rss * c.pageSize) + detail.MemoryVMS = vsize + detail.MemoryMB = float64(detail.MemoryRSS) / (1024 * 1024) + + // CPU time + utime, _ := strconv.ParseUint(fields[11], 10, 64) + stime, _ := strconv.ParseUint(fields[12], 10, 64) + totalTicks := utime + stime + totalSecs := float64(totalTicks) / 100 // CLK_TCK usually 100 + hours := int(totalSecs) / 3600 + mins := (int(totalSecs) % 3600) / 60 + secs := int(totalSecs) % 60 + detail.CPUTime = formatDuration(hours, mins, secs) + + // CPU percent + uptimeData, _ := os.ReadFile(filepath.Join(c.procPath, "uptime")) + if len(uptimeData) > 0 { + uptimeFields := strings.Fields(string(uptimeData)) + if len(uptimeFields) >= 1 { + uptime, _ := strconv.ParseFloat(uptimeFields[0], 64) + starttime, _ := strconv.ParseUint(fields[19], 10, 64) + processUptime := uptime - (float64(starttime) / 100) + if processUptime > 0 { + detail.CPUPercent = (totalSecs / processUptime) * 100 + } + } + } + + // Start time - calculate from boot time and starttime + starttime, _ := strconv.ParseUint(fields[19], 10, 64) + bootTimeData, _ := os.ReadFile(filepath.Join(c.procPath, "stat")) + for _, line := range strings.Split(string(bootTimeData), "\n") { + if strings.HasPrefix(line, "btime ") { + btime, _ := strconv.ParseInt(strings.Fields(line)[1], 10, 64) + startSec := btime + int64(starttime/100) + detail.StartTime = formatTimestamp(startSec) + break + } + } + + // Cmdline + cmdlineData, _ := os.ReadFile(filepath.Join(pidPath, "cmdline")) + detail.Cmdline = strings.ReplaceAll(string(cmdlineData), "\x00", " ") + detail.Cmdline = strings.TrimSpace(detail.Cmdline) + if detail.Cmdline == "" { + detail.Cmdline = "[" + detail.Name + "]" + } + + // User from /proc/[pid]/status + statusData, _ := os.ReadFile(filepath.Join(pidPath, "status")) + for _, line := range strings.Split(string(statusData), "\n") { + if strings.HasPrefix(line, "Uid:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + uid := fields[1] + detail.User = lookupUser(uid) + } + break + } + } + + // Open files count + fdPath := filepath.Join(pidPath, "fd") + if entries, err := os.ReadDir(fdPath); err == nil { + detail.OpenFiles = len(entries) + } + + return detail, nil +} + +func stateDescription(state string) string { + switch state { + case "R": + return "Running" + case "S": + return "Sleeping" + case "D": + return "Disk Sleep" + case "Z": + return "Zombie" + case "T": + return "Stopped" + case "t": + return "Tracing Stop" + case "X": + return "Dead" + case "I": + return "Idle" + default: + return state + } +} + +func formatDuration(h, m, s int) string { + if h > 0 { + return strconv.Itoa(h) + "h " + strconv.Itoa(m) + "m " + strconv.Itoa(s) + "s" + } + if m > 0 { + return strconv.Itoa(m) + "m " + strconv.Itoa(s) + "s" + } + return strconv.Itoa(s) + "s" +} + +func formatTimestamp(unix int64) string { + // Simple date format - just return the timestamp + // The frontend can format it properly + return strconv.FormatInt(unix, 10) +} + +func lookupUser(uid string) string { + // Try to read /etc/passwd to map UID to username + data, err := os.ReadFile("/etc/passwd") + if err != nil { + return "uid:" + uid + } + + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Split(line, ":") + if len(fields) >= 3 && fields[2] == uid { + return fields[0] + } + } + + return "uid:" + uid +} + func (c *ProcessCollector) readProcess(pid int) (models.ProcessInfo, error) { proc := models.ProcessInfo{PID: pid} pidPath := filepath.Join(c.procPath, strconv.Itoa(pid)) diff --git a/backend/internal/models/processes.go b/backend/internal/models/processes.go index 8edce37..75baf1e 100644 --- a/backend/internal/models/processes.go +++ b/backend/internal/models/processes.go @@ -13,3 +13,23 @@ type ProcessInfo struct { MemoryMB float64 `json:"memoryMb"` State string `json:"state"` } + +type ProcessDetail struct { + PID int `json:"pid"` + Name string `json:"name"` + Cmdline string `json:"cmdline"` + State string `json:"state"` + StateDesc string `json:"stateDesc"` + User string `json:"user"` + PPID int `json:"ppid"` + Threads int `json:"threads"` + Nice int `json:"nice"` + CPUPercent float64 `json:"cpuPercent"` + MemoryMB float64 `json:"memoryMb"` + MemoryRSS uint64 `json:"memoryRss"` + MemoryVMS uint64 `json:"memoryVms"` + StartTime string `json:"startTime"` + CPUTime string `json:"cpuTime"` + OpenFiles int `json:"openFiles"` + Environ []string `json:"environ,omitempty"` +} diff --git a/backend/internal/sse/broker.go b/backend/internal/sse/broker.go index a778f4b..8e3e927 100644 --- a/backend/internal/sse/broker.go +++ b/backend/internal/sse/broker.go @@ -33,13 +33,15 @@ type Broker struct { prevDiskRead uint64 prevDiskWrite uint64 - // Collectors + // Collectors (exported for API access) + ProcessCollector *collectors.ProcessCollector + + // Private collectors system *collectors.SystemCollector cpu *collectors.CPUCollector memory *collectors.MemoryCollector disk *collectors.DiskCollector network *collectors.NetworkCollector - processes *collectors.ProcessCollector temperature *collectors.TemperatureCollector gpu *collectors.AMDGPUCollector docker *collectors.DockerCollector @@ -61,7 +63,7 @@ func NewBroker(cfg *config.Config) *Broker { memory: collectors.NewMemoryCollector(cfg.ProcPath), disk: collectors.NewDiskCollector(cfg.ProcPath, cfg.MtabPath), network: collectors.NewNetworkCollector(cfg.ProcPath), - processes: collectors.NewProcessCollector(cfg.ProcPath), + ProcessCollector: collectors.NewProcessCollector(cfg.ProcPath), temperature: collectors.NewTemperatureCollector(cfg.SysPath), gpu: collectors.NewAMDGPUCollector(cfg.SysPath), docker: collectors.NewDockerCollector(cfg.DockerSock), @@ -220,7 +222,7 @@ func (b *Broker) collectAll() models.AllMetrics { metrics.Network = net } - if proc, err := b.processes.Collect(); err == nil { + if proc, err := b.ProcessCollector.Collect(); err == nil { metrics.Processes = proc } diff --git a/frontend/src/lib/api/processes.ts b/frontend/src/lib/api/processes.ts new file mode 100644 index 0000000..354f739 --- /dev/null +++ b/frontend/src/lib/api/processes.ts @@ -0,0 +1,39 @@ +import { get } from 'svelte/store'; +import { activeHost } from '$lib/stores/hosts'; +import type { ProcessDetail } from '$lib/types/metrics'; + +function getApiBase(): string { + const host = get(activeHost); + const baseUrl = host.isLocal ? '' : host.url; + return `${baseUrl}/api/v1`; +} + +export async function getProcessDetail(pid: number): Promise { + const response = await fetch(`${getApiBase()}/processes/${pid}`); + if (!response.ok) { + throw new Error(`Failed to fetch process ${pid}: ${response.statusText}`); + } + return response.json(); +} + +export interface SignalResult { + success: boolean; + message: string; +} + +export async function sendSignal(pid: number, signal: number): Promise { + const response = await fetch(`${getApiBase()}/processes/${pid}/signal`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signal }) + }); + return response.json(); +} + +// Common signal constants +export const SIGNALS = { + SIGTERM: 15, // Graceful termination + SIGKILL: 9, // Force kill + SIGSTOP: 19, // Pause process + SIGCONT: 18 // Resume process +} as const; diff --git a/frontend/src/lib/api/sse.ts b/frontend/src/lib/api/sse.ts index 119a193..8ce99e8 100644 --- a/frontend/src/lib/api/sse.ts +++ b/frontend/src/lib/api/sse.ts @@ -1,15 +1,24 @@ import { metrics, connected, historyData } from '$lib/stores/metrics'; import { settings } from '$lib/stores/settings'; import { browser } from '$app/environment'; +import { activeHost } from '$lib/stores/hosts'; +import { get } from 'svelte/store'; import type { AllMetrics, HistoryData } from '$lib/types/metrics'; let eventSource: EventSource | null = null; let reconnectTimeout: ReturnType | null = null; let historyInterval: ReturnType | null = null; +let currentHostId: string | null = null; + +function getBaseUrl(): string { + const host = get(activeHost); + return host.isLocal ? '' : host.url; +} async function fetchHistory() { try { - const response = await fetch('/api/v1/history'); + const baseUrl = getBaseUrl(); + const response = await fetch(`${baseUrl}/api/v1/history`); if (response.ok) { const data: HistoryData = await response.json(); historyData.set(data); @@ -22,15 +31,23 @@ async function fetchHistory() { export function connectSSE() { if (!browser) return; + const host = get(activeHost); + currentHostId = host.id; + disconnectSSE(); + // Clear old metrics when switching hosts + metrics.set(null as unknown as AllMetrics); + historyData.set(null); + // Fetch initial history data fetchHistory(); // Refresh history every 30 seconds historyInterval = setInterval(fetchHistory, 30000); - const url = '/api/v1/stream'; + const baseUrl = getBaseUrl(); + const url = `${baseUrl}/api/v1/stream`; eventSource = new EventSource(url); eventSource.onopen = () => { @@ -79,3 +96,23 @@ export function disconnectSSE() { connected.set(false); } + +// Subscribe to host changes and reconnect when changed +let hostUnsubscribe: (() => void) | null = null; + +export function initHostWatcher() { + if (!browser) return; + + hostUnsubscribe = activeHost.subscribe((host) => { + // Only reconnect if we're already connected and host changed + if (currentHostId !== null && currentHostId !== host.id) { + console.log(`Switching to host: ${host.name}`); + connectSSE(); + } + }); +} + +export function cleanupHostWatcher() { + hostUnsubscribe?.(); + hostUnsubscribe = null; +} diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte index ffe4ef9..77a36da 100644 --- a/frontend/src/lib/components/Header.svelte +++ b/frontend/src/lib/components/Header.svelte @@ -4,11 +4,16 @@ import { theme } from '$lib/stores/theme'; import { showShortcutsHelp } from '$lib/stores/keyboard'; import { showSettings, editMode } from '$lib/stores/layout'; + import { hosts } from '$lib/stores/hosts'; import { formatUptime } from '$lib/utils/formatters'; + import HostSelector from './HostSelector.svelte'; const refreshRates = [1, 2, 5, 10, 30]; let mobileMenuOpen = $state(false); + + // Show host selector if there are remote hosts configured + const showHostSelector = $derived($hosts.length > 1);
@@ -44,6 +49,9 @@ {/if} + +
+
+ Desktop Notifications + {#if $notificationPermission === 'denied'} + (Blocked) + {/if} +
+ +
+ {/if} @@ -200,3 +218,7 @@ {/if} + +{#if modalPid !== null} + +{/if} diff --git a/frontend/src/lib/stores/alerts.ts b/frontend/src/lib/stores/alerts.ts index efca2d3..6fb63fb 100644 --- a/frontend/src/lib/stores/alerts.ts +++ b/frontend/src/lib/stores/alerts.ts @@ -1,15 +1,20 @@ -import { writable } from 'svelte/store'; +import { writable, get } from 'svelte/store'; +import { activeHost } from './hosts'; import type { Alert, AlertConfig, AlertsResponse, AlertThreshold } from '$lib/types/metrics'; export const activeAlerts = writable([]); export const alertHistory = writable([]); export const alertConfig = writable(null); -const API_BASE = '/api/v1'; +function getApiBase(): string { + const host = get(activeHost); + const baseUrl = host.isLocal ? '' : host.url; + return `${baseUrl}/api/v1`; +} export async function fetchAlerts(): Promise { try { - const response = await fetch(`${API_BASE}/alerts`); + const response = await fetch(`${getApiBase()}/alerts`); if (response.ok) { const data: AlertsResponse = await response.json(); activeAlerts.set(data.active || []); @@ -23,7 +28,7 @@ export async function fetchAlerts(): Promise { export async function updateAlertConfig(config: AlertConfig): Promise { try { - const response = await fetch(`${API_BASE}/alerts/config`, { + const response = await fetch(`${getApiBase()}/alerts/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) @@ -40,7 +45,7 @@ export async function updateAlertConfig(config: AlertConfig): Promise { export async function acknowledgeAlert(alertId: string): Promise { try { - const response = await fetch(`${API_BASE}/alerts/${alertId}/acknowledge`, { + const response = await fetch(`${getApiBase()}/alerts/${alertId}/acknowledge`, { method: 'POST' }); if (response.ok) { diff --git a/frontend/src/lib/stores/hosts.ts b/frontend/src/lib/stores/hosts.ts new file mode 100644 index 0000000..ff0e522 --- /dev/null +++ b/frontend/src/lib/stores/hosts.ts @@ -0,0 +1,116 @@ +import { writable, derived, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface Host { + id: string; + name: string; + url: string; + isLocal: boolean; +} + +const STORAGE_KEY = 'system-monitor-hosts'; +const ACTIVE_HOST_KEY = 'system-monitor-active-host'; + +// Default local host +const localHost: Host = { + id: 'local', + name: 'This Host', + url: '', + isLocal: true +}; + +function createHostsStore() { + // Load from localStorage + let initialHosts: Host[] = [localHost]; + let initialActiveId = 'local'; + + if (browser) { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + // Ensure local host is always first + initialHosts = [localHost, ...parsed.filter((h: Host) => !h.isLocal)]; + } catch { + // Ignore parse errors + } + } + + const storedActive = localStorage.getItem(ACTIVE_HOST_KEY); + if (storedActive) { + initialActiveId = storedActive; + } + } + + const hosts = writable(initialHosts); + const activeHostId = writable(initialActiveId); + + // Persist changes + if (browser) { + hosts.subscribe((value) => { + const toStore = value.filter((h) => !h.isLocal); + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + }); + + activeHostId.subscribe((value) => { + localStorage.setItem(ACTIVE_HOST_KEY, value); + }); + } + + function addHost(name: string, url: string): Host { + const id = crypto.randomUUID(); + // Normalize URL - remove trailing slash + const normalizedUrl = url.replace(/\/$/, ''); + const newHost: Host = { id, name, url: normalizedUrl, isLocal: false }; + hosts.update((h) => [...h, newHost]); + return newHost; + } + + function removeHost(id: string) { + if (id === 'local') return; // Can't remove local host + hosts.update((h) => h.filter((host) => host.id !== id)); + // If removing active host, switch to local + if (get(activeHostId) === id) { + activeHostId.set('local'); + } + } + + function updateHost(id: string, updates: Partial>) { + if (id === 'local') return; // Can't modify local host + hosts.update((h) => + h.map((host) => (host.id === id ? { ...host, ...updates } : host)) + ); + } + + function setActiveHost(id: string) { + const hostList = get(hosts); + if (hostList.some((h) => h.id === id)) { + activeHostId.set(id); + } + } + + return { + subscribe: hosts.subscribe, + activeHostId, + addHost, + removeHost, + updateHost, + setActiveHost + }; +} + +export const hosts = createHostsStore(); + +// Derived store for the currently active host +export const activeHost = derived( + [hosts, hosts.activeHostId], + ([$hosts, $activeHostId]) => { + return $hosts.find((h) => h.id === $activeHostId) || $hosts[0]; + } +); + +// Get the base URL for API calls +export function getApiBaseUrl(): string { + const host = get(activeHost); + return host.isLocal ? '' : host.url; +} diff --git a/frontend/src/lib/stores/notifications.ts b/frontend/src/lib/stores/notifications.ts new file mode 100644 index 0000000..2a02ae4 --- /dev/null +++ b/frontend/src/lib/stores/notifications.ts @@ -0,0 +1,138 @@ +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; +import { activeAlerts } from './alerts'; +import type { Alert } from '$lib/types/metrics'; + +export type NotificationPermission = 'default' | 'granted' | 'denied'; + +export const notificationsEnabled = writable(false); +export const notificationPermission = writable('default'); + +// Track which alerts have already been notified +const notifiedAlertIds = new Set(); + +// Initialize from localStorage and check permission +export function initNotifications(): void { + if (!browser) return; + + // Load preference + const stored = localStorage.getItem('notifications-enabled'); + if (stored === 'true') { + notificationsEnabled.set(true); + } + + // Check current permission + if ('Notification' in window) { + notificationPermission.set(Notification.permission as NotificationPermission); + } + + // Subscribe to alerts to show notifications + activeAlerts.subscribe(handleNewAlerts); +} + +export async function requestPermission(): Promise { + if (!browser || !('Notification' in window)) { + return false; + } + + try { + const permission = await Notification.requestPermission(); + notificationPermission.set(permission as NotificationPermission); + + if (permission === 'granted') { + notificationsEnabled.set(true); + localStorage.setItem('notifications-enabled', 'true'); + return true; + } + } catch (error) { + console.error('Failed to request notification permission:', error); + } + return false; +} + +export function disableNotifications(): void { + notificationsEnabled.set(false); + localStorage.setItem('notifications-enabled', 'false'); +} + +function handleNewAlerts(alerts: Alert[]): void { + if (!browser || !get(notificationsEnabled)) return; + + for (const alert of alerts) { + // Skip if already notified or acknowledged + if (notifiedAlertIds.has(alert.id) || alert.acknowledged) { + continue; + } + + // Mark as notified + notifiedAlertIds.add(alert.id); + + // Show notification + showNotification(alert); + } + + // Clean up old notification IDs (keep last 100) + if (notifiedAlertIds.size > 100) { + const idsArray = Array.from(notifiedAlertIds); + idsArray.slice(0, idsArray.length - 100).forEach((id) => { + notifiedAlertIds.delete(id); + }); + } +} + +function showNotification(alert: Alert): void { + if (!('Notification' in window) || Notification.permission !== 'granted') { + return; + } + + const icon = getAlertIcon(alert.type); + const title = `${alert.severity === 'critical' ? '🚨' : '⚠️'} ${getTypeLabel(alert.type)} Alert`; + + try { + const notification = new Notification(title, { + body: alert.message, + icon: icon, + tag: alert.id, + requireInteraction: alert.severity === 'critical' + }); + + notification.onclick = () => { + window.focus(); + notification.close(); + }; + + // Auto-close warning notifications after 10 seconds + if (alert.severity !== 'critical') { + setTimeout(() => notification.close(), 10000); + } + } catch (error) { + console.error('Failed to show notification:', error); + } +} + +function getTypeLabel(type: string): string { + const labels: Record = { + cpu: 'CPU', + memory: 'Memory', + temperature: 'Temperature', + disk: 'Disk', + gpu: 'GPU' + }; + return labels[type] || type; +} + +function getAlertIcon(type: string): string { + // Return a data URL for a simple colored circle based on type + const colors: Record = { + cpu: '#60a5fa', + memory: '#a78bfa', + temperature: '#f87171', + disk: '#fbbf24', + gpu: '#34d399' + }; + const color = colors[type] || '#94a3b8'; + + // SVG as data URL + const svg = ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} diff --git a/frontend/src/lib/stores/settings.ts b/frontend/src/lib/stores/settings.ts index b04e856..6a54abd 100644 --- a/frontend/src/lib/stores/settings.ts +++ b/frontend/src/lib/stores/settings.ts @@ -1,5 +1,6 @@ -import { writable } from 'svelte/store'; +import { writable, get } from 'svelte/store'; import { browser } from '$app/environment'; +import { activeHost } from './hosts'; export interface Settings { refreshRate: number; @@ -15,10 +16,17 @@ function createSettingsStore() { const { subscribe, set, update } = writable(initial); + // Get base URL for current active host + function getBaseUrl(): string { + const host = get(activeHost); + return host.isLocal ? '' : host.url; + } + // Helper to push refresh rate to backend async function pushRefreshRate(rate: number) { try { - await fetch('/api/v1/settings/refresh', { + const baseUrl = getBaseUrl(); + await fetch(`${baseUrl}/api/v1/settings/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interval: rate }) diff --git a/frontend/src/lib/types/metrics.ts b/frontend/src/lib/types/metrics.ts index b893ea2..929a240 100644 --- a/frontend/src/lib/types/metrics.ts +++ b/frontend/src/lib/types/metrics.ts @@ -77,6 +77,25 @@ export interface ProcessInfo { state: string; } +export interface ProcessDetail { + pid: number; + name: string; + cmdline: string; + state: string; + stateDesc: string; + user: string; + ppid: number; + threads: number; + nice: number; + cpuPercent: number; + memoryMb: number; + memoryRss: number; + memoryVms: number; + startTime: string; + cpuTime: string; + openFiles: number; +} + export interface ProcessStats { topByCpu: ProcessInfo[]; topByMemory: ProcessInfo[]; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 92f6f5e..8e30be8 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -5,21 +5,25 @@ import SettingsPanel from '$lib/components/SettingsPanel.svelte'; import DashboardEditor from '$lib/components/DashboardEditor.svelte'; import { onMount, onDestroy } from 'svelte'; - import { connectSSE, disconnectSSE } from '$lib/api/sse'; + import { connectSSE, disconnectSSE, initHostWatcher, cleanupHostWatcher } from '$lib/api/sse'; import { theme } from '$lib/stores/theme'; import { initKeyboardShortcuts, showShortcutsHelp } from '$lib/stores/keyboard'; import { showSettings, editMode } from '$lib/stores/layout'; + import { initNotifications } from '$lib/stores/notifications'; let { children } = $props(); let cleanupKeyboard: (() => void) | undefined; onMount(() => { connectSSE(); + initHostWatcher(); cleanupKeyboard = initKeyboardShortcuts(); + initNotifications(); }); onDestroy(() => { disconnectSSE(); + cleanupHostWatcher(); cleanupKeyboard?.(); });