- 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>
238 lines
8.7 KiB
Svelte
238 lines
8.7 KiB
Svelte
<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>
|