feat(ui): refactor PolicyForm and automation page with atoms + i18n
- PolicyForm.svelte: replaced raw inputs/selects/buttons with /ui atoms (Button, Input, Select), added i18n for all labels and messages - Automation page: replaced manual layout with PageHeader/Card/EmptyState atoms, added i18n everywhere, stripped debug logging - Added i18n keys for en/ru (settings: 45 new keys, validation: 1 new key) - Fixed validation new page description to use dedicated i18n key
This commit is contained in:
@@ -5,16 +5,19 @@
|
||||
@PURPOSE: Form for creating and editing validation policies.
|
||||
@LAYER UI
|
||||
@RELATION DEPENDS_ON -> [ValidationPolicy]
|
||||
@PRE: Parent provides callable onSave/onCancel handlers and an environments collection that may be empty but remains array-like.
|
||||
@POST: Form submission forwards the current draft to onSave and cancel delegates dismissal to the parent callback without mutating external state directly.
|
||||
@PRE: Parent provides callable onSave/onCancel handlers and an environments collection.
|
||||
@POST: Form submission forwards the current draft to onSave and cancel delegates dismissal to the parent callback.
|
||||
|
||||
@UX_STATE: Idle -> Displays the policy form.
|
||||
@UX_STATE: Idle -> Displays the policy form with editable fields.
|
||||
@UX_STATE: Submitting -> Disables inputs and shows a loading state.
|
||||
@UX_FEEDBACK: Success -> Parent flow can close the form after a successful save.
|
||||
@UX_FEEDBACK: Error -> Invalid field or submit failures keep the draft visible for correction.
|
||||
@UX_REACTIVITY: Uses $state for mutable form data and $derived values for window health checks.
|
||||
@UX_FEEDBACK: Success -> Parent flow closes the form after a successful save.
|
||||
@UX_FEEDBACK: Error -> Submit failures keep the draft visible for correction.
|
||||
@UX_REACTIVITY: Uses $state for mutable form data and $derived for window health checks.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button, Input, Select } from '$lib/ui';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
|
||||
/** @type {{ policy: any, environments: any[], onSave: (p: any) => void, onCancel: () => void }} */
|
||||
let { policy = null, environments = [], onSave, onCancel } = $props();
|
||||
|
||||
@@ -31,7 +34,7 @@
|
||||
name: currentPolicy.name || '',
|
||||
environment_id: currentPolicy.environment_id || (currentEnvironments[0]?.id || ''),
|
||||
dashboard_ids: Array.isArray(currentPolicy.dashboard_ids) ? currentPolicy.dashboard_ids : [],
|
||||
schedule_days: Array.isArray(currentPolicy.schedule_days) ? currentPolicy.schedule_days : [1, 2, 3, 4, 5], // Default Mon-Fri
|
||||
schedule_days: Array.isArray(currentPolicy.schedule_days) ? currentPolicy.schedule_days : [1, 2, 3, 4, 5],
|
||||
window_start: currentPolicy.window_start || '01:00',
|
||||
window_end: currentPolicy.window_end || '05:00',
|
||||
notify_owners: currentPolicy.notify_owners ?? true,
|
||||
@@ -46,21 +49,25 @@
|
||||
formData = buildFormData(getSafePolicy(), getSafeEnvironments());
|
||||
});
|
||||
|
||||
const days = [
|
||||
{ id: 0, label: 'Sun' },
|
||||
{ id: 1, label: 'Mon' },
|
||||
{ id: 2, label: 'Tue' },
|
||||
{ id: 3, label: 'Wed' },
|
||||
{ id: 4, label: 'Thu' },
|
||||
{ id: 5, label: 'Fri' },
|
||||
{ id: 6, label: 'Sat' }
|
||||
];
|
||||
const days = $derived([
|
||||
{ id: 0, label: t.settings?.day_sun || 'Sun' },
|
||||
{ id: 1, label: t.settings?.day_mon || 'Mon' },
|
||||
{ id: 2, label: t.settings?.day_tue || 'Tue' },
|
||||
{ id: 3, label: t.settings?.day_wed || 'Wed' },
|
||||
{ id: 4, label: t.settings?.day_thu || 'Thu' },
|
||||
{ id: 5, label: t.settings?.day_fri || 'Fri' },
|
||||
{ id: 6, label: t.settings?.day_sat || 'Sat' }
|
||||
]);
|
||||
|
||||
const alertConditions = [
|
||||
{ id: 'FAIL_ONLY', label: 'Only on Failure' },
|
||||
{ id: 'WARN_AND_FAIL', label: 'On Warning or Failure' },
|
||||
{ id: 'ALWAYS', label: 'Always' }
|
||||
];
|
||||
const alertConditionOptions = $derived([
|
||||
{ value: 'FAIL_ONLY', label: t.settings?.alert_fail_only || 'Only on Failure' },
|
||||
{ value: 'WARN_AND_FAIL', label: t.settings?.alert_warn_and_fail || 'On Warning or Failure' },
|
||||
{ value: 'ALWAYS', label: t.settings?.alert_always || 'Always' }
|
||||
]);
|
||||
|
||||
const environmentOptions = $derived(
|
||||
getSafeEnvironments().map(e => ({ value: e.id, label: e.name || e.id }))
|
||||
);
|
||||
|
||||
let windowDurationMinutes = $derived(() => {
|
||||
const [h1, m1] = formData.window_start.split(':').map(Number);
|
||||
@@ -71,7 +78,7 @@
|
||||
return end - start;
|
||||
});
|
||||
|
||||
let isWindowTooSmall = $derived(windowDurationMinutes() < formData.dashboard_ids.length * 1); // Example: 1 min per dashboard
|
||||
let isWindowTooSmall = $derived(windowDurationMinutes() < formData.dashboard_ids.length * 1);
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
@@ -92,109 +99,110 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6 p-6 bg-surface-card dark:bg-terminal-surface rounded-lg shadow-sm">
|
||||
<form onsubmit={handleSubmit} class="space-y-6 bg-surface-card border border-border rounded-lg p-6 shadow-sm">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Basic Info -->
|
||||
<!-- Left Column: Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-text dark:text-text-subtle">Policy Name</label>
|
||||
<input id="name" type="text" bind:value={formData.name}
|
||||
class="mt-1 block w-full border-border-strong dark:border-border-strong dark:bg-terminal-bg rounded-md shadow-sm focus-visible:ring-primary-ring focus-visible:border-primary-ring sm:text-sm"
|
||||
placeholder="e.g., Production Morning Check" required disabled={isSubmitting} />
|
||||
</div>
|
||||
<Input
|
||||
label={$t.settings?.policy_name || 'Policy Name'}
|
||||
bind:value={formData.name}
|
||||
placeholder={$t.settings?.policy_name_placeholder || 'e.g., Production Morning Check'}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label for="env" class="block text-sm font-medium text-text dark:text-text-subtle">Environment</label>
|
||||
<select id="env" bind:value={formData.environment_id}
|
||||
class="mt-1 block w-full border-border-strong dark:border-border-strong dark:bg-terminal-bg rounded-md shadow-sm focus-visible:ring-primary-ring focus-visible:border-primary-ring sm:text-sm"
|
||||
disabled={isSubmitting}>
|
||||
{#each getSafeEnvironments() as env}
|
||||
<option value={env.id}>{env.name || env.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<Select
|
||||
label={$t.settings?.environment || 'Environment'}
|
||||
bind:value={formData.environment_id}
|
||||
options={environmentOptions}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-text dark:text-text-subtle">Schedule Days</span>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<!-- Schedule Days -->
|
||||
<fieldset class="border-0 p-0 m-0">
|
||||
<legend class="text-sm font-medium text-text mb-2">{$t.settings?.schedule_days || 'Schedule Days'}</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each days as day}
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleDay(day.id)}
|
||||
class="px-3 py-1 text-xs font-semibold rounded-full border transition-colors
|
||||
{formData.schedule_days.includes(day.id)
|
||||
? 'bg-primary-light text-primary border-primary-ring dark:bg-primary dark:text-primary'
|
||||
: 'bg-surface-muted text-text-muted border-border dark:bg-terminal-bg dark:text-text-subtle dark:border-border-strong'}"
|
||||
disabled={isSubmitting}>
|
||||
class="px-3 py-1 text-xs font-semibold rounded-full border transition-colors cursor-pointer
|
||||
{formData.schedule_days.includes(day.id)
|
||||
? 'bg-primary-light text-primary border-primary-ring dark:bg-primary dark:text-primary'
|
||||
: 'bg-surface-card text-text-muted border-border-strong hover:bg-surface-muted dark:bg-terminal-bg dark:text-text-subtle dark:border-border-strong'}"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Window & Alerts -->
|
||||
<!-- Right Column: Window & Alerts -->
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="start" class="block text-sm font-medium text-text dark:text-text-subtle">Window Start</label>
|
||||
<input id="start" type="time" bind:value={formData.window_start}
|
||||
class="mt-1 block w-full border-border-strong dark:border-border-strong dark:bg-terminal-bg rounded-md shadow-sm focus-visible:ring-primary-ring focus-visible:border-primary-ring sm:text-sm"
|
||||
disabled={isSubmitting} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="end" class="block text-sm font-medium text-text dark:text-text-subtle">Window End</label>
|
||||
<input id="end" type="time" bind:value={formData.window_end}
|
||||
class="mt-1 block w-full border-border-strong dark:border-border-strong dark:bg-terminal-bg rounded-md shadow-sm focus-visible:ring-primary-ring focus-visible:border-primary-ring sm:text-sm"
|
||||
disabled={isSubmitting} />
|
||||
</div>
|
||||
<Input
|
||||
label={$t.settings?.window_start || 'Window Start'}
|
||||
type="time"
|
||||
bind:value={formData.window_start}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Input
|
||||
label={$t.settings?.window_end || 'Window End'}
|
||||
type="time"
|
||||
bind:value={formData.window_end}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if formData.dashboard_ids.length > 0}
|
||||
<div class="p-3 bg-primary-light dark:bg-primary/30 rounded-md border border-primary-ring dark:border-primary-ring">
|
||||
<div class="p-3 bg-primary-light dark:bg-primary/30 rounded-md border border-primary-ring">
|
||||
<p class="text-xs text-primary dark:text-primary">
|
||||
💡 System will automatically distribute {formData.dashboard_ids.length} checks within this {Math.floor(windowDurationMinutes() / 60)}h {windowDurationMinutes() % 60}m window.
|
||||
{($t.settings?.distribution_info || 'System will automatically distribute {count} checks within this {hours}h {minutes}m window.')
|
||||
.replace('{count}', formData.dashboard_ids.length)
|
||||
.replace('{hours}', Math.floor(windowDurationMinutes() / 60))
|
||||
.replace('{minutes}', windowDurationMinutes() % 60)}
|
||||
</p>
|
||||
{#if isWindowTooSmall}
|
||||
<p class="mt-1 text-xs text-warning dark:text-warning font-medium">
|
||||
⚠️ Window might be too narrow for {formData.dashboard_ids.length} dashboards.
|
||||
<p class="mt-1 text-xs text-warning font-medium">
|
||||
{($t.settings?.window_warning || 'Window might be too narrow for {count} dashboards.')
|
||||
.replace('{count}', formData.dashboard_ids.length)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="alert" class="block text-sm font-medium text-text dark:text-text-subtle">Alert Condition</label>
|
||||
<select id="alert" bind:value={formData.alert_condition}
|
||||
class="mt-1 block w-full border-border-strong dark:border-border-strong dark:bg-terminal-bg rounded-md shadow-sm focus-visible:ring-primary-ring focus-visible:border-primary-ring sm:text-sm"
|
||||
disabled={isSubmitting}>
|
||||
{#each alertConditions as cond}
|
||||
<option value={cond.id}>{cond.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<Select
|
||||
label={$t.settings?.alert_condition || 'Alert Condition'}
|
||||
bind:value={formData.alert_condition}
|
||||
options={alertConditionOptions}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<!-- Notify Owners Checkbox -->
|
||||
<div class="flex items-center">
|
||||
<input id="notify" type="checkbox" bind:checked={formData.notify_owners}
|
||||
class="h-4 w-4 text-primary focus-visible:ring-primary-ring border-border-strong rounded"
|
||||
disabled={isSubmitting} />
|
||||
<input
|
||||
id="notify"
|
||||
type="checkbox"
|
||||
bind:checked={formData.notify_owners}
|
||||
class="h-4 w-4 text-primary focus-visible:ring-primary-ring border-border-strong rounded"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<label for="notify" class="ml-2 block text-sm text-text dark:text-text-subtle">
|
||||
Auto-notify Dashboard Owners
|
||||
{$t.settings?.notify_owners || 'Auto-notify Dashboard Owners'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-border dark:border-terminal-border flex justify-end space-x-3">
|
||||
<button type="button" onclick={onCancel}
|
||||
class="px-4 py-2 text-sm font-medium text-text bg-surface-card border border-border-strong rounded-md shadow-sm hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-ring dark:bg-terminal-bg dark:text-text-subtle dark:border-border-strong dark:hover:bg-surface-muted"
|
||||
disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary border border-transparent rounded-md shadow-sm hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-ring disabled:opacity-50"
|
||||
disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Policy'}
|
||||
</button>
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-border flex justify-end space-x-3">
|
||||
<Button variant="secondary" onclick={onCancel} disabled={isSubmitting}>
|
||||
{$t.settings?.cancel || 'Cancel'}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? ($t.settings?.saving_policy || 'Saving...') : ($t.settings?.save_policy || 'Save Policy')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- #endregion PolicyForm -->
|
||||
|
||||
@@ -149,5 +149,50 @@
|
||||
"help_migration_cron": "Cron expression for automatic cross-environment ID synchronization. Format: minute hour day month weekday. Example: 0 2 * * * (daily at 2 AM UTC).",
|
||||
"help_migration_sync_now": "Triggers an immediate synchronization of Dashboard, Chart, and Dataset IDs across all configured environments.",
|
||||
"help_feature_dataset_review": "Enable the Dataset Review workflow: automatic review, clarification sessions, and SQL execution for imported datasets.",
|
||||
"help_feature_health_monitor": "Enable dashboard health monitoring: validation reports, status aggregation, and health summary display."
|
||||
"help_feature_health_monitor": "Enable dashboard health monitoring: validation reports, status aggregation, and health summary display.",
|
||||
|
||||
"automation_policies": "Automation Policies",
|
||||
"automation_policies_subtitle": "Manage scheduled validation rules and execution windows.",
|
||||
"create_policy": "Create Policy",
|
||||
"edit_policy": "Edit Policy",
|
||||
"new_policy": "New Policy",
|
||||
"back_to_list": "Back to list",
|
||||
"no_policies": "No policies",
|
||||
"no_policies_desc": "Get started by creating a new automation policy.",
|
||||
"translation_schedules": "Translation Schedules",
|
||||
"policy_name": "Policy Name",
|
||||
"policy_name_placeholder": "e.g., Production Morning Check",
|
||||
"environment": "Environment",
|
||||
"schedule_days": "Schedule Days",
|
||||
"window_start": "Window Start",
|
||||
"window_end": "Window End",
|
||||
"alert_condition": "Alert Condition",
|
||||
"alert_fail_only": "Only on Failure",
|
||||
"alert_warn_and_fail": "On Warning or Failure",
|
||||
"alert_always": "Always",
|
||||
"notify_owners": "Auto-notify Dashboard Owners",
|
||||
"cancel": "Cancel",
|
||||
"save_policy": "Save Policy",
|
||||
"saving_policy": "Saving...",
|
||||
"policy_created": "Policy created successfully",
|
||||
"policy_updated": "Policy updated successfully",
|
||||
"policy_deleted": "Policy deleted successfully",
|
||||
"policy_save_failed": "Failed to save policy",
|
||||
"policy_delete_failed": "Failed to delete policy",
|
||||
"policy_load_failed": "Failed to load automation data",
|
||||
"delete_confirm": "Are you sure you want to delete this policy?",
|
||||
"dashboards_count": "{count} Dashboards",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"last_run": "Last: {date}",
|
||||
"distribution_info": "System will automatically distribute {count} checks within this {hours}h {minutes}m window.",
|
||||
"window_warning": "Window might be too narrow for {count} dashboards.",
|
||||
|
||||
"day_sun": "Sun",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"tasks_title": "Validation Tasks",
|
||||
"new_task_page": "New Validation Task",
|
||||
"new_task_description": "Configure a new LLM-powered dashboard validation task",
|
||||
"edit_task_page": "Edit Task",
|
||||
"step_name": "Name & Environment",
|
||||
"step_sources": "Sources",
|
||||
|
||||
@@ -149,5 +149,50 @@
|
||||
"help_migration_cron": "Cron-выражение для автоматической синхронизации ID между окружениями. Формат: минута час день месяц день_недели. Пример: 0 2 * * * (ежедневно в 02:00 UTC).",
|
||||
"help_migration_sync_now": "Запускает немедленную синхронизацию ID дашбордов, чартов и датасетов между всеми настроенными окружениями.",
|
||||
"help_feature_dataset_review": "Включить workflow обзора датасетов: автоматический обзор, уточняющие сессии и выполнение SQL для импортированных датасетов.",
|
||||
"help_feature_health_monitor": "Включить мониторинг здоровья дашбордов: отчёты валидации, агрегация статусов и сводка состояния."
|
||||
"help_feature_health_monitor": "Включить мониторинг здоровья дашбордов: отчёты валидации, агрегация статусов и сводка состояния.",
|
||||
|
||||
"automation_policies": "Политики автоматизации",
|
||||
"automation_policies_subtitle": "Управление расписаниями валидации и окнами выполнения.",
|
||||
"create_policy": "Создать политику",
|
||||
"edit_policy": "Редактировать политику",
|
||||
"new_policy": "Новая политика",
|
||||
"back_to_list": "Назад к списку",
|
||||
"no_policies": "Нет политик",
|
||||
"no_policies_desc": "Начните с создания новой политики автоматизации.",
|
||||
"translation_schedules": "Расписания переводов",
|
||||
"policy_name": "Название политики",
|
||||
"policy_name_placeholder": "например, Утренняя проверка Production",
|
||||
"environment": "Окружение",
|
||||
"schedule_days": "Дни расписания",
|
||||
"window_start": "Начало окна",
|
||||
"window_end": "Конец окна",
|
||||
"alert_condition": "Условие оповещения",
|
||||
"alert_fail_only": "Только при ошибке",
|
||||
"alert_warn_and_fail": "При предупреждении или ошибке",
|
||||
"alert_always": "Всегда",
|
||||
"notify_owners": "Автоуведомление владельцев дашбордов",
|
||||
"cancel": "Отмена",
|
||||
"save_policy": "Сохранить политику",
|
||||
"saving_policy": "Сохранение...",
|
||||
"policy_created": "Политика успешно создана",
|
||||
"policy_updated": "Политика успешно обновлена",
|
||||
"policy_deleted": "Политика успешно удалена",
|
||||
"policy_save_failed": "Не удалось сохранить политику",
|
||||
"policy_delete_failed": "Не удалось удалить политику",
|
||||
"policy_load_failed": "Не удалось загрузить данные автоматизации",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить эту политику?",
|
||||
"dashboards_count": "{count} дашбордов",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"last_run": "Последний: {date}",
|
||||
"distribution_info": "Система автоматически распределит {count} проверок в этом окне {hours}ч {minutes}м.",
|
||||
"window_warning": "Окно может быть слишком узким для {count} дашбордов.",
|
||||
|
||||
"day_sun": "Вс",
|
||||
"day_mon": "Пн",
|
||||
"day_tue": "Вт",
|
||||
"day_wed": "Ср",
|
||||
"day_thu": "Чт",
|
||||
"day_fri": "Пт",
|
||||
"day_sat": "Сб"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"tasks_title": "Задачи валидации",
|
||||
"new_task_page": "Новая задача валидации",
|
||||
"new_task_description": "Настройте новую задачу LLM-валидации дашбордов",
|
||||
"edit_task_page": "Редактировать задачу",
|
||||
"step_name": "Название и окружение",
|
||||
"step_sources": "Источники",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<!-- #region AutomationPage [C:3] [TYPE Page] [SEMANTICS sveltekit, automation, policy, validation, config] -->
|
||||
<!-- @BRIEF Page component: routes/settings/automation/+page.svelte -->
|
||||
<!-- @LAYER Page -->
|
||||
<!--
|
||||
@RELATION USES -> [EXT:frontend:App]
|
||||
|
||||
@PURPOSE: Settings page for managing validation policies.
|
||||
@LAYER Page
|
||||
@UX_STATE: Idle -> Displays the list of automation policies.
|
||||
@UX_STATE: Loading -> Shows a loading spinner while fetching policies.
|
||||
@UX_REATIVITY: State: $state, Derived: $derived.
|
||||
-->
|
||||
<!-- @RELATION USES -> [EXT:frontend:App] -->
|
||||
<!-- @PURPOSE: Settings page for managing validation policies. -->
|
||||
<!-- @UX_STATE: Idle -> Displays the list of automation policies. -->
|
||||
<!-- @UX_STATE: Loading -> Shows a loading spinner while fetching policies. -->
|
||||
<!-- @UX_REACTIVITY: State: $state, Derived: $derived. -->
|
||||
<!-- @UX_STATE: Form -> Policy creation/edit form visible. -->
|
||||
<!-- @UX_FEEDBACK: Toast on save/delete success/error. -->
|
||||
<!-- @UX_RECOVERY: Retry load via refresh. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button, PageHeader, Card, EmptyState } from '$lib/ui';
|
||||
import PolicyForm from '$lib/components/health/PolicyForm.svelte';
|
||||
import {
|
||||
getValidationPolicies,
|
||||
@@ -22,6 +22,7 @@
|
||||
getTranslationSchedules
|
||||
} from '$lib/api';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
|
||||
let policies = $state([]);
|
||||
let environments = $state([]);
|
||||
@@ -42,52 +43,12 @@
|
||||
getEnvironments(),
|
||||
getTranslationSchedules()
|
||||
]);
|
||||
|
||||
const policyArray = Array.isArray(policiesData) ? policiesData : [];
|
||||
const environmentArray = Array.isArray(envsData) ? envsData : [];
|
||||
|
||||
const invalidPolicies = policyArray
|
||||
.map((policy, index) => ({ index, policy }))
|
||||
.filter(({ policy }) => !policy || typeof policy !== 'object');
|
||||
const policiesWithMissingName = policyArray
|
||||
.map((policy, index) => ({ index, policy }))
|
||||
.filter(({ policy }) => policy && (policy.name === null || policy.name === undefined));
|
||||
|
||||
const invalidEnvironments = environmentArray
|
||||
.map((env, index) => ({ index, env }))
|
||||
.filter(({ env }) => !env || typeof env !== 'object');
|
||||
const environmentsWithMissingName = environmentArray
|
||||
.map((env, index) => ({ index, env }))
|
||||
.filter(({ env }) => env && (env.name === null || env.name === undefined));
|
||||
|
||||
console.log('[AutomationSettingsPage][Debug] Loaded payload shapes', {
|
||||
policiesCount: policyArray.length,
|
||||
environmentsCount: environmentArray.length,
|
||||
invalidPoliciesCount: invalidPolicies.length,
|
||||
policiesWithMissingNameCount: policiesWithMissingName.length,
|
||||
invalidEnvironmentsCount: invalidEnvironments.length,
|
||||
environmentsWithMissingNameCount: environmentsWithMissingName.length
|
||||
});
|
||||
|
||||
if (invalidPolicies.length > 0 || policiesWithMissingName.length > 0) {
|
||||
console.warn('[AutomationSettingsPage][Debug] Suspicious policy payload detected', {
|
||||
invalidPolicies,
|
||||
policiesWithMissingName
|
||||
});
|
||||
}
|
||||
|
||||
if (invalidEnvironments.length > 0 || environmentsWithMissingName.length > 0) {
|
||||
console.warn('[AutomationSettingsPage][Debug] Suspicious environments payload detected', {
|
||||
invalidEnvironments,
|
||||
environmentsWithMissingName
|
||||
});
|
||||
}
|
||||
|
||||
policies = policiesData;
|
||||
environments = envsData;
|
||||
translationSchedules = Array.isArray(schedulesData) ? schedulesData : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load automation data:', error);
|
||||
addToast(t.settings?.policy_load_failed || 'Failed to load automation data', 'error');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
@@ -99,16 +60,7 @@
|
||||
}
|
||||
|
||||
function handleEdit(policy) {
|
||||
if (!policy) {
|
||||
console.error('[AutomationSettingsPage][Debug] handleEdit received invalid policy', { policy });
|
||||
return;
|
||||
}
|
||||
console.log('[AutomationSettingsPage][Debug] handleEdit policy snapshot', {
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
environment_id: policy.environment_id,
|
||||
dashboard_ids_type: Array.isArray(policy.dashboard_ids) ? 'array' : typeof policy.dashboard_ids
|
||||
});
|
||||
if (!policy) return;
|
||||
selectedPolicy = policy;
|
||||
showForm = true;
|
||||
}
|
||||
@@ -117,95 +69,53 @@
|
||||
try {
|
||||
if (selectedPolicy) {
|
||||
await updateValidationPolicy(selectedPolicy.id, formData);
|
||||
addToast('Policy updated successfully', 'success');
|
||||
addToast(t.settings?.policy_updated || 'Policy updated successfully', 'success');
|
||||
} else {
|
||||
await createValidationPolicy(formData);
|
||||
addToast('Policy created successfully', 'success');
|
||||
addToast(t.settings?.policy_created || 'Policy created successfully', 'success');
|
||||
}
|
||||
showForm = false;
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to save policy:', error);
|
||||
addToast(t.settings?.policy_save_failed || 'Failed to save policy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('Are you sure you want to delete this policy?')) return;
|
||||
if (!confirm(t.settings?.delete_confirm || 'Are you sure you want to delete this policy?')) return;
|
||||
try {
|
||||
await deleteValidationPolicy(id);
|
||||
addToast('Policy deleted successfully', 'success');
|
||||
addToast(t.settings?.policy_deleted || 'Policy deleted successfully', 'success');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete policy:', error);
|
||||
addToast(t.settings?.policy_delete_failed || 'Failed to delete policy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvName(id) {
|
||||
const envMatch = environments.find((e) => e?.id === id);
|
||||
if (!envMatch) {
|
||||
console.warn('[AutomationSettingsPage][Debug] Environment not found for policy environment_id', {
|
||||
requestedEnvironmentId: id,
|
||||
environmentsCount: Array.isArray(environments) ? environments.length : -1
|
||||
});
|
||||
} else if (envMatch.name === null || envMatch.name === undefined) {
|
||||
console.warn('[AutomationSettingsPage][Debug] Environment has null/undefined name', {
|
||||
requestedEnvironmentId: id,
|
||||
environment: envMatch
|
||||
});
|
||||
}
|
||||
return envMatch?.name || id;
|
||||
}
|
||||
|
||||
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
$effect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
const policiesWithNullName = (Array.isArray(policies) ? policies : []).filter(
|
||||
(policy) => policy && (policy.name === null || policy.name === undefined)
|
||||
);
|
||||
const policyEntriesWithNullDashboardIds = (Array.isArray(policies) ? policies : []).filter(
|
||||
(policy) => policy && !Array.isArray(policy.dashboard_ids)
|
||||
);
|
||||
|
||||
if (policiesWithNullName.length > 0 || policyEntriesWithNullDashboardIds.length > 0) {
|
||||
console.warn('[AutomationSettingsPage][Debug] Render-time suspicious policy data detected', {
|
||||
policiesWithNullName,
|
||||
policyEntriesWithNullDashboardIds
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-6 max-w-5xl">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-text dark:text-terminal-bright">Automation Policies</h1>
|
||||
<p class="text-sm text-text-muted dark:text-text-subtle mt-1">Manage scheduled validation rules and execution windows.</p>
|
||||
</div>
|
||||
{#if !showForm}
|
||||
<button onclick={handleAdd}
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-ring">
|
||||
<svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create Policy
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto">
|
||||
{#if showForm}
|
||||
<div class="mb-8">
|
||||
<!-- Form Mode -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<button onclick={() => showForm = false} class="text-sm text-primary hover:text-primary-hover flex items-center">
|
||||
<Button variant="ghost" onclick={() => showForm = false}>
|
||||
<svg class="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to list
|
||||
</button>
|
||||
{$t.settings?.back_to_list || 'Back to list'}
|
||||
</Button>
|
||||
<span class="mx-2 text-text-subtle">|</span>
|
||||
<h2 class="text-lg font-medium text-text dark:text-terminal-bright">
|
||||
{selectedPolicy ? 'Edit Policy' : 'New Policy'}
|
||||
<h2 class="text-lg font-medium text-text">
|
||||
{selectedPolicy ? ($t.settings?.edit_policy || 'Edit Policy') : ($t.settings?.new_policy || 'New Policy')}
|
||||
</h2>
|
||||
</div>
|
||||
<PolicyForm
|
||||
@@ -216,121 +126,131 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if isLoading}
|
||||
<!-- Loading State -->
|
||||
<div class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-surface-card dark:bg-terminal-surface shadow overflow-hidden sm:rounded-md border border-border dark:border-terminal-border">
|
||||
<ul class="divide-y divide-border dark:divide-gray-700">
|
||||
<!-- Header -->
|
||||
<PageHeader title={$t.settings?.automation_policies || 'Automation Policies'}>
|
||||
{#snippet subtitle()}
|
||||
<p class="text-sm text-text-muted dark:text-text-subtle">{$t.settings?.automation_policies_subtitle || 'Manage scheduled validation rules and execution windows.'}</p>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button onclick={handleAdd}>
|
||||
<svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{$t.settings?.create_policy || 'Create Policy'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<!-- Policy List -->
|
||||
<Card padding="none" class="mb-8">
|
||||
<ul class="divide-y divide-border">
|
||||
{#each policies as policy}
|
||||
<li>
|
||||
<div class="px-4 py-4 sm:px-6 hover:bg-surface-muted dark:hover:bg-terminal-bg/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-10 w-10 rounded-full bg-primary-light dark:bg-primary flex items-center justify-center text-primary dark:text-primary">
|
||||
<div class="h-10 w-10 rounded-full bg-primary-light dark:bg-primary flex items-center justify-center text-primary">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-sm font-medium text-primary dark:text-primary truncate">{policy.name}</h3>
|
||||
<div class="ml-4 min-w-0">
|
||||
<h3 class="text-sm font-medium text-primary truncate">{policy.name}</h3>
|
||||
<div class="mt-1 flex items-center text-xs text-text-muted dark:text-text-subtle">
|
||||
<span class="font-medium">{getEnvName(policy.environment_id)}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{policy.dashboard_ids.length} Dashboards</span>
|
||||
<span class="font-medium truncate">{getEnvName(policy.environment_id)}</span>
|
||||
<span class="mx-2 shrink-0">•</span>
|
||||
<span class="whitespace-nowrap">{$t.settings?.dashboards_count?.replace('{count}', policy.dashboard_ids.length) || `${policy.dashboard_ids.length} Dashboards`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-4 shrink-0 ml-4">
|
||||
<div class="text-right hidden sm:block">
|
||||
<div class="text-xs font-medium text-text dark:text-terminal-bright">
|
||||
{policy.window_start} - {policy.window_end}
|
||||
<div class="text-xs font-medium text-text dark:text-terminal-bright whitespace-nowrap">
|
||||
{policy.window_start} – {policy.window_end}
|
||||
</div>
|
||||
<div class="text-xs text-text-muted dark:text-text-subtle">
|
||||
<div class="text-xs text-text-muted dark:text-text-subtle whitespace-nowrap">
|
||||
{policy.schedule_days.map(d => dayLabels[d]).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick={() => handleEdit(policy)} aria-label="Edit policy" title="Edit policy" class="p-2 text-text-subtle hover:text-primary dark:hover:text-primary">
|
||||
<div class="flex items-center space-x-1">
|
||||
<Button variant="ghost" onclick={() => handleEdit(policy)} aria-label={$t.settings?.edit_policy || 'Edit policy'}>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick={() => handleDelete(policy.id)} aria-label="Delete policy" title="Delete policy" class="p-2 text-text-subtle hover:text-destructive dark:hover:text-destructive">
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => handleDelete(policy.id)} aria-label="Delete" class="hover:text-destructive">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-4 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-text-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-text dark:text-terminal-bright">No policies</h3>
|
||||
<p class="mt-1 text-sm text-text-muted dark:text-text-subtle">Get started by creating a new automation policy.</p>
|
||||
<div class="mt-6">
|
||||
<button onclick={handleAdd} class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary-ring">
|
||||
Create Policy
|
||||
</button>
|
||||
</div>
|
||||
<li>
|
||||
<EmptyState
|
||||
title={$t.settings?.no_policies || 'No policies'}
|
||||
description={$t.settings?.no_policies_desc || 'Get started by creating a new automation policy.'}
|
||||
actionLabel={$t.settings?.create_policy || 'Create Policy'}
|
||||
onAction={handleAdd}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Translation Schedules -->
|
||||
{#if translationSchedules.length > 0}
|
||||
<h2 class="text-xl font-semibold text-text dark:text-terminal-bright mb-4 mt-8">Translation Schedules</h2>
|
||||
<div class="bg-surface-card dark:bg-terminal-surface shadow overflow-hidden sm:rounded-md border border-border dark:border-terminal-border">
|
||||
<ul class="divide-y divide-border dark:divide-gray-700">
|
||||
<h2 class="text-xl font-semibold text-text dark:text-terminal-bright mb-4">{$t.settings?.translation_schedules || 'Translation Schedules'}</h2>
|
||||
<Card padding="none">
|
||||
<ul class="divide-y divide-border">
|
||||
{#each translationSchedules as ts}
|
||||
<li>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-10 w-10 rounded-full bg-warning-light dark:bg-warning flex items-center justify-center text-warning dark:text-warning">
|
||||
<div class="h-10 w-10 rounded-full bg-warning-light dark:bg-warning flex items-center justify-center text-warning">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-sm font-medium text-text dark:text-terminal-bright">{ts.job_name}</h3>
|
||||
<div class="ml-4 min-w-0">
|
||||
<h3 class="text-sm font-medium text-text dark:text-terminal-bright truncate">{ts.job_name}</h3>
|
||||
<div class="mt-1 flex items-center text-xs text-text-muted dark:text-text-subtle">
|
||||
<span class="font-mono">{ts.cron_expression}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span class="mx-2 shrink-0">•</span>
|
||||
<span>{ts.timezone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {ts.is_active ? 'bg-success-light text-success dark:bg-success dark:text-success' : 'bg-surface-muted text-text dark:bg-terminal-bg dark:text-text-subtle'}">
|
||||
{ts.is_active ? 'Active' : 'Inactive'}
|
||||
<div class="flex items-center space-x-3 shrink-0 ml-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {ts.is_active ? 'bg-success-light text-success dark:bg-success dark:text-success' : 'bg-surface-muted text-text-muted dark:bg-terminal-bg dark:text-text-subtle'}">
|
||||
{ts.is_active ? ($t.settings?.active || 'Active') : ($t.settings?.inactive || 'Inactive')}
|
||||
</span>
|
||||
{#if ts.last_run_at}
|
||||
<span class="text-xs text-text-muted dark:text-text-subtle">
|
||||
Last: {new Date(ts.last_run_at).toLocaleString()}
|
||||
<span class="text-xs text-text-muted dark:text-text-subtle whitespace-nowrap">
|
||||
{($t.settings?.last_run || 'Last: {date}').replace('{date}', new Date(ts.last_run_at).toLocaleString())}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-4 py-8 text-center text-sm text-text-muted dark:text-text-subtle">
|
||||
No translation schedules configured
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion AutomationPage -->
|
||||
<!-- #endregion AutomationPage -->
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
{$t.validation?.new_task_page || 'New Validation Task'}
|
||||
</h1>
|
||||
<p class="text-sm text-text-muted mt-1">
|
||||
{$t.validation?.step_x_of_y || 'Configure a new LLM-powered dashboard validation task'}
|
||||
{$t.validation?.new_task_description || 'Configure a new LLM-powered dashboard validation task'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user