код написан
This commit is contained in:
@@ -363,6 +363,20 @@ export const api = {
|
||||
// Settings
|
||||
getConsolidatedSettings: () => fetchApi('/settings/consolidated'),
|
||||
updateConsolidatedSettings: (settings) => requestApi('/settings/consolidated', 'PATCH', settings),
|
||||
|
||||
// Automation Policies
|
||||
getValidationPolicies: () => fetchApi('/settings/automation/policies'),
|
||||
createValidationPolicy: (policy) => postApi('/settings/automation/policies', policy),
|
||||
updateValidationPolicy: (id, policy) => requestApi(`/settings/automation/policies/${id}`, 'PATCH', policy),
|
||||
deleteValidationPolicy: (id) => requestApi(`/settings/automation/policies/${id}`, 'DELETE'),
|
||||
|
||||
// Health
|
||||
getHealthSummary: (environmentId) => {
|
||||
const params = new URLSearchParams();
|
||||
if (environmentId) params.append('environment_id', environmentId);
|
||||
const query = params.toString();
|
||||
return fetchApi(`/health/summary${query ? `?${query}` : ''}`);
|
||||
},
|
||||
};
|
||||
// [/DEF:api:Data]
|
||||
|
||||
@@ -392,3 +406,8 @@ export const getDashboards = api.getDashboards;
|
||||
export const getDatasets = api.getDatasets;
|
||||
export const getConsolidatedSettings = api.getConsolidatedSettings;
|
||||
export const updateConsolidatedSettings = api.updateConsolidatedSettings;
|
||||
export const getValidationPolicies = api.getValidationPolicies;
|
||||
export const createValidationPolicy = api.createValidationPolicy;
|
||||
export const updateValidationPolicy = api.updateValidationPolicy;
|
||||
export const deleteValidationPolicy = api.deleteValidationPolicy;
|
||||
export const getHealthSummary = api.getHealthSummary;
|
||||
|
||||
79
frontend/src/lib/components/health/HealthMatrix.svelte
Normal file
79
frontend/src/lib/components/health/HealthMatrix.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- [DEF:HealthMatrix:Component] -->
|
||||
<!--
|
||||
@TIER: STANDARD
|
||||
@PURPOSE: Visual grid/matrix representing the health status of dashboards.
|
||||
@LAYER: UI/Component
|
||||
@UX_STATE: Idle -> Displays the health matrix grid.
|
||||
@UX_STATE: Loading -> Displays a skeleton or spinner.
|
||||
@UX_STATE: Error -> Displays an error message with a retry option.
|
||||
@UX_REATIVITY: State: $state, Derived: $derived.
|
||||
-->
|
||||
<script>
|
||||
/** @type {{
|
||||
* pass_count: number,
|
||||
* warn_count: number,
|
||||
* fail_count: number,
|
||||
* unknown_count: number,
|
||||
* loading: boolean,
|
||||
* error: string | null
|
||||
* }} */
|
||||
let {
|
||||
pass_count = 0,
|
||||
warn_count = 0,
|
||||
fail_count = 0,
|
||||
unknown_count = 0,
|
||||
loading = false,
|
||||
error = null
|
||||
} = $props();
|
||||
|
||||
// [REASON] We use derived state for total count to ensure consistency.
|
||||
let total = $derived(pass_count + warn_count + fail_count + unknown_count);
|
||||
</script>
|
||||
|
||||
<div class="health-matrix-summary mb-6">
|
||||
{#if loading}
|
||||
<div class="flex gap-4 animate-pulse">
|
||||
<div class="h-24 w-32 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
<div class="h-24 w-32 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
<div class="h-24 w-32 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400">
|
||||
<p class="font-medium">Failed to load health summary</p>
|
||||
<p class="text-sm">{error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-green-700 dark:text-green-400">🟢 {pass_count}</div>
|
||||
<div class="text-xs text-green-600 dark:text-green-500 uppercase tracking-wider font-semibold">Pass</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-yellow-700 dark:text-yellow-400">🟡 {warn_count}</div>
|
||||
<div class="text-xs text-yellow-600 dark:text-yellow-500 uppercase tracking-wider font-semibold">Warn</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-red-700 dark:text-red-400">🔴 {fail_count}</div>
|
||||
<div class="text-xs text-red-600 dark:text-red-500 uppercase tracking-wider font-semibold">Fail</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-gray-700 dark:text-gray-300">⚪ {unknown_count}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-500 uppercase tracking-wider font-semibold">Unknown</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if total === 0}
|
||||
<div class="mt-4 p-8 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-xl text-gray-500 dark:text-gray-400">
|
||||
No validation records found for this environment.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Tailwind handles most styling, custom tweaks here if needed */
|
||||
</style>
|
||||
<!-- [/DEF:HealthMatrix:Component] -->
|
||||
180
frontend/src/lib/components/health/PolicyForm.svelte
Normal file
180
frontend/src/lib/components/health/PolicyForm.svelte
Normal file
@@ -0,0 +1,180 @@
|
||||
<!-- [DEF:PolicyForm:Component] -->
|
||||
<!--
|
||||
@TIER: STANDARD
|
||||
@PURPOSE: Form for creating and editing validation policies.
|
||||
@LAYER: UI/Component
|
||||
@UX_STATE: Idle -> Displays the policy form.
|
||||
@UX_STATE: Submitting -> Disables inputs and shows a loading state.
|
||||
@UX_FEEDBACK: Success -> Shows a success toast and closes the form.
|
||||
@UX_FEEDBACK: Error -> Shows error messages on invalid fields.
|
||||
@UX_REATIVITY: State: $state, Derived: $derived.
|
||||
-->
|
||||
<script>
|
||||
/** @type {{ policy: any, environments: any[], onSave: (p: any) => void, onCancel: () => void }} */
|
||||
let { policy = {}, environments = [], onSave, onCancel } = $props();
|
||||
|
||||
let formData = $state({
|
||||
name: policy.name || '',
|
||||
environment_id: policy.environment_id || (environments[0]?.id || ''),
|
||||
dashboard_ids: policy.dashboard_ids || [],
|
||||
schedule_days: policy.schedule_days || [1, 2, 3, 4, 5], // Default Mon-Fri
|
||||
window_start: policy.window_start || '01:00',
|
||||
window_end: policy.window_end || '05:00',
|
||||
notify_owners: policy.notify_owners ?? true,
|
||||
alert_condition: policy.alert_condition || 'FAIL_ONLY'
|
||||
});
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
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 alertConditions = [
|
||||
{ id: 'FAIL_ONLY', label: 'Only on Failure' },
|
||||
{ id: 'WARN_AND_FAIL', label: 'On Warning or Failure' },
|
||||
{ id: 'ALWAYS', label: 'Always' }
|
||||
];
|
||||
|
||||
let windowDurationMinutes = $derived(() => {
|
||||
const [h1, m1] = formData.window_start.split(':').map(Number);
|
||||
const [h2, m2] = formData.window_end.split(':').map(Number);
|
||||
let start = h1 * 60 + m1;
|
||||
let end = h2 * 60 + m2;
|
||||
if (end <= start) end += 24 * 60;
|
||||
return end - start;
|
||||
});
|
||||
|
||||
let isWindowTooSmall = $derived(windowDurationMinutes() < formData.dashboard_ids.length * 1); // Example: 1 min per dashboard
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await onSave(formData);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDay(dayId) {
|
||||
if (formData.schedule_days.includes(dayId)) {
|
||||
formData.schedule_days = formData.schedule_days.filter(d => d !== dayId);
|
||||
} else {
|
||||
formData.schedule_days = [...formData.schedule_days, dayId];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Policy Name</label>
|
||||
<input id="name" type="text" bind:value={formData.name}
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
placeholder="e.g., Production Morning Check" required disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="env" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Environment</label>
|
||||
<select id="env" bind:value={formData.environment_id}
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={isSubmitting}>
|
||||
{#each environments as env}
|
||||
<option value={env.id}>{env.name || env.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Schedule Days</label>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each days as day}
|
||||
<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-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600'}"
|
||||
disabled={isSubmitting}>
|
||||
{day.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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-gray-700 dark:text-gray-300">Window Start</label>
|
||||
<input id="start" type="time" bind:value={formData.window_start}
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={isSubmitting} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="end" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Window End</label>
|
||||
<input id="end" type="time" bind:value={formData.window_end}
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={isSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formData.dashboard_ids.length > 0}
|
||||
<div class="p-3 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-100 dark:border-blue-800">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
💡 System will automatically distribute {formData.dashboard_ids.length} checks within this {Math.floor(windowDurationMinutes() / 60)}h {windowDurationMinutes() % 60}m window.
|
||||
</p>
|
||||
{#if isWindowTooSmall}
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
|
||||
⚠️ Window might be too narrow for {formData.dashboard_ids.length} dashboards.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="alert" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Alert Condition</label>
|
||||
<select id="alert" bind:value={formData.alert_condition}
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={isSubmitting}>
|
||||
{#each alertConditions as cond}
|
||||
<option value={cond.id}>{cond.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input id="notify" type="checkbox" bind:checked={formData.notify_owners}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
disabled={isSubmitting} />
|
||||
<label for="notify" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Auto-notify Dashboard Owners
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
|
||||
<button type="button" onclick={onCancel}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Policy'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- [/DEF:PolicyForm:Component] -->
|
||||
@@ -35,6 +35,8 @@
|
||||
setActiveItem,
|
||||
closeMobile,
|
||||
} from "$lib/stores/sidebar.js";
|
||||
import { healthStore, failingCount } from "$lib/stores/health.js";
|
||||
import { onMount } from "svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { auth } from "$lib/auth/store.js";
|
||||
import { buildSidebarCategories } from "$lib/components/layout/sidebarNavigation.js";
|
||||
@@ -146,6 +148,13 @@
|
||||
$: if (isMobileOpen && $page) {
|
||||
closeMobile();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
healthStore.refresh();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(() => healthStore.refresh(), 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Mobile overlay (only on mobile) -->
|
||||
@@ -207,14 +216,27 @@
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
|
||||
class="relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
|
||||
>
|
||||
<Icon name={category.icon} size={16} strokeWidth={2} />
|
||||
{#if !isExpanded && category.id === "dashboards" && $failingCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 ring-2 ring-white"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if isExpanded}
|
||||
<span class="ml-3 text-sm font-medium truncate"
|
||||
>{category.label}</span
|
||||
>
|
||||
{#if category.id === "dashboards" && $failingCount > 0}
|
||||
<span
|
||||
class="ml-auto mr-2 inline-flex items-center justify-center px-2 py-0.5 text-[10px] font-bold leading-none text-white bg-red-500 rounded-full"
|
||||
title="{$failingCount} failing dashboards"
|
||||
>
|
||||
{$failingCount}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
|
||||
@@ -46,6 +46,12 @@ export function buildSidebarCategories(i18nState, user) {
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.health_center,
|
||||
path: "/dashboards/health",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"datasets": "Datasets",
|
||||
"overview": "Overview",
|
||||
"all_datasets": "All Datasets",
|
||||
"health_center": "Health Center",
|
||||
"storage": "Storage",
|
||||
"backups": "Backups",
|
||||
"repositories": "Repositories",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"datasets": "Датасеты",
|
||||
"overview": "Обзор",
|
||||
"all_datasets": "Все датасеты",
|
||||
"health_center": "Центр здоровья",
|
||||
"storage": "Хранилище",
|
||||
"backups": "Бэкапы",
|
||||
"repositories": "Репозитории",
|
||||
|
||||
66
frontend/src/lib/stores/health.js
Normal file
66
frontend/src/lib/stores/health.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// [DEF:health_store:Store]
|
||||
// @TIER: STANDARD
|
||||
// @PURPOSE: Manage dashboard health summary state and failing counts for UI badges.
|
||||
// @LAYER: UI
|
||||
// @RELATION: DEPENDS_ON -> api.getHealthSummary
|
||||
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { api } from '../api.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} HealthState
|
||||
* @property {Array} items - List of dashboard health items
|
||||
* @property {number} pass_count - Number of passing dashboards
|
||||
* @property {number} warn_count - Number of warning dashboards
|
||||
* @property {number} fail_count - Number of failing dashboards
|
||||
* @property {number} unknown_count - Number of unknown status dashboards
|
||||
* @property {boolean} loading - Loading state
|
||||
* @property {Date|null} lastUpdated - Last successful fetch timestamp
|
||||
*/
|
||||
|
||||
function createHealthStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
items: [],
|
||||
pass_count: 0,
|
||||
warn_count: 0,
|
||||
fail_count: 0,
|
||||
unknown_count: 0,
|
||||
loading: false,
|
||||
lastUpdated: null
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh health summary from API
|
||||
* @param {string|null} environmentId - Optional environment filter
|
||||
*/
|
||||
async function refresh(environmentId = null) {
|
||||
update(s => ({ ...s, loading: true }));
|
||||
try {
|
||||
console.log(`[HealthStore][Action] Refreshing health summary context={{'environmentId': '${environmentId}'}}`);
|
||||
const summary = await api.getHealthSummary(environmentId);
|
||||
set({
|
||||
...summary,
|
||||
loading: false,
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[HealthStore][Coherence:Failed] Failed to fetch health summary:', error);
|
||||
update(s => ({ ...s, loading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
|
||||
export const healthStore = createHealthStore();
|
||||
|
||||
/**
|
||||
* Derived store for the total count of failing dashboards.
|
||||
* Used for sidebar badges and global notifications.
|
||||
*/
|
||||
export const failingCount = derived(healthStore, $health => $health.fail_count);
|
||||
|
||||
// [/DEF:health_store:Store]
|
||||
Reference in New Issue
Block a user