diff --git a/frontend/src/views/admin/ops/OpsDashboard.vue b/frontend/src/views/admin/ops/OpsDashboard.vue index be445a32..d059059d 100644 --- a/frontend/src/views/admin/ops/OpsDashboard.vue +++ b/frontend/src/views/admin/ops/OpsDashboard.vue @@ -169,7 +169,13 @@ const QUERY_KEYS = { platform: 'platform', groupId: 'group_id', queryMode: 'mode', - fullscreen: 'fullscreen' + fullscreen: 'fullscreen', + + // Deep links + openErrorDetails: 'open_error_details', + errorType: 'error_type', + alertRuleId: 'alert_rule_id', + openAlertRules: 'open_alert_rules' } as const const isApplyingRouteQuery = ref(false) @@ -249,6 +255,24 @@ const applyRouteQueryToState = () => { const fallback = adminSettingsStore.opsQueryModeDefault || 'auto' queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto' } + + // Deep links + const openRules = readQueryString(QUERY_KEYS.openAlertRules) + if (openRules === '1' || openRules === 'true') { + showAlertRulesCard.value = true + } + + const ruleID = readQueryNumber(QUERY_KEYS.alertRuleId) + if (typeof ruleID === 'number' && ruleID > 0) { + showAlertRulesCard.value = true + } + + const openErr = readQueryString(QUERY_KEYS.openErrorDetails) + if (openErr === '1' || openErr === 'true') { + const typ = readQueryString(QUERY_KEYS.errorType) + errorDetailsType.value = typ === 'upstream' ? 'upstream' : 'request' + showErrorDetails.value = true + } } applyRouteQueryToState() diff --git a/frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue b/frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue index 58a91355..ef6e8f80 100644 --- a/frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue +++ b/frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue @@ -3,42 +3,326 @@ import { computed, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import Select from '@/components/common/Select.vue' -import { opsAPI } from '@/api/admin/ops' +import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' +import { opsAPI, type AlertEventsQuery } from '@/api/admin/ops' import type { AlertEvent } from '../types' import { formatDateTime } from '../utils/opsFormatters' const { t } = useI18n() const appStore = useAppStore() -const loading = ref(false) -const events = ref([]) +const PAGE_SIZE = 20 -const limit = ref(100) -const limitOptions = computed(() => [ - { value: 50, label: '50' }, - { value: 100, label: '100' }, - { value: 200, label: '200' } +const loading = ref(false) +const loadingMore = ref(false) +const events = ref([]) +const hasMore = ref(true) + +// Detail modal +const showDetail = ref(false) +const selected = ref(null) +const detailLoading = ref(false) +const detailActionLoading = ref(false) +const historyLoading = ref(false) +const history = ref([]) +const historyRange = ref('7d') +const historyRangeOptions = computed(() => [ + { value: '7d', label: t('admin.ops.timeRange.7d') }, + { value: '30d', label: t('admin.ops.timeRange.30d') } ]) -async function load() { +const silenceDuration = ref('1h') +const silenceDurationOptions = computed(() => [ + { value: '1h', label: t('admin.ops.timeRange.1h') }, + { value: '24h', label: t('admin.ops.timeRange.24h') }, + { value: '7d', label: t('admin.ops.timeRange.7d') } +]) + +// Filters +const timeRange = ref('24h') +const timeRangeOptions = computed(() => [ + { value: '5m', label: t('admin.ops.timeRange.5m') }, + { value: '30m', label: t('admin.ops.timeRange.30m') }, + { value: '1h', label: t('admin.ops.timeRange.1h') }, + { value: '6h', label: t('admin.ops.timeRange.6h') }, + { value: '24h', label: t('admin.ops.timeRange.24h') }, + { value: '7d', label: t('admin.ops.timeRange.7d') }, + { value: '30d', label: t('admin.ops.timeRange.30d') } +]) + +const severity = ref('') +const severityOptions = computed(() => [ + { value: '', label: t('common.all') }, + { value: 'P0', label: 'P0' }, + { value: 'P1', label: 'P1' }, + { value: 'P2', label: 'P2' }, + { value: 'P3', label: 'P3' } +]) + +const status = ref('') +const statusOptions = computed(() => [ + { value: '', label: t('common.all') }, + { value: 'firing', label: t('admin.ops.alertEvents.status.firing') }, + { value: 'resolved', label: t('admin.ops.alertEvents.status.resolved') }, + { value: 'manual_resolved', label: t('admin.ops.alertEvents.status.manualResolved') } +]) + +const emailSent = ref('') +const emailSentOptions = computed(() => [ + { value: '', label: t('common.all') }, + { value: 'true', label: t('admin.ops.alertEvents.table.emailSent') }, + { value: 'false', label: t('admin.ops.alertEvents.table.emailIgnored') } +]) + +function buildQuery(overrides: Partial = {}): AlertEventsQuery { + const q: AlertEventsQuery = { + limit: PAGE_SIZE, + time_range: timeRange.value + } + if (severity.value) q.severity = severity.value + if (status.value) q.status = status.value + if (emailSent.value === 'true') q.email_sent = true + if (emailSent.value === 'false') q.email_sent = false + return { ...q, ...overrides } +} + +async function loadFirstPage() { loading.value = true try { - events.value = await opsAPI.listAlertEvents(limit.value) + const data = await opsAPI.listAlertEvents(buildQuery()) + events.value = data + hasMore.value = data.length === PAGE_SIZE } catch (err: any) { console.error('[OpsAlertEventsCard] Failed to load alert events', err) appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed')) events.value = [] + hasMore.value = false } finally { loading.value = false } } +async function loadMore() { + if (loadingMore.value || loading.value) return + if (!hasMore.value) return + const last = events.value[events.value.length - 1] + if (!last) return + + loadingMore.value = true + try { + const data = await opsAPI.listAlertEvents( + buildQuery({ before_fired_at: last.fired_at || last.created_at, before_id: last.id }) + ) + if (!data.length) { + hasMore.value = false + return + } + events.value = [...events.value, ...data] + if (data.length < PAGE_SIZE) hasMore.value = false + } catch (err: any) { + console.error('[OpsAlertEventsCard] Failed to load more alert events', err) + hasMore.value = false + } finally { + loadingMore.value = false + } +} + +function onScroll(e: Event) { + const el = e.target as HTMLElement | null + if (!el) return + const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 120 + if (nearBottom) loadMore() +} + +function getDimensionString(event: AlertEvent | null | undefined, key: string): string { + const v = event?.dimensions?.[key] + if (v == null) return '' + if (typeof v === 'string') return v + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + return '' +} + +function formatDurationMs(ms: number): string { + const safe = Math.max(0, Math.floor(ms)) + const sec = Math.floor(safe / 1000) + if (sec < 60) return `${sec}s` + const min = Math.floor(sec / 60) + if (min < 60) return `${min}m` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h` + const day = Math.floor(hr / 24) + return `${day}d` +} + +function formatDurationLabel(event: AlertEvent): string { + const firedAt = new Date(event.fired_at || event.created_at) + if (Number.isNaN(firedAt.getTime())) return '-' + const resolvedAtStr = event.resolved_at || null + const status = String(event.status || '').trim().toLowerCase() + + if (resolvedAtStr) { + const resolvedAt = new Date(resolvedAtStr) + if (!Number.isNaN(resolvedAt.getTime())) { + const ms = resolvedAt.getTime() - firedAt.getTime() + const prefix = status === 'manual_resolved' + ? t('admin.ops.alertEvents.status.manualResolved') + : t('admin.ops.alertEvents.status.resolved') + return `${prefix} ${formatDurationMs(ms)}` + } + } + + const now = Date.now() + const ms = now - firedAt.getTime() + return `${t('admin.ops.alertEvents.status.firing')} ${formatDurationMs(ms)}` +} + +function formatDimensionsSummary(event: AlertEvent): string { + const parts: string[] = [] + const platform = getDimensionString(event, 'platform') + if (platform) parts.push(`platform=${platform}`) + const groupId = event.dimensions?.group_id + if (groupId != null && groupId !== '') parts.push(`group_id=${String(groupId)}`) + const region = getDimensionString(event, 'region') + if (region) parts.push(`region=${region}`) + return parts.length ? parts.join(' ') : '-' +} + +function closeDetail() { + showDetail.value = false + selected.value = null + history.value = [] +} + +async function openDetail(row: AlertEvent) { + showDetail.value = true + selected.value = row + detailLoading.value = true + historyLoading.value = true + + try { + const detail = await opsAPI.getAlertEvent(row.id) + selected.value = detail + } catch (err: any) { + console.error('[OpsAlertEventsCard] Failed to load alert detail', err) + appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.loadFailed')) + } finally { + detailLoading.value = false + } + + await loadHistory() +} + +async function loadHistory() { + const ev = selected.value + if (!ev) { + history.value = [] + historyLoading.value = false + return + } + + historyLoading.value = true + try { + const platform = getDimensionString(ev, 'platform') + const groupIdRaw = ev.dimensions?.group_id + const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : undefined + + const items = await opsAPI.listAlertEvents({ + limit: 20, + time_range: historyRange.value, + platform: platform || undefined, + group_id: groupId, + status: '' + }) + + // Best-effort: narrow to same rule_id + dimensions + history.value = items.filter((it) => { + if (it.rule_id !== ev.rule_id) return false + const p1 = getDimensionString(it, 'platform') + const p2 = getDimensionString(ev, 'platform') + if ((p1 || '') !== (p2 || '')) return false + const g1 = it.dimensions?.group_id + const g2 = ev.dimensions?.group_id + return (g1 ?? null) === (g2 ?? null) + }) + } catch (err: any) { + console.error('[OpsAlertEventsCard] Failed to load alert history', err) + history.value = [] + } finally { + historyLoading.value = false + } +} + +function durationToUntilRFC3339(duration: string): string { + const now = Date.now() + if (duration === '1h') return new Date(now + 60 * 60 * 1000).toISOString() + if (duration === '24h') return new Date(now + 24 * 60 * 60 * 1000).toISOString() + if (duration === '7d') return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString() + return new Date(now + 60 * 60 * 1000).toISOString() +} + +async function silenceAlert() { + const ev = selected.value + if (!ev) return + if (detailActionLoading.value) return + detailActionLoading.value = true + try { + const platform = getDimensionString(ev, 'platform') + const groupIdRaw = ev.dimensions?.group_id + const groupId = typeof groupIdRaw === 'number' ? groupIdRaw : null + const region = getDimensionString(ev, 'region') || null + + await opsAPI.createAlertSilence({ + rule_id: ev.rule_id, + platform: platform || '', + group_id: groupId ?? undefined, + region: region ?? undefined, + until: durationToUntilRFC3339(silenceDuration.value), + reason: `silence from UI (${silenceDuration.value})` + }) + + appStore.showSuccess(t('admin.ops.alertEvents.detail.silenceSuccess')) + } catch (err: any) { + console.error('[OpsAlertEventsCard] Failed to silence alert', err) + appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.silenceFailed')) + } finally { + detailActionLoading.value = false + } +} + +async function manualResolve() { + if (!selected.value) return + if (detailActionLoading.value) return + detailActionLoading.value = true + try { + await opsAPI.updateAlertEventStatus(selected.value.id, 'manual_resolved') + appStore.showSuccess(t('admin.ops.alertEvents.detail.manualResolvedSuccess')) + + // Refresh detail + first page to reflect new status + const detail = await opsAPI.getAlertEvent(selected.value.id) + selected.value = detail + await loadFirstPage() + await loadHistory() + } catch (err: any) { + console.error('[OpsAlertEventsCard] Failed to resolve alert', err) + appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.detail.manualResolvedFailed')) + } finally { + detailActionLoading.value = false + } +} + onMounted(() => { - load() + loadFirstPage() }) -watch(limit, () => { - load() +watch([timeRange, severity, status, emailSent], () => { + events.value = [] + hasMore.value = true + loadFirstPage() +}) + +watch(historyRange, () => { + if (showDetail.value) loadHistory() }) function severityBadgeClass(severity: string | undefined): string { @@ -54,9 +338,19 @@ function statusBadgeClass(status: string | undefined): string { const s = String(status || '').trim().toLowerCase() if (s === 'firing') return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-500/30' if (s === 'resolved') return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30' + if (s === 'manual_resolved') return 'bg-slate-50 text-slate-700 ring-slate-600/20 dark:bg-slate-900/30 dark:text-slate-300 dark:ring-slate-500/30' return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30' } +function formatStatusLabel(status: string | undefined): string { + const s = String(status || '').trim().toLowerCase() + if (!s) return '-' + if (s === 'firing') return t('admin.ops.alertEvents.status.firing') + if (s === 'resolved') return t('admin.ops.alertEvents.status.resolved') + if (s === 'manual_resolved') return t('admin.ops.alertEvents.status.manualResolved') + return s.toUpperCase() +} + const empty = computed(() => events.value.length === 0 && !loading.value) @@ -69,11 +363,14 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
- + + + +
+ + + + + + +
+
+
{{ t('admin.ops.alertEvents.detail.firedAt') }}
+
{{ formatDateTime(selected.fired_at || selected.created_at) }}
+
+
+
{{ t('admin.ops.alertEvents.detail.resolvedAt') }}
+
{{ selected.resolved_at ? formatDateTime(selected.resolved_at) : '-' }}
+
+
+
{{ t('admin.ops.alertEvents.detail.ruleId') }}
+ +
+
+
{{ t('admin.ops.alertEvents.detail.dimensions') }}
+
+
platform={{ getDimensionString(selected, 'platform') }}
+
group_id={{ selected.dimensions.group_id }}
+
region={{ getDimensionString(selected, 'region') }}
+
+
+
+ + +
+
+
+
{{ t('admin.ops.alertEvents.detail.historyTitle') }}
+
{{ t('admin.ops.alertEvents.detail.historyHint') }}
+
+ + + + +
+
+
{{ t('admin.ops.errorDetail.compareB') || 'Compare B' }}
+ +
+
+ +
+
+
{{ selectedA ? `#${selectedA.id} · ${selectedA.mode} · ${selectedA.status}` : '—' }}
+
http: {{ selectedA?.http_status_code ?? '—' }} · used: {{ selectedA?.used_account_id ?? '—' }}
+
{{ selectedA?.response_preview || '' }}
+
{{ selectedA.error_message }}
+
+
+
{{ selectedB ? `#${selectedB.id} · ${selectedB.mode} · ${selectedB.status}` : '—' }}
+
http: {{ selectedB?.http_status_code ?? '—' }} · used: {{ selectedB?.used_account_id ?? '—' }}
+
{{ selectedB?.response_preview || '' }}
+
{{ selectedB.error_message }}
+
+
+ +
+
+
+
#{{ a.id }} · {{ a.mode }} · {{ a.status }}
+
{{ a.created_at }}
+
+
+
success: {{ a.success ?? '—' }}
+
http: {{ a.http_status_code ?? '—' }}
+
pinned: {{ a.pinned_account_id ?? '—' }}
+
used: {{ a.used_account_id ?? '—' }}
+
+
{{ a.response_preview }}
+
{{ a.error_message }}
+
+
+ + + + +
+
+

{{ t('admin.ops.errorDetail.requestBody') }}

+
{{ prettyJSON(detail.request_body) }}
+
+
+ +
+
+

{{ t('admin.ops.errorDetail.responseBody') || 'Response' }}

+
+ {{ responseTabHint }} +
+
{{ prettyJSON(responseTabBody) }}
+
+
+ + + + +
+
+ +
+
+