код написан

This commit is contained in:
2026-03-10 12:00:18 +03:00
parent 82435822eb
commit 31717870e3
57 changed files with 53951 additions and 4909 deletions

View File

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

View 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] -->

View 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] -->

View File

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

View File

@@ -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",
},
],
},
{

View File

@@ -45,6 +45,7 @@
"datasets": "Datasets",
"overview": "Overview",
"all_datasets": "All Datasets",
"health_center": "Health Center",
"storage": "Storage",
"backups": "Backups",
"repositories": "Repositories",

View File

@@ -45,6 +45,7 @@
"datasets": "Датасеты",
"overview": "Обзор",
"all_datasets": "Все датасеты",
"health_center": "Центр здоровья",
"storage": "Хранилище",
"backups": "Бэкапы",
"repositories": "Репозитории",

View 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]