Move host operation history to a paginated Hosts page
This commit is contained in:
@@ -59,7 +59,9 @@ T5 and for T6 when available, one setup operation at a time. Uploading new files
|
|||||||
or enrolling another host needs no manual propagation. Setup waits for an active, connected,
|
or enrolling another host needs no manual propagation. Setup waits for an active, connected,
|
||||||
healthy host with sufficient storage. Failed operations retry automatically with
|
healthy host with sufficient storage. Failed operations retry automatically with
|
||||||
backoff from one minute to fifteen minutes; their errors remain visible in the
|
backoff from one minute to fifteen minutes; their errors remain visible in the
|
||||||
host's setup panel and operation history. Disconnected hosts resume when they
|
host's setup panel and **Hosts → Operation history**. This shared history lists
|
||||||
|
operations across hosts, newest first, with a host column and 25 rows per page.
|
||||||
|
Disconnected hosts resume when they
|
||||||
reconnect. Draining and disabled hosts do not receive new automatic setup work.
|
reconnect. Draining and disabled hosts do not receive new automatic setup work.
|
||||||
|
|
||||||
An automatic download that has not been delivered to the agent can be replaced
|
An automatic download that has not been delivered to the agent can be replaced
|
||||||
@@ -284,7 +286,9 @@ explicit installer `--reenroll` when local credentials already exist.
|
|||||||
|
|
||||||
API outline:
|
API outline:
|
||||||
|
|
||||||
- `GET /api/hosts/:id`: host details, operation history and deletion blockers.
|
- `GET /api/hosts/:id`: host details and deletion blockers.
|
||||||
|
- `GET /api/hosts/operations?page=1`: operation history across hosts, newest first,
|
||||||
|
with 25 summaries per page and total/page counts. Payloads and results are omitted.
|
||||||
- `DELETE /api/hosts/:id`: administrator requests cleanup of an unused host;
|
- `DELETE /api/hosts/:id`: administrator requests cleanup of an unused host;
|
||||||
returns `202` while remote removal is pending, `200` for an unenrolled host,
|
returns `202` while remote removal is pending, `200` for an unenrolled host,
|
||||||
or `409` when the agent needs updating or unresolved ownership remains.
|
or `409` when the agent needs updating or unresolved ownership remains.
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ export interface HostOperation {
|
|||||||
payload: Record<string, unknown>; state: HostOperationState; result: unknown; error: string | null;
|
payload: Record<string, unknown>; state: HostOperationState; result: unknown; error: string | null;
|
||||||
createdAt: string; updatedAt: string;
|
createdAt: string; updatedAt: string;
|
||||||
}
|
}
|
||||||
|
export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'result'> {
|
||||||
|
hostName: string | null;
|
||||||
|
}
|
||||||
|
export interface HostOperationHistoryPage {
|
||||||
|
operations: HostOperationSummary[]; page: number; pageSize: 25; total: number; totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostPreparationItem {
|
export interface HostPreparationItem {
|
||||||
kind: 'runtime' | 'library'; label: string;
|
kind: 'runtime' | 'library'; label: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { db } from '../db/index.js';
|
|||||||
import { ensureHostSchema } from './schema.js';
|
import { ensureHostSchema } from './schema.js';
|
||||||
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
|
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
|
||||||
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
|
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
|
||||||
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
|
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationHistoryPage, HostOperationState } from '../../hosts.js';
|
||||||
|
|
||||||
export const AGENT_PROTOCOL_VERSION = 1;
|
export const AGENT_PROTOCOL_VERSION = 1;
|
||||||
export const HEARTBEAT_MAX_AGE_MS = 90_000;
|
export const HEARTBEAT_MAX_AGE_MS = 90_000;
|
||||||
@@ -356,6 +356,22 @@ export function listHostOperations(hostId: string, limit = 100, summary = false)
|
|||||||
return (database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE o.host_id=? ORDER BY o.created_at DESC,o.rowid DESC LIMIT ?`)
|
return (database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE o.host_id=? ORDER BY o.created_at DESC,o.rowid DESC LIMIT ?`)
|
||||||
.all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow);
|
.all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow);
|
||||||
}
|
}
|
||||||
|
/** Browse retained history without loading command payloads or credential-bearing results. */
|
||||||
|
export function listHostOperationHistory(page = 1): HostOperationHistoryPage {
|
||||||
|
const handle = database();
|
||||||
|
const pageSize = 25;
|
||||||
|
return handle.transaction((): HostOperationHistoryPage => {
|
||||||
|
const { total } = handle.prepare('SELECT COUNT(*) AS total FROM ec_host_operations').get() as { total: number };
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1);
|
||||||
|
const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind,
|
||||||
|
o.instance_id AS instanceId,o.generation,o.state,o.error,o.created_at AS createdAt,o.updated_at AS updatedAt
|
||||||
|
FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL
|
||||||
|
ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?`)
|
||||||
|
.all(pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations'];
|
||||||
|
return { operations, page: currentPage, pageSize, total, totalPages };
|
||||||
|
})();
|
||||||
|
}
|
||||||
/** Heartbeats can tell an executing create to stop at its next safe command boundary. */
|
/** Heartbeats can tell an executing create to stop at its next safe command boundary. */
|
||||||
export function pendingCreationCancellations(hostId: string): string[] {
|
export function pendingCreationCancellations(hostId: string): string[] {
|
||||||
return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations
|
return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export function ensureHostSchema(handle: Database.Database): void {
|
|||||||
UNIQUE(host_id, idempotency_key)
|
UNIQUE(host_id, idempotency_key)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ec_host_operations_queue ON ec_host_operations(host_id, state, created_at);
|
CREATE INDEX IF NOT EXISTS ec_host_operations_queue ON ec_host_operations(host_id, state, created_at);
|
||||||
|
-- Reversing this index also reverses its implicit rowid tie-breaker.
|
||||||
|
CREATE INDEX IF NOT EXISTS ec_host_operations_history ON ec_host_operations(created_at);
|
||||||
-- Polling checks cancellations and preparation by kind across retained
|
-- Polling checks cancellations and preparation by kind across retained
|
||||||
-- history. The queue index cannot narrow those lookups beyond host_id,
|
-- history. The queue index cannot narrow those lookups beyond host_id,
|
||||||
-- causing a history scan for every queued command on Node's main thread.
|
-- causing a history scan for every queued command on Node's main thread.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<p>This deletes the host's game-library copies, server files, agent data, credentials, and service installation. Electric Cherry's uploaded libraries on the controller and its operation history are kept.</p>
|
<p>This deletes the host's game-library copies, server files, agent data, credentials, and service installation. Electric Cherry's uploaded libraries on the controller and its operation history are kept.</p>
|
||||||
<p>The host remains visible until cleanup is confirmed. Offline hosts wait for reconnection. Unsent commands will be cancelled.</p>
|
<p>The host remains visible until cleanup is confirmed. Offline hosts wait for reconnection. Unsent commands will be cancelled.</p>
|
||||||
{#if checking}<p role="status">Checking servers and unfinished operations…</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 && !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>Review <a href="/hosts/operations" onclick={() => open = false}>operation history</a> or open <a href={`/hosts/${host?.id}`} onclick={() => open = false}>host details</a> to drain its servers.</p></div>
|
||||||
{:else if deletion?.allowed}<p>Cleanup cannot be undone once it starts. Adding this machine again requires installing and enrolling the agent again.</p>{/if}
|
{:else if deletion?.allowed}<p>Cleanup cannot be undone once it starts. Adding this machine again requires installing and enrolling the agent again.</p>{/if}
|
||||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,9 +143,18 @@
|
|||||||
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
|
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
|
||||||
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
|
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
|
||||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
|
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
|
||||||
|
: tabs.find(tab => tab.label === 'Hosts')!.match($page.url.pathname)
|
||||||
|
? [{ href: '/hosts', label: 'All hosts' }, { href: '/hosts/operations', label: 'Operation history' }]
|
||||||
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
|
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
|
||||||
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
|
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
|
||||||
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
|
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
|
||||||
|
|
||||||
|
function secondaryActive(href: string): boolean {
|
||||||
|
const path = $page.url.pathname;
|
||||||
|
if (href === '/settings') return path === href;
|
||||||
|
if (href === '/hosts') return path === href || /^\/hosts\/(?!operations(?:\/|$))/.test(path);
|
||||||
|
return path === href || path.startsWith(href + '/');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={closeMenu} />
|
<svelte:window onkeydown={closeMenu} />
|
||||||
@@ -184,7 +193,7 @@
|
|||||||
</div>{/if}
|
</div>{/if}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={item.href === '/settings' ? $page.url.pathname === item.href ? 'page' : undefined : $page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={secondaryActive(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
||||||
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
|
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { capacityCalibration } from '$lib/server/hosts/calibration.js';
|
import { capacityCalibration } from '$lib/server/hosts/calibration.js';
|
||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
|
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, updateHost } from '$lib/server/hosts/index.js';
|
||||||
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||||
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||||
import { config } from '$lib/server/config.js';
|
import { config } from '$lib/server/config.js';
|
||||||
type Event = { request: Request; params: { id: string } };
|
type Event = { request: Request; params: { id: string } };
|
||||||
export function GET({ request, params }: Event) {
|
export function GET({ request, params }: Event) {
|
||||||
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
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
|
return json({ host: presentHostPreparation(host), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id) });
|
||||||
// exposes metadata only; agent APIs remain the only command delivery path.
|
|
||||||
return json({ host: presentHostPreparation(host), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id, 100, true).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
|
||||||
}
|
}
|
||||||
export async function PATCH({ request, params }: Event) {
|
export async function PATCH({ request, params }: Event) {
|
||||||
requireManagementOrigin(request);
|
requireManagementOrigin(request);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { listHostOperationHistory } from '$lib/server/hosts/index.js';
|
||||||
|
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||||
|
|
||||||
|
export function GET({ request }: { request: Request }) {
|
||||||
|
requireManagementOrigin(request);
|
||||||
|
const page = Number(new URL(request.url).searchParams.get('page') ?? '1');
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.');
|
||||||
|
return json(listHostOperationHistory(page), { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
@@ -4,14 +4,14 @@
|
|||||||
let requestSequence = 0;
|
let requestSequence = 0;
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { Host, HostOperation, HostMode, CapacityCalibration } from '$lib/hosts.js';
|
import type { Host, HostMode, CapacityCalibration } from '$lib/hosts.js';
|
||||||
import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||||
import HostPreparation from '$ui/components/HostPreparation.svelte';
|
import HostPreparation from '$ui/components/HostPreparation.svelte';
|
||||||
import CapacitySamples from '$ui/components/CapacitySamples.svelte';
|
import CapacitySamples from '$ui/components/CapacitySamples.svelte';
|
||||||
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
|
let host = $state<Host | null>(null);
|
||||||
let calibration = $state<CapacityCalibration | null>(null);
|
let calibration = $state<CapacityCalibration | null>(null);
|
||||||
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
||||||
let deleteOpen = $state(false);
|
let deleteOpen = $state(false);
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
async function load(initialize = false, signal?: AbortSignal) {
|
async function load(initialize = false, signal?: AbortSignal) {
|
||||||
const target = endpoint; const sequence = ++requestSequence;
|
const target = endpoint; const sequence = ++requestSequence;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations; calibration = body.calibration ?? null; error = '';
|
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; calibration = body.calibration ?? null; error = '';
|
||||||
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; }
|
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; }
|
||||||
} catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; }
|
} catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; }
|
||||||
}
|
}
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
<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, settings, and credentials.">
|
||||||
{#snippet actions()}{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
|
{#snippet actions()}<Button href="/hosts/operations">Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
{#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}
|
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||||
@@ -85,11 +85,10 @@
|
|||||||
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
|
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
|
||||||
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
|
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
|
||||||
</section></div>
|
</section></div>
|
||||||
<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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
|
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
|
||||||
|
|
||||||
<style>
|
<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; } }
|
.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); } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { listHostOperationHistory } from '$lib/server/hosts/index.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ url }) => {
|
||||||
|
const requestedPage = url.searchParams.get('page');
|
||||||
|
const history = listHostOperationHistory(Number(requestedPage ?? 1));
|
||||||
|
if (requestedPage !== null && requestedPage !== String(history.page)) {
|
||||||
|
redirect(307, history.page === 1 ? '/hosts/operations' : `/hosts/operations?page=${history.page}`);
|
||||||
|
}
|
||||||
|
return { history };
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
import { navigating } from '$app/stores';
|
||||||
|
import type { HostOperationHistoryPage } from '$lib/hosts.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let refreshError = $state('');
|
||||||
|
let request: AbortController | null = null;
|
||||||
|
let sequence = 0;
|
||||||
|
const history = $derived(snapshot?.source === data ? snapshot.history : data.history);
|
||||||
|
const changingPage = $derived($navigating?.to?.url.pathname === '/hosts/operations');
|
||||||
|
const firstOperation = $derived((history.page - 1) * history.pageSize + 1);
|
||||||
|
const numbers = new Intl.NumberFormat('en-US');
|
||||||
|
function pageHref(page: number) { return page === 1 ? '/hosts/operations' : `/hosts/operations?page=${page}`; }
|
||||||
|
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
|
||||||
|
|
||||||
|
async function refresh(signal?: AbortSignal) {
|
||||||
|
if (refreshing || $navigating) return;
|
||||||
|
const current = ++sequence;
|
||||||
|
const source = data;
|
||||||
|
const controller = new AbortController();
|
||||||
|
request = controller;
|
||||||
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/hosts/operations?page=${history.page}`, {
|
||||||
|
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])])
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error('Could not refresh operation history.');
|
||||||
|
if (current === sequence && data === source && !$navigating) { snapshot = { source, history: body }; refreshError = ''; }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if (current === sequence && data === source && !signal?.aborted && !controller.signal.aborted) {
|
||||||
|
refreshError = 'Could not refresh operation history. Showing the last successfully loaded page.';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally { if (current === sequence) { refreshing = false; request = null; } }
|
||||||
|
}
|
||||||
|
beforeNavigate(cancel);
|
||||||
|
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; }); });
|
||||||
|
onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Operation history · Hosts · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
|
<section class="page" aria-busy={changingPage}>
|
||||||
|
<SectionHeader title="Operation history" eyebrow="Hosts" subtitle="Remote operations across all hosts, newest first.">
|
||||||
|
{#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
|
{#if refreshError}<p class="ui-error" role="alert">{refreshError}</p>{/if}
|
||||||
|
<section class="panel" aria-label="Host operation history">
|
||||||
|
{#if !history.operations.length}<p class="empty" role="status">No remote operations yet.</p>
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||||
|
<div class="table-wrap" role="region" aria-label="Operations" tabindex="0">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th scope="col">Operation</th><th scope="col">Host</th><th scope="col">State</th><th scope="col">Updated</th><th scope="col">Result</th></tr></thead>
|
||||||
|
<tbody>{#each history.operations as operation (operation.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{operation.kind}<small>{operation.id}</small></td>
|
||||||
|
<td>{#if operation.hostName !== null}<a href={`/hosts/${operation.hostId}`}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td>
|
||||||
|
<td class="state" class:failed={operation.state === 'failed'}>{operation.state}</td>
|
||||||
|
<td class="updated">{$timeFormat.dateTime(operation.updatedAt)}</td>
|
||||||
|
<td class="result">{operation.error ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<nav class="pagination" aria-label="Operation history pages">
|
||||||
|
<span role="status">{numbers.format(firstOperation)}–{numbers.format(firstOperation + history.operations.length - 1)} of {numbers.format(history.total)} operations</span>
|
||||||
|
<div class="page-controls">
|
||||||
|
<Button size="sm" href={pageHref(history.page - 1)} disabled={history.page === 1 || changingPage}>Previous</Button>
|
||||||
|
<span>Page {numbers.format(history.page)} of {numbers.format(history.totalPages)}</span>
|
||||||
|
<Button size="sm" href={pageHref(history.page + 1)} disabled={history.page >= history.totalPages || changingPage}>Next</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { display: grid; gap: 24px; min-width: 0; }
|
||||||
|
.panel { min-width: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||||
|
.table-wrap { overflow: auto; }
|
||||||
|
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
|
||||||
|
table { width: 100%; min-width: 760px; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
|
||||||
|
td, th { padding: 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: top; }
|
||||||
|
th { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
||||||
|
td { line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
|
||||||
|
.state { text-transform: capitalize; }
|
||||||
|
.failed { color: var(--state-danger); }
|
||||||
|
.updated { white-space: nowrap; }
|
||||||
|
.result { min-width: 160px; max-width: 420px; }
|
||||||
|
.pagination { padding: 16px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
.page-controls { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.empty { padding: 24px; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
|
@media (max-width: 600px) { .page-controls { width: 100%; justify-content: space-between; } }
|
||||||
|
</style>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
|
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
|
||||||
{#if outcome.active && detail.agentOperation.state === 'completed'}<p>The agent finished its command. EC is verifying readiness.</p>{/if}
|
{#if outcome.active && detail.agentOperation.state === 'completed'}<p>The agent finished its command. EC is verifying readiness.</p>{/if}
|
||||||
<a href={`/hosts/${encodeURIComponent(detail.agentOperation.hostId)}`}>View host operation history</a>
|
<a href="/hosts/operations">View host operation history</a>
|
||||||
</div></Card>
|
</div></Card>
|
||||||
{/if}
|
{/if}
|
||||||
{#if outcome.active && detail.workerActivity}
|
{#if outcome.active && detail.workerActivity}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('management route permissions', () => {
|
|||||||
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
||||||
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
||||||
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
||||||
|
'/hosts/operations', '/api/hosts/operations',
|
||||||
'/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline'
|
'/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline'
|
||||||
])('lets viewers read the safe summary route %s', route => {
|
])('lets viewers read the safe summary route %s', route => {
|
||||||
expect(routePermission(route, 'GET')).toBe('viewer');
|
expect(routePermission(route, 'GET')).toBe('viewer');
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||||
|
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
||||||
|
|
||||||
|
async function seedHistory(count = 53) {
|
||||||
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const first = hosts.createHost({ name: 'Alpha host' });
|
||||||
|
const second = hosts.createHost({ name: 'Beta host' });
|
||||||
|
const deleted = hosts.createHost({ name: 'Removed host' });
|
||||||
|
const handle = db();
|
||||||
|
const ids: string[] = [];
|
||||||
|
handle.transaction(() => {
|
||||||
|
const insert = handle.prepare(`INSERT INTO ec_host_operations
|
||||||
|
(id,host_id,kind,instance_id,generation,payload,result,state,error,created_at,updated_at)
|
||||||
|
VALUES(?,?,'instance.inspect','instance-one',2,?,?,'completed',NULL,?,?)`);
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
// Reverse IDs and tied timestamps ensure insertion order breaks ties.
|
||||||
|
const id = `operation-${String(count - index).padStart(4, '0')}`;
|
||||||
|
const timestamp = index < 3 ? '2026-10-01T12:00:00.000Z' : '2026-10-05T12:00:00.000Z';
|
||||||
|
ids.push(id);
|
||||||
|
insert.run(id, [first, second, deleted][index % 3].id,
|
||||||
|
'{"credential":"payload-secret"}', '{"credential":"result-secret"}', timestamp, timestamp);
|
||||||
|
}
|
||||||
|
handle.prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?').run('2026-10-05T12:30:00.000Z', deleted.id);
|
||||||
|
})();
|
||||||
|
return { ...hosts, handle, ids, first, second, deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('host operation history', () => {
|
||||||
|
it('pages all hosts in stable newest-first order, retaining deleted-host records', async () => {
|
||||||
|
const api = await seedHistory();
|
||||||
|
const pages = [1, 2, 3].map(page => api.listHostOperationHistory(page));
|
||||||
|
expect(pages.map(page => page.operations.length)).toEqual([25, 25, 3]);
|
||||||
|
pages.forEach((page, index) => expect(page).toMatchObject({ page: index + 1, pageSize: 25, total: 53, totalPages: 3 }));
|
||||||
|
const operations = pages.flatMap(page => page.operations);
|
||||||
|
expect(operations.map(operation => operation.id)).toEqual([...api.ids].reverse());
|
||||||
|
expect(operations.find(operation => operation.hostId === api.first.id)?.hostName).toBe('Alpha host');
|
||||||
|
expect(operations.find(operation => operation.hostId === api.second.id)?.hostName).toBe('Beta host');
|
||||||
|
expect(operations.filter(operation => operation.hostId === api.deleted.id).length).toBeGreaterThan(0);
|
||||||
|
expect(operations.filter(operation => operation.hostId === api.deleted.id).every(operation => operation.hostName === null)).toBe(true);
|
||||||
|
expect(api.listHostOperationHistory(999)).toEqual(pages[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds reads in SQLite using the history index and omits private payloads and results', async () => {
|
||||||
|
const api = await seedHistory(1003);
|
||||||
|
const prepare = vi.spyOn(api.handle, 'prepare');
|
||||||
|
const history = api.listHostOperationHistory(2);
|
||||||
|
const query = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.includes('FROM ec_host_operations o LEFT JOIN'))!;
|
||||||
|
prepare.mockRestore();
|
||||||
|
const projection = query.split('FROM ec_host_operations')[0];
|
||||||
|
expect(projection).not.toContain('payload');
|
||||||
|
expect(projection).not.toContain('result');
|
||||||
|
expect(projection).not.toContain('*');
|
||||||
|
expect(query).toContain('LIMIT ? OFFSET ?');
|
||||||
|
const plan = api.handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all(25, 25) as { detail: string }[];
|
||||||
|
expect(plan.some(row => row.detail.includes('USING INDEX ec_host_operations_history'))).toBe(true);
|
||||||
|
expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false);
|
||||||
|
expect(history.operations).toHaveLength(25);
|
||||||
|
expect(history.operations[0]).toMatchObject({ instanceId: 'instance-one', generation: 2, state: 'completed' });
|
||||||
|
expect(history.operations.every(operation => !('payload' in operation) && !('result' in operation))).toBe(true);
|
||||||
|
expect(JSON.stringify(history)).not.toContain('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty first page and normalizes invalid internal page numbers', async () => {
|
||||||
|
const api = await seedHistory(0);
|
||||||
|
const empty = { operations: [], page: 1, pageSize: 25, total: 0, totalPages: 1 };
|
||||||
|
expect(api.listHostOperationHistory()).toEqual(empty);
|
||||||
|
for (const page of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, 999]) {
|
||||||
|
expect(api.listHostOperationHistory(page)).toEqual(empty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves fixed-size API pages without caching or exposing private operation data', async () => {
|
||||||
|
const api = await seedHistory();
|
||||||
|
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
|
||||||
|
const first = GET({ request: new Request('https://ec.test/api/hosts/operations') });
|
||||||
|
expect(first.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect(await first.json()).toEqual(api.listHostOperationHistory(1));
|
||||||
|
const second = GET({ request: new Request('https://ec.test/api/hosts/operations?page=2&pageSize=500') });
|
||||||
|
const body = await second.json();
|
||||||
|
expect(body).toEqual(api.listHostOperationHistory(2));
|
||||||
|
expect(body.operations).toHaveLength(25);
|
||||||
|
expect(JSON.stringify(body)).not.toContain('secret');
|
||||||
|
const last = GET({ request: new Request('https://ec.test/api/hosts/operations?page=999') });
|
||||||
|
expect(await last.json()).toEqual(api.listHostOperationHistory(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid API pages and keeps host management access restrictions', async () => {
|
||||||
|
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
|
||||||
|
const url = 'https://ec.test/api/hosts/operations';
|
||||||
|
for (const page of ['', '0', '-1', '1.5', 'nope', 'Infinity', '9007199254740992']) {
|
||||||
|
expect(() => GET({ request: new Request(`${url}?page=${page}`) })).toThrow(expect.objectContaining({ status: 400 }));
|
||||||
|
}
|
||||||
|
const blockedHeaders: HeadersInit[] = [
|
||||||
|
{ origin: 'https://other.test' },
|
||||||
|
{ 'sec-fetch-site': 'cross-site' },
|
||||||
|
{ authorization: 'Bearer ec_host_secret' },
|
||||||
|
{ authorization: 'Bearer ec_enroll_secret' }
|
||||||
|
];
|
||||||
|
for (const headers of blockedHeaders) {
|
||||||
|
expect(() => GET({ request: new Request(url, { headers }) })).toThrow(expect.objectContaining({ status: 403 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps operation history out of host detail responses', async () => {
|
||||||
|
const api = await seedHistory();
|
||||||
|
const { GET } = await import('../../src/routes/api/hosts/[id]/+server.js');
|
||||||
|
const body = await GET({ request: new Request(`https://ec.test/api/hosts/${api.first.id}`), params: { id: api.first.id } }).json();
|
||||||
|
expect(body.host.id).toBe(api.first.id);
|
||||||
|
expect(body).not.toHaveProperty('operations');
|
||||||
|
expect(JSON.stringify(body)).not.toContain('secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user