feat: daemon health panel, brand-rail status chips, user timezone selector
Build / build (push) Successful in 10m35s
Build / build (push) Successful in 10m35s
- Health API now surfaces Docker /info + /version (version, platform, kernel, container/image counts, storage driver, memory, latency) and NPM aggregates (proxy host total, managed-by-Tinyforge count, access lists, certificates, endpoint URL). - Docker/NPM indicators moved out of the sidebar footer and into a compact mono-styled rail directly under the Tinyforge brand title, with pulse/fault animations and click-to-expand error hints. - New SystemDaemonsCard on the dashboard: two terminal-styled panels (Docker Engine + Proxy) with a running/paused/stopped stacked bar, key-value diagnostics, and a total-vs-managed proportion meter on the proxy-hosts tile. - Shared health store so the sidebar and dashboard share a single 30 s poll instead of duplicating traffic. - User-facing timezone preference with auto-detect fallback; all dates across projects, sites, stacks, settings, backup, event log and stale containers now render through \$fmt.date / \$fmt.datetime. - en/ru translations for both features.
This commit is contained in:
@@ -0,0 +1,780 @@
|
||||
<!--
|
||||
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
|
||||
);
|
||||
|
||||
function formatBytes(n: number | undefined): string {
|
||||
if (!n || n <= 0) return '—';
|
||||
const gb = n / 1024 ** 3;
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||
const mb = n / 1024 ** 2;
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
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 ?? 'Docker daemon is not reachable.'}</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 ?? `${proxyProvider.toUpperCase()} is not reachable.`}</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>
|
||||
Reference in New Issue
Block a user