Files
electric-cherry/src/lib/ui/components/DeployCollectionDialog.svelte
T

951 lines
36 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import NativeHostSelection from './NativeHostSelection.svelte';
import {
createCollectionLaunchController,
type CollectionLaunchState
} from '$ui/collection-launch.js';
import {
collectionMapGroups,
collectionKeyCoverage,
collectionCapacitySummary
} from '$ui/collection-summary.js';
import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import type { BlueprintSummary, CollectionMode } from '$lib/deployment-plan.js';
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } =
$props();
const id = $props.id();
let mode = $state<CollectionMode>('fill');
let modeInput = $state<HTMLInputElement>();
let games = $state<ZombieGame[]>([]);
let collectionName = $state('Launch collection');
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let hostReady = $state(false);
let hostStatus = $state<NativeHostSelectionStatus>({
loading: true,
error: '',
hasHosts: false,
filesReady: false,
missingGames: [],
uploadedAt: null
});
let confirmRemovals = $state(false);
let view = $state<CollectionLaunchState>({
preview: null,
checking: false,
error: '',
changeNotice: ''
});
let settledId: string | undefined;
const controller = createCollectionLaunchController((state) => {
if (state.checking || state.preview?.id !== settledId) confirmRemovals = false;
settledId = state.preview?.id;
view = state;
});
let loaded = $state(false);
let loadingError = $state('');
let launchError = $state('');
let launching = $state(false);
let unconfirmedLaunch = $state(false);
let refreshVersion = $state(0);
let now = $state(Date.now());
let wasOpen = false;
let activeBlueprint = '';
let visit = 0;
let initialization: AbortController | undefined;
let submission: AbortController | undefined;
const preview = $derived(view.preview);
const mapGroups = $derived(preview ? collectionMapGroups(preview) : []);
const keyCoverage = $derived(preview ? collectionKeyCoverage(preview) : []);
const missingKeys = $derived(
keyCoverage.filter((pool) => pool.available < pool.required - pool.unassigned)
);
const hasChanges = $derived(
!!preview && (preview.servers.length > 0 || preview.removals.length > 0)
);
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
const checking = $derived(
(!loaded && !loadingError) || (loaded && (hostStatus.loading || view.checking))
);
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
const currentReady = $derived(
!!preview &&
preview.mode === mode &&
hostReady &&
!hostStatus.loading &&
!view.checking &&
!expired
);
const noChanges = $derived(currentReady && preview?.ready && !hasChanges);
const canLaunch = $derived(
!launching &&
!!preview &&
(unconfirmedLaunch ||
(currentReady &&
preview.ready &&
hasChanges &&
preview.servers.every((server) => server.name && server.hasSavedKey) &&
(!preview.removals.length || confirmRemovals)))
);
const footerStatus = $derived(
launching
? 'Queueing changes…'
: unconfirmedLaunch
? 'Retry to confirm this launch.'
: loadingError
? 'Could not load collection.'
: checking
? 'Checking fleet changes…'
: noChanges
? 'Collection targets met'
: missingKeys.length
? 'More game keys needed.'
: currentReady &&
preview?.ready &&
preview.removals.length &&
!confirmRemovals
? 'Confirm the listed removals.'
: canLaunch
? 'Ready to queue'
: 'Launch unavailable'
);
const actionLabel = $derived(
unconfirmedLaunch
? 'Retry launch'
: preview?.removals.length
? `Create ${preview.servers.length} · Remove ${preview.removals.length}`
: `Queue ${preview?.servers.length ?? 0} server${preview?.servers.length === 1 ? '' : 's'}`
);
$effect(() => {
if (open && (!wasOpen || activeBlueprint !== blueprintId)) {
wasOpen = true;
activeBlueprint = blueprintId;
void initialize();
} else if (!open && wasOpen) {
wasOpen = false;
visit++;
initialization?.abort();
controller.clear();
}
});
$effect(() => {
const input =
open && loaded && hostReady && !hostStatus.loading
? {
blueprintId,
mode,
image: selection.image,
libraryIds: Object.fromEntries(
games.map((game) => [game, selection.libraryIds[game]])
)
}
: null;
if (!launching && !unconfirmedLaunch) controller.select(input);
});
async function initialize() {
const current = ++visit;
initialization?.abort();
initialization = new AbortController();
const signal = initialization.signal;
controller.clear();
loaded = false;
loadingError = '';
launchError = '';
launching = false;
unconfirmedLaunch = false;
mode = 'fill';
games = [];
collectionName = 'Launch collection';
confirmRemovals = false;
selection = { libraryIds: {}, image: '' };
hostReady = false;
hostStatus = {
loading: true,
error: '',
hasHosts: false,
filesReady: false,
missingGames: [],
uploadedAt: null
};
try {
const response = await fetch('/api/blueprints', { signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
if (current !== visit) return;
const blueprint = (body.blueprints as BlueprintSummary[]).find(
(item) => item.id === blueprintId
);
if (!blueprint) throw new Error('This collection is no longer available.');
games = [...new Set(blueprint.entries.map((entry) => entry.game))];
collectionName = blueprint.name;
loaded = true;
await tick();
if (current === visit && open) modeInput?.focus();
} catch (error) {
if (current === visit && !signal.aborted)
loadingError =
error instanceof Error ? error.message : 'Could not load collection.';
}
}
function selectionChanged() {
launchError = '';
confirmRemovals = false;
}
function refreshChecks() {
if (!open || launching || unconfirmedLaunch) return;
if (!loaded) {
void initialize();
return;
}
launchError = '';
confirmRemovals = false;
controller.select(null);
refreshVersion++;
}
onMount(() => {
let returning: ReturnType<typeof setTimeout>;
const refreshOnReturn = () => {
clearTimeout(returning);
if (document.visibilityState !== 'hidden') returning = setTimeout(refreshChecks, 100);
};
window.addEventListener('focus', refreshOnReturn);
document.addEventListener('visibilitychange', refreshOnReturn);
const timer = setInterval(() => {
now = Date.now();
}, 1000);
return () => {
clearTimeout(returning);
clearInterval(timer);
window.removeEventListener('focus', refreshOnReturn);
document.removeEventListener('visibilitychange', refreshOnReturn);
};
});
onDestroy(() => {
visit++;
initialization?.abort();
submission?.abort();
controller.dispose();
});
async function launch() {
if (!canLaunch || !preview) return;
const current = visit;
const reviewed = preview;
controller.suspend();
launching = true;
launchError = '';
submission = new AbortController();
try {
const response = await fetch('/api/deployment-collections', {
method: 'POST',
signal: submission.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ previewId: reviewed.id })
});
const body = await response.json();
if (current !== visit) return;
if (!response.ok) {
unconfirmedLaunch = response.status >= 500;
launchError = body.error ?? 'Could not queue this collection.';
if (!unconfirmedLaunch) {
controller.select(null);
refreshVersion++;
}
return;
}
if (!body.ok) throw new Error('The launch response could not be confirmed.');
open = false;
if (body.collectionId)
await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
} catch {
if (current === visit) {
// The queue may already be committed. Keep its preview ID for an idempotent retry.
unconfirmedLaunch = true;
launchError =
'Could not confirm the launch. Retry the same request, or check collection launches.';
}
} finally {
if (current === visit) launching = false;
}
}
</script>
<Dialog bind:open title={collectionName} description="Launch collection" wide busy={launching}>
<div class="launch-content">
{#if loadingError}
<p class="issue" role="alert">{loadingError}</p>
<button class="text-button" onclick={refreshChecks}>Retry checks</button>
{:else if !loaded}
<p class="muted" role="status">Loading collection…</p>
{:else}
<div class="mode-selection">
<fieldset disabled={launching || unconfirmedLaunch}>
<legend class="sr-only">Apply collection</legend>
<label class:selected={mode === 'fill'}
><input
bind:this={modeInput}
type="radio"
name={`${id}-collection-mode`}
value="fill"
bind:group={mode}
onchange={selectionChanged}
/>Fill missing</label
>
<label class:selected={mode === 'exact'}
><input
type="radio"
name={`${id}-collection-mode`}
value="exact"
bind:group={mode}
onchange={selectionChanged}
/>Match exactly</label
>
</fieldset>
<p class="muted">
{mode === 'exact'
? 'Match the entire fleet across both games. Excess lobbies and maps outside this collection will be removed.'
: 'Create missing lobbies. All existing servers and their settings stay as they are.'}
</p>
</div>
<section class="change-summary" aria-label="Fleet changes" aria-busy={checking}>
{#if preview}
<strong
>{noChanges
? 'Collection targets met'
: `${preview.servers.length} to create · ${preview.removals.length} to remove`}</strong
>
{#if mode === 'exact' && preview.retainedServers.length}<p class="muted">
{preview.retainedServers.length} existing or queued servers kept.
</p>{/if}
<div class="readiness">
{#each keyCoverage as pool}<span
class:ready={pool.available >= pool.required}
class:warning={pool.available < pool.required}
>{pool.game.toUpperCase()} keys: {pool.unassigned
? 'waiting for lobby availability'
: `${pool.available} of ${pool.required} required available`}</span
>{/each}
{#if preview.servers.length}<span class:ready={hostStatus.filesReady}
>{hostStatus.filesReady
? 'Game files ready'
: 'Game files preparing'}</span
>{/if}
</div>
{#if preview.servers.length}<p class="muted">
{collectionCapacitySummary(preview.availability)}
</p>{/if}
{#if noChanges}<p class="muted">
{mode === 'exact'
? 'Your fleet already matches this collection.'
: 'Your fleet meets or exceeds every target.'} Existing servers keep their
current settings.
</p>{/if}
{:else}<p role="status">
{checking
? 'Checking fleet changes…'
: 'Resolve the issues below to check this collection.'}
</p>{/if}
</section>
{#if launchError}<div class="issue" role="alert">
<p>{launchError}</p>
{#if unconfirmedLaunch}<a
href="/collections?view=active"
target="_blank"
rel="noopener">View collection launches ↗</a
>{/if}
</div>{/if}
{#if view.changeNotice}<p class="issue" role="status">{view.changeNotice}</p>{/if}
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div
class="issue"
role="status"
>
<p>
Upload {hostStatus.missingGames
.map((game) => game.toUpperCase())
.join(' and ')} game files before launching.
</p>
<a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a>
</div>
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">
Enter a valid runtime image in Advanced settings.
</p>
{:else if view.error}<p class="issue" role="alert">{view.error}</p>{/if}
{#if missingKeys.length}<div class="issue" role="status">
{#each missingKeys as pool}{@const shortfall =
pool.required - pool.unassigned - pool.available}
<p>
{pool.game.toUpperCase()} needs {shortfall} more unused key{shortfall ===
1
? ''
: 's'}.
<a
href={`/server-keys?game=${pool.game}`}
target="_blank"
rel="noopener">Manage keys ↗</a
>
</p>{/each}
{#if preview?.removals.length}<p>
Keys assigned to removals stay in use until cleanup finishes. Complete
those removals first to reuse their keys.
</p>{/if}
</div>{/if}
{#each preview?.blockers ?? [] as blocker}<p class="issue" role="alert">
{blocker}
</p>{/each}
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKeys.length || preview?.blockers.length)}<button
class="text-button"
disabled={launching}
onclick={refreshChecks}>Retry checks</button
>{/if}
{#if preview}
<section class="lineup" aria-label="Collection map targets">
{#each mapGroups as group}
<table>
<caption>{group.label}</caption>
<thead
><tr
><th scope="col">Map</th><th scope="col">Target</th><th
scope="col">Existing / queued</th
><th scope="col">Create</th>{#if mode === 'exact'}<th
scope="col">Remove</th
>{/if}</tr
></thead
>
<tbody
>{#each group.rows as row}<tr
><th scope="row">{row.mapName}</th><td>{row.target}</td><td
>{row.existing}</td
><td class:quantity={row.create > 0}>{row.create}</td
>{#if mode === 'exact'}<td class:danger={row.remove > 0}
>{row.remove}</td
>{/if}</tr
>{/each}</tbody
>
</table>
{/each}
<p class="muted">
Stopped and unhealthy servers also count toward targets. See Server details
for health.{#if mode === 'exact'}
Existing totals include the listed removals.{/if}
</p>
</section>
{#if preview.removals.length}<section
class="removals"
aria-label="Servers to remove"
>
<h3>
Destroy {preview.removals.length} server{preview.removals.length === 1
? ''
: 's'}
</h3>
<p>
Containers, files, and registrations will be removed. Occupied or
unreachable servers wait for a fresh empty-player check; unstarted
creations are cancelled.
</p>
<ul>
{#each preview.removals as server}<li>
<strong>{serverTitle(server.name)}</strong><code
>{server.name}</code
><span>{server.reason}</span>
</li>{/each}
</ul>
<label class="confirmation"
><input
type="checkbox"
bind:checked={confirmRemovals}
disabled={launching || unconfirmedLaunch || !currentReady}
/>Destroy the {preview.removals.length} listed servers and apply this collection.</label
>
</section>{/if}
<details class="details">
<summary>Server details</summary>
<div class="detail-body">
{#if preview.servers.length}<h3>Launch order</h3>
<p class="muted">
Hosts and ports are assigned when provisioning starts.
</p>
<ol class="server-list">
{#each preview.servers as server}<li>
<strong
>{server.name
? serverTitle(server.name)
: preview.entries[server.entryIndex].recipe
.mapName}</strong
><code>{server.name ?? 'No available lobby'}</code>
</li>{/each}
</ol>
{/if}
{#if preview.entries.some((entry) => entry.existing)}<h3>
Existing and queued servers
</h3>
<ul class="existing-list">
{#each preview.entries as entry}{#each entry.coverage ?? [] as server}<li
>
<a href={server.href} target="_blank" rel="noopener"
>{serverTitle(server.name)} ↗</a
><span
>{server.health === 'online'
? 'Healthy'
: server.health}{#if mode === 'exact'}
· {preview.retainedServers.includes(server.name)
? 'Keep'
: 'Remove'}{/if}</span
>
</li>{/each}{/each}
</ul>
{/if}
{#if !preview.servers.length && !preview.entries.some((entry) => entry.existing)}<p
class="muted"
>
No new or matching servers.
</p>{/if}
</div>
</details>
{#if preview.availability && preview.servers.length}<details class="details">
<summary>Capacity details</summary>
<div class="detail-body">
<dl class="capacity">
<div>
<dt>CPU available</dt>
<dd>
{preview.availability.cpuCoresAvailable === null
? 'Unknown'
: `${preview.availability.cpuCoresAvailable.toFixed(1)} cores`}
</dd>
</div>
<div>
<dt>Memory available</dt>
<dd>
{preview.availability.memoryBytesAvailable === null
? 'Unknown'
: `${(preview.availability.memoryBytesAvailable / 1024 ** 3).toFixed(1)} GiB`}
</dd>
</div>
<div>
<dt>Ports available</dt>
<dd>{preview.availability.portsAvailable ?? 'Unknown'}</dd>
</div>
<div>
<dt>Startup slots</dt>
<dd>
{preview.availability.startupSlots ??
'Unknown'}{#if preview.availability.startupLimit !== null}
/ {preview.availability.startupLimit}{/if}
</dd>
</div>
</dl>
{#each preview.warnings ?? [] as warning}<p class="muted">
{warning}
</p>{/each}
<p class="muted">
{preview.availability.updatedAt
? `Updated ${$timeFormat.dateTime(preview.availability.updatedAt)}.`
: 'Waiting for host reports.'} Capacity is checked for each server
when it starts.
</p>
<a href="/capacity" target="_blank" rel="noopener"
>View capacity and history ↗</a
>
</div>
</details>{/if}
{/if}
{#if open}<NativeHostSelection
{games}
bind:selection
bind:ready={hostReady}
bind:status={hostStatus}
compact
{refreshVersion}
disabled={launching || unconfirmedLaunch}
onchange={selectionChanged}
/>{/if}
{#if preview}<details class="details">
<summary>Setup details</summary>
<div class="detail-body">
<p class="muted">
New servers use the current production setup when provisioning starts.
Existing servers keep their files and configuration.
</p>
<dl>
<div>
<dt>Runtime image</dt>
<dd><code>{selection.image}</code></dd>
</div>
{#each games as game}{@const files = hostStatus.gameFiles?.[game]}
{#if files?.uploadedAt}<div>
<dt>{game.toUpperCase()} game files uploaded</dt>
<dd>{$timeFormat.dateTime(files.uploadedAt)}</dd>
</div>{/if}
{/each}
</dl>
{#each preview.entries as entry}<details class="map-setup">
<summary
>{entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}</summary
>
<div class="detail-body">
<p>{entry.recipe.configSource}</p>
<h3>{entry.recipe.files.length} managed files</h3>
<ul class="file-list">
{#each entry.recipe.files as file}<li>
<strong>{file.name}</strong><span
>{launchFileVersionLabel(file)} · {file.kind}</span
><code>{file.path}</code>
</li>{/each}
</ul>
</div>
</details>{/each}
</div>
</details>{/if}
{/if}
</div>
{#snippet actions()}
<div class="launch-actions">
<p class:ready={(canLaunch && !unconfirmedLaunch) || noChanges} role="status">
{footerStatus}
</p>
<div>
{#if noChanges}<Button variant="primary" onclick={() => (open = false)}>Done</Button
>
{:else}<Button variant="ghost" disabled={launching} onclick={() => (open = false)}
>Cancel</Button
><Button
variant={preview?.removals.length ? 'danger' : 'primary'}
loading={launching}
disabled={!canLaunch}
onclick={launch}>{actionLabel}</Button
>{/if}
</div>
</div>
{/snippet}
</Dialog>
<style>
.launch-content {
display: grid;
gap: 16px;
min-width: 0;
}
.mode-selection {
display: grid;
gap: 9px;
}
fieldset {
display: flex;
gap: 8px;
border: 0;
padding: 0;
margin: 0;
}
fieldset label {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
}
fieldset label.selected {
background: var(--surface-raised);
border-color: var(--accent-primary);
}
fieldset input {
margin: 0;
}
.change-summary {
display: grid;
gap: 8px;
padding: 14px 16px;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.change-summary > strong {
font-size: var(--text-md);
line-height: 1.4;
}
.readiness {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.readiness:empty {
display: none;
}
p {
margin: 0;
font-size: var(--text-sm);
line-height: 1.5;
}
.muted {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.ready,
.quantity {
color: var(--state-online);
}
.warning {
color: var(--state-warning);
}
.danger {
color: var(--state-danger);
}
.issue {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent);
border-radius: var(--radius-md);
color: var(--state-warning);
font-size: var(--text-sm);
}
a,
.text-button {
color: var(--accent-primary-hover);
font-size: var(--text-sm);
}
.text-button {
justify-self: start;
border: 0;
padding: 0;
background: none;
text-align: left;
cursor: pointer;
}
.lineup {
display: grid;
gap: 10px;
min-width: 0;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: var(--text-xs);
}
caption {
text-align: left;
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 600;
padding-bottom: 8px;
}
th,
td {
padding: 8px 6px;
text-align: center;
border-bottom: 1px solid var(--border-subtle);
overflow-wrap: anywhere;
}
th:first-child {
width: 40%;
text-align: left;
}
thead th {
color: var(--text-secondary);
font-weight: 500;
}
tbody th {
font-weight: 500;
}
td {
font-variant-numeric: tabular-nums;
}
.quantity,
.danger {
font-weight: 600;
}
.details {
border-top: 1px solid var(--border-subtle);
padding-top: 14px;
min-width: 0;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
.detail-body {
display: grid;
gap: 12px;
margin-top: 14px;
min-width: 0;
}
h3 {
font-size: var(--text-sm);
margin: 0;
}
dl {
display: grid;
gap: 14px;
margin: 0;
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
}
dd {
margin: 5px 0 0;
overflow-wrap: anywhere;
font-size: var(--text-sm);
}
code {
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.capacity {
grid-template-columns: repeat(4, 1fr);
}
.server-list {
margin: 0;
padding-left: 24px;
}
.server-list li {
padding: 8px 0;
font-size: var(--text-sm);
}
.server-list code {
display: block;
margin-top: 4px;
color: var(--text-secondary);
}
.existing-list,
.file-list,
.removals ul {
list-style: none;
padding: 0;
margin: 0;
}
.existing-list li {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.existing-list span,
.file-list span {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.file-list li {
display: grid;
gap: 5px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
.map-setup {
padding: 10px 0;
border-top: 1px solid var(--border-subtle);
}
.removals {
display: grid;
gap: 12px;
border: 1px solid var(--state-danger);
border-radius: var(--radius-md);
padding: 14px;
}
.removals h3 {
color: var(--state-danger);
}
.removals p,
.removals li span {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.removals li {
display: grid;
gap: 4px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
.removals code {
color: var(--text-secondary);
}
.confirmation {
display: flex;
align-items: flex-start;
gap: 9px;
font-size: var(--text-sm);
line-height: 1.5;
}
.confirmation input {
margin: 3px 0 0;
flex-shrink: 0;
}
.launch-actions {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.launch-actions > p {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.launch-actions > p.ready {
color: var(--state-online);
}
.launch-actions > div {
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 600px) {
.capacity {
grid-template-columns: repeat(2, 1fr);
}
th:first-child {
width: 34%;
}
th,
td {
padding: 8px 3px;
}
}
@media (max-width: 440px) {
fieldset label {
flex: 1;
padding: 10px;
font-size: var(--text-xs);
}
.launch-actions {
align-items: stretch;
flex-direction: column;
}
.launch-actions > div {
justify-content: flex-end;
}
}
</style>