Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Three independent Go/Vue modules: **server** (Go API), **agent** (Go collector),
- [composables/](frontend/src/composables/) — `useWebSocket`, `useCommandStream`, `useDashboard`, `useHostDetail`, `useFormValidator`, `useToast`, `useConfirmDialog`, `usePagination`, …
- [utils/](frontend/src/utils/) — formatters, chart theme, dockerPorts, cron, dayjs, httpErrorBus, statusClasses, …

**TypeScript adoption**: all `.vue` files use `<script setup lang="ts">` (100% migrated). Composables, stores and `utils/` are `.ts`. The API + WebSocket payloads are fully typed against the generated models (see "Generated API types" below), including the dashboard / notification / IP-timeline consumers. One intentional residual remains: `useHostDetail.ts` keeps its 12 host-detail display refs as a local `AnyRecord` behind safe `asRecord`/`asRecordArray` coercers — the WS boundary feeding them is already typed (`WSHostSnapshot`), so this is a deliberate display-layer abstraction, not an unchecked wire contract. Typing it would cascade through the large `HostDetailView` + its sub-tabs for no contract-robustness gain.
**TypeScript adoption**: all `.vue` files use `<script setup lang="ts">` (100% migrated). Composables, stores and `utils/` are `.ts`. WebSocket payloads and most API responses are typed against the generated models (see "Generated API types" below), including the dashboard / notification / IP-timeline consumers; some `api.get(` calls in the domain modules (mostly `api/proxmox.ts`, `api/hosts.ts`, `api/auth.ts`) still return untyped data — give them the generated response type when touching them rather than casting at the call site. One intentional residual remains: `useHostDetail.ts` keeps its 12 host-detail display refs as a local `AnyRecord` behind safe `asRecord`/`asRecordArray` coercers — the WS boundary feeding them is already typed (`WSHostSnapshot`), so this is a deliberate display-layer abstraction, not an unchecked wire contract. Typing it would cascade through the large `HostDetailView` + its sub-tabs for no contract-robustness gain.

### Agent ↔ Server protocol

Expand Down
12 changes: 6 additions & 6 deletions frontend/src/api/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { api } from './client'
import type { AlertRule, AlertRulePayload } from '../types/alert'
import type { AlertRule, AlertRulePayload, AlertMetricCatalog, AlertSplitCapabilities, AlertDockerCapabilities, AlertHostCapabilities } from '../types/alert'
import type { AlertRuleTemplate, AlertRuleTemplateRequest, ApplyAlertRuleTemplateRequest, ApplyAlertRuleTemplateResult } from '../types/generated'

// Re-exported so existing import sites keep working from the api barrel.
export type { AlertRule, AlertRulePayload } from '../types/alert'

