410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
780 lines
23 KiB
Svelte
780 lines
23 KiB
Svelte
<!--
|
|
Dashboard daemon panel. Renders detailed runtime info for the Docker engine
|
|
and the configured proxy provider (NPM or Traefik). Subscribes to the
|
|
shared health store so there is no extra network traffic beyond the
|
|
single /api/health poll already running for the sidebar chips.
|
|
|
|
Aesthetic: control-room / forge — two terminal-styled panels side by side,
|
|
with key/value rows, a container-state bar graph, and registration marks
|
|
that reveal on hover. Dimensions are generous but everything is rendered
|
|
in mono to reinforce the "instrumentation" feel.
|
|
-->
|
|
<script lang="ts">
|
|
import { health, refreshHealth } from '$lib/stores/health';
|
|
import { IconRefresh, IconContainer, IconServer, IconShield, IconWifi } from '$lib/components/icons';
|
|
import { t } from '$lib/i18n';
|
|
|
|
const docker = $derived($health.docker);
|
|
const proxy = $derived($health.proxy);
|
|
const checked = $derived($health.checked);
|
|
|
|
const dockerConnected = $derived(docker?.connected ?? false);
|
|
const proxyConnected = $derived(proxy?.connected ?? false);
|
|
const proxyProvider = $derived(proxy?.provider ?? 'none');
|
|
|
|
const totalContainers = $derived(
|
|
(docker?.running ?? 0) + (docker?.paused ?? 0) + (docker?.stopped ?? 0)
|
|
);
|
|
|
|
const runningPct = $derived(
|
|
totalContainers > 0 ? ((docker?.running ?? 0) / totalContainers) * 100 : 0
|
|
);
|
|
const pausedPct = $derived(
|
|
totalContainers > 0 ? ((docker?.paused ?? 0) / totalContainers) * 100 : 0
|
|
);
|
|
const stoppedPct = $derived(
|
|
totalContainers > 0 ? ((docker?.stopped ?? 0) / totalContainers) * 100 : 0
|
|
);
|
|
|
|
import { formatBytes as formatBytesShared } from '$lib/format/bytes';
|
|
|
|
function formatBytes(n: number | undefined): string {
|
|
if (!n || n <= 0) return '—';
|
|
return formatBytesShared(n);
|
|
}
|
|
|
|
function formatMs(n: number | undefined): string {
|
|
if (typeof n !== 'number') return '—';
|
|
return `${n} ms`;
|
|
}
|
|
|
|
let refreshing = $state(false);
|
|
async function onRefresh(): Promise<void> {
|
|
if (refreshing) return;
|
|
refreshing = true;
|
|
try {
|
|
await refreshHealth();
|
|
} finally {
|
|
refreshing = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<section class="daemons">
|
|
<header class="daemons-head">
|
|
<div>
|
|
<span class="eyebrow">SYSTEMS // DAEMONS</span>
|
|
<h2 class="daemons-title">{$t('daemons.title')}<span class="accent">.</span></h2>
|
|
</div>
|
|
<button class="refresh-btn" type="button" onclick={onRefresh} disabled={refreshing} title={$t('daemons.refresh')}>
|
|
<span class:spin={refreshing} class="refresh-icon"><IconRefresh size={14} /></span>
|
|
<span>{refreshing ? $t('daemons.refreshing') : $t('daemons.refresh')}</span>
|
|
</button>
|
|
</header>
|
|
|
|
<div class="daemons-grid">
|
|
<!-- ═══════════ DOCKER PANEL ═══════════ -->
|
|
<article class="panel" class:panel-down={checked && !dockerConnected}>
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<IconContainer size={15} />
|
|
<span>{$t('daemons.docker')}</span>
|
|
</div>
|
|
<span class="status-pill" class:live={dockerConnected} class:down={!dockerConnected}>
|
|
<span class="status-dot"></span>
|
|
{dockerConnected ? $t('daemons.online') : $t('daemons.offline')}
|
|
</span>
|
|
</div>
|
|
|
|
{#if !checked}
|
|
<div class="panel-skeleton">
|
|
<div class="skel-bar"></div>
|
|
<div class="skel-bar skel-bar-narrow"></div>
|
|
<div class="skel-bar"></div>
|
|
</div>
|
|
{:else if !dockerConnected}
|
|
<div class="panel-error">
|
|
<code>{docker?.error ?? $t('daemons.dockerNotReachable')}</code>
|
|
<p>{$t('daemons.dockerHint')}</p>
|
|
</div>
|
|
{:else}
|
|
<!-- Container state breakdown — stacked bar -->
|
|
<div class="state-block">
|
|
<div class="state-head">
|
|
<span class="state-caption">{$t('daemons.containers')}</span>
|
|
<span class="state-total">{totalContainers}</span>
|
|
</div>
|
|
{#if totalContainers > 0}
|
|
<div class="state-bar" aria-hidden="true">
|
|
<span class="seg seg-run" style="width: {runningPct}%"></span>
|
|
<span class="seg seg-pause" style="width: {pausedPct}%"></span>
|
|
<span class="seg seg-stop" style="width: {stoppedPct}%"></span>
|
|
</div>
|
|
{:else}
|
|
<div class="state-bar empty" aria-hidden="true"></div>
|
|
{/if}
|
|
<div class="state-legend">
|
|
<span class="leg"><i class="ld ld-run"></i>{$t('daemons.running')} <b>{docker?.running ?? 0}</b></span>
|
|
<span class="leg"><i class="ld ld-pause"></i>{$t('daemons.paused')} <b>{docker?.paused ?? 0}</b></span>
|
|
<span class="leg"><i class="ld ld-stop"></i>{$t('daemons.stopped')} <b>{docker?.stopped ?? 0}</b></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Key/value grid -->
|
|
<dl class="kv">
|
|
<div>
|
|
<dt>{$t('daemons.version')}</dt>
|
|
<dd>{docker?.version ?? '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.apiVersion')}</dt>
|
|
<dd>{docker?.api_version ?? '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.platform')}</dt>
|
|
<dd>{docker?.os ?? '—'}{docker?.arch ? ` · ${docker.arch}` : ''}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.kernel')}</dt>
|
|
<dd class="truncate" title={docker?.kernel ?? ''}>{docker?.kernel ?? '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.cpu')}</dt>
|
|
<dd>{docker?.ncpu ? `${docker.ncpu} cores` : '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.memory')}</dt>
|
|
<dd>{formatBytes(docker?.memory_total)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.storage')}</dt>
|
|
<dd>{docker?.storage_driver ?? '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.images')}</dt>
|
|
<dd>{docker?.images ?? 0}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.latency')}</dt>
|
|
<dd>{formatMs(docker?.latency_ms)}</dd>
|
|
</div>
|
|
<div class="kv-wide">
|
|
<dt>{$t('daemons.rootDir')}</dt>
|
|
<dd class="truncate" title={docker?.root_dir ?? ''}>{docker?.root_dir ?? '—'}</dd>
|
|
</div>
|
|
</dl>
|
|
{/if}
|
|
</article>
|
|
|
|
<!-- ═══════════ PROXY PANEL ═══════════ -->
|
|
<article class="panel" class:panel-down={checked && !proxyConnected && proxyProvider !== 'none'}>
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
{#if proxyProvider === 'npm'}
|
|
<IconShield size={15} />
|
|
{:else}
|
|
<IconWifi size={15} />
|
|
{/if}
|
|
<span>
|
|
{proxyProvider === 'npm'
|
|
? $t('daemons.npm')
|
|
: proxyProvider === 'traefik'
|
|
? $t('daemons.traefik')
|
|
: $t('daemons.proxy')}
|
|
</span>
|
|
</div>
|
|
{#if proxyProvider === 'none' || !proxy}
|
|
<span class="status-pill idle">
|
|
<span class="status-dot"></span>
|
|
{$t('daemons.notConfigured')}
|
|
</span>
|
|
{:else}
|
|
<span class="status-pill" class:live={proxyConnected} class:down={!proxyConnected}>
|
|
<span class="status-dot"></span>
|
|
{proxyConnected ? $t('daemons.online') : $t('daemons.offline')}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !checked}
|
|
<div class="panel-skeleton">
|
|
<div class="skel-bar"></div>
|
|
<div class="skel-bar skel-bar-narrow"></div>
|
|
<div class="skel-bar"></div>
|
|
</div>
|
|
{:else if proxyProvider === 'none' || !proxy}
|
|
<div class="panel-empty">
|
|
<IconServer size={20} />
|
|
<p>{$t('daemons.noProxyDesc')}</p>
|
|
<a href="/settings" class="inline-link">{$t('daemons.configureProxy')} →</a>
|
|
</div>
|
|
{:else if !proxyConnected}
|
|
<div class="panel-error">
|
|
<code>{proxy.error ?? $t('daemons.notReachable', { provider: proxyProvider.toUpperCase() })}</code>
|
|
<p>{$t('daemons.proxyHint')}</p>
|
|
{#if proxy.url}
|
|
<p class="url"><span class="kdim">URL</span> <code>{proxy.url}</code></p>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
{#if proxyProvider === 'npm'}
|
|
{@const total = proxy.proxy_hosts ?? 0}
|
|
{@const managed = proxy.proxy_hosts_managed ?? 0}
|
|
{@const external = Math.max(0, total - managed)}
|
|
{@const managedPct = total > 0 ? (managed / total) * 100 : 0}
|
|
|
|
<!-- NPM stat tiles -->
|
|
<div class="proxy-stats">
|
|
<a href="/proxies" class="pstat pstat-wide">
|
|
<span class="pstat-label">{$t('daemons.proxyHosts')}</span>
|
|
<div class="pstat-split">
|
|
<span class="pstat-value">{total}</span>
|
|
<span class="pstat-sep">·</span>
|
|
<span class="pstat-sub">
|
|
<span class="pstat-chip managed">
|
|
<i class="pstat-dot managed"></i>
|
|
{managed} <em>{$t('daemons.managed')}</em>
|
|
</span>
|
|
<span class="pstat-chip external">
|
|
<i class="pstat-dot external"></i>
|
|
{external} <em>{$t('daemons.external')}</em>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{#if total > 0}
|
|
<div class="pstat-meter" aria-hidden="true">
|
|
<span class="meter-fill" style="width: {managedPct}%"></span>
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
<a href="/settings/npm" class="pstat">
|
|
<span class="pstat-label">{$t('daemons.accessLists')}</span>
|
|
<span class="pstat-value">{proxy.access_lists ?? 0}</span>
|
|
</a>
|
|
<a href="/settings/npm" class="pstat">
|
|
<span class="pstat-label">{$t('daemons.certificates')}</span>
|
|
<span class="pstat-value">{proxy.certificates ?? 0}</span>
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
|
|
<dl class="kv">
|
|
<div>
|
|
<dt>{$t('daemons.provider')}</dt>
|
|
<dd class="mono">{proxyProvider.toUpperCase()}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('daemons.latency')}</dt>
|
|
<dd>{formatMs(proxy.latency_ms)}</dd>
|
|
</div>
|
|
<div class="kv-wide">
|
|
<dt>{$t('daemons.endpoint')}</dt>
|
|
<dd class="truncate" title={proxy.url ?? ''}>{proxy.url ?? '—'}</dd>
|
|
</div>
|
|
</dl>
|
|
{/if}
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.daemons {
|
|
position: relative;
|
|
}
|
|
|
|
/* ── Header ─────────────────────────────────────────────── */
|
|
.daemons-head {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 0.85rem;
|
|
}
|
|
.eyebrow {
|
|
display: block;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.22em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
.daemons-title {
|
|
font-family: var(--font-family-sans);
|
|
font-size: 1.35rem;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
letter-spacing: -0.01em;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
}
|
|
.daemons-title .accent {
|
|
color: var(--forge-accent);
|
|
font-weight: 700;
|
|
}
|
|
.refresh-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.38rem 0.7rem;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 8px;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
}
|
|
.refresh-btn:hover:not(:disabled) {
|
|
color: var(--text-primary);
|
|
border-color: var(--color-brand-400);
|
|
background: var(--surface-card-hover);
|
|
}
|
|
.refresh-btn:disabled { opacity: 0.55; cursor: wait; }
|
|
.refresh-icon { display: inline-flex; }
|
|
.refresh-icon.spin :global(svg) { animation: daemon-spin 0.9s linear infinite; }
|
|
|
|
@keyframes daemon-spin { to { transform: rotate(360deg); } }
|
|
|
|
/* ── Grid ───────────────────────────────────────────────── */
|
|
.daemons-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 0.75rem;
|
|
}
|
|
@media (min-width: 900px) {
|
|
.daemons-grid { grid-template-columns: 1.15fr 1fr; }
|
|
}
|
|
|
|
/* ── Panel base ─────────────────────────────────────────── */
|
|
.panel {
|
|
position: relative;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 14px;
|
|
padding: 1rem 1.1rem 1.1rem;
|
|
overflow: hidden;
|
|
}
|
|
.panel::before {
|
|
/* Registration mark: top-left corner tick */
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: 0;
|
|
width: 14px; height: 14px;
|
|
border-top: 2px solid var(--color-brand-500);
|
|
border-left: 2px solid var(--color-brand-500);
|
|
border-top-left-radius: 14px;
|
|
opacity: 0;
|
|
transition: opacity 200ms ease;
|
|
}
|
|
.panel:hover::before { opacity: 1; }
|
|
.panel::after {
|
|
/* Subtle orange wash top */
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0 0 auto 0;
|
|
height: 2px;
|
|
background: linear-gradient(90deg, var(--color-brand-500), transparent 70%);
|
|
opacity: 0;
|
|
transition: opacity 250ms ease;
|
|
}
|
|
.panel:hover::after { opacity: 0.55; }
|
|
.panel-down {
|
|
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
|
|
background: color-mix(in srgb, var(--color-danger) 3%, var(--surface-card));
|
|
}
|
|
|
|
.panel-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.85rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 1px dashed var(--border-secondary);
|
|
}
|
|
.panel-title {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.16em;
|
|
text-transform: uppercase;
|
|
color: var(--text-primary);
|
|
}
|
|
.panel-title :global(svg) { color: var(--color-brand-600); }
|
|
|
|
.status-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.22rem 0.55rem;
|
|
border-radius: 999px;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.58rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.16em;
|
|
text-transform: uppercase;
|
|
background: var(--surface-card-hover);
|
|
border: 1px solid var(--border-primary);
|
|
color: var(--text-tertiary);
|
|
}
|
|
.status-pill .status-dot {
|
|
width: 6px; height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--text-tertiary);
|
|
}
|
|
.status-pill.live {
|
|
color: var(--color-success-dark);
|
|
background: color-mix(in srgb, var(--color-success) 9%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
|
|
}
|
|
.status-pill.live .status-dot {
|
|
background: var(--color-success);
|
|
animation: daemon-pulse 1.8s ease-in-out infinite;
|
|
}
|
|
.status-pill.down {
|
|
color: var(--color-danger-dark);
|
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
|
|
}
|
|
.status-pill.down .status-dot {
|
|
background: var(--color-danger);
|
|
animation: daemon-blink 0.9s steps(2) infinite;
|
|
}
|
|
.status-pill.idle { opacity: 0.75; }
|
|
|
|
:global([data-theme='dark']) .status-pill.live { color: #86efac; }
|
|
:global([data-theme='dark']) .status-pill.down { color: #fca5a5; }
|
|
|
|
@keyframes daemon-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-success) 40%, transparent); }
|
|
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 0%, transparent); }
|
|
}
|
|
@keyframes daemon-blink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
|
|
/* ── Container state bar ───────────────────────────────── */
|
|
.state-block {
|
|
margin-bottom: 0.95rem;
|
|
padding: 0.75rem 0.85rem;
|
|
background: color-mix(in srgb, var(--color-brand-500) 4%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
|
border-radius: 10px;
|
|
}
|
|
.state-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.state-caption {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.58rem;
|
|
letter-spacing: 0.2em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.state-total {
|
|
font-family: var(--font-family-sans);
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: var(--text-primary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.state-bar {
|
|
display: flex;
|
|
height: 10px;
|
|
width: 100%;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
background: var(--border-secondary);
|
|
}
|
|
.state-bar.empty {
|
|
background: repeating-linear-gradient(45deg, var(--border-secondary), var(--border-secondary) 4px, transparent 4px, transparent 8px);
|
|
border: 1px dashed var(--border-primary);
|
|
}
|
|
.seg { display: block; height: 100%; transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1); }
|
|
.seg-run { background: var(--color-success); }
|
|
.seg-pause { background: var(--color-warning); }
|
|
.seg-stop { background: var(--text-tertiary); }
|
|
.state-legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.9rem;
|
|
margin-top: 0.55rem;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
color: var(--text-secondary);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.state-legend b { color: var(--text-primary); font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 0.25rem; }
|
|
.leg { display: inline-flex; align-items: center; gap: 0.4rem; }
|
|
.ld { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
|
|
.ld-run { background: var(--color-success); }
|
|
.ld-pause { background: var(--color-warning); }
|
|
.ld-stop { background: var(--text-tertiary); }
|
|
|
|
/* ── Key/Value grid ────────────────────────────────────── */
|
|
.kv {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 0.45rem 1.1rem;
|
|
margin: 0;
|
|
}
|
|
.kv > div {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.1rem;
|
|
min-width: 0;
|
|
}
|
|
.kv-wide { grid-column: 1 / -1; }
|
|
.kv dt {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.54rem;
|
|
letter-spacing: 0.2em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
margin: 0;
|
|
}
|
|
.kv dd {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.78rem;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
line-height: 1.35;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.kv dd.mono { letter-spacing: 0.04em; }
|
|
.truncate {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Proxy stat tiles ─────────────────────────────────── */
|
|
.proxy-stats {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
grid-template-areas:
|
|
'wide wide'
|
|
'a b';
|
|
gap: 0.4rem;
|
|
margin-bottom: 0.95rem;
|
|
}
|
|
.pstat-wide { grid-area: wide; }
|
|
.pstat {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
padding: 0.6rem 0.7rem;
|
|
background: color-mix(in srgb, var(--color-brand-500) 4%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--color-brand-500) 16%, transparent);
|
|
border-radius: 10px;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
transition: all 150ms ease;
|
|
}
|
|
.pstat:hover {
|
|
border-color: var(--color-brand-500);
|
|
background: color-mix(in srgb, var(--color-brand-500) 9%, transparent);
|
|
transform: translateY(-1px);
|
|
}
|
|
.pstat-label {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.52rem;
|
|
letter-spacing: 0.2em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.pstat-value {
|
|
font-family: var(--font-family-sans);
|
|
font-size: 1.35rem;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: var(--text-primary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Split-stat layout for proxy hosts (total / managed / external). */
|
|
.pstat-split {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.55rem;
|
|
flex-wrap: wrap;
|
|
margin-top: 0.15rem;
|
|
}
|
|
.pstat-split .pstat-value {
|
|
font-size: 1.8rem;
|
|
line-height: 1;
|
|
}
|
|
.pstat-sep {
|
|
color: var(--text-tertiary);
|
|
font-family: var(--forge-mono);
|
|
font-size: 1.2rem;
|
|
line-height: 1;
|
|
opacity: 0.55;
|
|
}
|
|
.pstat-sub {
|
|
display: inline-flex;
|
|
gap: 0.4rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.pstat-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.12rem 0.45rem 0.12rem 0.35rem;
|
|
border-radius: 999px;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.66rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.04em;
|
|
color: var(--text-primary);
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.pstat-chip em {
|
|
font-style: normal;
|
|
font-weight: 500;
|
|
font-size: 0.56rem;
|
|
letter-spacing: 0.18em;
|
|
text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
margin-left: 0.1rem;
|
|
}
|
|
.pstat-chip.managed {
|
|
border-color: color-mix(in srgb, var(--color-brand-500) 40%, transparent);
|
|
color: var(--color-brand-700);
|
|
background: color-mix(in srgb, var(--color-brand-500) 10%, var(--surface-card));
|
|
}
|
|
.pstat-chip.external {
|
|
border-color: var(--border-primary);
|
|
color: var(--text-secondary);
|
|
}
|
|
:global([data-theme='dark']) .pstat-chip.managed {
|
|
color: #c7d2fe;
|
|
background: color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
|
}
|
|
.pstat-dot {
|
|
width: 7px; height: 7px; border-radius: 50%;
|
|
display: inline-block;
|
|
}
|
|
.pstat-dot.managed { background: var(--color-brand-500); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
|
.pstat-dot.external { background: var(--text-tertiary); opacity: 0.6; }
|
|
|
|
/* Proportion meter under the total. */
|
|
.pstat-meter {
|
|
margin-top: 0.55rem;
|
|
height: 4px;
|
|
border-radius: 999px;
|
|
background: var(--border-secondary);
|
|
overflow: hidden;
|
|
}
|
|
.meter-fill {
|
|
display: block;
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--color-brand-500), var(--color-brand-600));
|
|
border-radius: inherit;
|
|
transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
}
|
|
|
|
/* ── Error / empty states ─────────────────────────────── */
|
|
.panel-error {
|
|
padding: 0.75rem 0.85rem;
|
|
border-radius: 10px;
|
|
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
|
|
}
|
|
.panel-error code {
|
|
display: block;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.7rem;
|
|
line-height: 1.55;
|
|
color: var(--color-danger-dark);
|
|
word-break: break-word;
|
|
margin-bottom: 0.45rem;
|
|
}
|
|
.panel-error p {
|
|
margin: 0;
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
.panel-error .url {
|
|
margin-top: 0.45rem;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.7rem;
|
|
}
|
|
.panel-error .url code {
|
|
display: inline;
|
|
color: var(--text-primary);
|
|
background: var(--surface-card-hover);
|
|
padding: 0.08rem 0.35rem;
|
|
border-radius: 4px;
|
|
margin: 0;
|
|
}
|
|
.kdim {
|
|
display: inline-block;
|
|
color: var(--text-tertiary);
|
|
letter-spacing: 0.2em;
|
|
text-transform: uppercase;
|
|
font-size: 0.54rem;
|
|
margin-right: 0.4rem;
|
|
}
|
|
:global([data-theme='dark']) .panel-error code { color: #fca5a5; }
|
|
|
|
.panel-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
padding: 0.9rem 1rem;
|
|
background: var(--surface-card-hover);
|
|
border: 1px dashed var(--border-primary);
|
|
border-radius: 10px;
|
|
color: var(--text-secondary);
|
|
}
|
|
.panel-empty :global(svg) { color: var(--text-tertiary); }
|
|
.panel-empty p { margin: 0; font-size: 0.8rem; }
|
|
.inline-link {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.66rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--color-brand-600);
|
|
text-decoration: none;
|
|
}
|
|
.inline-link:hover { text-decoration: underline; }
|
|
|
|
/* ── Skeleton ─────────────────────────────────────────── */
|
|
.panel-skeleton {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
padding: 0.4rem 0;
|
|
}
|
|
.skel-bar {
|
|
height: 10px;
|
|
border-radius: 6px;
|
|
background: linear-gradient(90deg, var(--surface-card-hover) 0%, var(--border-secondary) 50%, var(--surface-card-hover) 100%);
|
|
background-size: 200% 100%;
|
|
animation: skel-shift 1.4s ease-in-out infinite;
|
|
}
|
|
.skel-bar-narrow { width: 55%; }
|
|
@keyframes skel-shift {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
</style>
|