Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+366
-67
@@ -26,10 +26,23 @@
|
||||
opened_at: string;
|
||||
closed_at: string | null;
|
||||
scripts: Array<{
|
||||
id: number; name: string; target_rel_path: string; kind: ArtifactKind;
|
||||
version: { id: number; version: number; release_version: string | null; sha256: string } | null;
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
kind: ArtifactKind;
|
||||
version: {
|
||||
id: number;
|
||||
version: number;
|
||||
release_version: string | null;
|
||||
sha256: string;
|
||||
} | null;
|
||||
}>;
|
||||
servers: Array<{
|
||||
server: string;
|
||||
server_deleted_at?: string | null;
|
||||
stage: string;
|
||||
error: string | null;
|
||||
}>;
|
||||
servers: Array<{ server: string; server_deleted_at?: string | null; stage: string; error: string | null }>;
|
||||
}
|
||||
|
||||
let betas = $state<BetaListRow[]>([]);
|
||||
@@ -38,17 +51,27 @@
|
||||
let tab = $state('active');
|
||||
let search = $state('');
|
||||
let status = $state('');
|
||||
let pageNumber = $state(1), totalPages = $state(1), total = $state(0);
|
||||
let pageNumber = $state(1),
|
||||
totalPages = $state(1),
|
||||
total = $state(0);
|
||||
let counts = $state({ active: 0, history: 0 });
|
||||
let request = 0;
|
||||
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
|
||||
const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
|
||||
const active = (beta: BetaListRow) =>
|
||||
beta.state === 'running' || beta.state === 'ready-promote';
|
||||
const tabs = $derived([
|
||||
{ id: 'active', label: 'Active', badge: counts.active },
|
||||
{ id: 'history', label: 'History', badge: counts.history }
|
||||
]);
|
||||
let now = $state(Date.now());
|
||||
|
||||
function deadlineLabel(beta: BetaListRow) {
|
||||
if (!beta.deadlineAt || !active(beta) || beta.testOutcome === 'passed') return '';
|
||||
const minutes = Math.ceil((Date.parse(beta.deadlineAt) - now) / 60000);
|
||||
return minutes <= 0 ? 'Testing window ended' : minutes <= 120 ? `${minutes}m remaining` : '';
|
||||
return minutes <= 0
|
||||
? 'Testing window ended'
|
||||
: minutes <= 120
|
||||
? `${minutes}m remaining`
|
||||
: '';
|
||||
}
|
||||
$effect(() => {
|
||||
const params = $page.url.searchParams;
|
||||
@@ -60,11 +83,15 @@
|
||||
});
|
||||
function saveContext() {
|
||||
const url = new URL($page.url);
|
||||
if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view');
|
||||
if (search) url.searchParams.set('q', search); else url.searchParams.delete('q');
|
||||
if (status) url.searchParams.set('status', status); else url.searchParams.delete('status');
|
||||
if (tab === 'history') url.searchParams.set('view', tab);
|
||||
else url.searchParams.delete('view');
|
||||
if (search) url.searchParams.set('q', search);
|
||||
else url.searchParams.delete('q');
|
||||
if (status) url.searchParams.set('status', status);
|
||||
else url.searchParams.delete('status');
|
||||
url.searchParams.delete('limit');
|
||||
if (pageNumber > 1) url.searchParams.set('page', String(pageNumber)); else url.searchParams.delete('page');
|
||||
if (pageNumber > 1) url.searchParams.set('page', String(pageNumber));
|
||||
else url.searchParams.delete('page');
|
||||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
async function load(params = new URLSearchParams(window.location.search)) {
|
||||
@@ -77,79 +104,351 @@
|
||||
if (!res.ok) throw new Error('Could not refresh betas.');
|
||||
const body = await res.json();
|
||||
if (current !== request) return;
|
||||
betas = body.betas; counts = body.counts; total = body.total; totalPages = body.totalPages; pageNumber = body.page;
|
||||
now = Date.now(); error = '';
|
||||
} catch (e) { if (current === request) error = (e as Error).message; }
|
||||
finally { if (current === request) loading = false; }
|
||||
betas = body.betas;
|
||||
counts = body.counts;
|
||||
total = body.total;
|
||||
totalPages = body.totalPages;
|
||||
pageNumber = body.page;
|
||||
now = Date.now();
|
||||
error = '';
|
||||
} catch (e) {
|
||||
if (current === request) error = (e as Error).message;
|
||||
} finally {
|
||||
if (current === request) loading = false;
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
|
||||
if (query.has('artifact') || query.has('pluginId')) {
|
||||
void goto('/betas/new' + window.location.search, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
const stop = betaUpdates(() => load());
|
||||
const clock = setInterval(() => { now = Date.now(); }, 30000);
|
||||
return () => { stop(); clearInterval(clock); request++; };
|
||||
const clock = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 30000);
|
||||
return () => {
|
||||
stop();
|
||||
clearInterval(clock);
|
||||
request++;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Betas · Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Betas">
|
||||
{#snippet actions()}{#if loading || betas.length || error}<Button variant="primary" href="/betas/new">New beta</Button>{/if}{/snippet}
|
||||
{#snippet actions()}{#if loading || betas.length || error}<Button
|
||||
variant="primary"
|
||||
href="/betas/new">New beta</Button
|
||||
>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="toolbar">
|
||||
<div class="view-tabs"><Tabs {tabs} bind:value={tab} onchange={() => { pageNumber = 1; status = ''; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /></div>
|
||||
<label class="search">Search<input type="search" bind:value={search} oninput={() => { pageNumber = 1; saveContext(); }} placeholder="Name, beta number, file, or server" maxlength="200" /></label>
|
||||
<label>Status<select bind:value={status} onchange={() => { pageNumber = 1; saveContext(); }}>
|
||||
<option value="">All statuses</option>
|
||||
{#if tab === 'active'}<option value="ready">Ready to promote</option><option value="testing">Testing</option><option value="attention">Needs attention</option><option value="paused">Paused</option>
|
||||
{:else}<option value="promoted">Promoted</option><option value="rolled-back">Rolled back</option>{/if}
|
||||
</select></label>
|
||||
{#if betas.length && (search || status)}<Button variant="ghost" onclick={() => { search = ''; status = ''; pageNumber = 1; saveContext(); }}>Clear filters</Button>{/if}
|
||||
<div class="view-tabs">
|
||||
<Tabs
|
||||
{tabs}
|
||||
bind:value={tab}
|
||||
onchange={() => {
|
||||
pageNumber = 1;
|
||||
status = '';
|
||||
saveContext();
|
||||
}}
|
||||
idPrefix="beta-tabs"
|
||||
panelId="beta-list"
|
||||
ariaLabel="Beta views"
|
||||
/>
|
||||
</div>
|
||||
<label class="search"
|
||||
>Search<input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
oninput={() => {
|
||||
pageNumber = 1;
|
||||
saveContext();
|
||||
}}
|
||||
placeholder="Name, beta number, file, or server"
|
||||
maxlength="200"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Status<select
|
||||
bind:value={status}
|
||||
onchange={() => {
|
||||
pageNumber = 1;
|
||||
saveContext();
|
||||
}}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{#if tab === 'active'}<option value="ready">Ready to promote</option><option
|
||||
value="testing">Testing</option
|
||||
><option value="attention">Needs attention</option><option value="paused"
|
||||
>Paused</option
|
||||
>
|
||||
{:else}<option value="promoted">Promoted</option><option value="rolled-back"
|
||||
>Rolled back</option
|
||||
>{/if}
|
||||
</select></label
|
||||
>
|
||||
{#if betas.length && (search || status)}<Button
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
search = '';
|
||||
status = '';
|
||||
pageNumber = 1;
|
||||
saveContext();
|
||||
}}>Clear filters</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
|
||||
{#if error}<div class="ui-error" role="alert">
|
||||
{error}
|
||||
{betas.length ? 'Showing the last loaded results.' : ''}<Button
|
||||
size="sm"
|
||||
onclick={() => load()}>Retry</Button
|
||||
>
|
||||
</div>{/if}
|
||||
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
|
||||
{#if loading}<EmptyState title="Loading betas…" />
|
||||
{:else if !betas.length && !error}<EmptyState title={search || status ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={search || status ? 'Try another search or clear the filters.' : tab === 'active' ? 'Betas test file versions on selected servers before promotion. Choose the versions and servers to begin.' : 'Completed and rolled-back betas appear here. Start a beta to test a file version before promotion.'}>
|
||||
{#snippet actions()}{#if search || status}<Button onclick={() => { search = ''; status = ''; pageNumber = 1; saveContext(); }}>Clear filters</Button>{:else}<Button variant="primary" href="/betas/new">New beta</Button>{/if}{/snippet}
|
||||
</EmptyState>
|
||||
{:else if !betas.length && !error}<EmptyState
|
||||
title={search || status
|
||||
? 'No matching betas'
|
||||
: tab === 'active'
|
||||
? 'No active betas'
|
||||
: 'No beta history'}
|
||||
description={search || status
|
||||
? 'Try another search or clear the filters.'
|
||||
: tab === 'active'
|
||||
? 'Betas test file versions on selected servers before promotion. Choose the versions and servers to begin.'
|
||||
: 'Completed and rolled-back betas appear here. Start a beta to test a file version before promotion.'}
|
||||
>
|
||||
{#snippet actions()}{#if search || status}<Button
|
||||
onclick={() => {
|
||||
search = '';
|
||||
status = '';
|
||||
pageNumber = 1;
|
||||
saveContext();
|
||||
}}>Clear filters</Button
|
||||
>{:else}<Button variant="primary" href="/betas/new">New beta</Button
|
||||
>{/if}{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="table-scroll"><table>
|
||||
<thead><tr><th scope="col">Beta</th><th scope="col">Files</th><th scope="col">Status</th><th scope="col" class="started">Started</th></tr></thead>
|
||||
<tbody>{#each betas as beta (beta.id)}
|
||||
{@const presentation = betaStatus(beta)}
|
||||
{@const expiry = deadlineLabel(beta)}
|
||||
<tr>
|
||||
<td><a class="beta-link" href={contextHref('/betas/' + beta.id, $page.url)}>{beta.notes || `Beta #${beta.id}`}</a>{#if beta.notes}<span class="beta-id">#{beta.id}</span>{/if}</td>
|
||||
<td class="files">{#if beta.scripts.length === 1}{beta.scripts[0].name} <span>{artifactVersionLabel(beta.scripts[0].version)}</span>{:else}{beta.scripts.length} files{/if}</td>
|
||||
<td><Badge variant={presentation.tone} size="md">{presentation.label}</Badge>{#if expiry}<span class="expiry">{expiry}</span>{/if}</td>
|
||||
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}><RelativeTime value={beta.opened_at} /><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(beta.opened_at)}</span></button></td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
{#if totalPages > 1}<div class="pagination" aria-label="Beta pagination"><span>{total} betas · Page {pageNumber} of {totalPages}</span><div><Button size="sm" disabled={pageNumber <= 1} onclick={() => { pageNumber--; saveContext(); }}>Previous</Button><Button size="sm" disabled={pageNumber >= totalPages} onclick={() => { pageNumber++; saveContext(); }}>Next</Button></div></div>{/if}
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Beta</th><th scope="col">Files</th><th scope="col"
|
||||
>Status</th
|
||||
><th scope="col" class="started">Started</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each betas as beta (beta.id)}
|
||||
{@const presentation = betaStatus(beta)}
|
||||
{@const expiry = deadlineLabel(beta)}
|
||||
<tr>
|
||||
<td
|
||||
><a
|
||||
class="beta-link"
|
||||
href={contextHref('/betas/' + beta.id, $page.url)}
|
||||
>{beta.notes || `Beta #${beta.id}`}</a
|
||||
>{#if beta.notes}<span class="beta-id">#{beta.id}</span
|
||||
>{/if}</td
|
||||
>
|
||||
<td class="files"
|
||||
>{#if beta.scripts.length === 1}{beta.scripts[0].name}
|
||||
<span>{artifactVersionLabel(beta.scripts[0].version)}</span
|
||||
>{:else}{beta.scripts.length} files{/if}</td
|
||||
>
|
||||
<td
|
||||
><Badge variant={presentation.tone} size="md"
|
||||
>{presentation.label}</Badge
|
||||
>{#if expiry}<span class="expiry">{expiry}</span>{/if}</td
|
||||
>
|
||||
<td class="started"
|
||||
><button
|
||||
type="button"
|
||||
class="timestamp"
|
||||
aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}
|
||||
><RelativeTime value={beta.opened_at} /><span
|
||||
class="exact-time"
|
||||
aria-hidden="true"
|
||||
>{$timeFormat.dateTime(beta.opened_at)}</span
|
||||
></button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if totalPages > 1}<div class="pagination" aria-label="Beta pagination">
|
||||
<span>{total} betas · Page {pageNumber} of {totalPages}</span>
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pageNumber <= 1}
|
||||
onclick={() => {
|
||||
pageNumber--;
|
||||
saveContext();
|
||||
}}>Previous</Button
|
||||
><Button
|
||||
size="sm"
|
||||
disabled={pageNumber >= totalPages}
|
||||
onclick={() => {
|
||||
pageNumber++;
|
||||
saveContext();
|
||||
}}>Next</Button
|
||||
>
|
||||
</div>
|
||||
</div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; min-width:0; }
|
||||
.toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; }
|
||||
.view-tabs { align-self:flex-end; max-width:100%; }
|
||||
label { display:grid; gap:6px; font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
.search { flex:1; min-width:240px; }
|
||||
input,select { padding:10px 12px; min-height:40px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); }
|
||||
.table-scroll { overflow-x:auto; }
|
||||
table { width:100%; border-collapse:collapse; font-size:var(--text-sm); text-align:left; }
|
||||
th { padding:10px 16px; border-bottom:1px solid var(--border-strong); color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); }
|
||||
td { padding:16px; border-bottom:1px solid var(--border-subtle); }
|
||||
tbody tr:hover { background:var(--surface); }
|
||||
.beta-link { font-weight:var(--weight-semibold); color:var(--text-primary); text-decoration:none; overflow-wrap:anywhere; }
|
||||
.beta-link:hover { color:var(--accent-primary-hover); text-decoration:underline; }
|
||||
.beta-id { margin-left:8px; font-size:var(--text-xs); color:var(--text-tertiary); }
|
||||
.files { color:var(--text-secondary); overflow-wrap:anywhere; }.files span { white-space:nowrap; }
|
||||
.expiry { display:block; margin-top:6px; font-size:var(--text-xs); color:var(--state-warning); }
|
||||
.started { text-align:right; color:var(--text-secondary); }
|
||||
.timestamp { position:relative; display:inline-block; white-space:nowrap; cursor:help; background:transparent; border:0; color:inherit; font:inherit; padding:0; }
|
||||
.exact-time { display:none; position:absolute; right:0; bottom:100%; z-index:1; background:var(--surface-overlay); color:var(--text-primary); border:1px solid var(--border-strong); border-radius:var(--radius-sm); padding:6px 10px; font-size:var(--text-xs); }
|
||||
.timestamp:hover .exact-time,.timestamp:focus .exact-time { display:block; }
|
||||
.pagination,.pagination > div { display:flex; align-items:center; gap:12px; }.pagination { justify-content:space-between; margin-top:16px; font-size:var(--text-sm); color:var(--text-secondary); }
|
||||
@media(max-width:720px) { table { min-width:620px; } th,td { padding:12px 8px; }.search { flex-basis:100%; }.pagination { flex-wrap:wrap; } }
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.view-tabs {
|
||||
align-self: flex-end;
|
||||
max-width: 100%;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.search {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
padding: 10px 12px;
|
||||
min-height: 40px;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.beta-link {
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.beta-link:hover {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.beta-id {
|
||||
margin-left: 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.files {
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.files span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.expiry {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.started {
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.timestamp {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.exact-time {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
z-index: 1;
|
||||
background: var(--surface-overlay);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.timestamp:hover .exact-time,
|
||||
.timestamp:focus .exact-time {
|
||||
display: block;
|
||||
}
|
||||
.pagination,
|
||||
.pagination > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
table {
|
||||
min-width: 620px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
.search {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user