Add clear host deletion controls with workload safeguards
This commit is contained in:
@@ -2,6 +2,8 @@ export type HostMode = 'active' | 'draining' | 'disabled';
|
||||
export type HostReadiness = 'preparing' | 'ready' | 'degraded' | 'draining' | 'disabled';
|
||||
export type HostOperationState = 'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
|
||||
|
||||
export interface HostDeletion { allowed: boolean; blockers: string[] }
|
||||
|
||||
export interface HostLibrary { id: string; state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; progress?: number; error?: string }
|
||||
export interface HostInventory {
|
||||
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import { db } from '../db/index.js';
|
||||
import { ensureHostSchema } from './schema.js';
|
||||
import type { Host, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
|
||||
import type { Host, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
|
||||
|
||||
export const AGENT_PROTOCOL_VERSION = 1;
|
||||
export const HEARTBEAT_MAX_AGE_MS = 90_000;
|
||||
@@ -33,12 +33,57 @@ function hostFromRow(row: HostRow): Host {
|
||||
readiness: row.mode === 'draining' ? 'draining' : row.mode === 'disabled' ? 'disabled' : !row.enrolled_at || (infrastructureReady && reasons.length) ? 'preparing' : reasons.length ? 'degraded' : 'ready',
|
||||
readinessReasons: reasons, inventory, enrolled: !!row.enrolled_at, credentialActive: !!row.credential_active };
|
||||
}
|
||||
export function listHosts(): Host[] { return (database().prepare(hostSql + ' ORDER BY h.name, h.id').all() as HostRow[]).map(hostFromRow); }
|
||||
export function listHosts(): Host[] { return (database().prepare(hostSql + ' WHERE h.deleted_at IS NULL ORDER BY h.name, h.id').all() as HostRow[]).map(hostFromRow); }
|
||||
export function getHost(id: string): Host | null {
|
||||
const row = database().prepare(hostSql + ' WHERE h.id = ?').get(id) as HostRow | undefined;
|
||||
const row = database().prepare(hostSql + ' WHERE h.id = ? AND h.deleted_at IS NULL').get(id) as HostRow | undefined;
|
||||
return row ? hostFromRow(row) : null;
|
||||
}
|
||||
function requireHost(id: string): Host { const host = getHost(id); if (!host) throw new Error('Host was not found.'); return host; }
|
||||
|
||||
export class HostDeletionError extends Error {
|
||||
constructor(message: string, readonly status: 404 | 409, readonly blockers: string[] = []) { super(message); }
|
||||
}
|
||||
|
||||
/** Controller records can be removed only after all remote instance ownership is resolved. */
|
||||
export function hostDeletionStatus(id: string): HostDeletion {
|
||||
if (!getHost(id)) throw new HostDeletionError('Host not found.', 404);
|
||||
const handle = database();
|
||||
const blockers: string[] = [];
|
||||
const instances = handle.prepare('SELECT server FROM native_instances WHERE host_id=? AND retired_at IS NULL ORDER BY server').all(id) as { server: string }[];
|
||||
if (instances.length) {
|
||||
const names = instances.slice(0, 5).map(instance => instance.server).join(', ') + (instances.length > 5 ? ', …' : '');
|
||||
blockers.push(`Remove the ${instances.length} server instance${instances.length === 1 ? '' : 's'} assigned to this host first (${names}). Queued and failed deployments also require removal.`);
|
||||
}
|
||||
const ports = handle.prepare('SELECT COUNT(*) AS count FROM ec_host_ports WHERE host_id=?').get(id) as { count: number };
|
||||
if (ports.count && !instances.length) blockers.push(`This host still has ${ports.count} reserved game port${ports.count === 1 ? '' : 's'}. Finish or reconcile the corresponding server removals first.`);
|
||||
// A pending command may have reached the agent before its acknowledgement.
|
||||
// Only commands that have never been delivered are safe to cancel locally.
|
||||
const operations = handle.prepare(`SELECT kind,state FROM ec_host_operations WHERE host_id=?
|
||||
AND (state IN ('accepted','running','unknown') OR (state='pending' AND last_delivered_at IS NOT NULL))
|
||||
ORDER BY created_at`).all(id) as { kind: string; state: string }[];
|
||||
if (operations.length) {
|
||||
const work = operations.slice(0, 5).map(operation => `${operation.kind}: ${operation.state}`).join(', ') + (operations.length > 5 ? ', …' : '');
|
||||
blockers.push(`${operations.length} delivered operation${operations.length === 1 ? ' is' : 's are'} unresolved (${work}). Let the agent reconnect and finish or reconcile this work before deleting the host.`);
|
||||
}
|
||||
return { allowed: blockers.length === 0, blockers };
|
||||
}
|
||||
|
||||
/** Retain references and audit history; deletion never sends remote cleanup commands. */
|
||||
export function deleteHost(id: string): void {
|
||||
const handle = database();
|
||||
handle.transaction(() => {
|
||||
const status = hostDeletionStatus(id);
|
||||
if (!status.allowed) throw new HostDeletionError(status.blockers.join(' '), 409, status.blockers);
|
||||
const timestamp = now();
|
||||
const reason = 'Host deleted before this operation was delivered to the agent.';
|
||||
handle.prepare(`INSERT INTO ec_host_operation_events(operation_id,state,created_at,error)
|
||||
SELECT id,'failed',?,? FROM ec_host_operations WHERE host_id=? AND state='pending' AND last_delivered_at IS NULL`).run(timestamp, reason, id);
|
||||
handle.prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE host_id=? AND state='pending' AND last_delivered_at IS NULL").run(reason, timestamp, id);
|
||||
handle.prepare('UPDATE ec_host_credentials SET revoked_at=? WHERE host_id=? AND revoked_at IS NULL').run(timestamp, id);
|
||||
handle.prepare('UPDATE ec_host_enrollments SET consumed_at=? WHERE host_id=? AND consumed_at IS NULL').run(timestamp, id);
|
||||
handle.prepare("UPDATE ec_hosts SET deleted_at=?,mode='disabled' WHERE id=? AND deleted_at IS NULL").run(timestamp, id);
|
||||
}).immediate();
|
||||
}
|
||||
function textField(value: unknown, label: string, max = 200): string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f]/.test(value)) throw new Error(`${label} must be a nonempty string up to ${max} characters.`);
|
||||
return value.trim();
|
||||
@@ -195,6 +240,7 @@ export function pendingHostOperations(hostId: string): HostOperation[] {
|
||||
}
|
||||
export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation {
|
||||
return database().transaction(() => {
|
||||
requireHost(hostId);
|
||||
const operation = getHostOperation(id);
|
||||
if (!operation || operation.hostId !== hostId) throw new Error('Operation was not found for this host.');
|
||||
if (!['accepted','running','completed','failed','unknown'].includes(input.state as string)) throw new Error('Invalid operation state.');
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ensureHostSchema(handle: Database.Database): void {
|
||||
port_min INTEGER NOT NULL DEFAULT 28960, port_max INTEGER NOT NULL DEFAULT 28980,
|
||||
bind_address TEXT NOT NULL DEFAULT '0.0.0.0', public_address TEXT NOT NULL DEFAULT '',
|
||||
minimum_free_bytes INTEGER NOT NULL DEFAULT 1073741824,
|
||||
created_at TEXT NOT NULL, enrolled_at TEXT, last_heartbeat TEXT, inventory TEXT NOT NULL DEFAULT '{}'
|
||||
created_at TEXT NOT NULL, enrolled_at TEXT, last_heartbeat TEXT, inventory TEXT NOT NULL DEFAULT '{}', deleted_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ec_host_enrollments (
|
||||
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), token_hash TEXT NOT NULL UNIQUE,
|
||||
@@ -43,5 +43,7 @@ export function ensureHostSchema(handle: Database.Database): void {
|
||||
`);
|
||||
const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[];
|
||||
if (!columns.some(column => column.name === 'last_delivered_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN last_delivered_at TEXT');
|
||||
const hostColumns = handle.prepare('PRAGMA table_info(ec_hosts)').all() as { name: string }[];
|
||||
if (!hostColumns.some(column => column.name === 'deleted_at')) handle.exec('ALTER TABLE ec_hosts ADD COLUMN deleted_at TEXT');
|
||||
initialized.add(handle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { Host, HostDeletion } from '$lib/hosts.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
|
||||
let { open = $bindable(false), host, ondeleted }:
|
||||
{ open?: boolean; host: Pick<Host, 'id' | 'name'> | null; ondeleted: () => void | Promise<void> } = $props();
|
||||
let deletion = $state<HostDeletion | null>(null);
|
||||
let checking = $state(false); let deleting = $state(false); let error = $state('');
|
||||
|
||||
async function inspect(id: string, signal?: AbortSignal) {
|
||||
checking = true; deletion = null; error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not check this host.');
|
||||
if (!signal?.aborted) deletion = body.deletion;
|
||||
} catch (cause) { if (!signal?.aborted) error = (cause as Error).message; }
|
||||
finally { if (!signal?.aborted) checking = false; }
|
||||
}
|
||||
$effect(() => {
|
||||
if (!open || !host) return;
|
||||
const controller = new AbortController();
|
||||
void inspect(host.id, controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
async function remove() {
|
||||
if (!host || deleting || checking || !deletion?.allowed) return;
|
||||
const id = host.id;
|
||||
deleting = true; error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
await inspect(id);
|
||||
throw new Error(body.error ?? body.message ?? 'Could not delete this host.');
|
||||
}
|
||||
open = false;
|
||||
await ondeleted();
|
||||
} catch (cause) { error = (cause as Error).message; }
|
||||
finally { deleting = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title={`Delete ${host?.name ?? 'host'}?`} busy={deleting}
|
||||
description="Remove this host from Electric Cherry and revoke its agent access and unused installer links.">
|
||||
<div class="content">
|
||||
<p>The agent and game files stay on the machine. Published game libraries and operation history are kept. Commands that have never been sent to the host will be cancelled.</p>
|
||||
{#if checking}<p role="status">Checking servers and unfinished operations…</p>
|
||||
{:else if deletion && !deletion.allowed}<div role="status" class="blocked"><strong>This host still has work to finish:</strong><ul>{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}</ul><p>Open <a href={`/hosts/${host?.id}`} onclick={() => open = false}>host details</a> to review its operations or drain its servers.</p></div>
|
||||
{:else if deletion?.allowed}<p>This host is ready to delete. Adding it again requires a new enrollment.</p>{/if}
|
||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" disabled={deleting} onclick={() => open = false}>Cancel</Button>
|
||||
{#if !deletion?.allowed}<Button disabled={checking || deleting || !host} onclick={() => { if (host) void inspect(host.id); }}>Check again</Button>{/if}
|
||||
<Button variant="danger" loading={deleting} disabled={checking || !deletion?.allowed} onclick={remove}>Delete host</Button>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.content { display:grid; gap:16px; } p,li { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.65; }
|
||||
.blocked { display:grid; gap:12px; } strong { font-size:var(--text-sm); } ul { margin:0; padding-left:20px; }
|
||||
a { color:var(--accent-primary-hover); } .ui-error { color:var(--state-danger); }
|
||||
</style>
|
||||
@@ -1,14 +1,23 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { getHost, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
|
||||
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
type Event = { request: Request; params: { id: string } };
|
||||
export function GET({ request, params }: Event) {
|
||||
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
||||
// Payloads and results can contain RCON keys or staged credentials. History
|
||||
// exposes metadata only; agent APIs remain the only command delivery path.
|
||||
return json({ host, operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
||||
return json({ host, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
||||
}
|
||||
export async function PATCH({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { return json({ host: updateHost(params.id, await objectBody(request)) }); } catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
|
||||
export function DELETE({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { deleteHost(params.id); return json({ deleted: true }); }
|
||||
catch (cause) {
|
||||
if (cause instanceof HostDeletionError) return json({ error: cause.message, blockers: cause.blockers }, { status: cause.status });
|
||||
return hostApiError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,24 @@
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||
let hosts = $state<Host[]>([]);
|
||||
let loading = $state(true); let busy = $state(false); let error = $state('');
|
||||
let notice = $state(''); let deleteOpen = $state(false); let deletingHost = $state<Host | null>(null);
|
||||
let loadSequence = 0;
|
||||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980);
|
||||
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
|
||||
const canManage = $derived($page.data.user?.role === 'admin');
|
||||
async function load() {
|
||||
try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); hosts = body.hosts; }
|
||||
catch (cause) { error = (cause as Error).message; } finally { loading = false; }
|
||||
const sequence = ++loadSequence;
|
||||
try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); if (sequence === loadSequence) { hosts = body.hosts; error = ''; } }
|
||||
catch (cause) { if (sequence === loadSequence) error = (cause as Error).message; } finally { if (sequence === loadSequence) loading = false; }
|
||||
}
|
||||
async function deleted() {
|
||||
notice = `${deletingHost?.name ?? 'Host'} deleted.`;
|
||||
if (enrollment?.hostId === deletingHost?.id) enrollment = null;
|
||||
deletingHost = null;
|
||||
await load();
|
||||
}
|
||||
async function add(event: SubmitEvent) {
|
||||
event.preventDefault(); busy = true; error = ''; enrollment = null;
|
||||
@@ -28,6 +38,7 @@
|
||||
<section class="page">
|
||||
<SectionHeader title="Hosts" subtitle="Connect game-server hosts and prepare them for deployment." />
|
||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if canManage}<section class="panel">
|
||||
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.</p>
|
||||
<form onsubmit={add}><fieldset disabled={busy}>
|
||||
@@ -39,14 +50,15 @@
|
||||
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
|
||||
</section>{:else}<p>Administrators manage host setup, runtime preparation, and credentials.</p>{/if}
|
||||
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
|
||||
<div class="host-grid">{#each hosts as host}<a class="panel host" href={`/hosts/${host.id}`}><div class="row"><h2>{host.name}</h2><span class:ready={host.readiness === 'ready'} class="state">{host.readiness}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}</a>{/each}</div>
|
||||
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{host.readiness}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>Manage host</Button>{#if canManage}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>Delete host</Button>{/if}</div></article>{/each}</div>
|
||||
{/if}
|
||||
</section>
|
||||
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; }
|
||||
h2 { font-size:var(--text-md); margin:0; } h3 { font-size:var(--text-sm); } p { color:var(--text-secondary); line-height:1.6; font-size:var(--text-sm); }
|
||||
fieldset { display:flex; gap:16px; flex-wrap:wrap; align-items:end; border:0; padding:0; margin-top:20px; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; max-width:280px; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-sm); } .enrollment { border-top:1px solid var(--border-subtle); margin-top:24px; padding-top:16px; }
|
||||
.host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .host { text-decoration:none; color:inherit; } .host:hover { border-color:var(--accent-primary); } .row { display:flex; justify-content:space-between; gap:16px; } .state { font-size:var(--text-xs); text-transform:capitalize; color:var(--text-secondary); } .ready { color:var(--accent-primary); } .reason { margin-bottom:0; }
|
||||
.host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .host h2 a { color:inherit; text-decoration:none; } .host h2 a:hover { color:var(--accent-primary); } .host-actions { display:flex; flex-wrap:wrap; gap:12px; margin-top:20px; } .row { display:flex; justify-content:space-between; gap:16px; } .state { font-size:var(--text-xs); text-transform:capitalize; color:var(--text-secondary); } .ready { color:var(--accent-primary); } .reason { margin-bottom:0; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Host, HostOperation, HostMode } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
|
||||
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
|
||||
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
|
||||
const endpoint = $derived(`/api/hosts/${$page.params.id}`);
|
||||
@@ -28,7 +31,9 @@
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
||||
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history." />
|
||||
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history.">
|
||||
{#snippet actions()}{#if canManage && host}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>Delete host</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||
<section class="panel"><div class="row"><h2>{host.connectionState} · {host.readiness}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
|
||||
@@ -49,6 +54,7 @@
|
||||
<section class="panel"><h2>Operation history</h2>{#if !operations.length}<p>No remote operations yet.</p>{:else}<div class="table-wrap"><table><thead><tr><th>Operation</th><th>State</th><th>Updated</th><th>Result</th></tr></thead><tbody>{#each operations as operation}<tr><td>{operation.kind}<small>{operation.id}</small></td><td>{operation.state}</td><td>{new Date(operation.updatedAt).toLocaleString()}</td><td>{operation.error ?? '—'}</td></tr>{/each}</tbody></table></div>{/if}</section>
|
||||
{/if}
|
||||
</section>
|
||||
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={() => goto('/hosts')} />
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } td,th { padding:12px; border-bottom:1px solid var(--border-subtle); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:4px; } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user