feat: docker-compose stacks with Forge-themed UI
Build / build (push) Successful in 10m42s

Adds a new Stacks feature: upload/edit docker-compose YAML,
deploy as atomic units, browse revisions, roll back, and
stream logs. Backend in internal/stack + internal/api/stacks.go,
persistent storage in internal/store/stacks.go.

Stacks pages (list, new, detail) use a modern Forge aesthetic —
Instrument Serif display type, JetBrains Mono for meta/code,
indigo ember accents, dot-grid hero, registration marks on
hover, terminal panel for logs. Palette is sourced from the
app's existing design tokens so the feature remains consistent
with the rest of Tinyforge.

Fonts self-hosted via @fontsource/instrument-serif and
@fontsource/jetbrains-mono to satisfy the strict CSP.
This commit is contained in:
2026-04-16 03:48:37 +03:00
parent b622384774
commit 75424a5f25
23 changed files with 3603 additions and 18 deletions
+30
View File
@@ -7,6 +7,10 @@
"": {
"name": "tinyforge-web",
"version": "0.1.0",
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.0",
@@ -435,6 +439,22 @@
"node": ">=18"
}
},
"node_modules/@fontsource/instrument-serif": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz",
"integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2275,6 +2295,16 @@
"dev": true,
"optional": true
},
"@fontsource/instrument-serif": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz",
"integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw=="
},
"@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="
},
"@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+5 -1
View File
@@ -19,5 +19,9 @@
"typescript": "^5.7.0",
"vite": "^6.0.0"
},
"type": "module"
"type": "module",
"dependencies": {
"@fontsource/instrument-serif": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8"
}
}
+5
View File
@@ -1,5 +1,10 @@
@import 'tailwindcss';
@import '$lib/styles/tokens.css';
@import '@fontsource/instrument-serif/400.css';
@import '@fontsource/instrument-serif/400-italic.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/jetbrains-mono/700.css';
/* ── Base Styles ──────────────────────────────────────────────────── */
+76
View File
@@ -771,4 +771,80 @@ export function getStaticSiteStorage(
return get<import('./types').StaticSiteStorageUsage>(`/api/sites/${siteId}/storage`);
}
// ── Stacks (docker-compose) ─────────────────────────────────────────
import type { Stack, StackRevision, StackService } from './types';
export function listStacks(signal?: AbortSignal): Promise<Stack[]> {
return get<Stack[]>('/api/stacks', signal);
}
export function getStack(id: string, signal?: AbortSignal): Promise<Stack> {
return get<Stack>(`/api/stacks/${id}`, signal);
}
export function createStack(data: {
name: string;
description?: string;
yaml: string;
deploy?: boolean;
}): Promise<{ stack: Stack; revision: StackRevision }> {
return post<{ stack: Stack; revision: StackRevision }>('/api/stacks', data);
}
export function updateStack(id: string, data: { name?: string; description?: string }): Promise<Stack> {
return put<Stack>(`/api/stacks/${id}`, data);
}
export function deleteStack(id: string, removeVolumes = false): Promise<{ deleted: string }> {
const qs = removeVolumes ? '?remove_volumes=true' : '';
return del<{ deleted: string }>(`/api/stacks/${id}${qs}`);
}
export function listStackRevisions(id: string, signal?: AbortSignal): Promise<StackRevision[]> {
return get<StackRevision[]>(`/api/stacks/${id}/revisions`, signal);
}
export function getStackRevision(id: string, revId: string): Promise<StackRevision> {
return get<StackRevision>(`/api/stacks/${id}/revisions/${revId}`);
}
export function createStackRevision(id: string, yaml: string): Promise<StackRevision> {
return post<StackRevision>(`/api/stacks/${id}/revisions`, { yaml });
}
export function rollbackStack(id: string, revId: string): Promise<StackRevision> {
return post<StackRevision>(`/api/stacks/${id}/rollback/${revId}`);
}
export function stopStack(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/stacks/${id}/stop`);
}
export function startStack(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/stacks/${id}/start`);
}
export function getStackServices(id: string, signal?: AbortSignal): Promise<StackService[]> {
return get<StackService[]>(`/api/stacks/${id}/services`, signal);
}
export async function getStackLogs(
id: string,
service?: string,
tail = 200
): Promise<string> {
const params = new URLSearchParams();
if (service) params.set('service', service);
params.set('tail', String(tail));
const token = getAuthToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/stacks/${id}/logs?${params.toString()}`, { headers });
if (!res.ok) {
throw new ApiError(`Failed to fetch logs: ${res.status}`, res.status);
}
return res.text();
}
export { ApiError };
+2 -1
View File
@@ -18,7 +18,8 @@
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records",
"sites": "Sites"
"sites": "Sites",
"stacks": "Stacks"
},
"dashboard": {
"title": "Dashboard",
+2 -1
View File
@@ -18,7 +18,8 @@
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи",
"sites": "Сайты"
"sites": "Сайты",
"stacks": "Стеки"
},
"dashboard": {
"title": "Панель управления",
+34
View File
@@ -372,6 +372,40 @@ export interface StaticSiteStorageUsage {
export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped';
export type StackStatus = 'stopped' | 'deploying' | 'running' | 'failed';
export interface Stack {
id: string;
name: string;
description: string;
compose_project_name: string;
status: StackStatus;
error: string;
current_revision_id: string;
created_at: string;
updated_at: string;
}
export interface StackRevision {
id: string;
stack_id: string;
revision: number;
yaml: string;
author: string;
deploy_id: string;
status: string;
created_at: string;
}
export interface StackService {
Name: string;
Service: string;
State: string;
Status: string;
Health: string;
ExitCode: number;
}
export type GitProvider = '' | 'gitea' | 'github' | 'gitlab';
/** An encrypted environment variable for a static site's Deno backend. */
+4 -1
View File
@@ -6,7 +6,7 @@
import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox } from '$lib/components/icons';
import { goto } from '$app/navigation';
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
@@ -24,6 +24,7 @@
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe' },
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
@@ -163,6 +164,8 @@
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'globe'}
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'stacks'}
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'deploy'}
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'proxies'}
+582
View File
@@ -0,0 +1,582 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Stack } from '$lib/types';
import * as api from '$lib/api';
import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let stacks = $state<Stack[]>([]);
let loading = $state(true);
let error = $state('');
let confirmDelete = $state<Stack | null>(null);
let deleteRemoveVolumes = $state(false);
async function loadStacks() {
loading = true;
error = '';
try { stacks = await api.listStacks(); }
catch (e) { error = e instanceof Error ? e.message : 'Failed to load stacks'; }
finally { loading = false; }
}
async function handleStop(s: Stack) {
try { await api.stopStack(s.id); setTimeout(loadStacks, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
}
async function handleStart(s: Stack) {
try { await api.startStack(s.id); setTimeout(loadStacks, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
}
async function handleDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
const removeVolumes = deleteRemoveVolumes;
confirmDelete = null; deleteRemoveVolumes = false;
try { await api.deleteStack(id, removeVolumes); await loadStacks(); }
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
}
function statusMeta(status: string) {
switch (status) {
case 'running': return { label: 'RUNNING', cls: 'st-running' };
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
default: return { label: 'COLD', cls: 'st-stopped' };
}
}
function fmtTime(ts: string): string {
if (!ts) return '—';
try { return new Date(ts).toLocaleString(); } catch { return ts; }
}
onMount(loadStacks);
</script>
<div class="forge">
<div class="dot-grid" aria-hidden="true"></div>
<header class="head">
<div class="head-top">
<span class="eyebrow">
<span class="ember"></span>
<span>THE FORGE</span>
<span class="sep">//</span>
<span>STACKS</span>
</span>
<div class="toolbar">
<button class="btn-ghost" onclick={loadStacks} aria-label="Refresh">
<IconRefresh size={16} />
</button>
<a href="/stacks/new" class="btn-primary">
<IconPlus size={16} />
<span>New stack</span>
</a>
</div>
</div>
<h1 class="display">
Stacks<span class="title-accent">.</span>
</h1>
<p class="lede">
Compose blueprints, forged as <em>atomic units</em>.
Spin up services, iterate on revisions, roll back without breaking a sweat.
</p>
<dl class="runners">
<div><dt>TOTAL</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
<div><dt>RUNNING</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
<div><dt>FORGING</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
<div><dt>FAILED</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
</dl>
</header>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
{/if}
{#if loading}
<div class="grid">
{#each Array(3) as _, i}
<div class="skeleton" style:--i={i}></div>
{/each}
</div>
{:else if stacks.length === 0}
<div class="empty">
<div class="empty-mark">
<span></span><span></span><span></span>
</div>
<h2>The anvil is cold.</h2>
<p>Upload a <code>docker-compose.yml</code> to forge your first stack.</p>
<a href="/stacks/new" class="btn-primary">
<IconPlus size={16} /><span>New stack</span>
</a>
</div>
{:else}
<div class="grid">
{#each stacks as s, i (s.id)}
{@const sm = statusMeta(s.status)}
<article class="card {sm.cls}">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="card-head">
<span class="card-ref">[{String(i + 1).padStart(2, '0')} / {String(stacks.length).padStart(2, '0')}]</span>
<span class="status-pill">
<span class="pulse"></span>
{sm.label}
</span>
</header>
<a href="/stacks/{s.id}" class="card-title">{s.name}</a>
{#if s.description}
<p class="card-desc">{s.description}</p>
{:else}
<p class="card-desc dim">No description</p>
{/if}
{#if s.error}
<div class="card-err" title={s.error}>{s.error}</div>
{/if}
<div class="card-meta">
<span class="meta-k">Updated</span>
<span class="meta-v">{fmtTime(s.updated_at)}</span>
</div>
<footer class="card-foot">
{#if s.status === 'running'}
<button class="act" onclick={() => handleStop(s)} aria-label="Stop">
<IconStop size={13} /><span>Stop</span>
</button>
{:else}
<button class="act" onclick={() => handleStart(s)} aria-label="Start">
<IconPlay size={13} /><span>Start</span>
</button>
{/if}
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label="Delete">
<IconTrash size={13} /><span>Delete</span>
</button>
<a class="act-link" href="/stacks/{s.id}">Open <span class="arrow"></span></a>
</footer>
</article>
{/each}
</div>
{/if}
</div>
<ConfirmDialog
open={confirmDelete !== null}
title="Delete stack?"
message={confirmDelete ? `This runs 'docker compose down' and removes "${confirmDelete.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
confirmLabel="Delete"
confirmVariant="danger"
onconfirm={handleDelete}
oncancel={() => { confirmDelete = null; deleteRemoveVolumes = false; }}
/>
<style>
.forge {
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--mono: var(--font-family-mono);
--accent: var(--color-brand-600);
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
position: relative;
max-width: 1240px;
margin: 0 auto;
padding: 2rem clamp(1rem, 3vw, 1.75rem) 3rem;
color: var(--text-primary);
isolation: isolate;
}
/* subtle workshop dot grid behind hero */
.dot-grid {
position: absolute;
top: 0; left: 0; right: 0; height: 480px;
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
background-size: 22px 22px;
mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
pointer-events: none;
z-index: -1;
opacity: 0.8;
}
/* ── Head ──────────────────────────────────────── */
.head { margin-bottom: 2rem; }
.head-top {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 1.5rem; gap: 1rem; flex-wrap: wrap;
}
.eyebrow {
display: inline-flex; align-items: center; gap: 0.55rem;
font-family: var(--mono);
font-size: 0.7rem; letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.eyebrow .sep { opacity: 0.5; }
.ember {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
animation: breathe 2.4s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
}
.toolbar { display: flex; gap: 0.5rem; align-items: center; }
.btn-ghost {
display: inline-flex; align-items: center; justify-content: center;
width: 38px; height: 38px;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.btn-ghost:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
border-color: var(--color-brand-300);
}
.btn-primary {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--text-primary);
color: var(--surface-card);
border: 0; border-radius: var(--radius-lg);
font-family: var(--mono);
font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
box-shadow: 0 0 0 0 var(--glow);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 0 0 4px var(--glow);
}
.display {
font-family: var(--serif);
font-size: clamp(3.75rem, 9vw, 6rem);
font-weight: 400;
line-height: 1;
letter-spacing: 0;
margin: 0;
}
.title-accent {
color: var(--accent);
font-style: italic;
}
.lede {
font-family: var(--serif);
color: var(--text-secondary);
margin: 0.75rem 0 0;
max-width: 52ch;
font-size: 1.2rem;
line-height: 1.45;
}
.lede em {
color: var(--accent);
font-style: italic;
}
.runners {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0;
margin: 1.75rem 0 0;
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--surface-card);
}
.runners > div {
padding: 0.85rem 1.1rem;
border-right: 1px solid var(--border-secondary);
}
.runners > div:last-child { border-right: 0; }
.runners dt {
font-family: var(--mono); font-size: 0.62rem;
letter-spacing: 0.2em; color: var(--text-tertiary);
text-transform: uppercase; margin: 0 0 0.25rem;
}
.runners dd {
margin: 0;
font-family: var(--serif); font-size: 1.75rem; line-height: 1;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.runners dd.accent { color: var(--accent); }
.runners dd.warn { color: var(--color-danger); }
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex; gap: 0.7rem; align-items: center;
margin-bottom: 1.25rem;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
background: var(--color-danger); color: #fff;
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
/* ── Empty ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex; gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px; height: 10px; border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) { background: var(--accent); animation: breathe 2.4s ease-in-out infinite; }
.empty h2 {
font-family: var(--serif); font-weight: 400;
font-size: 2.25rem; margin: 0 0 0.5rem;
letter-spacing: 0;
}
.empty p { color: var(--text-secondary); margin: 0 0 1.5rem; font-size: 0.95rem; }
.empty code {
font-family: var(--mono); font-size: 0.85em;
padding: 0.1rem 0.4rem;
background: var(--surface-card-hover);
border-radius: var(--radius-sm);
}
.empty .btn-primary { display: inline-flex; }
/* ── Grid & Cards ──────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
}
.skeleton {
height: 230px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
background: linear-gradient(110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card {
position: relative;
display: flex; flex-direction: column;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
padding: 1.25rem 1.25rem 1.1rem;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.card::before {
content: '';
position: absolute; left: 0; top: 18px; bottom: 18px;
width: 3px; border-radius: 0 3px 3px 0;
background: var(--text-tertiary);
}
.card.st-running::before { background: var(--color-success); }
.card.st-deploying::before{
background: repeating-linear-gradient(0deg,
var(--color-info) 0 6px,
color-mix(in srgb, var(--color-info) 35%, transparent) 6px 12px);
}
.card.st-failed::before { background: var(--color-danger); }
.card:hover {
border-color: var(--color-brand-400);
box-shadow: 0 0 0 1px var(--color-brand-400), 0 14px 30px -18px var(--glow);
transform: translateY(-2px);
}
/* registration corners (precision marks) */
.reg {
position: absolute; width: 8px; height: 8px;
border-color: var(--color-brand-500);
border-style: solid; border-width: 0;
opacity: 0; transition: opacity 180ms ease;
}
.card:hover .reg { opacity: 1; }
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; }
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; }
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; }
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; }
.card-head {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.85rem;
}
.card-ref {
font-family: var(--mono); font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.status-pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-full);
background: var(--surface-card-hover);
font-family: var(--mono);
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
color: var(--text-secondary);
}
.status-pill .pulse {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-tertiary);
}
.st-running .status-pill { background: var(--color-success-light); color: var(--color-success-dark); }
.st-running .status-pill .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
.st-deploying .status-pill { background: var(--color-info-light); color: var(--color-info-dark); }
.st-deploying .status-pill .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
.st-failed .status-pill { background: var(--color-danger-light); color: var(--color-danger-dark); }
.st-failed .status-pill .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
:global([data-theme='dark']) .st-running .status-pill { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
:global([data-theme='dark']) .st-deploying .status-pill { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
:global([data-theme='dark']) .st-failed .status-pill { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
@keyframes blink {
0%, 60%, 100% { opacity: 1; }
70%, 90% { opacity: 0.3; }
}
.card-title {
font-family: var(--serif);
font-size: 1.85rem; line-height: 1.1;
color: var(--text-primary);
text-decoration: none;
letter-spacing: 0;
word-break: break-word;
margin-bottom: 0.35rem;
}
.card-title:hover {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 4px;
}
.card-desc {
font-size: 0.88rem;
color: var(--text-secondary);
margin: 0 0 0.9rem;
line-height: 1.45;
}
.card-desc.dim { color: var(--text-tertiary); font-style: italic; }
.card-err {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--color-danger-dark);
padding: 0.4rem 0.55rem;
margin-bottom: 0.85rem;
border-left: 2px solid var(--color-danger);
background: var(--color-danger-light);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
:global([data-theme='dark']) .card-err {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: #fca5a5;
}
.card-meta {
display: flex; gap: 0.5rem;
font-family: var(--mono);
font-size: 0.72rem;
padding: 0.55rem 0;
margin-bottom: 0.9rem;
border-top: 1px dashed var(--border-primary);
border-bottom: 1px dashed var(--border-primary);
}
.card-meta .meta-k {
color: var(--text-tertiary);
letter-spacing: 0.1em; text-transform: uppercase; font-size: 0.62rem;
align-self: center;
}
.card-meta .meta-v { color: var(--text-secondary); }
.card-foot {
display: flex; gap: 0.4rem; align-items: center;
margin-top: auto;
}
.act {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.38rem 0.7rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-family: var(--mono);
font-size: 0.68rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer;
transition: all 120ms ease;
}
.act:hover {
border-color: var(--color-brand-400);
background: var(--surface-card-hover);
color: var(--text-primary);
}
.act.danger { color: var(--color-danger); }
.act.danger:hover {
border-color: var(--color-danger);
background: var(--color-danger-light);
color: var(--color-danger-dark);
}
:global([data-theme='dark']) .act.danger:hover {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
.act-link {
margin-left: auto;
font-family: var(--mono);
font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--accent);
text-decoration: none;
}
.act-link .arrow { display: inline-block; transition: transform 150ms ease; }
.act-link:hover { color: var(--color-brand-700); }
.act-link:hover .arrow { transform: translateX(3px); }
@media (max-width: 640px) {
.head-top { align-items: flex-start; }
.display { font-size: 3rem; }
}
</style>
+952
View File
@@ -0,0 +1,952 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { Stack, StackRevision, StackService } from '$lib/types';
import * as api from '$lib/api';
import { IconArrowLeft, IconRefresh, IconPlay, IconStop, IconTrash } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const id = $derived($page.params.id ?? '');
let stack = $state<Stack | null>(null);
let revisions = $state<StackRevision[]>([]);
let services = $state<StackService[]>([]);
let loading = $state(true);
let error = $state('');
let editing = $state(false);
let editYaml = $state('');
let submitting = $state(false);
let logsService = $state('');
let logsText = $state('');
let logsLoading = $state(false);
let confirmRollback = $state<StackRevision | null>(null);
let confirmDelete = $state(false);
let deleteRemoveVolumes = $state(false);
let tab = $state<'yaml' | 'revisions' | 'logs'>('yaml');
let refreshTimer: ReturnType<typeof setInterval> | null = null;
async function loadAll() {
loading = true; error = '';
try {
const [s, revs, svcs] = await Promise.all([
api.getStack(id),
api.listStackRevisions(id),
api.getStackServices(id).catch(() => [] as StackService[])
]);
stack = s; revisions = revs; services = svcs;
if (!editing && revs.length > 0) editYaml = revs[0].yaml;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load stack';
} finally {
loading = false;
}
}
async function handleStop() {
if (!stack) return;
try { await api.stopStack(stack.id); setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
}
async function handleStart() {
if (!stack) return;
try { await api.startStack(stack.id); setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
}
async function submitNewRevision() {
if (!stack) return;
submitting = true; error = '';
try { await api.createStackRevision(stack.id, editYaml); editing = false; setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Update failed'; }
finally { submitting = false; }
}
async function doRollback() {
if (!stack || !confirmRollback) return;
const revId = confirmRollback.id;
confirmRollback = null;
try { await api.rollbackStack(stack.id, revId); setTimeout(loadAll, 1500); }
catch (e) { error = e instanceof Error ? e.message : 'Rollback failed'; }
}
async function doDelete() {
if (!stack) return;
const sid = stack.id;
const rm = deleteRemoveVolumes;
confirmDelete = false; deleteRemoveVolumes = false;
try { await api.deleteStack(sid, rm); await goto('/stacks'); }
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
}
async function loadLogs() {
if (!stack) return;
logsLoading = true;
try { logsText = await api.getStackLogs(stack.id, logsService || undefined, 300); }
catch (e) { logsText = e instanceof Error ? e.message : 'Failed to load logs'; }
finally { logsLoading = false; }
}
function statusMeta(status: string) {
switch (status) {
case 'running': return { label: 'RUNNING', cls: 'st-running' };
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
default: return { label: 'COLD', cls: 'st-stopped' };
}
}
function fmtTime(ts: string): string {
if (!ts) return '—';
try { return new Date(ts).toLocaleString(); } catch { return ts; }
}
function serviceState(s: string): string {
if (!s) return 'unknown';
return s.toLowerCase();
}
onMount(() => {
loadAll();
refreshTimer = setInterval(() => { if (!editing) loadAll(); }, 5000);
});
onDestroy(() => { if (refreshTimer) clearInterval(refreshTimer); });
</script>
<div class="forge">
<div class="dot-grid" aria-hidden="true"></div>
<a href="/stacks" class="back">
<IconArrowLeft size={13} />
<span>STACKS</span>
</a>
{#if loading && !stack}
<div class="loading">
<span class="spinner"></span>
<span>Loading blueprint…</span>
</div>
{:else if error && !stack}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
{:else if stack}
{@const sm = statusMeta(stack.status)}
<header class="head">
<div class="eyebrow">
<span class="ember"></span>
<span>THE FORGE</span>
<span class="sep">//</span>
<span class="mono-id">{stack.id.slice(0, 16)}</span>
<span class="sep">//</span>
<span class="status-pill {sm.cls}">
<span class="pulse"></span>{sm.label}
</span>
</div>
<div class="head-row">
<div class="head-left">
<h1 class="display">{stack.name}</h1>
{#if stack.description}
<p class="lede">{stack.description}</p>
{:else}
<p class="lede dim">No description</p>
{/if}
<span class="project-chip">
<span class="chip-k">COMPOSE PROJECT</span>
<code>{stack.compose_project_name}</code>
</span>
</div>
<div class="toolbar">
<button class="btn-ghost" onclick={loadAll} aria-label="Refresh">
<IconRefresh size={15} />
</button>
{#if stack.status === 'running'}
<button onclick={handleStop} class="chip-btn">
<IconStop size={13} /> <span>Stop</span>
</button>
{:else}
<button onclick={handleStart} class="chip-btn primary">
<IconPlay size={13} /> <span>Start</span>
</button>
{/if}
<button onclick={() => (confirmDelete = true)} class="chip-btn danger">
<IconTrash size={13} /> <span>Delete</span>
</button>
</div>
</div>
{#if stack.error}
<div class="alert">
<span class="alert-tag">FAULT</span>
<span>{stack.error}</span>
</div>
{/if}
</header>
<!-- ── Stat tiles ─────────────────────────────── -->
<section class="stats">
<div class="stat">
<span class="stat-label">Services</span>
<span class="stat-value">{String(services.length).padStart(2,'0')}</span>
<span class="stat-sub">in blueprint</span>
</div>
<div class="stat">
<span class="stat-label">Running</span>
<span class="stat-value accent">
{String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')}
</span>
<span class="stat-sub">active containers</span>
</div>
<div class="stat">
<span class="stat-label">Revisions</span>
<span class="stat-value">{String(revisions.length).padStart(2,'0')}</span>
<span class="stat-sub">in history</span>
</div>
<div class="stat">
<span class="stat-label">Current</span>
<span class="stat-value">
R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')}
</span>
<span class="stat-sub">deployed</span>
</div>
</section>
<!-- ── Services ───────────────────────────────── -->
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">Services<span class="title-accent">.</span></h2>
<span class="panel-count">{services.length} on the floor</span>
</header>
{#if services.length === 0}
<p class="panel-empty">— no containers running —</p>
{:else}
<ul class="svc-list">
{#each services as svc (svc.Name)}
{@const st = serviceState(svc.State)}
<li class="svc-row" data-state={st}>
<span class="svc-dot"></span>
<div class="svc-main">
<div class="svc-name">{svc.Service}</div>
<div class="svc-id">{svc.Name}</div>
</div>
<div class="svc-status">
<span class="svc-state">{svc.State}</span>
<span class="svc-detail">{svc.Status}</span>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ── Tabs ───────────────────────────────────── -->
<section class="panel">
<div class="tabs" role="tablist">
<button role="tab" class="tab" class:active={tab==='yaml'} onclick={() => tab='yaml'}>
<span class="tab-num">I</span><span>Blueprint</span>
</button>
<button role="tab" class="tab" class:active={tab==='revisions'} onclick={() => tab='revisions'}>
<span class="tab-num">II</span><span>Revisions</span>
<span class="tab-badge">{revisions.length}</span>
</button>
<button role="tab" class="tab" class:active={tab==='logs'} onclick={() => tab='logs'}>
<span class="tab-num">III</span><span>Logs</span>
</button>
</div>
{#if tab === 'yaml'}
<div class="panel-body">
<div class="panel-toolbar">
<span class="dim">Current revision</span>
{#if !editing}
<button class="chip" onclick={() => (editing = true)}>Edit &amp; redeploy</button>
{/if}
</div>
{#if editing}
<textarea
bind:value={editYaml}
rows="20"
class="yaml-edit"
spellcheck="false"
></textarea>
<div class="panel-foot">
<button class="btn-ghost" onclick={() => (editing = false)}>Cancel</button>
<button class="btn-primary" onclick={submitNewRevision} disabled={submitting}>
<span>{submitting ? 'Forging…' : 'Deploy new revision'}</span>
<span class="arrow"></span>
</button>
</div>
{:else if revisions[0]}
<div class="yaml-frame">
<div class="yaml-frame-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="yaml-title">docker-compose.yml</span>
</div>
<pre class="yaml-view">{revisions[0].yaml}</pre>
</div>
{/if}
</div>
{:else if tab === 'revisions'}
<div class="panel-body">
<ol class="timeline">
{#each revisions as rev (rev.id)}
<li class="tl-entry" class:current={rev.id === stack.current_revision_id}>
<div class="tl-dot"></div>
<div class="tl-content">
<div class="tl-head">
<span class="tl-rev">R{rev.revision.toString().padStart(2, '0')}</span>
{#if rev.id === stack.current_revision_id}
<span class="tl-badge">CURRENT</span>
{/if}
<span class="tl-status">{rev.status}</span>
<span class="tl-time">{fmtTime(rev.created_at)}</span>
</div>
<div class="tl-meta">
by <strong>{rev.author || 'operator'}</strong>
</div>
{#if rev.id !== stack.current_revision_id}
<button class="tl-action" onclick={() => (confirmRollback = rev)}>
← Rollback to this revision
</button>
{/if}
</div>
</li>
{/each}
</ol>
</div>
{:else if tab === 'logs'}
<div class="panel-body">
<div class="panel-toolbar">
<label class="log-select">
<span class="dim">Service:</span>
<select bind:value={logsService}>
<option value="">All services</option>
{#each services as svc (svc.Service)}
<option value={svc.Service}>{svc.Service}</option>
{/each}
</select>
</label>
<button onclick={loadLogs} class="chip" disabled={logsLoading}>
{logsLoading ? 'Fetching…' : 'Fetch logs'}
</button>
</div>
{#if logsText}
<div class="terminal">
<div class="terminal-head">
<span class="t-dot"></span>
<span class="t-dot"></span>
<span class="t-dot"></span>
<span class="t-title">~/forge/{stack.name}{logsService ? '/' + logsService : ''}.log</span>
</div>
<pre class="terminal-body">{logsText}</pre>
</div>
{:else}
<p class="panel-empty">— no logs loaded. tap fetch. —</p>
{/if}
</div>
{/if}
</section>
{/if}
</div>
<ConfirmDialog
open={confirmRollback !== null}
title="Rollback to revision?"
message={confirmRollback ? `Create a new revision from rev ${confirmRollback.revision} and redeploy the stack.` : ''}
confirmLabel="Rollback"
confirmVariant="primary"
onconfirm={doRollback}
oncancel={() => (confirmRollback = null)}
/>
<ConfirmDialog
open={confirmDelete}
title="Delete stack?"
message={stack ? `This runs 'docker compose down' and removes "${stack.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
confirmLabel="Delete"
confirmVariant="danger"
onconfirm={doDelete}
oncancel={() => { confirmDelete = false; deleteRemoveVolumes = false; }}
/>
<style>
.forge {
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--mono: var(--font-family-mono);
--accent: var(--color-brand-600);
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
position: relative;
max-width: 1240px;
margin: 0 auto;
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
color: var(--text-primary);
isolation: isolate;
}
.dot-grid {
position: absolute;
top: 0; left: 0; right: 0; height: 480px;
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
background-size: 22px 22px;
mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
-webkit-mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
pointer-events: none;
z-index: -1;
opacity: 0.8;
}
.back {
display: inline-flex; align-items: center; gap: 0.4rem;
font-family: var(--mono);
font-size: 0.68rem; letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-tertiary);
text-decoration: none;
margin-bottom: 1.5rem;
}
.back:hover { color: var(--accent); }
.loading {
display: flex; gap: 0.7rem; align-items: center;
font-family: var(--mono);
font-size: 0.82rem; color: var(--text-tertiary);
}
.spinner {
width: 12px; height: 12px;
border: 2px solid var(--text-tertiary);
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink {
0%, 60%, 100% { opacity: 1; }
70%, 90% { opacity: 0.3; }
}
@keyframes breathe {
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
}
/* ── Head ──────────────────────────────────────── */
.head { margin-bottom: 2rem; }
.eyebrow {
display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap;
font-family: var(--mono);
font-size: 0.68rem; letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 1rem;
}
.eyebrow .sep { opacity: 0.5; }
.mono-id { color: var(--text-secondary); }
.ember {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
animation: breathe 2.4s ease-in-out infinite;
}
.status-pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-full);
background: var(--surface-card-hover);
font-family: var(--mono);
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
color: var(--text-secondary);
}
.status-pill .pulse {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-tertiary);
}
.status-pill.st-running { background: var(--color-success-light); color: var(--color-success-dark); }
.status-pill.st-running .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
.status-pill.st-deploying { background: var(--color-info-light); color: var(--color-info-dark); }
.status-pill.st-deploying .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
.status-pill.st-failed { background: var(--color-danger-light); color: var(--color-danger-dark); }
.status-pill.st-failed .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
:global([data-theme='dark']) .status-pill.st-running { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
:global([data-theme='dark']) .status-pill.st-deploying { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
:global([data-theme='dark']) .status-pill.st-failed { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
.head-row {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 1.5rem; flex-wrap: wrap;
}
.head-left { flex: 1; min-width: 280px; }
.display {
font-family: var(--serif);
font-size: clamp(2.75rem, 7vw, 4.5rem);
font-weight: 400; line-height: 1.05;
letter-spacing: 0;
margin: 0;
word-break: break-word;
}
.lede {
font-family: var(--serif);
color: var(--text-secondary);
margin: 0.5rem 0 0;
font-size: 1.1rem;
line-height: 1.45;
max-width: 56ch;
}
.lede.dim { color: var(--text-tertiary); font-style: italic; }
.project-chip {
display: inline-flex; gap: 0.55rem; align-items: center;
margin-top: 0.85rem;
padding: 0.3rem 0.65rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
}
.chip-k {
font-family: var(--mono); font-size: 0.6rem;
letter-spacing: 0.15em; text-transform: uppercase;
color: var(--text-tertiary);
}
.project-chip code {
font-family: var(--mono); font-size: 0.75rem;
color: var(--text-primary);
}
.toolbar { display: flex; gap: 0.45rem; align-items: center; flex-wrap: wrap; }
.btn-ghost {
display: inline-flex; align-items: center; justify-content: center;
width: 38px; height: 38px;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.btn-ghost:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
border-color: var(--color-brand-300);
}
.chip-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 0.85rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
font-family: var(--mono);
font-size: 0.7rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer;
transition: all 120ms ease;
}
.chip-btn:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
border-color: var(--color-brand-300);
}
.chip-btn.primary {
background: var(--text-primary);
color: var(--surface-card);
border-color: var(--text-primary);
box-shadow: 0 0 0 0 var(--glow);
}
.chip-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 0 0 3px var(--glow);
}
.chip-btn.danger { color: var(--color-danger); }
.chip-btn.danger:hover {
background: var(--color-danger-light);
border-color: var(--color-danger);
color: var(--color-danger-dark);
}
:global([data-theme='dark']) .chip-btn.danger:hover {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
.alert {
display: flex; gap: 0.7rem; align-items: center;
margin-top: 1.25rem;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
background: var(--color-danger); color: #fff;
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
/* ── Stats ─────────────────────────────────────── */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
margin-bottom: 1.5rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--surface-card);
}
.stat {
padding: 1rem 1.15rem;
border-right: 1px solid var(--border-secondary);
display: flex; flex-direction: column; gap: 0.2rem;
}
.stat:last-child { border-right: 0; }
.stat-label {
font-family: var(--mono); font-size: 0.62rem;
letter-spacing: 0.2em; text-transform: uppercase;
color: var(--text-tertiary);
}
.stat-value {
font-family: var(--serif); font-size: 2.5rem; line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.stat-value.accent { color: var(--accent); }
.stat-sub {
font-family: var(--mono);
font-size: 0.66rem; color: var(--text-tertiary);
}
/* ── Panels ────────────────────────────────────── */
.panel {
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
margin-bottom: 1.5rem;
overflow: hidden;
}
.panel-head {
display: flex; align-items: flex-end; justify-content: space-between;
padding: 1rem 1.35rem 0.85rem;
border-bottom: 1px solid var(--border-secondary);
}
.panel-title {
font-family: var(--serif); font-size: 1.75rem;
margin: 0; font-weight: 400; line-height: 1;
letter-spacing: 0;
}
.title-accent { color: var(--accent); font-style: italic; }
.panel-count {
font-family: var(--mono); font-size: 0.66rem;
letter-spacing: 0.12em; color: var(--text-tertiary);
text-transform: uppercase;
}
.panel-empty {
padding: 1.75rem; margin: 0;
font-family: var(--serif); font-style: italic; color: var(--text-tertiary);
text-align: center; font-size: 1rem;
}
.panel-body { padding: 1.15rem 1.35rem 1.35rem; }
.panel-toolbar {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.9rem; flex-wrap: wrap;
}
.dim {
font-family: var(--mono);
color: var(--text-tertiary);
font-size: 0.7rem; letter-spacing: 0.08em;
}
.chip {
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 0.35rem 0.75rem;
font-family: var(--mono);
font-size: 0.66rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
}
.chip:hover:not(:disabled) {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.chip:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Services list ─────────────────────────────── */
.svc-list { list-style: none; margin: 0; padding: 0; }
.svc-row {
display: grid;
grid-template-columns: 14px 1fr auto;
gap: 1rem; align-items: center;
padding: 0.85rem 1.35rem;
border-bottom: 1px solid var(--border-secondary);
}
.svc-row:last-child { border-bottom: 0; }
.svc-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-tertiary);
}
.svc-row[data-state='running'] .svc-dot {
background: var(--color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);
}
.svc-row[data-state='exited'] .svc-dot,
.svc-row[data-state='dead'] .svc-dot { background: var(--color-danger); }
.svc-row[data-state='restarting'] .svc-dot { background: var(--color-warning); animation: blink 0.6s infinite; }
.svc-name {
font-family: var(--serif); font-size: 1.2rem;
color: var(--text-primary); line-height: 1.2;
}
.svc-id {
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-tertiary); margin-top: 0.1rem;
}
.svc-status { text-align: right; }
.svc-state {
display: inline-block;
font-family: var(--mono); font-size: 0.66rem;
font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--text-primary);
padding: 0.2rem 0.55rem;
background: var(--surface-card-hover);
border-radius: var(--radius-full);
}
.svc-detail {
display: block; margin-top: 0.25rem;
font-family: var(--mono); font-size: 0.68rem;
color: var(--text-tertiary);
}
/* ── Tabs ──────────────────────────────────────── */
.tabs {
display: flex; gap: 0;
border-bottom: 1px solid var(--border-primary);
background: var(--surface-card-hover);
}
.tab {
display: inline-flex; align-items: center; gap: 0.55rem;
padding: 0.95rem 1.25rem;
background: transparent;
border: 0;
border-right: 1px solid var(--border-secondary);
font-family: var(--mono);
font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer; position: relative;
transition: color 150ms ease, background 150ms ease;
}
.tab:hover { color: var(--text-secondary); }
.tab.active {
color: var(--text-primary);
background: var(--surface-card);
}
.tab.active::after {
content: '';
position: absolute; left: 0; right: 0; bottom: -1px;
height: 2px; background: var(--accent);
}
.tab-num {
font-family: var(--serif);
font-size: 1.15rem;
font-style: italic;
color: var(--accent);
letter-spacing: 0;
font-weight: 400;
}
.tab-badge {
font-size: 0.58rem;
padding: 0.1rem 0.4rem;
background: var(--text-primary); color: var(--surface-card);
border-radius: var(--radius-full);
letter-spacing: 0.08em;
}
/* ── YAML view / edit ──────────────────────────── */
.yaml-frame {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
overflow: hidden;
}
.yaml-frame-head {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 0.8rem;
background: var(--surface-card-hover);
border-bottom: 1px solid var(--border-secondary);
}
.yaml-frame-head .dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--border-input);
}
.yaml-frame-head .dot:nth-child(2) { background: var(--color-warning); }
.yaml-frame-head .dot:nth-child(3) { background: var(--color-success); }
.yaml-title {
margin-left: 0.6rem;
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-tertiary);
}
.yaml-view {
max-height: 440px; overflow: auto;
padding: 0.9rem 1rem; margin: 0;
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
color: var(--text-primary);
white-space: pre;
}
.yaml-edit {
width: 100%;
padding: 0.85rem 1rem;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
color: var(--text-primary);
resize: vertical;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.yaml-edit:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.panel-foot {
display: flex; justify-content: flex-end; gap: 0.5rem;
margin-top: 1rem;
}
.btn-primary {
display: inline-flex; align-items: center; gap: 0.55rem;
padding: 0.6rem 1.1rem;
background: var(--text-primary); color: var(--surface-card);
border: 0; border-radius: var(--radius-lg);
font-family: var(--mono);
font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
box-shadow: 0 0 0 0 var(--glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 0 0 4px var(--glow);
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.arrow { transition: transform 150ms ease; }
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
/* ── Timeline ──────────────────────────────────── */
.timeline { list-style: none; margin: 0; padding: 0.25rem 0 0; position: relative; }
.timeline::before {
content: '';
position: absolute; top: 1rem; bottom: 1rem; left: 8px;
width: 1px; background: var(--border-primary);
}
.tl-entry {
position: relative;
padding: 0.6rem 0 0.6rem 2rem;
}
.tl-dot {
position: absolute; left: 3px; top: 1.05rem;
width: 11px; height: 11px;
background: var(--surface-card);
border: 2px solid var(--text-tertiary);
border-radius: 50%;
}
.tl-entry.current .tl-dot {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.tl-head {
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
font-family: var(--mono); font-size: 0.68rem;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-secondary);
}
.tl-rev {
font-family: var(--serif); font-size: 1.5rem;
letter-spacing: 0; color: var(--text-primary); line-height: 1;
}
.tl-badge {
padding: 0.15rem 0.5rem;
background: var(--accent); color: #fff;
font-size: 0.58rem; font-weight: 600; letter-spacing: 0.16em;
border-radius: var(--radius-full);
}
.tl-status { color: var(--text-secondary); }
.tl-time { color: var(--text-tertiary); }
.tl-meta {
font-size: 0.82rem; color: var(--text-tertiary);
margin-top: 0.25rem; font-family: var(--serif);
}
.tl-meta strong { color: var(--text-secondary); font-weight: 500; }
.tl-action {
margin-top: 0.5rem;
background: transparent; border: 0;
padding: 0;
color: var(--accent); font-family: var(--mono);
font-size: 0.68rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
cursor: pointer;
}
.tl-action:hover { text-decoration: underline; text-underline-offset: 3px; }
/* ── Logs / Terminal ───────────────────────────── */
.log-select { display: inline-flex; align-items: center; gap: 0.55rem; }
.log-select select {
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
padding: 0.35rem 0.6rem;
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-primary);
}
.terminal {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: #0b1020;
overflow: hidden;
}
:global([data-theme='dark']) .terminal { background: #05070f; }
.terminal-head {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 0.9rem;
background: #141a2e;
border-bottom: 1px solid #0a0e1c;
}
:global([data-theme='dark']) .terminal-head { background: #0a0e1c; }
.t-dot {
width: 9px; height: 9px; border-radius: 50%;
background: rgba(255,255,255,0.12);
}
.t-dot:nth-child(1) { background: color-mix(in srgb, var(--color-danger) 70%, black); }
.t-dot:nth-child(2) { background: color-mix(in srgb, var(--color-warning) 70%, black); }
.t-dot:nth-child(3) { background: color-mix(in srgb, var(--color-success) 70%, black); }
.t-title {
margin-left: 0.6rem;
font-family: var(--mono); font-size: 0.7rem;
color: rgba(255,255,255,0.45);
}
.terminal-body {
max-height: 480px; overflow: auto;
margin: 0; padding: 1rem 1.1rem;
font-family: var(--mono); font-size: 0.76rem; line-height: 1.55;
color: #c7d0e0;
white-space: pre-wrap; word-break: break-all;
}
@media (max-width: 640px) {
.head-row { flex-direction: column; align-items: stretch; }
.display { font-size: 2.5rem; }
.svc-row { grid-template-columns: 14px 1fr; }
.svc-status { grid-column: 2; text-align: left; }
}
</style>
+621
View File
@@ -0,0 +1,621 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/api';
import { IconArrowLeft } from '$lib/components/icons';
let name = $state('');
let description = $state('');
let yaml = $state('');
let deployNow = $state(true);
let submitting = $state(false);
let error = $state('');
let fileInput = $state<HTMLInputElement | null>(null);
let dragOver = $state(false);
const sample = `services:
web:
image: nginx:alpine
ports:
- "8080:80"
cache:
image: redis:7-alpine`;
async function handleFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
yaml = await file.text();
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files?.[0];
if (!file) return;
yaml = await file.text();
}
function loadSample() { yaml = sample; }
async function submit(e: Event) {
e.preventDefault();
if (!name.trim() || !yaml.trim()) {
error = 'Name and compose YAML are required.';
return;
}
submitting = true; error = '';
try {
const { stack } = await api.createStack({
name: name.trim(),
description: description.trim(),
yaml,
deploy: deployNow
});
await goto(`/stacks/${stack.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create stack';
} finally {
submitting = false;
}
}
const lineNumbers = $derived(
yaml.split('\n').map((_, i) => String(i + 1).padStart(3, '0')).join('\n')
);
const lineCount = $derived(yaml ? yaml.split('\n').length : 0);
const byteCount = $derived(new Blob([yaml]).size);
function syncScroll(e: Event) {
const ta = e.target as HTMLTextAreaElement;
const gutter = ta.parentElement?.querySelector('.gutter') as HTMLElement | null;
if (gutter) gutter.scrollTop = ta.scrollTop;
}
</script>
<div class="forge">
<div class="dot-grid" aria-hidden="true"></div>
<a href="/stacks" class="back">
<IconArrowLeft size={13} />
<span>STACKS</span>
</a>
<header class="head">
<span class="eyebrow">
<span class="ember"></span>
<span>THE FORGE</span>
<span class="sep">//</span>
<span>NEW BLUEPRINT</span>
</span>
<h1 class="display">
Forge a<br/>new <em>stack</em>.
</h1>
<p class="lede">
Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint
deploy as a single atomic unit.
</p>
</header>
<form onsubmit={submit} class="form">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
{/if}
<div class="field">
<label for="stack-name" class="field-label">
<span class="num">01</span>
<span class="lbl">Name</span>
<span class="req">required</span>
</label>
<input
id="stack-name"
type="text"
bind:value={name}
required
placeholder="my-app-stack"
class="input"
/>
<p class="hint">Lowercase, hyphenated. Used as the compose project name.</p>
</div>
<div class="field">
<label for="stack-desc" class="field-label">
<span class="num">02</span>
<span class="lbl">Description</span>
<span class="opt">optional</span>
</label>
<input
id="stack-desc"
type="text"
bind:value={description}
placeholder="What does this stack do?"
class="input"
/>
</div>
<div class="field">
<div class="field-label">
<span class="num">03</span>
<span class="lbl">Compose YAML</span>
<span class="req">required</span>
<span class="spacer"></span>
<button type="button" class="chip" onclick={loadSample}>Load sample</button>
<button type="button" class="chip" onclick={() => fileInput?.click()}>Upload file</button>
<input
bind:this={fileInput}
type="file"
accept=".yml,.yaml"
class="sr-only"
onchange={handleFile}
/>
</div>
{#if !yaml}
<button
type="button"
class="dropzone"
class:drag-over={dragOver}
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
ondragleave={() => (dragOver = false)}
ondrop={handleDrop}
onclick={() => fileInput?.click()}
>
<div class="dz-icon"></div>
<div class="dz-title">Drop a <em>docker-compose.yml</em> here</div>
<div class="dz-sub">or click to browse · or use <strong>Load sample</strong> above</div>
</button>
{/if}
<div class="editor" class:hidden={!yaml}>
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">docker-compose.yml</span>
</div>
<div class="editor-body">
<div class="gutter" aria-hidden="true"><pre>{lineNumbers}</pre></div>
<textarea
bind:value={yaml}
onscroll={syncScroll}
rows="20"
spellcheck="false"
placeholder={sample}
class="yaml-area"
></textarea>
</div>
<div class="editor-foot">
<span>{lineCount} lines</span>
<span class="sep">·</span>
<span>{byteCount} bytes</span>
<span class="sep">·</span>
<span>YAML</span>
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>Clear</button>
</div>
</div>
</div>
<label class="deploy-toggle">
<input type="checkbox" bind:checked={deployNow} />
<span class="toggle-box"></span>
<span class="toggle-text">
<strong>Deploy immediately</strong>
<span class="dim">Strike while the iron's hot. If unchecked, the stack is saved cold.</span>
</span>
</label>
<div class="actions">
<a href="/stacks" class="btn-ghost">Cancel</a>
<button
type="submit"
disabled={submitting}
class="btn-primary"
>
<span>{submitting ? 'Forging…' : deployNow ? 'Forge & deploy' : 'Save blueprint'}</span>
<span class="arrow"></span>
</button>
</div>
</form>
</div>
<style>
.forge {
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--mono: var(--font-family-mono);
--accent: var(--color-brand-600);
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
position: relative;
max-width: 880px;
margin: 0 auto;
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
color: var(--text-primary);
isolation: isolate;
}
.dot-grid {
position: absolute;
top: 0; left: 0; right: 0; height: 400px;
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
background-size: 22px 22px;
mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
pointer-events: none;
z-index: -1;
opacity: 0.8;
}
.back {
display: inline-flex; align-items: center; gap: 0.4rem;
font-family: var(--mono);
font-size: 0.68rem; letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-tertiary);
text-decoration: none;
margin-bottom: 1.5rem;
}
.back:hover { color: var(--accent); }
/* ── Head ──────────────────────────────────────── */
.head { margin-bottom: 2rem; }
.eyebrow {
display: inline-flex; align-items: center; gap: 0.55rem;
font-family: var(--mono);
font-size: 0.7rem; letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 0.85rem;
}
.eyebrow .sep { opacity: 0.5; }
.ember {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
animation: breathe 2.4s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
}
.display {
font-family: var(--serif);
font-size: clamp(3rem, 7vw, 4.75rem);
font-weight: 400; line-height: 1.05;
letter-spacing: 0;
margin: 0;
}
.display em {
color: var(--accent);
font-style: italic;
}
.lede {
font-family: var(--serif);
color: var(--text-secondary);
margin: 0.75rem 0 0;
max-width: 56ch;
font-size: 1.15rem;
line-height: 1.45;
}
.lede code {
font-family: var(--mono);
font-size: 0.85em;
padding: 0.1rem 0.4rem;
background: var(--surface-card-hover);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
/* ── Form ──────────────────────────────────────── */
.form {
position: relative;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.75rem;
}
.reg {
position: absolute; width: 10px; height: 10px;
border-color: var(--color-brand-500);
border-style: solid; border-width: 0;
}
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; border-top-left-radius: var(--radius-2xl); }
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; border-top-right-radius: var(--radius-2xl); }
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; border-bottom-left-radius: var(--radius-2xl); }
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; border-bottom-right-radius: var(--radius-2xl); }
.alert {
display: flex; gap: 0.7rem; align-items: center;
padding: 0.7rem 0.9rem; margin-bottom: 1.25rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
background: var(--color-danger); color: #fff;
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
/* ── Fields ────────────────────────────────────── */
.field { margin-bottom: 1.5rem; }
.field-label {
display: flex; align-items: center; gap: 0.55rem;
margin-bottom: 0.55rem;
}
.field-label .num {
display: inline-flex; width: 26px; height: 26px;
justify-content: center; align-items: center;
background: var(--text-primary); color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 0.7rem; font-weight: 700;
}
.field-label .lbl {
font-family: var(--serif);
font-size: 1.25rem; line-height: 1;
color: var(--text-primary);
}
.field-label .req {
font-family: var(--mono);
font-size: 0.6rem; font-weight: 600;
color: var(--color-danger);
text-transform: uppercase; letter-spacing: 0.12em;
}
.field-label .opt {
font-family: var(--mono);
font-size: 0.6rem; font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase; letter-spacing: 0.12em;
}
.field-label .spacer { flex: 1; }
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.65rem 0.85rem;
font-size: 0.92rem;
color: var(--text-primary);
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.hint {
font-size: 0.78rem; color: var(--text-tertiary);
margin: 0.4rem 0 0;
}
.chip {
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 0.3rem 0.7rem;
font-family: var(--mono);
font-size: 0.66rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
}
.chip:hover {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
/* ── Dropzone ──────────────────────────────────── */
.dropzone {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 0.5rem;
width: 100%; min-height: 240px;
background: var(--surface-card-hover);
border: 2px dashed var(--border-primary);
border-radius: var(--radius-xl);
color: var(--text-secondary);
cursor: pointer;
padding: 2rem;
transition: all 180ms ease;
font-family: inherit;
}
.dropzone:hover, .dropzone.drag-over {
border-color: var(--color-brand-500);
background: color-mix(in srgb, var(--color-brand-500) 6%, transparent);
color: var(--text-primary);
}
.dz-icon { font-size: 2.25rem; line-height: 1; color: var(--text-tertiary); transition: color 150ms ease; }
.dropzone:hover .dz-icon, .dropzone.drag-over .dz-icon { color: var(--accent); }
.dz-title {
font-family: var(--serif); font-size: 1.5rem;
color: var(--text-primary);
}
.dz-title em { color: var(--accent); font-style: italic; }
.dz-sub {
font-family: var(--mono);
font-size: 0.72rem; letter-spacing: 0.06em;
color: var(--text-tertiary);
}
.dz-sub strong { color: var(--text-secondary); font-weight: 600; }
/* ── Editor ────────────────────────────────────── */
.editor {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
overflow: hidden;
}
.editor.hidden { display: none; }
.editor-head {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 0.8rem;
background: var(--surface-card-hover);
border-bottom: 1px solid var(--border-secondary);
}
.editor-head .dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--border-input);
}
.editor-head .dot:nth-child(2) { background: var(--color-warning); }
.editor-head .dot:nth-child(3) { background: var(--color-success); }
.editor-head .editor-title {
margin-left: 0.6rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--text-tertiary);
}
.editor-body {
position: relative;
display: flex;
}
.gutter {
flex-shrink: 0;
width: 54px;
overflow: hidden;
background: var(--surface-card-hover);
border-right: 1px solid var(--border-secondary);
pointer-events: none;
}
.gutter pre {
margin: 0; padding: 0.85rem 0.6rem 0.85rem 0;
font-family: var(--mono);
font-size: 0.72rem; line-height: 1.5;
color: var(--text-tertiary);
text-align: right;
}
.yaml-area {
flex: 1; display: block;
padding: 0.85rem 1rem;
background: transparent;
border: 0; outline: 0; resize: vertical;
font-family: var(--mono);
font-size: 0.8rem; line-height: 1.5;
color: var(--text-primary);
min-height: 300px;
}
.yaml-area::placeholder { color: var(--text-tertiary); }
.editor-foot {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 0.85rem;
background: var(--surface-card-hover);
border-top: 1px solid var(--border-secondary);
font-family: var(--mono);
font-size: 0.68rem;
color: var(--text-tertiary);
}
.editor-foot .sep { opacity: 0.5; }
.clear-btn {
margin-left: auto;
background: transparent; border: 0;
color: var(--text-secondary);
font-family: var(--mono);
font-size: 0.66rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
}
.clear-btn:hover {
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
}
/* ── Deploy toggle ─────────────────────────────── */
.deploy-toggle {
display: flex; align-items: flex-start; gap: 0.8rem;
padding: 1rem 1.1rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
margin-bottom: 1.25rem;
cursor: pointer;
transition: border-color 150ms ease;
}
.deploy-toggle:hover { border-color: var(--color-brand-300); }
.deploy-toggle input { position: absolute; opacity: 0; pointer-events: none; }
.toggle-box {
flex-shrink: 0;
width: 20px; height: 20px;
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
background: var(--surface-input);
position: relative;
margin-top: 2px;
}
.deploy-toggle input:checked + .toggle-box {
background: var(--accent);
border-color: var(--accent);
}
.deploy-toggle input:checked + .toggle-box::after {
content: '';
position: absolute;
left: 6px; top: 2px;
width: 5px; height: 10px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.toggle-text strong {
display: block; font-family: var(--serif);
font-size: 1.15rem; font-weight: 400; line-height: 1.2;
color: var(--text-primary); margin-bottom: 0.15rem;
}
.toggle-text .dim { color: var(--text-tertiary); font-size: 0.82rem; }
/* ── Actions ───────────────────────────────────── */
.actions {
display: flex; justify-content: flex-end; gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-secondary);
}
.btn-ghost {
padding: 0.6rem 1.1rem;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-secondary);
font-family: var(--mono);
font-size: 0.72rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
text-decoration: none;
cursor: pointer;
}
.btn-ghost:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
border-color: var(--color-brand-300);
}
.btn-primary {
display: inline-flex; align-items: center; gap: 0.55rem;
padding: 0.65rem 1.2rem;
background: var(--text-primary);
color: var(--surface-card);
border: 0; border-radius: var(--radius-lg);
font-family: var(--mono);
font-size: 0.74rem; font-weight: 600;
letter-spacing: 0.1em; text-transform: uppercase;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
box-shadow: 0 0 0 0 var(--glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 0 0 4px var(--glow);
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.arrow { transition: transform 150ms ease; }
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
</style>