release: bump version to 0.67.0
This commit is contained in:
@@ -4,6 +4,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import Login from './pages/Login/Login'
|
||||
import { AdminErrorBoundary } from './admin/components/AdminErrorBoundary'
|
||||
|
||||
const Register = lazy(() => import('./pages/Register/Register'))
|
||||
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
|
||||
@@ -51,7 +52,7 @@ function App() {
|
||||
<Route path={DOCS_ROUTE} element={<Docs />} />
|
||||
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
|
||||
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
|
||||
<Route path="/*" element={<AdminRoutes />} />
|
||||
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
39
frontend/src/admin/components/AdminErrorBoundary.tsx
Normal file
39
frontend/src/admin/components/AdminErrorBoundary.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { reportAdminRuntimeLog } from '../runtimeLogs'
|
||||
|
||||
type AdminErrorBoundaryProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type AdminErrorBoundaryState = {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
export class AdminErrorBoundary extends Component<AdminErrorBoundaryProps, AdminErrorBoundaryState> {
|
||||
state: AdminErrorBoundaryState = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
void reportAdminRuntimeLog({
|
||||
level: 'error',
|
||||
category: 'react-error-boundary',
|
||||
module: 'admin',
|
||||
message: error.message || '控制台渲染错误',
|
||||
detail: `${error.stack || error.message}\n${errorInfo.componentStack || ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="app-route-loading">
|
||||
<div className="app-route-loading__message">控制台发生错误,请刷新页面重试。</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import Scrollbar from '../../../components/Scrollbar/Scrollbar'
|
||||
import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import { useAdminTheme, type AdminThemeMode } from '../../design/theme'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
import { adminRouteGroups, getVisibleAdminRoutes } from '../../routes/manifest'
|
||||
import { useAdminSearch } from '../../search/AdminSearchContext'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
type BadgeTone = 'default' | 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'cyan' | 'slate'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('an-card', className)} {...props} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => <input ref={ref} className={cn('an-input', className)} {...props} />,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../co
|
||||
import { Dialog } from '../components/ui/dialog'
|
||||
import { Select } from '../components/ui/select'
|
||||
import { useToast } from '../components/ui/toast'
|
||||
import { formatNumber } from '../lib/utils'
|
||||
import { formatNumber } from '../utils'
|
||||
|
||||
interface Stats {
|
||||
total_datasources: number
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { Copy, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Copy, Pause, Play, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
@@ -20,6 +22,7 @@ const LOG_LEVEL_OPTIONS = [
|
||||
{ value: 'info', label: '信息' },
|
||||
{ value: 'debug', label: '调试' },
|
||||
]
|
||||
const LOG_TAIL_CHANNEL = 'logs_tail'
|
||||
|
||||
interface LogSourceSummary {
|
||||
source_id: string
|
||||
@@ -48,26 +51,44 @@ interface LogSnapshot {
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
type StoredLogFilters = {
|
||||
selectedSource?: string
|
||||
lineLimit?: number
|
||||
level?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
searchQuery?: string
|
||||
follow?: boolean
|
||||
}
|
||||
|
||||
function readStoredFilters() {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(LOG_FILTER_STORAGE_KEY)
|
||||
return rawValue ? JSON.parse(rawValue) as {
|
||||
selectedSource?: string
|
||||
lineLimit?: number
|
||||
level?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
searchQuery?: string
|
||||
} : null
|
||||
return rawValue ? JSON.parse(rawValue) as StoredLogFilters : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readUrlFilters(search: string): StoredLogFilters {
|
||||
const params = new URLSearchParams(search)
|
||||
const lineLimit = Number(params.get('limit') || '')
|
||||
return {
|
||||
selectedSource: params.get('source') || undefined,
|
||||
lineLimit: Number.isFinite(lineLimit) && lineLimit > 0 ? lineLimit : undefined,
|
||||
level: params.get('level') || undefined,
|
||||
startDate: params.get('start_date') || undefined,
|
||||
endDate: params.get('end_date') || undefined,
|
||||
searchQuery: params.get('search') || undefined,
|
||||
follow: params.get('follow') === '1',
|
||||
}
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === 'ok') return 'success'
|
||||
if (status === 'missing' || status === 'empty') return 'warning'
|
||||
if (status === 'missing') return 'warning'
|
||||
if (status === 'empty') return 'neutral'
|
||||
if (status.includes('unavailable')) return 'danger'
|
||||
return 'neutral'
|
||||
}
|
||||
@@ -89,27 +110,75 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
|
||||
export default function Logs() {
|
||||
const storedFilters = readStoredFilters()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const urlFilters = readUrlFilters(location.search)
|
||||
const { user } = useAuthStore()
|
||||
const { toast } = useToast()
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const [sources, setSources] = useState<LogSourceSummary[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState(storedFilters?.selectedSource || 'backend')
|
||||
const [lineLimit, setLineLimit] = useState(storedFilters?.lineLimit || 200)
|
||||
const [level, setLevel] = useState(storedFilters?.level || 'all')
|
||||
const [startDate, setStartDate] = useState(storedFilters?.startDate || '')
|
||||
const [endDate, setEndDate] = useState(storedFilters?.endDate || '')
|
||||
const [searchQuery, setSearchQuery] = useState(storedFilters?.searchQuery || '')
|
||||
const [submittedSearch, setSubmittedSearch] = useState(storedFilters?.searchQuery || '')
|
||||
const [selectedSource, setSelectedSource] = useState(urlFilters.selectedSource || storedFilters?.selectedSource || 'backend')
|
||||
const [lineLimit, setLineLimit] = useState(urlFilters.lineLimit || storedFilters?.lineLimit || 200)
|
||||
const [level, setLevel] = useState(urlFilters.level || storedFilters?.level || 'all')
|
||||
const [startDate, setStartDate] = useState(urlFilters.startDate || storedFilters?.startDate || '')
|
||||
const [endDate, setEndDate] = useState(urlFilters.endDate || storedFilters?.endDate || '')
|
||||
const [searchQuery, setSearchQuery] = useState(urlFilters.searchQuery ?? storedFilters?.searchQuery ?? '')
|
||||
const [submittedSearch, setSubmittedSearch] = useState(urlFilters.searchQuery ?? storedFilters?.searchQuery ?? '')
|
||||
const [followEnabled, setFollowEnabled] = useState(Boolean(urlFilters.follow || storedFilters?.follow))
|
||||
const [snapshot, setSnapshot] = useState<LogSnapshot | null>(null)
|
||||
const [sourcesLoading, setSourcesLoading] = useState(false)
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const shouldStickToBottomRef = useRef(true)
|
||||
|
||||
const selectedSourceInfo = useMemo(
|
||||
() => sources.find((source) => source.source_id === selectedSource) || null,
|
||||
[selectedSource, sources],
|
||||
)
|
||||
const hasActiveFilters = lineLimit !== 200 || level !== 'all' || Boolean(startDate || endDate || submittedSearch.trim() || searchQuery.trim())
|
||||
const hasActiveFilters = lineLimit !== 200 || level !== 'all' || Boolean(startDate || endDate || submittedSearch.trim() || searchQuery.trim() || followEnabled)
|
||||
|
||||
const { connected: followConnected, sendMessage } = useWebSocket({
|
||||
autoConnect: followEnabled && isSuperAdmin,
|
||||
onMessage: (message) => {
|
||||
if (message.channel !== LOG_TAIL_CHANNEL || !message.payload) return
|
||||
const payload = message.payload as { mode?: string; source_id?: string; lines?: unknown; line_count?: number; status?: string }
|
||||
if (payload.source_id !== selectedSource) return
|
||||
const incomingLines = Array.isArray(payload.lines) ? payload.lines.filter((line): line is string => typeof line === 'string') : []
|
||||
setSnapshot((current) => {
|
||||
const base = current || {
|
||||
source_id: selectedSource,
|
||||
name: selectedSourceInfo?.name || selectedSource,
|
||||
kind: selectedSourceInfo?.kind || 'unknown',
|
||||
location: selectedSourceInfo?.location || '',
|
||||
description: selectedSourceInfo?.description || '',
|
||||
category: selectedSourceInfo?.category || '',
|
||||
status: payload.status || 'ok',
|
||||
level,
|
||||
selected_levels: level === 'all' ? [] : [level],
|
||||
search_query: submittedSearch,
|
||||
available_levels: ['all', 'error', 'warning', 'info', 'debug'],
|
||||
line_limit: lineLimit,
|
||||
line_count: 0,
|
||||
lines: [],
|
||||
}
|
||||
const nextLines = payload.mode === 'snapshot'
|
||||
? incomingLines
|
||||
: [...(base.lines || []), ...incomingLines].slice(-lineLimit)
|
||||
return {
|
||||
...base,
|
||||
status: payload.status || base.status,
|
||||
line_limit: lineLimit,
|
||||
line_count: nextLines.length,
|
||||
lines: nextLines,
|
||||
}
|
||||
})
|
||||
setErrorMessage(null)
|
||||
},
|
||||
onError: () => {
|
||||
setErrorMessage('日志跟随连接失败,可暂停后使用手动刷新。')
|
||||
},
|
||||
})
|
||||
|
||||
const fetchSources = async () => {
|
||||
if (!isSuperAdmin) return
|
||||
@@ -159,8 +228,8 @@ export default function Logs() {
|
||||
}, [isSuperAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSnapshot()
|
||||
}, [isSuperAdmin, selectedSource, lineLimit, level, startDate, endDate, submittedSearch])
|
||||
if (!followEnabled) void fetchSnapshot()
|
||||
}, [isSuperAdmin, selectedSource, lineLimit, level, startDate, endDate, submittedSearch, followEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -178,8 +247,72 @@ export default function Logs() {
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery: submittedSearch,
|
||||
follow: followEnabled,
|
||||
}))
|
||||
}, [endDate, level, lineLimit, selectedSource, startDate, submittedSearch])
|
||||
}, [endDate, followEnabled, level, lineLimit, selectedSource, startDate, submittedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const filters = readUrlFilters(location.search)
|
||||
if (filters.selectedSource && filters.selectedSource !== selectedSource) setSelectedSource(filters.selectedSource)
|
||||
if (filters.lineLimit && filters.lineLimit !== lineLimit) setLineLimit(filters.lineLimit)
|
||||
if (filters.level && filters.level !== level) setLevel(filters.level)
|
||||
if ((filters.startDate || '') !== startDate) setStartDate(filters.startDate || '')
|
||||
if ((filters.endDate || '') !== endDate) setEndDate(filters.endDate || '')
|
||||
if (filters.searchQuery !== undefined && filters.searchQuery !== searchQuery) {
|
||||
setSearchQuery(filters.searchQuery)
|
||||
setSubmittedSearch(filters.searchQuery)
|
||||
}
|
||||
if (filters.follow !== followEnabled && new URLSearchParams(location.search).has('follow')) {
|
||||
setFollowEnabled(Boolean(filters.follow))
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const params = new URLSearchParams()
|
||||
if (selectedSource && selectedSource !== 'backend') params.set('source', selectedSource)
|
||||
if (lineLimit !== 200) params.set('limit', String(lineLimit))
|
||||
if (level !== 'all') params.set('level', level)
|
||||
if (startDate) params.set('start_date', startDate)
|
||||
if (endDate) params.set('end_date', endDate)
|
||||
if (submittedSearch.trim()) params.set('search', submittedSearch.trim())
|
||||
if (followEnabled) params.set('follow', '1')
|
||||
const nextSearch = params.toString()
|
||||
const currentSearch = location.search.replace(/^\?/, '')
|
||||
if (nextSearch !== currentSearch) {
|
||||
navigate({ pathname: location.pathname, search: nextSearch ? `?${nextSearch}` : '' }, { replace: true })
|
||||
}
|
||||
}, [endDate, followEnabled, level, lineLimit, location.pathname, location.search, navigate, selectedSource, startDate, submittedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!followEnabled || !followConnected || !isSuperAdmin || !selectedSource) return
|
||||
sendMessage({
|
||||
type: 'subscribe',
|
||||
data: {
|
||||
channel: LOG_TAIL_CHANNEL,
|
||||
source_id: selectedSource,
|
||||
limit: lineLimit,
|
||||
level,
|
||||
levels: level === 'all' ? undefined : level,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
search: submittedSearch.trim() || undefined,
|
||||
},
|
||||
})
|
||||
}, [endDate, followConnected, followEnabled, isSuperAdmin, level, lineLimit, selectedSource, sendMessage, startDate, submittedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollContainerRef.current
|
||||
if (!viewport || !shouldStickToBottomRef.current) return
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
}, [snapshot?.lines])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollContainerRef.current
|
||||
if (!viewport) return
|
||||
viewport.addEventListener('scroll', handleLogScroll, { passive: true })
|
||||
return () => viewport.removeEventListener('scroll', handleLogScroll)
|
||||
}, [snapshot?.source_id])
|
||||
|
||||
const resetFilters = () => {
|
||||
setLevel('all')
|
||||
@@ -188,6 +321,14 @@ export default function Logs() {
|
||||
setSearchQuery('')
|
||||
setSubmittedSearch('')
|
||||
setLineLimit(200)
|
||||
setFollowEnabled(false)
|
||||
}
|
||||
|
||||
const handleLogScroll = () => {
|
||||
const viewport = scrollContainerRef.current
|
||||
if (!viewport) return
|
||||
const distanceToBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
|
||||
shouldStickToBottomRef.current = distanceToBottom < 32
|
||||
}
|
||||
|
||||
const copyLogs = async () => {
|
||||
@@ -217,6 +358,15 @@ export default function Logs() {
|
||||
<Button size="icon" variant="subtle" onClick={() => void fetchSources()} loading={sourcesLoading} aria-label="刷新日志源" title="刷新日志源">
|
||||
<RefreshCw size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={followEnabled ? 'primary' : 'subtle'}
|
||||
onClick={() => setFollowEnabled((enabled) => !enabled)}
|
||||
aria-label={followEnabled ? '暂停日志跟随' : '跟随日志'}
|
||||
title={followEnabled ? '暂停日志跟随' : '跟随日志'}
|
||||
>
|
||||
{followEnabled ? <Pause size={15} /> : <Play size={15} />}
|
||||
</Button>
|
||||
<Button size="icon" variant="primary" onClick={() => void fetchSnapshot()} loading={logLoading} aria-label="刷新日志" title="刷新日志">
|
||||
<RefreshCw size={15} />
|
||||
</Button>
|
||||
@@ -256,6 +406,7 @@ export default function Logs() {
|
||||
</div>
|
||||
<div className="an-toolbar">
|
||||
{snapshot ? <Badge tone="blue">{snapshot.line_count} 行</Badge> : null}
|
||||
{followEnabled ? <Badge tone={followConnected ? 'green' : 'amber'}>{followConnected ? '跟随中' : '连接中'}</Badge> : null}
|
||||
<Button size="icon" variant="subtle" onClick={copyLogs} disabled={!snapshot?.lines?.length} aria-label="复制日志" title="复制日志">
|
||||
<Copy size={15} />
|
||||
</Button>
|
||||
@@ -289,7 +440,7 @@ export default function Logs() {
|
||||
{logLoading ? (
|
||||
<div className="an-loading"><span className="an-spinner" />加载中</div>
|
||||
) : snapshot?.lines?.length ? (
|
||||
<Scrollbar className="an-log-reader__scroll">
|
||||
<Scrollbar className="an-log-reader__scroll" viewportRef={scrollContainerRef}>
|
||||
<pre>{snapshot.lines.join('\n')}</pre>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
|
||||
@@ -5119,6 +5119,24 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
)
|
||||
}
|
||||
|
||||
const openLogsForTask = (item: Pick<CollectionQueueItem, 'taskId' | 'sourceId' | 'source'> & { requestId?: string | number | null }) => {
|
||||
const params = new URLSearchParams({ source: 'system-db' })
|
||||
const requestId = text(item.requestId, '')
|
||||
const taskId = text(item.taskId, '')
|
||||
const sourceId = text(item.sourceId, '')
|
||||
const source = text(item.source, '')
|
||||
if (requestId) {
|
||||
params.set('search', `request_id=${requestId}`)
|
||||
} else if (taskId) {
|
||||
params.set('search', `task_id=${taskId}`)
|
||||
} else if (sourceId) {
|
||||
params.set('search', `datasource_id=${sourceId}`)
|
||||
} else if (source) {
|
||||
params.set('search', source)
|
||||
}
|
||||
navigate(`/logs?${params.toString()}`)
|
||||
}
|
||||
|
||||
const renderModuleActions = () => {
|
||||
const actions: ReactNode[] = []
|
||||
if (config === configs.datasources) {
|
||||
@@ -5255,7 +5273,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const source = text(selected.source || selected.collector_name, '')
|
||||
const queueItem = collectionQueue.find((item) => isSameDatasourceRow(selected, item.sourceId || '', item.source || '') && isActiveQueueStatus(item.status))
|
||||
const status = queueItem?.status || datasourceStatus(selected)
|
||||
const taskId = queueItem?.taskId || selected.task_id
|
||||
const taskId = text(queueItem?.taskId || selected.task_id, '')
|
||||
return (
|
||||
<section className="an-task-summary">
|
||||
<div>
|
||||
@@ -5269,6 +5287,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<dt>进度</dt><dd>{queueItem ? `${queueProgress(queueItem)}%` : '-'}</dd>
|
||||
<dt>更新时间</dt><dd>{queueItem?.updatedAt ? new Date(queueItem.updatedAt).toLocaleTimeString() : text(selected.last_run_at, '-')}</dd>
|
||||
</dl>
|
||||
<div className="an-task-summary__actions">
|
||||
<Button size="sm" variant="subtle" onClick={() => openLogsForTask({ taskId, sourceId, source })}>
|
||||
<FileText size={14} />
|
||||
查看日志
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5396,6 +5420,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<Button size="icon" variant="subtle" title="查看数据源" aria-label="查看数据源" onClick={() => void jumpToQueueRecord(item)}>
|
||||
<Eye size={14} />
|
||||
</Button>
|
||||
<Button size="icon" variant="subtle" title="查看日志" aria-label="查看日志" onClick={() => openLogsForTask(item)}>
|
||||
<FileText size={14} />
|
||||
</Button>
|
||||
{item.status === 'failed' ? (
|
||||
<Button size="icon" variant="subtle" icon="trigger" title="重试" aria-label="重试" onClick={() => retryQueueItem(item)} />
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { cn } from '../lib/utils'
|
||||
import { cn } from '../utils'
|
||||
|
||||
export function PageFrame({
|
||||
title,
|
||||
|
||||
98
frontend/src/admin/runtimeLogs.ts
Normal file
98
frontend/src/admin/runtimeLogs.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
const RECENT_EVENT_TTL_MS = 15_000
|
||||
const MAX_DETAIL_LENGTH = 4000
|
||||
const recentEventMap = new Map<string, number>()
|
||||
const NON_ADMIN_PATH_PREFIXES = ['/earth', '/docs', '/login', '/register', '/verify-email', '/forgot-password']
|
||||
|
||||
type RuntimeLogLevel = 'error' | 'warning' | 'info' | 'debug'
|
||||
|
||||
type AdminRuntimeLogInput = {
|
||||
level?: RuntimeLogLevel
|
||||
message: string
|
||||
category?: string
|
||||
module?: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
function normalizeErrorDetail(detail: unknown) {
|
||||
if (!detail) return ''
|
||||
if (detail instanceof Error) {
|
||||
return detail.stack || detail.message || String(detail)
|
||||
}
|
||||
if (typeof detail === 'string') return detail
|
||||
try {
|
||||
return JSON.stringify(detail)
|
||||
} catch {
|
||||
return String(detail)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkip(level: string, message: string, detail: string, category: string) {
|
||||
const key = `${level}::${category}::${message}::${detail}`
|
||||
const now = Date.now()
|
||||
const lastSeenAt = recentEventMap.get(key)
|
||||
recentEventMap.set(key, now)
|
||||
for (const [entryKey, entryTime] of recentEventMap.entries()) {
|
||||
if (now - entryTime > RECENT_EVENT_TTL_MS) {
|
||||
recentEventMap.delete(entryKey)
|
||||
}
|
||||
}
|
||||
return Boolean(lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS)
|
||||
}
|
||||
|
||||
function isAdminRoute() {
|
||||
if (typeof window === 'undefined') return false
|
||||
const pathname = window.location.pathname
|
||||
return pathname !== '/' && !NON_ADMIN_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
|
||||
}
|
||||
|
||||
export async function reportAdminRuntimeLog({
|
||||
level = 'error',
|
||||
message,
|
||||
category = 'runtime',
|
||||
module = 'admin',
|
||||
detail = '',
|
||||
}: AdminRuntimeLogInput) {
|
||||
if (!message || typeof window === 'undefined' || !isAdminRoute()) return
|
||||
const normalizedDetail = normalizeErrorDetail(detail)
|
||||
if (shouldSkip(level, message, normalizedDetail, category)) return
|
||||
|
||||
try {
|
||||
await fetch('/api/v1/system/logs/admin-client', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
category,
|
||||
module,
|
||||
url: window.location.href,
|
||||
detail: normalizedDetail.slice(0, MAX_DETAIL_LENGTH),
|
||||
}),
|
||||
keepalive: true,
|
||||
})
|
||||
} catch {
|
||||
// Runtime log reporting must never create more runtime noise.
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAdminRuntimeErrorHandlers() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.addEventListener('error', (event) => {
|
||||
void reportAdminRuntimeLog({
|
||||
level: 'error',
|
||||
category: 'window-error',
|
||||
module: 'admin',
|
||||
message: event.message || '控制台发生未捕获错误',
|
||||
detail: event.error || `${event.filename || ''}:${event.lineno || 0}:${event.colno || 0}`,
|
||||
})
|
||||
})
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
void reportAdminRuntimeLog({
|
||||
level: 'error',
|
||||
category: 'unhandledrejection',
|
||||
module: 'admin',
|
||||
message: '控制台发生未处理 Promise 错误',
|
||||
detail: event.reason,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -577,6 +577,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.an-task-summary__actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin__content {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
@@ -2455,34 +2461,64 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.an-badge--green,
|
||||
.an-status-pill--success {
|
||||
.an-badge--green {
|
||||
border-color: color-mix(in srgb, var(--an-success) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-success) 12%, var(--an-surface-alt));
|
||||
color: var(--an-success);
|
||||
}
|
||||
|
||||
.an-badge--amber,
|
||||
.an-status-pill--warning {
|
||||
.an-badge--amber {
|
||||
border-color: color-mix(in srgb, var(--an-warning) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-warning) 12%, var(--an-surface-alt));
|
||||
color: var(--an-warning);
|
||||
}
|
||||
|
||||
.an-badge--red,
|
||||
.an-status-pill--danger {
|
||||
.an-badge--red {
|
||||
border-color: color-mix(in srgb, var(--an-danger) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-danger) 12%, var(--an-surface-alt));
|
||||
color: var(--an-danger);
|
||||
}
|
||||
|
||||
.an-badge--blue,
|
||||
.an-badge--cyan,
|
||||
.an-badge--cyan {
|
||||
border-color: color-mix(in srgb, var(--an-info) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-info) 12%, var(--an-surface-alt));
|
||||
color: var(--an-info);
|
||||
}
|
||||
|
||||
.an-badge--purple {
|
||||
border-color: color-mix(in srgb, #7c3aed 34%, var(--an-border));
|
||||
background: color-mix(in srgb, #7c3aed 12%, var(--an-surface-alt));
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.an-badge--slate {
|
||||
border-color: color-mix(in srgb, var(--an-muted) 28%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-muted) 10%, var(--an-surface-alt));
|
||||
color: var(--an-muted);
|
||||
}
|
||||
|
||||
.an-status-pill--success {
|
||||
color: var(--an-success);
|
||||
}
|
||||
|
||||
.an-status-pill--warning {
|
||||
color: var(--an-warning);
|
||||
}
|
||||
|
||||
.an-status-pill--danger {
|
||||
color: var(--an-danger);
|
||||
}
|
||||
|
||||
.an-status-pill--info,
|
||||
.an-status-pill--running {
|
||||
color: var(--an-info);
|
||||
}
|
||||
|
||||
.an-badge--purple,
|
||||
.an-status-pill--ai {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.an-badge--slate,
|
||||
.an-status-pill--neutral {
|
||||
color: var(--an-muted);
|
||||
}
|
||||
@@ -3323,7 +3359,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
background: var(--an-surface-alt);
|
||||
}
|
||||
|
||||
.an-logs-source span {
|
||||
.an-logs-source > span:not(.an-status-pill) {
|
||||
color: var(--an-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { registerAdminRuntimeErrorHandlers } from './admin/runtimeLogs'
|
||||
import './index.css'
|
||||
|
||||
registerAdminRuntimeErrorHandlers()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
|
||||
Reference in New Issue
Block a user