fix(web): show status chain evidence on alerts
This commit is contained in:
@@ -722,7 +722,12 @@
|
|||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"autoRefresh": "Auto-refresh every {seconds}s",
|
"autoRefresh": "Auto-refresh every {seconds}s",
|
||||||
"incidentCount": "{count, plural, one {# incident} other {# incidents}}"
|
"incidentCount": "{count, plural, one {# incident} other {# incidents}}",
|
||||||
|
"pageSummary": "Showing {from}-{to} of {total}",
|
||||||
|
"statusChainWindow": "AI flow evidence: {loaded}/{shown} on this page connected to truth-chain",
|
||||||
|
"previousPage": "Previous",
|
||||||
|
"nextPage": "Next",
|
||||||
|
"pageIndicator": "Page {page} of {totalPages}"
|
||||||
},
|
},
|
||||||
"navSection": {
|
"navSection": {
|
||||||
"aiCore": "AI Core",
|
"aiCore": "AI Core",
|
||||||
|
|||||||
@@ -723,7 +723,12 @@
|
|||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"autoRefresh": "每 {seconds} 秒自動刷新",
|
"autoRefresh": "每 {seconds} 秒自動刷新",
|
||||||
"incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}"
|
"incidentCount": "{count, plural, one {# 個事件} other {# 個事件}}",
|
||||||
|
"pageSummary": "顯示第 {from}-{to} 筆 / 共 {total} 筆",
|
||||||
|
"statusChainWindow": "AI 流程證據:本頁 {loaded}/{shown} 筆已接上 truth-chain",
|
||||||
|
"previousPage": "上一頁",
|
||||||
|
"nextPage": "下一頁",
|
||||||
|
"pageIndicator": "第 {page} / {totalPages} 頁"
|
||||||
},
|
},
|
||||||
"navSection": {
|
"navSection": {
|
||||||
"aiCore": "AI 核心",
|
"aiCore": "AI 核心",
|
||||||
|
|||||||
@@ -10,26 +10,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { AppLayout } from '@/components/layout'
|
import { AppLayout } from '@/components/layout'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useIncidents } from '@/hooks/useIncidents'
|
import { useIncidents } from '@/hooks/useIncidents'
|
||||||
|
import { useIncidentStatusChains } from '@/hooks/useIncidentStatusChains'
|
||||||
import { IncidentCard } from '@/components/incident'
|
import { IncidentCard } from '@/components/incident'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info } from 'lucide-react'
|
import { Bell, BellOff, RefreshCw, AlertTriangle, AlertCircle, Info, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Severity helpers
|
// Severity helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const SEVERITY_ORDER: Record<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 }
|
const SEVERITY_ORDER: Record<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 }
|
||||||
|
const ALERTS_PAGE_SIZE = 50
|
||||||
|
|
||||||
const SEVERITY_STYLE: Record<string, { bg: string; text: string; label: string }> = {
|
const SEVERITY_STYLE: Record<string, { bg: string; text: string }> = {
|
||||||
P0: { bg: 'bg-status-critical/10', text: 'text-status-critical', label: 'CRITICAL' },
|
P0: { bg: 'bg-status-critical/10', text: 'text-status-critical' },
|
||||||
P1: { bg: 'bg-status-warning/10', text: 'text-status-warning', label: 'HIGH' },
|
P1: { bg: 'bg-status-warning/10', text: 'text-status-warning' },
|
||||||
P2: { bg: 'bg-claw-blue/10', text: 'text-claw-blue', label: 'MEDIUM' },
|
P2: { bg: 'bg-claw-blue/10', text: 'text-claw-blue' },
|
||||||
P3: { bg: 'bg-nothing-gray-100', text: 'text-nothing-gray-600', label: 'LOW' },
|
P3: { bg: 'bg-nothing-gray-100', text: 'text-nothing-gray-600' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeverityBadge({ severity }: { severity: string }) {
|
function SeverityBadge({ severity, label }: { severity: string; label: string }) {
|
||||||
const style = SEVERITY_STYLE[severity] ?? SEVERITY_STYLE['P3']
|
const style = SEVERITY_STYLE[severity] ?? SEVERITY_STYLE['P3']
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -39,7 +42,7 @@ function SeverityBadge({ severity }: { severity: string }) {
|
|||||||
{severity === 'P0' && <AlertTriangle className="w-3 h-3" />}
|
{severity === 'P0' && <AlertTriangle className="w-3 h-3" />}
|
||||||
{severity === 'P1' && <AlertCircle className="w-3 h-3" />}
|
{severity === 'P1' && <AlertCircle className="w-3 h-3" />}
|
||||||
{(severity === 'P2' || severity === 'P3') && <Info className="w-3 h-3" />}
|
{(severity === 'P2' || severity === 'P3') && <Info className="w-3 h-3" />}
|
||||||
{style.label} · {severity}
|
{label} · {severity}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,14 +80,40 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
|||||||
const t = useTranslations()
|
const t = useTranslations()
|
||||||
const tAlerts = useTranslations('alerts')
|
const tAlerts = useTranslations('alerts')
|
||||||
|
|
||||||
const { incidents, isLoading, error, refresh } = useIncidents({
|
const { incidents, isLoading, error, refresh, lastUpdated } = useIncidents({
|
||||||
pollInterval: 15000,
|
pollInterval: 15000,
|
||||||
enablePolling: true,
|
enablePolling: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sorted = [...(incidents ?? [])].sort(
|
const sorted = useMemo(() => {
|
||||||
(a, b) => (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9)
|
return [...(incidents ?? [])].sort(
|
||||||
)
|
(a, b) => (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9)
|
||||||
|
)
|
||||||
|
}, [incidents])
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sorted.length / ALERTS_PAGE_SIZE))
|
||||||
|
const pageStart = (currentPage - 1) * ALERTS_PAGE_SIZE
|
||||||
|
const pageIncidents = sorted.slice(pageStart, pageStart + ALERTS_PAGE_SIZE)
|
||||||
|
const pageFrom = sorted.length === 0 ? 0 : pageStart + 1
|
||||||
|
const pageTo = pageStart + pageIncidents.length
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(page => Math.min(Math.max(page, 1), totalPages))
|
||||||
|
}, [totalPages])
|
||||||
|
|
||||||
|
const {
|
||||||
|
statusChains,
|
||||||
|
requestedIncidentIds,
|
||||||
|
isLoading: isStatusChainLoading,
|
||||||
|
} = useIncidentStatusChains({
|
||||||
|
incidentIds: pageIncidents.map(incident => incident.incident_id),
|
||||||
|
limit: ALERTS_PAGE_SIZE,
|
||||||
|
refreshKey: lastUpdated?.toISOString() ?? null,
|
||||||
|
})
|
||||||
|
const truthChainLoaded = requestedIncidentIds.filter(incidentId => {
|
||||||
|
const chain = statusChains[incidentId]
|
||||||
|
return Boolean(chain?.source_id && !chain.fetch_error)
|
||||||
|
}).length
|
||||||
|
|
||||||
const p0Count = sorted.filter(i => i.severity === 'P0').length
|
const p0Count = sorted.filter(i => i.severity === 'P0').length
|
||||||
const p1Count = sorted.filter(i => i.severity === 'P1').length
|
const p1Count = sorted.filter(i => i.severity === 'P1').length
|
||||||
@@ -127,6 +156,46 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
|||||||
<StatPill label={t('incident.severity.P3')} value={p3Count} />
|
<StatPill label={t('incident.severity.P3')} value={p3Count} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sorted.length > 0 && (
|
||||||
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-nothing-gray-200 bg-white px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-body text-sm font-semibold text-nothing-black">
|
||||||
|
{tAlerts('pageSummary', { from: pageFrom, to: pageTo, total: sorted.length })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-body text-xs text-nothing-gray-500">
|
||||||
|
{tAlerts('statusChainWindow', {
|
||||||
|
loaded: truthChainLoaded,
|
||||||
|
shown: requestedIncidentIds.length,
|
||||||
|
})}
|
||||||
|
{isStatusChainLoading ? ` · ${t('common.loading')}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-nothing-gray-200 bg-nothing-gray-50 px-3 py-1.5 font-body text-xs text-nothing-gray-700 transition-colors hover:bg-nothing-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
{tAlerts('previousPage')}
|
||||||
|
</button>
|
||||||
|
<span className="font-body text-xs text-nothing-gray-500">
|
||||||
|
{tAlerts('pageIndicator', { page: currentPage, totalPages })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-nothing-gray-200 bg-nothing-gray-50 px-3 py-1.5 font-body text-xs text-nothing-gray-700 transition-colors hover:bg-nothing-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{tAlerts('nextPage')}
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 rounded-lg bg-status-critical/10 border border-status-critical/20 flex items-center gap-2">
|
<div className="mb-4 p-4 rounded-lg bg-status-critical/10 border border-status-critical/20 flex items-center gap-2">
|
||||||
@@ -153,16 +222,16 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Incidents grouped by severity */}
|
{/* Incidents grouped by severity */}
|
||||||
{sorted.length > 0 && (
|
{pageIncidents.length > 0 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{(['P0', 'P1', 'P2', 'P3'] as const).map(sev => {
|
{(['P0', 'P1', 'P2', 'P3'] as const).map(sev => {
|
||||||
const group = sorted.filter(i => i.severity === sev)
|
const group = pageIncidents.filter(i => i.severity === sev)
|
||||||
if (group.length === 0) return null
|
if (group.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={sev}>
|
<div key={sev}>
|
||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<SeverityBadge severity={sev} />
|
<SeverityBadge severity={sev} label={t(`incident.severity.${sev}`)} />
|
||||||
<span className="text-[11px] font-body text-nothing-gray-400">
|
<span className="text-[11px] font-body text-nothing-gray-400">
|
||||||
{tAlerts('incidentCount', { count: group.length })}
|
{tAlerts('incidentCount', { count: group.length })}
|
||||||
</span>
|
</span>
|
||||||
@@ -170,7 +239,11 @@ export default function AlertsPage({ params }: { params: { locale: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{group.map(incident => (
|
{group.map(incident => (
|
||||||
<IncidentCard key={incident.incident_id} incident={incident} />
|
<IncidentCard
|
||||||
|
key={incident.incident_id}
|
||||||
|
incident={incident}
|
||||||
|
statusChain={statusChains[incident.incident_id] ?? null}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import React from 'react'
|
|||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useIncidents } from '@/hooks/useIncidents'
|
import { useIncidents } from '@/hooks/useIncidents'
|
||||||
|
import { useIncidentStatusChains } from '@/hooks/useIncidentStatusChains'
|
||||||
import { useHosts, useDashboardStore, type Host } from '@/stores/dashboard.store'
|
import { useHosts, useDashboardStore, type Host } from '@/stores/dashboard.store'
|
||||||
import { IncidentCard } from '@/components/incident'
|
import { IncidentCard } from '@/components/incident'
|
||||||
import { OpenClawPanel } from '@/components/ai/openclaw-panel'
|
import { OpenClawPanel } from '@/components/ai/openclaw-panel'
|
||||||
@@ -30,7 +31,6 @@ import { PendingApprovalsCard } from '@/components/shared/pending-approvals-card
|
|||||||
import { AIModelStatus } from '@/components/shared/ai-model-status'
|
import { AIModelStatus } from '@/components/shared/ai-model-status'
|
||||||
import { FlywheelKPICard } from '@/components/dashboard/flywheel-kpi-card'
|
import { FlywheelKPICard } from '@/components/dashboard/flywheel-kpi-card'
|
||||||
import { AutomationEvidenceCard } from '@/components/dashboard/automation-evidence-card'
|
import { AutomationEvidenceCard } from '@/components/dashboard/automation-evidence-card'
|
||||||
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
|
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||||
const STATUS_CHAIN_PREFETCH_LIMIT = 25
|
const STATUS_CHAIN_PREFETCH_LIMIT = 25
|
||||||
@@ -512,54 +512,11 @@ export default function Home({ params }: { params: { locale: string } }) {
|
|||||||
pollInterval: 15000,
|
pollInterval: 15000,
|
||||||
enablePolling: true,
|
enablePolling: true,
|
||||||
})
|
})
|
||||||
const statusChainIncidentKey = incidents
|
const { statusChains } = useIncidentStatusChains({
|
||||||
?.slice(0, STATUS_CHAIN_PREFETCH_LIMIT)
|
incidentIds: incidents?.map(incident => incident.incident_id) ?? [],
|
||||||
.map(incident => incident.incident_id)
|
limit: STATUS_CHAIN_PREFETCH_LIMIT,
|
||||||
.join('|') ?? ''
|
refreshKey: incidentsLastUpdated?.toISOString() ?? null,
|
||||||
const [statusChains, setStatusChains] = useState<Record<string, AwoooPStatusChain | null>>({})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const incidentIds = statusChainIncidentKey
|
|
||||||
? statusChainIncidentKey.split('|').filter(Boolean)
|
|
||||||
: []
|
|
||||||
if (incidentIds.length === 0) {
|
|
||||||
setStatusChains({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 12000)
|
|
||||||
|
|
||||||
Promise.all(
|
|
||||||
incidentIds.map(async (incidentId): Promise<[string, AwoooPStatusChain | null]> => {
|
|
||||||
const params = new URLSearchParams({ project_id: 'awoooi' })
|
|
||||||
params.append('incident_id', incidentId)
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/platform/status-chain?${params.toString()}`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
if (!response.ok) return [incidentId, null]
|
|
||||||
return [incidentId, await response.json() as AwoooPStatusChain]
|
|
||||||
} catch {
|
|
||||||
return [incidentId, null]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.then(entries => {
|
|
||||||
if (controller.signal.aborted) return
|
|
||||||
setStatusChains(Object.fromEntries(entries))
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!controller.signal.aborted) setStatusChains({})
|
|
||||||
})
|
|
||||||
.finally(() => window.clearTimeout(timeout))
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
controller.abort()
|
|
||||||
}
|
|
||||||
}, [statusChainIncidentKey, incidentsLastUpdated])
|
|
||||||
|
|
||||||
// ── Metrics 計算 ────────────────────────────────────────────────────────────
|
// ── Metrics 計算 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from './use-health'
|
|||||||
export * from './useSSE'
|
export * from './useSSE'
|
||||||
export * from './useApprovalSSE'
|
export * from './useApprovalSSE'
|
||||||
export * from './useIncidents'
|
export * from './useIncidents'
|
||||||
|
export * from './useIncidentStatusChains'
|
||||||
export * from './useGlobalPulseMetrics'
|
export * from './useGlobalPulseMetrics'
|
||||||
export * from './useKeyboardShortcuts'
|
export * from './useKeyboardShortcuts'
|
||||||
export * from './useErrors'
|
export * from './useErrors'
|
||||||
|
|||||||
85
apps/web/src/hooks/useIncidentStatusChains.ts
Normal file
85
apps/web/src/hooks/useIncidentStatusChains.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
|
||||||
|
import { API_V1_URL } from '@/lib/config'
|
||||||
|
|
||||||
|
interface UseIncidentStatusChainsOptions {
|
||||||
|
incidentIds: string[]
|
||||||
|
limit?: number
|
||||||
|
projectId?: string
|
||||||
|
refreshKey?: string | number | Date | null
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseIncidentStatusChainsResult {
|
||||||
|
statusChains: Record<string, AwoooPStatusChain | null>
|
||||||
|
requestedIncidentIds: string[]
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIncidentStatusChains({
|
||||||
|
incidentIds,
|
||||||
|
limit = 25,
|
||||||
|
projectId = 'awoooi',
|
||||||
|
refreshKey = null,
|
||||||
|
timeoutMs = 12000,
|
||||||
|
}: UseIncidentStatusChainsOptions): UseIncidentStatusChainsResult {
|
||||||
|
const incidentIdsKey = incidentIds.filter(Boolean).join('|')
|
||||||
|
const requestedIncidentIds = useMemo(() => {
|
||||||
|
return Array.from(new Set(incidentIdsKey ? incidentIdsKey.split('|') : [])).slice(0, limit)
|
||||||
|
}, [incidentIdsKey, limit])
|
||||||
|
const incidentKey = requestedIncidentIds.join('|')
|
||||||
|
const [statusChains, setStatusChains] = useState<Record<string, AwoooPStatusChain | null>>({})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestedIncidentIds.length === 0) {
|
||||||
|
setStatusChains({})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
requestedIncidentIds.map(async (incidentId): Promise<[string, AwoooPStatusChain | null]> => {
|
||||||
|
const params = new URLSearchParams({ project_id: projectId, incident_id: incidentId })
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_V1_URL}/platform/status-chain?${params.toString()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) return [incidentId, null]
|
||||||
|
return [incidentId, await response.json() as AwoooPStatusChain]
|
||||||
|
} catch {
|
||||||
|
return [incidentId, null]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then(entries => {
|
||||||
|
if (active) setStatusChains(Object.fromEntries(entries))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setStatusChains({})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
if (active) setIsLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [incidentKey, projectId, requestedIncidentIds, refreshKey, timeoutMs])
|
||||||
|
|
||||||
|
return { statusChains, requestedIncidentIds, isLoading }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { UseIncidentStatusChainsOptions, UseIncidentStatusChainsResult }
|
||||||
Reference in New Issue
Block a user