Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
import { setupRequired } from '$lib/server/auth/index.js';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user ?? null, setupRequired: setupRequired() });
export const load: LayoutServerLoad = ({ locals }) => ({
user: locals.user ?? null,
setupRequired: setupRequired()
});
+582 -129
View File
@@ -20,7 +20,10 @@
// the referenced woff/woff2 files get emitted into the build.
import '$ui/styles/fonts.js';
interface Props { children: import('svelte').Snippet; data: { user: AuthUser | null; setupRequired: boolean } }
interface Props {
children: import('svelte').Snippet;
data: { user: AuthUser | null; setupRequired: boolean };
}
let { children, data }: Props = $props();
const publicPage = $derived(['/login', '/setup'].includes($page.url.pathname));
const sessionReady = $derived(!!data.user && !data.user.mustChangePassword && !publicPage);
@@ -36,7 +39,8 @@
const accountInitial = $derived(Array.from(accountName.trim())[0]?.toLocaleUpperCase() || '?');
function dismissAccount(event: MouseEvent) {
if (event.target instanceof Element && !event.target.closest('.account-menu')) accountOpen = false;
if (event.target instanceof Element && !event.target.closest('.account-menu'))
accountOpen = false;
}
function openHelp() {
@@ -46,33 +50,61 @@
}
beforeNavigate(() => performance.mark('ec:navigation-start'));
afterNavigate(() => { if (performance.getEntriesByName('ec:navigation-start').length) { performance.measure('ec:navigation', 'ec:navigation-start'); performance.clearMarks('ec:navigation-start'); } });
afterNavigate(() => { accountOpen = false; drawerOpen = false; paletteOpen = false; resolveConfirmation(false); });
afterNavigate(() => {
if (performance.getEntriesByName('ec:navigation-start').length) {
performance.measure('ec:navigation', 'ec:navigation-start');
performance.clearMarks('ec:navigation-start');
}
});
afterNavigate(() => {
accountOpen = false;
drawerOpen = false;
paletteOpen = false;
resolveConfirmation(false);
});
function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
if (event.key === 'Escape' && accountOpen) {
accountOpen = false;
accountButton?.focus();
}
}
async function signOut() {
if (signingOut) return;
signingOut = true; signOutError = '';
signingOut = true;
signOutError = '';
try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); }
const response = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!response.ok) {
const body = await response.json();
throw new Error(body.error ?? 'Could not sign out. Try again.');
}
window.location.assign('/login');
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
} catch (cause) {
signOutError = (cause as Error).message;
signingOut = false;
}
}
$effect(() => {
if (!sessionReady) return;
const stopPipeline = startPipelineStream();
const stopServers = startServerStream();
return () => { stopPipeline?.(); stopServers?.(); };
return () => {
stopPipeline?.();
stopServers?.();
};
});
$effect(() => {
if (!sessionReady) return;
if ('fleetSnapshot' in $page.data && $page.data.fleetSnapshot) hydrateFleet($page.data.fleetSnapshot);
if ('fleetSnapshot' in $page.data && $page.data.fleetSnapshot)
hydrateFleet($page.data.fleetSnapshot);
});
$effect(() => {
@@ -84,10 +116,17 @@
onMount(() => {
const stopTimeZone = startTimeZonePreference();
const desktop = window.matchMedia('(min-width: 961px)');
const closeDrawer = () => { if (desktop.matches) drawerOpen = false; };
const closeDrawer = () => {
if (desktop.matches) drawerOpen = false;
};
desktop.addEventListener('change', closeDrawer);
const shortcuts = Object.fromEntries(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut).map(item => [item.shortcut!, item.href]));
const shortcuts = Object.fromEntries(
visibleNavigation(data.user?.role ?? 'viewer')
.flatMap((group) => group.items)
.filter((item) => item.shortcut)
.map((item) => [item.shortcut!, item.href])
);
let armed = false;
let armTimer: ReturnType<typeof setTimeout> | null = null;
@@ -96,24 +135,41 @@
// Global search also works while focus is in a page's search field.
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
accountOpen = false; drawerOpen = false; helpOpen = false;
accountOpen = false;
drawerOpen = false;
helpOpen = false;
paletteOpen = !paletteOpen;
return;
}
const target = e.target as HTMLElement;
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (
target &&
(['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
target.isContentEditable ||
target.closest('dialog'))
)
return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === '?') { e.preventDefault(); openHelp(); return; }
if (e.key === '?') {
e.preventDefault();
openHelp();
return;
}
if (!armed && e.key === 'g') {
armed = true;
if (armTimer) clearTimeout(armTimer);
armTimer = setTimeout(() => { armed = false; }, 1000);
armTimer = setTimeout(() => {
armed = false;
}, 1000);
return;
}
if (armed) {
armed = false;
if (armTimer) { clearTimeout(armTimer); armTimer = null; }
if (armTimer) {
clearTimeout(armTimer);
armTimer = null;
}
const dest = shortcuts[e.key];
if ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
if (dest) {
@@ -135,7 +191,11 @@
// Non-Chromium browsers fall through to plain navigation.
onNavigate(enableViewTransitions);
const shortcuts = $derived(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut));
const shortcuts = $derived(
visibleNavigation(data.user?.role ?? 'viewer')
.flatMap((group) => group.items)
.filter((item) => item.shortcut)
);
</script>
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} />
@@ -144,67 +204,147 @@
<a class="skip-link" href="#main-content">Skip to content</a>
{#if sessionReady && data.user}
<aside class="sidebar" id="desktop-navigation" hidden={sidebarCollapsed}>
<a class="sidebar-brand" href="/" aria-label="Electric Cherry home"><BrandMark size="sm" /></a>
<a class="sidebar-brand" href="/" aria-label="Electric Cherry home"
><BrandMark size="sm" /></a
>
<div class="sidebar-scroll"><Navigation role={data.user.role} /></div>
</aside>
<div class="navigation-drawer">
<Dialog bind:open={drawerOpen} title="Navigation" drawer>
<div id="mobile-navigation"><Navigation role={data.user.role} onNavigate={() => drawerOpen = false} /></div>
<div id="mobile-navigation">
<Navigation role={data.user.role} onNavigate={() => (drawerOpen = false)} />
</div>
</Dialog>
</div>
{/if}
{#if publicPage}
<header class="auth-brand"><BrandMark size="md" /></header>
{:else}
<header class="app-header">
{#if sessionReady}
<button class="icon-button desktop-toggle" type="button" aria-label={sidebarCollapsed ? 'Expand navigation' : 'Collapse navigation'} aria-controls="desktop-navigation" aria-expanded={!sidebarCollapsed} onclick={() => sidebarCollapsed = !sidebarCollapsed}>
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2" y="3" width="16" height="14" rx="2" /><path d="M7 3v14" /></svg>
</button>
<button class="icon-button mobile-toggle" type="button" aria-label="Open navigation" aria-controls="mobile-navigation" aria-expanded={drawerOpen} onclick={() => drawerOpen = true}>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14" /></svg>
</button>
{/if}
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
<BrandMark size="sm" />
</a>
{#if sessionReady}<button class="search-button" type="button" aria-label="Search pages, servers, hosts, and players" aria-keyshortcuts="Meta+k Control+k" onclick={() => paletteOpen = true}>
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8" cy="8" r="5" /><path d="m12 12 5 5" /></svg>
<span class="search-label">Search pages, servers, players…</span><kbd>⌘K / Ctrl K</kbd>
</button>{/if}
<div class="header-actions">
{#if sessionReady}<button class="icon-button help-button" type="button" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={openHelp}>?</button>{/if}
{#if data.user}
<div class="account-menu">
<button bind:this={accountButton} class="account-button" type="button"
aria-label={`Account: ${accountName}`} title={accountName}
aria-expanded={accountOpen} aria-controls="account-links"
onclick={() => accountOpen = !accountOpen}>
<span aria-hidden="true">{accountInitial}</span>
</button>
{#if accountOpen}
<div class="account-links" id="account-links">
<div class="account-name">{accountName}</div>
<a href="/account">My account</a>
{#if sessionReady}<button onclick={openHelp}>Keyboard shortcuts <span aria-hidden="true">?</span></button>{/if}
{#if data.user.role === 'admin' && sessionReady}<a href="/settings/users">Manage users</a>{/if}
<button disabled={signingOut} onclick={signOut}>{signingOut ? 'Signing out…' : 'Sign out'}</button>
{#if signOutError}<p role="alert">{signOutError}</p>{/if}
</div>
{/if}
</div>
<header class="app-header">
{#if sessionReady}
<button
class="icon-button desktop-toggle"
type="button"
aria-label={sidebarCollapsed ? 'Expand navigation' : 'Collapse navigation'}
aria-controls="desktop-navigation"
aria-expanded={!sidebarCollapsed}
onclick={() => (sidebarCollapsed = !sidebarCollapsed)}
>
<svg viewBox="0 0 20 20" aria-hidden="true"
><rect x="2" y="3" width="16" height="14" rx="2" /><path d="M7 3v14" /></svg
>
</button>
<button
class="icon-button mobile-toggle"
type="button"
aria-label="Open navigation"
aria-controls="mobile-navigation"
aria-expanded={drawerOpen}
onclick={() => (drawerOpen = true)}
>
<svg viewBox="0 0 20 20" aria-hidden="true"
><path d="M3 5h14M3 10h14M3 15h14" /></svg
>
</button>
{/if}
</div>
</header>
<a
class="brand-link"
href={sessionReady ? '/' : '/account'}
aria-label="Electric Cherry home"
>
<BrandMark size="sm" />
</a>
{#if sessionReady}<button
class="search-button"
type="button"
aria-label="Search pages, servers, hosts, and players"
aria-keyshortcuts="Meta+k Control+k"
onclick={() => (paletteOpen = true)}
>
<svg viewBox="0 0 20 20" aria-hidden="true"
><circle cx="8" cy="8" r="5" /><path d="m12 12 5 5" /></svg
>
<span class="search-label">Search pages, servers, players…</span><kbd
>⌘K / Ctrl K</kbd
>
</button>{/if}
<div class="header-actions">
{#if sessionReady}<button
class="icon-button help-button"
type="button"
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
onclick={openHelp}>?</button
>{/if}
{#if data.user}
<div class="account-menu">
<button
bind:this={accountButton}
class="account-button"
type="button"
aria-label={`Account: ${accountName}`}
title={accountName}
aria-expanded={accountOpen}
aria-controls="account-links"
onclick={() => (accountOpen = !accountOpen)}
>
<span aria-hidden="true">{accountInitial}</span>
</button>
{#if accountOpen}
<div class="account-links" id="account-links">
<div class="account-name">{accountName}</div>
<a href="/account">My account</a>
{#if sessionReady}<button onclick={openHelp}
>Keyboard shortcuts <span aria-hidden="true">?</span
></button
>{/if}
{#if data.user.role === 'admin' && sessionReady}<a
href="/settings/users">Manage users</a
>{/if}
<button disabled={signingOut} onclick={signOut}
>{signingOut ? 'Signing out…' : 'Sign out'}</button
>
{#if signOutError}<p role="alert">{signOutError}</p>{/if}
</div>
{/if}
</div>
{/if}
</div>
</header>
{/if}
<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}
{@render children()}
</main>
<Confirmation />
{#if sessionReady && data.user}<CommandPalette bind:open={paletteOpen} role={data.user.role} />{/if}
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then a destination key. Page shortcuts are inactive while typing or using a dialog; search works from any field.">
<dl class="shortcuts"><div><dt><kbd>⌘K / Ctrl K</kbd></dt><dd>Search pages, servers, hosts, and players</dd></div>{#each shortcuts as item}<div><dt><kbd>g {item.shortcut}</kbd></dt><dd>{item.href === '/' ? 'Fleet' : item.label}</dd></div>{/each}<div><dt><kbd>?</kbd></dt><dd>This help</dd></div></dl>
{#if sessionReady && data.user}<CommandPalette
bind:open={paletteOpen}
role={data.user.role}
/>{/if}
<Dialog
bind:open={helpOpen}
title="Keyboard shortcuts"
description="Press g, then a destination key. Page shortcuts are inactive while typing or using a dialog; search works from any field."
>
<dl class="shortcuts">
<div>
<dt><kbd>⌘K / Ctrl K</kbd></dt>
<dd>Search pages, servers, hosts, and players</dd>
</div>
{#each shortcuts as item}<div>
<dt><kbd>g {item.shortcut}</kbd></dt>
<dd>{item.href === '/' ? 'Fleet' : item.label}</dd>
</div>{/each}
<div>
<dt><kbd>?</kbd></dt>
<dd>This help</dd>
</div>
</dl>
</Dialog>
</div>
@@ -223,8 +363,12 @@
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
:global(*) { box-sizing: border-box; }
:global(a) { color: var(--accent-primary-hover); }
:global(*) {
box-sizing: border-box;
}
:global(a) {
color: var(--accent-primary-hover);
}
:global(::selection) {
background: var(--accent-primary);
color: var(--text-on-accent);
@@ -242,73 +386,382 @@
inset: 0;
pointer-events: none;
background:
radial-gradient(1200px 700px at 20% -10%, rgba(46, 134, 209, 0.10), transparent 60%),
radial-gradient(1200px 700px at 20% -10%, rgba(46, 134, 209, 0.1), transparent 60%),
radial-gradient(900px 600px at 90% 100%, rgba(124, 192, 245, 0.05), transparent 60%);
z-index: -1;
}
.shell { min-height: 100vh; }
.shell.with-sidebar { padding-left: 224px; }
.shell.sidebar-collapsed { padding-left: 0; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 25; display: flex; flex-direction: column; width: 224px; border-right: 1px solid var(--border-subtle); background: var(--surface); }
.sidebar[hidden] { display: none; }
.sidebar-brand { display: flex; align-items: center; min-height: 60px; padding: 0 var(--space-4); text-decoration: none; }
.sidebar-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-2) var(--space-6); scrollbar-width: thin; }
.navigation-drawer :global(dialog.drawer) { inset: 0 auto 0 0; width: min(320px, 100vw); border-left: 0; border-right: 1px solid var(--border-subtle); }
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
main { min-width:0; overflow-wrap:anywhere; padding:var(--space-6) var(--space-8) var(--space-16); max-width:1600px; margin:0 auto; view-transition-name:main; }
main.auth-main { max-width:520px; padding-top:16px; }
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); }
.viewer-note strong { color:var(--text-primary); }
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:var(--space-3); min-height:60px; padding:0 var(--space-5); border-bottom:1px solid var(--border-subtle); background:var(--surface-sunken); }
.brand-link { flex-shrink:0; display:flex; align-items:center; text-decoration:none; }
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link { display: none; }
.icon-button { display: inline-grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font-size: var(--text-lg); cursor: pointer; }
.icon-button:hover { color: var(--text-primary); background: var(--surface-raised); }
.icon-button svg, .search-button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.6; flex-shrink: 0; }
.mobile-toggle { display: none; }
.search-button { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex: 1; max-width: 440px; height: 36px; padding: 0 var(--space-3); border: 1px solid var(--border-control); border-radius: var(--radius-sm); background: var(--surface-sunken); color: var(--text-secondary); cursor: pointer; text-align: left; font-size: var(--text-xs); }
.search-button:hover { border-color: var(--border-control-hover); color: var(--text-primary); }
.search-label { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.search-button kbd { padding: 1px 4px; white-space: nowrap; }
.header-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
.account-menu { position:relative; }
.account-button { display:inline-flex; align-items:center; justify-content:center; min-width:44px; height:44px; padding:8px 12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); font:inherit; cursor:pointer; }
.account-button { border-radius:50%; color:var(--accent-primary-hover); background:color-mix(in oklab,var(--accent-primary) 12%,var(--surface)); font-weight:600; }
.account-links { position:absolute; right:0; top:calc(100% + 8px); width:220px; max-width:calc(100vw - 32px); max-height:calc(100dvh - 84px); overflow-y:auto; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
.account-name { padding:8px 12px 12px; margin-bottom:4px; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:var(--text-sm); overflow-wrap:anywhere; }
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); }
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
.shortcuts { display:grid; gap:12px; margin:0; }
.shortcuts > div { display:flex; gap:20px; align-items:center; }
.shortcuts dt { min-width:120px; }
.shortcuts dd { margin:0; font-size:var(--text-sm); }
kbd { font-family:var(--font-mono); padding:3px 8px; border:1px solid var(--border-strong); border-radius:5px; }
.skip-link { position:fixed; left:16px; top:-80px; z-index:300; padding:12px 16px; background:var(--surface-raised); border-radius:var(--radius-md); }
.skip-link:focus { top:12px; }
:global(input),:global(select),:global(textarea) { min-width:0; max-width:100%; }
:global(input),:global(select),:global(textarea),:global(button),:global(a) { scroll-margin-block:90px 100px; }
:global(dialog) { max-height:calc(100dvh - 32px); overflow-y:auto; overscroll-behavior:contain; }
@media (max-width:960px) {
.shell.with-sidebar { padding-left: 0; }
.sidebar, .desktop-toggle { display: none; }
.mobile-toggle { display: inline-grid; }
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link { display: flex; }
.app-header { min-height: 56px; gap: var(--space-2); padding: 0 var(--space-3); }
.search-button { margin-left: auto; max-width: 300px; }
.search-button kbd { display: none; }
.header-actions { margin-left: 0; }
.shell {
min-height: 100vh;
}
@media (max-width:720px) {
.app-header { padding-inline: var(--space-2); gap: var(--space-1); }
.brand-link :global(.mark-sm .word) { font-size: var(--text-xs); letter-spacing:1px; }
.search-button { flex: 0 0 44px; width: 44px; height: 44px; padding: 0; justify-content: center; border: 0; }
.search-label, .help-button { display: none; }
main { padding:var(--space-4) max(var(--space-4),env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4),env(safe-area-inset-left)); }
:global(input:not([type='checkbox']):not([type='radio'])),:global(select),:global(textarea) { font-size: var(--text-input) !important; min-height:44px; }
:global(button),:global(.btn) { min-height:44px; }
.shell.with-sidebar {
padding-left: 224px;
}
.shell.sidebar-collapsed {
padding-left: 0;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 25;
display: flex;
flex-direction: column;
width: 224px;
border-right: 1px solid var(--border-subtle);
background: var(--surface);
}
.sidebar[hidden] {
display: none;
}
.sidebar-brand {
display: flex;
align-items: center;
min-height: 60px;
padding: 0 var(--space-4);
text-decoration: none;
}
.sidebar-scroll {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-2) var(--space-2) var(--space-6);
scrollbar-width: thin;
}
.navigation-drawer :global(dialog.drawer) {
inset: 0 auto 0 0;
width: min(320px, 100vw);
border-left: 0;
border-right: 1px solid var(--border-subtle);
}
.auth-brand {
display: flex;
justify-content: center;
padding: 48px 20px 20px;
}
main {
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-6) var(--space-8) var(--space-16);
max-width: 1600px;
margin: 0 auto;
view-transition-name: main;
}
main.auth-main {
max-width: 520px;
padding-top: 16px;
}
.viewer-note {
display: flex;
gap: 8px 16px;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 24px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.viewer-note strong {
color: var(--text-primary);
}
.app-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 60px;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-sunken);
}
.brand-link {
flex-shrink: 0;
display: flex;
align-items: center;
text-decoration: none;
}
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link {
display: none;
}
.icon-button {
display: inline-grid;
place-items: center;
flex-shrink: 0;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-size: var(--text-lg);
cursor: pointer;
}
.icon-button:hover {
color: var(--text-primary);
background: var(--surface-raised);
}
.icon-button svg,
.search-button svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
flex-shrink: 0;
}
.mobile-toggle {
display: none;
}
.search-button {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
max-width: 440px;
height: 36px;
padding: 0 var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
color: var(--text-secondary);
cursor: pointer;
text-align: left;
font-size: var(--text-xs);
}
.search-button:hover {
border-color: var(--border-control-hover);
color: var(--text-primary);
}
.search-label {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.search-button kbd {
padding: 1px 4px;
white-space: nowrap;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.account-menu {
position: relative;
}
.account-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
.account-button {
border-radius: 50%;
color: var(--accent-primary-hover);
background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface));
font-weight: 600;
}
.account-links {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: 220px;
max-width: calc(100vw - 32px);
max-height: calc(100dvh - 84px);
overflow-y: auto;
z-index: 30;
display: grid;
padding: 8px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: 0 12px 32px #0005;
}
.account-name {
padding: 8px 12px 12px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-secondary);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.account-links a,
.account-links button {
text-align: left;
padding: 10px 12px;
color: var(--text-primary);
background: none;
border: 0;
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
text-decoration: none;
}
.account-links a:hover,
.account-links button:hover {
background: var(--surface-raised);
}
.account-links p {
padding: 0 12px;
font-size: var(--text-xs);
color: var(--state-danger);
}
.shortcuts {
display: grid;
gap: 12px;
margin: 0;
}
.shortcuts > div {
display: flex;
gap: 20px;
align-items: center;
}
.shortcuts dt {
min-width: 120px;
}
.shortcuts dd {
margin: 0;
font-size: var(--text-sm);
}
kbd {
font-family: var(--font-mono);
padding: 3px 8px;
border: 1px solid var(--border-strong);
border-radius: 5px;
}
.skip-link {
position: fixed;
left: 16px;
top: -80px;
z-index: 300;
padding: 12px 16px;
background: var(--surface-raised);
border-radius: var(--radius-md);
}
.skip-link:focus {
top: 12px;
}
:global(input),
:global(select),
:global(textarea) {
min-width: 0;
max-width: 100%;
}
:global(input),
:global(select),
:global(textarea),
:global(button),
:global(a) {
scroll-margin-block: 90px 100px;
}
:global(dialog) {
max-height: calc(100dvh - 32px);
overflow-y: auto;
overscroll-behavior: contain;
}
@media (max-width: 960px) {
.shell.with-sidebar {
padding-left: 0;
}
.sidebar,
.desktop-toggle {
display: none;
}
.mobile-toggle {
display: inline-grid;
}
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link {
display: flex;
}
.app-header {
min-height: 56px;
gap: var(--space-2);
padding: 0 var(--space-3);
}
.search-button {
margin-left: auto;
max-width: 300px;
}
.search-button kbd {
display: none;
}
.header-actions {
margin-left: 0;
}
}
@media (max-width: 720px) {
.app-header {
padding-inline: var(--space-2);
gap: var(--space-1);
}
.brand-link :global(.mark-sm .word) {
font-size: var(--text-xs);
letter-spacing: 1px;
}
.search-button {
flex: 0 0 44px;
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
border: 0;
}
.search-label,
.help-button {
display: none;
}
main {
padding: var(--space-4) max(var(--space-4), env(safe-area-inset-right))
calc(var(--space-16) + env(safe-area-inset-bottom))
max(var(--space-4), env(safe-area-inset-left));
}
:global(input:not([type='checkbox']):not([type='radio'])),
:global(select),
:global(textarea) {
font-size: var(--text-input) !important;
min-height: 44px;
}
:global(button),
:global(.btn) {
min-height: 44px;
}
}
@media (max-width: 360px) {
.brand-link :global(.mark-sm .word) {
display: none;
}
.brand-link::after {
content: 'EC';
margin-left: var(--space-2);
color: var(--text-primary);
font-size: var(--text-base);
font-weight: var(--weight-semibold);
}
.header-actions {
gap: 4px;
}
}
:global(::view-transition-old(root)),
:global(::view-transition-new(root)),
:global(::view-transition-old(main)),
:global(::view-transition-new(main)) {
animation-duration: var(--dur-base);
animation-timing-function: var(--ease-standard);
}
@media (max-width:360px) { .brand-link :global(.mark-sm .word) { display: none; } .brand-link::after { content: 'EC'; margin-left: var(--space-2); color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); } .header-actions { gap:4px; } }
:global(::view-transition-old(root)),:global(::view-transition-new(root)),:global(::view-transition-old(main)),:global(::view-transition-new(main)) { animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); }
</style>
+3 -1
View File
@@ -1,4 +1,6 @@
import { fleetSnapshot } from '$lib/server/fleet/snapshot.js';
import { fleetOperations } from '$lib/server/fleet/overview.js';
export function load() { return { fleetSnapshot: { ...fleetSnapshot(), operations: fleetOperations() } }; }
export function load() {
return { fleetSnapshot: { ...fleetSnapshot(), operations: fleetOperations() } };
}
+946 -215
View File
File diff suppressed because it is too large Load Diff
+279 -46
View File
@@ -4,73 +4,306 @@
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
let exampleDate = $state<Date | null>(null);
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
onMount(() => { exampleDate = new Date(); });
onMount(() => {
exampleDate = new Date();
});
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let currentPassword = $state(''); let newPassword = $state(''); let confirmPassword = $state('');
let busy = $state(false); let signingOut = $state(false); let error = $state(''); let notice = $state('');
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let busy = $state(false);
let signingOut = $state(false);
let error = $state('');
let notice = $state('');
const user = $derived($page.data.user);
async function changePassword(event: SubmitEvent) {
event.preventDefault(); if (busy || signingOut) return; error = ''; notice = '';
if (newPassword !== confirmPassword) { error = 'The new passwords do not match.'; return; }
event.preventDefault();
if (busy || signingOut) return;
error = '';
notice = '';
if (newPassword !== confirmPassword) {
error = 'The new passwords do not match.';
return;
}
const required = user?.mustChangePassword;
busy = true;
try {
const response = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
currentPassword = ''; newPassword = ''; confirmPassword = '';
if (required) { window.location.assign('/'); return; }
await invalidateAll(); notice = 'Your password has been changed.';
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
const response = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
currentPassword = '';
newPassword = '';
confirmPassword = '';
if (required) {
window.location.assign('/');
return;
}
await invalidateAll();
notice = 'Your password has been changed.';
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
async function logout() {
if (busy || signingOut) return; signingOut = true; error = '';
if (busy || signingOut) return;
signingOut = true;
error = '';
try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); }
const response = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!response.ok) {
const body = await response.json();
throw new Error(body.error ?? 'Could not sign out.');
}
window.location.assign('/login');
} catch (cause) { error = (cause as Error).message; signingOut = false; }
} catch (cause) {
error = (cause as Error).message;
signingOut = false;
}
}
</script>
<svelte:head><title>My account · Electric Cherry</title></svelte:head>
<section class="page"><SectionHeader title="My account" subtitle="Manage your password, display preferences, and current session." />
<section class="page">
<SectionHeader
title="My account"
subtitle="Manage your password, display preferences, and current session."
/>
{#if user}
<section class="panel identity"><div><h2>{user.displayName}</h2><p>@{user.username} · <span class="role">{user.role}</span></p></div><Button disabled={busy || signingOut} loading={signingOut} onclick={logout}>Sign out</Button></section>
{#if user.mustChangePassword}<p class="required" role="status">Your account uses a temporary password. Choose a new password before continuing to Electric Cherry.</p>{/if}
<section class="panel"><h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p class="notice" role="status">{notice}</p>{/if}
<form onsubmit={changePassword} aria-busy={busy}><fieldset disabled={busy || signingOut}>
<input type="text" class="password-username" value={user.username} autocomplete="username" aria-label="Username" readonly tabindex="-1" />
<label for="current-password">{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input id="current-password" name="currentPassword" type="password" autocomplete="current-password" required maxlength="128" bind:value={currentPassword} /></label>
<label for="new-password">New password<input id="new-password" name="newPassword" type="password" autocomplete="new-password" required minlength="8" maxlength="128" bind:value={newPassword} aria-describedby="new-password-help" /></label>
<p id="new-password-help" class="hint">Use 8–128 characters and a password you do not use elsewhere.</p>
<label for="confirm-password">Confirm new password<input id="confirm-password" name="confirmPassword" type="password" autocomplete="new-password" required minlength="8" maxlength="128" bind:value={confirmPassword} /></label>
<div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div>
</fieldset></form>
<section class="panel identity">
<div>
<h2>{user.displayName}</h2>
<p>@{user.username} · <span class="role">{user.role}</span></p>
</div>
<Button disabled={busy || signingOut} loading={signingOut} onclick={logout}
>Sign out</Button
>
</section>
{#if user.mustChangePassword}<p class="required" role="status">
Your account uses a temporary password. Choose a new password before continuing to
Electric Cherry.
</p>{/if}
<section class="panel">
<h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
class="notice"
role="status"
>
{notice}
</p>{/if}
<form onsubmit={changePassword} aria-busy={busy}>
<fieldset disabled={busy || signingOut}>
<input
type="text"
class="password-username"
value={user.username}
autocomplete="username"
aria-label="Username"
readonly
tabindex="-1"
/>
<label for="current-password"
>{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input
id="current-password"
name="currentPassword"
type="password"
autocomplete="current-password"
required
maxlength="128"
bind:value={currentPassword}
/></label
>
<label for="new-password"
>New password<input
id="new-password"
name="newPassword"
type="password"
autocomplete="new-password"
required
minlength="8"
maxlength="128"
bind:value={newPassword}
aria-describedby="new-password-help"
/></label
>
<p id="new-password-help" class="hint">
Use 8–128 characters and a password you do not use elsewhere.
</p>
<label for="confirm-password"
>Confirm new password<input
id="confirm-password"
name="confirmPassword"
type="password"
autocomplete="new-password"
required
minlength="8"
maxlength="128"
bind:value={confirmPassword}
/></label
>
<div>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={busy || signingOut}
>{user.mustChangePassword
? 'Save password and continue'
: 'Change password'}</Button
>
</div>
</fieldset>
</form>
</section>
<section class="panel" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<label class="timezone"
>Timezone
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}
>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE
? ' (default)'
: ''}</option
>{/each}
</select>
</label>
<p id="timezone-help">
Dates and times throughout the app use this timezone, including daylight saving
changes. Saved automatically in this browser.
</p>
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">
Browser storage is unavailable. Your timezone applies for this visit but cannot
be saved.
</p>{/if}
</section>
<section class="panel" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<label class="timezone">Timezone
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE ? ' (default)' : ''}</option>{/each}
</select>
</label>
<p id="timezone-help">Dates and times throughout the app use this timezone, including daylight saving changes. Saved automatically in this browser.</p>
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">Browser storage is unavailable. Your timezone applies for this visit but cannot be saved.</p>{/if}
</section>
{/if}
</section>
<style>
.timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; }
.timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
.timezone ~ p { font-size:var(--text-sm); color:var(--text-secondary); }
.page { max-width:760px; display:grid; gap:24px; } .panel { padding:24px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); } h2 { font-size:var(--text-md); margin:0; } .identity { display:flex; align-items:center; justify-content:space-between; gap:20px; } .identity p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-sm); } .role { text-transform:capitalize; }
.required { padding:16px; margin:0; color:var(--text-primary); background:var(--surface-raised); border-left:3px solid var(--accent-primary); font-size:var(--text-sm); line-height:1.6; }
form { margin-top:24px; max-width:460px; } fieldset { border:0; margin:0; padding:0; display:grid; gap:18px; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .password-username { position:absolute; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; padding:0; border:0; } .hint { margin:-8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.6; } .error { color:var(--state-danger); font-size:var(--text-sm); } .notice { color:var(--state-online); font-size:var(--text-sm); }
@media(max-width:540px) { .identity { align-items:flex-start; flex-direction:column; } }
.timezone {
display: grid;
gap: 8px;
margin-top: 16px;
font-size: var(--text-sm);
max-width: 420px;
}
.timezone select {
width: 100%;
padding: 10px 12px;
font: inherit;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.timezone ~ p {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.page {
max-width: 760px;
display: grid;
gap: 24px;
}
.panel {
padding: 24px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
}
h2 {
font-size: var(--text-md);
margin: 0;
}
.identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.identity p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.role {
text-transform: capitalize;
}
.required {
padding: 16px;
margin: 0;
color: var(--text-primary);
background: var(--surface-raised);
border-left: 3px solid var(--accent-primary);
font-size: var(--text-sm);
line-height: 1.6;
}
form {
margin-top: 24px;
max-width: 460px;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: 18px;
min-width: 0;
}
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input {
width: 100%;
padding: 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
font: inherit;
}
.password-username {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
overflow: hidden;
padding: 0;
border: 0;
}
.hint {
margin: -8px 0 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.error {
color: var(--state-danger);
font-size: var(--text-sm);
}
.notice {
color: var(--state-online);
font-size: var(--text-sm);
}
@media (max-width: 540px) {
.identity {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+7 -1
View File
@@ -2,5 +2,11 @@ import { parseActivityQuery } from '$lib/activity.js';
import { activityHistory } from '$lib/server/activity.js';
export function load({ url }: { url: URL }) {
return { activity: activityHistory({ ...parseActivityQuery(url.searchParams), excludeProvisioning: true, excludeMaintenance: true }) };
return {
activity: activityHistory({
...parseActivityQuery(url.searchParams),
excludeProvisioning: true,
excludeMaintenance: true
})
};
}
+9 -2
View File
@@ -7,10 +7,17 @@
<svelte:head><title>Activity · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Activity" subtitle="Releases and host activity. Server jobs are in Provisioning; maintenance and recovery are in Maintenance history." />
<SectionHeader
title="Activity"
subtitle="Releases and host activity. Server jobs are in Provisioning; maintenance and recovery are in Maintenance history."
/>
<ActivityLog initial={data.activity} excludeProvisioning excludeMaintenance />
</div>
<style>
.page { display: grid; gap: var(--space-6); min-width: 0; }
.page {
display: grid;
gap: var(--space-6);
min-width: 0;
}
</style>
+3 -1
View File
@@ -3,5 +3,7 @@ import { parseActivityQuery } from '$lib/activity.js';
import { activityHistory } from '$lib/server/activity.js';
export function GET({ url }: { url: URL }) {
return json(activityHistory(parseActivityQuery(url.searchParams)), { headers: { 'Cache-Control': 'no-store' } });
return json(activityHistory(parseActivityQuery(url.searchParams)), {
headers: { 'Cache-Control': 'no-store' }
});
}
+14 -7
View File
@@ -5,13 +5,20 @@ import { db } from '$lib/server/db/index.js';
export function GET({ request, params }: { request: Request; params: { sha256: string } }) {
const host = authenticateAgent(request);
if (!/^[a-f0-9]{64}$/.test(params.sha256)) error(404, 'File not found.');
const row = db().prepare(`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
const row = db()
.prepare(
`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
SELECT 1 FROM file_blob_references r JOIN ec_host_operations o ON o.id=r.owner_id
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`).get(params.sha256, host.id) as
{ content: Buffer; size: number } | undefined;
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`
)
.get(params.sha256, host.id) as { content: Buffer; size: number } | undefined;
if (!row) error(404, 'File not found.');
return new Response(new Uint8Array(row.content), { headers: {
'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size),
'Cache-Control': 'private, no-store', ETag: `"${params.sha256}"`
} });
return new Response(new Uint8Array(row.content), {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(row.size),
'Cache-Control': 'private, no-store',
ETag: `"${params.sha256}"`
}
});
}
+7 -2
View File
@@ -5,8 +5,13 @@ export async function POST({ request }: { request: Request }) {
const host = authenticateAgent(request);
try {
const body = await objectBody(request);
if (typeof body.credential !== 'string') throw new Error('Persist a randomly generated replacement credential before requesting rotation.');
if (typeof body.credential !== 'string')
throw new Error(
'Persist a randomly generated replacement credential before requesting rotation.'
);
rotateCredential(host.id, body.credential);
return json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
} catch (cause) {
return hostApiError(cause);
}
}
@@ -5,10 +5,15 @@ import { objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
/** The uninstall worker retains only this narrowly scoped credential after removing the agent. */
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
requireAgentTransport(request);
const token = request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1] ?? '';
try { return json(receiveDecommissionReceipt(params.id, token, await objectBody(request, 8192)), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) {
if (cause instanceof HostDeletionError) return json({ error: cause.message }, { status: cause.status });
const token =
request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1] ?? '';
try {
return json(receiveDecommissionReceipt(params.id, token, await objectBody(request, 8192)), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
if (cause instanceof HostDeletionError)
return json({ error: cause.message }, { status: cause.status });
throw cause;
}
}
+8 -2
View File
@@ -3,6 +3,12 @@ import { enrollHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
requireAgentTransport(request);
try { const body = await objectBody(request); return json(enrollHost(body.token, body.inventory), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
try {
const body = await objectBody(request);
return json(enrollHost(body.token, body.inventory), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return hostApiError(cause);
}
}
+15 -4
View File
@@ -1,5 +1,9 @@
import { json } from '@sveltejs/kit';
import { heartbeat, pendingCreationCancellations, shouldDeferPlutoniumPreparation } from '$lib/server/hosts/index.js';
import {
heartbeat,
pendingCreationCancellations,
shouldDeferPlutoniumPreparation
} from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
import { reconcileHostLibraryCleanup } from '$lib/server/game-files/index.js';
@@ -16,8 +20,15 @@ export async function POST({ request }: { request: Request }) {
}
reconcileHostPreparation(host.id);
reconcileHostLibraryCleanup(host.id);
return json({ host, cancelledCreations: pendingCreationCancellations(host.id), heartbeatSeconds: 15,
return json({
host,
cancelledCreations: pendingCreationCancellations(host.id),
heartbeatSeconds: 15,
deferPlutoniumPreparation: shouldDeferPlutoniumPreparation(host.id),
observationKeepDays: fleetSettings().observationKeepDays, serverTime: new Date().toISOString() });
} catch (cause) { return hostApiError(cause); }
observationKeepDays: fleetSettings().observationKeepDays,
serverTime: new Date().toISOString()
});
} catch (cause) {
return hostApiError(cause);
}
}
+8 -3
View File
@@ -3,13 +3,18 @@ import { authenticateAgent } from '$lib/server/hosts/http.js';
import { requireImportOwner, setImportManifest } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(requireImportOwner(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
try {
return json(requireImportOwner(authenticateAgent(request).id, params.id));
} catch (cause) {
return gameFileError(cause);
}
}
export async function PUT({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request);
const body = await readGameJson(request, 64 * 1024 * 1024);
return json(setImportManifest(host.id, params.id, body.files));
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
@@ -5,11 +5,17 @@ import { gameFileError } from '$lib/server/game-files/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const result = requestImportPublication(authenticateAgent(request).id, params.id);
return json(result, { status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200 });
return json(result, {
status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200
});
} catch (cause) {
return gameFileError(cause);
}
catch (cause) { return gameFileError(cause); }
}
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(importPublicationStatus(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
try {
return json(importPublicationStatus(authenticateAgent(request).id, params.id));
} catch (cause) {
return gameFileError(cause);
}
}
@@ -2,10 +2,18 @@ import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { uploadImportFile } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
export async function PUT({
request,
params
}: {
request: Request;
params: { id: string; path: string };
}) {
try {
const host = authenticateAgent(request);
await uploadImportFile(host.id, params.id, params.path, request.body);
return json({ uploaded: true });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
+5 -1
View File
@@ -1,2 +1,6 @@
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET() { return new Response(await agentReleaseAsset('install.sh'), { headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' } }); }
export async function GET() {
return new Response(await agentReleaseAsset('install.sh'), {
headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' }
});
}
@@ -1,7 +1,17 @@
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { gameFileError, serveGameFile } from '$lib/server/game-files/http.js';
export async function GET({ request, params }: { request: Request; params: { id: string; path: string } }) {
try { authenticateAgent(request); return await serveGameFile(request, params.id, params.path); }
catch (cause) { return gameFileError(cause); }
export async function GET({
request,
params
}: {
request: Request;
params: { id: string; path: string };
}) {
try {
authenticateAgent(request);
return await serveGameFile(request, params.id, params.path);
} catch (cause) {
return gameFileError(cause);
}
}
export const HEAD = GET;
@@ -6,8 +6,18 @@ export function GET({ request, params }: { request: Request; params: { id: strin
try {
authenticateAgent(request);
const version = getLibraryVersion(params.id);
if (!version || version.state !== 'ready') return json({ error: 'Published library not found.' }, { status: 404 });
if (version.filesRemoved || version.filesRemoving) return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
return json({ id: version.id, game: version.game, files: version.files, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 });
} catch (cause) { return gameFileError(cause); }
if (!version || version.state !== 'ready')
return json({ error: 'Published library not found.' }, { status: 404 });
if (version.filesRemoved || version.filesRemoving)
return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
return json({
id: version.id,
game: version.game,
files: version.files,
sizeBytes: version.sizeBytes,
manifestSha256: version.manifestSha256
});
} catch (cause) {
return gameFileError(cause);
}
}
@@ -4,8 +4,11 @@ import { updateHostLibraryCaches } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request); const body = await readGameJson(request);
const host = authenticateAgent(request);
const body = await readGameJson(request);
updateHostLibraryCaches(host.id, [{ ...body, libraryVersionId: params.id }]);
return json({ ok: true });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
+8 -1
View File
@@ -4,7 +4,14 @@ import { hostOperationsResponse } from '$lib/server/hosts/delivery.js';
export async function GET({ request }: { request: Request }) {
const host = authenticateAgent(request);
const params = new URL(request.url).searchParams;
const lane = params.get('lane') === 'health' ? 'health' : params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all';
const lane =
params.get('lane') === 'health'
? 'health'
: params.get('lane') === 'read'
? 'read'
: params.get('lane') === 'write'
? 'write'
: 'all';
let operations = pendingHostOperationRows(host.id, lane, false);
if (!operations.length && params.get('wait') === '20') {
await waitForHostWork(host.id, request.signal);
@@ -6,12 +6,22 @@ export async function POST({ request, params }: { request: Request; params: { id
const host = authenticateAgent(request);
try {
const body = await objectBody(request, 128 * 1024 * 1024);
const operation = reportHostOperationStatus(host.id, params.id, { state: body.state, result: body.result, error: body.error });
if (['completed', 'failed', 'unknown'].includes(operation.state)
&& (operation.kind.startsWith('instance.') && operation.kind !== 'instance.inspect' || operation.kind === 'files.stage')) {
void kickProvisioning().catch(error => console.error('[agent] provisioning wake failed:', error));
const operation = reportHostOperationStatus(host.id, params.id, {
state: body.state,
result: body.result,
error: body.error
});
if (
['completed', 'failed', 'unknown'].includes(operation.state) &&
((operation.kind.startsWith('instance.') && operation.kind !== 'instance.inspect') ||
operation.kind === 'files.stage')
) {
void kickProvisioning().catch((error) =>
console.error('[agent] provisioning wake failed:', error)
);
}
return json({ operation: { id: operation.id, state: operation.state } });
} catch (cause) {
return hostApiError(cause);
}
catch (cause) { return hostApiError(cause); }
}
@@ -4,6 +4,9 @@ import { claimRemovalAuthorization } from '$lib/server/autoscaling/authorization
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
const host = authenticateAgent(request);
try { return json(claimRemovalAuthorization(host.id, params.id, await objectBody(request, 4096))); }
catch (cause) { return hostApiError(cause); }
try {
return json(claimRemovalAuthorization(host.id, params.id, await objectBody(request, 4096)));
} catch (cause) {
return hostApiError(cause);
}
}
+13 -2
View File
@@ -1,6 +1,17 @@
import { error } from '@sveltejs/kit';
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET({ params }: { params: { file: string } }) {
if (!['ec-agent.py','ec-agent-update.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
if (!['ec-agent.py', 'ec-agent-update.py', 'manifest.json'].includes(params.file))
error(404, 'Release asset not found.');
return new Response(
await agentReleaseAsset(
params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'
),
{
headers: {
'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain',
'Cache-Control': 'no-cache'
}
}
);
}
+5 -3
View File
@@ -3,8 +3,10 @@ import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try {
const result = await authenticatePassword(await authBody(request),getClientAddress());
const result = await authenticatePassword(await authBody(request), getClientAddress());
revokeSession(cookies.get(sessionCookie(url)));
return sessionResponse(result,cookies,url);
} catch (cause) { return authError(cause); }
return sessionResponse(result, cookies, url);
} catch (cause) {
return authError(cause);
}
};
+3 -3
View File
@@ -3,7 +3,7 @@ import { recordAudit, revokeSession } from '$lib/server/auth/index.js';
import { sessionCookie } from '$lib/server/auth/http.js';
export const POST: RequestHandler = ({ cookies, url, locals }) => {
revokeSession(cookies.get(sessionCookie(url)));
cookies.delete(sessionCookie(url),{path:'/'});
recordAudit(locals.user?.id??null,'logout',locals.user?.id??null);
return json({ ok:true });
cookies.delete(sessionCookie(url), { path: '/' });
recordAudit(locals.user?.id ?? null, 'logout', locals.user?.id ?? null);
return json({ ok: true });
};
+2 -1
View File
@@ -1,2 +1,3 @@
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = ({ locals }) => json({ user:locals.user },{headers:{'Cache-Control':'no-store'}});
export const GET: RequestHandler = ({ locals }) =>
json({ user: locals.user }, { headers: { 'Cache-Control': 'no-store' } });
+9 -3
View File
@@ -4,8 +4,14 @@ import { authBody, authError, requireUser, sessionResponse } from '$lib/server/a
export const POST: RequestHandler = async ({ request, cookies, locals, url, getClientAddress }) => {
try {
const user = requireUser(locals.user);
consumeAuthAttempt(getClientAddress(),user.id,'password');
consumeAuthAttempt(getClientAddress(), user.id, 'password');
const body = await authBody(request);
return sessionResponse(await changePassword(user.id,body.currentPassword,body.newPassword),cookies,url);
} catch (cause) { return authError(cause); }
return sessionResponse(
await changePassword(user.id, body.currentPassword, body.newPassword),
cookies,
url
);
} catch (cause) {
return authError(cause);
}
};
+9 -2
View File
@@ -2,6 +2,13 @@ import type { RequestHandler } from './$types';
import { bootstrapAdmin } from '$lib/server/auth/index.js';
import { authBody, authError, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try { return sessionResponse(await bootstrapAdmin(await authBody(request),getClientAddress()),cookies,url); }
catch (cause) { return authError(cause); }
try {
return sessionResponse(
await bootstrapAdmin(await authBody(request), getClientAddress()),
cookies,
url
);
} catch (cause) {
return authError(cause);
}
};
+33 -8
View File
@@ -1,19 +1,44 @@
import { json } from '@sveltejs/kit';
import { saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
import {
saveScaling,
deleteScaling,
scalingReport,
setScalingGlobalPause,
ScalingError
} from '$lib/server/autoscaling/index.js';
export function GET() { return json(scalingReport()); }
export function GET() {
return json(scalingReport());
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScalingError('Provide a policy action.');
if (body.id !== undefined && typeof body.id !== 'string') throw new ScalingError('Invalid policy identity.');
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision) });
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new ScalingError('Provide a policy action.');
if (body.id !== undefined && typeof body.id !== 'string')
throw new ScalingError('Invalid policy identity.');
if (body.action === 'save')
return json({ policy: saveScaling(body.settings, body.id, body.revision) });
if (body.action === 'delete') {
if (typeof body.id !== 'string' || !body.id || !Number.isInteger(body.revision) || body.revision < 1) throw new ScalingError('Provide the policy identity and current revision.');
if (
typeof body.id !== 'string' ||
!body.id ||
!Number.isInteger(body.revision) ||
body.revision < 1
)
throw new ScalingError('Provide the policy identity and current revision.');
deleteScaling(body.id, body.revision);
return json({ ok: true });
}
if (body.action === 'global-pause' && typeof body.paused === 'boolean') { setScalingGlobalPause(body.paused); return json({ ok: true }); }
if (body.action === 'global-pause' && typeof body.paused === 'boolean') {
setScalingGlobalPause(body.paused);
return json({ ok: true });
}
throw new ScalingError('Choose a supported policy action.');
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof ScalingError ? error.status : 400 }); }
} catch (error) {
return json(
{ error: (error as Error).message },
{ status: error instanceof ScalingError ? error.status : 400 }
);
}
}
+64 -25
View File
@@ -1,8 +1,5 @@
import { json } from '@sveltejs/kit';
import {
openBeta,
BetaError
} from '$lib/server/fleet/beta.js';
import { openBeta, BetaError } from '$lib/server/fleet/beta.js';
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { db } from '$lib/server/db/index.js';
import type { BetaServerRow } from '$lib/server/fleet/beta.js';
@@ -14,16 +11,29 @@ import { parseBetaInstanceCounts } from '$lib/server/fleet/beta-selection.js';
export async function GET({ url }: { url: URL }) {
const state = url.searchParams.get('state');
if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 });
if (state && !['running', 'ready-promote', 'promoted', 'rolled-back'].includes(state))
return json({ error: 'Invalid beta state.' }, { status: 400 });
const { betas, ...pagination } = betaListPage(url.searchParams);
const ids = JSON.stringify(betas.map(beta => beta.id));
const bundles = betaScriptMetadata(betas.map(beta => beta.id));
const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
const ids = JSON.stringify(betas.map((beta) => beta.id));
const bundles = betaScriptMetadata(betas.map((beta) => beta.id));
const plans = new Map(
(
db()
.prepare(
`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
WHERE r.beta_id=p.beta_id ORDER BY revision DESC LIMIT 1), b.opened_at) started_at
FROM beta_test_plans p JOIN betas b ON b.id=p.beta_id
WHERE p.beta_id IN (SELECT value FROM json_each(?))`).all(ids) as Array<StoredPlan & { started_at: string }>).map(plan => [plan.beta_id, plan]));
const members = db().prepare('SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server').all(ids) as BetaServerRow[];
const rows = betas.map(b => {
WHERE p.beta_id IN (SELECT value FROM json_each(?))`
)
.all(ids) as Array<StoredPlan & { started_at: string }>
).map((plan) => [plan.beta_id, plan])
);
const members = db()
.prepare(
'SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server'
)
.all(ids) as BetaServerRow[];
const rows = betas.map((b) => {
const scripts = bundles.get(b.id) ?? [];
const test = plans.get(b.id);
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
@@ -31,9 +41,11 @@ export async function GET({ url }: { url: URL }) {
...b,
testOutcome: test?.outcome ?? null,
testReason: test?.reason ?? null,
deadlineAt: plan ? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString() : null,
deadlineAt: plan
? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString()
: null,
scripts,
servers: members.filter(member => member.beta_id === b.id)
servers: members.filter((member) => member.beta_id === b.id)
};
});
return json({ betas: rows, ...pagination });
@@ -54,15 +66,18 @@ export async function POST({ request }: { request: Request }) {
notes?: string;
testPlan?: BetaPlan;
};
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ ok: false, error: 'Expected a JSON object' }, { status: 400 });
if (!body || typeof body !== 'object' || Array.isArray(body))
return json({ ok: false, error: 'Expected a JSON object' }, { status: 400 });
// Normalize into the openBeta shape. Both fields feed the same list.
const rawEntries: Array<{ scriptId?: number; scriptVersionId?: number }> = [];
if (Array.isArray(body.artifacts)) {
for (const a of body.artifacts) rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId });
for (const a of body.artifacts)
rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId });
}
if (Array.isArray(body.scripts)) {
for (const s of body.scripts) rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId });
for (const s of body.scripts)
rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId });
}
if (rawEntries.length === 0) {
return json(
@@ -71,17 +86,35 @@ export async function POST({ request }: { request: Request }) {
);
}
if ((body.servers !== undefined) === (body.instancesPerGame !== undefined)) {
return json({ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' }, { status: 400 });
return json(
{ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' },
{ status: 400 }
);
}
if (body.instancesPerGame !== undefined) {
try { body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame); }
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
} else if (!Array.isArray(body.servers) || !body.servers.length || !body.servers.every(name => typeof name === 'string' && name.trim())) {
return json({ ok: false, error: 'servers must contain at least one server name' }, { status: 400 });
try {
body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
} else if (
!Array.isArray(body.servers) ||
!body.servers.length ||
!body.servers.every((name) => typeof name === 'string' && name.trim())
) {
return json(
{ ok: false, error: 'servers must contain at least one server name' },
{ status: 400 }
);
}
const normalized: Array<{ scriptId: number; scriptVersionId: number }> = [];
for (const s of rawEntries) {
if (!Number.isSafeInteger(s.scriptId) || !Number.isSafeInteger(s.scriptVersionId) || s.scriptId! < 1 || s.scriptVersionId! < 1) {
if (
!Number.isSafeInteger(s.scriptId) ||
!Number.isSafeInteger(s.scriptVersionId) ||
s.scriptId! < 1 ||
s.scriptVersionId! < 1
) {
return json(
{ ok: false, error: 'each entry needs scriptId/id and scriptVersionId/versionId' },
{ status: 400 }
@@ -92,8 +125,11 @@ export async function POST({ request }: { request: Request }) {
try {
let testPlan: BetaPlan | undefined;
try { if (body.testPlan !== undefined) testPlan = parseBetaPlan(body.testPlan); }
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
try {
if (body.testPlan !== undefined) testPlan = parseBetaPlan(body.testPlan);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
const result = await openBeta({
testPlan,
scripts: normalized,
@@ -107,7 +143,10 @@ export async function POST({ request }: { request: Request }) {
return json({ ok: true, result });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind, details: err.details },
{ status: 409 }
);
}
throw err;
}
+40 -7
View File
@@ -21,12 +21,45 @@ export async function GET({ params }: { params: { id: string } }) {
const servers = listBetaServers(id);
const { events, nextBefore: eventsNextBefore } = betaActivityPage(id);
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
const promotion = campaign ? { ...campaign,
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n,
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1")
const campaign = db()
.prepare(
"SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1"
)
.get(id) as { id: string } | undefined;
return json({ beta, scripts, servers, events, eventsNextBefore, test: testView(id), promotion, rollback: rollback ?? null,
revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) });
const promotion = campaign
? {
...campaign,
pending: (
db()
.prepare(
"SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')"
)
.get(campaign.id) as { n: number }
).n,
failed: (
db()
.prepare(
"SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'"
)
.get(campaign.id) as { n: number }
).n
}
: null;
const rollback = db()
.prepare(
"SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1"
)
.get(id) as { id: string } | undefined;
return json({
beta,
scripts,
servers,
events,
eventsNextBefore,
test: testView(id),
promotion,
rollback: rollback ?? null,
revision: currentBetaRevision(id),
revisions: betaRevisionHistory(id)
});
}
+2 -1
View File
@@ -4,7 +4,8 @@ import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
export function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID.' }, { status: 400 });
if (!Number.isSafeInteger(id) || id < 1)
return json({ error: 'Invalid beta ID.' }, { status: 400 });
if (!getBeta(id)) return json({ error: 'Beta not found.' }, { status: 404 });
return json(betaActivityPage(id, url.searchParams));
}
+15 -4
View File
@@ -9,8 +9,12 @@ function parseId(raw: string): number | null {
export async function GET({ params }: { params: { id: string } }) {
const id = parseId(params.id);
if (id === null) return json({ ok: false, error: 'Invalid beta ID.' }, { status: 400 });
try { return json({ preview: previewBetaPromotion(id) }, { headers: { 'Cache-Control': 'no-store' } }); }
catch (err) {
try {
return json(
{ preview: previewBetaPromotion(id) },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (err) {
if (err instanceof BetaError) return json({ error: err.message }, { status: 409 });
throw err;
}
@@ -20,13 +24,20 @@ export async function POST({ params, request }: { params: { id: string }; reques
const id = parseId(params.id);
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const body = await request.json().catch(() => null);
if (typeof body?.previewToken !== 'string') return json({ ok: false, error: 'Review promotion targets before confirming.' }, { status: 400 });
if (typeof body?.previewToken !== 'string')
return json(
{ ok: false, error: 'Review promotion targets before confirming.' },
{ status: 400 }
);
try {
const result = await promoteBeta(id, body.previewToken);
return json({ ok: true, result });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind, details: err.details },
{ status: 409 }
);
}
throw err;
}
+51 -13
View File
@@ -17,31 +17,67 @@ export async function POST({ params, request }: { params: { id: string }; reques
const betaId = parseId(params.id);
if (betaId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const beta = getBeta(betaId);
if (!beta || !['running', 'ready-promote'].includes(beta.state)) return json({ ok: false, error: 'Beta is closed or missing' }, { status: 409 });
if (storedPlan(betaId)) return json({ ok: false, error: 'Use the test plan retry action to collect fresh evidence' }, { status: 409 });
if (!beta || !['running', 'ready-promote'].includes(beta.state))
return json({ ok: false, error: 'Beta is closed or missing' }, { status: 409 });
if (storedPlan(betaId))
return json(
{ ok: false, error: 'Use the test plan retry action to collect fresh evidence' },
{ status: 409 }
);
const body = (await request.json().catch(() => ({}))) as { server?: string };
if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 });
const row = db()
.prepare('SELECT stage, deployed_at, restarted_at, healthy_at, server_deleted_at FROM beta_servers WHERE beta_id = ? AND server = ?')
.prepare(
'SELECT stage, deployed_at, restarted_at, healthy_at, server_deleted_at FROM beta_servers WHERE beta_id = ? AND server = ?'
)
.get(betaId, body.server) as
| { stage: string; deployed_at: string | null; restarted_at: string | null; healthy_at: string | null; server_deleted_at: string | null }
| {
stage: string;
deployed_at: string | null;
restarted_at: string | null;
healthy_at: string | null;
server_deleted_at: string | null;
}
| undefined;
if (!row) return json({ ok: false, error: 'no such beta_server' }, { status: 404 });
if (!isActiveBetaServer(betaId, body.server)) return json({ ok: false, error: 'This beta server was deleted; open a new beta for its replacement.' }, { status: 409 });
if (row.stage !== 'failed') return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
if (stagedFileChanges('beta', String(betaId), body.server).some(change => change.state === 'applying')) {
return json({ ok: false, error: 'A file write was interrupted. Inspect the server and roll back before opening a fresh beta.' }, { status: 409 });
if (!isActiveBetaServer(betaId, body.server))
return json(
{
ok: false,
error: 'This beta server was deleted; open a new beta for its replacement.'
},
{ status: 409 }
);
if (row.stage !== 'failed')
return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
if (
stagedFileChanges('beta', String(betaId), body.server).some(
(change) => change.state === 'applying'
)
) {
return json(
{
ok: false,
error: 'A file write was interrupted. Inspect the server and roll back before opening a fresh beta.'
},
{ status: 409 }
);
}
// Preparation may have failed before any bytes were queued (for example,
// an overlay without a baseline). Retrying must prepare its payload first.
if (!row.deployed_at && !row.restarted_at) {
try { await deployBetaServers(betaId, [body.server]); }
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 409 }); }
const prepared = db().prepare('SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?')
try {
await deployBetaServers(betaId, [body.server]);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 409 });
}
const prepared = db()
.prepare('SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?')
.get(betaId, body.server) as { stage: string; error: string | null };
if (prepared.stage === 'failed') return json({ ok: false, error: prepared.error }, { status: 409 });
if (prepared.stage === 'failed')
return json({ ok: false, error: prepared.error }, { status: 409 });
}
// Best-effort resume point: if we already got past restart, go back to
@@ -50,7 +86,9 @@ export async function POST({ params, request }: { params: { id: string }; reques
if (!row.restarted_at) releaseRestartRequest(body.server, { kind: 'beta', id: String(betaId) });
const now = new Date().toISOString();
db()
.prepare('UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?')
.prepare(
'UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?'
)
.run(nextStage, now, betaId, body.server);
emit({ type: 'beta-server-stage', betaId, server: body.server, stage: nextStage, error: null });
return json({ ok: true, stage: nextStage });
+23 -8
View File
@@ -5,24 +5,39 @@ import { kick } from '$lib/server/pipeline/worker.js';
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
if (!Number.isSafeInteger(id) || id < 1)
return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
try {
const form = await request.formData();
const expectedRevision = Number(form.get('expectedRevision'));
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) throw new Error('A reviewed revision is required.');
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)
throw new Error('A reviewed revision is required.');
const uploads: BetaRevisionUpload[] = [];
for (const [key, value] of form) {
if (!key.startsWith('file:')) continue;
const scriptId = Number(key.slice(5));
if (!Number.isSafeInteger(scriptId) || scriptId < 1 || !(value instanceof File)) throw new Error('Invalid replacement file.');
uploads.push({ scriptId, content: Buffer.from(await value.arrayBuffer()),
releaseVersion: form.has(`release:${scriptId}`) ? String(form.get(`release:${scriptId}`)) : undefined });
if (!Number.isSafeInteger(scriptId) || scriptId < 1 || !(value instanceof File))
throw new Error('Invalid replacement file.');
uploads.push({
scriptId,
content: Buffer.from(await value.arrayBuffer()),
releaseVersion: form.has(`release:${scriptId}`)
? String(form.get(`release:${scriptId}`))
: undefined
});
}
const result = await reviseBeta(id, expectedRevision, uploads, String(form.get('notes') ?? ''));
const result = await reviseBeta(
id,
expectedRevision,
uploads,
String(form.get('notes') ?? '')
);
void kick();
return json({ ok: true, result });
} catch (error) {
return json({ ok: false, error: (error as Error).message },
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 });
return json(
{ ok: false, error: (error as Error).message },
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 }
);
}
}
@@ -2,16 +2,35 @@ import { blobContent } from '$lib/server/db/file-blobs.js';
import { db } from '$lib/server/db/index.js';
/** Current files can be reviewed before the first revision is archived. */
export async function GET({ params }: { params: { id: string; revision: string; scriptId: string } }) {
export async function GET({
params
}: {
params: { id: string; revision: string; scriptId: string };
}) {
const values = [params.id, params.revision, params.scriptId].map(Number);
if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 });
if (values.some((value) => !Number.isSafeInteger(value) || value < 1))
return new Response('Invalid file', { status: 400 });
const [id, revision, scriptId] = values;
const file = db().prepare(`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`)
.get(id, revision, scriptId) as { content: Buffer } | undefined
?? (revision === 1 ? db().prepare(`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`)
.get(id, scriptId, id) as { content: Buffer } | undefined : undefined);
const file =
(db()
.prepare(
`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`
)
.get(id, revision, scriptId) as { content: Buffer } | undefined) ??
(revision === 1
? (db()
.prepare(
`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`
)
.get(id, scriptId, id) as { content: Buffer } | undefined)
: undefined);
if (!file?.content) return new Response('File not found', { status: 404 });
return new Response(new Uint8Array(file.content), { headers: { 'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } });
return new Response(new Uint8Array(file.content), {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment',
'Cache-Control': 'no-store'
}
});
}
@@ -24,7 +24,10 @@ export async function POST({ params }: { params: { id: string } }) {
return json({ ok: true, result });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind, details: err.details },
{ status: 409 }
);
}
throw err;
}
+99 -26
View File
@@ -3,78 +3,151 @@ import { releaseRestartRequest } from '$lib/server/provisioning/index.js';
import { db } from '$lib/server/db/index.js';
import { deployBetaServers, getBeta } from '$lib/server/fleet/beta.js';
import { stagedFileChanges } from '$lib/server/fleet/staged-files.js';
import { activity, currentRun, evidence, readMetrics, saveMetrics, setOutcome, storedPlan } from '$lib/server/fleet/beta-tests.js';
import {
activity,
currentRun,
evidence,
readMetrics,
saveMetrics,
setOutcome,
storedPlan
} from '$lib/server/fleet/beta-tests.js';
import { betaState, plannedBetaTick } from '$lib/server/pipeline/beta-plan-runner.js';
import { parseBetaPlan } from '$lib/beta-plan.js';
import { kick } from '$lib/server/pipeline/worker.js';
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID' }, { status: 400 });
if (!Number.isSafeInteger(id) || id < 1)
return json({ error: 'Invalid beta ID' }, { status: 400 });
await plannedBetaTick();
const beta = getBeta(id);
const stored = storedPlan(id);
if (!beta || !stored) return json({ error: 'Test plan not found' }, { status: 404 });
if (!['running', 'ready-promote'].includes(beta.state)) return json({ error: 'Beta is closed' }, { status: 409 });
if (!['running', 'ready-promote'].includes(beta.state))
return json({ error: 'Beta is closed' }, { status: 409 });
try {
const body = await request.json();
if (db().prepare('SELECT 1 FROM beta_servers WHERE beta_id = ? AND server_deleted_at IS NOT NULL').get(id)) {
return json({ ok: false, error: 'A beta server was deleted. Roll back this beta and open a new one to collect fresh evidence.' }, { status: 409 });
if (
db()
.prepare(
'SELECT 1 FROM beta_servers WHERE beta_id = ? AND server_deleted_at IS NOT NULL'
)
.get(id)
) {
return json(
{
ok: false,
error: 'A beta server was deleted. Roll back this beta and open a new one to collect fresh evidence.'
},
{ status: 409 }
);
}
const plan = parseBetaPlan(JSON.parse(stored.plan));
if (body.action === 'sign-off') {
if (!['active', 'passed'].includes(stored.outcome)) throw new Error('Resume or retry the test before signing off');
const requirement = plan.requirements.find(r => r.id === body.requirementId && r.kind === 'manual');
if (!['active', 'passed'].includes(stored.outcome))
throw new Error('Resume or retry the test before signing off');
const requirement = plan.requirements.find(
(r) => r.id === body.requirementId && r.kind === 'manual'
);
const run = typeof body.server === 'string' ? currentRun(id, body.server) : undefined;
if (!requirement || !run?.observing_at) throw new Error('Checklist item or active server run not found');
if (typeof body.note !== 'string' || !body.note.trim() || body.note.length > 1000) throw new Error('Add a tester name and observation (up to 1000 characters)');
if (!requirement || !run?.observing_at)
throw new Error('Checklist item or active server run not found');
if (typeof body.note !== 'string' || !body.note.trim() || body.note.length > 1000)
throw new Error('Add a tester name and observation (up to 1000 characters)');
const metrics = readMetrics(run);
metrics.manual[requirement.id] = body.note.trim();
saveMetrics(run, metrics);
evidence(run, `manual:${requirement.id}:${Date.now()}`, 'manual-sign-off', { requirement: requirement.label, note: body.note.trim() });
evidence(run, `manual:${requirement.id}:${Date.now()}`, 'manual-sign-off', {
requirement: requirement.label,
note: body.note.trim()
});
activity(id, `${body.server}: ${requirement.label} signed off: ${body.note.trim()}`);
} else if (body.action === 'pause') {
setOutcome(id, 'paused', 'Paused by operator; deployment and evidence evaluation held');
betaState(id, false);
} else if (body.action === 'resume') {
if (stored.outcome !== 'paused') throw new Error('Only paused plans can resume; failed or expired plans need a fresh attempt');
const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL').all(id) as Parameters<typeof readMetrics>[0][];
if (stored.outcome !== 'paused')
throw new Error(
'Only paused plans can resume; failed or expired plans need a fresh attempt'
);
const runs = db()
.prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL')
.all(id) as Parameters<typeof readMetrics>[0][];
for (const run of runs) {
const metrics = readMetrics(run);
delete metrics.match;
delete metrics.lastSample;
delete metrics.wasHealthy;
metrics.streaks = {};
for (const r of plan.requirements) if (r.kind === 'healthy-minutes') metrics.values[r.id] = 0;
for (const r of plan.requirements)
if (r.kind === 'healthy-minutes') metrics.values[r.id] = 0;
saveMetrics(run, metrics);
}
setOutcome(id, 'active', null);
} else if (body.action === 'retry') {
// Fresh attempts keep history and the current revision's deadline.
if (stored.outcome === 'inconclusive') throw new Error('The testing window ended. Update the files to start a new revision, or roll back and open a new beta with a longer deadline');
if (stagedFileChanges('beta', String(id)).some(change => change.state === 'applying')) {
throw new Error('A file write was interrupted. Inspect the server and roll back before opening a fresh beta');
if (stored.outcome === 'inconclusive')
throw new Error(
'The testing window ended. Update the files to start a new revision, or roll back and open a new beta with a longer deadline'
);
if (
stagedFileChanges('beta', String(id)).some((change) => change.state === 'applying')
) {
throw new Error(
'A file write was interrupted. Inspect the server and roll back before opening a fresh beta'
);
}
const prepare = db().prepare("SELECT server FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL AND restarted_at IS NULL")
const prepare = db()
.prepare(
"SELECT server FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL AND restarted_at IS NULL"
)
.all(id) as { server: string }[];
if (prepare.length) {
await deployBetaServers(id, prepare.map(row => row.server));
const failed = db().prepare("SELECT error FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL").get(id) as { error: string | null } | undefined;
await deployBetaServers(
id,
prepare.map((row) => row.server)
);
const failed = db()
.prepare(
"SELECT error FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL"
)
.get(id) as { error: string | null } | undefined;
if (failed) throw new Error(failed.error ?? 'Could not prepare beta files');
}
db().transaction(() => {
const servers = db().prepare('SELECT server FROM beta_servers WHERE beta_id = ?').all(id) as { server: string }[];
for (const { server } of servers) releaseRestartRequest(server, { kind: 'beta', id: String(id) });
db().prepare('UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND retired_at IS NULL').run(new Date().toISOString(), id);
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty', error = NULL, restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error NOT LIKE 'deploy:%'").run(id);
const servers = db()
.prepare('SELECT server FROM beta_servers WHERE beta_id = ?')
.all(id) as { server: string }[];
for (const { server } of servers)
releaseRestartRequest(server, { kind: 'beta', id: String(id) });
db()
.prepare(
'UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND retired_at IS NULL'
)
.run(new Date().toISOString(), id);
db()
.prepare(
"UPDATE beta_servers SET stage = 'awaiting-empty', error = NULL, restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error NOT LIKE 'deploy:%'"
)
.run(id);
// SQL NULL does not satisfy NOT LIKE.
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty', restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error IS NULL").run(id);
db()
.prepare(
"UPDATE beta_servers SET stage = 'awaiting-empty', restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error IS NULL"
)
.run(id);
betaState(id, false);
setOutcome(id, 'active', null);
activity(id, 'Fresh test attempt requested; deployed servers will restart when empty');
activity(
id,
'Fresh test attempt requested; deployed servers will restart when empty'
);
})();
} else throw new Error('Unknown test action');
void kick();
return json({ ok: true });
} catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
}
+20 -6
View File
@@ -2,17 +2,31 @@ import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { builtInPlans, parseBetaPlan } from '$lib/beta-plan.js';
export function GET() {
const saved = db().prepare('SELECT name, plan FROM beta_test_presets ORDER BY name').all() as Array<{ name: string; plan: string }>;
return json({ presets: [...builtInPlans, ...saved.map(p => ({ name: p.name, plan: JSON.parse(p.plan) }))] });
const saved = db()
.prepare('SELECT name, plan FROM beta_test_presets ORDER BY name')
.all() as Array<{ name: string; plan: string }>;
return json({
presets: [
...builtInPlans,
...saved.map((p) => ({ name: p.name, plan: JSON.parse(p.plan) }))
]
});
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) throw new Error('Preset name must be 1–80 characters');
if (builtInPlans.some(p => p.name === body.name.trim())) throw new Error('Choose a name other than a built-in preset');
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80)
throw new Error('Preset name must be 1–80 characters');
if (builtInPlans.some((p) => p.name === body.name.trim()))
throw new Error('Choose a name other than a built-in preset');
const plan = parseBetaPlan(body.plan);
db().prepare('INSERT INTO beta_test_presets (name, plan) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET plan = excluded.plan')
db()
.prepare(
'INSERT INTO beta_test_presets (name, plan) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET plan = excluded.plan'
)
.run(body.name.trim(), JSON.stringify(plan));
return json({ ok: true });
} catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
}
+5 -2
View File
@@ -1,12 +1,15 @@
import { json } from '@sveltejs/kit';
import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function GET() { return json({ blueprints: listBlueprints() }); }
export function GET() {
return json({ blueprints: listBlueprints() });
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and a launch recipe.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection name and a launch recipe.');
return json({ blueprint: saveBlueprint(body) }, { status: 201 });
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
+8 -3
View File
@@ -2,13 +2,18 @@ import { json } from '@sveltejs/kit';
import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function DELETE({ params }: { params: { id: string } }) {
return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Collection not found.' }, { status: 404 });
return deleteBlueprint(params.id)
? json({ ok: true })
: json({ error: 'Collection not found.' }, { status: 404 });
}
export async function PUT({ params, request }: { params: { id: string }; request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and map selections.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection name and map selections.');
return json({ blueprint: updateBlueprint(params.id, body) });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
+40 -15
View File
@@ -9,8 +9,14 @@ export async function GET({ url }: { url: URL }) {
const query = (url.searchParams.get('q') ?? '').slice(0, 100).trim().toLowerCase();
const action = url.searchParams.get('action');
const status = url.searchParams.get('status');
const limit = Math.min(500, Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25)));
const offset = Math.min(1_000_000, Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0)));
const limit = Math.min(
500,
Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25))
);
const offset = Math.min(
1_000_000,
Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0))
);
const attention = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state IN ('failed','held','manual'))`;
const pending = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state='pending')`;
const active = `(${attention} OR ${pending})`;
@@ -20,14 +26,32 @@ export async function GET({ url }: { url: URL }) {
WHEN EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id)
AND NOT EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.restart_state != 'done') THEN 'complete'
ELSE 'inactive' END`;
const names = query ? (db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as { server: string }[])
.map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(query)) : [];
const conditions = ['(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))'];
const names = query
? (
db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as {
server: string;
}[]
)
.map((row) => row.server)
.filter((name) => `${name} ${serverTitle(name)}`.toLowerCase().includes(query))
: [];
const conditions = [
'(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))'
];
const parameters: (string | null)[] = [server, server];
if (activeOnly) conditions.push(active);
else if (historyOnly) conditions.push(`NOT ${active}`);
if (action && ['promote', 'rollback', 'drift'].includes(action)) { conditions.push('d.action=?'); parameters.push(action); }
if (status && ['attention', 'progress', 'complete', 'rolled-back', 'inactive'].includes(status)) { conditions.push(`(${outcome})=?`); parameters.push(status); }
if (action && ['promote', 'rollback', 'drift'].includes(action)) {
conditions.push('d.action=?');
parameters.push(action);
}
if (
status &&
['attention', 'progress', 'complete', 'rolled-back', 'inactive'].includes(status)
) {
conditions.push(`(${outcome})=?`);
parameters.push(status);
}
if (query) {
conditions.push(`(instr(lower(d.id), ?) > 0
OR instr(lower(CASE WHEN d.action='drift' THEN 'maintenance repair' ELSE d.action END), ?) > 0
@@ -38,7 +62,9 @@ export async function GET({ url }: { url: URL }) {
parameters.push(query, query, query, query, query, ...names);
}
const where = conditions.join(' AND ');
const { total } = db().prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`).get(...parameters) as { total: number };
const { total } = db()
.prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`)
.get(...parameters) as { total: number };
const deployments = db()
.prepare(
@@ -70,7 +96,7 @@ export async function GET({ url }: { url: URL }) {
prior_version: number | null;
}>;
const ids = deployments.map(deployment => deployment.id);
const ids = deployments.map((deployment) => deployment.id);
if (!ids.length) return json({ deployments: [], total, limit, offset });
const placeholders = ids.map(() => '?').join(',');
const serversByDep = db()
@@ -122,12 +148,11 @@ export async function GET({ url }: { url: URL }) {
scriptsByDep.set(s.deployment_id, arr);
}
const rows = deployments
.map((d) => ({
...d,
servers: grouped.get(d.id) ?? [],
scripts: scriptsByDep.get(d.id) ?? []
}));
const rows = deployments.map((d) => ({
...d,
servers: grouped.get(d.id) ?? [],
scripts: scriptsByDep.get(d.id) ?? []
}));
return json({ deployments: rows, total, limit, offset });
}
+24 -7
View File
@@ -3,9 +3,8 @@ import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { id: string } }) {
const dep = db()
.prepare('SELECT * FROM deployments WHERE id = ?')
.get(params.id) as Record<string, unknown> | undefined;
const dep = db().prepare('SELECT * FROM deployments WHERE id = ?').get(params.id) as
Record<string, unknown> | undefined;
if (!dep) throw error(404, 'deployment not found');
const servers = db()
@@ -40,8 +39,26 @@ export async function GET({ params }: { params: { id: string } }) {
)
.all(params.id);
const events = db().prepare('SELECT id, created_at, message FROM campaign_events WHERE deployment_id = ? ORDER BY id').all(params.id);
const watchdog = db().prepare('SELECT incident_id FROM watchdog_actions WHERE campaign_id = ? LIMIT 1').get(params.id) as { incident_id: string } | undefined;
const fileContentsExpired = !!db().prepare("SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?").get(params.id);
return json({ fileContentsExpired, deployment: dep, servers, files, scripts, events, watchdogIncidentId: watchdog?.incident_id ?? null });
const events = db()
.prepare(
'SELECT id, created_at, message FROM campaign_events WHERE deployment_id = ? ORDER BY id'
)
.all(params.id);
const watchdog = db()
.prepare('SELECT incident_id FROM watchdog_actions WHERE campaign_id = ? LIMIT 1')
.get(params.id) as { incident_id: string } | undefined;
const fileContentsExpired = !!db()
.prepare(
"SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?"
)
.get(params.id);
return json({
fileContentsExpired,
deployment: dep,
servers,
files,
scripts,
events,
watchdogIncidentId: watchdog?.incident_id ?? null
});
}
@@ -6,8 +6,17 @@ import { db } from '$lib/server/db/index.js';
// so the DiffViewer can render a unified diff without a second query.
export async function GET({ params }: { params: { id: string; fileId: string } }) {
if (db().prepare("SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?").get(params.id)) {
return json({ error: 'File contents expired under the retention policy.' }, { status: 410 });
if (
db()
.prepare(
"SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?"
)
.get(params.id)
) {
return json(
{ error: 'File contents expired under the retention policy.' },
{ status: 410 }
);
}
const row = db()
.prepare(
@@ -27,7 +36,13 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
}
| undefined;
if (!row) throw error(404, 'file not found');
if (row.snapshot_pending) return json({ error: 'The rollback snapshot is not available yet. It is captured immediately before file activation.' }, { status: 409 });
if (row.snapshot_pending)
return json(
{
error: 'The rollback snapshot is not available yet. It is captured immediately before file activation.'
},
{ status: 409 }
);
return json({
verb: row.verb,
@@ -5,21 +5,30 @@ import { kick } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
const changed = db().transaction(() => {
const rows = db().prepare(`SELECT ds.server, d.state FROM deployment_servers ds
const rows = db()
.prepare(
`SELECT ds.server, d.state FROM deployment_servers ds
LEFT JOIN server_deployments d ON d.id = ds.provisioning_id
WHERE ds.deployment_id = ? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('held','manual')`)
WHERE ds.deployment_id = ? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('held','manual')`
)
.all(params.id) as { server: string; state: string | null }[];
for (const row of rows) {
if (row.state && ['failed','interrupted','cancelled'].includes(row.state)) {
if (row.state && ['failed', 'interrupted', 'cancelled'].includes(row.state)) {
releaseRestartRequest(row.server, { kind: 'campaign', id: params.id });
}
}
const result = db().prepare(`UPDATE deployment_servers SET restart_state = 'pending', error = NULL
WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state IN ('held','manual')`).run(params.id);
if (result.changes) db().prepare('UPDATE deployments SET finished_at = NULL WHERE id = ?').run(params.id);
const result = db()
.prepare(
`UPDATE deployment_servers SET restart_state = 'pending', error = NULL
WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state IN ('held','manual')`
)
.run(params.id);
if (result.changes)
db().prepare('UPDATE deployments SET finished_at = NULL WHERE id = ?').run(params.id);
return result.changes;
})();
if (!changed) return json({ error: 'This campaign has no held changes to resume.' }, { status: 409 });
void kick().catch(err => console.error('[campaign] activation kick failed:', err));
if (!changed)
return json({ error: 'This campaign has no held changes to resume.' }, { status: 409 });
void kick().catch((err) => console.error('[campaign] activation kick failed:', err));
return json({ ok: true }, { status: 202 });
}
+3 -1
View File
@@ -3,5 +3,7 @@ import { parseCapacityQuery } from '$lib/capacity.js';
import { capacityPage } from '$lib/server/hosts/capacity-page.js';
export async function GET({ url }: { url: URL }) {
return json(await capacityPage(parseCapacityQuery(url.searchParams)), { headers: { 'cache-control': 'no-store' } });
return json(await capacityPage(parseCapacityQuery(url.searchParams)), {
headers: { 'cache-control': 'no-store' }
});
}
+14 -4
View File
@@ -4,11 +4,21 @@ import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scrip
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid config ID.' }, { status: 400 });
if (!Number.isSafeInteger(id) || id <= 0)
return json({ error: 'Invalid config ID.' }, { status: 400 });
const script = getScript(id);
if (!script || !['config', 'baseline'].includes(script.kind)) return json({ error: 'Config not found.' }, { status: 404 });
if (!script || !['config', 'baseline'].includes(script.kind))
return json({ error: 'Config not found.' }, { status: 404 });
const requested = url.searchParams.get('version');
const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested));
if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 });
return json({ text: getVersionContent(version.id).toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } });
if (!version || version.script_id !== id)
return json({ error: 'Config version not found.' }, { status: 404 });
return json(
{
text: getVersionContent(version.id).toString('utf8'),
versionId: version.id,
version: version.version
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
+70 -15
View File
@@ -1,44 +1,87 @@
import { getVersionContent } from '$lib/server/fleet/scripts.js';
import { json } from '@sveltejs/kit';
import { getServer, listServers } from '$lib/server/fleet/servers.js';
import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js';
import {
getBaselineForScope,
getScript,
getVersion,
listPromotedOverlaysForScope,
type ScriptRow
} from '$lib/server/fleet/scripts.js';
import { inspectConfig, type ConfigLayer } from '$lib/server/fleet/config-inspect.js';
import type { ConfigInspection } from '$lib/config-workbench.js';
function inspect(serverName: string, draft?: { artifactId: number; text: string }): ConfigInspection {
function inspect(
serverName: string,
draft?: { artifactId: number; text: string }
): ConfigInspection {
const server = getServer(serverName);
if (!server || server.destroyed_at) throw new Error('Server not found.');
const artifact = draft ? getScript(draft.artifactId) : undefined;
if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind))) throw new Error('Config not found.');
if (artifact && (artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])) {
if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind)))
throw new Error('Config not found.');
if (
artifact &&
(artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])
) {
throw new Error('This config does not apply to the selected server.');
}
if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game') throw new Error('Server composition requires a game baseline.');
const baseline = artifact?.kind === 'baseline' ? artifact : server.game ? getBaselineForScope('game', server.game) : undefined;
if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game')
throw new Error('Server composition requires a game baseline.');
const baseline =
artifact?.kind === 'baseline'
? artifact
: server.game
? getBaselineForScope('game', server.game)
: undefined;
function layer(row: ScriptRow): ConfigLayer | null {
const isDraft = row.id === draft?.artifactId;
const version = row.current_version_id ? getVersion(row.current_version_id) : undefined;
if (!isDraft && !version) return null;
return {
source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft },
source: {
id: row.id,
name: row.name,
version: isDraft ? null : version!.version,
kind: row.kind as 'baseline' | 'config',
draft: isDraft
},
text: isDraft ? draft!.text : getVersionContent(version!.id).toString('utf8')
};
}
const base = baseline ? layer(baseline) : null;
if (!base) return { text: '', sources: [], settings: [], warnings: [], reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.' };
if (!base)
return {
text: '',
sources: [],
settings: [],
warnings: [],
reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.'
};
const overlays = [
...(server.game ? listPromotedOverlaysForScope('game', server.game) : []),
...(server.map ? listPromotedOverlaysForScope('map', server.map) : [])
].filter(row => row.id !== artifact?.id);
].filter((row) => row.id !== artifact?.id);
if (artifact?.kind === 'config') overlays.push(artifact);
return inspectConfig(base, overlays.map(layer).filter((l): l is ConfigLayer => l !== null), server.game, server.map, server.map_slug);
return inspectConfig(
base,
overlays.map(layer).filter((l): l is ConfigLayer => l !== null),
server.game,
server.map,
server.map_slug
);
}
export async function GET({ url }: { url: URL }) {
const servers = listServers().filter(s => !s.destroyed_at).map(({ name, game, map }) => ({ name, game, map }));
const servers = listServers()
.filter((s) => !s.destroyed_at)
.map(({ name, game, map }) => ({ name, game, map }));
try {
const server = url.searchParams.get('server');
return json({ servers, inspection: server ? inspect(server) : null }, { headers: { 'Cache-Control': 'no-store' } });
return json(
{ servers, inspection: server ? inspect(server) : null },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
@@ -46,12 +89,24 @@ export async function GET({ url }: { url: URL }) {
export async function POST({ request }: { request: Request }) {
let body;
try { body = await request.json(); } catch { return json({ error: 'Invalid preview request.' }, { status: 400 }); }
if (!body || typeof body.server !== 'string' || !Number.isSafeInteger(body.artifactId) || typeof body.text !== 'string') {
try {
body = await request.json();
} catch {
return json({ error: 'Invalid preview request.' }, { status: 400 });
}
if (
!body ||
typeof body.server !== 'string' ||
!Number.isSafeInteger(body.artifactId) ||
typeof body.text !== 'string'
) {
return json({ error: 'Server, config ID and text are required.' }, { status: 400 });
}
try {
return json({ inspection: inspect(body.server, body), promoted: inspect(body.server) }, { headers: { 'Cache-Control': 'no-store' } });
return json(
{ inspection: inspect(body.server, body), promoted: inspect(body.server) },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
@@ -2,18 +2,26 @@ import { json } from '@sveltejs/kit';
import { listCollections, queueCollection } from '$lib/server/fleet/collections.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function GET() { return json({ collections: listCollections() }); }
export function GET() {
return json({ collections: listCollections() });
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection preview and its server keys.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection preview and its server keys.');
const result = queueCollection(body);
// The durable queue is committed. Let the response and pending I/O run
// before provisioning loads launch files or starts checking removals.
if (result.collectionId) setImmediate(() => {
void kickProvisioning().catch(err => console.error('[collection] worker kick failed:', err));
});
if (result.collectionId)
setImmediate(() => {
void kickProvisioning().catch((err) =>
console.error('[collection] worker kick failed:', err)
);
});
return json(result, { status: result.collectionId ? 202 : 200 });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
@@ -8,7 +8,12 @@ export function POST({ params }: { params: { id: string } }) {
return json({ error: 'Collection not found.' }, { status: 404 });
}
if (!cancelCollection(params.id)) {
return json({ error: 'This collection has no work that can still be cancelled. Refresh to see its current state.' }, { status: 409 });
return json(
{
error: 'This collection has no work that can still be cancelled. Refresh to see its current state.'
},
{ status: 409 }
);
}
void kickProvisioning();
return json({ ok: true });
@@ -5,5 +5,7 @@ export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
return json(previewCollection(body?.blueprintId, body?.mode, body));
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
+5 -4
View File
@@ -4,11 +4,12 @@ import { db } from '$lib/server/db/index.js';
import { getDeployment } from '$lib/server/provisioning/index.js';
export async function GET({ params, url }: { params: { id: string }; url?: URL }) {
const lifecycle = getDeployment(params.id, { events: url?.searchParams.get('events') === 'recent' ? 'recent' : 'all' });
const lifecycle = getDeployment(params.id, {
events: url?.searchParams.get('events') === 'recent' ? 'recent' : 'all'
});
if (lifecycle) return json(lifecycle);
const dep = db()
.prepare('SELECT * FROM deployments WHERE id = ?')
.get(params.id) as Record<string, unknown> | undefined;
const dep = db().prepare('SELECT * FROM deployments WHERE id = ?').get(params.id) as
Record<string, unknown> | undefined;
if (!dep) throw error(404, 'deployment not found');
const servers = db()
@@ -1,10 +1,20 @@
import { json } from '@sveltejs/kit';
import { cancelProvisioningJob, rebalanceCancellationBlocker } from '$lib/server/provisioning/cancellation.js';
import {
cancelProvisioningJob,
rebalanceCancellationBlocker
} from '$lib/server/provisioning/cancellation.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
if (!cancelProvisioningJob(params.id)) return json({ error: rebalanceCancellationBlocker(params.id)
?? 'This operation can no longer be cancelled. Refresh to see its current state.' }, { status: 409 });
if (!cancelProvisioningJob(params.id))
return json(
{
error:
rebalanceCancellationBlocker(params.id) ??
'This operation can no longer be cancelled. Refresh to see its current state.'
},
{ status: 409 }
);
void kickProvisioning();
return json({ ok: true });
}
@@ -1,9 +1,17 @@
import { json } from '@sveltejs/kit';
import { resumeVerification, resumeCleanup, resumeRebuild } from '$lib/server/provisioning/index.js';
import {
resumeVerification,
resumeCleanup,
resumeRebuild
} from '$lib/server/provisioning/index.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
if (!resumeVerification(params.id) && !resumeCleanup(params.id) && !resumeRebuild(params.id)) return json({ error: 'This operation cannot resume. Refresh and inspect its server.' }, { status: 409 });
void kick().catch(err => console.error('[deployment] verification kick failed:', err));
if (!resumeVerification(params.id) && !resumeCleanup(params.id) && !resumeRebuild(params.id))
return json(
{ error: 'This operation cannot resume. Refresh and inspect its server.' },
{ status: 409 }
);
void kick().catch((err) => console.error('[deployment] verification kick failed:', err));
return json({ ok: true }, { status: 202 });
}
@@ -8,7 +8,10 @@ export function GET() {
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!Array.isArray(body?.servers) || !body.servers.every((name: unknown) => typeof name === 'string')) {
if (
!Array.isArray(body?.servers) ||
!body.servers.every((name: unknown) => typeof name === 'string')
) {
return json({ error: 'A reviewed server list is required.' }, { status: 400 });
}
let result: ReturnType<typeof queueHouseParty>;
@@ -18,6 +21,6 @@ export async function POST({ request }: { request: Request }) {
return json({ ok: false, error: (err as Error).message }, { status: 409 });
}
if (!result.ok) return json(result, { status: 409 });
void kick().catch(err => console.error('[house-party] worker kick failed:', err));
void kick().catch((err) => console.error('[house-party] worker kick failed:', err));
return json(result, { status: 202 });
}
@@ -4,8 +4,12 @@ import { previewCreation } from '$lib/server/provisioning/index.js';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.');
if (body.blueprintId !== undefined) throw new Error('Collections use target counts. Use /api/deployment-collections/preview to preview only missing servers.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a launch selection.');
if (body.blueprintId !== undefined)
throw new Error(
'Collections use target counts. Use /api/deployment-collections/preview to preview only missing servers.'
);
return json(previewCreation(body));
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
+21 -4
View File
@@ -4,7 +4,11 @@ import { previewDrift } from '$lib/server/fleet/drift.js';
// GET returns the current drift plan for the whole fleet without touching disk.
export async function GET() {
const plan = await previewDrift();
const entries = plan.files.map(file => ({ ...file, size: file.afterSize ?? file.beforeSize ?? 0, sha256: file.afterSha256 ?? file.beforeSha256 ?? '' }));
const entries = plan.files.map((file) => ({
...file,
size: file.afterSize ?? file.beforeSize ?? 0,
sha256: file.afterSha256 ?? file.beforeSha256 ?? ''
}));
const byServer = new Map<
string,
Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }>
@@ -21,7 +25,8 @@ export async function GET() {
byServer.set(e.server, arr);
}
return json({
totalDrift: entries.length, scannedAt: plan.scannedAt,
totalDrift: entries.length,
scannedAt: plan.scannedAt,
servers: [...byServer.entries()].map(([server, files]) => ({ server, files }))
});
}
@@ -29,12 +34,24 @@ export async function GET() {
// Legacy previews remain readable. Watchdog owns all new reconciliation requests.
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!body || (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.every((server: unknown) => typeof server === 'string')))) {
if (
!body ||
(body.servers !== undefined &&
(!Array.isArray(body.servers) ||
!body.servers.every((server: unknown) => typeof server === 'string')))
) {
return json({ ok: false, error: 'Provide the server IDs to review.' }, { status: 400 });
}
try {
if (body.dryRun === true) return json({ ok: true, plan: await previewDrift(body.servers) });
return json({ ok: false, error: 'Drift reconciliation has moved to Watchdog. Scan there to queue a rebuild and campaign.', watchdogUrl: '/watchdog' }, { status: 410 });
return json(
{
ok: false,
error: 'Drift reconciliation has moved to Watchdog. Scan there to queue a rebuild and campaign.',
watchdogUrl: '/watchdog'
},
{ status: 410 }
);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
+9 -4
View File
@@ -6,9 +6,14 @@ const encoder = new TextEncoder();
export async function GET() {
return eventStreamResponse({
accepts: event => event.type === 'server-state' || event.type === 'deployment-opened' ||
event.type === 'server-changed' || event.type === 'sweep-tick' ||
event.type === 'fleet-invalidated' || event.type === 'beta-changed',
encode: event => encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
accepts: (event) =>
event.type === 'server-state' ||
event.type === 'deployment-opened' ||
event.type === 'server-changed' ||
event.type === 'sweep-tick' ||
event.type === 'fleet-invalidated' ||
event.type === 'beta-changed',
encode: (event) =>
encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
});
}
+12 -5
View File
@@ -14,21 +14,28 @@ const frames = new WeakMap<PipelineEvent, Uint8Array>();
export async function GET() {
const observation = observationReader();
const capacity = playerCapacityReader();
const observe = (server: ReturnType<typeof listServers>[number]) => statusEntry(server, observation, capacity(server.name));
const observe = (server: ReturnType<typeof listServers>[number]) =>
statusEntry(server, observation, capacity(server.name));
return eventStreamResponse({
accepts: event => event.type === 'server-status' || event.type === 'server-telemetry',
accepts: (event) => event.type === 'server-status' || event.type === 'server-telemetry',
encode(event) {
let frame = frames.get(event);
if (!frame) {
frame = encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`);
frame = encoder.encode(
`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`
);
frames.set(event, frame);
}
return frame;
},
initialFrames: [
encoder.encode(`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map(server => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`),
encoder.encode(`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`)
encoder.encode(
`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map((server) => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`
),
encoder.encode(
`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`
)
]
});
}
+13 -4
View File
@@ -3,8 +3,17 @@ import { createLibraryUpload, libraryOverview } from '$lib/server/game-files/ind
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
export function GET() { return json(libraryOverview()); }
export async function POST({ request }: { request: Request }) {
try { requireManagementOrigin(request); return json({ version: createLibraryUpload(await readGameJson(request, 32 * 1024 * 1024)) }, { status: 202 }); }
catch (cause) { return gameFileError(cause); }
export function GET() {
return json(libraryOverview());
}
export async function POST({ request }: { request: Request }) {
try {
requireManagementOrigin(request);
return json(
{ version: createLibraryUpload(await readGameJson(request, 32 * 1024 * 1024)) },
{ status: 202 }
);
} catch (cause) {
return gameFileError(cause);
}
}
+3 -1
View File
@@ -2,5 +2,7 @@ import { json } from '@sveltejs/kit';
import { getLibraryVersion } from '$lib/server/game-files/index.js';
export function GET({ params }: { params: { id: string } }) {
const version = getLibraryVersion(params.id);
return version ? json({ version }) : json({ error: 'Library version not found.' }, { status: 404 });
return version
? json({ version })
: json({ error: 'Library version not found.' }, { status: 404 });
}
@@ -8,5 +8,7 @@ export async function POST({ request, params }: { request: Request; params: { id
const body = await readGameJson(request);
if (typeof body.hostId !== 'string') throw new Error('Choose a target host.');
return json({ operation: queueLibraryDownload(body.hostId, params.id) }, { status: 202 });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
@@ -3,6 +3,11 @@ import { browserUploadStatus } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export async function GET({ params }: { params: { id: string } }) {
try { return json(await browserUploadStatus(params.id), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return gameFileError(cause); }
try {
return json(await browserUploadStatus(params.id), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return gameFileError(cause);
}
}
@@ -7,5 +7,7 @@ export async function POST({ request, params }: { request: Request; params: { id
try {
requireManagementOrigin(request);
return json(await requestBrowserPublication(params.id), { status: 202 });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
@@ -5,13 +5,26 @@ import { requireManagementOrigin } from '$lib/server/hosts/http.js';
function integerHeader(request: Request, name: string): number {
const value = request.headers.get(name);
if (value === null || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error(`Provide a valid ${name} header.`);
if (value === null || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
throw new Error(`Provide a valid ${name} header.`);
return Number(value);
}
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
export async function PUT({
request,
params
}: {
request: Request;
params: { id: string; path: string };
}) {
try {
requireManagementOrigin(request);
const chunk = { offset: integerHeader(request, 'X-Upload-Offset'), size: integerHeader(request, 'X-Upload-Length'), sha256: request.headers.get('X-Content-SHA256') ?? '' };
const chunk = {
offset: integerHeader(request, 'X-Upload-Offset'),
size: integerHeader(request, 'X-Upload-Length'),
sha256: request.headers.get('X-Content-SHA256') ?? ''
};
return json(await uploadBrowserFile(params.id, params.path, chunk, request.body));
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
+30 -4
View File
@@ -1,15 +1,41 @@
import { json } from '@sveltejs/kit';
import { createEnrollment, createHost } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import {
hostApiError,
installerCommand,
objectBody,
requireManagementOrigin
} from '$lib/server/hosts/http.js';
import { hostOverview } from '$lib/server/hosts/overview.js';
import { config } from '$lib/server/config.js';
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: hostOverview(), serverNow: Date.now(), defaultRuntimeImage: config.defaultRuntimeImage }, { headers: { 'Cache-Control': 'no-store' } }); }
export function GET({ request }: { request: Request }) {
requireManagementOrigin(request);
return json(
{
hosts: hostOverview(),
serverNow: Date.now(),
defaultRuntimeImage: config.defaultRuntimeImage
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
export async function POST({ request }: { request: Request }) {
requireManagementOrigin(request);
try {
const host = createHost(await objectBody(request));
const enrollment = createEnrollment(host.id);
return json({ host, enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { status: 201, headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
return json(
{
host,
enrollment: {
...enrollment,
command: installerCommand(request.url, enrollment.token)
}
},
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
);
} catch (cause) {
return hostApiError(cause);
}
}
+36 -9
View File
@@ -1,6 +1,12 @@
import { capacityCalibration } from '$lib/server/hosts/calibration.js';
import { error, json } from '@sveltejs/kit';
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, 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 { presentHostPreparation } from '$lib/server/hosts/preparation.js';
import { config } from '$lib/server/config.js';
@@ -9,26 +15,47 @@ import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.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.');
return json({ host: presentHostPreparation(host), instances: hostInstances(host.id), serverNow: Date.now(), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.defaultRuntimeImage, deletion: hostDeletionStatus(host.id) });
requireManagementOrigin(request);
const host = getHost(params.id);
if (!host) error(404, 'Host not found.');
return json({
host: presentHostPreparation(host),
instances: hostInstances(host.id),
serverNow: Date.now(),
calibration: capacityCalibration(host.id),
defaultRuntimeImage: config.defaultRuntimeImage,
deletion: hostDeletionStatus(host.id)
});
}
export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request);
try {
const body = await objectBody(request);
if (body.mode !== undefined && body.mode !== 'active' && body.mode !== 'draining') throw new Error('Choose Enable or Drain host. Hosts disable automatically after draining.');
if (body.mode !== undefined && body.mode !== 'active' && body.mode !== 'draining')
throw new Error(
'Choose Enable or Drain host. Hosts disable automatically after draining.'
);
updateHost(params.id, body);
if (body.mode === 'active') reconcileHostPreparation(params.id);
if (body.mode === 'draining') void kickProvisioning().catch(cause => console.error('[host-drain]', cause));
if (body.mode === 'draining')
void kickProvisioning().catch((cause) => console.error('[host-drain]', cause));
return json({ host: presentHostPreparation(getHost(params.id)!) });
} catch (cause) { return hostApiError(cause); }
} catch (cause) {
return hostApiError(cause);
}
}
export function DELETE({ request, params }: Event) {
requireManagementOrigin(request);
try { const result = deleteHost(params.id); return json(result, { status: result.deleted ? 200 : 202 }); }
catch (cause) {
if (cause instanceof HostDeletionError) return json({ error: cause.message, blockers: cause.blockers }, { status: cause.status });
try {
const result = deleteHost(params.id);
return json(result, { status: result.deleted ? 200 : 202 });
} catch (cause) {
if (cause instanceof HostDeletionError)
return json(
{ error: cause.message, blockers: cause.blockers },
{ status: cause.status }
);
return hostApiError(cause);
}
}
@@ -4,9 +4,19 @@ import { hostApiError, requireManagementOrigin } from '$lib/server/hosts/http.js
type Event = { request: Request; params: { id: string } };
export function DELETE({ request, params }: Event) {
requireManagementOrigin(request);
try { revokeCredentials(params.id); return json({ ok: true }); } catch (cause) { return hostApiError(cause); }
try {
revokeCredentials(params.id);
return json({ ok: true });
} catch (cause) {
return hostApiError(cause);
}
}
export function POST({ request, params }: Event) {
requireManagementOrigin(request);
try { const operation = queueHostOperation(params.id, 'credentials.rotate', {}); return json({ operationId: operation.id }, { status: 202 }); } catch (cause) { return hostApiError(cause); }
try {
const operation = queueHostOperation(params.id, 'credentials.rotate', {});
return json({ operationId: operation.id }, { status: 202 });
} catch (cause) {
return hostApiError(cause);
}
}
@@ -3,6 +3,18 @@ import { createEnrollment } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, requireManagementOrigin } from '$lib/server/hosts/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
requireManagementOrigin(request);
try { const enrollment = createEnrollment(params.id); return json({ enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
try {
const enrollment = createEnrollment(params.id);
return json(
{
enrollment: {
...enrollment,
command: installerCommand(request.url, enrollment.token)
}
},
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (cause) {
return hostApiError(cause);
}
}
@@ -5,8 +5,15 @@ export async function POST({ request, params }: { request: Request; params: { id
requireManagementOrigin(request);
try {
const body = await objectBody(request);
if (body.kind !== 'runtime.pull' || typeof body.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)) throw new Error('Provide a runtime.pull request and a valid container image.');
if (
body.kind !== 'runtime.pull' ||
typeof body.image !== 'string' ||
!/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)
)
throw new Error('Provide a runtime.pull request and a valid container image.');
const operation = queueHostOperation(params.id, 'runtime.pull', { image: body.image });
return json({ operationId: operation.id }, { status: 202 });
} catch (cause) { return hostApiError(cause); }
} catch (cause) {
return hostApiError(cause);
}
}
+5 -2
View File
@@ -11,7 +11,10 @@ export function GET({ request, params }: { request: Request; params: { id: strin
const phase = search.get('phase') ?? 'all';
const page = Number(search.get('page') ?? '1');
if (scope !== 'host' && scope !== 'fleet') error(400, 'Invalid sample scope.');
if (phase !== 'all' && phase !== 'startup' && phase !== 'running') error(400, 'Invalid sample phase.');
if (phase !== 'all' && phase !== 'startup' && phase !== 'running')
error(400, 'Invalid sample phase.');
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid sample page.');
return json(capacitySamples(params.id, { scope, phase, page }), { headers: { 'Cache-Control': 'no-store' } });
return json(capacitySamples(params.id, { scope, phase, page }), {
headers: { 'Cache-Control': 'no-store' }
});
}
+5 -2
View File
@@ -12,7 +12,10 @@ export function GET({ request }: { request: Request }) {
if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.');
const scope = hostOperationScope(params);
for (const key of ['host', 'server', 'operation'] as const) {
if (params.has(key) && params.get(key) !== scope[key]) error(400, 'Invalid operation history scope.');
if (params.has(key) && params.get(key) !== scope[key])
error(400, 'Invalid operation history scope.');
}
return json(listHostOperationHistory(page, filter, scope), { headers: { 'Cache-Control': 'no-store' } });
return json(listHostOperationHistory(page, filter, scope), {
headers: { 'Cache-Control': 'no-store' }
});
}
+38 -13
View File
@@ -12,20 +12,45 @@ export async function GET({ url }: { url: URL }) {
const players = params.get('players');
const rawLimit = params.get('limit');
const rawOffset = params.get('offset');
const integer = (value: string | null, fallback: number) => value === null ? fallback
: /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN;
const integer = (value: string | null, fallback: number) =>
value === null
? fallback
: /^\d+$/.test(value) && Number.isSafeInteger(Number(value))
? Number(value)
: NaN;
const limit = integer(rawLimit, 50);
const offset = integer(rawOffset, 0);
if ((game !== null && game !== 't5' && game !== 't6')
|| (map !== null && map.length > 1024) || (q !== null && q.length > 100)
|| (metric !== null && !isLeaderboardMetric(metric))
|| (players !== null && !/^[1-4]$/.test(players))
|| !Number.isFinite(limit) || limit < 1 || limit > 100
|| !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) {
return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 });
if (
(game !== null && game !== 't5' && game !== 't6') ||
(map !== null && map.length > 1024) ||
(q !== null && q.length > 100) ||
(metric !== null && !isLeaderboardMetric(metric)) ||
(players !== null && !/^[1-4]$/.test(players)) ||
!Number.isFinite(limit) ||
limit < 1 ||
limit > 100 ||
!Number.isFinite(offset) ||
offset < 0 ||
offset > MAX_LEADERBOARD_OFFSET
) {
return json(
{
ok: false,
kind: 'bad-request',
error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.'
},
{ status: 400 }
);
}
return json(await leaderboardReport({ game: game ?? undefined, map: map || undefined,
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
players: players === null ? undefined : Number(players) as LeaderboardPlayerCount,
q: q ?? undefined, limit, offset }));
return json(
await leaderboardReport({
game: game ?? undefined,
map: map || undefined,
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
players: players === null ? undefined : (Number(players) as LeaderboardPlayerCount),
q: q ?? undefined,
limit,
offset
})
);
}
+60 -18
View File
@@ -2,50 +2,92 @@ import { json } from '@sveltejs/kit';
import { maintenanceOverview } from '$lib/server/maintenance/index.js';
import { pauseMaintenance, setMaintenancePolicy } from '$lib/server/maintenance/policies.js';
import { watchdogTick } from '$lib/server/maintenance/recovery.js';
import { cancelMaintenance, checkPlutonium, queueUpdates, retryMaintenance, saveMaintenanceSchedule } from '$lib/server/maintenance/runtime.js';
import {
cancelMaintenance,
checkPlutonium,
queueUpdates,
retryMaintenance,
saveMaintenanceSchedule
} from '$lib/server/maintenance/runtime.js';
import { validTimeZone } from '$lib/time-zone.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function GET(event?: { url: URL }) {
const job = event?.url.searchParams.get('job');
if (job !== undefined && job !== null && (!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))) return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
if (
job !== undefined &&
job !== null &&
(!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))
)
return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
return json(maintenanceOverview(job ? Number(job) : null));
}
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'Invalid request.' }, { status: 400 });
let notice = '';
if (!body || typeof body !== 'object' || Array.isArray(body))
return json({ error: 'Invalid request.' }, { status: 400 });
let notice: string;
try {
switch (body.action) {
case 'pause':
if (typeof body.paused !== 'boolean') throw new Error('Provide a paused boolean.');
pauseMaintenance(body.paused);
notice = body.paused ? 'Maintenance paused. Submitted operations continue recovery.' : 'Maintenance resumed.';
notice = body.paused
? 'Maintenance paused. Submitted operations continue recovery.'
: 'Maintenance resumed.';
break;
case 'policy':
if (!['health', 'drift', 'updates'].includes(body.policy) || typeof body.enabled !== 'boolean') throw new Error('Choose a policy and an enabled boolean.');
setMaintenancePolicy(body.policy, body.enabled); notice = 'Policy saved.';
if (
!['health', 'drift', 'updates'].includes(body.policy) ||
typeof body.enabled !== 'boolean'
)
throw new Error('Choose a policy and an enabled boolean.');
setMaintenancePolicy(body.policy, body.enabled);
notice = 'Policy saved.';
break;
case 'scan':
await Promise.all([watchdogTick(true), checkPlutonium()]); notice = 'Checks finished.';
await Promise.all([watchdogTick(true), checkPlutonium()]);
notice = 'Checks finished.';
break;
case 'update':
if (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((v: unknown) => typeof v !== 'string'))) throw new Error('Choose existing fleet servers.');
if (
body.servers !== undefined &&
(!Array.isArray(body.servers) ||
!body.servers.length ||
body.servers.some((v: unknown) => typeof v !== 'string'))
)
throw new Error('Choose existing fleet servers.');
notice = `${queueUpdates(body.servers)} update restart(s) queued. Occupied servers wait until empty.`;
break;
case 'schedule':
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !validTimeZone(body.timeZone)) throw new Error('Provide enabled, a time, and a valid timezone.');
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone); notice = 'Schedule saved. Existing requests are retained.';
if (
typeof body.enabled !== 'boolean' ||
typeof body.time !== 'string' ||
!validTimeZone(body.timeZone)
)
throw new Error('Provide enabled, a time, and a valid timezone.');
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone);
notice = 'Schedule saved. Existing requests are retained.';
break;
case 'cancel':
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id)) return json({ error: 'Only unsubmitted restarts can be cancelled.' }, { status: 409 });
notice = 'Queued restart cancelled.'; break;
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id))
return json(
{ error: 'Only unsubmitted restarts can be cancelled.' },
{ status: 409 }
);
notice = 'Queued restart cancelled.';
break;
case 'retry':
if (!Number.isSafeInteger(body.id)) throw new Error('Choose a maintenance activity.');
notice = `${retryMaintenance(body.id)} restart(s) queued for verification.`; break;
default: throw new Error('Unknown maintenance action.');
if (!Number.isSafeInteger(body.id))
throw new Error('Choose a maintenance activity.');
notice = `${retryMaintenance(body.id)} restart(s) queued for verification.`;
break;
default:
throw new Error('Unknown maintenance action.');
}
} catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
void kick().catch(error => console.error('[maintenance] worker kick failed:', error));
} catch (error) {
return json({ error: (error as Error).message }, { status: 400 });
}
void kick().catch((error) => console.error('[maintenance] worker kick failed:', error));
return json({ ...maintenanceOverview(), notice });
}
+6 -3
View File
@@ -4,13 +4,16 @@ import { kick } from '$lib/server/pipeline/worker.js';
export function GET({ params }: { params: { id: string } }) {
const detail = watchdogDetail(params.id);
return detail ? json(detail) : json({ error: 'Maintenance finding not found.' }, { status: 404 });
return detail
? json(detail)
: json({ error: 'Maintenance finding not found.' }, { status: 404 });
}
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const body = await request.json().catch(() => null);
if (!body || !['retry','dismiss'].includes(body.action)) return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
if (!body || !['retry', 'dismiss'].includes(body.action))
return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
const result = updateWatchdogIncident(params.id, body.action);
if (!result.ok) return json({ error: result.error }, { status: result.status });
void kick().catch(err => console.error('[maintenance] worker kick failed:', err));
void kick().catch((err) => console.error('[maintenance] worker kick failed:', err));
return json(watchdogDetail(params.id));
}
+5 -2
View File
@@ -8,7 +8,10 @@ export function GET({ url }: { url: URL }) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
}
let query;
try { query = readMatchQuery(url.searchParams); }
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
try {
query = readMatchQuery(url.searchParams);
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(undefined, limit, query));
}
@@ -5,15 +5,24 @@ import { ScriptError } from '$lib/server/fleet/scripts.js';
export async function POST({ request }: { request: Request }) {
let input;
try { input = await request.json(); } catch { return json({ error: 'Invalid JSON body.' }, { status: 400 }); }
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'Import details are required.' }, { status: 400 });
if ((input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') ||
(input.scopeValue != null && typeof input.scopeValue !== 'string')) {
try {
input = await request.json();
} catch {
return json({ error: 'Invalid JSON body.' }, { status: 400 });
}
if (!input || typeof input !== 'object' || Array.isArray(input))
return json({ error: 'Import details are required.' }, { status: 400 });
if (
(input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') ||
(input.scopeValue != null && typeof input.scopeValue !== 'string')
) {
return json({ error: 'Invalid plugin scope.' }, { status: 400 });
}
try { return json({ ok: true, ...await importPluginRelease(input) }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
try {
return json({ ok: true, ...(await importPluginRelease(input)) });
} catch (error) {
if (error instanceof GithubReleaseError)
return json({ error: error.message }, { status: error.status });
if (error instanceof ScriptError) return json({ error: error.message }, { status: 409 });
throw error;
}
@@ -2,9 +2,11 @@ import { json } from '@sveltejs/kit';
import { getGithubRelease, GithubReleaseError } from '$lib/server/integrations/github-releases.js';
export async function GET({ url }: { url: URL }) {
try { return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
try {
return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') });
} catch (error) {
if (error instanceof GithubReleaseError)
return json({ error: error.message }, { status: error.status });
throw error;
}
}
+10 -5
View File
@@ -8,10 +8,13 @@ export async function GET({ url }: { url: URL }) {
const raw = url.searchParams.get('pluginId');
if (raw !== null) {
const id = Number(raw);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid plugin id.' }, { status: 400 });
try { return json({ updates: [await checkPluginUpdate(id)] }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
if (!Number.isSafeInteger(id) || id < 1)
return json({ error: 'Invalid plugin id.' }, { status: 400 });
try {
return json({ updates: [await checkPluginUpdate(id)] });
} catch (error) {
if (error instanceof GithubReleaseError)
return json({ error: error.message }, { status: error.status });
throw error;
}
}
@@ -19,7 +22,9 @@ export async function GET({ url }: { url: URL }) {
const updates: PluginUpdate[] = [];
// Bound outbound concurrency for large fleets; repository requests share a five-minute cache.
for (let i = 0; i < plugins.length; i += 4) {
updates.push(...await Promise.all(plugins.slice(i, i + 4).map(p => checkPluginUpdate(p.id))));
updates.push(
...(await Promise.all(plugins.slice(i, i + 4).map((p) => checkPluginUpdate(p.id))))
);
}
return json({ updates });
}
+39 -10
View File
@@ -1,28 +1,57 @@
import { json } from '@sveltejs/kit';
import { validTimeZone } from '$lib/time-zone.js';
import { cancelMaintenance, checkPlutonium, plutoniumStatus, queueMaintenance, saveMaintenanceSchedule } from '$lib/server/fleet/plutonium.js';
import {
cancelMaintenance,
checkPlutonium,
plutoniumStatus,
queueMaintenance,
saveMaintenanceSchedule
} from '$lib/server/fleet/plutonium.js';
export function GET() { return json(plutoniumStatus()); }
export function GET() {
return json(plutoniumStatus());
}
export async function POST({ request }: { request: Request }) {
let body;
try { body = await request.json(); } catch { return json({ error: 'Invalid JSON.' }, { status: 400 }); }
if (!body || typeof body !== 'object') return json({ error: 'Invalid request.' }, { status: 400 });
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON.' }, { status: 400 });
}
if (!body || typeof body !== 'object')
return json({ error: 'Invalid request.' }, { status: 400 });
if (body.action === 'check') {
await checkPlutonium();
} else if (body.action === 'schedule') {
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time) || !validTimeZone(body.timeZone)) {
return json({ error: 'Provide enabled, a valid time (HH:mm), and a valid timezone.' }, { status: 400 });
if (
typeof body.enabled !== 'boolean' ||
typeof body.time !== 'string' ||
!/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time) ||
!validTimeZone(body.timeZone)
) {
return json(
{ error: 'Provide enabled, a valid time (HH:mm), and a valid timezone.' },
{ status: 400 }
);
}
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone);
} else if (body.action === 'restart') {
if (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((s: unknown) => typeof s !== 'string')) {
if (
!Array.isArray(body.servers) ||
!body.servers.length ||
body.servers.some((s: unknown) => typeof s !== 'string')
) {
return json({ error: 'Choose servers to restart.' }, { status: 400 });
}
try { queueMaintenance(body.servers); }
catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
try {
queueMaintenance(body.servers);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
} else if (body.action === 'cancel') {
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id)) return json({ error: 'Only queued restarts can be cancelled.' }, { status: 409 });
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id))
return json({ error: 'Only queued restarts can be cancelled.' }, { status: 409 });
} else return json({ error: 'Unknown action.' }, { status: 400 });
return json(plutoniumStatus());
}
+38 -16
View File
@@ -1,37 +1,59 @@
import { json } from '@sveltejs/kit';
import { deploymentListPage } from '$lib/server/provisioning/history.js';
import { requestLifecycle } from '$lib/server/provisioning/index.js';
import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js';
import {
provisioningActions,
runtimeActions,
type ProvisioningAction,
type RuntimeAction
} from '$lib/provisioning.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
import { provisioningListSources, provisioningListStatuses } from '$lib/provisioning-list.js';
export function GET({ url }: { url: URL }) {
const params = url.searchParams;
const action = params.get('action');
const status = params.get('status') as typeof provisioningListStatuses[number];
const source = params.get('source') as typeof provisioningListSources[number];
return json(deploymentListPage({
view: params.get('view') === 'history' ? 'history' : 'active',
action: provisioningActions.includes(action as ProvisioningAction) ? action as ProvisioningAction : undefined,
status: provisioningListStatuses.includes(status) ? status : undefined,
source: provisioningListSources.includes(source) ? source : undefined,
server: params.get('server') ?? '',
limit: Number(params.get('limit') ?? 25),
offset: Number(params.get('offset') ?? 0)
}), { headers: { 'Cache-Control': 'no-store' } });
const status = params.get('status') as (typeof provisioningListStatuses)[number];
const source = params.get('source') as (typeof provisioningListSources)[number];
return json(
deploymentListPage({
view: params.get('view') === 'history' ? 'history' : 'active',
action: provisioningActions.includes(action as ProvisioningAction)
? (action as ProvisioningAction)
: undefined,
status: provisioningListStatuses.includes(status) ? status : undefined,
source: provisioningListSources.includes(source) ? source : undefined,
server: params.get('server') ?? '',
limit: Number(params.get('limit') ?? 25),
offset: Number(params.get('offset') ?? 0)
}),
{ headers: { 'Cache-Control': 'no-store' } }
);
}
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!body || typeof body.server !== 'string' || !body.server || !runtimeActions.includes(body.action)) {
return json({ error: 'server and a valid lifecycle action are required.' }, { status: 400 });
if (
!body ||
typeof body.server !== 'string' ||
!body.server ||
!runtimeActions.includes(body.action)
) {
return json(
{ error: 'server and a valid lifecycle action are required.' },
{ status: 400 }
);
}
try {
const result = requestLifecycle(body.server, body.action as RuntimeAction);
if (!result.ok) return json({ error: result.error }, { status: result.status });
setImmediate(() => {
void kickProvisioning().catch(err => console.error('[provisioning] worker kick failed:', err));
void kickProvisioning().catch((err) =>
console.error('[provisioning] worker kick failed:', err)
);
});
return json(result, { status: 202 });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
@@ -4,7 +4,11 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
const cleanupId = cancelCreation(params.id);
if (!cleanupId) return json({ error: 'This creation can no longer be removed. Refresh its current state.' }, { status: 409 });
if (!cleanupId)
return json(
{ error: 'This creation can no longer be removed. Refresh its current state.' },
{ status: 409 }
);
void kickProvisioning();
return json({ ok: true, cleanupId });
}
@@ -12,13 +12,22 @@ function integer(params: URLSearchParams, key: string): number | undefined {
}
export function GET({ params, url }: { params: { id: string }; url: URL }) {
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ?').get(params.id)) error(404, 'Provisioning operation not found.');
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ?').get(params.id))
error(404, 'Provisioning operation not found.');
const order = url.searchParams.get('order') ?? 'newest';
if (order !== 'newest' && order !== 'oldest') error(400, 'Choose newest or oldest activity order.');
if (order !== 'newest' && order !== 'oldest')
error(400, 'Choose newest or oldest activity order.');
const query = url.searchParams.get('query') ?? '';
if (query.length > 500) error(400, 'Activity search must be 500 characters or fewer.');
const limit = integer(url.searchParams, 'limit');
if (limit === 0) error(400, 'Activity limit must be at least 1.');
return json(deploymentActivity(params.id, { order, query, limit,
cursor: integer(url.searchParams, 'cursor'), watermark: integer(url.searchParams, 'watermark') }));
return json(
deploymentActivity(params.id, {
order,
query,
limit,
cursor: integer(url.searchParams, 'cursor'),
watermark: integer(url.searchParams, 'watermark')
})
);
}
@@ -5,10 +5,16 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') return json({ error: 'Administrator access is required.' }, { status: 403 });
if (!locals.user || locals.user.role !== 'admin')
return json({ error: 'Administrator access is required.' }, { status: 403 });
let body: Record<string, unknown>;
try { body = await authBody(request); } catch (error) { return authError(error); }
if (body.confirmed !== true) return json({ error: 'Confirm the admin override before continuing.' }, { status: 400 });
try {
body = await authBody(request);
} catch (error) {
return authError(error);
}
if (body.confirmed !== true)
return json({ error: 'Confirm the admin override before continuing.' }, { status: 400 });
const result = enableSafetyOverride(params.id, locals.user);
if (!result.ok) return json({ error: result.error }, { status: 409 });
void kickProvisioning();
@@ -5,6 +5,8 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
const result = requestRetryWithRebuild(params.id);
if (!result.ok) return json({ error: result.error }, { status: result.status });
void kickProvisioning().catch(err => console.error('[provisioning] rebuild retry kick failed:', err));
void kickProvisioning().catch((err) =>
console.error('[provisioning] rebuild retry kick failed:', err)
);
return json(result, { status: 202 });
}
+26 -6
View File
@@ -1,14 +1,34 @@
import { json } from '@sveltejs/kit';
import { RebalanceError, previewRebalance, rebalanceReport, startRebalance, stopRebalance } from '$lib/server/rebalancing/index.js';
import {
RebalanceError,
previewRebalance,
rebalanceReport,
startRebalance,
stopRebalance
} from '$lib/server/rebalancing/index.js';
export function GET() { return json(rebalanceReport()); }
export function GET() {
return json(rebalanceReport());
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RebalanceError('Provide a rebalance action.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new RebalanceError('Provide a rebalance action.');
if (body.action === 'preview') return json({ preview: previewRebalance(body.settings) });
if (body.action === 'start' && typeof body.previewId === 'string' && body.previewId.length <= 100) return json({ run: startRebalance(body.previewId) });
if (body.action === 'stop' && typeof body.id === 'string' && body.id.length <= 100) return json({ run: stopRebalance(body.id) });
if (
body.action === 'start' &&
typeof body.previewId === 'string' &&
body.previewId.length <= 100
)
return json({ run: startRebalance(body.previewId) });
if (body.action === 'stop' && typeof body.id === 'string' && body.id.length <= 100)
return json({ run: stopRebalance(body.id) });
throw new RebalanceError('Choose a supported rebalance action.');
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof RebalanceError ? error.status : 400 }); }
} catch (error) {
return json(
{ error: (error as Error).message },
{ status: error instanceof RebalanceError ? error.status : 400 }
);
}
}
+9 -5
View File
@@ -16,7 +16,10 @@ function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
throw new ScriptError(`invalid scope_type: ${s}`, 'invalid-scope');
}
function kindOrDefault(v: FormDataEntryValue | string | null, fallback: ArtifactKind = 'script'): ArtifactKind {
function kindOrDefault(
v: FormDataEntryValue | string | null,
fallback: ArtifactKind = 'script'
): ArtifactKind {
if (v === null || v === undefined || v === '') return fallback;
const s = String(v);
if (!isArtifactKind(s)) throw new ScriptError(`invalid kind: ${s}`, 'invalid-kind');
@@ -66,9 +69,8 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
// Target rel path: for script/plugin, default to the upload filename.
// For config/baseline, createScript() defaults to config.configTargetFile.
const targetRelPath = kind === 'script' || kind === 'plugin'
? uploadedFilename ?? undefined
: undefined;
const targetRelPath =
kind === 'script' || kind === 'plugin' ? (uploadedFilename ?? undefined) : undefined;
try {
const { script, version } = createScript({
@@ -78,7 +80,9 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
scopeValue,
content,
notes,
releaseVersion: form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined,
releaseVersion: form.has('releaseVersion')
? String(form.get('releaseVersion'))
: undefined,
kind
});
return json({ ok: true, script, versionId: version.id });
+17 -7
View File
@@ -29,21 +29,31 @@ export async function GET({ params }: { params: { id: string } }) {
sha256: v.sha256,
size: v.size,
notes: v.notes,
source: script.kind === 'plugin'
? db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as PluginSource | undefined ?? null
: null,
source:
script.kind === 'plugin'
? ((db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as
PluginSource | undefined) ?? null)
: null,
state: v.state,
uploaded_at: v.uploaded_at
}));
const matched = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
: [];
const matched =
script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
: [];
const runningBeta = findRunningBetaForScript(id);
return json({
script,
versions,
installedVersions: script.kind === 'script' || script.kind === 'plugin' ? db().prepare(`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`).all(id, script.target_rel_path) : [],
installedVersions:
script.kind === 'script' || script.kind === 'plugin'
? db()
.prepare(
`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`
)
.all(id, script.target_rel_path)
: [],
matchedServers: matched,
runningBetaId: runningBeta?.id ?? null
});
+36 -7
View File
@@ -1,23 +1,52 @@
import { json } from '@sveltejs/kit';
import { getScript, setScope, serversMatchingScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
import {
getScript,
setScope,
serversMatchingScope,
ScriptError,
type ScopeType
} from '$lib/server/fleet/scripts.js';
import { unsetScopeAndDetach } from '$lib/server/fleet/beta.js';
import { db } from '$lib/server/db/index.js';
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid file ID.' }, { status: 400 });
if (!Number.isSafeInteger(id) || id <= 0)
return json({ error: 'Invalid file ID.' }, { status: 400 });
const script = getScript(id);
if (!script) return json({ error: 'File not found.' }, { status: 404 });
const type = url.searchParams.get('type');
const value = url.searchParams.get('value');
if (type !== 'none' && type !== 'game' && type !== 'map' || type !== 'none' && !value) return json({ error: 'Choose a complete scope.' }, { status: 400 });
if ((type !== 'none' && type !== 'game' && type !== 'map') || (type !== 'none' && !value))
return json({ error: 'Choose a complete scope.' }, { status: 400 });
const before = serversMatchingScope(script.scope_type, script.scope_value);
const after = type === 'none' ? [] : serversMatchingScope(type as ScopeType, value);
const composed = script.kind === 'config' || script.kind === 'baseline';
const tracked = composed ? [] : (db().prepare(`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`).all(script.target_rel_path, script.id) as Array<{ server: string }>).map(row => row.server);
return json({ before, after, added: after.filter(name => !before.includes(name)), removed: before.filter(name => !after.includes(name)),
affected: composed ? [...new Set([...before, ...after])].sort() : type === 'none' ? tracked : [...new Set([...before, ...after])].sort(),
composed, detached: type === 'none' ? tracked : [] }, { headers: { 'Cache-Control': 'no-store' } });
const tracked = composed
? []
: (
db()
.prepare(
`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`
)
.all(script.target_rel_path, script.id) as Array<{ server: string }>
).map((row) => row.server);
return json(
{
before,
after,
added: after.filter((name) => !before.includes(name)),
removed: before.filter((name) => !after.includes(name)),
affected: composed
? [...new Set([...before, ...after])].sort()
: type === 'none'
? tracked
: [...new Set([...before, ...after])].sort(),
composed,
detached: type === 'none' ? tracked : []
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
function parseId(raw: string): number | null {
@@ -18,27 +18,44 @@ export async function POST({ params, request }: { params: { id: string }; reques
const script = getScript(scriptId);
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
if ((script.kind === 'config' || script.kind === 'baseline') && findRunningBetaForScript(scriptId)) {
return json({ ok: false, error: 'Finish the active beta before saving a config version.' }, { status: 409 });
if (
(script.kind === 'config' || script.kind === 'baseline') &&
findRunningBetaForScript(scriptId)
) {
return json(
{ ok: false, error: 'Finish the active beta before saving a config version.' },
{ status: 409 }
);
}
const form = await request.formData();
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
const file = form.get('content');
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
if (!(file instanceof File))
return json({ ok: false, error: 'content file is required' }, { status: 400 });
const content = Buffer.from(await file.arrayBuffer());
if (script.kind === 'script' && form.get('reuseExisting') === 'true') {
if (!content.length) return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
if (!content.length)
return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
if (findRunningBetaForScript(scriptId)) {
return json({ ok: false, error: 'Finish the active beta before uploading a version.' }, { status: 409 });
return json(
{ ok: false, error: 'Finish the active beta before uploading a version.' },
{ status: 409 }
);
}
const sha = createHash('sha256').update(content).digest('hex');
const existing = db().prepare('SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1').get(scriptId, sha);
const existing = db()
.prepare(
'SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1'
)
.get(scriptId, sha);
if (existing) return json({ ok: true, version: existing, reused: true });
}
try {
const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined;
const releaseVersion = form.has('releaseVersion')
? String(form.get('releaseVersion'))
: undefined;
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
return json({
ok: true,
+9 -3
View File
@@ -9,9 +9,15 @@ export async function POST({ request }: { request: Request }) {
if (!(file instanceof File) || file.size === 0) {
return json({ error: 'Choose a non-empty script file.' }, { status: 400 });
}
const sha256 = createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex');
const duplicates = db().prepare(`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
const sha256 = createHash('sha256')
.update(Buffer.from(await file.arrayBuffer()))
.digest('hex');
const duplicates = db()
.prepare(
`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
FROM script_versions v JOIN scripts s ON s.id = v.script_id
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`).all(sha256);
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`
)
.all(sha256);
return json({ sha256, duplicates });
}
+2 -1
View File
@@ -17,7 +17,8 @@ export async function PUT({ request }: { request: Request }) {
export async function DELETE({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128) return json({ error: 'Invalid key ID' }, { status: 400 });
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128)
return json({ error: 'Invalid key ID' }, { status: 400 });
try {
return json({ ok: removeServerKey(body.id) });
} catch (err) {
+45 -9
View File
@@ -4,11 +4,18 @@ import { json } from '@sveltejs/kit';
import { queueCreateServer } from '$lib/server/provisioning/index.js';
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
export function GET() { return json(fleetSnapshot()); }
export function GET() {
return json(fleetSnapshot());
}
export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as {
hostId?: string; libraryId?: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number;
hostId?: string;
libraryId?: string;
image?: string;
bindAddress?: string;
publicAddress?: string;
publicPort?: number;
game?: string;
gameMode?: string;
mapSlug?: string;
@@ -16,9 +23,21 @@ export async function POST({ request }: { request: Request }) {
expectedName?: string;
previewId?: string;
};
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string') || (body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))) {
if (
!body ||
(body.game !== undefined && body.game !== 't5' && body.game !== 't6') ||
typeof body.gameMode !== 'string' ||
typeof body.mapSlug !== 'string' ||
(body.plutoKey !== undefined && typeof body.plutoKey !== 'string') ||
(body.expectedName !== undefined && typeof body.expectedName !== 'string') ||
(body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))
) {
return json(
{ ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
{
ok: false,
error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings',
kind: 'bad-request'
},
{ status: 400 }
);
}
@@ -29,17 +48,34 @@ export async function POST({ request }: { request: Request }) {
plutoKey: body.plutoKey,
expectedName: body.expectedName,
previewId: body.previewId,
createdBy: 'ui', hostId: body.hostId, libraryId: body.libraryId, image: body.image, bindAddress: body.bindAddress, publicAddress: body.publicAddress, publicPort: body.publicPort
createdBy: 'ui',
hostId: body.hostId,
libraryId: body.libraryId,
image: body.image,
bindAddress: body.bindAddress,
publicAddress: body.publicAddress,
publicPort: body.publicPort
});
if (!result.ok) {
const status = result.kind === 'bad-request' ? 400 : result.kind === 'unconfigured' ? 503
: result.kind === 'conflict' || result.kind === 'unknown-map' || result.kind === 'no-slot' || result.kind === 'no-port' ? 409
: 500;
const status =
result.kind === 'bad-request'
? 400
: result.kind === 'unconfigured'
? 503
: result.kind === 'conflict' ||
result.kind === 'unknown-map' ||
result.kind === 'no-slot' ||
result.kind === 'no-port'
? 409
: 500;
return json({ ok: false, error: result.message, kind: result.kind }, { status });
}
// Return the durable job ID before the worker's synchronous admission work.
setImmediate(() => {
void kickProvisioning().catch((err) => console.error('[create] worker kick failed:', err));
});
return json({ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId }, { status: 202 });
return json(
{ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId },
{ status: 202 }
);
}
+8 -2
View File
@@ -10,12 +10,18 @@ export function GET({ params }: { params: { name: string } }) {
export async function DELETE({ params }: { params: { name: string } }) {
const name = params.name;
if (!name) {
return json({ ok: false, error: 'server name required', kind: 'bad-request' }, { status: 400 });
return json(
{ ok: false, error: 'server name required', kind: 'bad-request' },
{ status: 400 }
);
}
const result = await destroyServer(name);
if (!result.ok) {
const status = result.kind === 'not-found' ? 404 : result.kind === 'conflict' ? 409 : 500;
return json({ ok: false, error: result.message, kind: result.kind, warnings: result.warnings }, { status });
return json(
{ ok: false, error: result.message, kind: result.kind, warnings: result.warnings },
{ status }
);
}
setImmediate(() => {
void kickProvisioning().catch((err) => console.error('[destroy] worker kick failed:', err));
@@ -10,10 +10,11 @@ export async function GET({ params }: { params: { name: string } }) {
return json({ ok: false, reason: 'Server not found' }, { status: 404, headers });
}
const path = resolveLogPath(server.name, gameLogKey(server.name));
if (!path) return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
if (!path)
return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
try {
return json({ ok: true, ...await collectServerChat(server) }, { headers });
return json({ ok: true, ...(await collectServerChat(server)) }, { headers });
} catch (error) {
console.error('Could not read server chat', error);
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });
@@ -8,7 +8,10 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
}
let query;
try { query = readMatchQuery(url.searchParams); }
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
try {
query = readMatchQuery(url.searchParams);
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(params.name, limit, query));
}
@@ -26,7 +26,12 @@ export const POST: RequestHandler = async ({ params }) => {
// Keep the diagnostic response, but never persist old-process evidence
// across a lifecycle request or replacement registration.
const current = getServer(name);
if (!current || current.created_at !== row.created_at || epoch !== latestGameSubmission(name) || isProvisioning(name)) {
if (
!current ||
current.created_at !== row.created_at ||
epoch !== latestGameSubmission(name) ||
isProvisioning(name)
) {
return json(result);
}
const now = new Date().toISOString();
@@ -6,6 +6,13 @@ import { redactRconAddresses } from '$lib/server/integrations/rcon-diagnostics.j
export function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name);
if (!server || server.destroyed_at) return json({ error: 'No such server.' }, { status: 404 });
return json({ replySnippet: server.last_error && server.last_reply_snippet
? redactRconAddresses(server.last_reply_snippet) : null }, { headers: { 'Cache-Control': 'no-store' } });
return json(
{
replySnippet:
server.last_error && server.last_reply_snippet
? redactRconAddresses(server.last_reply_snippet)
: null
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
@@ -6,16 +6,34 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name);
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
const metadata = db().prepare(`SELECT i.rel_path, s.kind, s.id AS artifactId, v.version, v.release_version AS releaseVersion FROM server_scripts i
const metadata = db()
.prepare(
`SELECT i.rel_path, s.kind, s.id AS artifactId, v.version, v.release_version AS releaseVersion FROM server_scripts i
JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id
WHERE i.server = ? AND i.sha256 = v.sha256`).all(params.name) as Array<{ rel_path: string; kind: string; artifactId: number; version: number; releaseVersion: string | null }>;
const versions = new Map(metadata.map(row => [row.rel_path, row]));
WHERE i.server = ? AND i.sha256 = v.sha256`
)
.all(params.name) as Array<{
rel_path: string;
kind: string;
artifactId: number;
version: number;
releaseVersion: string | null;
}>;
const versions = new Map(metadata.map((row) => [row.rel_path, row]));
const scripts = listServerScripts(params.name).map((r) => ({
relPath: r.rel_path,
artifactId: versions.get(r.rel_path)?.artifactId ?? null,
version: versions.get(r.rel_path)?.version ?? null,
releaseVersion: versions.get(r.rel_path)?.releaseVersion ?? null,
kind: versions.get(r.rel_path)?.kind ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'),
kind:
versions.get(r.rel_path)?.kind ??
(/\.cfg$/i.test(r.rel_path)
? 'config'
: /\.dll$/i.test(r.rel_path)
? 'plugin'
: /\.gsc$/i.test(r.rel_path)
? 'script'
: 'file'),
scriptVersionId: r.script_version_id,
sha256: r.sha256,
size: r.size,

Some files were not shown because too many files have changed in this diff Show More