2aff22f565
Build / build (push) Successful in 10m39s
Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).
Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged
Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated
Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
the static-source inline port + hard legacy cutover (Priority 1)
650 lines
17 KiB
Svelte
650 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import * as api from '$lib/api';
|
|
import type { RedeployTrigger } from '$lib/api';
|
|
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
import { t } from '$lib/i18n';
|
|
|
|
// Known kinds drive the kind-aware form switch in /new and the
|
|
// filter chips here. Future kinds are tolerated: an unknown kind
|
|
// renders with a generic label + grey badge instead of dropping
|
|
// the row.
|
|
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule', 'webhook', 'logscan'] as const;
|
|
type KnownKind = (typeof KNOWN_KINDS)[number];
|
|
|
|
let triggers = $state<RedeployTrigger[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let kindFilter = $state<'all' | KnownKind | string>('all');
|
|
|
|
const filtered = $derived(
|
|
kindFilter === 'all' ? triggers : triggers.filter((t) => t.kind === kindFilter)
|
|
);
|
|
|
|
const withWebhook = $derived(triggers.filter((t) => t.webhook_enabled).length);
|
|
const totalBindings = $derived(
|
|
triggers.reduce((sum, t) => sum + (t.binding_count ?? 0), 0)
|
|
);
|
|
|
|
// Group triggers by kind for the hero stat rail. Caps to first
|
|
// three kinds + a roll-up so the rail stays single-line on
|
|
// narrow screens; the chip filter row exposes the full breakdown.
|
|
const byKind = $derived.by(() => {
|
|
const acc: Record<string, number> = {};
|
|
for (const t of triggers) acc[t.kind] = (acc[t.kind] ?? 0) + 1;
|
|
return acc;
|
|
});
|
|
|
|
const presentKinds = $derived(Object.keys(byKind));
|
|
|
|
function kindLabel(k: string): string {
|
|
const key = `redeployTriggers.kind.${k}`;
|
|
const label = $t(key);
|
|
return label === key ? k : label;
|
|
}
|
|
|
|
function kindShort(k: string): string {
|
|
const key = `redeployTriggers.kindShort.${k}`;
|
|
const label = $t(key);
|
|
return label === key ? k.slice(0, 3).toUpperCase() : label;
|
|
}
|
|
|
|
function kindClass(k: string): string {
|
|
// CSS-only kind colour. Falls through to the neutral
|
|
// `kind-other` style for unknown kinds so the row still
|
|
// renders cleanly.
|
|
return KNOWN_KINDS.includes(k as KnownKind) ? `kind-${k}` : 'kind-other';
|
|
}
|
|
|
|
function fmtCreated(iso: string): string {
|
|
try {
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit'
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
triggers = await api.listTriggers();
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Failed to load triggers';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
onMount(load);
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{$t('redeployTriggers.title')} · Tinyforge</title>
|
|
</svelte:head>
|
|
|
|
<div class="forge" aria-busy={loading}>
|
|
{#snippet toolbar()}
|
|
<button
|
|
class="forge-btn-icon"
|
|
onclick={load}
|
|
aria-label={$t('observability.refresh')}
|
|
disabled={loading}
|
|
>
|
|
<IconRefresh size={16} />
|
|
</button>
|
|
<a href="/triggers/new" class="forge-btn">
|
|
<IconPlus size={14} />
|
|
<span>{$t('redeployTriggers.toolbar.newButton')}</span>
|
|
</a>
|
|
{/snippet}
|
|
|
|
{#snippet stats()}
|
|
<div>
|
|
<dt>{$t('redeployTriggers.stat.total')}</dt>
|
|
<dd>{loading ? '—' : String(triggers.length).padStart(2, '0')}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('redeployTriggers.stat.withWebhook')}</dt>
|
|
<dd class="accent">{loading ? '—' : String(withWebhook).padStart(2, '0')}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{$t('redeployTriggers.stat.boundWorkloads')}</dt>
|
|
<dd>{loading ? '—' : String(totalBindings).padStart(2, '0')}</dd>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet lede()}
|
|
{$t('redeployTriggers.lede')}
|
|
{/snippet}
|
|
|
|
<ForgeHero
|
|
eyebrowSuffix={$t('redeployTriggers.section').toUpperCase()}
|
|
title={$t('redeployTriggers.title')}
|
|
size="lg"
|
|
toolbar={toolbar}
|
|
lede_html={lede}
|
|
stats={stats}
|
|
/>
|
|
|
|
{#if error}
|
|
<div class="alert" role="alert">
|
|
<span class="alert-tag">ERR</span><span>{error}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !loading && triggers.length > 0}
|
|
<!-- Kind filter chips. ALL is always present; per-kind chips are
|
|
rendered only for kinds present in the result set so the
|
|
row stays scannable when the operator only uses two kinds. -->
|
|
<div class="filter-row" role="group" aria-label={$t('redeployTriggers.filter.ariaLabel')}>
|
|
<button
|
|
type="button"
|
|
class="chip"
|
|
class:active={kindFilter === 'all'}
|
|
aria-pressed={kindFilter === 'all'}
|
|
onclick={() => (kindFilter = 'all')}
|
|
>
|
|
<span class="chip-label">{$t('redeployTriggers.filter.all')}</span>
|
|
<span class="chip-count">{String(triggers.length).padStart(2, '0')}</span>
|
|
</button>
|
|
{#each presentKinds as k}
|
|
<button
|
|
type="button"
|
|
class="chip"
|
|
class:active={kindFilter === k}
|
|
aria-pressed={kindFilter === k}
|
|
onclick={() => (kindFilter = k)}
|
|
>
|
|
<span class="chip-label">{kindLabel(k)}</span>
|
|
<span class="chip-count">{String(byKind[k]).padStart(2, '0')}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if loading}
|
|
<div class="skeleton-rows" aria-busy="true" aria-live="polite" aria-label={$t('observability.loading')}>
|
|
{#each Array(3) as _, i}
|
|
<div class="skeleton-row" style:--i={i}></div>
|
|
{/each}
|
|
</div>
|
|
{:else if triggers.length === 0}
|
|
<div class="empty">
|
|
<div class="empty-mark" aria-hidden="true">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
<h2>{$t('redeployTriggers.empty.heading')}</h2>
|
|
<p>{$t('redeployTriggers.empty.body')}</p>
|
|
<a href="/triggers/new" class="forge-btn">
|
|
<IconPlus size={14} /><span>{$t('redeployTriggers.empty.cta')}</span>
|
|
</a>
|
|
</div>
|
|
{:else}
|
|
<div class="table-wrap">
|
|
<table class="forge-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{$t('redeployTriggers.list.name')}</th>
|
|
<th>{$t('redeployTriggers.list.kind')}</th>
|
|
<th>{$t('redeployTriggers.list.bindings')}</th>
|
|
<th>{$t('redeployTriggers.list.webhook')}</th>
|
|
<th class="hide-md">{$t('redeployTriggers.list.created')}</th>
|
|
<th class="t-right">{$t('redeployTriggers.list.open')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filtered as trig, i (trig.id)}
|
|
<tr>
|
|
<td>
|
|
<a class="row-link" href={`/triggers/${trig.id}`}>
|
|
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
|
|
<span class="row-name">{trig.name}</span>
|
|
</a>
|
|
</td>
|
|
<td>
|
|
<span class="badge kind {kindClass(trig.kind)}">
|
|
<span class="kind-tag">{kindShort(trig.kind)}</span>
|
|
<span class="kind-name">{kindLabel(trig.kind)}</span>
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{#if trig.binding_count > 0}
|
|
<span class="bindings-pill" title={String(trig.binding_count)}>
|
|
<span class="bp-num">{trig.binding_count}</span>
|
|
<span class="bp-bar" aria-hidden="true">
|
|
{#each Array(Math.min(trig.binding_count, 6)) as _}
|
|
<span></span>
|
|
{/each}
|
|
</span>
|
|
</span>
|
|
{:else}
|
|
<span class="muted mono small">{$t('redeployTriggers.list.noBindings')}</span>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
<span class="status" class:on={trig.webhook_enabled} class:off={!trig.webhook_enabled}>
|
|
<span class="status-dot" aria-hidden="true"></span>
|
|
{trig.webhook_enabled
|
|
? $t('redeployTriggers.list.webhookOn')
|
|
: $t('redeployTriggers.list.webhookOff')}
|
|
</span>
|
|
</td>
|
|
<td class="muted mono small hide-md">{fmtCreated(trig.created_at)}</td>
|
|
<td class="actions-cell">
|
|
<a class="row-action" href={`/triggers/${trig.id}`}>
|
|
{$t('observability.open')} <span class="arrow" aria-hidden="true">→</span>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.forge {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.25rem;
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* ── Alert ─────────────────────────────────────── */
|
|
.alert {
|
|
display: flex;
|
|
gap: 0.7rem;
|
|
align-items: center;
|
|
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(--forge-mono);
|
|
font-weight: 700;
|
|
font-size: 0.65rem;
|
|
letter-spacing: 0.16em;
|
|
padding: 0.15rem 0.4rem;
|
|
background: var(--color-danger);
|
|
color: var(--surface-card);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
:global([data-theme='dark']) .alert {
|
|
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
|
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
|
|
}
|
|
|
|
/* ── Filter chips ──────────────────────────────── */
|
|
.filter-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
}
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.35rem 0.75rem;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-full);
|
|
cursor: pointer;
|
|
color: var(--text-secondary);
|
|
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
|
|
transform 150ms ease;
|
|
}
|
|
.chip:hover {
|
|
background: var(--surface-card-hover);
|
|
color: var(--text-primary);
|
|
transform: translateY(-1px);
|
|
}
|
|
.chip:focus-visible {
|
|
outline: 2px solid var(--border-focus);
|
|
outline-offset: 2px;
|
|
}
|
|
.chip.active {
|
|
background: var(--text-primary);
|
|
color: var(--surface-card);
|
|
border-color: var(--text-primary);
|
|
}
|
|
.chip-label {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
}
|
|
.chip-count {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.6rem;
|
|
opacity: 0.7;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* ── Skeleton ──────────────────────────────────── */
|
|
.skeleton-rows {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
}
|
|
.skeleton-row {
|
|
height: 52px;
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-lg);
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* ── 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(--forge-accent);
|
|
animation: ember 2.4s ease-in-out infinite;
|
|
}
|
|
@keyframes ember {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 3px var(--forge-accent-soft);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
|
}
|
|
}
|
|
.empty h2 {
|
|
font-weight: 700;
|
|
font-size: 1.5rem;
|
|
margin: 0 0 0.5rem;
|
|
letter-spacing: -0.01em;
|
|
color: var(--text-primary);
|
|
}
|
|
.empty p {
|
|
color: var(--text-secondary);
|
|
margin: 0 auto 1.5rem;
|
|
font-size: 0.95rem;
|
|
max-width: 52ch;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* ── Table ─────────────────────────────────────── */
|
|
.table-wrap {
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-xl);
|
|
background: var(--surface-card);
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
.table-wrap :global(.forge-table) {
|
|
min-width: 720px;
|
|
}
|
|
.t-right {
|
|
text-align: right;
|
|
}
|
|
.actions-cell {
|
|
text-align: right;
|
|
}
|
|
@media (max-width: 820px) {
|
|
.hide-md {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* ── Row link / action ─────────────────────────── */
|
|
.row-link {
|
|
display: inline-flex;
|
|
align-items: baseline;
|
|
gap: 0.6rem;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
transition: color 120ms ease;
|
|
}
|
|
.row-link:hover {
|
|
color: var(--forge-accent);
|
|
}
|
|
.row-link:focus-visible {
|
|
outline: 2px solid var(--border-focus);
|
|
outline-offset: 2px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.row-ref {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.68rem;
|
|
letter-spacing: 0.1em;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.row-name {
|
|
font-weight: 600;
|
|
}
|
|
.row-action {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.68rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--forge-accent);
|
|
text-decoration: none;
|
|
}
|
|
.row-action:hover {
|
|
color: var(--color-brand-500);
|
|
}
|
|
.arrow {
|
|
display: inline-block;
|
|
transition: transform 150ms ease;
|
|
}
|
|
.row-action:hover .arrow {
|
|
transform: translateX(3px);
|
|
}
|
|
|
|
/* ── Kind badge ─────────────────────────────────
|
|
Two-segment pill: a tight monospace tag (REG, GIT…)
|
|
followed by the human-readable kind name. The tag
|
|
carries the colour so the eye can pick out the kind
|
|
even when the operator filters all rows down to two
|
|
kinds and the names line up.
|
|
*/
|
|
.badge.kind {
|
|
display: inline-flex;
|
|
align-items: stretch;
|
|
overflow: hidden;
|
|
border-radius: var(--radius-full);
|
|
border: 1px solid var(--border-primary);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
background: var(--surface-card-hover);
|
|
color: var(--text-secondary);
|
|
}
|
|
.kind-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.18rem 0.5rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.16em;
|
|
color: var(--surface-card);
|
|
background: var(--text-primary);
|
|
}
|
|
.kind-name {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.18rem 0.55rem 0.18rem 0.5rem;
|
|
}
|
|
|
|
/* Per-kind colour. The pattern matches the rest of the app:
|
|
coloured tag + soft-tinted body that reads in both themes. */
|
|
.badge.kind.kind-registry .kind-tag {
|
|
background: var(--color-brand-600);
|
|
}
|
|
.badge.kind.kind-registry {
|
|
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-brand-500) 30%, transparent);
|
|
color: var(--color-brand-600);
|
|
}
|
|
.badge.kind.kind-git .kind-tag {
|
|
background: #6b46c1;
|
|
}
|
|
.badge.kind.kind-git {
|
|
background: color-mix(in srgb, #8b5cf6 12%, transparent);
|
|
border-color: color-mix(in srgb, #8b5cf6 32%, transparent);
|
|
color: #6b46c1;
|
|
}
|
|
:global([data-theme='dark']) .badge.kind.kind-git {
|
|
color: #c4b5fd;
|
|
}
|
|
.badge.kind.kind-manual .kind-tag {
|
|
background: var(--text-secondary);
|
|
}
|
|
.badge.kind.kind-schedule .kind-tag {
|
|
background: var(--color-warning, #f59e0b);
|
|
}
|
|
.badge.kind.kind-schedule {
|
|
background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
|
|
color: var(--color-warning-dark, #b45309);
|
|
}
|
|
.badge.kind.kind-webhook .kind-tag {
|
|
background: var(--forge-accent);
|
|
}
|
|
.badge.kind.kind-webhook {
|
|
background: var(--forge-accent-soft);
|
|
border-color: color-mix(in srgb, var(--forge-accent) 35%, transparent);
|
|
color: var(--forge-accent);
|
|
}
|
|
.badge.kind.kind-logscan .kind-tag {
|
|
background: #0891b2;
|
|
}
|
|
.badge.kind.kind-logscan {
|
|
background: color-mix(in srgb, #06b6d4 12%, transparent);
|
|
border-color: color-mix(in srgb, #06b6d4 32%, transparent);
|
|
color: #0e7490;
|
|
}
|
|
:global([data-theme='dark']) .badge.kind.kind-logscan {
|
|
color: #67e8f9;
|
|
}
|
|
|
|
/* ── Bindings pill ──────────────────────────────
|
|
Number + miniature segmented bar that visually
|
|
communicates fan-out without taking a whole column.
|
|
*/
|
|
.bindings-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
padding: 0.18rem 0.55rem;
|
|
background: var(--surface-card-hover);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-full);
|
|
}
|
|
.bp-num {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.bp-bar {
|
|
display: inline-flex;
|
|
gap: 2px;
|
|
}
|
|
.bp-bar span {
|
|
width: 4px;
|
|
height: 10px;
|
|
background: var(--forge-accent);
|
|
border-radius: 1px;
|
|
opacity: 0.85;
|
|
}
|
|
.bp-bar span:nth-child(2) { opacity: 0.7; }
|
|
.bp-bar span:nth-child(3) { opacity: 0.6; }
|
|
.bp-bar span:nth-child(4) { opacity: 0.5; }
|
|
.bp-bar span:nth-child(5) { opacity: 0.4; }
|
|
.bp-bar span:nth-child(6) { opacity: 0.3; }
|
|
|
|
.small {
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
/* ── Status (webhook on/off) ──────────────────── */
|
|
.status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.1em;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
}
|
|
.status-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
}
|
|
.status.on {
|
|
color: var(--color-success-dark);
|
|
}
|
|
.status.on .status-dot {
|
|
background: var(--color-success);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 20%, transparent);
|
|
}
|
|
.status.off {
|
|
color: var(--text-tertiary);
|
|
}
|
|
.status.off .status-dot {
|
|
background: var(--text-tertiary);
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.muted {
|
|
color: var(--text-tertiary);
|
|
}
|
|
.mono {
|
|
font-family: var(--forge-mono);
|
|
}
|
|
</style>
|