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:
2026-06-03 11:48:13 +03:00
parent c10cd6b4d5
commit 399eb2ada7
7 changed files with 280 additions and 260 deletions

View File

@@ -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 -->

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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": "Сб"
}

View File

@@ -1,6 +1,7 @@
{
"tasks_title": "Задачи валидации",
"new_task_page": "Новая задача валидации",
"new_task_description": "Настройте новую задачу LLM-валидации дашбордов",
"edit_task_page": "Редактировать задачу",
"step_name": "Название и окружение",
"step_sources": "Источники",

View File

@@ -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 -->

View File

@@ -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>