release: bump version to 0.67.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
rayd1o
2026-05-27 13:50:16 +08:00
parent d15a9d488a
commit b18ffa0b0a
46 changed files with 2116 additions and 648 deletions

View File

@@ -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>
)

View 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
}
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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} />

View File

@@ -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} />,

View File

@@ -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

View File

@@ -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

View File

@@ -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>
) : (

View File

@@ -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}

View File

@@ -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,

View 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,
})
})
}

View File

@@ -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;
}

View File

@@ -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 }}>