diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0d9368258..f2047b9d9 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -70,13 +70,3 @@ Packages the Helm chart and pushes it to the GitHub Container Registry (OCI) whe - Chart and app versions mirror the Pulse release tag (e.g., `v4.24.0` → `4.24.0`) - Publishes to `oci://ghcr.io//pulse-chart` - Requires no additional secrets—uses the built-in `GITHUB_TOKEN` with `packages: write` permission - -## Helm Integration (Kind) - -**File**: `helm-integration.yml` - -Creates a disposable Kind cluster, installs the chart, waits for the hub deployment to report ready, and performs a `/health` smoke check from inside the cluster. - -- Triggered alongside the lint workflow for PRs/pushes touching Helm content -- Disables persistence to keep the Kind cluster lightweight -- Provides early detection of runtime issues (missing secrets, invalid probes, etc.) diff --git a/.github/workflows/helm-integration.yml b/.github/workflows/helm-integration.yml deleted file mode 100644 index d80079742..000000000 --- a/.github/workflows/helm-integration.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Helm Integration - -on: - push: - branches: [main] - paths: - - "deploy/helm/**" - - ".github/workflows/helm-*.yml" - - "docs/KUBERNETES.md" - - "README.md" - pull_request: - paths: - - "deploy/helm/**" - - ".github/workflows/helm-*.yml" - - "docs/KUBERNETES.md" - - "README.md" - workflow_dispatch: {} - -jobs: - kind-smoke-test: - name: Deploy to Kind and Smoke Test - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Helm - uses: azure/setup-helm@v4 - with: - version: v3.15.2 - - - name: Create Kind cluster - uses: helm/kind-action@v1.8.0 - with: - wait: 120s - - - name: Install Pulse chart - run: | - helm upgrade --install pulse ./deploy/helm/pulse \ - --namespace pulse \ - --create-namespace \ - --set persistence.enabled=false \ - --set server.secretEnv.create=true \ - --set server.secretEnv.data.API_TOKENS=dummy-token \ - --wait \ - --timeout 5m - - - name: Verify deployment is available - run: kubectl -n pulse wait --for=condition=available deployment/pulse --timeout=120s - - - name: Hit health endpoint from inside the cluster - run: | - kubectl -n pulse run smoke-test \ - --rm \ - --image=curlimages/curl:8.3.0 \ - --restart=Never \ - -- curl -fsS http://pulse:7655/health diff --git a/README.md b/README.md index b1dacc6c4..de321e1d6 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,12 @@ Quick start - most settings are in the web UI: - **Settings → Security**: Authentication and API tokens - **Alerts**: Thresholds and notifications +### Apprise CLI Notifications + +Pulse can broadcast grouped alerts through the [Apprise](https://github.com/caronc/apprise) CLI. Install Apprise on the Pulse host (for example with `pip install apprise`) and configure the targets under **Alerts → Notifications**. Each target URL should be a valid Apprise destination (Discord, Slack, email, SMS, etc.). + +You can also override the CLI path and execution timeout if Apprise is installed in a non-standard location. Pulse automatically skips Apprise delivery when no targets are configured. + ### Configuration Files Pulse uses three separate configuration files with clear separation of concerns: diff --git a/frontend-modern/src/api/notifications.ts b/frontend-modern/src/api/notifications.ts index 505bdce8e..5556de4f9 100644 --- a/frontend-modern/src/api/notifications.ts +++ b/frontend-modern/src/api/notifications.ts @@ -57,6 +57,13 @@ export interface Webhook { customFields?: Record; } +export interface AppriseConfig { + enabled: boolean; + targets: string[]; + cliPath?: string; + timeoutSeconds?: number; +} + export interface NotificationTestRequest { type: 'email' | 'webhook'; config?: Record; // Backend expects different format than frontend types @@ -66,6 +73,17 @@ export interface NotificationTestRequest { export class NotificationsAPI { private static baseUrl = '/api/notifications'; + static async getAppriseConfig(): Promise { + return apiFetchJSON(`${this.baseUrl}/apprise`); + } + + static async updateAppriseConfig(config: AppriseConfig): Promise { + return apiFetchJSON(`${this.baseUrl}/apprise`, { + method: 'PUT', + body: JSON.stringify(config), + }); + } + // Email configuration static async getEmailConfig(): Promise { const backendConfig = await apiFetchJSON>(`${this.baseUrl}/email`); diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx index 5873b9b03..5f2198ced 100644 --- a/frontend-modern/src/components/Alerts/ResourceTable.tsx +++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx @@ -133,7 +133,15 @@ export function ResourceTable(props: ResourceTableProps) { return props.resources ?? []; }; - const hasRows = () => flattenResources().length > 0; + const hasRows = () => { + if (flattenResources().length > 0) { + return true; + } + if (props.groupedResources && Object.keys(props.groupedResources).length > 0) { + return true; + } + return Boolean(props.globalDefaults); + }; const [activeMetricInput, setActiveMetricInput] = createSignal<{ resourceId: string; metric: string } | null>(null); const [showDelayRow, setShowDelayRow] = createSignal(false); diff --git a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx index 990adddb4..64de7e21b 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx @@ -15,7 +15,7 @@ import type { DockerHost, DockerContainer, } from '@/types/api'; -import type { RawOverrideConfig, PMGThresholdDefaults } from '@/types/alerts'; +import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig } from '@/types/alerts'; import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable'; type OverrideType = | 'guest' @@ -169,6 +169,14 @@ interface ThresholdsTableProps { | Record> | ((prev: Record>) => Record>), ) => void; + snapshotDefaults: () => SnapshotAlertConfig; + setSnapshotDefaults: ( + value: + | SnapshotAlertConfig + | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig), + ) => void; + snapshotFactoryDefaults?: SnapshotAlertConfig; + resetSnapshotDefaults?: () => void; setHasUnsavedChanges: (value: boolean) => void; activeAlerts?: Record; removeAlerts?: (predicate: (alert: Alert) => boolean) => void; @@ -200,6 +208,9 @@ interface ThresholdsTableProps { setDisableAllDockerHostsOffline: (value: boolean) => void; } +const DEFAULT_SNAPSHOT_WARNING = 30; +const DEFAULT_SNAPSHOT_CRITICAL = 45; + export function ThresholdsTable(props: ThresholdsTableProps) { const navigate = useNavigate(); const location = useLocation(); @@ -744,6 +755,74 @@ const dockerContainersGroupedByHost = createMemo>((pr /* no-op placeholder for future scroll restoration */ }; + const snapshotFactoryConfig = () => + props.snapshotFactoryDefaults ?? { + enabled: false, + warningDays: DEFAULT_SNAPSHOT_WARNING, + criticalDays: DEFAULT_SNAPSHOT_CRITICAL, + }; + + const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => { + let warning = Math.max(0, Math.round(config.warningDays ?? 0)); + let critical = Math.max(0, Math.round(config.criticalDays ?? 0)); + + if (critical > 0 && warning > critical) { + warning = critical; + } + if (critical === 0 && warning > 0) { + critical = warning; + } + + return { + enabled: !!config.enabled, + warningDays: warning, + criticalDays: critical, + }; + }; + + const updateSnapshotDefaults = ( + updater: + | SnapshotAlertConfig + | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig), + ) => { + props.setSnapshotDefaults((prev) => { + const next = + typeof updater === 'function' + ? (updater as (prev: SnapshotAlertConfig) => SnapshotAlertConfig)(prev) + : { ...prev, ...updater }; + return sanitizeSnapshotConfig(next); + }); + props.setHasUnsavedChanges(true); + }; + + const snapshotDefaultsRecord = createMemo(() => { + const current = props.snapshotDefaults(); + return { + 'warning days': current.warningDays ?? 0, + 'critical days': current.criticalDays ?? 0, + }; + }); + + const snapshotFactoryDefaultsRecord = createMemo(() => { + const factory = snapshotFactoryConfig(); + return { + 'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING, + 'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL, + }; + }); + + const snapshotOverridesCount = createMemo(() => { + const current = props.snapshotDefaults(); + const factory = snapshotFactoryConfig(); + return current.enabled !== factory.enabled || + (current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !== + (factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) || + (current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !== + (factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) + ? 1 + : 0; + }); + // Process guests with their overrides and group by node const guestsGroupedByNode = createMemo>((prev = {}) => { // If we're currently editing, return the previous value to avoid re-renders @@ -1082,16 +1161,23 @@ const dockerContainersGroupedByHost = createMemo>((pr }, { key: 'storage' as const, - label: 'Storage', - total: props.storage?.length ?? 0, - overrides: countOverrides(storageWithOverrides()), - tab: 'proxmox' as const, - }, - { - key: 'pbs' as const, - label: 'PBS Servers', - total: props.pbsInstances?.length ?? 0, - overrides: countOverrides(pbsServersWithOverrides()), + label: 'Storage', + total: props.storage?.length ?? 0, + overrides: countOverrides(storageWithOverrides()), + tab: 'proxmox' as const, + }, + { + key: 'snapshots' as const, + label: 'Snapshots', + total: 1, + overrides: snapshotOverridesCount(), + tab: 'proxmox' as const, + }, + { + key: 'pbs' as const, + label: 'PBS Servers', + total: props.pbsInstances?.length ?? 0, + overrides: countOverrides(pbsServersWithOverrides()), tab: 'proxmox' as const, }, { @@ -1883,6 +1969,72 @@ const dockerContainersGroupedByHost = createMemo>((pr + +
+ { + updateSnapshotDefaults((prev) => { + const currentRecord = { + 'warning days': prev.warningDays ?? 0, + 'critical days': prev.criticalDays ?? 0, + }; + const nextRecord = + typeof value === 'function' + ? value(currentRecord) + : { ...currentRecord, ...value }; + return { + ...prev, + warningDays: + typeof nextRecord['warning days'] === 'number' + ? nextRecord['warning days'] + : prev.warningDays, + criticalDays: + typeof nextRecord['critical days'] === 'number' + ? nextRecord['critical days'] + : prev.criticalDays, + }; + }); + }} + setHasUnsavedChanges={props.setHasUnsavedChanges} + globalDisableFlag={() => !props.snapshotDefaults().enabled} + onToggleGlobalDisable={() => + updateSnapshotDefaults((prev) => ({ + ...prev, + enabled: !prev.enabled, + })) + } + factoryDefaults={snapshotFactoryDefaultsRecord()} + onResetDefaults={ + props.resetSnapshotDefaults + ? () => { + props.resetSnapshotDefaults?.(); + props.setHasUnsavedChanges(true); + } + : () => + updateSnapshotDefaults({ + ...snapshotFactoryConfig(), + }) + } + /> +
+
+
({ useNavigate: () => vi.fn(), @@ -87,6 +87,10 @@ const baseProps = () => ({ factoryNodeDefaults: {}, factoryDockerDefaults: {}, factoryStorageDefault: 85, + snapshotDefaults: () => ({ enabled: false, warningDays: 30, criticalDays: 45 }), + setSnapshotDefaults: vi.fn(), + snapshotFactoryDefaults: { enabled: false, warningDays: 30, criticalDays: 45 } as SnapshotAlertConfig, + resetSnapshotDefaults: vi.fn(), timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5 }), metricTimeThresholds: () => ({}), setMetricTimeThresholds: vi.fn(), diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 7e82931aa..2280cc2d8 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -3,18 +3,18 @@ import type { JSX } from 'solid-js'; import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect'; import { WebhookConfig } from '@/components/Alerts/WebhookConfig'; import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable'; -import type { RawOverrideConfig, PMGThresholdDefaults } from '@/types/alerts'; +import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig } from '@/types/alerts'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { SettingsPanel } from '@/components/shared/SettingsPanel'; import { Toggle } from '@/components/shared/Toggle'; -import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; +import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form'; import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; import { AlertsAPI } from '@/api/alerts'; import { NotificationsAPI, Webhook } from '@/api/notifications'; -import type { EmailConfig } from '@/api/notifications'; +import type { EmailConfig, AppriseConfig } from '@/api/notifications'; import type { HysteresisThreshold } from '@/types/alerts'; import type { Alert, State, VM, Container, DockerHost, DockerContainer } from '@/types/api'; import { useNavigate, useLocation } from '@solidjs/router'; @@ -98,6 +98,7 @@ export const tabFromPath = ( // Store reference interfaces interface DestinationsRef { emailConfig?: () => EmailConfig; + appriseConfig?: () => AppriseConfig; } // Override interface for both guests and nodes @@ -151,6 +152,13 @@ interface UIEmailConfig { rateLimit: number; } +interface UIAppriseConfig { + enabled: boolean; + targetsText: string; + cliPath: string; + timeoutSeconds: number; +} + interface QuietHoursConfig { enabled: boolean; start: string; @@ -226,6 +234,22 @@ export const createDefaultGrouping = (): GroupingConfig => ({ byGuest: false, }); +const createDefaultAppriseConfig = (): UIAppriseConfig => ({ + enabled: false, + targetsText: '', + cliPath: 'apprise', + timeoutSeconds: 15, +}); + +const parseAppriseTargets = (value: string): string[] => + value + .split(/\r?\n|,/) + .map((entry) => entry.trim()) + .filter((entry, index, arr) => entry.length > 0 && arr.indexOf(entry) === index); + +const formatAppriseTargets = (targets: string[] | undefined | null): string => + targets && targets.length > 0 ? targets.join('\n') : ''; + export const normalizeMetricDelayMap = ( input: Record> | undefined | null, ): Record> => { @@ -348,11 +372,11 @@ export function Alerts() { >({}); // Store raw config // Email configuration state moved to parent to persist across tab changes - const [emailConfig, setEmailConfig] = createSignal({ - enabled: false, - provider: '', - server: '', // Fixed: use 'server' not 'smtpHost' - port: 587, // Fixed: use 'port' not 'smtpPort' +const [emailConfig, setEmailConfig] = createSignal({ + enabled: false, + provider: '', + server: '', // Fixed: use 'server' not 'smtpHost' + port: 587, // Fixed: use 'port' not 'smtpPort' username: '', password: '', from: '', @@ -362,8 +386,12 @@ export function Alerts() { replyTo: '', maxRetries: 3, retryDelay: 5, - rateLimit: 60, - }); + rateLimit: 60, +}); + +const [appriseConfig, setAppriseConfig] = createSignal( + createDefaultAppriseConfig(), +); // Schedule configuration state moved to parent to persist across tab changes const [scheduleQuietHours, setScheduleQuietHours] = @@ -395,6 +423,16 @@ export function Alerts() { } as EmailConfig; }; + destinationsRef.appriseConfig = () => { + const config = appriseConfig(); + return { + enabled: config.enabled, + targets: parseAppriseTargets(config.targetsText), + cliPath: config.cliPath, + timeoutSeconds: config.timeoutSeconds, + } as AppriseConfig; + }; + // Process raw overrides config when state changes createEffect(() => { // Skip this effect if there are unsaved changes to prevent losing focus @@ -658,6 +696,8 @@ export function Alerts() { rateLimit: 60, }); + setAppriseConfig(createDefaultAppriseConfig()); + try { const config = await AlertsAPI.getConfig(); @@ -729,6 +769,29 @@ export function Alerts() { setMetricTimeThresholds({}); } + // Load snapshot thresholds + if (config.snapshotDefaults) { + const enabled = Boolean(config.snapshotDefaults.enabled); + const rawWarning = config.snapshotDefaults.warningDays ?? 30; + const rawCritical = config.snapshotDefaults.criticalDays ?? 45; + const safeCritical = Math.max(0, rawCritical); + const normalizedWarning = Math.max(0, rawWarning); + const warningDays = + safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning; + const criticalDays = Math.max(safeCritical, warningDays); + setSnapshotDefaults({ + enabled, + warningDays, + criticalDays, + }); + } else { + setSnapshotDefaults({ + enabled: false, + warningDays: 30, + criticalDays: 45, + }); + } + // Load PMG thresholds if (config.pmgDefaults) { setPMGThresholds({ @@ -867,6 +930,21 @@ export function Alerts() { console.error('Failed to load email configuration:', emailErr); } + try { + const appriseData = await NotificationsAPI.getAppriseConfig(); + setAppriseConfig({ + enabled: appriseData.enabled ?? false, + targetsText: formatAppriseTargets(appriseData.targets), + cliPath: appriseData.cliPath || 'apprise', + timeoutSeconds: + typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0 + ? appriseData.timeoutSeconds + : 15, + }); + } catch (appriseErr) { + console.error('Failed to load Apprise configuration:', appriseErr); + } + if (options.notify) { showSuccess('Changes discarded'); } @@ -911,6 +989,22 @@ export function Alerts() { .catch((err) => { console.error('Failed to reload email configuration:', err); }); + + NotificationsAPI.getAppriseConfig() + .then((appriseData) => { + setAppriseConfig({ + enabled: appriseData.enabled ?? false, + targetsText: formatAppriseTargets(appriseData.targets), + cliPath: appriseData.cliPath || 'apprise', + timeoutSeconds: + typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0 + ? appriseData.timeoutSeconds + : 15, + }); + }) + .catch((err) => { + console.error('Failed to reload Apprise configuration:', err); + }); } }); @@ -959,6 +1053,11 @@ export function Alerts() { }; const FACTORY_STORAGE_DEFAULT = 85; + const FACTORY_SNAPSHOT_DEFAULTS: SnapshotAlertConfig = { + enabled: false, + warningDays: 30, + criticalDays: 45, + }; // Threshold states - using trigger values for display const [guestDefaults, setGuestDefaults] = createSignal>({ ...FACTORY_GUEST_DEFAULTS }); @@ -997,15 +1096,22 @@ export function Alerts() { setStorageDefault(FACTORY_STORAGE_DEFAULT); setHasUnsavedChanges(true); }; -const [timeThreshold, setTimeThreshold] = createSignal(DEFAULT_DELAY_SECONDS); // Legacy -const [timeThresholds, setTimeThresholds] = createSignal({ - guest: DEFAULT_DELAY_SECONDS, - node: DEFAULT_DELAY_SECONDS, - storage: DEFAULT_DELAY_SECONDS, - pbs: DEFAULT_DELAY_SECONDS, -}); + const resetSnapshotDefaults = () => { + setSnapshotDefaults({ ...FACTORY_SNAPSHOT_DEFAULTS }); + setHasUnsavedChanges(true); + }; + const [timeThreshold, setTimeThreshold] = createSignal(DEFAULT_DELAY_SECONDS); // Legacy + const [timeThresholds, setTimeThresholds] = createSignal({ + guest: DEFAULT_DELAY_SECONDS, + node: DEFAULT_DELAY_SECONDS, + storage: DEFAULT_DELAY_SECONDS, + pbs: DEFAULT_DELAY_SECONDS, + }); const [metricTimeThresholds, setMetricTimeThresholds] = createSignal>>({}); + const [snapshotDefaults, setSnapshotDefaults] = createSignal({ + ...FACTORY_SNAPSHOT_DEFAULTS, + }); const [pmgThresholds, setPMGThresholds] = createSignal({ queueTotalWarning: 500, @@ -1117,6 +1223,14 @@ const [timeThresholds, setTimeThresholds] = createSignal({ }; }; + const snapshotConfig = snapshotDefaults(); + const normalizedWarningDays = Math.max(0, snapshotConfig.warningDays ?? 0); + const normalizedCriticalDays = Math.max(0, snapshotConfig.criticalDays ?? 0); + const finalCriticalDays = + normalizedCriticalDays > 0 + ? Math.max(normalizedCriticalDays, normalizedWarningDays) + : normalizedWarningDays; + const alertConfig = { enabled: true, // Global disable flags per resource type @@ -1168,6 +1282,11 @@ const [timeThresholds, setTimeThresholds] = createSignal({ timeThreshold: timeThreshold() || 0, // Legacy timeThresholds: timeThresholds(), metricTimeThresholds: normalizeMetricDelayMap(metricTimeThresholds()), + snapshotDefaults: { + enabled: snapshotConfig.enabled, + warningDays: normalizedWarningDays, + criticalDays: finalCriticalDays, + }, pmgDefaults: pmgThresholds(), // Use rawOverridesConfig which is already properly formatted with disabled flags overrides: rawOverridesConfig(), @@ -1217,6 +1336,20 @@ const [timeThresholds, setTimeThresholds] = createSignal({ await NotificationsAPI.updateEmailConfig(emailData); } + if (destinationsRef.appriseConfig) { + const appriseData = destinationsRef.appriseConfig(); + const updatedApprise = await NotificationsAPI.updateAppriseConfig(appriseData); + setAppriseConfig({ + enabled: updatedApprise.enabled ?? false, + targetsText: formatAppriseTargets(updatedApprise.targets), + cliPath: updatedApprise.cliPath || 'apprise', + timeoutSeconds: + typeof updatedApprise.timeoutSeconds === 'number' && updatedApprise.timeoutSeconds > 0 + ? updatedApprise.timeoutSeconds + : 15, + }); + } + setHasUnsavedChanges(false); showSuccess('Configuration saved successfully!'); } catch (err) { @@ -1357,13 +1490,17 @@ const [timeThresholds, setTimeThresholds] = createSignal({ resetDockerDefaults={resetDockerDefaults} resetDockerIgnoredPrefixes={resetDockerIgnoredPrefixes} resetStorageDefault={resetStorageDefault} + resetSnapshotDefaults={resetSnapshotDefaults} factoryGuestDefaults={FACTORY_GUEST_DEFAULTS} factoryNodeDefaults={FACTORY_NODE_DEFAULTS} factoryDockerDefaults={FACTORY_DOCKER_DEFAULTS} factoryStorageDefault={FACTORY_STORAGE_DEFAULT} + snapshotFactoryDefaults={FACTORY_SNAPSHOT_DEFAULTS} timeThresholds={timeThresholds} metricTimeThresholds={metricTimeThresholds} setMetricTimeThresholds={setMetricTimeThresholds} + snapshotDefaults={snapshotDefaults} + setSnapshotDefaults={setSnapshotDefaults} pmgThresholds={pmgThresholds} setPMGThresholds={setPMGThresholds} activeAlerts={activeAlerts} @@ -1404,6 +1541,8 @@ const [timeThresholds, setTimeThresholds] = createSignal({ setHasUnsavedChanges={setHasUnsavedChanges} emailConfig={emailConfig} setEmailConfig={setEmailConfig} + appriseConfig={appriseConfig} + setAppriseConfig={setAppriseConfig} /> @@ -1881,6 +2020,14 @@ interface ThresholdsTabProps { | Record> | ((prev: Record>) => Record>), ) => void; + snapshotDefaults: () => SnapshotAlertConfig; + setSnapshotDefaults: ( + value: + | SnapshotAlertConfig + | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig), + ) => void; + snapshotFactoryDefaults: SnapshotAlertConfig; + resetSnapshotDefaults: () => void; setOverrides: (value: Override[]) => void; setRawOverridesConfig: (value: Record) => void; activeAlerts: Record; @@ -1926,7 +2073,6 @@ interface ThresholdsTabProps { } function ThresholdsTab(props: ThresholdsTabProps) { - // Use the new table component for a cleaner, more information-dense layout return ( void; emailConfig: () => UIEmailConfig; setEmailConfig: (config: UIEmailConfig) => void; + appriseConfig: () => UIAppriseConfig; + setAppriseConfig: (config: UIAppriseConfig) => void; } function DestinationsTab(props: DestinationsTabProps) { const [webhooks, setWebhooks] = createSignal([]); const [testingEmail, setTestingEmail] = createSignal(false); const [testingWebhook, setTestingWebhook] = createSignal(null); - + const appriseState = () => props.appriseConfig(); + const updateApprise = (partial: Partial) => { + props.setAppriseConfig({ ...props.appriseConfig(), ...partial }); + }; // Load webhooks on mount (email config is now loaded in parent) onMount(async () => { try { @@ -2105,6 +2260,78 @@ function DestinationsTab(props: DestinationsTabProps) {
+ { + updateApprise({ enabled: e.currentTarget.checked }); + props.setHasUnsavedChanges(true); + }} + containerClass="sm:self-start" + label={ + + {appriseState().enabled ? 'Enabled' : 'Disabled'} + + } + /> + } + class="min-w-0" + bodyClass="space-y-4" + > +
+
+ +