export const alertsApi = {
getAgentAlertRuleCapabilities: () => api.get('/v1/alert-rules/capabilities/agent'),
getProxmoxAlertRuleCapabilities: () => api.get('/v1/alert-rules/capabilities/proxmox'),
getSyntheticAlertRuleCapabilities: () => api.get('/v1/alert-rules/capabilities/synthetic'),
getDockerAlertCapabilities: () => api.get('/v1/alert-rules/capabilities/docker'),
getHostCapabilities: (hostId: string) => api.get(`/v1/hosts/${hostId}/capabilities`),
getAgentAlertRuleCapabilities: () => api.get<AlertMetricCatalog>('/v1/alert-rules/capabilities/agent'),
getProxmoxAlertRuleCapabilities: () => api.get<AlertSplitCapabilities>('/v1/alert-rules/capabilities/proxmox'),
getSyntheticAlertRuleCapabilities: () => api.get<AlertMetricCatalog>('/v1/alert-rules/capabilities/synthetic'),
getDockerAlertCapabilities: () => api.get<AlertDockerCapabilities>('/v1/alert-rules/capabilities/docker'),
getHostCapabilities: (hostId: string) => api.get<AlertHostCapabilities>(`/v1/hosts/${hostId}/capabilities`),
getAlertRules: () => api.get<AlertRule[]>('/v1/alert-rules'),
createAlertRule: (payload: AlertRulePayload) => api.post('/v1/alert-rules', payload),
updateAlertRule: (id: number, payload: AlertRulePayload) => api.patch(`/v1/alert-rules/${id}`, payload),
Expand Down
48 changes: 1 addition & 47 deletions frontend/src/components/alerts/AlertRuleList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ import { IconPencil, IconTrash } from '@tabler/icons-vue'
import { formatDurationSecs } from '../../utils/formatters'
import { getAlertMetricMeta } from '../../utils/alertMetrics'
import { notificationChannelBadgeClass } from '../../utils/categoryBadges'
import type { AlertRule } from '../../types/alert'

const { t } = useI18n()

Expand All @@ -231,53 +232,6 @@ interface Host {
name?: string
}

interface ProxmoxScope {
scope_mode?: string
connection_id?: string | number
node_id?: string | number
guest_id?: string | number
storage_id?: string | number
disk_id?: string | number
}

interface CommandTrigger {
module: string
action: string
target?: string
}

interface AlertActions {
channels?: string[]
command_trigger?: CommandTrigger | null
}

interface DockerScope {
scope_mode?: string
host_id?: string
container_id?: string
project_name?: string
}

interface AlertRule {
id: string | number
name?: string
enabled?: boolean
host_id?: string
source_type?: string
metric: string
operator: string
threshold_warn?: number
threshold_crit?: number
threshold_clear_warn?: number | null
threshold_clear_crit?: number | null
duration_seconds?: number
active_incident_count?: number
last_fired?: string
actions?: AlertActions
proxmox_scope?: ProxmoxScope
docker_scope?: DockerScope
}

function channelLabel(channel: string): string {
if (channel === 'browser') return t('alerts.channelBrowserLabel')
if (channel === 'smtp') return t('alerts.channelEmail')
Expand Down
66 changes: 59 additions & 7 deletions frontend/src/components/alerts/AlertRuleModal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { setLocale } from '../../i18n'

Expand All @@ -12,14 +12,25 @@ vi.mock('../../api', () => ({
},
}))

import apiClient from '../../api'
import AlertRuleModal from './AlertRuleModal.vue'
import type { AlertMetricCapability, AlertRuleCapabilities } from '../../types/alert'

const capabilities = {
metrics: [
{ metric: 'cpu', label: 'CPU', icon: '', supports_host_filter: true },
{ metric: 'memory', label: 'Mémoire', icon: '', supports_host_filter: true },
],
proxmox_scope: { connections: [], nodes: [], storages: [], guests: [], disks: [] },
function metric(key: string, label: string): AlertMetricCapability {
return {
metric: key, label, unit: '%', icon: '', badge_class: '',
supports_threshold: true, supports_duration: true, supports_host_filter: true,
}
}

const agentMetrics = [metric('cpu', 'CPU'), metric('memory', 'Mémoire')]
const capabilities: AlertRuleCapabilities = {
metrics: agentMetrics,
agent_metrics: agentMetrics,
proxmox_metrics: [],
synthetic_metrics: [],
docker_metrics: [],
proxmox_scope: { modes: [], connections: [], nodes: [], storages: [], guests: [], disks: [] },
}

function mountModal(props: Record<string, unknown> = {}) {
Expand Down Expand Up @@ -146,4 +157,45 @@ describe('AlertRuleModal (characterization)', () => {

expect((wrapper.find('input[placeholder="Ex: CPU élevé sur serveur web"]').element as HTMLInputElement).value).toBe('CPU élevé')
})

it('"Tester" sends the rule being edited and shows the API error when the test fails', async () => {
// Earlier tests leave mounted modals whose step-2 preview timers can still
// fire, so only calls carrying this rule's name are counted.
const name = 'Tester rule'
const testAlertRule = vi.mocked(apiClient.testAlertRule)
const callsForRule = () => testAlertRule.mock.calls.filter(([p]) => (p as { name?: string }).name === name)
testAlertRule.mockImplementation(async (payload) => {
if ((payload as { name?: string }).name === name) throw { response: { data: { error: 'métrique inconnue' } } }
return { data: { results: [] } } as never
})
const rule = {
id: 1, name, source_type: 'agent', metric: 'cpu', operator: '>',
threshold_warn: 70, threshold_crit: 85, duration: 0, actions: { channels: [] },
}
// Entering step 2 schedules this modal's automatic preview test; fake
// timers let it run before the manual click is counted.
vi.useFakeTimers()
try {
const wrapper = mountModal({ rule })
await nextTick()
for (let i = 0; i < 2; i++) {
await wrapper.findAll('button').find((b) => b.text().includes('Suivant'))!.trigger('click')
await nextTick()
}
await vi.runAllTimersAsync()
await flushPromises()
const before = callsForRule().length

await wrapper.findAll('button').find((b) => b.text() === 'Tester')!.trigger('click')
await flushPromises()

expect(callsForRule().length - before).toBe(1)
const calls = callsForRule()
expect(calls[calls.length - 1][0]).toMatchObject({ metric: 'cpu', threshold_crit: 85 })
expect(wrapper.text()).toContain('métrique inconnue')
} finally {
vi.useRealTimers()
testAlertRule.mockImplementation(async () => ({ data: { results: [] } }) as never)
}
})
})
77 changes: 24 additions & 53 deletions frontend/src/components/alerts/AlertRuleModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,10 @@ import AlertRuleStepSource from './AlertRuleStepSource.vue'
import AlertRuleStepConditions from './AlertRuleStepConditions.vue'
import AlertRuleStepNotifications from './AlertRuleStepNotifications.vue'
import { useAlertRuleForm, type AlertRuleInput } from '../../composables/useAlertRuleForm'
import type { AlertRulePayload as ApiAlertRulePayload } from '../../types/alert'
import type {
AlertRule, AlertRulePayload as ApiAlertRulePayload, AlertRuleCapabilities, AlertMetricCapability,
AlertDockerCapabilities, AlertDockerHostScope, AlertHostCapabilities,
} from '../../types/alert'
import { useModalChrome } from '../../composables/useModalChrome'
import { ALERT_METRIC_ORDER, getAlertMetricMeta } from '../../utils/alertMetrics'
import { ALERT_RULE_PRESETS, type AlertRulePreset } from '../../utils/alertRulePresets'
Expand All @@ -216,36 +219,6 @@ interface Host {
name?: string
}

interface MetricMeta {
metric: string
label: string
icon?: string
unit?: string
supports_host_filter?: boolean
}

interface ProxmoxScopeItem {
id: string | number
name?: string
[key: string]: unknown
}

interface Capabilities {
metrics?: MetricMeta[]
proxmox_scope?: {
connections?: ProxmoxScopeItem[]
nodes?: ProxmoxScopeItem[]
storages?: ProxmoxScopeItem[]
guests?: ProxmoxScopeItem[]
disks?: ProxmoxScopeItem[]
}
}

interface AlertRule {
id?: string | number
[key: string]: unknown
}

interface TestResult {
has_data?: boolean
[key: string]: unknown
Expand All @@ -256,16 +229,11 @@ interface TestResults {
[key: string]: unknown
}

interface HostMetrics {
metrics?: MetricMeta[]
[key: string]: unknown
}

const props = withDefaults(defineProps<{
visible?: boolean
rule?: AlertRule | null
hosts?: Host[]
capabilities?: Capabilities | null
capabilities?: AlertRuleCapabilities | null
capabilitiesLoading?: boolean
capabilitiesError?: string
saving?: boolean
Expand All @@ -283,7 +251,7 @@ const props = withDefaults(defineProps<{

const emit = defineEmits<{
(e: 'close'): void
(e: 'submit', payload: unknown): void
(e: 'submit', payload: ApiAlertRulePayload): void
}>()

const { t } = useI18n()
Expand All @@ -295,7 +263,7 @@ const browserPermission = ref<NotificationPermission | 'unsupported'>(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
)
const step = ref(1)
const hostMetrics = ref<HostMetrics | null>(null)
const hostMetrics = ref<AlertHostCapabilities | null>(null)
const hostMetricsLoading = ref(false)
const hostMetricsError = ref('')

Expand Down Expand Up @@ -323,8 +291,8 @@ const metricCards = computed(() => {
const fromCapabilities = props.capabilities?.metrics
if (Array.isArray(fromCapabilities) && fromCapabilities.length > 0) {
return fromCapabilities
.filter((metric: MetricMeta) => matchesSource(metric.metric))
.map((metric: MetricMeta) => ({
.filter((metric: AlertMetricCapability) => matchesSource(metric.metric))
.map((metric: AlertMetricCapability) => ({
value: metric.metric,
label: metric.label,
icon: metric.icon || getAlertMetricMeta(metric.metric).icon,
Expand All @@ -342,17 +310,13 @@ const proxmoxStorages = computed(() => props.capabilities?.proxmox_scope?.storag
const proxmoxGuests = computed(() => props.capabilities?.proxmox_scope?.guests || [])
const proxmoxDisks = computed(() => props.capabilities?.proxmox_scope?.disks || [])

interface DockerContainer { id: string; name: string; image: string; state: string }
interface DockerProject { name: string; services: string[] }
interface DockerHostOption { host_id: string; host_name: string; containers: DockerContainer[]; projects: DockerProject[] }

const dockerCapabilities = ref<{ hosts: DockerHostOption[] } | null>(null)
const dockerCapabilities = ref<AlertDockerCapabilities | null>(null)
const dockerCapabilitiesLoading = ref(false)
const dockerHosts = computed<DockerHostOption[]>(() => dockerCapabilities.value?.hosts || [])
const dockerHosts = computed<AlertDockerHostScope[]>(() => dockerCapabilities.value?.hosts || [])

const metricMetaByKey = computed<Record<string, MetricMeta>>(() => {
const metricMetaByKey = computed<Record<string, AlertMetricCapability>>(() => {
const items = props.capabilities?.metrics || []
return Object.fromEntries(items.map((item: MetricMeta) => [item.metric, item]))
return Object.fromEntries(items.map((item) => [item.metric, item]))
})

const metricAllowsStorageScope = computed(() => form.value.metric === 'proxmox_storage_percent')
Expand Down Expand Up @@ -448,7 +412,7 @@ watch(
const response = await apiClient.getDockerAlertCapabilities()
dockerCapabilities.value = response.data
} catch {
dockerCapabilities.value = { hosts: [] }
dockerCapabilities.value = { metrics: [], hosts: [] }
} finally {
dockerCapabilitiesLoading.value = false
}
Expand Down Expand Up @@ -562,11 +526,18 @@ onUnmounted(() => {
if (autoTestTimer) clearTimeout(autoTestTimer)
})

// The form payload keeps explicit nulls for cleared hysteresis values and
// scopes (the server treats null as "unset"), where the API type only says
// optional — convert in this one place.
function apiPayload(): ApiAlertRulePayload {
return buildPayload() as unknown as ApiAlertRulePayload
}

async function submit() {
if (channelBrowser.value && typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
browserPermission.value = await Notification.requestPermission()
}
emit('submit', buildPayload())
emit('submit', apiPayload())
}

async function testAlert(): Promise<void> {
Expand All @@ -575,7 +546,7 @@ async function testAlert(): Promise<void> {
testResults.value = null
testError.value = ''
try {
const response = await apiClient.testAlertRule(buildPayload() as unknown as ApiAlertRulePayload)
const response = await apiClient.testAlertRule(apiPayload())
testResults.value = response.data
} catch (err: unknown) {
testResults.value = null
Expand All @@ -594,7 +565,7 @@ async function downloadTestLogs(): Promise<void> {
if (downloadingLogs.value || !canDownloadTestLogs.value) return
downloadingLogs.value = true
try {
const response = await apiClient.downloadAlertRuleTestLogs(buildPayload() as unknown as ApiAlertRulePayload)
const response = await apiClient.downloadAlertRuleTestLogs(apiPayload())
const blob = response.data instanceof Blob ? response.data : new Blob([response.data], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
Expand Down
29 changes: 9 additions & 20 deletions frontend/src/components/alerts/AlertRuleStepSource.vue
Original file line number Diff line number Diff line change
Expand Up @@ -484,43 +484,32 @@ import { useI18n } from 'vue-i18n'
import type { AlertRuleFormData, DockerScope } from '../../composables/useAlertRuleForm'
import { getAlertMetricMeta } from '../../utils/alertMetrics'
import { getEntityStateLabel } from '../../utils/statusClasses'
import type { AlertRuleCapabilities, AlertHostCapabilities, AlertScopeOption, AlertDockerHostScope } from '../../types/alert'

interface ScopeOption { id: string; label: string }
interface MetricCard { value: string; label: string; icon: string }
interface HostOption { id: string; name: string }
interface HostMetrics { metrics?: Array<{ metric: string; label: string; icon?: string }> }
interface Capabilities { metrics?: Array<{ metric: string }> }

interface DockerContainer { id: string; name: string; image: string; state: string }
interface DockerProject { name: string; services: string[] }
interface DockerHostOption {
host_id: string
host_name: string
containers: DockerContainer[]
projects: DockerProject[]
}

const props = defineProps<{
form: AlertRuleFormData
rule?: { id?: number | string } | null
hosts: HostOption[]
capabilities?: Capabilities | null
capabilities?: Pick<AlertRuleCapabilities, 'metrics'> | null
capabilitiesLoading?: boolean
capabilitiesError?: string
hostMetrics?: HostMetrics | null
hostMetrics?: Pick<AlertHostCapabilities, 'metrics'> | null
hostMetricsLoading?: boolean
hostMetricsError?: string
metricCards: MetricCard[]
metricSupportsHostFilter: boolean
metricAllowsGuestScope: boolean
metricAllowsStorageScope: boolean
metricAllowsDiskScope: boolean
proxmoxConnections: ScopeOption[]
proxmoxNodes: ScopeOption[]
proxmoxStorages: ScopeOption[]
proxmoxGuests: ScopeOption[]
proxmoxDisks: ScopeOption[]
dockerHosts: DockerHostOption[]
proxmoxConnections: AlertScopeOption[]
proxmoxNodes: AlertScopeOption[]
proxmoxStorages: AlertScopeOption[]
proxmoxGuests: AlertScopeOption[]
proxmoxDisks: AlertScopeOption[]
dockerHosts: AlertDockerHostScope[]
dockerCapabilitiesLoading?: boolean
}>()

Expand Down
Loading
Loading