mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
feat: split Host Alerts into Host Alerts, Container Alerts, and Stacks guardrails (#1461)
* feat: split Host Alerts into Host Alerts, Container Alerts, and Stacks guardrails Move global_crash from Host Alerts to new Monitoring > Container Alerts section. Move health gate and env deploy guardrails from Host Alerts to Infrastructure > Stacks > Deploy Guardrails subsection. Host Alerts now contains only host threshold settings (CPU, RAM, disk, alert suppression, and the master host_alerts_enabled toggle). Stacks gains a Deploy Guardrails subsection (node-scoped, admin-gated) alongside the existing Workflow controls (browser-local). Dashboard Crash detection row now routes to Container Alerts. * docs: update crash detection toggle description to match new Container Alerts section
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { DEFAULT_SETTINGS } from './types';
|
||||
import type { PatchableSettings } from './types';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import { useSettingsDirty } from './useSettingsDirty';
|
||||
|
||||
interface ContainerAlertsSectionProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
function SectionSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ContainerAlertFields = Pick<PatchableSettings, 'global_crash'>;
|
||||
|
||||
const DEFAULT_CONTAINER_ALERTS: ContainerAlertFields = {
|
||||
global_crash: DEFAULT_SETTINGS.global_crash,
|
||||
};
|
||||
|
||||
export function ContainerAlertsSection({ onDirtyChange }: ContainerAlertsSectionProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const readOnly = !isAdmin;
|
||||
const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty<ContainerAlertFields>({ ...DEFAULT_CONTAINER_ALERTS });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
|
||||
useMastheadStats(
|
||||
isLoading
|
||||
? null
|
||||
: [
|
||||
{
|
||||
label: 'EDITED',
|
||||
value: hasChanges ? `${dirtyCount} pending` : 'saved',
|
||||
tone: hasChanges ? 'warn' : 'value',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nodeRes = await apiFetch('/settings');
|
||||
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
|
||||
const safe: ContainerAlertFields = {
|
||||
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
};
|
||||
reset(safe);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch container alert settings', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeNode?.id]);
|
||||
|
||||
const onSettingChange = <K extends keyof ContainerAlertFields>(key: K, value: ContainerAlertFields[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
const submitted = { ...settings };
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ global_crash: submitted.global_crash }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
||||
return;
|
||||
}
|
||||
markSaved(submitted);
|
||||
toast.success('Container alert settings saved.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <SectionSkeleton />;
|
||||
|
||||
return (
|
||||
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
|
||||
<SettingsSection title="Container crash & health alerts">
|
||||
<SettingsField
|
||||
label="Container crash & health alerts"
|
||||
helper="Send alerts for unexpected container exits, OOM kills, and Docker healthcheck failures. Auto-Heal can still observe crash signals independently."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.global_crash === '1'}
|
||||
onChange={(c) => onSettingChange('global_crash', c ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
'Save settings'
|
||||
)}
|
||||
</SettingsPrimaryButton>
|
||||
)}
|
||||
</SettingsActions>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user