feat(alerts): metric-alert rule-management UI (Phase 2)
Completes metric-threshold alerting end-to-end: /metric-alert-rules list/new/edit routes (mirroring log-scan-rules) with metric/comparator/ threshold fields, the workload scope picker, ToggleSwitch, and a ConfirmDialog delete flow; an api.ts MetricAlertRule CRUD client; an "Observe" nav entry; and a full metricalert.* i18n namespace (en/ru parity). Create-form cooldown defaults to 300s to match the server. Rules are now manageable in the WebUI; breaches already surface in the per-app activity timeline and fire any configured event-trigger webhook. Reviewed: typescript APPROVE (0 CRITICAL/HIGH).
This commit is contained in:
@@ -1375,3 +1375,56 @@ export function getLogScanStats(signal?: AbortSignal): Promise<LogScanStats> {
|
||||
return get<LogScanStats>('/api/log-scan-rules/stats', signal);
|
||||
}
|
||||
|
||||
// ── Metric alert rules ──────────────────────────────────────────────
|
||||
// Backend: internal/api/metric_alert_rules.go. Rules compare a sampled
|
||||
// container metric (cpu/memory) against a threshold using a comparator.
|
||||
// Scope model: workload_id="" → global; workload_id set → workload-only.
|
||||
// Unlike log-scan rules there is no override / test / effective-rules
|
||||
// concept — a metric-alert rule is a flat threshold check.
|
||||
|
||||
export interface MetricAlertRule {
|
||||
id: number;
|
||||
workload_id: string; // "" = global
|
||||
name: string;
|
||||
metric: 'cpu_percent' | 'memory_percent' | 'memory_bytes';
|
||||
comparator: 'gt' | 'lt';
|
||||
threshold: number;
|
||||
severity: 'info' | 'warn' | 'error';
|
||||
cooldown_seconds: number;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface MetricAlertRuleInput {
|
||||
workload_id?: string;
|
||||
name: string;
|
||||
metric: 'cpu_percent' | 'memory_percent' | 'memory_bytes';
|
||||
comparator: 'gt' | 'lt';
|
||||
threshold: number;
|
||||
severity?: 'info' | 'warn' | 'error';
|
||||
cooldown_seconds?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
export function listMetricAlertRules(opts?: {
|
||||
workloadID?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<MetricAlertRule[]> {
|
||||
const params = opts?.workloadID ? `?workload_id=${encodeURIComponent(opts.workloadID)}` : '';
|
||||
return get<MetricAlertRule[]>(`/api/metric-alert-rules${params}`, opts?.signal);
|
||||
}
|
||||
export function getMetricAlertRule(id: number, signal?: AbortSignal): Promise<MetricAlertRule> {
|
||||
return get<MetricAlertRule>(`/api/metric-alert-rules/${id}`, signal);
|
||||
}
|
||||
export function createMetricAlertRule(data: MetricAlertRuleInput): Promise<MetricAlertRule> {
|
||||
return post<MetricAlertRule>('/api/metric-alert-rules', data);
|
||||
}
|
||||
export function updateMetricAlertRule(
|
||||
id: number,
|
||||
data: MetricAlertRuleInput
|
||||
): Promise<MetricAlertRule> {
|
||||
return patch<MetricAlertRule>(`/api/metric-alert-rules/${id}`, data);
|
||||
}
|
||||
export function deleteMetricAlertRule(id: number): Promise<void> {
|
||||
return del<void>(`/api/metric-alert-rules/${id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"apps": "Apps",
|
||||
"eventTriggers": "Event Triggers",
|
||||
"logScanRules": "Log Rules",
|
||||
"metricAlertRules": "Metric Alerts",
|
||||
"triggers": "Triggers",
|
||||
"proxies": "Proxies",
|
||||
"events": "Events",
|
||||
@@ -887,6 +888,105 @@
|
||||
"disabled": "disabled"
|
||||
}
|
||||
},
|
||||
"metricalert": {
|
||||
"title": "Metric alert rules",
|
||||
"titleNew": "Forge a new alert",
|
||||
"titleSingular": "Alert rule",
|
||||
"lede": "Threshold checks the watcher runs against each running container's sampled CPU and memory. When a sample crosses the threshold the rule fires into event_log with the rule's severity, where event triggers pick it up and fan out to operator-configured webhooks. {enabled} of {total} enabled.",
|
||||
"ledeNew": "Pick a metric, a comparator, and a threshold. Leave the workload field empty to create a global rule that applies to every workload, or scope it to a single workload.",
|
||||
"stat": {
|
||||
"total": "TOTAL",
|
||||
"global": "GLOBAL",
|
||||
"workload": "WORKLOAD",
|
||||
"enabled": "ENABLED"
|
||||
},
|
||||
"toolbar": {
|
||||
"newButton": "New alert",
|
||||
"backToList": "Back to alerts"
|
||||
},
|
||||
"filter": {
|
||||
"all": "ALL",
|
||||
"global": "GLOBAL",
|
||||
"workload": "WORKLOAD"
|
||||
},
|
||||
"empty": {
|
||||
"heading": "No alert rules yet",
|
||||
"body": "Start with a global rule like CPU greater than 80%, then narrow per-workload by scoping a rule to a single workload.",
|
||||
"cta": "Create the first alert"
|
||||
},
|
||||
"list": {
|
||||
"name": "Name",
|
||||
"condition": "Condition",
|
||||
"scope": "Scope",
|
||||
"severity": "Severity",
|
||||
"status": "Status",
|
||||
"open": "Open"
|
||||
},
|
||||
"detail": {
|
||||
"config": "Configuration",
|
||||
"configSub": "id #{id} · scope {scope}",
|
||||
"dangerZone": "Danger zone",
|
||||
"dangerZoneSub": "Deleting an alert rule removes it immediately and stops it from firing.",
|
||||
"deleteButton": "Delete alert",
|
||||
"deleteTitle": "Delete alert rule?",
|
||||
"deleteMessage": "Rule \"{name}\" will be removed immediately and will stop firing."
|
||||
},
|
||||
"form": {
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. Worker CPU saturated",
|
||||
"condition": "Condition",
|
||||
"metric": "Metric",
|
||||
"comparator": "Comparator",
|
||||
"threshold": "Threshold",
|
||||
"thresholdPlaceholder": "e.g. 80",
|
||||
"thresholdHintPercent": "Percent of the limit (0–100). The rule fires when the sampled value crosses this threshold.",
|
||||
"thresholdHintBytes": "Absolute bytes (e.g. 536870912 for 512 MiB). The rule fires when sampled memory crosses this threshold.",
|
||||
"matchShape": "Match shape",
|
||||
"matchShapeOpts": "SEVERITY · COOLDOWN",
|
||||
"severity": "Severity",
|
||||
"cooldown": "Cooldown (s)",
|
||||
"cooldownHint": "Cooldown is per-rule per-container — the same rule firing on two containers stays independent. It caps how often a sustained breach re-emits to event_log.",
|
||||
"scope": "Scope",
|
||||
"scopeHint": "Workload-scoped rules apply only to that workload's containers. Leave empty to apply the rule to every workload.",
|
||||
"scopeGlobal": "Global (applies to every workload)",
|
||||
"scopePick": "Pick workload…",
|
||||
"scopePickTitle": "Pick a workload",
|
||||
"scopeClear": "Make global",
|
||||
"scopeSelected": "Workload",
|
||||
"scopeUnknown": "Unknown workload",
|
||||
"enabled": "Enabled",
|
||||
"enabledHint": "Disabled rules stay in the table but never fire.",
|
||||
"required": "REQUIRED",
|
||||
"optional": "OPTIONAL",
|
||||
"submit": "Forge alert",
|
||||
"submitting": "Forging…"
|
||||
},
|
||||
"metric": {
|
||||
"cpu_percent": "CPU %",
|
||||
"memory_percent": "Memory %",
|
||||
"memory_bytes": "Memory (bytes)"
|
||||
},
|
||||
"metricShort": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory"
|
||||
},
|
||||
"comparator": {
|
||||
"gt": "greater than",
|
||||
"lt": "less than"
|
||||
},
|
||||
"unit": {
|
||||
"percent": "%",
|
||||
"bytes": "bytes"
|
||||
},
|
||||
"scope": {
|
||||
"global": "global",
|
||||
"workload": "workload {id}"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "enabled",
|
||||
"disabled": "disabled"
|
||||
}
|
||||
},
|
||||
"logscan": {
|
||||
"title": "Log scan rules",
|
||||
"titleNew": "Forge a new rule",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"apps": "Приложения",
|
||||
"eventTriggers": "Триггеры событий",
|
||||
"logScanRules": "Лог-правила",
|
||||
"metricAlertRules": "Метрик-алерты",
|
||||
"triggers": "Триггеры",
|
||||
"proxies": "Прокси",
|
||||
"events": "События",
|
||||
@@ -887,6 +888,105 @@
|
||||
"disabled": "выключен"
|
||||
}
|
||||
},
|
||||
"metricalert": {
|
||||
"title": "Правила метрик-алертов",
|
||||
"titleNew": "Создать новый алерт",
|
||||
"titleSingular": "Правило алерта",
|
||||
"lede": "Пороговые проверки, которые наблюдатель выполняет по выборкам CPU и памяти каждого запущенного контейнера. Когда выборка пересекает порог, правило записывается в event_log с указанной важностью, откуда триггеры событий подхватывают его и рассылают по настроенным вебхукам. Включено {enabled} из {total}.",
|
||||
"ledeNew": "Выберите метрику, оператор сравнения и порог. Оставьте поле рабочей нагрузки пустым, чтобы создать глобальное правило для всех нагрузок, или ограничьте его одной нагрузкой.",
|
||||
"stat": {
|
||||
"total": "ВСЕГО",
|
||||
"global": "ГЛОБАЛЬНЫЕ",
|
||||
"workload": "НАГРУЗКА",
|
||||
"enabled": "ВКЛЮЧЕНО"
|
||||
},
|
||||
"toolbar": {
|
||||
"newButton": "Новый алерт",
|
||||
"backToList": "К списку алертов"
|
||||
},
|
||||
"filter": {
|
||||
"all": "ВСЕ",
|
||||
"global": "ГЛОБАЛЬНЫЕ",
|
||||
"workload": "НАГРУЗКА"
|
||||
},
|
||||
"empty": {
|
||||
"heading": "Пока нет правил алертов",
|
||||
"body": "Начните с глобального правила, например «CPU больше 80%», затем сузьте его, ограничив правило отдельной рабочей нагрузкой.",
|
||||
"cta": "Создать первый алерт"
|
||||
},
|
||||
"list": {
|
||||
"name": "Название",
|
||||
"condition": "Условие",
|
||||
"scope": "Область",
|
||||
"severity": "Важность",
|
||||
"status": "Статус",
|
||||
"open": "Открыть"
|
||||
},
|
||||
"detail": {
|
||||
"config": "Конфигурация",
|
||||
"configSub": "id #{id} · область {scope}",
|
||||
"dangerZone": "Опасная зона",
|
||||
"dangerZoneSub": "Удаление правила алерта немедленно убирает его и прекращает срабатывания.",
|
||||
"deleteButton": "Удалить алерт",
|
||||
"deleteTitle": "Удалить правило алерта?",
|
||||
"deleteMessage": "Правило «{name}» будет удалено немедленно и перестанет срабатывать."
|
||||
},
|
||||
"form": {
|
||||
"name": "Название",
|
||||
"namePlaceholder": "напр. Перегрузка CPU воркера",
|
||||
"condition": "Условие",
|
||||
"metric": "Метрика",
|
||||
"comparator": "Оператор",
|
||||
"threshold": "Порог",
|
||||
"thresholdPlaceholder": "напр. 80",
|
||||
"thresholdHintPercent": "Процент от лимита (0–100). Правило срабатывает, когда выборка пересекает этот порог.",
|
||||
"thresholdHintBytes": "Абсолютные байты (напр. 536870912 для 512 МиБ). Правило срабатывает, когда выборка памяти пересекает этот порог.",
|
||||
"matchShape": "Параметры срабатывания",
|
||||
"matchShapeOpts": "ВАЖНОСТЬ · ЗАДЕРЖКА",
|
||||
"severity": "Важность",
|
||||
"cooldown": "Задержка (с)",
|
||||
"cooldownHint": "Задержка действует на каждое правило и контейнер отдельно — одно правило на двух контейнерах работает независимо. Она ограничивает, как часто длительное превышение повторно пишется в event_log.",
|
||||
"scope": "Область",
|
||||
"scopeHint": "Правила, привязанные к нагрузке, применяются только к её контейнерам. Оставьте пустым, чтобы применить правило ко всем нагрузкам.",
|
||||
"scopeGlobal": "Глобально (применяется ко всем нагрузкам)",
|
||||
"scopePick": "Выбрать нагрузку…",
|
||||
"scopePickTitle": "Выберите нагрузку",
|
||||
"scopeClear": "Сделать глобальным",
|
||||
"scopeSelected": "Нагрузка",
|
||||
"scopeUnknown": "Неизвестная нагрузка",
|
||||
"enabled": "Включено",
|
||||
"enabledHint": "Отключённые правила остаются в таблице, но не срабатывают.",
|
||||
"required": "ОБЯЗАТЕЛЬНО",
|
||||
"optional": "НЕОБЯЗАТЕЛЬНО",
|
||||
"submit": "Создать алерт",
|
||||
"submitting": "Создаём…"
|
||||
},
|
||||
"metric": {
|
||||
"cpu_percent": "CPU %",
|
||||
"memory_percent": "Память %",
|
||||
"memory_bytes": "Память (байты)"
|
||||
},
|
||||
"metricShort": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Память"
|
||||
},
|
||||
"comparator": {
|
||||
"gt": "больше чем",
|
||||
"lt": "меньше чем"
|
||||
},
|
||||
"unit": {
|
||||
"percent": "%",
|
||||
"bytes": "байт"
|
||||
},
|
||||
"scope": {
|
||||
"global": "глобально",
|
||||
"workload": "нагрузка {id}"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "включено",
|
||||
"disabled": "отключено"
|
||||
}
|
||||
},
|
||||
"logscan": {
|
||||
"title": "Правила сканирования логов",
|
||||
"titleNew": "Новое правило",
|
||||
|
||||
@@ -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, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconBox, IconContainer } from '$lib/components/icons';
|
||||
import { IconDashboard, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconBox, IconContainer, IconAlert } from '$lib/components/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||
@@ -49,6 +49,7 @@
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', section: 'observe', countKey: 'eventsErrors', alert: true },
|
||||
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', section: 'observe' },
|
||||
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', section: 'observe' },
|
||||
{ href: '/metric-alert-rules', labelKey: 'nav.metricAlertRules', icon: 'alert', section: 'observe' },
|
||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings', section: 'system' }
|
||||
];
|
||||
|
||||
@@ -316,6 +317,8 @@
|
||||
<IconWifi 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 === 'events'}
|
||||
<IconEvents 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 === 'alert'}
|
||||
<IconAlert 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 === 'settings'}
|
||||
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as api from '$lib/api';
|
||||
import type { MetricAlertRule } from '$lib/api';
|
||||
import { IconPlus, IconRefresh } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let rules = $state<MetricAlertRule[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let filter = $state<'all' | 'global' | 'workload'>('all');
|
||||
|
||||
const globals = $derived(rules.filter((r) => r.workload_id === ''));
|
||||
const workloadOnly = $derived(rules.filter((r) => r.workload_id !== ''));
|
||||
const enabledCount = $derived(rules.filter((r) => r.enabled).length);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
switch (filter) {
|
||||
case 'global':
|
||||
return globals;
|
||||
case 'workload':
|
||||
return workloadOnly;
|
||||
default:
|
||||
return rules;
|
||||
}
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
rules = await api.listMetricAlertRules();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load metric alert rules';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(r: MetricAlertRule): string {
|
||||
if (r.workload_id !== '') {
|
||||
return $t('metricalert.scope.workload', { id: r.workload_id.slice(0, 8) });
|
||||
}
|
||||
return $t('metricalert.scope.global');
|
||||
}
|
||||
|
||||
function scopeClass(r: MetricAlertRule): string {
|
||||
if (r.workload_id !== '') return 'scope-workload';
|
||||
return 'scope-global';
|
||||
}
|
||||
|
||||
// Human metric label, e.g. "CPU", "Memory" — used inside the
|
||||
// summary string. memory_percent and memory_bytes both read
|
||||
// "Memory"; the unit (% vs bytes) disambiguates them visually.
|
||||
function metricLabel(metric: MetricAlertRule['metric']): string {
|
||||
switch (metric) {
|
||||
case 'cpu_percent':
|
||||
return $t('metricalert.metricShort.cpu');
|
||||
case 'memory_percent':
|
||||
case 'memory_bytes':
|
||||
return $t('metricalert.metricShort.memory');
|
||||
}
|
||||
}
|
||||
|
||||
// Build the "<Metric> <op> <value><unit>" summary shown in the
|
||||
// list, e.g. "CPU > 80%", "Memory < 90%", "Memory > 536870912 bytes".
|
||||
function ruleSummary(r: MetricAlertRule): string {
|
||||
const op = r.comparator === 'gt' ? '>' : '<';
|
||||
const unit =
|
||||
r.metric === 'memory_bytes'
|
||||
? ` ${$t('metricalert.unit.bytes')}`
|
||||
: $t('metricalert.unit.percent');
|
||||
const value =
|
||||
r.metric === 'memory_bytes' ? r.threshold.toLocaleString() : String(r.threshold);
|
||||
return `${metricLabel(r.metric)} ${op} ${value}${unit}`;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('metricalert.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="/metric-alert-rules/new" class="forge-btn">
|
||||
<IconPlus size={14} />
|
||||
<span>{$t('metricalert.toolbar.newButton')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet stats()}
|
||||
<div>
|
||||
<dt>{$t('metricalert.stat.total')}</dt>
|
||||
<dd>{loading ? '—' : String(rules.length).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('metricalert.stat.global')}</dt>
|
||||
<dd>{loading ? '—' : String(globals.length).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('metricalert.stat.workload')}</dt>
|
||||
<dd>{loading ? '—' : String(workloadOnly.length).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('metricalert.stat.enabled')}</dt>
|
||||
<dd class="accent">{loading ? '—' : String(enabledCount).padStart(2, '0')}</dd>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet lede()}
|
||||
{$t('metricalert.lede', { enabled: String(enabledCount), total: String(rules.length) })}
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
eyebrowSuffix={$t('observability.section').toUpperCase()}
|
||||
title={$t('metricalert.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 && rules.length > 0}
|
||||
<div class="filter-row" role="group" aria-label={$t('metricalert.list.scope')}>
|
||||
{#each [['all', $t('metricalert.filter.all'), rules.length], ['global', $t('metricalert.filter.global'), globals.length], ['workload', $t('metricalert.filter.workload'), workloadOnly.length]] as [key, label, count]}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:active={filter === key}
|
||||
aria-pressed={filter === key}
|
||||
onclick={() => (filter = key as typeof filter)}
|
||||
>
|
||||
<span class="chip-label">{label}</span>
|
||||
<span class="chip-count">{String(count).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(4) as _, i}
|
||||
<div class="skeleton-row" style:--i={i}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if rules.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-mark" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<h2>{$t('metricalert.empty.heading')}</h2>
|
||||
<p>{$t('metricalert.empty.body')}</p>
|
||||
<a href="/metric-alert-rules/new" class="forge-btn">
|
||||
<IconPlus size={14} /><span>{$t('metricalert.empty.cta')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table class="forge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('metricalert.list.name')}</th>
|
||||
<th>{$t('metricalert.list.condition')}</th>
|
||||
<th>{$t('metricalert.list.scope')}</th>
|
||||
<th>{$t('metricalert.list.severity')}</th>
|
||||
<th>{$t('metricalert.list.status')}</th>
|
||||
<th class="t-right">{$t('metricalert.list.open')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as r, i (r.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="row-link" href={`/metric-alert-rules/${r.id}`}>
|
||||
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="row-name">{r.name}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td class="muted mono small condition">{ruleSummary(r)}</td>
|
||||
<td>
|
||||
<span class="badge {scopeClass(r)}">{scopeLabel(r)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="severity sev-{r.severity}">{r.severity}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status" class:on={r.enabled} class:off={!r.enabled}>
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
{r.enabled ? $t('metricalert.status.enabled') : $t('metricalert.status.disabled')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<a class="row-action" href={`/metric-alert-rules/${r.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: 1200px;
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.condition {
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.condition {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
.row-action:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
.row-action:hover .arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
.actions-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Badges ────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--border-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.scope-global {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.badge.scope-workload {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── Severity ──────────────────────────────────── */
|
||||
.severity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-primary);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sev-info {
|
||||
background: color-mix(in srgb, var(--color-info, var(--text-tertiary)) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-info, var(--text-tertiary)) 30%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sev-warn {
|
||||
background: color-mix(in srgb, var(--color-warning, #f59e0b) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
|
||||
color: var(--color-warning-dark, #b45309);
|
||||
}
|
||||
.sev-error {
|
||||
background: var(--color-danger-light);
|
||||
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .sev-error {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: color-mix(in srgb, var(--color-danger) 60%, var(--text-primary));
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* ── Status ────────────────────────────────────── */
|
||||
.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>
|
||||
@@ -0,0 +1,516 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import * as api from '$lib/api';
|
||||
import type { MetricAlertRule, MetricAlertRuleInput } from '$lib/api';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
// SvelteKit gives a string id; a non-numeric path silently maps
|
||||
// to NaN. Guard explicitly so the rest of the page doesn't make
|
||||
// bogus API calls.
|
||||
const id = $derived.by(() => {
|
||||
const n = Number($page.params.id);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
});
|
||||
|
||||
let rule = $state<MetricAlertRule | null>(null);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let error = $state('');
|
||||
// Cached workload name for the scope label. We fetch by id
|
||||
// rather than listing every workload because the rule already
|
||||
// tells us exactly which one to look up. A failed lookup falls
|
||||
// back to the truncated id so the page still renders.
|
||||
let scopedWorkloadName = $state('');
|
||||
|
||||
let name = $state('');
|
||||
let metric = $state<'cpu_percent' | 'memory_percent' | 'memory_bytes'>('cpu_percent');
|
||||
let comparator = $state<'gt' | 'lt'>('gt');
|
||||
// Threshold is held as a string so an empty field is distinguishable
|
||||
// from 0; submit sends Number(threshold) and is blocked when NaN.
|
||||
let threshold = $state('');
|
||||
let severity = $state<'info' | 'warn' | 'error'>('warn');
|
||||
let cooldownSeconds = $state(60);
|
||||
let enabled = $state(true);
|
||||
|
||||
const thresholdNum = $derived.by(() => {
|
||||
const n = Number(threshold);
|
||||
return threshold.trim() !== '' && Number.isFinite(n) ? n : null;
|
||||
});
|
||||
|
||||
// Units hint under the threshold field swaps with the selected
|
||||
// metric: percent metrics read "%", memory_bytes reads "bytes".
|
||||
const thresholdHint = $derived(
|
||||
metric === 'memory_bytes'
|
||||
? $t('metricalert.form.thresholdHintBytes')
|
||||
: $t('metricalert.form.thresholdHintPercent')
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (id === null) {
|
||||
error = 'Invalid rule id';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const r = await api.getMetricAlertRule(id);
|
||||
rule = r;
|
||||
name = r.name;
|
||||
metric = r.metric;
|
||||
comparator = r.comparator;
|
||||
threshold = String(r.threshold);
|
||||
severity = r.severity;
|
||||
cooldownSeconds = r.cooldown_seconds;
|
||||
enabled = r.enabled;
|
||||
// Best-effort: resolve the workload name for the scope
|
||||
// label. Failure here doesn't block the rest of the page —
|
||||
// scopeLabel falls back to the truncated id.
|
||||
if (r.workload_id) {
|
||||
try {
|
||||
const w = await api.getWorkload(r.workload_id);
|
||||
scopedWorkloadName = w.name;
|
||||
} catch {
|
||||
scopedWorkloadName = '';
|
||||
}
|
||||
} else {
|
||||
scopedWorkloadName = '';
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load rule';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(e?: Event): Promise<void> {
|
||||
e?.preventDefault();
|
||||
if (!rule || id === null || saving || thresholdNum === null) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
try {
|
||||
const body: MetricAlertRuleInput = {
|
||||
name: name.trim(),
|
||||
metric,
|
||||
comparator,
|
||||
threshold: thresholdNum,
|
||||
severity,
|
||||
cooldown_seconds: cooldownSeconds,
|
||||
enabled
|
||||
};
|
||||
rule = await api.updateMetricAlertRule(id, body);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Save failed';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete(): Promise<void> {
|
||||
if (id === null) return;
|
||||
deleting = true;
|
||||
error = '';
|
||||
try {
|
||||
await api.deleteMetricAlertRule(id);
|
||||
goto('/metric-alert-rules');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
deleting = false;
|
||||
confirmDelete = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(r: MetricAlertRule | null): string {
|
||||
if (!r) return '';
|
||||
if (r.workload_id !== '') {
|
||||
// Prefer the human-readable name when the workload load
|
||||
// succeeded; fall back to the truncated id so the label
|
||||
// still resolves on missing/deleted workloads.
|
||||
const label = scopedWorkloadName || r.workload_id.slice(0, 8);
|
||||
return $t('metricalert.scope.workload', { id: label });
|
||||
}
|
||||
return $t('metricalert.scope.global');
|
||||
}
|
||||
|
||||
// Reload when route id changes — SvelteKit reuses the component
|
||||
// instance across [id] transitions, so onMount alone would leave
|
||||
// stale data when navigating between sibling pages.
|
||||
$effect(() => {
|
||||
const _ = id;
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{rule?.name ?? $t('metricalert.titleSingular')} · Tinyforge</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="forge" aria-busy={loading}>
|
||||
{#snippet detailLede()}
|
||||
{#if rule}
|
||||
<span class="lede-meta">
|
||||
{$t('metricalert.list.scope')} <code>{scopeLabel(rule)}</code> ·
|
||||
{$t('metricalert.list.severity')} <code>{rule.severity}</code>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
backHref="/metric-alert-rules"
|
||||
backLabel={$t('metricalert.toolbar.backToList')}
|
||||
eyebrowSuffix={$t('metricalert.titleSingular').toUpperCase()}
|
||||
title={rule?.name ?? $t('observability.loading')}
|
||||
size="lg"
|
||||
lede_html={rule ? detailLede : undefined}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert" role="alert">
|
||||
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading || !rule}
|
||||
<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}
|
||||
<form class="panel" onsubmit={save} aria-busy={saving}>
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">{$t('metricalert.detail.config')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{$t('metricalert.detail.configSub', { id: String(rule.id), scope: scopeLabel(rule) })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label for="r-name" class="sub-label">{$t('metricalert.form.name')}</label>
|
||||
<input id="r-name" type="text" class="input" bind:value={name} required />
|
||||
</div>
|
||||
|
||||
<div class="row three">
|
||||
<label class="sub" for="r-metric">
|
||||
<span class="sub-label">{$t('metricalert.form.metric')}</span>
|
||||
<select id="r-metric" class="input" bind:value={metric}>
|
||||
<option value="cpu_percent">{$t('metricalert.metric.cpu_percent')}</option>
|
||||
<option value="memory_percent">{$t('metricalert.metric.memory_percent')}</option>
|
||||
<option value="memory_bytes">{$t('metricalert.metric.memory_bytes')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="r-comparator">
|
||||
<span class="sub-label">{$t('metricalert.form.comparator')}</span>
|
||||
<select id="r-comparator" class="input" bind:value={comparator}>
|
||||
<option value="gt">{$t('metricalert.comparator.gt')}</option>
|
||||
<option value="lt">{$t('metricalert.comparator.lt')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="r-threshold">
|
||||
<span class="sub-label">{$t('metricalert.form.threshold')}</span>
|
||||
<input
|
||||
id="r-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
class="input"
|
||||
bind:value={threshold}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">{thresholdHint}</p>
|
||||
|
||||
<div class="row three">
|
||||
<label class="sub" for="r-severity">
|
||||
<span class="sub-label">{$t('metricalert.form.severity')}</span>
|
||||
<select id="r-severity" class="input" bind:value={severity}>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="r-cooldown">
|
||||
<span class="sub-label">{$t('metricalert.form.cooldown')}</span>
|
||||
<input
|
||||
id="r-cooldown"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input"
|
||||
bind:value={cooldownSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row-toggle">
|
||||
<div class="toggle-copy">
|
||||
<span class="lbl" aria-hidden="true">{$t('metricalert.form.enabled')}</span>
|
||||
<p class="hint">{$t('metricalert.form.enabledHint')}</p>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={enabled} label={$t('metricalert.form.enabled')} />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="forge-btn"
|
||||
disabled={saving || !name.trim() || thresholdNum === null}
|
||||
aria-busy={saving}
|
||||
>
|
||||
{saving ? $t('observability.saving') : $t('observability.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="panel danger-panel" aria-labelledby="danger-heading">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title" id="danger-heading">{$t('metricalert.detail.dangerZone')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">{$t('metricalert.detail.dangerZoneSub')}</span>
|
||||
</header>
|
||||
<div class="danger-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost forge-btn-danger"
|
||||
onclick={() => (confirmDelete = true)}
|
||||
>
|
||||
{$t('metricalert.detail.deleteButton')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title={$t('metricalert.detail.deleteTitle')}
|
||||
message={$t('metricalert.detail.deleteMessage', { name: name.trim() || rule.name })}
|
||||
confirmLabel={deleting ? $t('observability.deleting') : $t('observability.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => (confirmDelete = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 820px;
|
||||
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));
|
||||
}
|
||||
|
||||
/* ── Skeleton ──────────────────────────────────── */
|
||||
.skeleton-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.skeleton-row {
|
||||
height: 64px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Panel ─────────────────────────────────────── */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.panel {
|
||||
padding: 1.1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
.panel.danger-panel {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--border-primary));
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.title-accent {
|
||||
color: var(--forge-accent);
|
||||
}
|
||||
.panel-sub {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Fields ────────────────────────────────────── */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
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(--forge-accent-soft);
|
||||
}
|
||||
|
||||
.row.three {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.row.three {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.row.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Hints ──────────────────────────────────────── */
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Toggle row ─────────────────────────────────── */
|
||||
.row-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.toggle-copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.lbl {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Action footers ─────────────────────────────── */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.danger-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
.actions :global(.forge-btn),
|
||||
.actions :global(.forge-btn-ghost) {
|
||||
justify-content: center;
|
||||
}
|
||||
.danger-actions :global(.forge-btn-ghost) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,593 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as api from '$lib/api';
|
||||
import type { MetricAlertRuleInput } from '$lib/api';
|
||||
import type { EntityPickerItem, Workload } from '$lib/types';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import { IconX } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let name = $state('');
|
||||
let metric = $state<'cpu_percent' | 'memory_percent' | 'memory_bytes'>('cpu_percent');
|
||||
let comparator = $state<'gt' | 'lt'>('gt');
|
||||
// Threshold is held as a string so an empty field is distinguishable
|
||||
// from 0 — `bind:value` on a number input coerces "" to NaN, which we
|
||||
// reject on submit. The submitted body sends Number(threshold).
|
||||
let threshold = $state('');
|
||||
let severity = $state<'info' | 'warn' | 'error'>('warn');
|
||||
let cooldownSeconds = $state(300); // matches the backend's omitted-cooldown default
|
||||
let workloadID = $state(''); // empty = global
|
||||
let enabled = $state(true);
|
||||
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Workload picker state. Loaded once on mount so the modal is
|
||||
// instant when the user opens it. Failure to load is non-fatal —
|
||||
// we surface the load error in the page-level alert.
|
||||
let workloads = $state<Workload[]>([]);
|
||||
let pickerOpen = $state(false);
|
||||
|
||||
// Map each workload to a picker item. Plugin-native rows
|
||||
// surface their source plugin; legacy rows show their kind
|
||||
// (project / stack / site). The group label lets the picker's
|
||||
// grouped layout cluster related entries together.
|
||||
const pickerItems = $derived<EntityPickerItem[]>(
|
||||
workloads.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.name,
|
||||
description: w.source_kind || w.kind,
|
||||
group: (w.source_kind || w.kind || 'other').toUpperCase()
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedWorkload = $derived(workloads.find((w) => w.id === workloadID));
|
||||
|
||||
// Threshold parses to a finite number? Drives both the units hint
|
||||
// suffix and the submit-disabled guard.
|
||||
const thresholdNum = $derived.by(() => {
|
||||
const n = Number(threshold);
|
||||
return threshold.trim() !== '' && Number.isFinite(n) ? n : null;
|
||||
});
|
||||
|
||||
// Units hint under the threshold field swaps with the selected
|
||||
// metric: percent metrics read "%", memory_bytes reads "bytes".
|
||||
const thresholdHint = $derived(
|
||||
metric === 'memory_bytes'
|
||||
? $t('metricalert.form.thresholdHintBytes')
|
||||
: $t('metricalert.form.thresholdHintPercent')
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
workloads = await api.listWorkloads();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load workloads';
|
||||
}
|
||||
});
|
||||
|
||||
function pickWorkload(value: string): void {
|
||||
workloadID = value;
|
||||
pickerOpen = false;
|
||||
}
|
||||
|
||||
function clearWorkload(): void {
|
||||
workloadID = '';
|
||||
}
|
||||
|
||||
async function submit(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (submitting || thresholdNum === null) return;
|
||||
error = '';
|
||||
submitting = true;
|
||||
try {
|
||||
const body: MetricAlertRuleInput = {
|
||||
name: name.trim(),
|
||||
metric,
|
||||
comparator,
|
||||
threshold: thresholdNum,
|
||||
severity,
|
||||
cooldown_seconds: cooldownSeconds,
|
||||
workload_id: workloadID.trim(),
|
||||
enabled
|
||||
};
|
||||
const created = await api.createMetricAlertRule(body);
|
||||
goto(`/metric-alert-rules/${created.id}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Create failed';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('metricalert.titleNew')} · Tinyforge</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="forge">
|
||||
{#snippet lede()}
|
||||
{$t('metricalert.ledeNew')}
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
backHref="/metric-alert-rules"
|
||||
backLabel={$t('metricalert.toolbar.backToList')}
|
||||
eyebrowSuffix={$t('metricalert.toolbar.newButton').toUpperCase()}
|
||||
title={$t('metricalert.titleNew')}
|
||||
size="lg"
|
||||
lede_html={lede}
|
||||
/>
|
||||
|
||||
<form onsubmit={submit} class="form" aria-busy={submitting}>
|
||||
{#if error}
|
||||
<div class="alert" role="alert">
|
||||
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="r-name" class="field-label">
|
||||
<span class="num" aria-hidden="true">01</span>
|
||||
<span class="lbl">{$t('metricalert.form.name')}</span>
|
||||
<span class="req">{$t('metricalert.form.required')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="r-name"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder={$t('metricalert.form.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field group">
|
||||
<div class="field-label">
|
||||
<span class="num" aria-hidden="true">02</span>
|
||||
<span class="lbl">{$t('metricalert.form.condition')}</span>
|
||||
<span class="req">{$t('metricalert.form.required')}</span>
|
||||
</div>
|
||||
<div class="row three">
|
||||
<label class="sub" for="r-metric">
|
||||
<span class="sub-label">{$t('metricalert.form.metric')}</span>
|
||||
<select id="r-metric" class="input" bind:value={metric}>
|
||||
<option value="cpu_percent">{$t('metricalert.metric.cpu_percent')}</option>
|
||||
<option value="memory_percent">{$t('metricalert.metric.memory_percent')}</option>
|
||||
<option value="memory_bytes">{$t('metricalert.metric.memory_bytes')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="r-comparator">
|
||||
<span class="sub-label">{$t('metricalert.form.comparator')}</span>
|
||||
<select id="r-comparator" class="input" bind:value={comparator}>
|
||||
<option value="gt">{$t('metricalert.comparator.gt')}</option>
|
||||
<option value="lt">{$t('metricalert.comparator.lt')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="r-threshold">
|
||||
<span class="sub-label">{$t('metricalert.form.threshold')}</span>
|
||||
<input
|
||||
id="r-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
class="input"
|
||||
bind:value={threshold}
|
||||
required
|
||||
placeholder={$t('metricalert.form.thresholdPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">{thresholdHint}</p>
|
||||
</div>
|
||||
|
||||
<div class="field group">
|
||||
<div class="field-label">
|
||||
<span class="num" aria-hidden="true">03</span>
|
||||
<span class="lbl">{$t('metricalert.form.matchShape')}</span>
|
||||
<span class="opt">{$t('metricalert.form.matchShapeOpts')}</span>
|
||||
</div>
|
||||
<div class="row two">
|
||||
<label class="sub" for="r-severity">
|
||||
<span class="sub-label">{$t('metricalert.form.severity')}</span>
|
||||
<select id="r-severity" class="input" bind:value={severity}>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="r-cooldown">
|
||||
<span class="sub-label">{$t('metricalert.form.cooldown')}</span>
|
||||
<input
|
||||
id="r-cooldown"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input"
|
||||
bind:value={cooldownSeconds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">{$t('metricalert.form.cooldownHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num" aria-hidden="true">04</span>
|
||||
<span class="lbl">{$t('metricalert.form.scope')}</span>
|
||||
<span class="opt">{$t('metricalert.form.optional')}</span>
|
||||
</div>
|
||||
<div class="scope-picker">
|
||||
{#if workloadID === ''}
|
||||
<div class="scope-state global">
|
||||
<span class="scope-icon" aria-hidden="true">●</span>
|
||||
<span class="scope-text">{$t('metricalert.form.scopeGlobal')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost xs"
|
||||
onclick={() => (pickerOpen = true)}
|
||||
>
|
||||
{$t('metricalert.form.scopePick')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scope-state workload">
|
||||
<span class="scope-tag">{$t('metricalert.form.scopeSelected')}</span>
|
||||
{#if selectedWorkload}
|
||||
<span class="scope-text">{selectedWorkload.name}</span>
|
||||
<code class="scope-meta">
|
||||
{selectedWorkload.source_kind || selectedWorkload.kind}
|
||||
</code>
|
||||
{:else}
|
||||
<span class="scope-text muted">{$t('metricalert.form.scopeUnknown')}</span>
|
||||
<code class="scope-meta">{workloadID}</code>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost xs"
|
||||
onclick={() => (pickerOpen = true)}
|
||||
>
|
||||
{$t('observability.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-clear"
|
||||
onclick={clearWorkload}
|
||||
aria-label={$t('metricalert.form.scopeClear')}
|
||||
title={$t('metricalert.form.scopeClear')}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="hint">{$t('metricalert.form.scopeHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field row-toggle">
|
||||
<div class="toggle-copy">
|
||||
<span class="lbl small" aria-hidden="true">{$t('metricalert.form.enabled')}</span>
|
||||
<p class="hint">{$t('metricalert.form.enabledHint')}</p>
|
||||
</div>
|
||||
<ToggleSwitch bind:checked={enabled} label={$t('metricalert.form.enabled')} />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/metric-alert-rules" class="forge-btn-ghost">{$t('observability.cancel')}</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="forge-btn"
|
||||
disabled={submitting || !name.trim() || thresholdNum === null}
|
||||
aria-busy={submitting}
|
||||
>
|
||||
{submitting ? $t('metricalert.form.submitting') : $t('metricalert.form.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={pickerOpen}
|
||||
items={pickerItems}
|
||||
current={workloadID}
|
||||
title={$t('metricalert.form.scopePickTitle')}
|
||||
onselect={pickWorkload}
|
||||
onclose={() => (pickerOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 1.75rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.form {
|
||||
padding: 1.1rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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));
|
||||
}
|
||||
|
||||
/* ── Field structure ────────────────────────────── */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.field.group {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.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(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.lbl {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lbl.small {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.req,
|
||||
.opt {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.req {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.opt {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Inputs ─────────────────────────────────────── */
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
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(--forge-accent-soft);
|
||||
}
|
||||
|
||||
.row.three {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.row.two {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.row.three {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.row.three,
|
||||
.row.two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Hints ──────────────────────────────────────── */
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Toggle row ─────────────────────────────────── */
|
||||
.row-toggle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
.toggle-copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Action footer ──────────────────────────────── */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
.actions :global(.forge-btn),
|
||||
.actions :global(.forge-btn-ghost) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scope picker chip ────────────────────────────────────
|
||||
Replaces the free-text workload-id input with a single
|
||||
state-rendering row: either "Global · [Pick workload…]"
|
||||
or "Workload · <name> <kind> [Edit] [×]". Visual style
|
||||
mirrors the apps detail page's chain/status chips. */
|
||||
.scope-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.scope-state {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.scope-state.global {
|
||||
border-style: dashed;
|
||||
}
|
||||
.scope-state.workload {
|
||||
background: color-mix(in srgb, var(--color-brand-500) 8%, var(--surface-card-hover));
|
||||
border-color: color-mix(in srgb, var(--color-brand-500) 35%, var(--border-primary));
|
||||
}
|
||||
.scope-icon {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.scope-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.scope-text.muted {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
.scope-tag {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.12rem 0.4rem;
|
||||
background: var(--color-brand-500);
|
||||
color: var(--surface-card);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.scope-meta {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.1rem 0.35rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.scope-state .forge-btn-ghost {
|
||||
margin-left: auto;
|
||||
}
|
||||
.scope-state.workload .forge-btn-ghost {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
.scope-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.scope-clear:hover {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
:global(.forge-btn-ghost.xs) {
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user