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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
39
frontend/src/lib/api/processes.ts
Normal file
39
frontend/src/lib/api/processes.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
237
frontend/src/lib/components/HostSelector.svelte
Normal file
237
frontend/src/lib/components/HostSelector.svelte
Normal 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>
|
||||
270
frontend/src/lib/components/ProcessDetailModal.svelte
Normal file
270
frontend/src/lib/components/ProcessDetailModal.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
116
frontend/src/lib/stores/hosts.ts
Normal file
116
frontend/src/lib/stores/hosts.ts
Normal 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;
|
||||
}
|
||||
138
frontend/src/lib/stores/notifications.ts
Normal file
138
frontend/src/lib/stores/notifications.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user