feat(triggers): add schedule trigger kind + internal scheduler
Build / build (push) Successful in 10m42s
Build / build (push) Successful in 10m42s
Fourth trigger kind alongside registry/git/manual. Recurring time-interval fires driven by a new internal/scheduler tick loop (default 30s, clamped to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as inbound HTTP webhooks, so per-binding concurrency, outcome accounting, and config-merge semantics are identical. Schema: triggers.last_fired_at TEXT column (additive ALTER for existing DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking Match cannot wedge a tight loop; failed deploys wait one full interval before retry — correct trade-off for a periodic refresh trigger. Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly, custom interval input matched to Go time.ParseDuration syntax, optional pinned reference). /triggers/[id] surfaces "last fired" on schedule rows. EN+RU i18n in parity. Review fixes from go-reviewer / security-reviewer / typescript-reviewer: - Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double- cancel panic on shutdown re-entry). - shouldFire rejects sub-MinInterval as defense-in-depth against hand-inserted rows that bypassed Validate. - fire() asserts trigger Kind=="schedule" before dispatching. - Aligned isValidInterval regex across all three frontend sites; reject the unsupported "d" unit (Go time.ParseDuration doesn't accept it). - formatLastFired falls back to lastFiredNever on malformed timestamps rather than leaking raw bytes into the UI. - main.go scheduler closure logs per-fire deployed/errored counts.
This commit is contained in:
@@ -719,6 +719,9 @@ export interface RedeployTrigger {
|
||||
webhook_enabled: boolean;
|
||||
webhook_require_signature: boolean;
|
||||
binding_count: number;
|
||||
/** RFC3339 timestamp the scheduler last dispatched this trigger. Empty for
|
||||
* never-fired or non-scheduler-driven triggers. */
|
||||
last_fired_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,17 @@
|
||||
<script lang="ts" module>
|
||||
import type { TriggerInput } from '$lib/api';
|
||||
|
||||
export const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
|
||||
export const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
|
||||
export type KnownTriggerKind = (typeof KNOWN_KINDS)[number];
|
||||
|
||||
/** Suggested intervals offered as chips in the schedule form. Operator
|
||||
* can always type a custom Go duration into the input. */
|
||||
export const SCHEDULE_PRESETS = [
|
||||
{ key: 'hourly', value: '1h' },
|
||||
{ key: 'daily', value: '24h' },
|
||||
{ key: 'weekly', value: '168h' }
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* State shared between the component and its parent. The parent owns
|
||||
* one of these and binds it; the component mutates fields in place
|
||||
@@ -41,6 +49,9 @@
|
||||
gitMode: 'push' | 'tag';
|
||||
gitBranch: string;
|
||||
gitTagPattern: string;
|
||||
// schedule
|
||||
schInterval: string;
|
||||
schReference: string;
|
||||
// JSON fallback
|
||||
jsonText: string;
|
||||
}
|
||||
@@ -60,12 +71,32 @@
|
||||
gitMode: init.gitMode ?? 'push',
|
||||
gitBranch: init.gitBranch ?? 'main',
|
||||
gitTagPattern: init.gitTagPattern ?? 'v*',
|
||||
schInterval: init.schInterval ?? '24h',
|
||||
schReference: init.schReference ?? '',
|
||||
jsonText: init.jsonText ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function isKnownKind(k: string): k is KnownTriggerKind {
|
||||
return (KNOWN_KINDS as readonly string[]).includes(k);
|
||||
/** Matches MinInterval enforced by the schedule trigger plugin
|
||||
* (internal/workload/plugin/trigger/schedule). Validation that mirrors
|
||||
* the backend rule keeps the submit button accurate. Go's
|
||||
* time.ParseDuration accepts s/m/h (NOT d) — keep this in sync to
|
||||
* avoid submit-then-server-reject. */
|
||||
function isValidInterval(s: string): boolean {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return false;
|
||||
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
|
||||
if (single) {
|
||||
const n = parseInt(single[1], 10);
|
||||
const unit = single[2].toLowerCase();
|
||||
if (!Number.isFinite(n) || n <= 0) return false;
|
||||
if (unit === 's' && n < 60) return false;
|
||||
return true;
|
||||
}
|
||||
// Compound like "1h30m" / "90m". Tighten the fallback so we
|
||||
// don't green-light "1 h" (whitespace inside), "-1h" (negative),
|
||||
// or "1.h" — Go's time.ParseDuration rejects all of those.
|
||||
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
|
||||
}
|
||||
|
||||
export function isTriggerFormValid(s: TriggerKindFormState): boolean {
|
||||
@@ -86,6 +117,8 @@
|
||||
return !!s.gitRepo.trim();
|
||||
case 'manual':
|
||||
return true;
|
||||
case 'schedule':
|
||||
return isValidInterval(s.schInterval);
|
||||
default:
|
||||
// Unknown kinds without an advanced JSON payload are unsubmittable.
|
||||
return false;
|
||||
@@ -112,6 +145,11 @@
|
||||
};
|
||||
} else if (s.kind === 'manual') {
|
||||
config = {};
|
||||
} else if (s.kind === 'schedule') {
|
||||
const ref = s.schReference.trim();
|
||||
config = ref
|
||||
? { interval: s.schInterval.trim(), reference: ref }
|
||||
: { interval: s.schInterval.trim() };
|
||||
} else {
|
||||
config = {};
|
||||
}
|
||||
@@ -379,6 +417,60 @@
|
||||
<span class="note-tag">MANUAL</span>
|
||||
<p>{$t('redeployTriggers.form.manualNote')}</p>
|
||||
</div>
|
||||
{:else if state.kind === 'schedule'}
|
||||
<div class="note">
|
||||
<span class="note-tag">CRN</span>
|
||||
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
|
||||
<div
|
||||
class="mode-row"
|
||||
role="radiogroup"
|
||||
aria-label={$t('redeployTriggers.form.intervalPresets')}
|
||||
>
|
||||
{#each SCHEDULE_PRESETS as p (p.key)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={state.schInterval === p.value}
|
||||
class="mode-chip"
|
||||
class:active={state.schInterval === p.value}
|
||||
onclick={() => (state.schInterval = p.value)}
|
||||
>
|
||||
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="{idPrefix}-interval">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.interval')}</span>
|
||||
<input
|
||||
id="{idPrefix}-interval"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class:bad={!isValidInterval(state.schInterval)}
|
||||
bind:value={state.schInterval}
|
||||
placeholder="24h"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
|
||||
</label>
|
||||
<label class="sub" for="{idPrefix}-schref">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</span>
|
||||
<input
|
||||
id="{idPrefix}-schref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={state.schReference}
|
||||
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<div class="note">
|
||||
<span class="note-tag">?</span>
|
||||
@@ -543,10 +635,15 @@
|
||||
|
||||
.kind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 900px) {
|
||||
.kind-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.kind-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1083,7 +1083,9 @@
|
||||
"rotateConfirm": "Rotate now",
|
||||
"unbindTitle": "Unbind workload?",
|
||||
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
|
||||
"unbindConfirm": "Unbind"
|
||||
"unbindConfirm": "Unbind",
|
||||
"lastFired": "Last fired",
|
||||
"lastFiredNever": "Never fired"
|
||||
},
|
||||
"form": {
|
||||
"kindLabel": "Kind",
|
||||
@@ -1108,6 +1110,18 @@
|
||||
"branchPlaceholder": "main",
|
||||
"branchHint": "Only push events advancing this branch fire the trigger.",
|
||||
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
|
||||
"scheduleNote": "Fires on a fixed interval driven by Tinyforge's internal scheduler. No external webhook is required — enable the webhook ingress below only if a CI also needs to fire it on demand.",
|
||||
"intervalPresets": "Quick presets",
|
||||
"intervalPreset": {
|
||||
"hourly": "Hourly",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly"
|
||||
},
|
||||
"interval": "Interval",
|
||||
"intervalHint": "Go duration (e.g. \"30m\", \"6h\", \"24h\", \"168h\"). Minimum 1 minute.",
|
||||
"scheduleReference": "Pinned reference (optional)",
|
||||
"scheduleReferencePlaceholder": "stable",
|
||||
"scheduleReferenceHint": "Optional tag, branch, or revision the source plugin should re-pull each fire. Leave empty to let the source use its default.",
|
||||
"unknownNote": "This kind has no built-in form yet. Use the JSON editor below; the server validates the shape.",
|
||||
"advancedToggle": "Advanced JSON",
|
||||
"advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.",
|
||||
|
||||
@@ -1083,7 +1083,9 @@
|
||||
"rotateConfirm": "Сменить",
|
||||
"unbindTitle": "Отвязать нагрузку?",
|
||||
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
|
||||
"unbindConfirm": "Отвязать"
|
||||
"unbindConfirm": "Отвязать",
|
||||
"lastFired": "Последний запуск",
|
||||
"lastFiredNever": "Ни разу не срабатывал"
|
||||
},
|
||||
"form": {
|
||||
"kindLabel": "Вид",
|
||||
@@ -1108,6 +1110,18 @@
|
||||
"branchPlaceholder": "main",
|
||||
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
|
||||
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
|
||||
"scheduleNote": "Срабатывает по фиксированному интервалу, который ведёт внутренний планировщик Tinyforge. Внешний webhook не нужен — включите его ниже только если CI тоже должен запускать триггер вручную.",
|
||||
"intervalPresets": "Быстрые пресеты",
|
||||
"intervalPreset": {
|
||||
"hourly": "Каждый час",
|
||||
"daily": "Каждый день",
|
||||
"weekly": "Каждую неделю"
|
||||
},
|
||||
"interval": "Интервал",
|
||||
"intervalHint": "Длительность в формате Go (например «30m», «6h», «24h», «168h»). Минимум 1 минута.",
|
||||
"scheduleReference": "Фиксированная ссылка (опционально)",
|
||||
"scheduleReferencePlaceholder": "stable",
|
||||
"scheduleReferenceHint": "Опциональный тег, ветка или ревизия, которые источник будет подтягивать на каждом срабатывании. Оставьте пустым, чтобы использовать значение по умолчанию.",
|
||||
"unknownNote": "У этого вида ещё нет встроенной формы. Используйте JSON-редактор ниже; сервер валидирует форму.",
|
||||
"advancedToggle": "Расширенный JSON",
|
||||
"advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.",
|
||||
|
||||
@@ -21,9 +21,39 @@
|
||||
// the type checker — server validation rejects empty ids anyway.
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
|
||||
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
|
||||
type KnownKind = (typeof KNOWN_KINDS)[number];
|
||||
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ key: 'hourly', value: '1h' },
|
||||
{ key: 'daily', value: '24h' },
|
||||
{ key: 'weekly', value: '168h' }
|
||||
] as const;
|
||||
|
||||
function isValidInterval(s: string): boolean {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return false;
|
||||
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
|
||||
if (single) {
|
||||
const n = parseInt(single[1], 10);
|
||||
const unit = single[2].toLowerCase();
|
||||
if (!Number.isFinite(n) || n <= 0) return false;
|
||||
if (unit === 's' && n < 60) return false;
|
||||
return true;
|
||||
}
|
||||
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
|
||||
}
|
||||
|
||||
function formatLastFired(ts: string): string {
|
||||
if (!ts) return $t('redeployTriggers.detail.lastFiredNever');
|
||||
const d = new Date(ts);
|
||||
// Defensive: a malformed timestamp from a future writer should
|
||||
// not leak raw bytes into the UI. Fall back to the never-fired
|
||||
// label rather than render an unparseable string.
|
||||
if (Number.isNaN(d.getTime())) return $t('redeployTriggers.detail.lastFiredNever');
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
let trigger = $state<RedeployTrigger | null>(null);
|
||||
let webhook = $state<TriggerWebhook | null>(null);
|
||||
let bindings = $state<TriggerBinding[]>([]);
|
||||
@@ -56,6 +86,8 @@
|
||||
let gitMode = $state<'push' | 'tag'>('push');
|
||||
let gitBranch = $state('main');
|
||||
let gitTagPattern = $state('v*');
|
||||
let schInterval = $state('24h');
|
||||
let schReference = $state('');
|
||||
|
||||
let jsonText = $state('');
|
||||
|
||||
@@ -106,6 +138,10 @@
|
||||
case 'manual':
|
||||
// no fields
|
||||
break;
|
||||
case 'schedule':
|
||||
schInterval = typeof cfg.interval === 'string' ? cfg.interval : '24h';
|
||||
schReference = typeof cfg.reference === 'string' ? cfg.reference : '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +160,12 @@
|
||||
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
|
||||
case 'manual':
|
||||
return {};
|
||||
case 'schedule': {
|
||||
const ref = schReference.trim();
|
||||
return ref
|
||||
? { interval: schInterval.trim(), reference: ref }
|
||||
: { interval: schInterval.trim() };
|
||||
}
|
||||
default:
|
||||
return JSON.parse(jsonText || '{}');
|
||||
}
|
||||
@@ -458,6 +500,62 @@
|
||||
<span class="note-tag">MANUAL</span>
|
||||
<p>{$t('redeployTriggers.form.manualNote')}</p>
|
||||
</div>
|
||||
{:else if trigger.kind === 'schedule'}
|
||||
<div class="note">
|
||||
<span class="note-tag">CRN</span>
|
||||
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
|
||||
<div
|
||||
class="mode-row"
|
||||
role="radiogroup"
|
||||
aria-label={$t('redeployTriggers.form.intervalPresets')}
|
||||
>
|
||||
{#each SCHEDULE_PRESETS as p (p.key)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={schInterval === p.value}
|
||||
class="mode-chip"
|
||||
class:active={schInterval === p.value}
|
||||
onclick={() => (schInterval = p.value)}
|
||||
>
|
||||
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-interval" class="sub-label">{$t('redeployTriggers.form.interval')}</label>
|
||||
<input
|
||||
id="t-interval"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class:bad={!isValidInterval(schInterval)}
|
||||
bind:value={schInterval}
|
||||
placeholder="24h"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="t-schref" class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</label>
|
||||
<input
|
||||
id="t-schref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={schReference}
|
||||
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
|
||||
</div>
|
||||
<div class="field schedule-status">
|
||||
<span class="sub-label">{$t('redeployTriggers.detail.lastFired')}</span>
|
||||
<span class="mono">{formatLastFired(trigger.last_fired_at)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Webhook ingress toggles live in the same form so a
|
||||
|
||||
@@ -6,14 +6,39 @@
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
// Three kinds have hand-rolled forms today; anything else falls
|
||||
// Four kinds have hand-rolled forms today; anything else falls
|
||||
// back to the JSON editor. KNOWN_KINDS gates the structured form
|
||||
// switch — see formNote() for the manual/unknown explainer text.
|
||||
const KNOWN_KINDS = ['registry', 'git', 'manual'] as const;
|
||||
const KNOWN_KINDS = ['registry', 'git', 'manual', 'schedule'] as const;
|
||||
type KnownKind = (typeof KNOWN_KINDS)[number];
|
||||
const ALL_PICKABLE: ReadonlyArray<KnownKind> = KNOWN_KINDS;
|
||||
|
||||
let kind = $state<KnownKind | string>('registry');
|
||||
// Suggested intervals for schedule triggers. Operators can always
|
||||
// type a custom Go duration ("90m", "1h30m", "168h") into the input.
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ key: 'hourly', value: '1h' },
|
||||
{ key: 'daily', value: '24h' },
|
||||
{ key: 'weekly', value: '168h' }
|
||||
] as const;
|
||||
|
||||
function isValidInterval(s: string): boolean {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return false;
|
||||
const single = trimmed.match(/^(\d+)\s*(s|m|h)$/i);
|
||||
if (single) {
|
||||
const n = parseInt(single[1], 10);
|
||||
const unit = single[2].toLowerCase();
|
||||
if (!Number.isFinite(n) || n <= 0) return false;
|
||||
if (unit === 's' && n < 60) return false;
|
||||
return true;
|
||||
}
|
||||
return /^([0-9]+(\.[0-9]+)?(h|m|s))+$/i.test(trimmed);
|
||||
}
|
||||
|
||||
// Kind is always one of KNOWN_KINDS — the picker only emits those.
|
||||
// Keeping the literal union (no `| string`) preserves discriminated
|
||||
// narrowing inside buildConfig/canSubmit.
|
||||
let kind = $state<KnownKind>('registry');
|
||||
let name = $state('');
|
||||
let webhookEnabled = $state(false);
|
||||
let webhookRequireSig = $state(true);
|
||||
@@ -32,6 +57,8 @@
|
||||
let gitMode = $state<'push' | 'tag'>('push');
|
||||
let gitBranch = $state('main');
|
||||
let gitTagPattern = $state('v*');
|
||||
let schInterval = $state('24h');
|
||||
let schReference = $state('');
|
||||
|
||||
// Advanced JSON editor — primed with the sample shape for the
|
||||
// current kind on first toggle so the operator has something to
|
||||
@@ -68,6 +95,12 @@
|
||||
: { repo: gitRepo.trim(), mode: 'tag', tag_pattern: gitTagPattern.trim() || '*' };
|
||||
case 'manual':
|
||||
return {};
|
||||
case 'schedule': {
|
||||
const ref = schReference.trim();
|
||||
return ref
|
||||
? { interval: schInterval.trim(), reference: ref }
|
||||
: { interval: schInterval.trim() };
|
||||
}
|
||||
default:
|
||||
// Unknown kind reached the structured path — fall back
|
||||
// to an empty object; advanced JSON would normally be
|
||||
@@ -87,6 +120,8 @@
|
||||
return !!gitRepo.trim();
|
||||
case 'manual':
|
||||
return true;
|
||||
case 'schedule':
|
||||
return isValidInterval(schInterval);
|
||||
default:
|
||||
return false; // unknown kinds force advanced JSON
|
||||
}
|
||||
@@ -361,6 +396,60 @@
|
||||
<span class="note-tag">MANUAL</span>
|
||||
<p>{$t('redeployTriggers.form.manualNote')}</p>
|
||||
</div>
|
||||
{:else if kind === 'schedule'}
|
||||
<div class="note">
|
||||
<span class="note-tag">CRN</span>
|
||||
<p>{$t('redeployTriggers.form.scheduleNote')}</p>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.intervalPresets')}</span>
|
||||
<div
|
||||
class="mode-row"
|
||||
role="radiogroup"
|
||||
aria-label={$t('redeployTriggers.form.intervalPresets')}
|
||||
>
|
||||
{#each SCHEDULE_PRESETS as p (p.key)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={schInterval === p.value}
|
||||
class="mode-chip"
|
||||
class:active={schInterval === p.value}
|
||||
onclick={() => (schInterval = p.value)}
|
||||
>
|
||||
{$t(`redeployTriggers.form.intervalPreset.${p.key}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="trig-interval">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.interval')}</span>
|
||||
<input
|
||||
id="trig-interval"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class:bad={!isValidInterval(schInterval)}
|
||||
bind:value={schInterval}
|
||||
placeholder="24h"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.intervalHint')}</span>
|
||||
</label>
|
||||
<label class="sub" for="trig-schref">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.scheduleReference')}</span>
|
||||
<input
|
||||
id="trig-schref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={schReference}
|
||||
placeholder={$t('redeployTriggers.form.scheduleReferencePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.scheduleReferenceHint')}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<div class="note">
|
||||
<span class="note-tag">?</span>
|
||||
@@ -634,10 +723,13 @@
|
||||
adds a subtle inner glow so the choice is obvious. */
|
||||
.kind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 900px) {
|
||||
.kind-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.kind-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.kind-card {
|
||||
|
||||
Reference in New Issue
Block a user