release: bump version to 0.38.0

This commit is contained in:
linkong
2026-04-23 17:57:35 +08:00
parent 195a8bf71c
commit d5f3784ffb
39 changed files with 4958 additions and 146 deletions

View File

@@ -0,0 +1,515 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Card, DatePicker, Empty, Input, InputNumber, Select, Space, Spin, Tag, Tooltip, Typography, message } from 'antd'
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons'
import axios from 'axios'
import dayjs, { Dayjs } from 'dayjs'
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useAuthStore } from '../../stores/auth'
const { Paragraph, Text, Title } = Typography
const { RangePicker } = DatePicker
const LOG_FILTER_STORAGE_KEY = 'planet.logs.filters'
const DATE_PRESET_OPTIONS = [
{ key: 'today', label: 'Today', days: 0 },
{ key: 'last3', label: '3 Days', days: 2 },
{ key: 'last7', label: '7 Days', days: 6 },
] as const
interface LogSourceSummary {
source_id: string
name: string
kind: string
location: string
description: string
category: string
status: string
}
interface LogSourcesResponse {
items: LogSourceSummary[]
}
interface LogSnapshot {
source_id: string
name: string
kind: string
location: string
description: string
category: string
status: string
level: string
selected_levels: string[]
search_query: string
available_levels: string[]
daily_markers: Array<{
date_token: string
total: number
dominant_level: 'error' | 'warning' | 'info' | 'debug'
}>
line_limit: number
line_count: number
lines: string[]
}
interface DailyLogMarker {
total: number
dominantLevel: 'error' | 'warning' | 'info' | 'debug'
}
const LOG_LIMIT_OPTIONS = [100, 200, 400, 800]
const LOG_LEVEL_OPTIONS = [
{ value: 'error', label: 'ERROR' },
{ value: 'warning', label: 'WARNING' },
{ value: 'info', label: 'INFO' },
{ value: 'debug', label: 'DEBUG' },
]
function isDayjsValue(value: unknown): value is Dayjs {
return dayjs.isDayjs(value)
}
function normalizeSelectedLevels(levels: string[] | null | undefined): string[] {
const allowedLevels = new Set(LOG_LEVEL_OPTIONS.map((item) => item.value))
return Array.from(new Set((levels || []).filter((level) => allowedLevels.has(level))))
}
function getLogLevelTagColor(level: string): string {
if (level === 'error') return 'error'
if (level === 'warning') return 'warning'
if (level === 'info') return 'success'
if (level === 'debug') return 'default'
return 'default'
}
function readStoredFilters() {
if (typeof window === 'undefined') {
return null
}
try {
const rawValue = window.localStorage.getItem(LOG_FILTER_STORAGE_KEY)
if (!rawValue) return null
const parsed = JSON.parse(rawValue) as {
selectedSource?: string
lineLimit?: number
selectedLevels?: string[]
selectedDateRange?: [string, string] | null
searchQuery?: string
}
return parsed
} catch {
return null
}
}
function getStatusTagColor(status: string): string {
if (status === 'ok') return 'success'
if (status === 'missing') return 'warning'
return 'default'
}
function getStatusLabel(status: string): string {
if (status === 'ok') return '可用'
if (status === 'missing') return '暂无日志'
if (status === 'empty') return '暂无上报'
if (status === 'docker_unavailable') return 'Docker 不可用'
if (status === 'source_unavailable') return '日志源不可用'
return status
}
function getStatusHelp(status: string): string | null {
if (status === 'missing') return '当前日志文件尚未生成,通常需要先启动对应服务。'
if (status === 'empty') return '当前日志源还没有收到任何上报事件。'
if (status === 'docker_unavailable') return '当前环境没有可用的 docker 命令,暂时无法读取容器日志。'
if (status === 'source_unavailable') return '日志源当前不可读取,请检查服务是否已启动。'
return null
}
function resolvePresetRange(days: number): [Dayjs, Dayjs] {
const end = dayjs().endOf('day')
const start = dayjs().subtract(days, 'day').startOf('day')
return [start, end]
}
function normalizeDateRange(
range: [Dayjs | null, Dayjs | null] | null,
): [Dayjs | null, Dayjs | null] | null {
if (!range?.[0] || !range?.[1]) return null
return [range[0].startOf('day'), range[1].endOf('day')]
}
function getActiveDatePreset(range: [Dayjs | null, Dayjs | null] | null): string | null {
if (!range?.[0] || !range?.[1]) return null
for (const option of DATE_PRESET_OPTIONS) {
const [presetStart, presetEnd] = resolvePresetRange(option.days)
if (range[0].isSame(presetStart, 'day') && range[1].isSame(presetEnd, 'day')) {
return option.key
}
}
return null
}
function Logs() {
const storedFilters = readStoredFilters()
const { user } = useAuthStore()
const isSuperAdmin = user?.role === 'super_admin'
const [sources, setSources] = useState<LogSourceSummary[]>([])
const [selectedSource, setSelectedSource] = useState<string>(storedFilters?.selectedSource || 'backend')
const [lineLimit, setLineLimit] = useState<number>(storedFilters?.lineLimit || 200)
const [selectedLevels, setSelectedLevels] = useState<string[]>(
normalizeSelectedLevels(storedFilters?.selectedLevels),
)
const [selectedDateRange, setSelectedDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(
storedFilters?.selectedDateRange
? normalizeDateRange([dayjs(storedFilters.selectedDateRange[0]), dayjs(storedFilters.selectedDateRange[1])])
: null,
)
const [searchQuery, setSearchQuery] = useState<string>(typeof storedFilters?.searchQuery === 'string' ? storedFilters.searchQuery : '')
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 [messageApi, contextHolder] = message.useMessage()
const fetchSources = async () => {
setSourcesLoading(true)
try {
const res = await axios.get<LogSourcesResponse>('/api/v1/system/logs/sources')
setSources(res.data.items)
setErrorMessage(null)
if (res.data.items.length > 0 && !res.data.items.some((item) => item.source_id === selectedSource)) {
setSelectedSource(res.data.items[0].source_id)
}
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setErrorMessage(typeof detail === 'string' ? detail : '加载日志源失败')
} finally {
setSourcesLoading(false)
}
}
const fetchSnapshot = async (
sourceId: string,
limit: number,
levels: string[],
dateRange: [Dayjs | null, Dayjs | null] | null,
searchValue: string,
) => {
setLogLoading(true)
try {
const res = await axios.get<LogSnapshot>(`/api/v1/system/logs/${sourceId}`, {
params: {
limit,
level: levels.length === 1 ? levels[0] : 'all',
levels: levels.length > 0 ? levels.join(',') : undefined,
start_date: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
end_date: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
search: searchValue.trim() || undefined,
},
})
setSnapshot(res.data)
setErrorMessage(null)
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setSnapshot(null)
setErrorMessage(typeof detail === 'string' ? detail : '加载日志内容失败')
} finally {
setLogLoading(false)
}
}
useEffect(() => {
if (!isSuperAdmin) return
fetchSources()
}, [isSuperAdmin])
useEffect(() => {
if (!isSuperAdmin || !selectedSource) return
fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
}, [isSuperAdmin, selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery])
useEffect(() => {
if (typeof window === 'undefined') return
window.localStorage.setItem(
LOG_FILTER_STORAGE_KEY,
JSON.stringify({
selectedSource,
lineLimit,
selectedLevels,
selectedDateRange:
selectedDateRange?.[0] && selectedDateRange?.[1]
? [
selectedDateRange[0].format('YYYY-MM-DD'),
selectedDateRange[1].format('YYYY-MM-DD'),
]
: null,
searchQuery,
}),
)
}, [lineLimit, searchQuery, selectedLevels, selectedDateRange, selectedSource])
if (!isSuperAdmin) {
return (
<AppLayout>
<Alert type="warning" showIcon message="仅超级管理员可查看系统日志" />
</AppLayout>
)
}
const selectedMeta = sources.find((item) => item.source_id === selectedSource)
const statusHelp = getStatusHelp(snapshot?.status || selectedMeta?.status || '')
const activeDatePreset = getActiveDatePreset(selectedDateRange)
const dailyLogMarkers = useMemo(
() =>
new Map<string, DailyLogMarker>(
(snapshot?.daily_markers || []).map((marker) => [
marker.date_token,
{
total: marker.total,
dominantLevel: marker.dominant_level,
},
]),
),
[snapshot?.daily_markers],
)
const currentResultLines = snapshot?.lines || []
const lineCountLabel = currentResultLines.length
const hasDateFilter = Boolean(selectedDateRange?.[0] && selectedDateRange?.[1])
const effectiveLevelLabels = selectedLevels.length === 0
? ['ALL']
: normalizeSelectedLevels(selectedLevels).map(
(level) => LOG_LEVEL_OPTIONS.find((item) => item.value === level)?.label || level.toUpperCase(),
)
const applyDatePreset = (days: number) => {
setSelectedDateRange(resolvePresetRange(days))
}
const renderLevelTag = (props: CustomTagProps) => {
const { label, value, closable, onClose } = props
return (
<Tag
color={getLogLevelTagColor(String(value))}
closable={closable}
onClose={onClose}
style={{ marginInlineEnd: 4 }}
>
{label}
</Tag>
)
}
return (
<AppLayout>
{contextHolder}
<div className="page-shell logs-page">
<div className="page-shell__header">
<div className="logs-page__header-copy">
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
Planet Earth
</Paragraph>
</div>
</div>
<div className="page-shell__body">
<Card className="logs-page__card">
<div className="logs-page__card-body">
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
<div className="logs-page__toolbar">
<div className="logs-page__toolbar-row logs-page__toolbar-row--primary">
<Select
value={selectedSource}
onChange={(value) => setSelectedSource(value)}
loading={sourcesLoading}
className="logs-page__source-select"
options={sources.map((item) => ({
value: item.source_id,
label: item.name,
}))}
/>
<Select
mode="multiple"
value={selectedLevels}
onChange={(value) => setSelectedLevels(normalizeSelectedLevels(value))}
options={LOG_LEVEL_OPTIONS}
className="logs-page__level-select"
maxTagCount="responsive"
allowClear
tagRender={renderLevelTag}
placeholder="全部级别"
/>
<Select
value={lineLimit}
onChange={(value) => setLineLimit(Number(value))}
options={LOG_LIMIT_OPTIONS.map((value) => ({ value, label: `最近 ${value}` }))}
className="logs-page__line-limit-select"
dropdownRender={(menu) => (
<>
{menu}
<div className="logs-page__line-limit-customizer">
<Text type="secondary"></Text>
<InputNumber
min={1}
max={1000}
value={lineLimit}
onChange={(value) => setLineLimit(Number(value) || 200)}
style={{ width: '100%' }}
/>
</div>
</>
)}
/>
<Input.Search
allowClear
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onSearch={(value) => setSearchQuery(value)}
placeholder="搜索日志内容、模块名、错误关键字"
className="logs-page__search-input"
/>
</div>
<div className="logs-page__toolbar-row logs-page__toolbar-row--secondary">
<RangePicker
allowClear
value={selectedDateRange}
onChange={(value) => setSelectedDateRange(normalizeDateRange(value as [Dayjs | null, Dayjs | null] | null))}
cellRender={(current, info) => {
if (info.type !== 'date' || !isDayjsValue(current)) return info.originNode
const marker = dailyLogMarkers.get(current.format('YYYY-MM-DD'))
if (!marker) return info.originNode
return (
<div
className={`logs-page__calendar-cell logs-page__calendar-cell--${marker.dominantLevel}`}
title={`${current.format('YYYY-MM-DD')} · ${marker.total} lines`}
>
{info.originNode}
</div>
)
}}
format="YYYY-MM-DD"
placeholder={['开始日期', '结束日期']}
className="logs-page__date-range"
/>
<Space size={6} className="logs-page__preset-group">
{DATE_PRESET_OPTIONS.map((option) => (
<Button
key={option.key}
size="small"
type={activeDatePreset === option.key ? 'primary' : 'default'}
onClick={() => applyDatePreset(option.days)}
>
{option.label}
</Button>
))}
<Button
size="small"
onClick={() => setSelectedDateRange(null)}
disabled={!hasDateFilter}
>
Clear
</Button>
</Space>
</div>
</div>
<div className="logs-page__summary-card">
<div className="logs-page__summary-header">
<Space wrap size={8}>
<Text strong>{snapshot?.name || selectedMeta?.name || '未选择日志源'}</Text>
<Tag color={getStatusTagColor(snapshot?.status || selectedMeta?.status || 'default')}>
{getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')}
</Tag>
<Tag>{(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()}</Tag>
<Text type="secondary"> {lineCountLabel} </Text>
</Space>
</div>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">{snapshot?.description || selectedMeta?.description || '-'}</Text>
<Text type="secondary">
: {snapshot?.location || selectedMeta?.location || '-'}
{selectedLevels.length > 0 ? ` · 级别: ${effectiveLevelLabels.join(' / ')}` : ''}
{selectedDateRange?.[0] && selectedDateRange?.[1]
? ` · 日期: ${selectedDateRange[0].format('YYYY-MM-DD')} ~ ${selectedDateRange[1].format('YYYY-MM-DD')}`
: ''}
{searchQuery.trim() ? ` · 检索: ${searchQuery.trim()}` : ''}
</Text>
{statusHelp ? <Alert type="info" showIcon message={statusHelp} /> : null}
</Space>
</div>
<div className="system-log-console">
<div className="system-log-console__actions">
<Tooltip title="刷新日志">
<Button
type="text"
size="small"
shape="circle"
icon={<ReloadOutlined />}
className="playground-message__actions-btn"
onClick={() => {
void fetchSources()
if (selectedSource) {
void fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
}
}}
/>
</Tooltip>
<Tooltip title="复制日志">
<Button
type="text"
size="small"
shape="circle"
icon={<CopyOutlined />}
className="playground-message__actions-btn"
onClick={async () => {
const content = currentResultLines.join('\n')
try {
await navigator.clipboard.writeText(content)
messageApi.success('日志内容已复制')
} catch {
messageApi.error('复制失败,请检查浏览器剪贴板权限')
}
}}
disabled={currentResultLines.length === 0}
/>
</Tooltip>
</div>
{logLoading ? (
<div className="system-log-console__placeholder">
<Spin />
</div>
) : currentResultLines.length > 0 ? (
<Scrollbar className="system-log-console__scroll">
<pre className="system-log-console__content">{currentResultLines.join('\n')}</pre>
</Scrollbar>
) : (
<div className="system-log-console__placeholder">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
selectedDateRange?.[0] && selectedDateRange?.[1]
? '当前日期范围没有匹配的日志内容'
: '当前没有可显示的日志内容'
}
/>
</div>
)}
</div>
</div>
</Card>
</div>
</div>
</AppLayout>
)
}
export default Logs