Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+279
-46
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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 });
|
||||
};
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,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' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user