feat: add process details, notifications, export, multi-host support

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 06:08:43 +01:00
parent 1e83819318
commit b0c500e07b
19 changed files with 1341 additions and 15 deletions

View File

@@ -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()
}

View File

@@ -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))

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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<ProcessDetail> {
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<SignalResult> {
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;

View File

@@ -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<typeof setTimeout> | null = null;
let historyInterval: ReturnType<typeof setInterval> | 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;
}

View File

@@ -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);
</script>
<header class="sticky top-0 z-50 backdrop-blur-xl border-b {$theme === 'light' ? 'border-black/5' : 'border-white/5'}">
@@ -44,6 +49,9 @@
<!-- Desktop controls -->
<div class="hidden sm:flex items-center gap-3 sm:gap-4">
<!-- Host selector -->
<HostSelector />
<!-- Refresh rate -->
<div class="flex items-center gap-2">
<span class="text-xs {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'} hidden md:inline">Refresh</span>

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { hosts, activeHost, type Host } from '$lib/stores/hosts';
import { theme } from '$lib/stores/theme';
let isOpen = $state(false);
let showAddForm = $state(false);
let newHostName = $state('');
let newHostUrl = $state('');
let editingHost = $state<Host | null>(null);
function toggleDropdown() {
isOpen = !isOpen;
if (!isOpen) {
showAddForm = false;
editingHost = null;
}
}
function selectHost(id: string) {
hosts.setActiveHost(id);
isOpen = false;
}
function handleAddHost() {
if (newHostName.trim() && newHostUrl.trim()) {
hosts.addHost(newHostName.trim(), newHostUrl.trim());
newHostName = '';
newHostUrl = '';
showAddForm = false;
}
}
function handleRemoveHost(id: string, e: MouseEvent) {
e.stopPropagation();
hosts.removeHost(id);
}
function handleEditHost(host: Host, e: MouseEvent) {
e.stopPropagation();
editingHost = host;
newHostName = host.name;
newHostUrl = host.url;
}
function handleSaveEdit() {
if (editingHost && newHostName.trim() && newHostUrl.trim()) {
hosts.updateHost(editingHost.id, {
name: newHostName.trim(),
url: newHostUrl.trim()
});
editingHost = null;
newHostName = '';
newHostUrl = '';
}
}
function handleBackdropClick() {
isOpen = false;
showAddForm = false;
editingHost = null;
}
</script>
{#if isOpen}
<div
class="fixed inset-0 z-40"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && handleBackdropClick()}
role="button"
tabindex="-1"
></div>
{/if}
<div class="relative">
<button
onclick={toggleDropdown}
class="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors
{$theme === 'light' ? 'bg-black/5 hover:bg-black/10 text-slate-700' : 'bg-white/5 hover:bg-white/10 text-slate-200'}"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
<span class="text-sm max-w-[100px] truncate">{$activeHost.name}</span>
<svg class="w-4 h-4 transition-transform {isOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<div class="absolute top-full right-0 mt-2 w-72 rounded-xl shadow-2xl border z-50
{$theme === 'light' ? 'bg-white border-slate-200' : 'bg-slate-800 border-slate-700'}">
<!-- Host List -->
<div class="p-2 max-h-64 overflow-y-auto">
{#each $hosts as host (host.id)}
{#if editingHost?.id === host.id}
<!-- Edit Form -->
<div class="p-3 rounded-lg {$theme === 'light' ? 'bg-slate-50' : 'bg-slate-700/50'}">
<input
type="text"
bind:value={newHostName}
placeholder="Name"
class="w-full px-3 py-1.5 text-sm rounded-lg mb-2
{$theme === 'light' ? 'bg-white border-slate-300' : 'bg-slate-800 border-slate-600'} border"
/>
<input
type="url"
bind:value={newHostUrl}
placeholder="http://host:9847"
class="w-full px-3 py-1.5 text-sm rounded-lg mb-2
{$theme === 'light' ? 'bg-white border-slate-300' : 'bg-slate-800 border-slate-600'} border"
/>
<div class="flex gap-2">
<button
onclick={handleSaveEdit}
class="flex-1 px-3 py-1.5 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600"
>
Save
</button>
<button
onclick={() => { editingHost = null; newHostName = ''; newHostUrl = ''; }}
class="px-3 py-1.5 text-sm rounded-lg
{$theme === 'light' ? 'bg-slate-200 hover:bg-slate-300' : 'bg-slate-600 hover:bg-slate-500'}"
>
Cancel
</button>
</div>
</div>
{:else}
<button
onclick={() => selectHost(host.id)}
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-left
{$activeHost.id === host.id
? ($theme === 'light' ? 'bg-blue-50 text-blue-700' : 'bg-blue-500/20 text-blue-300')
: ($theme === 'light' ? 'hover:bg-slate-50' : 'hover:bg-slate-700/50')}"
>
<div class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
{host.isLocal
? 'bg-emerald-500/20 text-emerald-500'
: 'bg-purple-500/20 text-purple-500'}">
{#if host.isLocal}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate {$theme === 'light' ? 'text-slate-800' : 'text-white'}">
{host.name}
</div>
<div class="text-xs truncate {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}">
{host.isLocal ? 'Current machine' : host.url}
</div>
</div>
{#if $activeHost.id === host.id}
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
{/if}
{#if !host.isLocal}
<div class="flex gap-1">
<button
onclick={(e) => handleEditHost(host, e)}
class="p-1 rounded hover:bg-white/10 text-slate-400 hover:text-slate-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
onclick={(e) => handleRemoveHost(host.id, e)}
class="p-1 rounded hover:bg-red-500/20 text-slate-400 hover:text-red-400"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/if}
</button>
{/if}
{/each}
</div>
<!-- Add Host Form -->
{#if showAddForm}
<div class="p-3 border-t {$theme === 'light' ? 'border-slate-200' : 'border-slate-700'}">
<input
type="text"
bind:value={newHostName}
placeholder="Host name (e.g., Web Server)"
class="w-full px-3 py-2 text-sm rounded-lg mb-2
{$theme === 'light' ? 'bg-slate-50 border-slate-300' : 'bg-slate-700 border-slate-600'} border"
/>
<input
type="url"
bind:value={newHostUrl}
placeholder="http://192.168.1.100:9847"
class="w-full px-3 py-2 text-sm rounded-lg mb-2
{$theme === 'light' ? 'bg-slate-50 border-slate-300' : 'bg-slate-700 border-slate-600'} border"
/>
<div class="flex gap-2">
<button
onclick={handleAddHost}
disabled={!newHostName.trim() || !newHostUrl.trim()}
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Host
</button>
<button
onclick={() => { showAddForm = false; newHostName = ''; newHostUrl = ''; }}
class="px-3 py-2 text-sm rounded-lg
{$theme === 'light' ? 'bg-slate-100 hover:bg-slate-200' : 'bg-slate-700 hover:bg-slate-600'}"
>
Cancel
</button>
</div>
</div>
{:else}
<div class="p-2 border-t {$theme === 'light' ? 'border-slate-200' : 'border-slate-700'}">
<button
onclick={() => showAddForm = true}
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors
{$theme === 'light' ? 'text-blue-600 hover:bg-blue-50' : 'text-blue-400 hover:bg-blue-500/20'}"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Remote Host
</button>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import { getProcessDetail, sendSignal, SIGNALS } from '$lib/api/processes';
import type { ProcessDetail } from '$lib/types/metrics';
import { formatBytes } from '$lib/utils/formatters';
interface Props {
pid: number;
onClose: () => void;
}
let { pid, onClose }: Props = $props();
let detail = $state<ProcessDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let signalStatus = $state<{ message: string; success: boolean } | null>(null);
let confirmKill = $state(false);
async function loadDetail() {
loading = true;
error = null;
try {
detail = await getProcessDetail(pid);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load process details';
} finally {
loading = false;
}
}
async function handleSignal(signal: number, signalName: string) {
signalStatus = null;
try {
const result = await sendSignal(pid, signal);
signalStatus = result;
if (result.success && (signal === SIGNALS.SIGTERM || signal === SIGNALS.SIGKILL)) {
// Close modal after successful termination
setTimeout(() => onClose(), 1500);
}
} catch (e) {
signalStatus = { success: false, message: `Failed to send ${signalName}` };
}
confirmKill = false;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
function formatStartTime(timestamp: string): string {
const unix = parseInt(timestamp, 10);
if (isNaN(unix)) return timestamp;
const date = new Date(unix * 1000);
return date.toLocaleString();
}
function getStateColor(state: string): string {
const colors: Record<string, string> = {
R: 'text-emerald-400',
S: 'text-blue-400',
D: 'text-amber-400',
Z: 'text-red-400',
T: 'text-slate-400',
I: 'text-slate-500'
};
return colors[state] || 'text-slate-400';
}
// Load on mount
$effect(() => {
loadDetail();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<!-- Modal -->
<div class="bg-slate-900 border border-white/10 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<span class="text-lg">📊</span>
</div>
<div>
<h2 class="text-lg font-semibold text-white">
{detail?.name || `Process ${pid}`}
</h2>
<p class="text-sm text-slate-400">PID {pid}</p>
</div>
</div>
<button
onclick={onClose}
class="text-slate-400 hover:text-white transition-colors p-2 hover:bg-white/10 rounded-lg"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="p-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full"></div>
</div>
{:else if error}
<div class="text-center py-8">
<div class="text-red-400 text-lg mb-2">Error</div>
<p class="text-slate-400">{error}</p>
<button
onclick={loadDetail}
class="mt-4 px-4 py-2 bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors"
>
Retry
</button>
</div>
{:else if detail}
<!-- Process Info Grid -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">State</div>
<div class="font-medium {getStateColor(detail.state)}">{detail.stateDesc}</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">User</div>
<div class="font-medium text-slate-200">{detail.user}</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">Parent PID</div>
<div class="font-medium text-slate-200 font-mono">{detail.ppid}</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">Threads</div>
<div class="font-medium text-slate-200">{detail.threads}</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">Nice</div>
<div class="font-medium text-slate-200">{detail.nice}</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">Open Files</div>
<div class="font-medium text-slate-200">{detail.openFiles}</div>
</div>
</div>
<!-- Resource Usage -->
<div class="mb-6">
<h3 class="text-sm font-medium text-slate-300 mb-3">Resource Usage</h3>
<div class="space-y-3">
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-slate-400">CPU</span>
<span class="text-blue-400 font-mono">{detail.cpuPercent.toFixed(2)}%</span>
</div>
<div class="h-2 bg-white/5 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-blue-500 to-cyan-400"
style="width: {Math.min(detail.cpuPercent, 100)}%"
></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-slate-400">Memory (RSS)</span>
<span class="text-purple-400 font-mono">{formatBytes(detail.memoryRss)}</span>
</div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Virtual Memory</span>
<span class="font-mono">{formatBytes(detail.memoryVms)}</span>
</div>
</div>
</div>
</div>
<!-- Time Info -->
<div class="mb-6">
<h3 class="text-sm font-medium text-slate-300 mb-3">Time</h3>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">Started</div>
<div class="font-medium text-slate-200 text-sm">{formatStartTime(detail.startTime)}</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-slate-500 mb-1">CPU Time</div>
<div class="font-medium text-slate-200 font-mono">{detail.cpuTime}</div>
</div>
</div>
</div>
<!-- Command Line -->
<div class="mb-6">
<h3 class="text-sm font-medium text-slate-300 mb-2">Command Line</h3>
<div class="bg-black/30 rounded-lg p-3 font-mono text-xs text-slate-300 overflow-x-auto whitespace-pre-wrap break-all">
{detail.cmdline}
</div>
</div>
<!-- Signal Status -->
{#if signalStatus}
<div class="mb-4 p-3 rounded-lg {signalStatus.success ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}">
{signalStatus.message}
</div>
{/if}
<!-- Action Buttons -->
<div class="border-t border-white/10 pt-4">
<h3 class="text-sm font-medium text-slate-300 mb-3">Process Control</h3>
<div class="flex flex-wrap gap-2">
<button
onclick={() => handleSignal(SIGNALS.SIGTERM, 'SIGTERM')}
class="px-4 py-2 bg-amber-500/20 text-amber-400 rounded-lg hover:bg-amber-500/30 transition-colors text-sm font-medium"
>
Terminate (SIGTERM)
</button>
{#if !confirmKill}
<button
onclick={() => { confirmKill = true; }}
class="px-4 py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors text-sm font-medium"
>
Kill (SIGKILL)
</button>
{:else}
<button
onclick={() => handleSignal(SIGNALS.SIGKILL, 'SIGKILL')}
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium animate-pulse"
>
Confirm Kill
</button>
<button
onclick={() => { confirmKill = false; }}
class="px-4 py-2 bg-white/10 text-slate-300 rounded-lg hover:bg-white/20 transition-colors text-sm"
>
Cancel
</button>
{/if}
<button
onclick={() => handleSignal(SIGNALS.SIGSTOP, 'SIGSTOP')}
class="px-4 py-2 bg-slate-500/20 text-slate-300 rounded-lg hover:bg-slate-500/30 transition-colors text-sm"
>
Pause
</button>
<button
onclick={() => handleSignal(SIGNALS.SIGCONT, 'SIGCONT')}
class="px-4 py-2 bg-emerald-500/20 text-emerald-400 rounded-lg hover:bg-emerald-500/30 transition-colors text-sm"
>
Resume
</button>
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { layout, cardMeta, editMode } from '$lib/stores/layout';
import { theme } from '$lib/stores/theme';
import { activeHost } from '$lib/stores/hosts';
interface Props {
onClose: () => void;
@@ -38,6 +39,21 @@
onClose();
}
}
function exportMetrics(format: 'csv' | 'json') {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const hostName = $activeHost.name.replace(/\s+/g, '-').toLowerCase();
const filename = `system-metrics-${hostName}-${timestamp}.${format}`;
const baseUrl = $activeHost.isLocal ? '' : $activeHost.url;
const url = `${baseUrl}/api/v1/export/metrics?format=${format}`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
<svelte:window onkeydown={handleKeydown} />
@@ -97,6 +113,32 @@
</svg>
</button>
<!-- Export Section -->
<div class="mb-4 p-3 rounded-xl {$theme === 'light' ? 'bg-slate-50 border border-slate-200' : 'bg-slate-700/30 border border-slate-600/50'}">
<div class="flex items-center gap-2 mb-3">
<svg class="w-4 h-4 {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span class="text-sm font-medium {$theme === 'light' ? 'text-slate-700' : 'text-slate-200'}">Export Metrics</span>
</div>
<div class="flex gap-2">
<button
onclick={() => exportMetrics('csv')}
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors
{$theme === 'light' ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30'}"
>
CSV
</button>
<button
onclick={() => exportMetrics('json')}
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors
{$theme === 'light' ? 'bg-blue-100 text-blue-700 hover:bg-blue-200' : 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'}"
>
JSON
</button>
</div>
</div>
<p class="text-sm mb-3 {$theme === 'light' ? 'text-slate-600' : 'text-slate-400'}">
Quick toggle card visibility:
</p>

View File

@@ -11,11 +11,25 @@
startAlertPolling,
stopAlertPolling
} from '$lib/stores/alerts';
import {
notificationsEnabled,
notificationPermission,
requestPermission,
disableNotifications
} from '$lib/stores/notifications';
import type { Alert, AlertThreshold } from '$lib/types/metrics';
let showSettings = $state(false);
let localConfig = $state<AlertThreshold[]>([]);
async function toggleNotifications() {
if ($notificationsEnabled) {
disableNotifications();
} else {
await requestPermission();
}
}
onMount(() => {
startAlertPolling(5000);
});
@@ -129,6 +143,28 @@
</div>
{/if}
<!-- Notification Toggle -->
<div class="flex items-center justify-between pt-3 border-t border-white/5">
<div class="flex items-center gap-2">
<span class="text-sm text-slate-300">Desktop Notifications</span>
{#if $notificationPermission === 'denied'}
<span class="text-xs text-red-400">(Blocked)</span>
{/if}
</div>
<button
onclick={toggleNotifications}
disabled={$notificationPermission === 'denied'}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors
{$notificationsEnabled ? 'bg-blue-500' : 'bg-white/10'}
{$notificationPermission === 'denied' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white shadow-lg transition-transform
{$notificationsEnabled ? 'translate-x-6' : 'translate-x-1'}"
></span>
</button>
</div>
<!-- Settings Toggle -->
<button
onclick={() => showSettings = !showSettings}

View File

@@ -22,6 +22,8 @@
const gpuValues = $derived(() => getRecentValues($historyData?.gpu));
const networkRxValues = $derived(() => getRecentValues($historyData?.networkRx));
const networkTxValues = $derived(() => getRecentValues($historyData?.networkTx));
const diskReadValues = $derived(() => getRecentValues($historyData?.diskRead));
const diskWriteValues = $derived(() => getRecentValues($historyData?.diskWrite));
const currentCpu = $derived(() => {
const vals = cpuValues();
@@ -42,10 +44,20 @@
const vals = networkTxValues();
return vals.length > 0 ? vals[vals.length - 1] : 0;
});
const currentDiskRead = $derived(() => {
const vals = diskReadValues();
return vals.length > 0 ? vals[vals.length - 1] : 0;
});
const currentDiskWrite = $derived(() => {
const vals = diskWriteValues();
return vals.length > 0 ? vals[vals.length - 1] : 0;
});
</script>
<Card title="History" icon="📈" info="Historical metrics over the last 10 minutes. Data is stored server-side (up to 1 hour at 1s intervals). Charts update every 30 seconds.">
<div class="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">
<!-- CPU History -->
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="flex justify-between items-center mb-2">
@@ -81,5 +93,23 @@
</div>
<SparklineChart data={networkTxValues()} color="#fbbf24" />
</div>
<!-- Disk Read -->
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="flex justify-between items-center mb-2">
<span class="text-xs uppercase tracking-wider text-slate-500">Disk Read</span>
<span class="text-sm font-mono text-cyan-400">{formatBytes(currentDiskRead())}/s</span>
</div>
<SparklineChart data={diskReadValues()} color="#22d3ee" />
</div>
<!-- Disk Write -->
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="flex justify-between items-center mb-2">
<span class="text-xs uppercase tracking-wider text-slate-500">Disk Write</span>
<span class="text-sm font-mono text-rose-400">{formatBytes(currentDiskWrite())}/s</span>
</div>
<SparklineChart data={diskWriteValues()} color="#fb7185" />
</div>
</div>
</Card>

View File

@@ -1,11 +1,13 @@
<script lang="ts">
import Card from '$lib/components/common/Card.svelte';
import ProcessDetailModal from '$lib/components/ProcessDetailModal.svelte';
import { processStats } from '$lib/stores/metrics';
import type { ProcessInfo } from '$lib/types/metrics';
let view = $state<'cpu' | 'memory'>('cpu');
let searchQuery = $state('');
let expandedPid = $state<number | null>(null);
let modalPid = $state<number | null>(null);
// Get all processes based on view
const allProcesses = $derived.by(() => {
@@ -59,6 +61,15 @@
function toggleExpand(pid: number) {
expandedPid = expandedPid === pid ? null : pid;
}
function openModal(pid: number, e: MouseEvent) {
e.stopPropagation();
modalPid = pid;
}
function closeModal() {
modalPid = null;
}
</script>
<Card title="Processes" icon="📊" info="Top 10 processes by CPU or memory usage. Use the search box to filter by name or PID. Data from /proc/[pid]/stat. CPU% is relative to a single core.">
@@ -188,6 +199,13 @@
</div>
</div>
</div>
<!-- Details button -->
<button
onclick={(e) => openModal(proc.pid, e)}
class="mt-3 w-full py-1.5 text-xs font-medium text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 rounded-lg transition-colors"
>
View Details & Manage
</button>
</div>
{/if}
</div>
@@ -200,3 +218,7 @@
</div>
{/if}
</Card>
{#if modalPid !== null}
<ProcessDetailModal pid={modalPid} onClose={closeModal} />
{/if}

View File

@@ -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<Alert[]>([]);
export const alertHistory = writable<Alert[]>([]);
export const alertConfig = writable<AlertConfig | null>(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<void> {
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<void> {
export async function updateAlertConfig(config: AlertConfig): Promise<boolean> {
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<boolean> {
export async function acknowledgeAlert(alertId: string): Promise<boolean> {
try {
const response = await fetch(`${API_BASE}/alerts/${alertId}/acknowledge`, {
const response = await fetch(`${getApiBase()}/alerts/${alertId}/acknowledge`, {
method: 'POST'
});
if (response.ok) {

View File

@@ -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<Host[]>(initialHosts);
const activeHostId = writable<string>(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<Omit<Host, 'id' | 'isLocal'>>) {
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;
}

View File

@@ -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<boolean>(false);
export const notificationPermission = writable<NotificationPermission>('default');
// Track which alerts have already been notified
const notifiedAlertIds = new Set<string>();
// 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<boolean> {
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<string, string> = {
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<string, string> = {
cpu: '#60a5fa',
memory: '#a78bfa',
temperature: '#f87171',
disk: '#fbbf24',
gpu: '#34d399'
};
const color = colors[type] || '#94a3b8';
// SVG as data URL
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="${color}"/></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}

View File

@@ -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<Settings>(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 })

View File

@@ -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[];

View File

@@ -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?.();
});
</script>