feat: complete Loki query, insights, settings and deployment workflows

Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application.

Validated with go test ./..., npm run build, and npm run lint.
This commit is contained in:
logan committed 2026-10-25 21:28:32 +08:00
1 parent f9c6c1d826
commit 2d73661dd1
127 files changed
+8117 -2876

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
// 告警域 API — 飞书通知配置 + 告警规则
import { http } from '../http'
export interface NotifyConfig {
enabled: boolean
hasWebhook: boolean
hasSecret: boolean
}
export interface AlertRule {
id: number
name: string
sourceId: number
channelIds: number[]
query: string
windowMin: number
threshold: number
enabled: boolean
cooldownMin: number
lastFiredAt?: string
createdAt?: string
}
export interface AlertDelivery {
id: number
ruleId: number
ruleName: string
count: number
status: 'sent' | 'failed' | 'cooldown'
reason: string
createdAt: string
}
export const alertsApi = {
getNotifyConfig: () => http.get<NotifyConfig>('/alerts/notify-config'),
saveNotifyConfig: (data: { enabled: boolean; webhook?: string; signingSecret?: string }) =>
http.put<{ message: string }>('/alerts/notify-config', data),
testNotifyConfig: () => http.post<{ status: string }>('/alerts/notify-config/test'),
listRules: () => http.get<AlertRule[]>('/alerts/rules'),
createRule: (data: Omit<AlertRule, 'id' | 'lastFiredAt' | 'createdAt'>) =>
http.post<AlertRule>('/alerts/rules', data),
updateRule: (id: number, data: Partial<AlertRule>) =>
http.put<AlertRule>(`/alerts/rules/${id}`, data),
deleteRule: (id: number) => http.delete<{ message: string }>(`/alerts/rules/${id}`),
checkNow: () => http.post<{ message: string }>('/alerts/check'),
deliveries: () => http.get<AlertDelivery[]>('/alerts/deliveries'),
// 通知通道(多 webhook 对应不同告警群)
listChannels: () => http.get<NotifyChannel[]>('/alerts/channels'),
saveChannel: (data: { id?: number; name: string; webhook?: string; signingSecret?: string; enabled: boolean }) =>
http.post<NotifyChannel>('/alerts/channels', data),
updateChannel: (id: number, data: { name: string; webhook?: string; signingSecret?: string; enabled: boolean }) =>
http.put<NotifyChannel>(`/alerts/channels/${id}`, data),
deleteChannel: (id: number) => http.delete<{ message: string }>(`/alerts/channels/${id}`),
testChannel: (data: { id?: number; name?: string; webhook?: string; signingSecret?: string }) =>
http.post<{ status: string }>('/alerts/channels/test', data),
}
export interface NotifyChannel {
id: number
name: string
hasWebhook: boolean
hasSecret: boolean
enabled: boolean
createdAt?: string
}
+10
View File
@@ -14,6 +14,7 @@ export interface ClusterCreateInput {
export type ClusterUpdateInput = Partial<ClusterCreateInput>;
export const clusterApi = {
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Cluster>>(`/clusters${buildQuery(params)}`, options),
@@ -31,4 +32,13 @@ export const clusterApi = {
http.post<{ message: string }>(`/clusters/${id}/validate`),
getInfo: (id: number) => http.get<ClusterInfo>(`/clusters/${id}/info`),
namespaces: (id: number, options?: RequestOptions) =>
http.get<{ namespaces: string[] }>(`/clusters/${id}/namespaces`, options),
deployments: (id: number, namespace: string, options?: RequestOptions) =>
http.get<{ deployments: Array<{ name: string; replicas: number; readyReplicas: number }> }>(
`/clusters/${id}/namespaces/${encodeURIComponent(namespace)}/deployments`, options
),
};
+3
View File
@@ -10,6 +10,9 @@ export const collectorApi = {
create: (data: CreateCollectorRequest) => http.post<Collector>('/collectors', data),
update: (id: number, data: { name: string; logPath: string }) =>
http.put<{ message: string }>(`/collectors/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/collectors/${id}`),
install: (id: number) => http.post<{ message: string }>(`/collectors/${id}/install`),
+6
View File
@@ -21,6 +21,12 @@ export const deploymentApi = {
update: (id: number, data: DeploymentUpdateInput) =>
http.put<{ message: string }>(`/deployments/${id}`, data),
updateRetention: (id: number, yaml: string, apply: boolean) =>
http.post<{ retentionDays: number; applying: boolean }>(`/deployments/${id}/retention`, { yaml, apply }),
liveConfig: (id: number) =>
http.get<{ location: string; yaml: string }>(`/deployments/${id}/config`),
remove: (id: number) => http.delete<{ message: string }>(`/deployments/${id}`),
deploy: (id: number) => http.post<{ message: string }>(`/deployments/${id}/deploy`),
+2
View File
@@ -8,3 +8,5 @@ export * from './storage';
export * from './deployment';
export * from './collector';
export * from './user';
export * from './alerts';
export * from './settings';
+32
View File
@@ -0,0 +1,32 @@
import { http, buildQuery } from '../http'
import type { PageData } from '@/types'
export interface LogSourceAccess { userId: number; labels: Record<string, string> }
export interface LogPolicyGroup { id: number; name: string; sourceId: number; labels: Record<string, string>; userIds: number[]; createdAt?: string; updatedAt?: string }
export interface LogSource {
id: number; name: string; targetType: 'url' | 'cluster-svc'; clusterID?: number
namespace: string; service: string; port: number; url: string
authType: string; basicUser: string; hasBasicPass?: boolean
headers?: Record<string, string>; headerNames?: string[]; skipTLSVerify?: boolean; hasBearerToken?: boolean
isDefault: boolean; managed?: boolean; access?: LogSourceAccess[]
}
export interface LogEntry { timestamp: string; timestampNs: string; level: string; message: string; labels: Record<string, string> }
export interface HistogramBucket { timestamp: string; count: number }
export interface LogResult { entries: LogEntry[]; hasMore: boolean; nextCursor: string }
export interface LogQuery { sourceId: number; query: string; start: number; end: number; limit?: number; direction?: 'backward' | 'forward'; cursor?: string }
export const logsApi = {
sources: () => http.get<PageData<LogSource>>('/logs/sources'),
createSource: (source: Partial<LogSource> & { basicPass?: string; bearerToken?: string }) => http.post<LogSource>('/logs/sources', source),
updateSource: (id: number, data: Partial<LogSource>) => http.put(`/logs/sources/${id}`, data),
deleteSource: (id: number) => http.delete(`/logs/sources/${id}`),
testSource: (data: Partial<LogSource>) => http.post<{ success: boolean; message: string }>('/logs/sources/test', data),
query: (data: LogQuery) => http.post<LogResult>('/logs/query', data, { timeout: 125000 }),
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string> }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data),
histogram: (data: LogQuery) => http.post<Array<Record<string, unknown>>>('/logs/histogram', data, { timeout: 125000 }),
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`),
policyGroups: () => http.get<LogPolicyGroup[]>('/logs/policy-groups'),
createPolicyGroup: (data: Omit<LogPolicyGroup, 'id'>) => http.post<LogPolicyGroup>('/logs/policy-groups', data),
updatePolicyGroup: (id: number, data: Omit<LogPolicyGroup, 'id'>) => http.put<LogPolicyGroup>(`/logs/policy-groups/${id}`, data),
deletePolicyGroup: (id: number) => http.delete(`/logs/policy-groups/${id}`),
}
+17
View File
@@ -0,0 +1,17 @@
// 设置域 API — 镜像源等平台级配置
import { http } from '../http'
export interface ImageRegistryConfig {
mode: 'overseas' | 'cn' | 'custom'
customHost: string
sampleLoki: string
sampleGrafana: string
samplePromtail: string
sampleFluentBit: string
}
export const settingsApi = {
getImageRegistry: () => http.get<ImageRegistryConfig>('/settings/image-registry'),
saveImageRegistry: (data: { mode: string; customHost?: string }) =>
http.put<ImageRegistryConfig>('/settings/image-registry', data),
}
+1
View File
@@ -13,6 +13,7 @@ export interface StorageCreateInput {
pathPrefix?: string;
insecure?: boolean;
isDefault?: boolean;
hasSecretKey?: boolean;
}
export type StorageUpdateInput = Partial<StorageCreateInput>;
+12 -12
View File
@@ -46,10 +46,10 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
onClick={onNavigate}
aria-label={item.label}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all',
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
? 'bg-sidebar-primary/15 text-sidebar-primary font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-primary'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
title={!collapsed ? item.label : undefined}
>
@@ -68,15 +68,15 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
// 侧栏 Logo 区
function SidebarLogo({ collapsed }: { collapsed: boolean }) {
return (
<div className="h-16 flex items-center px-4 border-b border-slate-700 flex-shrink-0">
<div className="h-16 flex items-center px-4 border-b border-sidebar-border flex-shrink-0">
{collapsed ? (
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center mx-auto">
<Activity className="w-5 h-5 text-white" />
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center mx-auto">
<Activity className="w-5 h-5 text-sidebar-primary" />
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Activity className="w-5 h-5 text-white" />
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center">
<Activity className="w-5 h-5 text-sidebar-primary" />
</div>
<span className="font-bold text-lg tracking-tight">opsx-loki</span>
</div>
@@ -162,18 +162,18 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
{!isMobile && (
<aside
className={cn(
'bg-slate-900 text-white transition-all duration-300 flex flex-col flex-shrink-0',
'bg-sidebar text-sidebar-accent-foreground transition-all duration-300 flex flex-col flex-shrink-0',
collapsed ? 'w-16' : 'w-64'
)}
>
<SidebarLogo collapsed={collapsed} />
<SidebarNav collapsed={collapsed} />
<div className="p-2 border-t border-slate-700 flex-shrink-0">
<div className="p-2 border-t border-sidebar-border flex-shrink-0">
<button
onClick={toggleSidebar}
aria-label={collapsed ? '展开菜单' : '收起菜单'}
title={collapsed ? '展开菜单' : '收起菜单'}
className="flex items-center justify-center w-full py-2.5 rounded-lg text-slate-400 hover:bg-slate-800 hover:text-white transition-all"
className="flex items-center justify-center w-full py-2.5 rounded-lg text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
{collapsed ? (
<PanelLeftOpen className="w-5 h-5" />
@@ -188,7 +188,7 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
{/* 移动端抽屉侧栏 */}
{isMobile && (
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="bg-slate-900 text-white border-none p-0 w-72">
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
<SheetHeader className="sr-only">
<SheetTitle className="text-white">opsx-loki 导航</SheetTitle>
</SheetHeader>
+8 -1
View File
@@ -1,6 +1,8 @@
import { memo } from 'react'
import { useLocation } from 'react-router'
import { cn } from '@/lib/utils'
import { PageHeader } from '@/components/PageHeader'
import { findRouteMeta } from '@/router/routes'
interface PageContainerProps {
/** 页头标题;缺省则不渲染页头(如仪表盘总览页) */
@@ -23,9 +25,14 @@ export const PageContainer = memo(function PageContainer({
className,
children,
}: PageContainerProps) {
const { pathname } = useLocation()
const routeTitle = findRouteMeta(pathname)?.title
const repeatedTitle = Boolean(title && routeTitle && title.replace(/\s/g, '') === routeTitle.replace(/\s/g, ''))
const showHeader = Boolean(title && (!repeatedTitle || description || action))
return (
<div className={cn('space-y-6', className)}>
{title && <PageHeader title={title} description={description} action={action} />}
{showHeader && <PageHeader title={repeatedTitle ? undefined : title} description={description} action={action} />}
{children}
</div>
)
+5 -7
View File
@@ -2,7 +2,7 @@ import { memo } from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
title?: string
description?: string
action?: React.ReactNode
className?: string
@@ -20,13 +20,11 @@ export const PageHeader = memo(function PageHeader({
className,
}: PageHeaderProps) {
return (
<div className={cn('flex items-center justify-between', className)}>
<div>
<h2 className="text-lg font-semibold text-foreground">
{title}
</h2>
<div className={cn('flex flex-wrap items-center justify-between gap-3', className)}>
<div className="min-w-0">
{title && <h2 className="text-lg font-semibold text-foreground">{title}</h2>}
{description && (
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
<p className={cn('text-sm text-muted-foreground', title && 'mt-0.5')}>{description}</p>
)}
</div>
{action && <div className="flex-shrink-0">{action}</div>}
+97
View File
@@ -0,0 +1,97 @@
import { memo } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
export interface ResourceField {
label: string
value: React.ReactNode
/** 值提示(截断时 title) */
title?: string
}
interface ResourceCardProps {
/** 头部图标 */
icon: LucideIcon
/** 图标配色(text-info/success/warning 等语义 token 类) */
iconClassName?: string
title: string
/** 标题旁附加(如 ready 状态点、uuid) */
titleExtra?: React.ReactNode
/** 右上角徽章(状态/保留天数等) */
badges?: React.ReactNode
/** 副标题/描述(单行截断) */
description?: string
/** 信息字段(两列网格;奇数个最后一项占整行) */
fields: ResourceField[]
/** 底部状态消息(如错误信息,浅底块) */
message?: string
/** 底部操作区(border-top 分隔) */
actions: React.ReactNode
}
/**
* 资源卡片 — 五大资源页(集群/主机/存储/部署/采集)统一样式:
* 图标+标题+徽章 → 描述 → 两列信息网格(label 上 value 下) → 分隔线操作区。
* 统一 p-4 内边距、hover 阴影、等高布局(网格内自动对齐)。
*/
export const ResourceCard = memo(function ResourceCard({
icon: Icon,
iconClassName = 'text-muted-foreground',
title,
titleExtra,
badges,
description,
fields,
message,
actions,
}: ResourceCardProps) {
return (
<Card className="border-border hover:shadow-md transition-shadow h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* 头部:图标 + 标题 + 徽章 */}
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-muted/70 flex items-center justify-center shrink-0">
<Icon className={cn('size-4', iconClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-sm text-foreground truncate" title={title}>
{title}
</h3>
{titleExtra}
{badges && <span className="ml-auto flex items-center gap-1.5 shrink-0">{badges}</span>}
</div>
{description && (
<p className="text-xs text-muted-foreground truncate mt-0.5" title={description}>
{description}
</p>
)}
</div>
</div>
{/* 信息:两列网格(label 灰小字在上,value 在下) */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
{fields.map((f) => (
<div key={f.label} className="min-w-0">
<p className="text-muted-foreground leading-4">{f.label}</p>
<p className="font-medium text-foreground truncate leading-5" title={f.title}>
{f.value}
</p>
</div>
))}
</div>
{/* 状态消息(错误/提示) */}
{message && (
<p className="rounded-md bg-muted/60 px-2.5 py-1.5 text-xs text-muted-foreground" title={message}>
{message}
</p>
)}
{/* 操作区:贴底 */}
<div className="flex gap-2 pt-3 border-t border-border mt-auto">{actions}</div>
</CardContent>
</Card>
)
})
+1
View File
@@ -3,6 +3,7 @@
export { default as PageContainer } from './PageContainer';
export { default as PageHeader } from './PageHeader';
export { default as InfoRow } from './InfoRow';
export { ResourceCard } from './ResourceCard';
export { default as ConfirmDialog } from './ConfirmDialog';
export { default as ErrorState } from './ErrorState';
export { default as EmptyState } from './EmptyState';
+6 -6
View File
@@ -38,12 +38,12 @@
--radius: 0.625rem;
--sidebar-background: 222 47% 11%;
--sidebar-foreground: 215 20% 65%;
--sidebar-primary: 161 94% 30%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-primary: 213 94% 72%;
--sidebar-primary-foreground: 222 47% 11%;
--sidebar-accent: 217 33% 17%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 33% 20%;
--sidebar-ring: 161 94% 30%;
--sidebar-ring: 213 94% 72%;
}
.dark {
@@ -74,12 +74,12 @@
--ring: 160 84% 39%;
--sidebar-background: 222 47% 8%;
--sidebar-foreground: 215 20% 65%;
--sidebar-primary: 160 84% 39%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-primary: 213 94% 72%;
--sidebar-primary-foreground: 222 47% 11%;
--sidebar-accent: 217 33% 17%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 33% 20%;
--sidebar-ring: 160 84% 39%;
--sidebar-ring: 213 94% 72%;
}
}
+362
View File
@@ -0,0 +1,362 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { PageContainer } from '@/components/PageContainer'
import { EmptyState } from '@/components/EmptyState'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { alertsApi, type AlertRule, type AlertDelivery, type NotifyChannel } from '@/api/alerts'
import { logsApi, type LogSource } from '@/api/logs'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
interface RuleForm {
name: string
sourceId: string
channelIds: string[]
query: string
windowMin: string
threshold: string
cooldownMin: string
enabled: boolean
}
const DEFAULTS: RuleForm = {
name: '', sourceId: '', channelIds: [], query: '{namespace=~".+"} |= "error"',
windowMin: '15', threshold: '0', cooldownMin: '60', enabled: true,
}
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
const AlertsPage = memo(function AlertsPage() {
const [rules, setRules] = useState<AlertRule[]>([])
const [sources, setSources] = useState<LogSource[]>([])
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [deliveries, setDeliveries] = useState<AlertDelivery[]>([])
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<AlertRule | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AlertRule | null>(null)
const [deleting, setDeleting] = useState(false)
const [checking, setChecking] = useState(false)
const [submitting, setSubmitting] = useState(false)
const form = useForm<RuleForm>({ defaultValues: DEFAULTS })
const refresh = useCallback(async () => {
try {
const [r, s, d, ch] = await Promise.all([
alertsApi.listRules(), logsApi.sources(), alertsApi.deliveries(), alertsApi.listChannels(),
])
setRules(Array.isArray(r) ? r : (r as unknown as { list: AlertRule[] }).list ?? [])
setSources(s.list)
setDeliveries(Array.isArray(d) ? d : [])
setChannels(Array.isArray(ch) ? ch : [])
} catch (err) {
toast.error(err instanceof Error ? err.message : '加载告警数据失败')
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const openCreate = () => {
setEditing(null)
form.reset({ ...DEFAULTS, sourceId: sources[0] ? String(sources[0].id) : '', channelIds: channels.filter(c => c.enabled).slice(0, 1).map(c => String(c.id)) })
setOpen(true)
}
const openEdit = (rule: AlertRule) => {
setEditing(rule)
form.reset({
name: rule.name, sourceId: String(rule.sourceId), channelIds: (rule.channelIds || []).map(String), query: rule.query,
windowMin: String(rule.windowMin), threshold: String(rule.threshold),
cooldownMin: String(rule.cooldownMin), enabled: rule.enabled,
})
setOpen(true)
}
const onSubmit = form.handleSubmit(async (v) => {
setSubmitting(true)
try {
const payload = {
name: v.name, sourceId: Number(v.sourceId), channelIds: v.channelIds.map(Number).filter(Boolean), query: v.query,
windowMin: Number(v.windowMin) || 15,
threshold: Number(v.threshold) || 0,
cooldownMin: Number(v.cooldownMin) || 60,
enabled: v.enabled,
}
if (editing) await alertsApi.updateRule(editing.id, payload)
else await alertsApi.createRule(payload)
setOpen(false)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
} finally {
setSubmitting(false)
}
})
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await alertsApi.deleteRule(deleteTarget.id)
setDeleteTarget(null)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting(false)
}
}
const handleCheckNow = async () => {
setChecking(true)
try {
await alertsApi.checkNow()
toast.info('已执行一次检查,结果见发送历史')
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '检查失败')
} finally {
setChecking(false)
}
}
const toggleRule = async (rule: AlertRule, enabled: boolean) => {
try {
await alertsApi.updateRule(rule.id, { enabled })
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '切换失败')
}
}
return (
<PageContainer
title="日志告警"
description="按 LogQL 计数规则监控日志,超阈值推送飞书卡片(周期 1 分钟)"
action={
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCheckNow} disabled={checking}>
<Play className="w-4 h-4 mr-1" />
{checking ? '检查中...' : '立即检查'}
</Button>
<Button onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
新建规则
</Button>
</div>
}
>
{rules.length === 0 ? (
<EmptyState
icon={BellRing}
title="暂无告警规则"
description={'例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书'}
/>
) : (
<div className="space-y-3">
{rules.map((rule) => {
const src = sources.find((s) => s.id === rule.sourceId)
return (
<Card key={rule.id} className="border-border">
<CardContent className="p-4 flex items-center gap-4 flex-wrap">
<Switch checked={rule.enabled} onCheckedChange={(v) => void toggleRule(rule, v)} />
<div className="min-w-0 flex-1">
<p className="font-medium">{rule.name}</p>
<p className="text-xs text-muted-foreground truncate font-mono mt-0.5">{rule.query}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{src?.name ?? `数据源 ${rule.sourceId}`} → {(rule.channelIds || []).map((id) => channels.find((c) => c.id === id)?.name || `#${id}`).join('、') || '未关联通道'} · {rule.windowMin} 分钟超过 {rule.threshold} 条触发 · 冷却 {rule.cooldownMin} 分钟
{rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · 上次触发 ${new Date(rule.lastFiredAt).toLocaleString()}`}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" aria-label={`编辑 ${rule.name}`} onClick={() => openEdit(rule)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={`删除 ${rule.name}`}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(rule)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* 发送历史 */}
{deliveries.length > 0 && (
<Card className="border-border">
<CardContent className="p-4">
<h2 className="text-sm font-semibold mb-3">发送历史(最近 50 条)</h2>
<div className="divide-y divide-border text-sm">
{deliveries.map((d) => (
<div key={d.id} className="py-2 flex items-center gap-3 flex-wrap">
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
d.status === 'sent' ? 'bg-success/10 text-success'
: d.status === 'cooldown' ? 'bg-muted text-muted-foreground'
: 'bg-destructive/10 text-destructive'
}`}
>
{d.status === 'sent' ? '已发送' : d.status === 'cooldown' ? '冷却跳过' : '失败'}
</span>
<span className="font-medium">{d.ruleName}</span>
<span className="text-muted-foreground text-xs">{d.count} 条命中</span>
{d.reason && <span className="text-destructive text-xs truncate flex-1">{d.reason}</span>}
<span className="text-muted-foreground text-xs ml-auto">{new Date(d.createdAt).toLocaleString()}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<p className="text-xs text-muted-foreground">
通知未配置?去
<Link to="/settings" className="text-primary mx-1">系统设置</Link>
配置飞书 Webhook
</p>
{/* 规则弹窗 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? `编辑规则 · ${editing.name}` : '新建告警规则'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>规则名称 *</FormLabel>
<FormControl><Input placeholder="应用致命异常" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="sourceId" render={({ field }) => (
<FormItem>
<FormLabel>数据源 *</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl><SelectTrigger><SelectValue placeholder="选择数据源" /></SelectTrigger></FormControl>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="query" render={({ field }) => (
<FormItem>
<FormLabel>LogQL 查询 *</FormLabel>
<FormControl>
<Input className="font-mono" placeholder='{namespace="log"} |= "FATAL"' {...field} />
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
{'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"'}
</p>
</FormItem>
)} />
<FormField control={form.control} name="channelIds" render={({ field }) => (
<FormItem>
<FormLabel>通知通道(可多选,扇出到多个告警群)</FormLabel>
<div className="grid grid-cols-2 gap-2 rounded-md border bg-background p-3 max-h-40 overflow-auto">
{channels.length === 0 && <p className="text-xs text-muted-foreground col-span-2">请先在「系统设置 → 告警通知」创建通道</p>}
{channels.map((ch) => {
const checked = field.value.includes(String(ch.id))
return (
<label key={ch.id} className={`flex items-center gap-2 text-sm cursor-pointer rounded-md border px-2.5 py-1.5 ${checked ? 'border-primary bg-primary/5 text-primary' : 'border-border'}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => {
if (e.target.checked) field.onChange([...field.value, String(ch.id)])
else field.onChange(field.value.filter((x) => x !== String(ch.id)))
}}
className="accent-primary"
/>
{ch.name}
{!ch.enabled && <span className="text-xs text-muted-foreground">(禁用)</span>}
</label>
)
})}
</div>
<FormMessage />
</FormItem>
)} />
<div className="grid grid-cols-3 gap-4">
<FormField control={form.control} name="windowMin" render={({ field }) => (
<FormItem>
<FormLabel>统计窗口(分)</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="threshold" render={({ field }) => (
<FormItem>
<FormLabel>触发阈值(条)</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">0 = 出现即告警</p>
</FormItem>
)} />
<FormField control={form.control} name="cooldownMin" render={({ field }) => (
<FormItem>
<FormLabel>冷却(分)</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="enabled" render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
<FormLabel>启用规则</FormLabel>
</FormItem>
)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : editing ? '保存修改' : '创建规则'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title="确认删除告警规则"
loading={deleting}
onConfirm={handleDelete}
/>
</PageContainer>
)
})
export default AlertsPage
@@ -1,9 +1,8 @@
import { memo } from 'react'
import { Server, RefreshCw, Trash2, Eye } from 'lucide-react'
import { Server, RefreshCw, Trash2, Eye, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { Cluster } from '@/types'
interface ClusterCardProps {
@@ -12,90 +11,42 @@ interface ClusterCardProps {
onViewInfo: (c: Cluster) => void
onValidate: (id: number) => void
onDelete: (c: Cluster) => void
onEdit: (c: Cluster) => void
}
/** 集群卡片 — 列表项展示与操作入口 */
/** 集群卡片 — 统一 ResourceCard 样式 */
export const ClusterCard = memo(function ClusterCard({
cluster,
validating,
onViewInfo,
onValidate,
onDelete,
cluster, validating, onViewInfo, onValidate, onDelete, onEdit,
}: ClusterCardProps) {
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Server className="w-5 h-5 text-info flex-shrink-0" />
<h3 className="font-semibold text-foreground truncate">
{cluster.name}
</h3>
</div>
<StatusBadge status={cluster.status} />
</div>
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{cluster.description || '无描述'}
</p>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow
label="接入方式"
value={cluster.configType === 'token' ? 'Token' : 'Kubeconfig'}
/>
{cluster.apiServer && (
<InfoRow
label="API Server"
value={cluster.apiServer}
valueClass="truncate max-w-[150px]"
/>
)}
<InfoRow label="版本" value={cluster.version || '未知'} />
<InfoRow label="节点数" value={String(cluster.nodesCount || 0)} />
<InfoRow
label="最后连接"
value={
cluster.lastConnected
? new Date(cluster.lastConnected).toLocaleString()
: '从未'
}
/>
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onViewInfo(cluster)}
>
<Eye className="w-3 h-3 mr-1" />
详情
<ResourceCard
icon={Server}
iconClassName="text-info"
title={cluster.name}
description={cluster.description || undefined}
badges={<StatusBadge status={cluster.status} />}
fields={[
{ label: '接入方式', value: cluster.configType === 'token' ? 'Token' : 'Kubeconfig' },
...(cluster.apiServer ? [{ label: 'API Server', value: cluster.apiServer, title: cluster.apiServer }] : []),
{ label: '版本', value: cluster.version || '未知' },
{ label: '节点数', value: String(cluster.nodesCount || 0) },
]}
actions={
<>
<Button variant="outline" size="sm" aria-label={`编辑集群 ${cluster.name}`} onClick={() => onEdit(cluster)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onValidate(cluster.id)}
disabled={validating === cluster.id}
>
<RefreshCw
className={`w-3 h-3 mr-1 ${validating === cluster.id ? 'animate-spin' : ''}`}
/>
验证
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onViewInfo(cluster)}>
<Eye className="size-3 mr-1" />详情
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除集群 ${cluster.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(cluster)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`验证集群 ${cluster.name}`} className="text-xs" disabled={validating === cluster.id} onClick={() => onValidate(cluster.id)}>
<RefreshCw className={`size-3 ${validating === cluster.id ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除集群 ${cluster.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(cluster)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -42,6 +42,7 @@ const clusterFormSchema = z
context: z.string().optional(),
})
.superRefine((data, ctx) => {
if (EDITING_REF.current) return // 编辑模式:凭证留空=保留,不做必填校验
if (data.configType === 'kubeconfig' && !data.kubeconfig?.trim()) {
ctx.addIssue({ code: 'custom', path: ['kubeconfig'], message: '请填写 kubeconfig 内容' })
}
@@ -72,13 +73,18 @@ interface ClusterFormDialogProps {
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并关闭弹窗) */
onCreated: () => void
/** 编辑目标(编辑模式:kubeconfig 留空=保留) */
editing?: import('@/types').Cluster | null
}
/** 添加集群弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
export const ClusterFormDialog = memo(function ClusterFormDialog({
open,
onOpenChange,
onCreated,
editing,
}: ClusterFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
@@ -90,24 +96,38 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
const kubeconfig = form.watch('kubeconfig')
// 打开时重置(编辑回填/关闭重置时机明确)
EDITING_REF.current = !!editing
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
form.reset(editing ? {
name: editing.name, description: editing.description || '',
configType: (editing.configType as 'kubeconfig' | 'token') || 'kubeconfig',
kubeconfig: '', apiServer: editing.apiServer || '', token: '', context: editing.context || '',
} as never : DEFAULT_VALUES)
}
}, [open, form])
}, [open, form, editing])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await clusterApi.create({
name: values.name,
description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' ? values.kubeconfig : undefined,
token: values.configType === 'token' ? values.token : undefined,
context: values.context,
})
await (editing
? clusterApi.update(editing.id, {
name: values.name, description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' && values.kubeconfig ? values.kubeconfig : undefined,
token: values.configType === 'token' && values.token ? values.token : undefined,
context: values.context,
})
: clusterApi.create({
name: values.name,
description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' ? values.kubeconfig : undefined,
token: values.configType === 'token' ? values.token : undefined,
context: values.context,
}))
onOpenChange(false)
onCreated()
} catch (err) {
@@ -121,7 +141,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 Kubernetes 集群</DialogTitle>
<DialogTitle>{editing ? `编辑集群${editing.name ? ` · ${editing.name}` : ''}` : '添加 Kubernetes 集群'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -175,7 +195,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="kubeconfig"
render={({ field }) => (
<FormItem>
<FormLabel>Kubeconfig 内容 *</FormLabel>
<FormLabel>Kubeconfig 内容{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
@@ -212,7 +232,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>Token *</FormLabel>
<FormLabel>Token{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 Service Account Token..."
@@ -253,7 +273,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '添加中...' : '添加集群'}
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加集群'}
</Button>
</form>
</Form>
+13 -4
View File
@@ -27,6 +27,7 @@ const ClustersPage = memo(function ClustersPage() {
const [formOpen, setFormOpen] = useState(false)
const [detailCluster, setDetailCluster] = useState<Cluster | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Cluster | null>(null)
const [editingTarget, setEditingTarget] = useState<Cluster | null>(null)
const [deleting, setDeleting] = useState(false)
const [validating, setValidating] = useState<number | null>(null)
@@ -110,7 +111,7 @@ const ClustersPage = memo(function ClustersPage() {
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((cluster) => (
<ClusterCard
key={cluster.id}
@@ -119,6 +120,7 @@ const ClustersPage = memo(function ClustersPage() {
onViewInfo={setDetailCluster}
onValidate={handleValidate}
onDelete={setDeleteTarget}
onEdit={setEditingTarget}
/>
))}
</div>
@@ -132,9 +134,15 @@ const ClustersPage = memo(function ClustersPage() {
)}
<ClusterFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onCreated={query.reload}
open={formOpen || editingTarget !== null}
onOpenChange={(o) => {
if (!o) {
setFormOpen(false)
setEditingTarget(null)
}
}}
onCreated={query.refresh}
editing={editingTarget}
/>
<ClusterDetailDialog
@@ -143,6 +151,7 @@ const ClustersPage = memo(function ClustersPage() {
onClose={() => setDetailCluster(null)}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -4,25 +4,25 @@ import {
Trash2,
Upload,
Download,
Pencil,
Server,
Monitor,
Cloud,
type LucideIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { Collector } from '@/types'
// 采集器类型元数据(图标 + 类型徽章配色)
const COLLECTOR_TYPE_META: Record<string, { label: string; icon: LucideIcon; color: string }> = {
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'bg-purple-100 text-purple-700' },
'k8s-daemonset': { label: 'K8s DaemonSet', icon: Cloud, color: 'bg-info/10 text-info' },
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'text-info' },
'k8s-daemonset': { label: 'Promtail DaemonSet', icon: Cloud, color: 'text-info' },
'k8s-fluentbit-sidecar': { label: 'Fluent Bit Sidecar', icon: Server, color: 'text-info' },
}
const FALLBACK_META = { label: '', icon: RadioTower, color: 'bg-muted text-slate-600' }
const FALLBACK_META = { label: '', icon: RadioTower, color: 'text-muted-foreground' }
interface CollectorCardProps {
collector: Collector
@@ -31,114 +31,48 @@ interface CollectorCardProps {
onInstall: (id: number) => void
onUninstall: (id: number) => void
onDelete: (c: Collector) => void
onEdit: (c: Collector) => void
}
/** 采集器卡片 — 列表项展示与操作入口(部署/卸载/删除) */
/** 采集策略卡片 — 统一 ResourceCard 样式 */
export const CollectorCard = memo(function CollectorCard({
collector,
operating,
onInstall,
onUninstall,
onDelete,
collector, operating, onInstall, onUninstall, onDelete, onEdit,
}: CollectorCardProps) {
const meta = COLLECTOR_TYPE_META[collector.type] ?? {
...FALLBACK_META,
label: collector.type,
}
const meta = COLLECTOR_TYPE_META[collector.type] ?? FALLBACK_META
const Icon = meta.icon
const isBusy = operating === collector.id
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate">
{collector.name}
</h3>
<p className="text-xs text-muted-foreground">
关联 Loki:{collector.lokiDeployment?.name || '未知'}
</p>
</div>
</div>
<StatusBadge status={collector.status} />
</div>
<div className="space-y-1.5 text-xs text-muted-foreground mb-3">
<div className="flex justify-between">
<span>类型</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>
{meta.label}
</span>
</div>
{collector.host && (
<InfoRow
label="主机"
value={`${collector.host.name}(${collector.host.hostIP})`}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.cluster && (
<InfoRow
label="集群"
value={collector.cluster.name}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.targetDeployment && (
<InfoRow
label="目标 Deployment"
value={collector.targetDeployment}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.namespace && (
<InfoRow label="命名空间" value={collector.namespace} valueClass="truncate max-w-[200px]" />
)}
{collector.logPath && (
<InfoRow label="日志路径" value={collector.logPath} valueClass="truncate max-w-[200px]" />
)}
{collector.statusMessage && (
<div className="p-2 bg-muted rounded text-xs mt-1">
{collector.statusMessage}
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onInstall(collector.id)}
disabled={isBusy}
>
<Upload className="w-3 h-3 mr-1" />
{isBusy ? '处理中...' : '部署'}
<ResourceCard
icon={Icon}
iconClassName={meta.color}
title={collector.name}
description={meta.label}
badges={<StatusBadge status={collector.status} />}
fields={[
{ label: '关联 Loki', value: collector.lokiDeployment?.name || '未知' },
...(collector.cluster ? [{ label: '集群', value: collector.cluster.name }] : []),
...(collector.host ? [{ label: '主机', value: `${collector.host.name}(${collector.host.hostIP})` }] : []),
...(collector.namespace ? [{ label: '命名空间', value: collector.namespace }] : []),
...(collector.logPath ? [{ label: '日志路径', value: collector.logPath, title: collector.logPath }] : []),
...(collector.targetDeployment ? [{ label: '目标', value: collector.targetDeployment }] : []),
]}
message={collector.statusMessage || undefined}
actions={
<>
<Button variant="outline" size="sm" aria-label={`编辑采集策略 ${collector.name}`} title="编辑采集策略" onClick={() => onEdit(collector)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onUninstall(collector.id)}
disabled={isBusy}
>
<Download className="w-3 h-3 mr-1" />
卸载
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onInstall(collector.id)} disabled={isBusy}>
<Upload className="size-3 mr-1" />{isBusy ? '处理中...' : '部署'}
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除采集器 ${collector.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(collector)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onUninstall(collector.id)} disabled={isBusy}>
<Download className="size-3 mr-1" />卸载
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除采集器 ${collector.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(collector)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -0,0 +1,74 @@
import { memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { collectorApi } from '@/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import type { Collector } from '@/types'
const schema = z.object({
name: z.string().trim().min(1, '请输入采集策略名称'),
logPath: z.string().trim(),
})
type Values = z.infer<typeof schema>
interface Props {
collector: Collector | null
onOpenChange: (open: boolean) => void
onSaved: () => void
}
export const CollectorEditDialog = memo(function CollectorEditDialog({ collector, onOpenChange, onSaved }: Props) {
const [saving, setSaving] = useState(false)
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: { name: '', logPath: '' } })
useEffect(() => {
if (collector) form.reset({ name: collector.name, logPath: collector.logPath || '' })
}, [collector, form])
const submit = form.handleSubmit(async (values) => {
if (!collector) return
setSaving(true)
try {
await collectorApi.update(collector.id, values)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存采集策略失败')
} finally {
setSaving(false)
}
})
const supportsPath = collector?.type !== 'k8s-daemonset'
return (
<Dialog open={collector !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>编辑采集策略</DialogTitle></DialogHeader>
<Form {...form}>
<form onSubmit={submit} className="space-y-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem><FormLabel>名称</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
{supportsPath && <FormField control={form.control} name="logPath" render={({ field }) => (
<FormItem>
<FormLabel>采集日志路径</FormLabel>
<FormControl><Input placeholder={collector?.type === 'host-promtail' ? '/var/log/*.log' : '/var/log/app/*.log'} {...field} /></FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。</p>
</FormItem>
)} />}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button type="submit" disabled={saving}>{saving ? '保存中…' : '保存'}</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
})
@@ -25,7 +25,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { collectorApi } from '@/api'
import { clusterApi, collectorApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { toast } from 'sonner'
import type { Cluster, Host, LokiDeployment } from '@/types'
@@ -34,8 +34,8 @@ type CollectorType = 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'
const COLLECTOR_TYPES: Array<{ value: CollectorType; label: string }> = [
{ value: 'host-promtail', label: '主机 Promtail(SSH)' },
{ value: 'k8s-daemonset', label: 'K8s Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar', label: 'K8s Fluent Bit Sidecar' },
{ value: 'k8s-daemonset', label: 'Kubernetes Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar', label: 'Kubernetes Fluent Bit Sidecar' },
]
// 表单 schema:zod 为唯一校验源,消息中文且具体
@@ -69,6 +69,9 @@ const collectorFormSchema = z
message: '请填写目标 Deployment 名称',
})
}
if (data.type === 'k8s-fluentbit-sidecar' && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请选择命名空间' })
}
})
type CollectorFormValues = z.infer<typeof collectorFormSchema>
@@ -105,20 +108,70 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
hosts,
}: CollectorFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const [namespaces, setNamespaces] = useState<string[]>([])
const [workloads, setWorkloads] = useState<Array<{ name: string; replicas: number; readyReplicas: number }>>([])
const [namespacesLoading, setNamespacesLoading] = useState(false)
const [workloadsLoading, setWorkloadsLoading] = useState(false)
const [namespacesError, setNamespacesError] = useState('')
const [workloadsError, setWorkloadsError] = useState('')
const [namespaceReload, setNamespaceReload] = useState(0)
const [workloadReload, setWorkloadReload] = useState(0)
const [manualNamespace, setManualNamespace] = useState(false)
const [manualDeployment, setManualDeployment] = useState(false)
const form = useForm<CollectorFormValues>({
resolver: zodResolver(collectorFormSchema),
defaultValues: DEFAULT_VALUES,
})
const collectorType = form.watch('type')
const clusterID = form.watch('clusterID')
const namespace = form.watch('namespace')
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
// 打开时重置(关闭重置时机明确)
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
setManualNamespace(false)
setManualDeployment(false)
}
}, [open, form])
useEffect(() => {
if (!open || !isSidecarType || !clusterID) return
const controller = new AbortController()
setNamespacesLoading(true)
setNamespacesError('')
setNamespaces([])
clusterApi.namespaces(Number(clusterID), { signal: controller.signal })
.then(({ namespaces: items }) => {
setNamespaces(items)
if (items.includes('default') && !form.getValues('namespace')) form.setValue('namespace', 'default')
})
.catch((err) => {
if (!controller.signal.aborted) setNamespacesError(err instanceof Error ? err.message : '读取命名空间失败')
})
.finally(() => { if (!controller.signal.aborted) setNamespacesLoading(false) })
return () => controller.abort()
}, [open, isSidecarType, clusterID, form, namespaceReload])
useEffect(() => {
if (!open || !isSidecarType || !clusterID || !namespace) return
const controller = new AbortController()
setWorkloadsLoading(true)
setWorkloadsError('')
setWorkloads([])
const timer = setTimeout(() => {
clusterApi.deployments(Number(clusterID), namespace, { signal: controller.signal })
.then(({ deployments: items }) => setWorkloads(items))
.catch((err) => {
if (!controller.signal.aborted) setWorkloadsError(err instanceof Error ? err.message : '读取 Deployment 失败')
})
.finally(() => { if (!controller.signal.aborted) setWorkloadsLoading(false) })
}, 250)
return () => { clearTimeout(timer); controller.abort() }
}, [open, isSidecarType, clusterID, namespace, workloadReload])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
@@ -151,7 +204,6 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
const isHostType = collectorType === 'host-promtail'
const isK8sType = collectorType !== 'host-promtail'
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -262,7 +314,14 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormItem>
<FormLabel>目标集群 *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
form.setValue('namespace', '')
form.setValue('targetDeployment', '')
setManualNamespace(false)
setManualDeployment(false)
setWorkloads([])
}}>
<SelectTrigger>
<SelectValue placeholder="选择集群" />
</SelectTrigger>
@@ -285,14 +344,33 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="namespace"
render={({ field }) => (
<FormItem>
<FormLabel>命名空间</FormLabel>
<FormLabel>命名空间{isSidecarType ? ' *' : ''}</FormLabel>
<FormControl>
<Input
placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'}
{...field}
/>
{isSidecarType && !manualNamespace ? (
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
form.setValue('targetDeployment', '')
}} disabled={!clusterID || namespacesLoading}>
<SelectTrigger><SelectValue placeholder={!clusterID ? '先选择集群' : namespacesLoading ? '加载命名空间…' : '选择命名空间'} /></SelectTrigger>
<SelectContent>
{namespaces.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'} {...field} onChange={(event) => {
field.onChange(event)
if (isSidecarType) form.setValue('targetDeployment', '')
}} />
)}
</FormControl>
<FormMessage />
{isSidecarType && <div className="flex items-center gap-2 text-xs">
<Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setManualNamespace((value) => !value)}>
{manualNamespace ? '从集群选择' : '手动填写'}
</Button>
{namespacesError && <><span className="text-destructive">{namespacesError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setNamespaceReload((value) => value + 1)}>重试</Button></>}
{!namespacesLoading && !namespacesError && clusterID && namespaces.length === 0 && <span className="text-muted-foreground">暂无可读取的命名空间</span>}
</div>}
</FormItem>
)}
/>
@@ -304,9 +382,27 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormItem>
<FormLabel>目标 Deployment *</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
{manualDeployment ? (
<Input placeholder="my-app" {...field} />
) : (
<Select value={field.value} onValueChange={field.onChange} disabled={!namespace || workloadsLoading}>
<SelectTrigger><SelectValue placeholder={!namespace ? '先选择命名空间' : workloadsLoading ? '加载 Deployment…' : '选择 Deployment'} /></SelectTrigger>
<SelectContent>
{workloads.map((item) => <SelectItem key={item.name} value={item.name}>
{item.name}(就绪 {item.readyReplicas}/{item.replicas})
</SelectItem>)}
</SelectContent>
</Select>
)}
</FormControl>
<FormMessage />
<div className="flex items-center gap-2 text-xs">
<Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setManualDeployment((value) => !value)}>
{manualDeployment ? '从集群选择' : '手动填写'}
</Button>
{workloadsError && <><span className="text-destructive">{workloadsError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setWorkloadReload((value) => value + 1)}>重试</Button></>}
{!workloadsLoading && !workloadsError && namespace && workloads.length === 0 && <span className="text-muted-foreground">此命名空间暂无 Deployment</span>}
</div>
</FormItem>
)}
/>
@@ -333,6 +429,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔
</p>
)}
{isSidecarType && <p className="text-xs text-muted-foreground">填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。</p>}
</FormItem>
)}
/>
+6 -1
View File
@@ -13,6 +13,7 @@ import { useListQuery } from '@/hooks/useListQuery'
import type { Cluster, Collector, Host, LokiDeployment } from '@/types'
import { CollectorCard } from './components/CollectorCard'
import { CollectorFormDialog } from './components/CollectorFormDialog'
import { CollectorEditDialog } from './components/CollectorEditDialog'
/**
* 日志采集页 — 采集策略编排:列表走 useListQuery(取消+版本号),
@@ -30,6 +31,7 @@ const CollectorsPage = memo(function CollectorsPage() {
const [hosts, setHosts] = useState<Host[]>([])
const [formOpen, setFormOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Collector | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Collector | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/卸载指令进行中的采集器 ID(按钮 loading + 防重复点击) */
@@ -128,7 +130,7 @@ const CollectorsPage = memo(function CollectorsPage() {
/>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((collector) => (
<CollectorCard
key={collector.id}
@@ -137,6 +139,7 @@ const CollectorsPage = memo(function CollectorsPage() {
onInstall={(id) => handleAction(id, 'install')}
onUninstall={(id) => handleAction(id, 'uninstall')}
onDelete={setDeleteTarget}
onEdit={setEditTarget}
/>
))}
</div>
@@ -158,6 +161,8 @@ const CollectorsPage = memo(function CollectorsPage() {
hosts={hosts}
/>
<CollectorEditDialog collector={editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null) }} onSaved={query.refresh} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -1,10 +1,9 @@
import { memo, useEffect, useState } from 'react'
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink } from 'lucide-react'
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink, Pencil, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge, StatusIcon } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { StatusBadge } from '@/components/StatusBadge'
import { deploymentApi } from '@/api'
import { ResourceCard } from '@/components/ResourceCard'
import type { LokiDeployment } from '@/types'
/** 部署目标展示:优先集群,其次主机 */
@@ -23,6 +22,7 @@ interface DeploymentCardProps {
onRestart: (id: number) => void
onDelete: (d: LokiDeployment) => void
onViewLogs: (d: LokiDeployment) => void
onEditRetention: (d: LokiDeployment) => void
}
/** 部署卡片 — 列表项展示与操作入口(部署/停止/重启/日志/访问/删除) */
@@ -34,6 +34,7 @@ export const DeploymentCard = memo(function DeploymentCard({
onRestart,
onDelete,
onViewLogs,
onEditRetention,
}: DeploymentCardProps) {
// Loki 就绪探测(运行中时经数据源代理 /ready)
const [ready, setReady] = useState<boolean | null>(null)
@@ -58,122 +59,75 @@ export const DeploymentCard = memo(function DeploymentCard({
const canDeploy = d.status === 'pending' || d.status === 'failed' || d.status === 'stopped'
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={d.status} size="md" />
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate">
{d.name}
</h3>
<p className="text-xs text-muted-foreground">{d.uuid}</p>
</div>
</div>
<StatusBadge status={d.status} />
{d.status === 'running' && ready !== null && (
<span
className={`w-2 h-2 rounded-full ${ready ? 'bg-success' : 'bg-destructive'}`}
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
/>
)}
</div>
<p className="text-sm text-muted-foreground mb-3">{d.description || '无描述'}</p>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow label="方式" value={d.mode} />
<InfoRow label="版本" value={d.version} />
<InfoRow label="目标" value={getTargetLabel(d)} />
<InfoRow label="命名空间" value={d.namespace || 'loki'} />
<InfoRow label="副本数" value={String(d.replicas)} />
<InfoRow label="存储" value={d.storageConfig?.name || '本地'} />
{d.endpoint && (
<InfoRow label="访问地址" value={d.endpoint} valueClass="text-primary truncate max-w-[200px]" />
)}
{d.grafanaEnabled && d.grafanaEndpoint && (
<div className="flex justify-between items-center gap-2">
<span>Grafana</span>
<a
href={d.grafanaEndpoint}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline truncate max-w-[200px]"
title={`${d.grafanaEndpoint}(admin/admin)`}
>
{d.grafanaEndpoint.replace(/^https?:\/\//, '')}
</a>
</div>
)}
{d.statusMessage && (
<div className="p-2 bg-muted rounded text-xs">
{d.statusMessage}
</div>
)}
</div>
<div className="flex gap-1 pt-3 border-t border-border">
<ResourceCard
icon={Rocket}
iconClassName={d.status === 'running' ? 'text-success' : d.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}
title={d.name}
titleExtra={
d.status === 'running' && ready !== null ? (
<span
className={`w-2 h-2 rounded-full shrink-0 ${ready ? 'bg-success' : 'bg-destructive'}`}
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
/>
) : undefined
}
badges={<StatusBadge status={d.status} />}
description={d.description || d.uuid}
fields={[
{ label: '方式', value: d.mode === 'host-docker' ? '主机 Docker' : d.mode === 'k8s-yaml' ? 'Kubernetes' : d.mode },
{ label: '版本', value: d.version },
{ label: '目标', value: getTargetLabel(d) },
...(d.mode !== 'host-docker' ? [
{ label: '命名空间', value: d.namespace || 'loki' },
{ label: '副本', value: `${d.replicas} · 保留 ${d.retentionDays || 30} 天` },
] : [{ label: '保留', value: `${d.retentionDays || 30} 天` }]),
{ label: '存储', value: d.storageConfig?.name || '本地' },
...(d.endpoint ? [{ label: '访问', value: (
<span className="text-primary">{d.endpoint.replace(/^https?:\/\//, '')}</span>
), title: d.endpoint }] : []),
...(d.grafanaEnabled && d.grafanaEndpoint ? [{ label: 'Grafana', value: (
<a href={d.grafanaEndpoint} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" title={`${d.grafanaEndpoint}(admin/admin)`}>
{d.grafanaEndpoint.replace(/^https?:\/\//, '')}
</a>
) }] : []),
]}
message={d.statusMessage || undefined}
actions={
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
<Pencil className="size-3 mr-1" />配置
</Button>
{canDeploy ? (
<Button
variant="outline"
size="sm"
className="flex-1 text-xs bg-success/10 text-primary hover:bg-success/20 border-success/30"
onClick={() => onDeploy(d.id)}
disabled={isBusy}
>
<Play className="w-3 h-3 mr-1" />
{isBusy ? '部署中...' : '部署'}
<Button variant="outline" size="sm" className="flex-1 text-xs bg-success/10 text-primary hover:bg-success/20 border-success/30" onClick={() => onDeploy(d.id)} disabled={isBusy}>
<Play className="size-3 mr-1" />{isBusy ? '部署中...' : '部署'}
</Button>
) : isDeploying ? (
<Button variant="outline" size="sm" className="flex-1 text-xs" disabled>
<RotateCw className="w-3 h-3 mr-1 animate-spin" />
部署中...
<RotateCw className="size-3 mr-1 animate-spin" />部署中...
</Button>
) : (
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onStop(d.id)}>
<Square className="w-3 h-3 mr-1" />
停止
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onStop(d.id)}>
<Square className="size-3 mr-1" />停止
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onRestart(d.id)}
disabled={isBusy}
>
<RotateCw className={`w-3 h-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />
重启
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onRestart(d.id)} disabled={isBusy}>
<RotateCw className={`size-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />重启
</Button>
</>
)}
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
<Terminal className="w-3 h-3 mr-1" />
日志
<Terminal className="size-3 mr-1" />日志
</Button>
{d.endpoint && (
<Button
variant="outline"
size="sm"
className="text-xs text-primary hover:text-primary/80"
onClick={() => window.open(d.endpoint, '_blank', 'noopener,noreferrer')}
title="浏览器访问"
>
<ExternalLink className="w-3 h-3 mr-1" />
访问
<Button variant="outline" size="sm" className="text-xs text-primary hover:text-primary/80" onClick={() => window.open(d.endpoint, '_blank', 'noopener,noreferrer')} title="浏览器访问">
<ExternalLink className="size-3" />
</Button>
)}
<Button
variant="outline"
size="sm"
aria-label={`删除部署 ${d.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(d)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`删除部署 ${d.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(d)}>
<Trash2 className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
</>
}
/>
)
})
@@ -81,7 +81,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
defaultValues: DEFAULT_VALUES,
})
const mode = form.watch('mode')
const grafanaEnabled = form.watch('grafanaEnabled')
const hostID = form.watch('hostID')
// 打开时重置(关闭重置时机明确)
useEffect(() => {
@@ -100,6 +100,8 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
mode: values.mode,
clusterID: values.clusterID ? Number(values.clusterID) : undefined,
hostID: values.hostID ? Number(values.hostID) : undefined,
listenIP: values.mode === 'host-docker' ? (values.listenIP || hosts.find((h) => String(h.id) === values.hostID)?.hostIP) : undefined,
listenPort: values.mode === 'host-docker' ? Number(values.listenPort) : undefined,
namespace: values.namespace,
storageConfigID: values.storageConfigID ? Number(values.storageConfigID) : 0,
authEnabled: values.authEnabled,
@@ -108,8 +110,6 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
resourcesCPU: values.resourcesCPU,
resourcesMem: values.resourcesMem,
storageSize: values.storageSize,
grafanaEnabled: values.grafanaEnabled,
grafanaVersion: values.grafanaEnabled ? values.grafanaVersion || undefined : undefined,
})
onOpenChange(false)
onCreated()
@@ -121,7 +121,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
})
const showK8s = isK8sMode(mode)
const showHost = mode === 'host-binary'
const showHost = mode === 'host-docker'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -171,7 +171,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
{versions.filter((v) => mode !== 'host-docker' || v.startsWith('3.')).map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
@@ -190,14 +190,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>部署方式</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={(next) => {
field.onChange(next)
if (next === 'host-docker' && !form.getValues('version').startsWith('3.')) form.setValue('version', '3.0.0')
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k8s-helm">K8s Helm 部署</SelectItem>
<SelectItem value="k8s-yaml">K8s YAML 部署</SelectItem>
<SelectItem value="host-binary">主机二进制部署</SelectItem>
<SelectItem value="k8s-yaml">Kubernetes</SelectItem>
<SelectItem value="host-docker">主机 Docker</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -249,7 +251,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
</>
)}
{showHost && (
{showHost && (<>
<FormField
control={form.control}
name="hostID"
@@ -274,7 +276,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
</FormItem>
)}
/>
)}
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="listenIP" render={({ field }) => (
<FormItem><FormLabel>监听内网 IP</FormLabel><FormControl><Input placeholder={hosts.find((h) => String(h.id) === hostID)?.hostIP || '选择主机后默认使用主机 IP'} {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="listenPort" render={({ field }) => (
<FormItem><FormLabel>访问端口</FormLabel><FormControl><Input type="number" min={1} max={65535} {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<p className="text-xs text-muted-foreground">通过 SSH 在主机启动 Docker 容器,配置和数据保存在主机本地。</p>
</>)}
<FormField
control={form.control}
@@ -303,7 +314,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
/>
<div className="grid grid-cols-3 gap-4">
<FormField
{showK8s && <FormField
control={form.control}
name="replicas"
render={({ field }) => (
@@ -315,7 +326,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormMessage />
</FormItem>
)}
/>
/>}
<FormField
control={form.control}
name="retentionDays"
@@ -329,7 +340,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
</FormItem>
)}
/>
<FormField
{showK8s && <FormField
control={form.control}
name="storageSize"
render={({ field }) => (
@@ -341,7 +352,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormMessage />
</FormItem>
)}
/>
/>}
</div>
<div className="grid grid-cols-2 gap-4">
@@ -392,41 +403,6 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
)}
/>
<FormField
control={form.control}
name="grafanaEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel>同时部署 Grafana(可选 — 平台已内置日志查询,通常无需 Grafana)</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{grafanaEnabled && (
<FormField
control={form.control}
name="grafanaVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Grafana 版本</FormLabel>
<FormControl>
<Input placeholder="11.3.0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90"
@@ -0,0 +1,146 @@
import { memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { deploymentApi } from '@/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Textarea } from '@/components/ui/textarea'
import type { LokiDeployment } from '@/types'
const schema = z.object({ yaml: z.string().trim().min(1, '请输入 YAML 配置') })
type Values = z.infer<typeof schema>
interface Props {
deployment: LokiDeployment | null
onClose: () => void
onSaved: () => void
}
function configLocation(deployment: LokiDeployment) {
if (deployment.mode === 'k8s-helm') return 'Helm values.yaml → loki.limits_config.retention_period'
if (deployment.mode === 'host-binary') return '/etc/loki/loki.yaml → limits_config.retention_period'
if (deployment.mode === 'host-docker') return `主机 /etc/lokideploy/loki-${deployment.id}/loki.yaml → limits_config.retention_period`
return `ConfigMap ${deployment.namespace || 'log'}/loki-config → loki.yaml`
}
export const RetentionDialog = memo(function RetentionDialog({ deployment, onClose, onSaved }: Props) {
const [saving, setSaving] = useState(false)
const [liveConfig, setLiveConfig] = useState<{ location: string; yaml: string } | null>(null)
const [configLoading, setConfigLoading] = useState(false)
const [configError, setConfigError] = useState('')
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { yaml: 'limits_config:\n retention_period: 30d\n' },
})
useEffect(() => {
if (deployment) {
form.reset({ yaml: `limits_config:\n retention_period: ${deployment.retentionDays || 30}d\n` })
}
setLiveConfig(null)
setConfigError('')
}, [deployment, form])
const loadConfig = async () => {
if (!deployment) return
setConfigLoading(true)
setConfigError('')
try {
setLiveConfig(await deploymentApi.liveConfig(deployment.id))
} catch (err) {
setConfigError(err instanceof Error ? err.message : '读取运行配置失败')
} finally {
setConfigLoading(false)
}
}
const save = (apply: boolean) => form.handleSubmit(async ({ yaml }) => {
if (!deployment) return
setSaving(true)
try {
const result = await deploymentApi.updateRetention(deployment.id, yaml, apply)
onSaved()
onClose()
if (apply) toast.info(`已保存 ${result.retentionDays} 天,Loki 正在重新部署`)
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存 Loki 配置失败')
onSaved()
} finally {
setSaving(false)
}
})
return (
<Dialog open={deployment !== null} onOpenChange={(open) => { if (!open && !saving) onClose() }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>编辑 Loki 配置 · {deployment?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-1 text-sm text-muted-foreground">
<p>配置位置:{deployment ? configLocation(deployment) : ''}</p>
<p>保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。</p>
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">运行中的完整 loki.yaml</p>
<p className="text-xs text-muted-foreground">从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={loadConfig} disabled={configLoading || deployment?.mode === 'k8s-helm'}>
{configLoading ? '读取中…' : liveConfig ? '刷新配置' : '查看完整配置'}
</Button>
</div>
{deployment?.mode === 'k8s-helm' && <p className="text-xs text-muted-foreground">Helm 模式暂不支持读取运行配置。</p>}
{configError && <p className="text-sm text-destructive">{configError}</p>}
{liveConfig && (
<div>
<p className="mb-2 text-xs text-muted-foreground">{liveConfig.location}</p>
<pre className="max-h-[45vh] overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
</div>
)}
</div>
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
<p className="mb-1 font-medium text-foreground">由部署模板生成</p>
<pre className="text-muted-foreground">{'compactor:\n retention_enabled: true\n retention_delete_delay: 2h'}</pre>
</div>
<Form {...form}>
<form onSubmit={save(false)} className="space-y-4">
<FormField
control={form.control}
name="yaml"
render={({ field }) => (
<FormItem>
<FormLabel>保留配置 YAML</FormLabel>
<FormControl>
<Textarea rows={4} spellCheck={false} className="font-mono text-sm" {...field} />
</FormControl>
<p className="text-xs text-muted-foreground">
编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。
</p>
<FormMessage />
</FormItem>
)}
/>
<p className="text-sm text-muted-foreground">
{deployment?.status === 'running'
? '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。'
: '保存后在下次部署时生效。'}
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>取消</Button>
<Button type="submit" variant="outline" disabled={saving}>仅保存</Button>
{deployment?.status === 'running' && (
<Button type="button" onClick={save(true)} disabled={saving}>
{saving ? '处理中…' : '保存并应用'}
</Button>
)}
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
})
+12 -10
View File
@@ -5,10 +5,9 @@ import { z } from 'zod'
// 版本下拉兜底值;实际列表运行时从 GET /deployments/versions 拉取
export const FALLBACK_VERSIONS = ['3.0.0', '2.9.8', '2.9.7', '2.9.6']
export type DeploymentMode = 'k8s-helm' | 'k8s-yaml' | 'host-binary'
export type DeploymentMode = 'k8s-yaml' | 'host-docker'
export const isK8sMode = (mode: DeploymentMode) =>
mode === 'k8s-helm' || mode === 'k8s-yaml'
export const isK8sMode = (mode: DeploymentMode) => mode === 'k8s-yaml'
// 整数字段校验(输入框 type=number 产出字符串,提交前统一转换)
const isPositiveInt = (v: string, min: number, max?: number) => {
@@ -21,12 +20,12 @@ export const deploymentFormSchema = z
name: z.string().min(1, '请输入部署名称'),
description: z.string(),
version: z.string().min(1, '请选择 Loki 版本'),
mode: z.enum(['k8s-helm', 'k8s-yaml', 'host-binary']),
mode: z.enum(['k8s-yaml', 'host-docker']),
clusterID: z.string(),
hostID: z.string(),
listenIP: z.string(),
listenPort: z.string(),
namespace: z.string(),
grafanaEnabled: z.boolean(),
grafanaVersion: z.string().optional(),
storageConfigID: z.string(),
authEnabled: z.boolean(),
retentionDays: z
@@ -43,9 +42,12 @@ export const deploymentFormSchema = z
if (isK8sMode(data.mode) && !data.clusterID) {
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: '请选择目标集群' })
}
if (data.mode === 'host-binary' && !data.hostID) {
if (data.mode === 'host-docker' && !data.hostID) {
ctx.addIssue({ code: 'custom', path: ['hostID'], message: '请选择目标主机' })
}
if (data.mode === 'host-docker' && !isPositiveInt(data.listenPort, 1, 65535)) {
ctx.addIssue({ code: 'custom', path: ['listenPort'], message: '端口须在 1-65535 之间' })
}
if (isK8sMode(data.mode) && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' })
}
@@ -57,12 +59,12 @@ export const DEFAULT_VALUES: DeploymentFormValues = {
name: '',
description: '',
version: '3.0.0',
mode: 'k8s-helm',
mode: 'k8s-yaml',
clusterID: '',
hostID: '',
listenIP: '',
listenPort: '3100',
namespace: 'log',
grafanaEnabled: false,
grafanaVersion: '11.3.0',
storageConfigID: '',
authEnabled: false,
retentionDays: '30',
+10 -1
View File
@@ -13,6 +13,7 @@ import { useListQuery } from '@/hooks/useListQuery'
import type { Cluster, Host, LokiDeployment, StorageConfig } from '@/types'
import { DeploymentCard } from './components/DeploymentCard'
import { DeploymentFormDialog } from './components/DeploymentFormDialog'
import { RetentionDialog } from './components/RetentionDialog'
import { LogViewerDialog } from './components/LogViewerDialog'
/**
@@ -32,6 +33,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
const [formOpen, setFormOpen] = useState(false)
const [logsDeployment, setLogsDeployment] = useState<LokiDeployment | null>(null)
const [retentionDeployment, setRetentionDeployment] = useState<LokiDeployment | null>(null)
const [deleteTarget, setDeleteTarget] = useState<LokiDeployment | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/重启进行中的部署 ID(按钮 loading + 防重复点击) */
@@ -155,7 +157,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
/>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((deployment) => (
<DeploymentCard
key={deployment.id}
@@ -166,6 +168,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
onRestart={handleRestart}
onDelete={setDeleteTarget}
onViewLogs={setLogsDeployment}
onEditRetention={setRetentionDeployment}
/>
))}
</div>
@@ -192,6 +195,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
onClose={() => setLogsDeployment(null)}
/>
<RetentionDialog
deployment={retentionDeployment}
onClose={() => setRetentionDeployment(null)}
onSaved={query.refresh}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -1,9 +1,8 @@
import { memo } from 'react'
import { Monitor, RefreshCw, Trash2, Eye, Terminal } from 'lucide-react'
import { Monitor, RefreshCw, Trash2, Eye, Terminal, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { Host } from '@/types'
interface HostCardProps {
@@ -13,85 +12,44 @@ interface HostCardProps {
onOpenTerminal: (h: Host) => void
onValidate: (id: number) => void
onDelete: (h: Host) => void
onEdit: (h: Host) => void
}
/** 主机卡片 — 列表项展示与操作入口(删除传整个对象,供确认弹窗展示名称) */
/** 主机卡片 — 统一 ResourceCard 样式 */
export const HostCard = memo(function HostCard({
host,
validating,
onViewInfo,
onOpenTerminal,
onValidate,
onDelete,
host, validating, onViewInfo, onOpenTerminal, onValidate, onDelete, onEdit,
}: HostCardProps) {
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Monitor className="w-5 h-5 text-purple-500 flex-shrink-0" />
<h3 className="font-semibold text-foreground truncate">
{host.name}
</h3>
</div>
<StatusBadge status={host.status} />
</div>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow label="IP 地址" value={`${host.hostIP}:${host.port}`} />
<InfoRow label="用户名" value={host.user} />
<InfoRow
label="认证方式"
value={host.authType === 'key' ? 'SSH 密钥' : '密码'}
/>
<InfoRow
label="系统"
value={`${host.os || '未知'} (${host.arch || '未知'})`}
/>
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onViewInfo(host)}
>
<Eye className="w-3 h-3 mr-1" />
详情
<ResourceCard
icon={Monitor}
iconClassName="text-purple-500"
title={host.name}
badges={<StatusBadge status={host.status} />}
fields={[
{ label: '地址', value: `${host.hostIP}:${host.port}` },
{ label: '用户', value: host.user },
{ label: '认证', value: host.authType === 'key' ? 'SSH 密钥' : '密码' },
{ label: '系统', value: `${host.os || '未知'} / ${host.arch || '未知'}` },
]}
actions={
<>
<Button variant="outline" size="sm" aria-label={'编辑主机 ' + host.name} onClick={() => onEdit(host)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onOpenTerminal(host)}
>
<Terminal className="w-3 h-3 mr-1" />
终端
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onViewInfo(host)}>
<Eye className="size-3 mr-1" />详情
</Button>
<Button
variant="outline"
size="sm"
aria-label={`验证主机 ${host.name}`}
className="text-xs"
onClick={() => onValidate(host.id)}
disabled={validating === host.id}
>
<RefreshCw
className={`w-3 h-3 ${validating === host.id ? 'animate-spin' : ''}`}
/>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onOpenTerminal(host)}>
<Terminal className="size-3 mr-1" />终端
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除主机 ${host.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(host)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`验证主机 ${host.name}`} disabled={validating === host.id} onClick={() => onValidate(host.id)}>
<RefreshCw className={`size-3 ${validating === host.id ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除主机 ${host.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(host)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -43,6 +43,7 @@ const hostFormSchema = z
labels: z.string().optional(),
})
.superRefine((data, ctx) => {
if (EDITING_REF.current) return
if (data.authType === 'password' && !data.password?.trim()) {
ctx.addIssue({
code: 'custom',
@@ -77,15 +78,19 @@ interface HostFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并关闭弹窗) */
onCreated: () => void
onCreated: () => void /** 编辑目标(编辑模式:凭证留空=保留) */
editing?: import('@/types').Host | null
}
/** 添加 SSH 主机弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
export const HostFormDialog = memo(function HostFormDialog({
open,
onOpenChange,
onCreated,
}: HostFormDialogProps) {
editing,
}: HostFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const form = useForm<HostFormValues>({
@@ -95,26 +100,35 @@ export const HostFormDialog = memo(function HostFormDialog({
const authType = form.watch('authType')
// 打开时重置(默认值),关闭/重开时机明确
EDITING_REF.current = !!editing
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
form.reset(editing ? {
name: editing.name, hostIP: editing.hostIP, port: String(editing.port),
user: editing.user, authType: editing.authType,
password: '', privateKey: '', keyPhrase: '', labels: editing.labels || '',
} as never : DEFAULT_VALUES)
}
}, [open, form])
}, [open, form, editing])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await hostApi.create({
name: values.name,
hostIP: values.hostIP,
port: Number(values.port),
user: values.user,
authType: values.authType,
password: values.authType === 'password' ? values.password : undefined,
privateKey: values.authType === 'key' ? values.privateKey : undefined,
keyPhrase: values.authType === 'key' ? values.keyPhrase : undefined,
labels: values.labels,
})
await (editing
? hostApi.update(editing.id, {
name: values.name, hostIP: values.hostIP, port: Number(values.port),
user: values.user, authType: values.authType,
password: values.password || undefined,
privateKey: values.privateKey || undefined,
keyPhrase: values.keyPhrase || undefined,
labels: values.labels,
})
: hostApi.create({
name: values.name, hostIP: values.hostIP, port: Number(values.port),
user: values.user, authType: values.authType,
password: values.password, privateKey: values.privateKey,
keyPhrase: values.keyPhrase, labels: values.labels,
}))
onOpenChange(false)
onCreated()
} catch (err) {
@@ -128,7 +142,7 @@ export const HostFormDialog = memo(function HostFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 SSH 主机</DialogTitle>
<DialogTitle>{editing ? `编辑主机${editing.name ? ` · ${editing.name}` : ''}` : '添加 SSH 主机'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -232,7 +246,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>密码 *</FormLabel>
<FormLabel>密码{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<PasswordInput placeholder="SSH 登录密码" {...field} />
</FormControl>
@@ -247,7 +261,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>私钥 *</FormLabel>
<FormLabel>私钥{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
@@ -294,7 +308,7 @@ export const HostFormDialog = memo(function HostFormDialog({
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '添加中...' : '添加主机'}
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加主机'}
</Button>
</form>
</Form>
+12 -4
View File
@@ -29,6 +29,7 @@ const HostsPage = memo(function HostsPage() {
const [detailHost, setDetailHost] = useState<Host | null>(null)
const [terminalHost, setTerminalHost] = useState<Host | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Host | null>(null)
const [editingTarget, setEditingTarget] = useState<Host | null>(null)
const [deleting, setDeleting] = useState(false)
const [validating, setValidating] = useState<number | null>(null)
@@ -111,7 +112,7 @@ const HostsPage = memo(function HostsPage() {
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((host) => (
<HostCard
key={host.id}
@@ -121,6 +122,7 @@ const HostsPage = memo(function HostsPage() {
onOpenTerminal={setTerminalHost}
onValidate={handleValidate}
onDelete={setDeleteTarget}
onEdit={setEditingTarget}
/>
))}
</div>
@@ -134,9 +136,15 @@ const HostsPage = memo(function HostsPage() {
)}
<HostFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onCreated={query.reload}
open={formOpen || editingTarget !== null}
onOpenChange={(o) => {
if (!o) {
setFormOpen(false)
setEditingTarget(null)
}
}}
onCreated={query.refresh}
editing={editingTarget}
/>
<HostDetailDialog
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
import { logsApi } from '@/api/logs'
import { SearchableValueSelect } from './SearchableValueSelect'
const policyLabels = ['namespace', 'app', 'pod'] as const
type PolicyLabel = typeof policyLabels[number]
interface AccessRuleEditorProps {
sourceId: number
username: string
labels: Record<string, string>
onChange: (labels: Record<string, string>) => void
}
/** Exact stream-label restrictions for one user's access to a Loki source. */
export function AccessRuleEditor({ sourceId, username, labels, onChange }: AccessRuleEditorProps) {
const [options, setOptions] = useState<Record<PolicyLabel, string[]>>({ namespace: [], app: [], pod: [] })
const [loading, setLoading] = useState<Record<PolicyLabel, boolean>>({ namespace: false, app: false, pod: false })
const namespace = labels.namespace ?? ''
const app = labels.app ?? ''
useEffect(() => {
if (!sourceId) return
let active = true
const load = (name: PolicyLabel, scope: Record<string, string>) => {
setLoading(current => ({ ...current, [name]: true }))
logsApi.labelValues(sourceId, name, Object.keys(scope).length ? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}` : '')
.then(values => { if (active) setOptions(current => ({ ...current, [name]: values })) })
.catch(() => { if (active) setOptions(current => ({ ...current, [name]: [] })) })
.finally(() => { if (active) setLoading(current => ({ ...current, [name]: false })) })
}
load('namespace', {})
load('app', namespace ? { namespace } : {})
load('pod', { ...(namespace ? { namespace } : {}), ...(app ? { app } : {}) })
return () => { active = false }
}, [sourceId, namespace, app])
function setLabel(name: PolicyLabel, value: string) {
const next = { ...labels }
if (value) next[name] = value
else delete next[name]
if (name === 'namespace') { delete next.app; delete next.pod }
if (name === 'app') delete next.pod
onChange(next)
}
return <div className="grid gap-2 sm:grid-cols-3 flex-1" aria-label={`${username} 查询范围`}>
{policyLabels.map(name => <SearchableValueSelect key={name} label={name === 'namespace' ? '命名空间' : name === 'app' ? 'App' : '副本 / Pod'} values={options[name]} value={labels[name] ?? ''} onChange={value => setLabel(name, value)} allowEmpty loading={loading[name]} disabled={!sourceId} />)}
</div>
}
@@ -0,0 +1,11 @@
function escapeRegExp(text: string) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
export function HighlightedLogLine({ message, terms, className = '' }: { message: string; terms: string[]; className?: string }) {
if (!terms.length) return <pre className={className}>{message}</pre>
const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'g')
const pieces = message.split(pattern)
const matches = new Set(terms)
return <pre className={className}>{pieces.map((piece, index) => matches.has(piece)
? <mark key={index} className="rounded-sm bg-warning/30 px-0.5 text-foreground">{piece}</mark>
: piece)}</pre>
}
@@ -0,0 +1,62 @@
import { useMemo, useState } from 'react'
import type { IngressSample, IngressRouteStats } from '../ingress-insights'
import { summarizeIngress } from '../ingress-insights'
type Mode = 'traffic' | 'errors' | 'slow' | 'detail'
type ErrorClass = 'all' | '5xx' | '4xx'
const modes: { id: Mode; label: string }[] = [
{ id: 'traffic', label: '接口 PV / UV' },
{ id: 'errors', label: '异常 Top 10' },
{ id: 'slow', label: '慢请求 Top 10' },
{ id: 'detail', label: '请求明细' },
]
const heading = 'px-3 py-2 text-left font-medium whitespace-nowrap'
const cell = 'px-3 py-2 tabular-nums'
const formatSeconds = (seconds: number | null) => seconds === null ? '—' : `${seconds.toFixed(3)} s`
const routeKey = (route: IngressRouteStats) => `${route.domain}\u0000${route.path}`
function Route({ route }: { route: IngressRouteStats }) {
return <><td className={`${cell} max-w-48 truncate`} title={route.domain}>{route.domain}</td>
<td className={`${cell} max-w-96 truncate font-mono`} title={route.path}>{route.path}</td></>
}
export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) {
const [mode, setMode] = useState<Mode>('traffic')
const [errorClass, setErrorClass] = useState<ErrorClass>('all')
const [slowThreshold, setSlowThreshold] = useState(1)
const summary = useMemo(() => summarizeIngress(samples, slowThreshold), [samples, slowThreshold])
const traffic = useMemo(() => [...summary.routes].sort((a, b) => b.pv - a.pv || b.error5xx - a.error5xx).slice(0, 10), [summary.routes])
const errors = useMemo(() => [...summary.routes]
.filter(route => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx)
.sort((a, b) => {
const count = (route: IngressRouteStats) => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx
return count(b) - count(a) || b.error5xx - a.error5xx
}).slice(0, 10), [summary.routes, errorClass])
const slow = useMemo(() => [...summary.routes].filter(route => route.slow || route.timeoutSignals)
.sort((a, b) => b.slow - a.slow || b.timeoutSignals - a.timeoutSignals || (b.maxSeconds ?? 0) - (a.maxSeconds ?? 0)).slice(0, 10), [summary.routes])
return <div className="space-y-3">
<p className="text-xs text-muted-foreground">仅统计当前页已加载的 {loadedCount} 条日志,其中 {samples.length} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['PV', summary.pv], ['UV(IP)', summary.uv], ['4xx / 5xx', summary.errors], [`慢请求 ≥ ${slowThreshold}s`, summary.slow],
].map(([label, value]) => <div key={label} className="rounded-lg border bg-muted/20 px-3 py-2"><div className="text-xs text-muted-foreground">{label}</div><div className="text-lg font-semibold tabular-nums">{value}</div></div>)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="Ingress 统计方式" className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
{modes.map(item => <button key={item.id} type="button" role="tab" aria-selected={mode === item.id} onClick={() => setMode(item.id)} className={`rounded px-2.5 py-1.5 text-xs ${mode === item.id ? 'bg-card font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{item.label}</button>)}
</div>
{mode === 'errors' && <select aria-label="异常状态码范围" value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">全部 4xx / 5xx</option><option value="5xx">只看 5xx</option><option value="4xx">只看 4xx</option></select>}
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">慢请求阈值 <select aria-label="慢请求阈值" value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>0.5 秒</option><option value={1}>1 秒</option><option value={3}>3 秒</option><option value={5}>5 秒</option><option value={10}>10 秒</option></select></label>}
</div>
{mode === 'slow' && <p className="text-xs text-muted-foreground">依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。{summary.timed < summary.pv && ` ${summary.pv - summary.timed} 条缺少有效耗时。`}</p>}
{mode === 'traffic' && <p className="text-xs text-muted-foreground">访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。</p>}
<div className="max-h-[60vh] overflow-auto rounded-lg border">
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', 'PV', 'UV(IP)', '成功率', '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '4xx', '5xx', '异常率', '状态码'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '慢请求', '最长耗时', '平均耗时', '408 / 499 / 504'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', '请求 Host', '接口路径', '客户端 IP', '状态', '耗时'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">当前页没有符合条件的请求</p>}
</div>
</div>
}
@@ -0,0 +1,45 @@
import { Crosshair } from 'lucide-react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { LogEntry } from '@/api/logs'
import { HighlightedLogLine } from './HighlightedLogLine'
export type LogContext = { row: LogEntry; before: LogEntry[]; after: LogEntry[] }
function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean; terms: string[] }) {
const match = row.message.match(/^\d{4}-\d\d-\d\dT\S+\s+(stdout|stderr)\s+[FP]\s+([\s\S]*)$/)
return <article className={`border-l-[3px] px-3 py-2 ${current ? 'border-l-warning bg-warning/15' : 'border-l-transparent bg-card'}`}>
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
{current && <span className="inline-flex items-center gap-1 rounded bg-warning px-2 py-0.5 font-semibold text-warning-foreground"><Crosshair className="size-3" />当前日志</span>}
<time className="font-mono tabular-nums text-muted-foreground">{new Date(row.timestamp).toLocaleString()}</time>
{match && <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground">{match[1]}</span>}
{row.level && <span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{row.level}</span>}
</div>
<HighlightedLogLine message={match?.[2] ?? row.message} terms={terms} className="m-0 whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground" />
</article>
}
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b px-5 py-4 pr-12">
<DialogTitle>同一日志流的前后文</DialogTitle>
<DialogDescription>{context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟</DialogDescription>
{context && <div className="flex flex-wrap gap-1.5 pt-1 text-xs">
{['namespace', 'app', 'pod'].filter(key => context.row.labels[key]).map(key => <span key={key} className="rounded bg-muted px-2 py-1 font-mono text-foreground">{key}={context.row.labels[key]}</span>)}
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">文件: {context.row.labels.filename.split('/').slice(-2).join('/')}</span>}
</div>}
</DialogHeader>
{context && <div className="space-y-3 overflow-y-auto bg-muted/20 px-5 py-4">
<section aria-label="之前的日志">
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之前 · {context.before.length} 条</h3>
{context.before.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.before.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此前 5 分钟内无日志</p>}
</section>
<section aria-label="当前日志" className="overflow-hidden rounded-lg border border-warning/70 shadow-sm"><ContextRow row={context.row} current terms={terms} /></section>
<section aria-label="之后的日志">
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之后 · {context.after.length} 条</h3>
{context.after.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.after.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此后 5 分钟内无日志</p>}
</section>
</div>}
</DialogContent>
</Dialog>
}
@@ -0,0 +1,68 @@
import { memo } from 'react'
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
import type { HistogramBucket } from '@/api/logs'
interface LogHistogramProps {
buckets: HistogramBucket[]
loading: boolean
/** 点击柱体 → 缩放到相邻 bucket 时间范围 */
onZoomRange: (start: number, end: number) => void
}
/** 日志量直方图(服务端 count_over_time 聚合);点击柱体缩放时间范围 */
export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoomRange }: LogHistogramProps) {
const data = buckets.map((b) => ({
ts: b.timestamp.slice(11, 16),
count: b.count,
raw: new Date(b.timestamp).getTime(),
}))
if (loading && data.length === 0) {
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">统计中…</div>
}
if (data.length === 0) {
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">暂无日志量数据</div>
}
const handleClick = (idx: number) => {
const cur = data[idx]?.raw
if (!cur) return
const step = data.length > 1 ? Math.max(data[1].raw - data[0].raw, 60000) : 300000
onZoomRange(cur - step / 2, cur + step / 2)
}
return (
<div className="h-20">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
onClick={(state: { activeTooltipIndex?: number }) => {
if (state?.activeTooltipIndex !== undefined) handleClick(state.activeTooltipIndex)
}}
>
<defs>
<linearGradient id="logVol" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="ts" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" width={28} allowDecimals={false} />
<Tooltip
contentStyle={{ fontSize: 11, borderRadius: 8, background: 'hsl(var(--popover))', border: '1px solid hsl(var(--border))' }}
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
/>
<Area
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
fill="url(#logVol)"
strokeWidth={1.5}
cursor="pointer"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
})
@@ -0,0 +1,125 @@
import { useMemo, useState } from 'react'
import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import type { LogEntry } from '@/api/logs'
import { httpStatusClass, parseIngressAccessLog, pathWithoutQuery, stripLogEnvelope } from '../ingress-access-log'
import { IngressInsightsTable } from './IngressInsightsTable'
export type ResultView = 'raw' | 'table' | 'line' | 'stream' | 'pie'
type Bucket = { timestamp: string; count: number }
const colors = ['hsl(var(--info))', 'hsl(var(--success))', 'hsl(var(--warning))', 'hsl(var(--primary))', 'hsl(var(--destructive))', 'hsl(var(--muted-foreground))']
function EmptyChart({ message }: { message: string }) {
return <div className="flex h-64 items-center justify-center rounded-lg bg-muted/20 text-sm text-muted-foreground">{message}</div>
}
export function LogResultViews({ view, entries, buckets, start, end, domainFiltered = false }: {
view: Exclude<ResultView, 'raw'>
entries: LogEntry[]
buckets: Bucket[]
start: string
end: string
domainFiltered?: boolean
}) {
const [groupBy, setGroupBy] = useState<'domain' | 'status' | 'method' | 'path' | 'upstream' | 'app' | 'namespace' | 'pod' | 'level'>('domain')
const [lineBy, setLineBy] = useState<'domain' | 'total'>('domain')
const [streamBy, setStreamBy] = useState<'domain' | 'status'>('domain')
const parsedRows = useMemo(() => entries.map(entry => parseIngressAccessLog(entry.message)), [entries])
const ingressCount = useMemo(() => parsedRows.filter(Boolean).length, [parsedRows])
const ingressSamples = useMemo(() => entries.flatMap((entry, index) => {
const access = parsedRows[index]
return access ? [{ access, timestamp: entry.timestamp }] : []
}), [entries, parsedRows])
const streamDomains = useMemo(() => {
const counts = new Map<string, number>()
for (const row of parsedRows) if (row) {
const domain = row.domain || '未记录域名'
counts.set(domain, (counts.get(domain) ?? 0) + 1)
}
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain)
}, [parsedRows])
const effectiveGroupBy = ingressCount ? groupBy : ['domain', 'status', 'method', 'path', 'upstream'].includes(groupBy) ? 'app' : groupBy
const lineData = useMemo(() => {
if (!domainFiltered && (lineBy === 'total' || !ingressCount)) return buckets.map(bucket => ({ time: new Date(bucket.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: bucket.count }))
const from = new Date(start).getTime(), to = new Date(end).getTime()
if (!(from < to)) return []
const steps = 16
const counts: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: 0 }))
for (const [rowIndex, entry] of entries.entries()) {
const index = Math.floor((new Date(entry.timestamp).getTime() - from) / (to - from) * steps)
if (index < 0 || index >= steps) continue
if (lineBy === 'domain' && ingressCount) {
const row = parsedRows[rowIndex]
if (!row) continue
const domain = row.domain || '未记录域名'
const key = streamDomains.includes(domain) ? domain : '其他域名'
counts[index][key] = Number(counts[index][key] ?? 0) + 1
} else counts[index].count = Number(counts[index].count) + 1
}
return counts
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains])
const streamData = useMemo(() => {
const from = new Date(start).getTime(), to = new Date(end).getTime()
if (!(from < to)) return []
const steps = 16
const rows: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), error: 0, warn: 0, info: 0, other: 0, '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 }))
for (const [rowIndex, entry] of entries.entries()) {
const time = new Date(entry.timestamp).getTime()
const index = Math.floor((time - from) / (to - from) * steps)
if (index < 0 || index >= steps) continue
const ingress = parsedRows[rowIndex]
if (ingressCount) {
if (ingress) {
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || '未记录域名') ? ingress.domain || '未记录域名' : '其他域名') : httpStatusClass(ingress.status)
rows[index][key] = Number(rows[index][key] ?? 0) + 1
}
continue
}
const level = entry.level.toLowerCase()
const key = level === 'error' || level === 'warn' || level === 'info' ? level : 'other'
rows[index][key] = Number(rows[index][key] ?? 0) + 1
}
return rows
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end])
const pieData = useMemo(() => {
const counts = new Map<string, number>()
for (const [index, entry] of entries.entries()) {
const ingress = parsedRows[index]
if (['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) && !ingress) continue
const name = effectiveGroupBy === 'domain' ? ingress?.domain || '未记录域名'
: effectiveGroupBy === 'status' ? ingress?.status || '未知'
: effectiveGroupBy === 'method' ? ingress?.method || '未知'
: effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/')
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || '未标记'
: effectiveGroupBy === 'level' ? entry.level || 'unknown'
: entry.labels[effectiveGroupBy] || '未标记'
counts.set(name, (counts.get(name) ?? 0) + 1)
}
const sorted = [...counts].sort((a, b) => b[1] - a[1])
const top = sorted.slice(0, 7).map(([name, count]) => ({ name, count }))
const other = sorted.slice(7).reduce((sum, [, count]) => sum + count, 0)
if (other) top.push({ name: '其他', count: other })
return top
}, [entries, parsedRows, effectiveGroupBy])
if (view === 'table') return ingressCount > 0
? <IngressInsightsTable samples={ingressSamples} loadedCount={entries.length} />
: <div>
<p className="mb-2 text-xs text-muted-foreground">当前页已加载 {entries.length} 条日志。</p>
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志" /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', 'namespace / pod', '级别', '日志'].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
</div>
if (view === 'line') return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? '按当前已加载日志统计;更多结果请继续加载。' : '按查询时间范围统计的日志量,来自 Loki 时间聚合。'}</p>{ingressCount > 0 && <select aria-label="折线图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">按域名</option><option value="total">总量</option></select>}</div>
{lineData.length === 0 ? <EmptyChart message="当前查询没有可用的时间统计" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, '其他域名'].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name="日志条数" stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
</div>
if (view === 'stream') return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? `展示 ${ingressCount} 条已解析日志` : `按级别展示当前已加载的 ${entries.length} 条日志`},基于当前已加载结果。</p>{ingressCount > 0 && <select aria-label="流图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">按域名</option><option value="status">按 HTTP 状态段</option></select>}</div>
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志流" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, '其他域名'] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
</div>
return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? `按访问字段统计已解析的 ${ingressCount} 条日志` : `按标签统计当前已加载的 ${entries.length} 条日志`}</p><select aria-label="饼图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">域名(请求 Host)</option><option value="status">HTTP 状态码</option><option value="method">请求方法</option><option value="path">请求路径</option><option value="upstream">上游服务</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">级别</option></select></div>
{pieData.length === 0 ? <EmptyChart message="暂无可展示的标签分布" /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
</div>
}
@@ -0,0 +1,212 @@
import { memo, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { LogEntry } from '@/api/logs'
import { applyLabelFilter } from '../query-utils'
// 级别 → 颜色(语义 token)
const LEVEL_COLOR: Record<string, string> = {
error: 'text-destructive',
warn: 'text-warning',
info: 'text-success',
debug: 'text-muted-foreground',
}
/** 从日志行提取 k=v / JSON 字段(点击过滤用) */
function detectFields(message: string): Array<[string, string]> {
const out: Array<[string, string]> = []
const trimmed = message.trim()
if (trimmed.startsWith('{')) {
try {
const obj = JSON.parse(trimmed)
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
out.push([k, String(v)])
}
}
return out.slice(0, 12)
} catch {
/* 非 JSON 继续 */
}
}
const re = /([a-zA-Z_][\w.-]*)=("[^"]*"|\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(message)) !== null && out.length < 12) {
out.push([m[1], m[2].replace(/^"|"$/g, '')])
}
return out
}
interface LogResultsProps {
entries: LogEntry[]
loading: boolean
hasMore: boolean
onLoadMore: () => void
/** 点击标签过滤(改写查询) */
onFilter: (nextQuery: string) => void
query: string
}
/**
* 日志结果区(Grafana 风格裁剪版):级别着色、行展开(原文 + labels + 提取字段,
* 点击即过滤/排除)、去重开关
*/
export const LogResults = memo(function LogResults({
entries,
loading,
hasMore,
onLoadMore,
onFilter,
query,
}: LogResultsProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [dedup, setDedup] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
const display = useMemo(() => {
if (!dedup) return entries
const seen = new Set<string>()
return entries.filter((e) => {
const k = e.message.replace(/\d+/g, '#')
if (seen.has(k)) return false
seen.add(k)
return true
})
}, [entries, dedup])
const toggle = (key: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
// 公共标签(所有结果共有)
const commonLabels = useMemo(() => {
if (entries.length === 0) return [] as string[]
let keys = Object.keys(entries[0].labels)
for (const e of entries) {
keys = keys.filter((k) => e.labels[k] !== undefined && e.labels[k] === entries[0].labels[k])
}
return keys
}, [entries])
if (loading && entries.length === 0) {
return <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">查询中…</div>
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{display.length} 条{dedup && `(去重后,原 ${entries.length} 条)`}
{commonLabels.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{commonLabels.slice(0, 6).map((k) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted">
{k}={entries[0].labels[k]}
</span>
))}
</span>
)}
</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={dedup} onChange={(e) => setDedup(e.target.checked)} className="accent-primary" />
去重
</label>
</div>
<div ref={listRef} className="rounded-lg border border-border divide-y divide-border max-h-[52vh] overflow-y-auto bg-card">
{display.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">没有匹配的日志</div>
) : (
display.map((e, idx) => {
const key = `${e.timestamp}-${idx}`
const open = expanded.has(key)
const fields = open ? detectFields(e.message) : []
return (
<div key={key} className="text-xs">
<button
type="button"
onClick={() => toggle(key)}
className="w-full text-left flex items-start gap-2 px-3 py-1.5 hover:bg-muted/50"
>
{open ? (
<ChevronDown className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
)}
<span className="text-muted-foreground flex-shrink-0 tabular-nums">{e.timestamp.slice(11, 23)}</span>
<span className={cn('font-semibold flex-shrink-0 uppercase w-10', LEVEL_COLOR[e.level] || 'text-muted-foreground')}>
{e.level}
</span>
<span className="font-mono break-all min-w-0">{e.message.slice(0, 500)}</span>
</button>
{open && (
<div className="px-3 pb-3 pl-10 space-y-2">
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">日志原文</p>
<pre className="p-2 rounded bg-muted/60 font-mono whitespace-pre-wrap break-all">{e.message}</pre>
</div>
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">标签(点击过滤)</p>
<div className="flex flex-wrap gap-1">
{Object.entries(e.labels).map(([k, v]) => (
<span key={k} className="group inline-flex items-center rounded bg-muted px-1.5 py-0.5">
<button
type="button"
className="hover:text-primary"
title="过滤此值"
onClick={() => onFilter(applyLabelFilter(query, k, v))}
>
{k}={v}
</button>
<button
type="button"
className="ml-1 opacity-0 group-hover:opacity-100 text-destructive"
title="排除此值"
onClick={() => onFilter(applyLabelFilter(query, k, v, true))}
>
×
</button>
</span>
))}
</div>
</div>
{fields.length > 0 && (
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">提取字段</p>
<div className="flex flex-wrap gap-1">
{fields.map(([k, v]) => (
<span key={k} className="inline-flex items-center rounded bg-info/10 text-info px-1.5 py-0.5">
<button type="button" className="hover:underline" onClick={() => onFilter(applyLabelFilter(query, k, v))}>
{k}={v}
</button>
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})
)}
</div>
{hasMore && (
<button
type="button"
onClick={onLoadMore}
className="w-full py-2 text-xs text-primary hover:bg-primary/5 rounded-lg border border-border"
disabled={loading}
>
{loading ? '加载中…' : '加载更多'}
</button>
)}
</div>
)
})
@@ -0,0 +1,72 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { LogEntry } from '@/api/logs'
import { parseIngressAccessLog } from '../ingress-access-log'
import { applyLabelFilter } from '../query-utils'
import { HighlightedLogLine } from './HighlightedLogLine'
import { LogResultViews, type ResultView } from './LogResultViews'
import { QueryEmptyState } from './QueryEmptyState'
type Bucket = { timestamp: string; count: number }
const views: { id: ResultView; label: string }[] = [
{ id: 'raw', label: '原始日志' }, { id: 'table', label: '表格' },
{ id: 'line', label: '折线图' }, { id: 'stream', label: '流图' }, { id: 'pie', label: '饼图' },
]
export function LogResultsPanel({ entries, buckets, start, end, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
entries: LogEntry[]; buckets: Bucket[]; start: string; end: string; searched: boolean; hasSource: boolean
pageIndex: number; hasNextPage: boolean; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[]
onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void
}) {
const [view, setView] = useState<ResultView>('raw')
const [domainFilter, setDomainFilter] = useState('')
const [clock, setClock] = useState(0)
useEffect(() => {
if (!loading) return
const timer = window.setInterval(() => setClock(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [loading])
const domains = useMemo(() => [...new Set(entries.map(entry => parseIngressAccessLog(entry.message)?.domain).filter((domain): domain is string => Boolean(domain)))].sort(), [entries])
const visibleEntries = useMemo(() => {
const needle = domainFilter.trim().toLowerCase()
return needle ? entries.filter(entry => parseIngressAccessLog(entry.message)?.domain.includes(needle)) : entries
}, [entries, domainFilter])
return <section className="rounded-xl border bg-card p-4" aria-label="日志结果">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">日志结果 <span className="text-sm font-normal text-muted-foreground">第 {pageIndex + 1} 页 · 本页 {entries.length} 条{hasNextPage && ' · 还有更多'}</span></h2>
<div role="tablist" aria-label="日志展示方式" className="flex flex-wrap gap-1 rounded-lg bg-muted/60 p-1">
{views.map(item => <button key={item.id} role="tab" aria-selected={view === item.id} type="button" className={`rounded px-2.5 py-1.5 text-xs transition-colors ${view === item.id ? 'bg-card font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`} onClick={() => setView(item.id)}>{item.label}</button>)}
</div>
</div>
{loading && <div role="status" aria-live="polite" className="mb-3 flex items-center gap-2 rounded-md border border-info/30 bg-info/5 px-3 py-2 text-sm text-foreground">
<Loader2 className="size-4 shrink-0 animate-spin text-info" />
<span>{entries.length ? '正在加载日志页' : '正在查询 Loki'} · 已等待 {clock && loadingStartedAt ? Math.max(0, Math.floor((clock - loadingStartedAt) / 1000)) : 0} 秒。大范围查询可能需要较长时间。</span>
</div>}
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。</p>}
{domains.length > 0 && <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<label htmlFor="log-domain-filter" className="shrink-0 text-muted-foreground">域名筛选</label>
<Input id="log-domain-filter" list="log-domain-options" type="search" className="h-8 w-60" value={domainFilter} onChange={event => setDomainFilter(event.target.value)} placeholder="输入或选择域名" />
<datalist id="log-domain-options">{domains.map(domain => <option key={domain} value={domain} />)}</datalist>
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>在 Loki 中查询</Button>
<span className="text-muted-foreground">仅匹配日志记录的请求 Host。当前 {visibleEntries.length} / {entries.length} 条。</span>
</div>}
{view === 'raw' ? entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">当前域名筛选没有匹配的日志</p> : <div className="divide-y">
{visibleEntries.map((row, index) => <div key={`${row.timestampNs}-${index}`} className="py-2 text-sm">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
<span className="rounded bg-muted px-1">{row.level}</span>
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title="筛选此标签" className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>查看前后各 5 条</button>
</div>
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
</div>)}
</div> : <LogResultViews view={view} entries={visibleEntries} buckets={buckets} start={start} end={end} domainFiltered={Boolean(domainFilter.trim())} />}
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>上一页</Button>
<span className="text-xs tabular-nums text-muted-foreground">第 {pageIndex + 1} 页</span>
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>下一页</Button>
</div>}
</section>
}
@@ -0,0 +1,65 @@
import { useRef, useState } from 'react'
type Bucket = { timestamp: string; count: number }
const formatTime = (timestamp: string) => new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const formatCount = (count: number) => new Intl.NumberFormat('zh-CN').format(count)
export function LogVolumeTimeline({ buckets, onSelect }: {
buckets: Bucket[]
onSelect: (start: number, end: number) => void
}) {
const [hover, setHover] = useState<number | null>(null)
const [selection, setSelection] = useState<[number, number] | null>(null)
const startIndex = useRef<number | null>(null)
const max = Math.max(1, ...buckets.map(bucket => bucket.count))
const total = buckets.reduce((sum, bucket) => sum + bucket.count, 0)
const timestamps = buckets.map(bucket => new Date(bucket.timestamp).getTime())
const step = timestamps.length > 1 ? Math.max(1000, timestamps[1] - timestamps[0]) : 60000
function indexAt(clientX: number, element: HTMLElement) {
const bounds = element.getBoundingClientRect()
return Math.max(0, Math.min(buckets.length - 1, Math.floor((clientX - bounds.left) / bounds.width * buckets.length)))
}
function finish(index: number) {
if (startIndex.current === null) return
const from = Math.min(startIndex.current, index)
const to = Math.max(startIndex.current, index)
startIndex.current = null
setSelection([from, to])
onSelect(timestamps[from], timestamps[to] + step)
}
if (buckets.length === 0) return null
return <section className="rounded-xl border bg-card p-4" aria-label="日志量时间线">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<div><h2 className="text-sm font-semibold">日志量时间线</h2><p className="text-xs text-muted-foreground">悬停查看数量,按住任意高度拖选时间;选择后自动查询。</p></div>
<span className="text-sm tabular-nums">时间桶合计 <strong>{formatCount(total)}</strong> 条</span>
</div>
<div className="flex gap-2">
<div className="relative h-44 min-w-0 flex-1 select-none touch-none cursor-crosshair"
onPointerDown={event => { event.currentTarget.setPointerCapture(event.pointerId); const index = indexAt(event.clientX, event.currentTarget); startIndex.current = index; setSelection([index, index]); setHover(index) }}
onPointerMove={event => { const index = indexAt(event.clientX, event.currentTarget); setHover(index); if (startIndex.current !== null) setSelection([Math.min(startIndex.current, index), Math.max(startIndex.current, index)]) }}
onPointerUp={event => finish(indexAt(event.clientX, event.currentTarget))}
onPointerCancel={() => { startIndex.current = null; setSelection(null) }}
onPointerLeave={() => { if (startIndex.current === null) setHover(null) }}
>
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between"><span className="border-t border-dashed" /><span className="border-t border-dashed" /><span className="border-t border-dashed" /></div>
<div className="pointer-events-none absolute inset-0 flex items-end gap-px">
{buckets.map((bucket, index) => <div key={`${bucket.timestamp}-${index}`} className="flex h-full min-w-0 flex-1 items-end">
<span className={`w-full min-h-0 rounded-t-sm ${selection && index >= selection[0] && index <= selection[1] ? 'bg-primary' : index === hover ? 'bg-info' : 'bg-info/40'}`} style={{ height: `${Math.max(2, bucket.count / max * 100)}%` }} />
</div>)}
</div>
{hover !== null && <div className="pointer-events-none absolute z-10 top-2 rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-sm" style={{ left: `${Math.min(78, Math.max(2, (hover + 0.5) / buckets.length * 100))}%` }}>
{new Date(buckets[hover].timestamp).toLocaleString()} · <strong>{formatCount(buckets[hover].count)} 条</strong>
</div>}
</div>
<div className="flex h-44 w-14 flex-col justify-between text-right text-xs tabular-nums text-muted-foreground"><span>{formatCount(max)}</span><span>{formatCount(Math.round(max / 2))}</span><span>0</span></div>
</div>
<div className="mr-16 mt-2 flex justify-between text-xs tabular-nums text-muted-foreground">
<span>{formatTime(buckets[0].timestamp)}</span><span>{formatTime(buckets[Math.floor((buckets.length - 1) / 2)].timestamp)}</span><span>{formatTime(buckets[buckets.length - 1].timestamp)}</span>
</div>
</section>
}
@@ -0,0 +1,71 @@
import { useEffect, useMemo, useState } from 'react'
import { ChevronDown, ChevronRight, Tags } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { logsApi } from '@/api/logs'
import { applyLabelFilter, extractSelector } from '../query-utils'
function currentLabels(query: string): Record<string, string> {
const result: Record<string, string> = {}
for (const match of extractSelector(query).matchAll(/([a-zA-Z_][\w]*)\s*=\s*("(?:\\.|[^"\\])*")/g)) {
try { result[match[1]] = JSON.parse(match[2]) as string } catch { /* Ignore incomplete input. */ }
}
return result
}
function streamSelector(labels: Record<string, string>) {
return `{${Object.entries(labels).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}`
}
export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }: { sourceId: number; query: string; onQueryChange: (query: string) => void; start: string; end: string }) {
const [open, setOpen] = useState(false)
const [labels, setLabels] = useState<string[]>([])
const [activeLabel, setActiveLabel] = useState('')
const [values, setValues] = useState<string[]>([])
const [filter, setFilter] = useState('')
const [loadingLabels, setLoadingLabels] = useState(false)
const [loadingValues, setLoadingValues] = useState(false)
const [scoped, setScoped] = useState(false)
const selected = useMemo(() => currentLabels(query), [query])
const startNs = Number.isFinite(new Date(start).getTime()) ? new Date(start).getTime() * 1e6 : undefined
const endNs = Number.isFinite(new Date(end).getTime()) ? new Date(end).getTime() * 1e6 : undefined
useEffect(() => {
if (!open || !sourceId) return
let active = true
logsApi.labels(sourceId, startNs, endNs).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) })
return () => { active = false }
}, [open, sourceId, startNs, endNs])
useEffect(() => {
if (!open || !sourceId || !activeLabel) return
let active = true
const scope = { ...selected }
delete scope[activeLabel]
logsApi.labelValues(sourceId, activeLabel, scoped && Object.keys(scope).length ? streamSelector(scope) : '', startNs, endNs)
.then(next => { if (active) setValues(next) }).catch(() => { if (active) setValues([]) }).finally(() => { if (active) setLoadingValues(false) })
return () => { active = false }
}, [open, sourceId, activeLabel, selected, scoped, startNs, endNs])
const shown = values.filter(value => value.toLowerCase().includes(filter.toLowerCase())).slice(0, 200)
return <section className="overflow-hidden rounded-lg border bg-card" aria-label="Label 浏览器">
<button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
<Tags className="size-4 text-info" />Label 浏览器
{Object.keys(selected).length > 0 && <span className="rounded bg-info/10 px-2 py-0.5 text-xs text-info">已选 {Object.keys(selected).length}</span>}
</button>
{open && <div className="grid min-h-44 border-t sm:grid-cols-[minmax(150px,220px)_1fr]">
<div className="max-h-64 overflow-auto border-b p-1 sm:border-b-0 sm:border-r">
{loadingLabels ? <p className="px-3 py-4 text-sm text-muted-foreground">加载标签中…</p> : labels.length === 0 ? <p className="px-3 py-4 text-sm text-muted-foreground">暂无可用标签</p> : labels.map(label => <button key={label} type="button" className={`block w-full rounded px-3 py-2 text-left font-mono text-sm ${activeLabel === label ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onClick={() => { if (label !== activeLabel) { setActiveLabel(label); setValues([]); setLoadingValues(true) } setFilter('') }}>{label}{selected[label] && <span className="ml-1 text-xs text-success">✓</span>}</button>)}
</div>
<div className="min-w-0 p-3">
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">选择左侧标签,再搜索对应的值</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{activeLabel} 的值</strong><Input aria-label={`搜索 ${activeLabel} 的值`} className="h-8 max-w-sm" placeholder="搜索或输入标签值" value={filter} onChange={event => setFilter(event.target.value)} /></div>
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />按当前查询条件缩小候选</label>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, value))} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, filter.trim()))}>使用“{filter.trim()}”作为值</button>}
<p className="mt-2 text-xs text-muted-foreground">候选来自当前时间范围内已写入 Loki 的日志;Kubernetes 存在的命名空间未必有日志。授权范围仍由服务端限制。</p>
</>}
</div>
</div>}
</section>
}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { ErrorState } from '@/components/ErrorState'
import { logsApi, type LogPolicyGroup, type LogSource } from '@/api/logs'
import { userApi } from '@/api/user'
import type { User } from '@/types'
import { AccessRuleEditor } from './AccessRuleEditor'
type PolicyForm = Omit<LogPolicyGroup, 'id'> & { id?: number }
const emptyForm = (sourceId: number): PolicyForm => ({ name: '', sourceId, labels: {}, userIds: [] })
export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
const [groups, setGroups] = useState<LogPolicyGroup[]>([])
const [users, setUsers] = useState<User[]>([])
const [form, setForm] = useState<PolicyForm>(() => emptyForm(sources[0]?.id ?? 0))
const [filter, setFilter] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<LogPolicyGroup | null>(null)
async function reload() {
setLoading(true)
try {
const [policies, firstPage] = await Promise.all([logsApi.policyGroups(), userApi.list({ page: 1, pageSize: 100 })])
const allUsers = [...firstPage.list]
for (let page = 2; allUsers.length < firstPage.total; page++) {
const next = await userApi.list({ page, pageSize: 100 })
allUsers.push(...next.list)
if (next.list.length === 0) break
}
setGroups(policies)
setUsers(allUsers.filter(user => user.role !== 'admin'))
setError('')
} catch (err) { setError(err instanceof Error ? err.message : '加载查询策略失败') }
finally { setLoading(false) }
}
useEffect(() => { void reload() }, [])
useEffect(() => { if (!form.sourceId && sources[0]?.id) setForm(current => ({ ...current, sourceId: sources[0].id })) }, [sources, form.sourceId])
async function save() {
if (!form.name.trim() || !form.sourceId || form.userIds.length === 0) {
toast.error('请填写策略组名称,选择数据源和至少一位用户')
return
}
setSaving(true)
try {
const body = { name: form.name.trim(), sourceId: form.sourceId, labels: form.labels, userIds: form.userIds }
if (form.id) await logsApi.updatePolicyGroup(form.id, body)
else await logsApi.createPolicyGroup(body)
await reload()
setForm(emptyForm(form.sourceId))
} catch (err) { toast.error(err instanceof Error ? err.message : '保存策略组失败') }
finally { setSaving(false) }
}
async function remove() {
if (!deleteTarget) return
setSaving(true)
try {
await logsApi.deletePolicyGroup(deleteTarget.id)
setDeleteTarget(null)
if (form.id === deleteTarget.id) setForm(emptyForm(form.sourceId))
await reload()
} catch (err) { toast.error(err instanceof Error ? err.message : '删除策略组失败') }
finally { setSaving(false) }
}
const visibleUsers = users.filter(user => user.username.toLowerCase().includes(filter.toLowerCase()) || (user.nickname ?? '').toLowerCase().includes(filter.toLowerCase()))
const assignedElsewhere = new Set(groups.filter(group => group.sourceId === form.sourceId && group.id !== form.id).flatMap(group => group.userIds))
const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? `数据源 #${id}`
return <div className="grid gap-6 xl:grid-cols-[minmax(240px,0.85fr)_minmax(0,2fr)]">
<aside className="rounded-xl border bg-card p-5 space-y-4">
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">查询策略组</h2><Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}>新建</Button></div>
<p className="text-sm text-muted-foreground">一套日志范围可关联多个用户,后续修改会同时生效。</p>
{loading && <p className="text-sm text-muted-foreground">加载中…</p>}
{error && <ErrorState message={error} onRetry={() => void reload()} />}
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">还没有策略组</p>}
<div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}>
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.length} 位用户</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
</button>)}</div>
</aside>
<section className="rounded-xl border bg-card p-5 space-y-6">
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? '编辑策略组' : '新建策略组'}</h2><p className="mt-1 text-sm text-muted-foreground">选择数据源及允许的日志范围,再关联需要复用此策略的用户。</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>删除</Button>}</div>
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">策略组名称<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder="例如:生产环境 Agent 日志" /></label><label className="text-sm font-medium">数据源<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>请先添加数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
<div><h3 className="mb-2 text-sm font-medium">允许查询的日志范围</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || '策略组'} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。</p></div>
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">关联用户 <span className="text-muted-foreground">已选 {form.userIds.length} 位</span></h3><div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>选择当前筛选</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>清空</Button></div></div><p className="text-xs text-muted-foreground">同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。</p><Input aria-label="搜索用户" value={filter} onChange={event => setFilter(event.target.value)} placeholder="搜索用户名或昵称" /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">已分组</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">没有匹配用户</p>}</div></div>
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? '保存中…' : '保存策略组'}</Button></div>
</section>
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title="删除查询策略组" description={`删除「${deleteTarget?.name ?? ''}」后,关联用户将失去由该组授予的数据源访问权限。`} loading={saving} onConfirm={() => void remove()} />
</div>
}
@@ -0,0 +1,11 @@
import { Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function QueryEmptyState({ searched, hasSource, onRetry }: { searched: boolean; hasSource: boolean; onRetry: () => void }) {
return <div className="flex min-h-56 flex-col items-center justify-center gap-3 rounded-lg bg-muted/20 px-4 text-center">
<div className="rounded-full bg-muted p-3"><Search className="size-6 text-muted-foreground" /></div>
<p className="font-medium">{!hasSource ? '暂无可查询的数据源' : searched ? '未找到匹配的日志记录' : '输入 LogQL 并运行查询'}</p>
<p className="text-sm text-muted-foreground">{!hasSource ? '请联系管理员添加或授权数据源' : searched ? '当前时间范围和筛选条件下没有匹配结果' : '也可以使用 Label 浏览器快速构建查询'}</p>
{hasSource && searched && <Button size="sm" variant="outline" onClick={onRetry}>重新查询</Button>}
</div>
}
@@ -0,0 +1,155 @@
import { memo, useEffect, useRef, useState } from 'react'
import { logsApi } from '@/api/logs'
import { QuickQueryTemplates } from './QuickQueryTemplates'
const PIPE_OPS = ['|= ', '!= ', '|~ ', '!~ ', '| json', '| regexp ', '| line_format ', '| unwrap ', '| pattern ']
const LOGQL_TOKENS = /("(?:[^"\\]|\\.)*")|([a-zA-Z_][\w-]*)(?=\s*(?:=|!=|=~|!~))|(\|\s*(?:json|regexp|line_format|unwrap|pattern|label_format)\b)|\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b|([{}])|(\|=|!=|=~|!~|\|~|=)/g
function colorizedLogQL(query: string) {
const parts: React.ReactNode[] = []
let start = 0
for (const match of query.matchAll(LOGQL_TOKENS)) {
const index = match.index ?? 0
if (index > start) parts.push(query.slice(start, index))
const tone = match[1] ? 'text-success' : match[2] || match[5] ? 'text-warning' : match[3] ? 'text-primary' : match[4] ? 'text-info' : 'text-destructive'
parts.push(<span className={tone} key={index}>{match[0]}</span>)
start = index + match[0].length
}
if (start < query.length) parts.push(query.slice(start))
return parts
}
interface QueryInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
sourceId: number
history: string[]
}
/** LogQL editor with syntax colors and label/operator suggestions. */
export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit, sourceId, history }: QueryInputProps) {
const [labels, setLabels] = useState<string[]>([])
const [hints, setHints] = useState<string[]>([])
const [active, setActive] = useState(0)
const [hintKind, setHintKind] = useState<'label' | 'value' | 'pipe' | null>(null)
const [currentLabel, setCurrentLabel] = useState('')
const editor = useRef<HTMLTextAreaElement>(null)
const overlay = useRef<HTMLPreElement>(null)
const requestVersion = useRef(0)
const root = useRef<HTMLDivElement>(null)
const [assistantOpen, setAssistantOpen] = useState(false)
const [assistantTab, setAssistantTab] = useState<'quick' | 'history'>('quick')
useEffect(() => {
const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setAssistantOpen(false) }
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [])
useEffect(() => {
if (!sourceId) return
let active = true
logsApi.labels(sourceId).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) })
return () => { active = false }
}, [sourceId])
function computeHints(text: string, caret: number) {
const before = text.slice(0, caret)
const valueMatch = before.match(/([a-zA-Z_][\w]*)\s*=\s*"([^"}]*)$/)
if (valueMatch && before.lastIndexOf('{') > before.lastIndexOf('}')) {
const label = valueMatch[1]
const typed = valueMatch[2].toLowerCase()
setCurrentLabel(label)
setHintKind('value')
const version = ++requestVersion.current
const selector = text.match(/\{[^}]*\}/)?.[0] ?? ''
logsApi.labelValues(sourceId, label, selector).then(values => {
if (version === requestVersion.current) { setHints(values.filter(item => item.toLowerCase().includes(typed)).slice(0, 12)); setActive(0) }
}).catch(() => { if (version === requestVersion.current) setHints([]) })
return
}
requestVersion.current++
if (/\|\s*[\w]*$/.test(before)) { setHintKind('pipe'); setHints(PIPE_OPS); setActive(0); return }
if (before.lastIndexOf('{') > before.lastIndexOf('}')) {
const typed = before.slice(before.lastIndexOf('{') + 1).split(/[\s,]/).pop() ?? ''
setHintKind('label')
setHints(labels.filter(label => label.toLowerCase().startsWith(typed.toLowerCase())).slice(0, 12))
setActive(0)
return
}
setHintKind(null); setHints([])
}
function applyHint(hint: string) {
const caret = editor.current?.selectionStart ?? value.length
const before = value.slice(0, caret)
const after = value.slice(caret)
let next = value
let nextCaret = caret
if (hintKind === 'value') {
const prefix = before.replace(/"[^"}]*$/, '')
const quoted = JSON.stringify(hint)
next = prefix + quoted + (after.startsWith('"') ? after.slice(1) : after)
nextCaret = prefix.length + quoted.length
}
if (hintKind === 'label') {
const typed = before.slice(before.lastIndexOf('{') + 1).split(/[\s,]/).pop() ?? ''
const prefix = before.slice(0, before.length - typed.length)
next = prefix + hint + '=' + after
nextCaret = prefix.length + hint.length + 1
}
if (hintKind === 'pipe') {
const prefix = before.replace(/\|\s*[\w]*$/, '')
next = prefix + hint + after
nextCaret = prefix.length + hint.length
}
onChange(next)
setHints([]); setHintKind(null)
requestAnimationFrame(() => {
editor.current?.focus()
editor.current?.setSelectionRange(nextCaret, nextCaret)
})
}
function chooseQuery(next: string) {
onChange(next)
requestAnimationFrame(() => { editor.current?.focus(); setAssistantOpen(false) })
}
return <div ref={root} className="relative min-w-0 flex-1">
<div className="relative h-10 overflow-hidden rounded-md border border-input bg-background focus-within:ring-2 focus-within:ring-ring">
<pre ref={overlay} aria-hidden className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre p-2.5 font-mono text-sm leading-5 text-foreground">{colorizedLogQL(value)}{'\u200b'}</pre>
{!value && <span aria-hidden className="pointer-events-none absolute inset-0 flex items-center px-2.5 font-mono text-sm text-muted-foreground">{'{app="service"} |= "error"'}</span>}
<textarea ref={editor} aria-label="LogQL 查询语句" placeholder={'{app="service"} |= "error"'} value={value} rows={1} wrap="off" spellCheck={false} autoComplete="off"
onFocus={() => setAssistantOpen(true)}
onChange={event => { onChange(event.target.value); computeHints(event.target.value, event.target.selectionStart) }}
onClick={event => computeHints(event.currentTarget.value, event.currentTarget.selectionStart)}
onScroll={() => { if (overlay.current && editor.current) overlay.current.scrollLeft = editor.current.scrollLeft }}
onKeyDown={event => {
if (hints.length && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { event.preventDefault(); setActive(index => (index + (event.key === 'ArrowDown' ? 1 : hints.length - 1)) % hints.length); return }
if (hints.length && (event.key === 'Enter' || event.key === 'Tab')) { event.preventDefault(); applyHint(hints[active]); return }
if (event.key === 'Escape') { setHints([]); setAssistantOpen(false); return }
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); setAssistantOpen(false); onSubmit() }
}}
onBlur={() => window.setTimeout(() => setHints([]), 150)}
className="absolute inset-0 h-full w-full resize-none overflow-auto whitespace-pre bg-transparent p-2.5 font-mono text-sm leading-5 text-transparent caret-foreground outline-none"
/>
</div>
{hints.length > 0 && <div role="listbox" className="absolute left-0 right-0 top-full z-40 mt-1 max-h-56 overflow-auto rounded-md border bg-popover p-1 shadow-lg">
{hintKind === 'value' && <p className="px-2 py-1 text-xs text-muted-foreground">{currentLabel} 的值</p>}
{hints.map((hint, index) => <button role="option" aria-selected={index === active} type="button" key={`${hint}-${index}`} onMouseDown={event => { event.preventDefault(); applyHint(hint) }} className={`block w-full rounded px-2 py-1.5 text-left font-mono text-xs ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}>{hint}</button>)}
</div>}
{assistantOpen && hints.length === 0 && <div className="absolute left-0 top-full z-30 mt-1 w-[min(90vw,760px)] max-w-full rounded-lg border bg-popover p-3 shadow-xl">
<div className="mb-2 flex gap-1 border-b pb-2">
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'quick' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('quick')}>快捷 LogQL</button>
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'history' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('history')}>查询历史 {history.length > 0 && `(${history.length})`}</button>
<span className="ml-auto self-center text-xs text-muted-foreground">Esc 关闭</span>
</div>
{assistantTab === 'quick' ? <QuickQueryTemplates compact sourceId={sourceId} query={value} onQueryChange={chooseQuery} /> :
<div className="max-h-64 overflow-y-auto">
{history.length === 0 ? <p className="px-2 py-5 text-center text-xs text-muted-foreground">运行查询后,常用 LogQL 会出现在这里</p> : history.map((item, index) => <button key={`${item}-${index}`} type="button" className="block w-full truncate rounded px-2 py-2 text-left font-mono text-xs hover:bg-muted" title={item} onClick={() => chooseQuery(item)}>{item}</button>)}
</div>}
</div>}
</div>
})
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { logsApi } from '@/api/logs'
import { applyLabelFilter, extractSelector } from '../query-utils'
type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {
sourceId: number
query: string
onQueryChange: (query: string) => void
compact?: boolean
}) {
const [app, setApp] = useState('')
const [namespace, setNamespace] = useState('')
const [pod, setPod] = useState('')
const [apps, setApps] = useState<string[]>([])
const [namespaces, setNamespaces] = useState<string[]>([])
const [pods, setPods] = useState<string[]>([])
useEffect(() => {
if (!sourceId) return
let active = true
Promise.allSettled([
logsApi.labelValues(sourceId, 'app'),
logsApi.labelValues(sourceId, 'namespace'),
logsApi.labelValues(sourceId, 'pod'),
]).then(([nextApps, nextNamespaces, nextPods]) => {
if (active) {
setApps(nextApps.status === 'fulfilled' ? nextApps.value : [])
setNamespaces(nextNamespaces.status === 'fulfilled' ? nextNamespaces.value : [])
setPods(nextPods.status === 'fulfilled' ? nextPods.value : [])
}
})
return () => { active = false }
}, [sourceId])
function generate(template: Template) {
let next = extractSelector(query) || '{}'
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
else if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim())
if (app.trim()) next = applyLabelFilter(next, 'app', app.trim())
if (pod.trim()) next = applyLabelFilter(next, 'pod', pod.trim())
if (next === '{}') return
if (template === 'error') next += ' |= "error"'
if (template === 'warn') next += ' |= "warn"'
if (template === 'ingress') next += ' |= "HTTP/"'
onQueryChange(next)
}
const hasSelector = extractSelector(query).replace(/[{}\s]/g, '').length > 0
const hasScope = Boolean(app.trim() || namespace.trim() || pod.trim() || hasSelector)
return <section className="space-y-2" aria-label="快捷查询模板">
{!compact && <h2 className="text-sm font-medium">快捷生成 LogQL</h2>}
{!compact && <p className="text-xs text-muted-foreground">选 app、namespace 或副本,再生成可编辑的 LogQL。</p>}
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-36 flex-1 text-xs text-muted-foreground">app
<Input className="mt-1 h-8" list="quick-log-apps" value={app} onChange={event => setApp(event.target.value)} placeholder="搜索或输入 app" />
</label>
<datalist id="quick-log-apps">{apps.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">namespace
<Input className="mt-1 h-8" list="quick-log-namespaces" value={namespace} onChange={event => setNamespace(event.target.value)} placeholder="可选" />
</label>
<datalist id="quick-log-namespaces">{namespaces.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">副本 pod
<Input className="mt-1 h-8" list="quick-log-pods" value={pod} onChange={event => setPod(event.target.value)} placeholder="可选" />
</label>
<datalist id="quick-log-pods">{pods.map(value => <option value={value} key={value} />)}</datalist>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>查询该范围日志</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>错误 error</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>告警 warn</Button>
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>指定副本日志</Button>
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>Ingress 访问日志</Button>
{!hasScope && <span className="self-center text-xs text-muted-foreground">先填写 app、namespace 或 pod,也可在 Label 浏览器选值</span>}
</div>
</section>
}
@@ -0,0 +1,66 @@
import { useEffect, useId, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
interface SearchableValueSelectProps {
label: string
values: string[]
value: string
onChange: (value: string) => void
placeholder?: string
allowEmpty?: boolean
loading?: boolean
disabled?: boolean
}
/** A searchable single-value combobox for Loki label values. */
export function SearchableValueSelect({ label, values, value, onChange, placeholder, allowEmpty = false, loading = false, disabled = false }: SearchableValueSelectProps) {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [active, setActive] = useState(0)
const root = useRef<HTMLDivElement>(null)
const id = useId()
const choices = values.filter(item => item.toLowerCase().includes(filter.toLowerCase()))
useEffect(() => {
const close = (event: MouseEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [])
function choose(next: string) {
onChange(next)
setFilter('')
setActive(0)
setOpen(false)
}
return <div ref={root} className="relative min-w-48">
<label className="block text-sm font-medium mb-1" htmlFor={id}>{label}</label>
<Input
id={id}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={`${id}-options`}
disabled={disabled}
value={open ? filter : value}
placeholder={placeholder ?? '搜索并选择'}
onFocus={() => { setFilter(''); setOpen(true) }}
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
onKeyDown={event => {
if (event.key === 'Escape') setOpen(false)
if (event.key === 'ArrowDown') { event.preventDefault(); setActive(current => Math.max(0, Math.min(current + 1, choices.length - 1))) }
if (event.key === 'ArrowUp') { event.preventDefault(); setActive(current => Math.max(0, current - 1)) }
if (event.key === 'Enter' && open && choices[active]) { event.preventDefault(); choose(choices[active]) }
}}
/>
{open && <div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>不限</button>}
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">加载中…</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">无匹配值</p> : choices.slice(0, 200).map((item, index) =>
<button key={item} type="button" role="option" aria-selected={item === value} className={`block w-full rounded px-2 py-1.5 text-left text-sm ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onMouseDown={event => event.preventDefault()} onClick={() => choose(item)}>{item}</button>
)}
</div>}
</div>
}
@@ -0,0 +1,68 @@
import { useEffect, useRef, useState } from 'react'
import { CalendarRange, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getUser } from '@/lib/auth-storage'
const presets = [
{ label: '最近 5 分钟', ms: 300000 },
{ label: '最近 15 分钟', ms: 900000 },
{ label: '最近 1 小时', ms: 3600000 },
{ label: '最近 6 小时', ms: 21600000 },
{ label: '最近 24 小时', ms: 86400000 },
]
const localTime = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
const historyKey = () => `opsxloki.logs.range-history.${getUser()?.id ?? 'anonymous'}`
type SavedRange = { start: string; end: string }
function readHistory(): SavedRange[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(historyKey()) ?? '[]')
return Array.isArray(value) ? value.filter((item): item is SavedRange => typeof item?.start === 'string' && typeof item?.end === 'string').slice(0, 5) : []
} catch { return [] }
}
export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) {
const [open, setOpen] = useState(false)
const [draftStart, setDraftStart] = useState(start)
const [draftEnd, setDraftEnd] = useState(end)
const [history, setHistory] = useState(readHistory)
const [selection, setSelection] = useState({ start, end, ms: 300000 as number | null })
const root = useRef<HTMLDivElement>(null)
useEffect(() => {
const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false) }
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [])
function choose(nextStart: string, nextEnd: string, nextPreset: number | null = null) {
if (!(new Date(nextStart).getTime() < new Date(nextEnd).getTime())) return
setSelection({ start: nextStart, end: nextEnd, ms: nextPreset })
onChange(nextStart, nextEnd)
setDraftStart(nextStart); setDraftEnd(nextEnd); setOpen(false)
const next = [{ start: nextStart, end: nextEnd }, ...history.filter(item => item.start !== nextStart || item.end !== nextEnd)].slice(0, 5)
setHistory(next)
try { localStorage.setItem(historyKey(), JSON.stringify(next)) } catch { /* Storage may be unavailable. */ }
}
const activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined
const label = activePreset?.label ?? '自定义时间'
const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`
return <div ref={root} className="relative min-w-0">
<Button type="button" variant="outline" aria-expanded={open} className="h-9 max-w-full gap-2" onClick={() => { setDraftStart(start); setDraftEnd(end); setOpen(!open) }}>
<CalendarRange className="size-4 shrink-0" /><span className="shrink-0 font-medium">{label}</span><span className="hidden truncate font-normal text-muted-foreground xl:inline">{rangeText}</span><ChevronDown className="size-4 shrink-0" />
</Button>
{open && <div className="absolute left-0 top-full z-40 mt-1 w-[min(92vw,520px)] rounded-lg border bg-popover p-4 shadow-xl">
<h2 className="text-sm font-medium">选择时间范围</h2>
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{item.label}</Button>)}</div>
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
<label className="text-xs text-muted-foreground">开始时间<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
<label className="text-xs text-muted-foreground">结束时间<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
</div>
<div className="mt-2 flex justify-end"><Button type="button" size="sm" disabled={!(new Date(draftStart).getTime() < new Date(draftEnd).getTime())} onClick={() => choose(draftStart, draftEnd)}>应用时间</Button></div>
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">最近使用</h3><div className="max-h-32 space-y-0.5 overflow-y-auto">{history.map(item => <button type="button" key={`${item.start}-${item.end}`} className="block w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-muted" onClick={() => choose(item.start, item.end)}>{new Date(item.start).toLocaleString()} ~ {new Date(item.end).toLocaleString()}</button>)}</div></div>}
</div>}
</div>
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PageContainer } from '@/components/PageContainer'
import { ErrorState } from '@/components/ErrorState'
import { getUser } from '@/lib/auth-storage'
import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs'
import { clusterApi } from '@/api'
import type { Cluster } from '@/types'
import { Play, Radio, Lock, Loader2 } from 'lucide-react'
import { QueryInput } from './components/QueryInput'
import { LokiLabelBrowser } from './components/LokiLabelBrowser'
import { LogVolumeTimeline } from './components/LogVolumeTimeline'
import { TimeRangePicker } from './components/TimeRangePicker'
import { PolicyGroupsPage } from './components/PolicyGroupsPage'
import { LogContextDialog, type LogContext } from './components/LogContextDialog'
import { LogResultsPanel } from './components/LogResultsPanel'
import { literalFilters } from './query-utils'
import { readQueryHistory, rememberQuery } from './query-history'
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
const ns = (ms: number) => Math.round(ms).toString() + '000000'
type Bucket = { timestamp: string; count: number }
export default function LogsPage() {
const admin = getUser()?.role === 'admin'
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
const [query, setQuery] = useState('')
const [executedQuery, setExecutedQuery] = useState('')
const [executedRange, setExecutedRange] = useState<{ start: number; end: number } | null>(null)
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 300000)))
const [end, setEnd] = useState(() => toLocal(new Date()))
const [limit, setLimit] = useState(1000)
const [queryHistory, setQueryHistory] = useState(readQueryHistory)
const [entries, setEntries] = useState<LogEntry[]>([])
const [searched, setSearched] = useState(false)
const [buckets, setBuckets] = useState<Bucket[]>([])
const [histogramLoading, setHistogramLoading] = useState(false)
const [histogramError, setHistogramError] = useState(false)
const [cursor, setCursor] = useState('')
const [pageIndex, setPageIndex] = useState(0)
const [pageCursors, setPageCursors] = useState<string[]>([''])
const [loading, setLoading] = useState(false)
const [loadingStartedAt, setLoadingStartedAt] = useState<number | null>(null)
const [error, setError] = useState('')
const [context, setContext] = useState<LogContext | null>(null)
const [tab, setTab] = useState<'query' | 'sources' | 'access'>('query')
const [sourceForm, setSourceForm] = useState<Partial<LogSource> & { basicPass?: string; bearerToken?: string }>({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })
const [saving, setSaving] = useState(false)
const [tail, setTail] = useState(false)
const requestVersion = useRef(0)
const [clusters, setClusters] = useState<Cluster[]>([])
useEffect(() => { if (admin) clusterApi.list({ page: 1, pageSize: 100 }).then(r => setClusters(r.list)).catch(() => setClusters([])) }, [admin])
async function reloadSources() {
try {
const result = await logsApi.sources()
setSources(result.list)
setSourceId(current => result.list.some(s => s.id === current) ? current : (result.list.find(s => s.isDefault)?.id ?? result.list[0]?.id ?? 0))
} catch (e) { setError(e instanceof Error ? e.message : '加载数据源失败') }
}
useEffect(() => { void reloadSources() }, [])
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(false); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId])
function range(): { start: number; end: number } | null {
const a = new Date(start).getTime(), b = new Date(end).getTime()
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError('请选择有效的起止时间'); return null }
return { start: Number(ns(a)), end: Number(ns(b)) }
}
async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) {
const paging = targetPage !== undefined
const r = paging ? executedRange : selectedRange ?? range()
const searchQuery = paging ? executedQuery : overrideQuery ?? query.trim()
if (!r || !sourceId || !searchQuery) return
const version = ++requestVersion.current
setLoading(true); setLoadingStartedAt(Date.now()); setError('')
if (!paging) { setBuckets([]); setHistogramLoading(false); setHistogramError(false) }
const data: LogQuery = { sourceId, query: searchQuery, ...r, limit, direction: 'backward', cursor: nextCursor }
try {
const result = await logsApi.query(data)
if (version !== requestVersion.current) return
if (paging && result.entries.length === 0) { setCursor(''); return }
if (!paging) setQueryHistory(rememberQuery(data.query))
setExecutedQuery(data.query)
if (!paging) setExecutedRange(r)
setSearched(true)
setEntries(result.entries)
setCursor(result.hasMore ? result.nextCursor : '')
setPageIndex(targetPage ?? 0)
setPageCursors(previous => paging ? [...previous.slice(0, targetPage), nextCursor] : [''])
if (!paging) {
setHistogramLoading(true)
setHistogramError(false)
void logsApi.histogram(data).then(histogram => {
if (version === requestVersion.current) {
setBuckets(histogram.map(item => ({ timestamp: String(item.timestamp), count: Number(item.count) || 0 })))
}
}).catch(() => { if (version === requestVersion.current) setHistogramError(true) })
.finally(() => { if (version === requestVersion.current) setHistogramLoading(false) })
}
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : '查询失败') }
finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } }
}
useEffect(() => {
if (!tail || !sourceId || !query.trim()) return
const poll = async () => {
const now = Date.now()
setEnd(toLocal(new Date(now)))
setStart(toLocal(new Date(now - 300000)))
try {
const result = await logsApi.query({ sourceId, query, start: Number(ns(now - 300000)), end: Number(ns(now)), limit, direction: 'backward' })
setExecutedQuery(query)
setSearched(true)
setEntries(result.entries)
setCursor(result.hasMore ? result.nextCursor : '')
setPageIndex(0)
setPageCursors([''])
} catch { /* 静默轮询,手动查询仍会显示错误 */ }
}
void poll()
const timer = window.setInterval(() => void poll(), 3000)
return () => window.clearInterval(timer)
}, [tail, sourceId, query, limit])
async function showContext(row: LogEntry) {
try {
const result = await logsApi.context({ sourceId, timestampNs: row.timestampNs, labels: row.labels })
setContext({ row, before: result.before.slice().reverse(), after: result.after })
} catch (e) { toast.error(e instanceof Error ? e.message : '读取上下文失败') }
}
function saveSource() {
setSaving(true)
const action = sourceForm.id ? logsApi.updateSource(sourceForm.id, sourceForm) : logsApi.createSource(sourceForm)
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : '保存失败')).finally(() => setSaving(false))
}
const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery])
const applyDomainFilter = (domain: string) => {
const nextQuery = `${query.trim()} |= ${JSON.stringify(domain)}`
setQuery(nextQuery)
void search('', false, undefined, nextQuery)
}
const selectTimeRange = (from: number, to: number) => {
setStart(toLocal(new Date(from))); setEnd(toLocal(new Date(to))); setTail(false)
void search('', false, { start: Number(ns(from)), end: Number(ns(to)) })
}
const pageTitle = tab === 'query' ? 'Loki 查询' : tab === 'access' ? '查询授权' : '数据源管理'
const description = tab === 'query' ? '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。' : tab === 'access' ? '用策略组复用日志范围,并一次关联多个用户。' : '配置 Loki 连接与认证方式。'
return (
<div className="-m-4 md:-m-6">
<nav aria-label="日志中心导航" className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>Loki 查询</Button>
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>查询授权</Button>}
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>数据源管理</Button>}
</nav>
<div className="p-4 md:p-6">
<PageContainer title={tab === 'query' ? undefined : pageTitle} description={description}>
<div className="space-y-6">
{error && <ErrorState message={error} onRetry={() => void reloadSources()} />}
{tab === 'query' && <section className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
<div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={() => void search()} sourceId={sourceId} history={queryHistory} /></div>
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? '查询中…' : '查询'}</Button>
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? '停止 Tail' : 'Tail'}</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
<select aria-label="数据源" className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>暂无可用数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
<label className="flex items-center gap-1 text-xs text-muted-foreground">每页条数<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
<span className="text-xs text-muted-foreground">每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。</span>
</div>
<LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} />
</section>}
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label="数据源管理">
<h2 className="font-semibold">Loki 数据源</h2>
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
<div className="space-y-2">
{sources.map(source => {
const managed = source.managed
const cluster = clusters.find(c => c.id === source.clusterID)
return managed ? (
<div key={source.id} className="flex items-center gap-3 rounded-lg border bg-muted/40 px-3 py-2 text-sm">
<Lock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-medium">{source.name}</span>
<span className="text-muted-foreground truncate">
{cluster ? `集群 ${cluster.name}` : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
</span>
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">平台部署 · 随部署管理</span>
</div>
) : (
<Button key={source.id} size="sm" variant="outline" onClick={() => setSourceForm({ ...source, headers: Object.fromEntries((source.headerNames ?? []).map(name => [name, ''])) })}>
{source.name}
</Button>
)
})}
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>+ 手动添加</Button>
</div>
{/* 表单:URL 直连 或 集群服务(集群名下拉,服务默认 loki) */}
<div className="grid gap-2 sm:grid-cols-2">
<label>名称<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
<div className="flex gap-2 items-end">
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>URL 直连</button>
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>集群服务</button>
</div>
{sourceForm.targetType === 'url'
? <label className="sm:col-span-2">Loki 地址<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
: <>
<label>集群<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
<option value="">选择集群…</option>
{clusters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select></label>
<label>命名空间<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
<label>Service<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
<label>端口<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
</>}
<label>认证方式<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">无认证</option><option value="basic">Basic Auth</option><option value="bearer">Bearer Token</option><option value="headers">自定义 Headers</option></select></label>
{sourceForm.authType === 'basic' && <><label>用户名<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>密码<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
{sourceForm.authType === 'bearer' && <label>令牌<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">自定义 Headers(JSON 对象)<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error('Headers 必须是字符串键值 JSON 对象') } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />跳过 TLS 校验</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />设为默认</label>
</div>
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>测试连接</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>保存数据源</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>删除</Button>}</div>
</section>}
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />正在统计整个时间范围的日志量,日志页可继续浏览。</div>}
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。</p>}
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} buckets={buckets} start={executedRange ? toLocal(new Date(executedRange.start / 1e6)) : start} end={executedRange ? toLocal(new Date(executedRange.end / 1e6)) : end} searched={searched} hasSource={sources.length > 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} loading={loading} loadingStartedAt={loadingStartedAt} query={query} terms={highlightTerms} onQueryChange={setQuery} onApplyDomain={applyDomainFilter} onShowContext={row => void showContext(row)} onSearch={() => void search()} onNextPage={() => void search(cursor, false, undefined, undefined, pageIndex + 1)} onPreviousPage={() => void search(pageCursors[pageIndex - 1] ?? '', false, undefined, undefined, pageIndex - 1)} />}
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
</div>
</PageContainer>
</div>
</div>
)
}
@@ -0,0 +1,100 @@
/** ingress-nginx 默认 access log;采集器可能在前面附加 CRI 时间/stdout 包装。 */
export interface IngressAccessLog {
clientIp: string
method: string
path: string
status: string
bytesSent: string
referer: string
domain: string
domainSource: 'host' | 'referer' | ''
userAgent: string
requestLength: string
requestTime: string
upstreamName: string
upstreamAddress: string
upstreamStatus: string
upstreamResponseTime: string
}
export function stripLogEnvelope(message: string): string {
return message.replace(/^\d{4}-\d\d-\d\dT\S+\s+(?:stdout|stderr)\s+[FP]\s+/, '')
}
const accessPattern = /^(\S+)\s+\S+\s+\S+\s+\[[^\]]+\]\s+"([A-Z]+)\s+(\S+)\s+[^"]+"\s+(\d{3})\s+(\S+)\s+"([^"]*)"\s+"([^"]*)"(?:\s+(.*))?$/
const upstreamPattern = /^(\S+)\s+(\S+)\s+\[([^\]]*)\]\s+\[[^\]]*\]\s+(\S+)\s+\S+\s+(\S+)\s+(\S+)(?:\s+\S+)?$/
const asText = (value: unknown) => value === undefined || value === null ? '' : String(value)
function domainFromHost(value: string): string {
const host = value.trim().toLowerCase()
if (!host || host === '-') return ''
try {
return new URL(/^https?:\/\//.test(host) ? host : `http://${host}`).hostname
} catch {
return ''
}
}
function domainFromReferer(value: string): string {
try {
return new URL(value).hostname.toLowerCase()
} catch {
return ''
}
}
function accessDomain(host: string, path: string): Pick<IngressAccessLog, 'domain' | 'domainSource'> {
const requestHost = domainFromHost(host) || (/^https?:\/\//i.test(path) ? domainFromReferer(path) : '')
if (requestHost) return { domain: requestHost, domainSource: 'host' }
return { domain: '', domainSource: '' }
}
export function parseIngressAccessLog(message: string): IngressAccessLog | null {
const content = stripLogEnvelope(message).trim()
if (content.startsWith('{')) {
try {
const value: unknown = JSON.parse(content)
if (value && typeof value === 'object' && !Array.isArray(value)) {
const row = value as Record<string, unknown>
const method = asText(row.method ?? row.request_method)
const path = asText(row.path ?? row.uri ?? row.request_uri)
const status = asText(row.status ?? row.status_code)
const referer = asText(row.http_referer ?? row.referer)
if (method && path && /^\d{3}$/.test(status)) return {
clientIp: asText(row.remote_addr ?? row.client_ip), method, path, status,
bytesSent: asText(row.bytes_sent ?? row.body_bytes_sent),
referer,
...accessDomain(asText(row.http_host || row.host || row.server_name), path),
userAgent: asText(row.http_user_agent ?? row.user_agent),
requestLength: asText(row.request_length), requestTime: asText(row.request_time),
upstreamName: asText(row.proxy_upstream_name ?? row.upstream_name),
upstreamAddress: asText(row.upstream_addr),
upstreamStatus: asText(row.upstream_status),
upstreamResponseTime: asText(row.upstream_response_time),
}
}
} catch { /* Fall through to plain access log parsing. */ }
}
const match = content.match(accessPattern)
if (!match) return null
const upstream = (match[8] ?? '').match(upstreamPattern)
return {
clientIp: match[1], method: match[2], path: match[3], status: match[4],
bytesSent: match[5], referer: match[6], userAgent: match[7],
...accessDomain('', match[3]),
requestLength: upstream?.[1] ?? '', requestTime: upstream?.[2] ?? '',
upstreamName: upstream?.[3] ?? '', upstreamAddress: upstream?.[4] ?? '',
upstreamResponseTime: upstream?.[5] ?? '', upstreamStatus: upstream?.[6] ?? '',
}
}
export function httpStatusClass(status: string): '2xx' | '3xx' | '4xx' | '5xx' | 'other' {
const group = status[0]
return group === '2' || group === '3' || group === '4' || group === '5' ? `${group}xx` : 'other'
}
export function pathWithoutQuery(path: string): string {
return path.split('?')[0] || '/'
}
@@ -0,0 +1,77 @@
import type { IngressAccessLog } from './ingress-access-log'
import { pathWithoutQuery } from './ingress-access-log'
export interface IngressSample {
access: IngressAccessLog
timestamp: string
}
export interface IngressRouteStats {
domain: string
path: string
pv: number
uv: number
success: number
error4xx: number
error5xx: number
slow: number
timeoutSignals: number
averageSeconds: number | null
maxSeconds: number | null
statusCodes: string
}
export function summarizeIngress(samples: IngressSample[], slowThreshold: number): {
routes: IngressRouteStats[]
pv: number
uv: number
errors: number
slow: number
timed: number
} {
const routes = new Map<string, {
domain: string; path: string; pv: number; success: number; error4xx: number; error5xx: number
slow: number; timeoutSignals: number; ips: Set<string>; seconds: number[]; statuses: Map<string, number>
}>()
const allIps = new Set<string>()
let errors = 0, slow = 0, timed = 0
for (const { access } of samples) {
const domain = access.domain || '未记录 Host'
const path = pathWithoutQuery(access.path)
const key = `${domain}\u0000${path}`
let route = routes.get(key)
if (!route) {
route = { domain, path, pv: 0, success: 0, error4xx: 0, error5xx: 0, slow: 0,
timeoutSignals: 0, ips: new Set(), seconds: [], statuses: new Map() }
routes.set(key, route)
}
route.pv++
if (access.clientIp && access.clientIp !== '-') {
route.ips.add(access.clientIp)
allIps.add(access.clientIp)
}
route.statuses.set(access.status, (route.statuses.get(access.status) ?? 0) + 1)
const status = Number(access.status)
if (status >= 200 && status < 400) route.success++
if (status >= 400 && status < 500) { route.error4xx++; errors++ }
if (status >= 500 && status < 600) { route.error5xx++; errors++ }
if (status === 408 || status === 499 || status === 504) route.timeoutSignals++
const seconds = Number(access.requestTime)
if (access.requestTime.trim() && Number.isFinite(seconds) && seconds >= 0) {
route.seconds.push(seconds)
timed++
if (seconds >= slowThreshold) { route.slow++; slow++ }
}
}
return {
routes: [...routes.values()].map(route => ({
domain: route.domain, path: route.path, pv: route.pv, uv: route.ips.size,
success: route.success, error4xx: route.error4xx, error5xx: route.error5xx,
slow: route.slow, timeoutSignals: route.timeoutSignals,
averageSeconds: route.seconds.length ? route.seconds.reduce((sum, value) => sum + value, 0) / route.seconds.length : null,
maxSeconds: route.seconds.length ? Math.max(...route.seconds) : null,
statusCodes: [...route.statuses].filter(([code]) => Number(code) >= 400).sort((a, b) => Number(a[0]) - Number(b[0])).map(([code, count]) => `${code}×${count}`).join(' · '),
})),
pv: samples.length, uv: allIps.size, errors, slow, timed,
}
}
+16
View File
@@ -0,0 +1,16 @@
import { getUser } from '@/lib/auth-storage'
const key = () => `opsxloki.logs.query-history.${getUser()?.id ?? 'anonymous'}`
export function readQueryHistory(): string[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(key()) ?? '[]')
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 20) : []
} catch { return [] }
}
export function rememberQuery(query: string): string[] {
const next = [query, ...readQueryHistory().filter(item => item !== query)].slice(0, 20)
try { localStorage.setItem(key(), JSON.stringify(next)) } catch { /* Storage may be unavailable. */ }
return next
}
+37
View File
@@ -0,0 +1,37 @@
// 日志查询纯工具(与组件分离)
/** 从 LogQL 提取 {...} 选择器部分 */
export function extractSelector(query: string): string {
const i = query.indexOf('{')
const j = query.indexOf('}', i)
return i >= 0 && j > i ? query.slice(i, j + 1) : ''
}
/**
* 把 key="value" 合并进 LogQL 的 {...} 选择器(同名标签替换旧值);exclude=true 用 != 排除
*/
export function applyLabelFilter(query: string, key: string, value: string, exclude = false): string {
const op = exclude ? '!=' : '='
const frag = `${key}${op}"${value}"`
const i = query.indexOf('{')
if (i < 0) return `{${frag}}`
const j = query.indexOf('}', i)
if (j < 0) return query
const inner = query.slice(i + 1, j).trim()
// 同名标签(=、!=、=~、!~)替换,其余保留
const parts = inner ? inner.split(',').map((s) => s.trim()).filter(Boolean) : []
const kept = parts.filter((p) => !new RegExp(`^${key}(=|!=|=~|!~)`).test(p))
kept.push(frag)
return query.slice(0, i + 1) + kept.join(', ') + query.slice(j)
}
/** 提取 LogQL 管道中的字面过滤词(|= "x" 与 != "y"),用于结果行高亮 */
export function literalFilters(query: string): string[] {
const terms: string[] = []
const re = /\|=\s*"((?:[^"\\]|\\.)*)"/g
let m: RegExpExecArray | null
while ((m = re.exec(query)) !== null) {
if (m[1]) terms.push(m[1].replace(/\\"/g, '"'))
}
return terms
}
@@ -0,0 +1,119 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Globe, Save } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { settingsApi, type ImageRegistryConfig } from '@/api/settings'
import { toast } from 'sonner'
const MODES = [
{ value: 'overseas', label: '海外官方', desc: 'docker.io,海外网络环境' },
{ value: 'cn', label: '国内加速', desc: 'docker.m.daocloud.io 镜像,国内环境防被墙' },
{ value: 'custom', label: '私有仓库', desc: '自有 registry 前缀(如华为云 SWR)' },
] as const
/**
* 镜像源设置 — 影响平台拉取的全部镜像(Loki/Grafana/Promtail/Fluent Bit),
* 参照 kuboard 的国内/海外选择;实时预览四类镜像的生效地址。
*/
export const ImageRegistrySection = memo(function ImageRegistrySection() {
const [cfg, setCfg] = useState<ImageRegistryConfig | null>(null)
const [customHost, setCustomHost] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
settingsApi
.getImageRegistry()
.then((c) => {
setCfg(c)
setCustomHost(c.customHost || '')
})
.catch(() => toast.error('加载镜像源配置失败'))
}, [])
const handleSave = useCallback(async () => {
if (!cfg) return
setSaving(true)
try {
const saved = await settingsApi.saveImageRegistry({
mode: cfg.mode,
customHost: cfg.mode === 'custom' ? customHost : undefined,
})
setCfg(saved)
toast.success('镜像源已保存,后续部署/采集将使用新镜像地址')
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
} finally {
setSaving(false)
}
}, [cfg, customHost])
if (!cfg) return null
return (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4" />
镜像仓库源
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{MODES.map((m) => (
<button
key={m.value}
type="button"
aria-pressed={cfg.mode === m.value}
onClick={() => setCfg({ ...cfg, mode: m.value })}
className={cn(
'text-left rounded-xl border-2 p-3 transition-all',
cfg.mode === m.value ? 'border-primary bg-primary/5' : 'border-border hover:border-input'
)}
>
<p className={cn('text-sm font-medium', cfg.mode === m.value && 'text-primary')}>{m.label}</p>
<p className="text-xs text-muted-foreground mt-1">{m.desc}</p>
</button>
))}
</div>
{cfg.mode === 'custom' && (
<div className="space-y-2">
<Label>私有仓库前缀 *</Label>
<Input
placeholder="swr.cn-east-2.myhuaweicloud.com/your-namespace"
value={customHost}
onChange={(e) => setCustomHost(e.target.value)}
/>
<p className="text-xs text-muted-foreground">平台会在镜像名前拼接此前缀,如 {customHost || 'your-registry'}/grafana/loki:3.4.0</p>
</div>
)}
{/* 生效镜像预览 */}
<div className="rounded-lg bg-muted/50 p-3 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">生效镜像预览</p>
{([
['Loki', cfg.sampleLoki],
['Grafana', cfg.sampleGrafana],
['Promtail', cfg.samplePromtail],
['Fluent Bit', cfg.sampleFluentBit],
] as const).map(([name, img]) => (
<p key={name} className="text-xs font-mono flex gap-2">
<span className="text-muted-foreground w-20 shrink-0">{name}</span>
<span className="truncate" title={img}>{img}</span>
</p>
))}
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />
{saving ? '保存中...' : '保存镜像源'}
</Button>
</div>
</CardContent>
</Card>
)
})
@@ -193,7 +193,7 @@ export const LdapSection = memo(function LdapSection() {
<Button variant="outline" onClick={handleTest} disabled={testing || !cfg.enabled}>
{testing ? '测试中...' : '测试连接'}
</Button>
<Button onClick={handleSave} disabled={saving || !cfg.enabled}>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
@@ -0,0 +1,240 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { BellRing, Plus, Pencil, Trash2, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { PasswordInput } from '@/components/PasswordInput'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { alertsApi, type NotifyChannel } from '@/api/alerts'
import { toast } from 'sonner'
interface ChannelForm {
name: string
webhook: string
signingSecret: string
enabled: boolean
}
/**
* 通知通道(飞书/Lark)— 多 webhook 对应不同告警群。
* webhook 加密存储(留空=保留旧值),每通道可独立测试/禁用/删除(有关联规则时禁删)。
*/
export const NotifySection = memo(function NotifySection() {
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
const [form, setForm] = useState<ChannelForm>({ name: '', webhook: '', signingSecret: '', enabled: true })
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<NotifyChannel | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
try {
const list = await alertsApi.listChannels()
setChannels(Array.isArray(list) ? list : [])
} catch (err) {
toast.error(err instanceof Error ? err.message : '加载通知通道失败')
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const openCreate = () => {
setEditing(null)
setForm({ name: '', webhook: '', signingSecret: '', enabled: true })
setOpen(true)
}
const openEdit = (ch: NotifyChannel) => {
setEditing(ch)
setForm({ name: ch.name, webhook: '', signingSecret: '', enabled: ch.enabled })
setOpen(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
toast.error('请填写通道名称')
return
}
if (!editing && !form.webhook) {
toast.error('新建通道需要填写 Webhook 地址')
return
}
setSaving(true)
try {
if (editing) {
await alertsApi.updateChannel(editing.id, {
name: form.name,
webhook: form.webhook || undefined,
signingSecret: form.signingSecret || undefined,
enabled: form.enabled,
})
} else {
await alertsApi.saveChannel({
name: form.name,
webhook: form.webhook,
signingSecret: form.signingSecret || undefined,
enabled: form.enabled,
})
}
setOpen(false)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
} finally {
setSaving(false)
}
}
const handleTest = async (ch: NotifyChannel) => {
setTesting(true)
try {
await alertsApi.testChannel({ id: ch.id })
toast.success(`测试卡片已发送到「${ch.name}」`)
} catch (err) {
toast.error(err instanceof Error ? err.message : '发送失败')
} finally {
setTesting(false)
}
}
const handleToggle = async (ch: NotifyChannel, enabled: boolean) => {
try {
await alertsApi.updateChannel(ch.id, { name: ch.name, enabled })
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '切换失败')
}
}
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await alertsApi.deleteChannel(deleteTarget.id)
setDeleteTarget(null)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting(false)
}
}
return (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<BellRing className="w-4 h-4" />
告警通知通道(飞书/Lark)
</span>
<Button size="sm" variant="outline" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
新建通道
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-2 text-center">
尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),
在
<Link to="/alerts" className="text-primary mx-1">日志告警</Link>
页把规则关联到对应通道
</p>
) : (
channels.map((ch) => (
<div key={ch.id} className="flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
<Switch checked={ch.enabled} onCheckedChange={(v) => void handleToggle(ch, v)} />
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">{ch.name}</p>
<p className="text-xs text-muted-foreground">
{ch.hasWebhook ? 'Webhook 已配置' : '未配置'}
{ch.hasSecret ? ' · 签名已配置' : ''}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" aria-label={`测试 ${ch.name}`} disabled={testing} onClick={() => void handleTest(ch)}>
<Send className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" aria-label={`编辑 ${ch.name}`} onClick={() => openEdit(ch)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={`删除 ${ch.name}`}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(ch)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))
)}
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? `编辑通道 · ${editing.name}` : '新建通知通道'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>通道名称 *</Label>
<Input
placeholder="如:应用告警群 / Nginx 告警群"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Webhook 地址{editing ? '(留空保持不变)' : ' *'}</Label>
<PasswordInput
placeholder={editing?.hasWebhook ? '已保存,输入新地址以修改' : 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'}
value={form.webhook}
onChange={(e) => setForm((f) => ({ ...f, webhook: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>签名校验密钥(可选)</Label>
<PasswordInput
placeholder={editing?.hasSecret ? '已保存,留空保持不变' : '飞书机器人「签名校验」密钥'}
value={form.signingSecret}
onChange={(e) => setForm((f) => ({ ...f, signingSecret: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
<Label>启用通道</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : editing ? '保存修改' : '创建通道'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title="确认删除通知通道"
loading={deleting}
onConfirm={handleDelete}
/>
</Card>
)
})
@@ -0,0 +1,41 @@
import { memo } from 'react'
import { useTheme } from 'next-themes'
import { Sun, Moon, Monitor } from 'lucide-react'
import { cn } from '@/lib/utils'
const OPTIONS = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
] as const
/**
* 主题设置 — 三态单选卡片(浅色/深色/跟随系统),next-themes 驱动,
* key `opsxloki.theme.mode`。
*/
export const ThemeSection = memo(function ThemeSection() {
const { theme, setTheme } = useTheme()
return (
<div className="grid grid-cols-3 gap-3">
{OPTIONS.map((opt) => {
const Icon = opt.icon
const active = theme === opt.value
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => setTheme(opt.value)}
className={cn(
'flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all',
active ? 'border-primary bg-primary/5 text-primary' : 'border-border bg-card text-muted-foreground hover:border-input'
)}
>
<Icon className="w-5 h-5" />
<span className="text-sm font-medium">{opt.label}</span>
</button>
)
})}
</div>
)
})
+40 -6
View File
@@ -10,6 +10,11 @@ import { clearAuth } from '@/lib/auth-storage'
import { toast } from 'sonner'
import { PageContainer } from '@/components/PageContainer'
import { LdapSection } from './components/LdapSection'
import { ThemeSection } from './components/ThemeSection'
import { Palette } from 'lucide-react'
import { NotifySection } from './components/NotifySection'
import { ImageRegistrySection } from './components/ImageRegistrySection'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
// 构建指纹由 vite define 注入;本地 tsc/预览时回退
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
@@ -25,7 +30,7 @@ const SYSTEM_INFO = [
]
/**
* 系统设置 — 修改密码 + 关于。
* 系统设置 — 通用配置(主题/密码/关于)/ 告警通知 / LDAP 接入。
* 表单保持手写(字段少、校验已达标准);成功后清空表单即反馈(成功静默)。
*/
const SettingsPage = memo(function SettingsPage() {
@@ -63,8 +68,27 @@ const SettingsPage = memo(function SettingsPage() {
)
return (
<PageContainer title="系统设置" description="修改登录密码、LDAP 接入与系统信息">
<LdapSection />
<PageContainer title="系统设置" description="通用配置、告警通知与 LDAP 接入">
<Tabs defaultValue="general" className="space-y-6">
<TabsList>
<TabsTrigger value="general">通用配置</TabsTrigger>
<TabsTrigger value="notify">告警通知</TabsTrigger>
<TabsTrigger value="ldap">LDAP 接入</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
{/* 主题 */}
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Palette className="w-4 h-4" />
主题
</CardTitle>
</CardHeader>
<CardContent>
<ThemeSection />
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 修改密码 */}
@@ -136,9 +160,7 @@ const SettingsPage = memo(function SettingsPage() {
<div
key={item.label}
className={`flex justify-between py-2.5 ${
index < SYSTEM_INFO.length - 1
? 'border-b border-border'
: ''
index < SYSTEM_INFO.length - 1 ? 'border-b border-border' : ''
}`}
>
<span className="text-sm text-muted-foreground">{item.label}</span>
@@ -148,7 +170,19 @@ const SettingsPage = memo(function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="notify" className="space-y-6">
<ImageRegistrySection />
<NotifySection />
</TabsContent>
<TabsContent value="ldap">
<LdapSection />
</TabsContent>
</Tabs>
</PageContainer>
)
})
@@ -1,91 +1,59 @@
import { memo } from 'react'
import { HardDrive, Star, Trash2 } from 'lucide-react'
import { HardDrive, Star, Trash2, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { StorageConfig } from '@/types'
import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS, STORAGE_TYPE_COLORS } from '../constants'
import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS } from '../constants'
interface StorageCardProps {
storage: StorageConfig
onSetDefault: (id: number) => void
onDelete: (s: StorageConfig) => void
onEdit: (s: StorageConfig) => void
}
/** 存储配置卡片 — 列表项展示与操作入口(删除传整个对象,供确认弹窗展示名称) */
/** 存储配置卡片 — 统一 ResourceCard 样式 */
export const StorageCard = memo(function StorageCard({
storage,
onSetDefault,
onDelete,
storage, onSetDefault, onDelete, onEdit,
}: StorageCardProps) {
const Icon = STORAGE_TYPE_ICONS[storage.storageType] || HardDrive
const cls = STORAGE_TYPE_ICON_CLASS[storage.storageType] || 'text-muted-foreground'
return (
<Card
className={`border-border hover:shadow-md transition-shadow ${
storage.isDefault ? 'ring-1 ring-primary' : ''
}`}
>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
{(() => {
const Icon = STORAGE_TYPE_ICONS[storage.storageType] || HardDrive
const cls =
STORAGE_TYPE_ICON_CLASS[storage.storageType] || 'text-muted-foreground'
return <Icon className={`w-5 h-5 ${cls}`} />
})()}
<h3 className="font-semibold text-foreground truncate">
{storage.name}
</h3>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{storage.isDefault && (
<span className="px-1.5 py-0.5 rounded-full bg-success/10 text-primary text-xs font-medium flex items-center gap-0.5">
<Star className="w-3 h-3" />
默认
</span>
)}
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium uppercase ${
STORAGE_TYPE_COLORS[storage.storageType] || 'bg-muted text-slate-600'
}`}
>
{storage.storageType}
<ResourceCard
icon={Icon}
iconClassName={cls}
title={storage.name}
badges={
<>
<span className="px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium uppercase">
{storage.storageType}
</span>
{storage.isDefault && (
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium inline-flex items-center gap-0.5">
<Star className="size-3" />默认
</span>
</div>
</div>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow label="访问地址" value={storage.endpoint || '默认'} valueClass="truncate max-w-[150px]" />
<InfoRow label="Bucket" value={storage.bucket} valueClass="truncate max-w-[150px]" />
{(storage.storageType === 's3' || storage.storageType === 'oss') && (
<InfoRow label="区域" value={storage.region || '无'} valueClass="truncate max-w-[150px]" />
)}
<InfoRow label="路径前缀" value={storage.pathPrefix || '无'} valueClass="truncate max-w-[150px]" />
<InfoRow label="不加密" value={storage.insecure ? '是' : '否'} />
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onSetDefault(storage.id)}
disabled={storage.isDefault}
>
<Star className="w-3 h-3 mr-1" />
{storage.isDefault ? '默认' : '设为默认'}
</>
}
fields={[
{ label: '访问地址', value: storage.endpoint || '默认', title: storage.endpoint },
{ label: 'Bucket', value: storage.bucket },
{ label: '路径前缀', value: storage.pathPrefix || '无' },
{ label: '连接', value: storage.insecure ? 'HTTP(不加密)' : 'HTTPS' },
]}
actions={
<>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onSetDefault(storage.id)} disabled={storage.isDefault}>
<Star className="size-3 mr-1" />{storage.isDefault ? '默认' : '设为默认'}
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除存储配置 ${storage.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(storage)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`编辑存储配置 ${storage.name}`} onClick={() => onEdit(storage)}>
<Pencil className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除存储配置 ${storage.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(storage)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -50,7 +50,7 @@ const DEFAULT_VALUES: StorageFormValues = {
region: '',
accessKey: '',
secretKey: '',
pathPrefix: 'loki',
pathPrefix: '',
insecure: false,
isDefault: false,
}
@@ -60,6 +60,8 @@ interface StorageFormDialogProps {
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并关闭弹窗) */
onCreated: () => void
/** 编辑目标(传入即编辑模式:凭证留空=保留旧值) */
editing?: import('@/types').StorageConfig | null
}
/** 添加存储配置弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
@@ -67,6 +69,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
open,
onOpenChange,
onCreated,
editing,
}: StorageFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const [testing, setTesting] = useState(false)
@@ -81,13 +84,18 @@ export const StorageFormDialog = memo(function StorageFormDialog({
// 区域仅对公有云(s3/oss)有意义;minio/rustfs 为本地部署不显示、不提交
const isCloudStorage = storageType === 's3' || storageType === 'oss'
// 打开时重置(默认值),关闭/重开时机明确
// 打开时重置;编辑模式回填(密钥留空=保留旧值)
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
form.reset(editing ? {
name: editing.name, storageType: editing.storageType,
endpoint: editing.endpoint || '', bucket: editing.bucket,
region: editing.region || '', secretKey: '',
pathPrefix: editing.pathPrefix || '', insecure: editing.insecure, isDefault: editing.isDefault,
} as never : DEFAULT_VALUES)
setTestResult(null)
}
}, [open, form])
}, [open, form, editing])
// 测试连接:用当前表单值(未保存)直连对象存储验证
const handleTest = async () => {
@@ -123,7 +131,16 @@ export const StorageFormDialog = memo(function StorageFormDialog({
}
setSubmitting(true)
try {
await storageApi.create(values)
if (editing) {
await storageApi.update(editing.id, {
name: values.name, storageType: values.storageType,
endpoint: values.endpoint, bucket: values.bucket, region: values.region,
secretKey: values.secretKey || undefined,
pathPrefix: values.pathPrefix, insecure: values.insecure, isDefault: values.isDefault,
})
} else {
await storageApi.create(values)
}
onOpenChange(false)
onCreated()
} catch (err) {
@@ -137,7 +154,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加存储配置</DialogTitle>
<DialogTitle>{editing ? `编辑存储配置${editing.name ? ` · ${editing.name}` : ''}` : '添加存储配置'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -261,7 +278,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
name="secretKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Key</FormLabel>
<FormLabel>Secret Key{editing && '(留空保持不变)'}</FormLabel>
<FormControl>
<PasswordInput placeholder="minioadmin" {...field} />
</FormControl>
@@ -271,20 +288,6 @@ export const StorageFormDialog = memo(function StorageFormDialog({
/>
</div>
<FormField
control={form.control}
name="pathPrefix"
render={({ field }) => (
<FormItem>
<FormLabel>路径前缀</FormLabel>
<FormControl>
<Input placeholder="loki" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-wrap items-center gap-6">
<FormField
control={form.control}
@@ -357,7 +360,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
className="flex-1 bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '添加中...' : '添加存储'}
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加存储'}
</Button>
</div>
+12 -4
View File
@@ -25,6 +25,7 @@ const StoragePage = memo(function StoragePage() {
const [formOpen, setFormOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<StorageConfig | null>(null)
const [editingTarget, setEditingTarget] = useState<StorageConfig | null>(null)
const [deleting, setDeleting] = useState(false)
const [depRefCount, setDepRefCount] = useState(0)
@@ -90,13 +91,14 @@ const StoragePage = memo(function StoragePage() {
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((storage) => (
<StorageCard
key={storage.id}
storage={storage}
onSetDefault={handleSetDefault}
onDelete={setDeleteTarget}
onEdit={setEditingTarget}
/>
))}
</div>
@@ -110,9 +112,15 @@ const StoragePage = memo(function StoragePage() {
)}
<StorageFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onCreated={query.reload}
open={formOpen || editingTarget !== null}
onOpenChange={(o) => {
if (!o) {
setFormOpen(false)
setEditingTarget(null)
}
}}
onCreated={query.refresh}
editing={editingTarget}
/>
<ConfirmDialog
@@ -26,7 +26,7 @@ interface StepDeployProps {
}
/**
* 步骤③ 部署 Loki(与 Grafana)— 创建后自动触发部署;
* 步骤③ 部署 Loki— 创建后自动触发部署;
* 已有运行实例则展示状态卡(无需重复部署,可直接进入下一步)。
*/
export const StepDeploy = memo(function StepDeploy({
@@ -117,7 +117,7 @@ export const StepDeploy = memo(function StepDeploy({
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
尚无运行中的 Loki —— 选择目标与模式(k8s 副本 / 主机二进制),可同时部署 Grafana
尚无运行中的 Loki —— 选择 Kubernetes 或主机 Docker 部署
</p>
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
@@ -43,7 +43,7 @@ export const StepEnv = memo(function StepEnv({
kind: 'k8s',
icon: Boxes,
title: 'Kubernetes 集群',
desc: '以 StatefulSet/Helm 部署 Loki 与 Grafana 副本,DaemonSet 采集容器日志',
desc: '通过 Kubernetes API 部署 Loki StatefulSet,DaemonSet 采集容器日志',
ready: hasActiveCluster,
names: clusterNames,
},
@@ -51,7 +51,7 @@ export const StepEnv = memo(function StepEnv({
kind: 'host',
icon: Server,
title: '裸金属主机',
desc: '以二进制 + systemd 部署 Loki 与 Grafana,采集指定日志文件',
desc: '以主机 Docker 部署 Loki,采集指定日志文件',
ready: hasActiveHost,
names: hostNames,
},
+1 -1
View File
@@ -101,7 +101,7 @@ const WizardPage = memo(function WizardPage() {
if (firstUndone === 1) hints.push('环境已就绪,建议先配置对象存储')
if (firstUndone === 2) hints.push('目标与存储已就绪,创建 Loki 部署吧')
if (firstUndone === 3) hints.push('Loki 已运行,添加采集策略开始收集日志')
if (firstUndone === -1) hints.push('全部就绪:日志正在收集,可到部署页查看与 Grafana 分析')
if (firstUndone === -1) hints.push('全部就绪:日志正在收集,可到日志查询页分析')
return (
<PageContainer
+4 -1
View File
@@ -13,6 +13,7 @@ import {
UsersRound,
Wand2,
ScrollText,
BellRing,
} from 'lucide-react';
import type { AppRoute, MenuItem, RouteMeta } from '@/types/router';
@@ -27,6 +28,7 @@ const SettingsPage = lazy(() => import('@/pages/settings'));
const NotFound = lazy(() => import('@/pages/not-found'));
const WizardPage = lazy(() => import('@/pages/wizard'));
const LogsPage = lazy(() => import('@/pages/logs'));
const AlertsPage = lazy(() => import('@/pages/alerts'));
const UsersPage = lazy(() => import('@/pages/users'));
export const routes: AppRoute[] = [
@@ -39,8 +41,9 @@ export const routes: AppRoute[] = [
{ path: '/deployments', component: Deployments, auth: true, meta: { title: '部署 Loki', icon: Rocket, order: 6, adminOnly: true } },
{ path: '/collectors', component: Collectors, auth: true, meta: { title: '日志采集', icon: RadioTower, order: 7, adminOnly: true } },
{ path: '/logs', component: LogsPage, auth: true, meta: { title: '日志查询', icon: ScrollText, order: 8 } },
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: '日志告警', icon: BellRing, order: 9 } },
{ path: '/settings', component: SettingsPage, auth: true, meta: { title: '系统设置', icon: Settings, order: 9, adminOnly: true } },
{ path: '/users', component: UsersPage, auth: true, meta: { title: '用户管理', icon: UsersRound, order: 10, adminOnly: true } },
{ path: '/users', component: UsersPage, auth: true, meta: { title: '用户管理', icon: UsersRound, order: 11, adminOnly: true } },
{ path: '*', component: NotFound, auth: false, meta: { title: '页面不存在', hideInMenu: true } },
];
+7 -2
View File
@@ -101,6 +101,7 @@ export interface StorageConfig {
pathPrefix: string;
insecure: boolean;
isDefault: boolean;
hasSecretKey?: boolean;
createdAt: string;
updatedAt: string;
}
@@ -112,11 +113,13 @@ export interface LokiDeployment {
name: string;
description: string;
version: string;
mode: 'k8s-helm' | 'k8s-yaml' | 'host-binary';
mode: 'k8s-helm' | 'k8s-yaml' | 'host-binary' | 'host-docker';
clusterID?: number;
cluster?: Cluster;
hostID?: number;
host?: Host;
listenIP?: string;
listenPort?: number;
namespace: string;
storageConfigID: number;
storageConfig?: StorageConfig;
@@ -152,9 +155,11 @@ export interface CreateDeploymentRequest {
name: string;
description?: string;
version?: string;
mode: 'k8s-helm' | 'k8s-yaml' | 'host-binary';
mode: 'k8s-helm' | 'k8s-yaml' | 'host-binary' | 'host-docker';
clusterID?: number;
hostID?: number;
listenIP?: string;
listenPort?: number;
namespace?: string;
storageConfigID?: number;
authEnabled?: boolean;