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:
1 parent
f9c6c1d826
commit
2d73661dd1
127 files changed
+8117
-2876
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user