Files
tyto/frontend/src/lib/components/HostSelector.svelte
vikingowl b0c500e07b 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>
2025-12-28 06:08:43 +01:00

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>