746 lines
30 KiB
Svelte
746 lines
30 KiB
Svelte
<script lang="ts">
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import { serverTitle } from '$ui/server-labels.js';
|
||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import { tick } from 'svelte';
|
||
import { page } from '$app/stores';
|
||
import { canOperate } from '$lib/auth.js';
|
||
import { contextHref } from '$lib/navigation-context.js';
|
||
import type { BetaDeploymentServer } from '$ui/betas.js';
|
||
import Badge from './primitives/Badge.svelte';
|
||
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
|
||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||
import ListFooter from './primitives/ListFooter.svelte';
|
||
import Button from './primitives/Button.svelte';
|
||
import { confirmAction } from '$ui/confirm.js';
|
||
import {
|
||
coverageSummary,
|
||
requirementSummary,
|
||
needsGameTelemetry
|
||
} from '$lib/beta-plan-summary.js';
|
||
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
|
||
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
|
||
let {
|
||
test,
|
||
betaId,
|
||
closed,
|
||
deadline,
|
||
retryBlocked,
|
||
deletedParticipants = false,
|
||
deployments,
|
||
busy = $bindable(false),
|
||
onUpdate
|
||
}: {
|
||
test: BetaTestView;
|
||
betaId: number;
|
||
closed: boolean;
|
||
deadline?: string;
|
||
retryBlocked?: string;
|
||
deletedParticipants?: boolean;
|
||
deployments?: BetaDeploymentServer[];
|
||
busy?: boolean;
|
||
onUpdate: () => Promise<void>;
|
||
} = $props();
|
||
let notes = $state<Record<string, string>>({});
|
||
let evidenceOpen = $state(false),
|
||
evidenceLimit = $state(25),
|
||
error = $state(''),
|
||
message = $state('');
|
||
let evidenceServer = $state(''),
|
||
evidenceKind = $state(''),
|
||
evidenceQuery = $state('');
|
||
let evidenceSection = $state<HTMLDetailsElement>();
|
||
const editable = $derived(canOperate($page.data?.user?.role));
|
||
const hasDeletedServers = $derived(
|
||
deletedParticipants ||
|
||
test.servers.some((server) => server.server_deleted_at) ||
|
||
deployments?.some((server) => server.server_deleted_at)
|
||
);
|
||
const canSign = $derived(
|
||
editable && !closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome)
|
||
);
|
||
const expired = $derived(
|
||
test.outcome === 'inconclusive' ||
|
||
(!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now())
|
||
);
|
||
const ranked = $derived(
|
||
[...test.servers].sort(
|
||
(a, b) =>
|
||
(a.progress.some((r) => r.failed) ? 0 : a.passed ? 2 : 1) -
|
||
(b.progress.some((r) => r.failed) ? 0 : b.passed ? 2 : 1) ||
|
||
a.server.localeCompare(b.server)
|
||
)
|
||
);
|
||
let expanded = $state<Record<string, boolean>>({});
|
||
const combined = $derived.by(() => {
|
||
const rows = [...(deployments ?? [])];
|
||
for (const server of test.servers) {
|
||
if (!rows.some((row) => row.server === server.server))
|
||
rows.push({
|
||
server: server.server,
|
||
server_deleted_at: server.server_deleted_at,
|
||
stage: 'unknown',
|
||
error: null,
|
||
deployed_at: null,
|
||
restarted_at: null,
|
||
healthy_at: null,
|
||
match_seen_at: null,
|
||
provisioning_id: null
|
||
});
|
||
}
|
||
return rows
|
||
.map((deployment) => {
|
||
const result = test.servers.find((server) => server.server === deployment.server);
|
||
const deletedAt = deployment.server_deleted_at ?? result?.server_deleted_at ?? null;
|
||
return {
|
||
deployment: { ...deployment, server_deleted_at: deletedAt },
|
||
result: result ? { ...result, server_deleted_at: deletedAt } : undefined
|
||
};
|
||
})
|
||
.sort(
|
||
(a, b) =>
|
||
rank(a) - rank(b) || a.deployment.server.localeCompare(b.deployment.server)
|
||
);
|
||
});
|
||
function rank(row: {
|
||
deployment: BetaDeploymentServer;
|
||
result?: BetaTestView['servers'][number];
|
||
}) {
|
||
return row.deployment.stage === 'failed' ||
|
||
row.result?.progress.some((check) => check.failed)
|
||
? 0
|
||
: row.result?.passed
|
||
? 2
|
||
: 1;
|
||
}
|
||
const filteredEvidence = $derived(
|
||
test.evidence.filter(
|
||
(event) =>
|
||
(!evidenceServer || event.server === evidenceServer) &&
|
||
(!evidenceKind ||
|
||
(evidenceKind === 'gameplay'
|
||
? ['game_start', 'round_change', 'heartbeat', 'game_end'].includes(
|
||
event.kind
|
||
)
|
||
: evidenceKind === 'health'
|
||
? ['health', 'status-sample'].includes(event.kind)
|
||
: event.kind === evidenceKind)) &&
|
||
(!evidenceQuery ||
|
||
`${betaEvidenceSummary(event)} ${event.payload}`
|
||
.toLowerCase()
|
||
.includes(evidenceQuery.toLowerCase()))
|
||
)
|
||
);
|
||
$effect(() => {
|
||
void evidenceServer;
|
||
void evidenceKind;
|
||
void evidenceQuery;
|
||
evidenceLimit = 25;
|
||
});
|
||
export async function action(action: string, extra: Record<string, string> = {}) {
|
||
if (!editable) return;
|
||
if (
|
||
action === 'retry' &&
|
||
!(await confirmAction({
|
||
title: 'Start a fresh test attempt?',
|
||
message: `Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,
|
||
confirmLabel: 'Reset and restart testing'
|
||
}))
|
||
)
|
||
return;
|
||
busy = true;
|
||
error = '';
|
||
message = '';
|
||
try {
|
||
const response = await fetch(`/api/betas/${betaId}/test`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action, ...extra })
|
||
}),
|
||
body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
|
||
await onUpdate();
|
||
message =
|
||
action === 'sign-off'
|
||
? 'Observation recorded.'
|
||
: action === 'retry'
|
||
? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.'
|
||
: action === 'pause'
|
||
? 'Testing paused. The original deadline still applies.'
|
||
: 'Testing resumed. Continuous timing checks restart from fresh observations.';
|
||
} catch (e) {
|
||
error = (e as Error).message;
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
async function showEvidence(server: string, requirement: BetaRequirement) {
|
||
evidenceServer = server;
|
||
evidenceKind = needsGameTelemetry(requirement)
|
||
? 'gameplay'
|
||
: requirement.kind === 'manual'
|
||
? 'manual-sign-off'
|
||
: ['failure-budget', 'healthy-minutes'].includes(requirement.kind)
|
||
? 'health'
|
||
: '';
|
||
evidenceQuery = '';
|
||
evidenceOpen = true;
|
||
await tick();
|
||
evidenceSection?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||
evidenceSection?.querySelector('summary')?.focus();
|
||
}
|
||
</script>
|
||
|
||
<section aria-label="Test plan progress">
|
||
<div class="heading">
|
||
<h2>{deployments ? 'Test servers' : 'Test results'}</h2>
|
||
{#if !deployments}<StatusBadge state={test.outcome} />{/if}
|
||
</div>
|
||
{#if !closed && !deployments}
|
||
{#if editable && !hasDeletedServers}<div class="actions">
|
||
{#if test.outcome === 'paused' && !expired}<Button
|
||
size="sm"
|
||
disabled={busy}
|
||
onclick={() => action('resume')}>Resume testing</Button
|
||
>{/if}
|
||
{#if test.outcome === 'active'}<Button
|
||
size="sm"
|
||
disabled={busy}
|
||
onclick={() => action('pause')}>Pause testing</Button
|
||
>{/if}
|
||
{#if ['paused', 'failed'].includes(test.outcome) && !expired}<Button
|
||
size="sm"
|
||
disabled={busy || !!retryBlocked}
|
||
onclick={() => action('retry')}>Start fresh attempt</Button
|
||
>{/if}
|
||
</div>{/if}
|
||
{#if retryBlocked && ['paused', 'failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
|
||
{#if expired}<p class="guidance">
|
||
The testing window ended. Use “Update beta” to test changed files in a fresh
|
||
revision, or roll back and reuse the plan with a longer window.
|
||
</p>
|
||
{:else if test.outcome === 'paused' && !hasDeletedServers}<p class="guidance">
|
||
Resume retains completed milestones; continuous health and in-progress timing
|
||
intervals start fresh. Pausing does not extend the deadline.
|
||
</p>{/if}
|
||
{/if}
|
||
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
||
{#if message}<p role="status">{message}</p>{/if}
|
||
{#if !test.servers.length}<p>
|
||
{closed
|
||
? 'Test results were not recorded.'
|
||
: hasDeletedServers
|
||
? 'No test run was recorded before a participating server was deleted.'
|
||
: 'Awaiting the first test run.'}
|
||
</p>{/if}
|
||
{#if deployments}
|
||
<div class="table-scroll">
|
||
<table>
|
||
<thead
|
||
><tr
|
||
><th scope="col">Server</th><th scope="col">Deployment</th><th scope="col"
|
||
>Test result</th
|
||
><th scope="col" class="align-right">Details</th></tr
|
||
></thead
|
||
>
|
||
<tbody
|
||
>{#each combined as { deployment, result } (deployment.server)}
|
||
{@const failed =
|
||
deployment.stage === 'failed' ||
|
||
result?.progress.some((check) => check.failed)}
|
||
{@const open = expanded[deployment.server] ?? Boolean(failed)}
|
||
<tr>
|
||
<td
|
||
><HistoricalServerIdentity
|
||
name={deployment.server}
|
||
deletedAt={deployment.server_deleted_at}
|
||
href={contextHref(
|
||
`/servers/${encodeURIComponent(deployment.server)}`,
|
||
$page.url
|
||
)}
|
||
/></td
|
||
>
|
||
<td
|
||
><StatusBadge
|
||
state={deployment.stage === 'observing'
|
||
? 'ready'
|
||
: deployment.stage}
|
||
/></td
|
||
>
|
||
<td
|
||
>{#if result?.passed}<Badge variant="online">Passed</Badge>
|
||
{:else if result?.progress.some((check) => check.failed)}<Badge
|
||
variant="danger">Failed</Badge
|
||
>
|
||
{:else if !result}<span
|
||
>{closed || deployment.server_deleted_at
|
||
? 'Not recorded'
|
||
: 'Awaiting test run'}</span
|
||
>
|
||
{:else}<Badge variant="neutral"
|
||
>{closed
|
||
? 'Incomplete'
|
||
: test.outcome === 'paused'
|
||
? 'Paused'
|
||
: 'Waiting'}</Badge
|
||
>{/if}
|
||
</td>
|
||
<td class="align-right"
|
||
><button
|
||
class="disclosure"
|
||
aria-expanded={open}
|
||
aria-controls={`beta-server-${betaId}-${deployment.server}`}
|
||
aria-label={`${open ? 'Hide' : 'Show'} details for ${deployment.server}`}
|
||
onclick={() =>
|
||
(expanded = { ...expanded, [deployment.server]: !open })}
|
||
>{open ? 'Hide' : 'Details'}
|
||
<span aria-hidden="true">{open ? '−' : '+'}</span></button
|
||
></td
|
||
>
|
||
</tr>
|
||
<tr
|
||
hidden={!open}
|
||
id={`beta-server-${betaId}-${deployment.server}`}
|
||
class="server-detail"
|
||
><td colspan="4">
|
||
{#if deployment.error}<p
|
||
class:failed={deployment.stage === 'failed'}
|
||
>
|
||
{deployment.error}
|
||
</p>{/if}
|
||
<div class="deployment-details">
|
||
<span
|
||
>Files: {deployment.deployed_at
|
||
? 'installed'
|
||
: 'pending'}</span
|
||
><span
|
||
>Restart: {deployment.restarted_at
|
||
? 'submitted'
|
||
: 'pending'}</span
|
||
><span
|
||
>Health: {deployment.healthy_at
|
||
? 'verified'
|
||
: 'not recorded'}</span
|
||
>
|
||
{#if deployment.match_seen_at}<span
|
||
>Match observed {$timeFormat.dateTime(
|
||
deployment.match_seen_at
|
||
)}</span
|
||
>{/if}
|
||
</div>
|
||
<div class="deployment-links">
|
||
{#if !deployment.server_deleted_at}<a
|
||
href={contextHref(
|
||
`/servers/${encodeURIComponent(deployment.server)}?tab=diagnostics`,
|
||
$page.url
|
||
)}>Diagnostics</a
|
||
><a
|
||
href={contextHref(
|
||
`/servers/${encodeURIComponent(deployment.server)}?tab=logs`,
|
||
$page.url
|
||
)}>Open logs</a
|
||
>{/if}
|
||
{#if deployment.provisioning_id}<a
|
||
href={contextHref(
|
||
`/provisioning/${encodeURIComponent(deployment.provisioning_id)}`,
|
||
$page.url
|
||
)}>View provisioning</a
|
||
>{/if}
|
||
</div>
|
||
{#if result}
|
||
{#if result.progress.length}<p>
|
||
{result.progress.filter((check) => check.passed).length} of
|
||
{result.progress.length} checks passed{test.plan.requirements.some(
|
||
(requirement) => requirement.kind === 'recovery'
|
||
)
|
||
? ` · Recovery cycle ${result.phase}`
|
||
: ''}
|
||
</p>{/if}
|
||
{@render checks(result)}
|
||
{:else}<p>
|
||
{closed || deployment.server_deleted_at
|
||
? 'No test results were recorded for this server.'
|
||
: 'Checks become available after the server restarts.'}
|
||
</p>{/if}
|
||
</td></tr
|
||
>
|
||
{/each}</tbody
|
||
>
|
||
</table>
|
||
</div>
|
||
{:else}
|
||
{#each ranked as server (server.server)}
|
||
<details class="server" open={!server.passed}>
|
||
<summary
|
||
><HistoricalServerIdentity
|
||
name={server.server}
|
||
deletedAt={server.server_deleted_at}
|
||
/><span
|
||
>{server.progress.filter((p) => p.passed).length} / {server.progress.length} checks
|
||
passed</span
|
||
>{#if test.plan.requirements.some((r) => r.kind === 'recovery')}<span
|
||
>Recovery cycles: {server.phase}</span
|
||
>{/if}</summary
|
||
>
|
||
{@render checks(server)}
|
||
</details>
|
||
{/each}
|
||
{/if}
|
||
<details>
|
||
<summary>Test policy</summary>
|
||
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
|
||
<p>
|
||
{test.plan.waveSize
|
||
? `${test.plan.waveSize} servers per wave.`
|
||
: 'All selected servers deployed together.'}
|
||
{test.plan.failureAction === 'rollback'
|
||
? 'Failed checks trigger automatic rollback.'
|
||
: test.plan.failureAction === 'pause'
|
||
? 'Failed checks pause testing for investigation.'
|
||
: 'Failed checks hold testing for review.'}
|
||
{test.plan.rollbackRestart === 'when-empty'
|
||
? 'Rollback restarts wait for empty servers.'
|
||
: 'Rollback changes wait until the rollback campaign is resumed.'} Health checks continue
|
||
after passing until promotion.
|
||
</p>
|
||
</details>
|
||
<details bind:this={evidenceSection} bind:open={evidenceOpen} class="evidence-section">
|
||
<summary
|
||
>Evidence ({test.evidence.length}{test.evidence.length === 200
|
||
? ' most recent'
|
||
: ''})</summary
|
||
>
|
||
{#if evidenceOpen}
|
||
<p>
|
||
Newest first. Includes retained evidence from earlier attempts and recovery cycles;
|
||
current progress uses only the current attempt.
|
||
</p>
|
||
<div class="filters">
|
||
<Field label="Server">
|
||
<Select bind:value={evidenceServer}
|
||
><option value="">All servers</option
|
||
>{#each [...new Set(test.evidence.map((event) => event.server))] as server}<option
|
||
value={server}>{serverTitle(server)}</option
|
||
>{/each}</Select
|
||
></Field
|
||
><Field label="Event type">
|
||
<Select bind:value={evidenceKind}
|
||
><option value="">All events</option><option value="gameplay"
|
||
>Gameplay reports</option
|
||
><option value="health">Health observations</option><option
|
||
value="manual-sign-off">Tester sign-offs</option
|
||
></Select
|
||
></Field
|
||
><Field label="Search evidence">
|
||
<Input
|
||
type="search"
|
||
bind:value={evidenceQuery}
|
||
placeholder="Round, map, tester, observation…"
|
||
/></Field
|
||
>
|
||
</div>
|
||
{#each filteredEvidence.slice(0, evidenceLimit) as event (event.id)}<article
|
||
class="evidence"
|
||
>
|
||
<div class="heading">
|
||
<HistoricalServerIdentity
|
||
name={event.server}
|
||
deletedAt={event.server_deleted_at}
|
||
/><span class="time"><RelativeTime value={event.received_at} /></span>
|
||
</div>
|
||
<p>{betaEvidenceSummary(event)}</p>
|
||
<details>
|
||
<summary>Raw payload · {event.kind}</summary>
|
||
<pre>{event.payload}</pre>
|
||
</details>
|
||
</article>{/each}
|
||
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events" />
|
||
{#if !filteredEvidence.length}<p>
|
||
{test.evidence.length
|
||
? 'No evidence matches these filters.'
|
||
: 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}
|
||
</p>{/if}
|
||
{/if}
|
||
</details>
|
||
</section>
|
||
|
||
{#snippet checks(server: BetaTestView['servers'][number])}
|
||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">
|
||
{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll(
|
||
'_',
|
||
' '
|
||
)} ·
|
||
<span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span>
|
||
· {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null}
|
||
· Round {server.observation.round}{/if}{:else}No gameplay reports in this
|
||
attempt yet.{/if}
|
||
{#if !server.server_deleted_at}<a
|
||
href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}
|
||
>Telemetry diagnostics</a
|
||
>{/if}
|
||
</p>{/if}
|
||
{#each server.progress as progress (progress.id)}
|
||
{@const requirement = test.plan.requirements.find((r) => r.id === progress.id)}
|
||
{#if requirement}<div class="requirement" class:failed={progress.failed}>
|
||
<div class="heading">
|
||
<strong
|
||
><span class="result"
|
||
>{progress.passed
|
||
? 'Passed'
|
||
: progress.failed
|
||
? 'Failed'
|
||
: 'Waiting'}</span
|
||
>
|
||
{requirementSummary(requirement)}</strong
|
||
><span>{progress.detail}</span>
|
||
</div>
|
||
{#if !progress.passed && !closed && !hasDeletedServers}<p>
|
||
{betaWaitingCondition(requirement, server.observation)}
|
||
</p>{/if}
|
||
{#if !['manual', 'failure-budget'].includes(requirement.kind)}<progress
|
||
aria-label={requirementSummary(requirement)}
|
||
value={Math.min(progress.current, progress.target)}
|
||
max={progress.target}
|
||
></progress>{/if}
|
||
{#if editable && requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
|
||
{#if canSign && server.observingAt}<div class="signoff">
|
||
<label
|
||
>Tester name and observation<textarea
|
||
aria-label={`Tester and observation for ${progress.label} on ${server.server}`}
|
||
rows="2"
|
||
maxlength="1000"
|
||
bind:value={notes[`${server.server}:${progress.id}`]}
|
||
placeholder="Name · what you checked and observed"
|
||
></textarea></label
|
||
><Button
|
||
size="sm"
|
||
disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()}
|
||
onclick={() =>
|
||
action('sign-off', {
|
||
server: server.server,
|
||
requirementId: progress.id,
|
||
note: notes[`${server.server}:${progress.id}`]
|
||
})}>Sign off</Button
|
||
>
|
||
</div>
|
||
{:else if canSign}<p>
|
||
Waiting for this server's restart and health verification to finish
|
||
before tester sign-off.
|
||
</p>
|
||
{:else}<p>
|
||
Sign-off is unavailable while this test is {test.outcome}. {test.outcome ===
|
||
'paused'
|
||
? 'Resume testing first.'
|
||
: 'Use the recovery instructions above.'}
|
||
</p>{/if}
|
||
{/if}
|
||
<button
|
||
class="evidence-link"
|
||
onclick={() => showEvidence(server.server, requirement)}
|
||
>View relevant evidence</button
|
||
>
|
||
</div>{/if}
|
||
{/each}
|
||
{/snippet}
|
||
|
||
<style>
|
||
.table-scroll {
|
||
overflow-x: auto;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: var(--text-sm);
|
||
text-align: left;
|
||
}
|
||
th {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--border-strong);
|
||
color: var(--text-tertiary);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--weight-medium);
|
||
}
|
||
td {
|
||
padding: 16px 12px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
.align-right {
|
||
text-align: right;
|
||
}
|
||
.disclosure {
|
||
background: transparent;
|
||
border: 0;
|
||
padding: 8px 0;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
white-space: nowrap;
|
||
}
|
||
.server-detail {
|
||
background: var(--surface);
|
||
}
|
||
.deployment-details,
|
||
.deployment-links {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 20px;
|
||
margin: 12px 0;
|
||
font-size: var(--text-sm);
|
||
}
|
||
@media (max-width: 720px) {
|
||
table {
|
||
min-width: 620px;
|
||
}
|
||
}
|
||
section {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
h2,
|
||
p {
|
||
margin: 0;
|
||
}
|
||
h2 {
|
||
font-size: var(--text-md);
|
||
}
|
||
p,
|
||
span,
|
||
.time {
|
||
font-size: var(--text-sm);
|
||
line-height: 1.6;
|
||
color: var(--text-secondary);
|
||
}
|
||
a {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.heading,
|
||
.actions,
|
||
.filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.heading {
|
||
justify-content: space-between;
|
||
}
|
||
.server {
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
padding: 16px;
|
||
}
|
||
.server > summary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 16px;
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
font-size: var(--text-base);
|
||
}
|
||
.server > summary::before {
|
||
content: '▸';
|
||
}
|
||
.server[open] > summary::before {
|
||
content: '▾';
|
||
}
|
||
.requirement {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
border-top: 1px solid var(--border-subtle);
|
||
padding-top: 16px;
|
||
}
|
||
.result {
|
||
font-weight: 600;
|
||
margin-right: 6px;
|
||
color: inherit;
|
||
}
|
||
progress {
|
||
width: 100%;
|
||
height: 8px;
|
||
accent-color: var(--state-online);
|
||
}
|
||
.failed {
|
||
color: var(--state-danger);
|
||
}
|
||
.observation,
|
||
.guidance {
|
||
margin-top: 12px;
|
||
}
|
||
.evidence-link {
|
||
justify-self: start;
|
||
padding: 6px 0;
|
||
color: var(--accent-primary-hover);
|
||
border: 0;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: var(--text-xs);
|
||
}
|
||
.signoff {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: end;
|
||
flex-wrap: wrap;
|
||
}
|
||
.signoff label {
|
||
flex: 1 1 240px;
|
||
}
|
||
.signoff :global(.field) {
|
||
flex: 1 1 240px;
|
||
}
|
||
label {
|
||
display: grid;
|
||
gap: 6px;
|
||
font-size: var(--text-sm);
|
||
color: var(--text-secondary);
|
||
}
|
||
textarea {
|
||
min-height: 40px;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border-control);
|
||
border-radius: 6px;
|
||
background: var(--surface-sunken);
|
||
color: var(--text-primary);
|
||
font: inherit;
|
||
max-width: 100%;
|
||
}
|
||
textarea {
|
||
resize: vertical;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.filters {
|
||
margin: 16px 0;
|
||
align-items: end;
|
||
}
|
||
.filters label {
|
||
flex: 1 1 180px;
|
||
min-width: 0;
|
||
}
|
||
.filters :global(.field) {
|
||
flex: 1 1 180px;
|
||
min-width: 0;
|
||
}
|
||
.evidence {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 16px 0;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
.evidence-section {
|
||
scroll-margin-top: 80px;
|
||
}
|
||
pre {
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
font-size: var(--text-xs);
|
||
}
|
||
.evidence-section > p {
|
||
margin-top: 12px;
|
||
}
|
||
</style>
|