feat: 精简配置管理与运行默认值

This commit is contained in:
chuan
2026-08-10 21:45:32 +08:00
parent db0130e606
commit 3b997c8f40
19 changed files with 294 additions and 455 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
- 仅在系统诊断页打开时每秒刷新服务状态
- 独立展示进程 RocksDB Tantivy DHT 和队列诊断数据
- 使用懒加载 ECharts 展示内存存储压力 Metadata 吞吐以及 HTTP 请求错误趋势
- 使用 reka-ui 页签按职责分组的完整配置查看编辑校验保存和重启提示
- 使用设置与过滤两个精简页签管理常用配置 Metadata 单位换算和按行通配符
- 搜索诊断配置使用互不冲突的单页路由
- 加载 空结果 接口错误 重试和移动端适配
+95 -149
View File
@@ -1,108 +1,55 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AlertTriangle, Check, FileCog, LoaderCircle, Plus, RotateCcw, Save, Trash2 } from '@lucide/vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { AlertTriangle, Check, LoaderCircle, RotateCcw, Save } from '@lucide/vue'
import { SwitchRoot, SwitchThumb, TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { onBeforeRouteLeave } from 'vue-router'
import { Button } from '@/components/ui/button'
import { AppSelect } from '@/components/ui/select'
import { getConfig, updateConfig } from '@/lib/api'
import type { AppConfigDto, ConfigSnapshot, ContentFilterRule } from '@/types/api'
import type { AppConfigDto, ConfigSnapshot } from '@/types/api'
type ConfigFieldType = 'text' | 'number' | 'boolean' | 'select'
type ConfigFieldType = 'number' | 'boolean' | 'select' | 'bytes'
interface ConfigField {
path: string
label: string
description: string
type: ConfigFieldType
nullable?: boolean
options?: Array<{ value: string; label: string }>
options?: ReadonlyArray<{ value: string; label: string }>
}
interface ConfigSection {
title: string
description: string
fields: ConfigField[]
kind?: 'fields' | 'content-filter'
}
const sections: ConfigSection[] = [
{ title: '基础', description: '数据目录、任务队列和索引提交', fields: [
{ path: 'data_dir', label: '数据目录', description: 'RocksDB 和 Tantivy 的根目录', type: 'text' },
{ title: '设置', fields: [
{ path: 'dht.netmode', label: '网络模式', description: '选择 DHT 使用的 IP 协议族', type: 'select', options: [{ value: 'ipv4-only', label: '仅 IPv4' }, { value: 'ipv6-only', label: '仅 IPv6' }, { value: 'dual-stack', label: '双栈' }] },
{ path: 'dht.max_outbound_queries_per_second', label: 'UDP 总速率', description: '所有主动 DHT 查询的每秒硬上限', type: 'number' },
{ path: 'dht.metadata_timeout_secs', label: 'Metadata 超时', description: '单次 Metadata 下载的总超时秒数', type: 'number' },
{ path: 'dht.metadata_workers', label: 'Metadata Worker', description: '同时处理 Metadata 下载的最大任务数', type: 'number' },
{ path: 'dht.metadata_connects_per_second', label: 'Metadata 建连速率', description: '每秒发起 TCP 连接的硬上限', type: 'number' },
{ path: 'metadata_limits.max_metadata_bytes', label: '最大 Metadata', description: '单条 Metadata 允许占用的最大空间', type: 'bytes' },
{ path: 'metadata_limits.max_files', label: '最大文件数', description: '单个种子允许包含的文件数量', type: 'number' },
{ path: 'persistence_queue_capacity', label: '持久化队列容量', description: 'Metadata 到 RocksDB 的有界队列', type: 'number' },
{ path: 'stats_interval_secs', label: '状态日志间隔', description: '运行状态日志输出秒数', type: 'number' },
{ path: 'run_duration_secs', label: '运行时长', description: '留空表示持续运行', type: 'number', nullable: true },
{ path: 'index_batch_size', label: '索引批量大小', description: '单次提交的内容组数量', type: 'number' },
{ path: 'index_interval_millis', label: '索引提交间隔', description: 'Tantivy 批量提交毫秒数', type: 'number' },
] },
{ title: 'DHT 网络', description: 'UDP 预算、采样和 Metadata 下载并发', fields: [
{ path: 'dht.port', label: 'DHT 端口', description: '本机 UDP 监听端口', type: 'number' },
{ path: 'dht.netmode', label: '网络模式', description: 'DHT 使用的 IP 协议族', type: 'select', options: [{ value: 'ipv4-only', label: '仅 IPv4' }, { value: 'ipv6-only', label: '仅 IPv6' }, { value: 'dual-stack', label: '双栈' }] },
{ path: 'dht.hash_queue_capacity', label: 'InfoHash 队列', description: '待处理 Hash 有界容量', type: 'number' },
{ path: 'dht.max_outbound_queries_per_second', label: 'UDP 总速率', description: '所有主动 DHT 查询每秒硬上限', type: 'number' },
{ path: 'dht.outbound_query_burst', label: 'UDP 突发', description: '空闲后可立即消费的查询数', type: 'number' },
{ path: 'dht.find_node_queries_per_second', label: 'Find Node 速率', description: '每秒节点探测查询', type: 'number' },
{ path: 'dht.find_node_max_in_flight', label: 'Find Node 在途', description: '同时等待响应的查询数', type: 'number' },
{ path: 'dht.new_destinations_per_minute', label: '新目标速率', description: '每分钟首次探测的目标数', type: 'number' },
{ path: 'dht.peer_lookups_per_second', label: 'Peer Lookup 速率', description: '每秒启动的 Peer 查找', type: 'number' },
{ path: 'dht.peer_lookup_max_active', label: 'Peer Lookup 并发', description: '同时运行的 Peer 查找', type: 'number' },
{ path: 'dht.sample_queries_per_second', label: 'BEP-51 采样速率', description: '每秒采样查询数', type: 'number' },
{ path: 'dht.sample_max_in_flight', label: '采样在途', description: '同时等待的采样请求数', type: 'number' },
{ path: 'dht.sample_new_node_percent', label: '新节点采样比例', description: '分流到直接采样的百分比', type: 'number' },
{ path: 'dht.sample_candidate_queue_capacity', label: '采样候选队列', description: '新节点采样通道容量', type: 'number' },
{ path: 'dht.sample_fallback_to_iterative', label: '采样递归回退', description: '直接采样失败后继续迭代查找', type: 'boolean' },
{ path: 'dht.metadata_timeout_secs', label: 'Metadata 超时', description: '单次下载总超时秒数', type: 'number' },
{ path: 'dht.metadata_queue_capacity', label: 'Metadata 队列', description: '待下载任务容量', type: 'number' },
{ path: 'dht.metadata_workers', label: 'Metadata Worker', description: '同时处理的下载任务数', type: 'number' },
{ path: 'dht.metadata_connects_per_second', label: 'Metadata 建连速率', description: '每秒 TCP 连接硬上限', type: 'number' },
] },
{ title: 'Metadata 安全限制', description: '拒绝异常或恶意元数据的资源边界', fields: [
{ path: 'metadata_limits.max_metadata_bytes', label: '最大 Metadata', description: '单条 Metadata 最大字节数', type: 'number' },
{ path: 'metadata_limits.max_files', label: '最大文件数', description: '单个种子允许的文件数量', type: 'number' },
{ path: 'metadata_limits.max_name_bytes', label: '最大名称长度', description: '种子名称最大字节数', type: 'number' },
{ path: 'metadata_limits.max_path_bytes', label: '最大路径长度', description: '文件路径最大字节数', type: 'number' },
{ path: 'metadata_limits.max_path_depth', label: '最大目录层级', description: '文件路径允许的目录深度', type: 'number' },
] },
{ title: '内容过滤', description: '从展示、统计、搜索和内容聚合中隐藏无效文件', kind: 'content-filter', fields: [] },
{ title: '磁盘与备份', description: '空间保护和 RocksDB 在线检查点', fields: [
{ path: 'disk_guard.enabled', label: '磁盘保护', description: '空间不足时自动进入只读状态', type: 'boolean' },
{ path: 'disk_guard.check_interval_secs', label: '磁盘检查间隔', description: '剩余空间检查秒数', type: 'number' },
{ path: 'disk_guard.minimum_free_bytes', label: '只读阈值', description: '低于该字节数停止写入', type: 'number' },
{ path: 'disk_guard.resume_free_bytes', label: '恢复阈值', description: '高于该字节数恢复写入', type: 'number' },
{ path: 'backup.enabled', label: '自动备份', description: '启用 RocksDB 在线检查点', type: 'boolean' },
{ path: 'backup.directory', label: '备份目录', description: '检查点保存位置', type: 'text' },
{ path: 'backup.interval_secs', label: '备份间隔', description: '自动检查点间隔秒数', type: 'number' },
{ path: 'backup.retain_checkpoints', label: '备份保留数量', description: '保留最近检查点数量', type: 'number' },
{ path: 'backup.create_on_start', label: '启动时备份', description: '服务启动后立即创建检查点', type: 'boolean' },
] },
{ title: '运行诊断', description: '独立 SQLite 历史采样与保留', fields: [
{ path: 'diagnostics.enabled', label: '运行诊断', description: '采集并保存资源快照', type: 'boolean' },
{ path: 'diagnostics.database', label: '诊断数据库', description: 'SQLite 数据库路径', type: 'text' },
{ path: 'diagnostics.sample_interval_secs', label: '采样间隔', description: '实时采样秒数', type: 'number' },
{ path: 'diagnostics.raw_retention_hours', label: '原始数据保留', description: '原始采样保留小时数', type: 'number' },
{ path: 'diagnostics.minute_retention_days', label: '分钟数据保留', description: '分钟快照保留天数', type: 'number' },
{ path: 'diagnostics.queue_capacity', label: '诊断写入队列', description: 'SQLite writer 有界容量', type: 'number' },
] },
{ title: '日志', description: '终端和滚动文件日志', fields: [
{ path: 'logging.directory', label: '日志目录', description: '滚动日志文件位置', type: 'text' },
{ path: 'logging.file_enabled', label: '文件日志', description: '写入滚动日志文件', type: 'boolean' },
{ path: 'logging.console_enabled', label: '终端日志', description: '同时输出到当前终端', type: 'boolean' },
{ path: 'logging.rotation', label: '轮转周期', description: '日志文件切换周期', type: 'select', options: [{ value: 'minutely', label: '每分钟' }, { value: 'hourly', label: '每小时' }, { value: 'daily', label: '每天' }, { value: 'never', label: '不轮转' }] },
{ path: 'logging.retain_files', label: '日志保留数量', description: '最多保留的匹配日志文件', type: 'number' },
{ path: 'logging.file_prefix', label: '日志前缀', description: '滚动日志文件名前缀', type: 'text' },
] },
{ title: 'HTTP 与验证', description: 'Web 服务和按需种子有效性验证', fields: [
{ path: 'http.listen', label: 'HTTP 监听地址', description: 'Web 与 API 监听地址', type: 'text' },
{ path: 'http.web_dir', label: 'Web 静态目录', description: '前端生产构建目录', type: 'text' },
{ path: 'verification.enabled', label: '按需验证', description: '搜索和详情触发异步验证', type: 'boolean' },
{ path: 'verification.queue_capacity', label: '验证队列', description: '持久化验证任务容量', type: 'number' },
{ path: 'verification.max_active', label: '验证并发', description: '同时验证的种子数量', type: 'number' },
{ path: 'verification.max_peer_attempts', label: 'Peer 尝试数', description: '每个种子最多握手数量', type: 'number' },
{ path: 'verification.lease_secs', label: '验证租约', description: '异常退出后的任务恢复秒数', type: 'number' },
{ path: 'verification.poll_interval_millis', label: '验证轮询间隔', description: '队列轮询毫秒数', type: 'number' },
{ path: 'index_batch_size', label: '索引批量大小', description: 'Tantivy 单次提交的内容组数量', type: 'number' },
{ path: 'index_interval_millis', label: '索引提交间隔', description: 'Tantivy 批量提交的间隔毫秒数', type: 'number' },
{ path: 'diagnostics.enabled', label: '运行诊断', description: '采集并保存资源与运行趋势', type: 'boolean' },
{ path: 'logging.file_enabled', label: '保存日志文件', description: '写入每天轮转并保留七天的日志文件', type: 'boolean' },
{ path: 'verification.enabled', label: '按需验证', description: '搜索和详情访问后异步验证种子有效性', type: 'boolean' },
] },
{ title: '过滤', kind: 'content-filter', fields: [] },
]
const byteUnitOptions = [
{ value: 'KiB', label: 'KiB', multiplier: 1024 },
{ value: 'MiB', label: 'MiB', multiplier: 1024 ** 2 },
{ value: 'GiB', label: 'GiB', multiplier: 1024 ** 3 },
] as const
const snapshot = ref<ConfigSnapshot | null>(null)
const config = ref<AppConfigDto | null>(null)
const activeSection = ref(sections[0]?.title ?? '')
@@ -111,35 +58,19 @@ const loading = ref(true)
const saving = ref(false)
const error = ref('')
const saved = ref(false)
const metadataUnit = ref('MiB')
const fileNamePatterns = ref('')
const filePathPatterns = ref('')
const dirty = computed(() => config.value !== null && JSON.stringify(config.value) !== original.value)
const filterFieldOptions = [
{ value: 'file-name', label: '文件名' },
{ value: 'file-path', label: '文件路径' },
]
const filterMatchOptions = [
{ value: 'exact', label: '精确匹配' },
{ value: 'prefix', label: '前缀' },
{ value: 'suffix', label: '后缀' },
{ value: 'contains', label: '包含' },
{ value: 'wildcard', label: '通配符' },
{ value: 'regex', label: '正则表达式' },
]
function fieldValue(path: string): unknown {
if (!config.value) return ''
return path.split('.').reduce<unknown>((value, key) => (value as Record<string, unknown>)[key], config.value)
}
function updateField(field: ConfigField, event: Event) {
function setFieldValue(path: string, value: unknown) {
if (!config.value) return
const input = event.target as HTMLInputElement | HTMLSelectElement
let value: unknown
if (field.type === 'boolean') value = (input as HTMLInputElement).checked
else if (field.type === 'number') value = field.nullable && input.value === '' ? null : Number(input.value)
else value = input.value
const keys = field.path.split('.')
const keys = path.split('.')
const last = keys.pop()
if (!last) return
let target = config.value as unknown as Record<string, unknown>
@@ -148,33 +79,61 @@ function updateField(field: ConfigField, event: Event) {
saved.value = false
}
function markChanged() {
function updateNumberField(field: ConfigField, event: Event) {
const value = Number((event.target as HTMLInputElement).value)
if (Number.isFinite(value)) setFieldValue(field.path, value)
}
function updateBooleanField(field: ConfigField, value: boolean) {
setFieldValue(field.path, value)
}
function selectByteUnit(bytes: number): string {
for (const unit of [...byteUnitOptions].reverse()) {
if (bytes >= unit.multiplier && bytes % unit.multiplier === 0) return unit.value
}
return 'KiB'
}
function metadataUnitMultiplier(): number {
return byteUnitOptions.find((unit) => unit.value === metadataUnit.value)?.multiplier ?? 1
}
function metadataDisplayValue(): number {
return Number(fieldValue('metadata_limits.max_metadata_bytes')) / metadataUnitMultiplier()
}
function updateMetadataBytes(event: Event) {
const value = Number((event.target as HTMLInputElement).value)
if (!Number.isFinite(value)) return
setFieldValue('metadata_limits.max_metadata_bytes', Math.round(value * metadataUnitMultiplier()))
}
function splitPatterns(value: string): string[] {
const seen = new Set<string>()
return value
.split(/\r?\n/)
.map((pattern) => pattern.trim())
.filter((pattern) => {
if (!pattern) return false
const key = pattern.toLocaleLowerCase()
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function syncContentFilter() {
if (!config.value) return
config.value.content_filter.file_name_patterns = splitPatterns(fileNamePatterns.value)
config.value.content_filter.file_path_patterns = splitPatterns(filePathPatterns.value)
saved.value = false
}
function addFilterRule() {
if (!config.value) return
const used = new Set(config.value.content_filter.file_rules.map((rule) => rule.id))
let index = config.value.content_filter.file_rules.length + 1
while (used.has(`rule-${index}`)) index += 1
const rule: ContentFilterRule = {
id: `rule-${index}`,
enabled: true,
field: 'file-name',
match: 'contains',
value: '',
case_sensitive: false,
action: 'hide',
reason: '',
}
config.value.content_filter.file_rules.push(rule)
markChanged()
}
function removeFilterRule(index: number) {
if (!config.value) return
config.value.content_filter.file_rules.splice(index, 1)
markChanged()
function syncEditorState(next: AppConfigDto) {
metadataUnit.value = selectByteUnit(next.metadata_limits.max_metadata_bytes)
fileNamePatterns.value = next.content_filter.file_name_patterns.join('\n')
filePathPatterns.value = next.content_filter.file_path_patterns.join('\n')
}
async function load() {
@@ -184,6 +143,7 @@ async function load() {
const next = await getConfig()
snapshot.value = next
config.value = structuredClone(next.config)
syncEditorState(next.config)
original.value = JSON.stringify(next.config)
saved.value = false
} catch (cause) {
@@ -201,6 +161,7 @@ async function save() {
const next = await updateConfig({ revision: snapshot.value.revision, config: config.value })
snapshot.value = next
config.value = structuredClone(next.config)
syncEditorState(next.config)
original.value = JSON.stringify(next.config)
saved.value = true
} catch (cause) {
@@ -231,7 +192,6 @@ onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,
</div>
<div v-if="snapshot?.restart_required" class="mb-4 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm"><AlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-500" /><div><p class="font-medium">配置已保存,需要重启服务后生效</p><p class="mt-1 text-muted-foreground">当前进程仍使用启动时的配置</p></div></div>
<div v-if="snapshot?.command_line_overrides.length" class="mb-4 flex items-start gap-3 rounded-xl border bg-muted/40 p-4 text-sm"><FileCog class="mt-0.5 size-4 shrink-0" /><div><p class="font-medium">存在命令行覆盖</p><p class="mt-1 text-muted-foreground">{{ snapshot.command_line_overrides.join('') }} 会优先于配置文件</p></div></div>
<div v-if="saved" class="mb-4 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm text-emerald-700 dark:text-emerald-400"><Check class="size-4" />配置已安全保存</div>
<div v-if="error" class="mb-4 rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{{ error }}</div>
@@ -246,35 +206,21 @@ onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,
<TabsContent v-for="section in sections" :key="section.title" :value="section.title" class="mt-4 outline-none">
<section class="rounded-xl border bg-card shadow-sm">
<div class="flex items-center gap-3 border-b px-5 py-4">
<div><h2 class="font-semibold">{{ section.title }}</h2><p class="mt-1 text-sm text-muted-foreground">{{ section.description }}</p></div>
<Button v-if="section.kind === 'content-filter'" class="ml-auto" size="sm" type="button" variant="outline" @click="addFilterRule"><Plus />添加规则</Button>
</div>
<div v-if="section.kind !== 'content-filter'" class="grid gap-x-6 px-5 sm:grid-cols-2">
<label v-for="field in section.fields" :key="field.path" class="config-field" :class="field.type === 'boolean' ? 'flex-row items-center' : 'flex-col'">
<span class="min-w-0 flex-1"><b>{{ field.label }}</b><small>{{ field.description }}</small></span>
<input v-if="field.type === 'boolean'" class="size-4 accent-foreground" type="checkbox" :checked="Boolean(fieldValue(field.path))" @change="updateField(field, $event)" />
<select v-else-if="field.type === 'select'" class="config-input" :value="String(fieldValue(field.path))" @change="updateField(field, $event)"><option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</option></select>
<input v-else class="config-input" :type="field.type" :value="fieldValue(field.path) ?? ''" :min="field.type === 'number' ? 0 : undefined" step="1" @input="updateField(field, $event)" />
<SwitchRoot v-if="field.type === 'boolean'" :model-value="Boolean(fieldValue(field.path))" class="relative h-5 w-9 shrink-0 rounded-full bg-input outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/30 data-[state=checked]:bg-foreground" @update:model-value="updateBooleanField(field, $event)"><SwitchThumb class="block size-4 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-[1.125rem]" /></SwitchRoot>
<AppSelect v-else-if="field.type === 'select'" :model-value="String(fieldValue(field.path))" :options="field.options ?? []" :label="field.label" @update:model-value="setFieldValue(field.path, $event)" />
<div v-else-if="field.type === 'bytes'" class="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-2">
<input class="config-input mt-0" type="number" min="0" step="any" :value="metadataDisplayValue()" @input="updateMetadataBytes" />
<AppSelect :model-value="metadataUnit" :options="byteUnitOptions" label="Metadata 大小单位" @update:model-value="metadataUnit = $event" />
</div>
<input v-else class="config-input" type="number" :value="fieldValue(field.path)" min="0" step="1" @input="updateNumberField(field, $event)" />
</label>
</div>
<div v-else class="space-y-3 p-5">
<p v-if="config.content_filter.file_rules.length === 0" class="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">当前没有内容过滤规则</p>
<article v-for="(rule, index) in config.content_filter.file_rules" :key="index" class="rounded-xl border bg-background p-4">
<div class="mb-4 flex items-center gap-3">
<label class="flex items-center gap-2 text-sm font-medium"><input v-model="rule.enabled" class="size-4 accent-foreground" type="checkbox" @change="markChanged" />启用</label>
<span class="text-xs text-muted-foreground">规则 {{ index + 1 }}</span>
<Button class="ml-auto" size="icon-sm" type="button" variant="ghost" aria-label="删除过滤规则" title="删除规则" @click="removeFilterRule(index)"><Trash2 /></Button>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<label class="field-label">规则 ID<input v-model="rule.id" class="config-input mt-0" type="text" @input="markChanged" /></label>
<label class="field-label">匹配字段<select v-model="rule.field" class="config-input mt-0" @change="markChanged"><option v-for="option in filterFieldOptions" :key="option.value" :value="option.value">{{ option.label }}</option></select></label>
<label class="field-label">匹配方式<select v-model="rule.match" class="config-input mt-0" @change="markChanged"><option v-for="option in filterMatchOptions" :key="option.value" :value="option.value">{{ option.label }}</option></select></label>
<label class="field-label sm:col-span-2">匹配值<input v-model="rule.value" class="config-input mt-0 font-mono" type="text" @input="markChanged" /></label>
<label class="field-label">区分大小写<span class="flex h-9 items-center"><input v-model="rule.case_sensitive" class="size-4 accent-foreground" type="checkbox" @change="markChanged" /></span></label>
<label class="field-label sm:col-span-2 lg:col-span-3">规则说明<input v-model="rule.reason" class="config-input mt-0" type="text" @input="markChanged" /></label>
</div>
</article>
<div v-else class="grid gap-4 p-5 md:grid-cols-2">
<label class="field-label">文件名过滤<textarea v-model="fileNamePatterns" class="filter-textarea" spellcheck="false" placeholder="每行一条通配符&#10;*_____padding_file_*" @input="syncContentFilter" /></label>
<label class="field-label">文件路径过滤<textarea v-model="filePathPatterns" class="filter-textarea" spellcheck="false" placeholder="每行一条通配符&#10;*.pad/*" @input="syncContentFilter" /></label>
</div>
</section>
</TabsContent>
+3
View File
@@ -179,6 +179,9 @@
.config-input {
@apply mt-2 h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus:border-foreground/25 focus:ring-3 focus:ring-ring/15;
}
.filter-textarea {
@apply mt-1 min-h-64 w-full resize-y rounded-lg border bg-background p-3 font-mono text-sm leading-6 text-foreground outline-none transition placeholder:text-muted-foreground focus:border-foreground/25 focus:ring-3 focus:ring-ring/15;
}
dl dt {
@apply text-xs text-muted-foreground;
}
+2 -24
View File
@@ -221,23 +221,9 @@ export interface DiagnosticHistory {
export type NetworkMode = 'ipv4-only' | 'ipv6-only' | 'dual-stack'
export type LogRotation = 'minutely' | 'hourly' | 'daily' | 'never'
export type ContentFilterField = 'file-name' | 'file-path'
export type ContentFilterMatch = 'exact' | 'prefix' | 'suffix' | 'contains' | 'wildcard' | 'regex'
export interface ContentFilterRule {
id: string
enabled: boolean
field: ContentFilterField
match: ContentFilterMatch
value: string
case_sensitive: boolean
action: 'hide'
reason: string
}
export interface ContentFilterConfig {
version: number
file_rules: ContentFilterRule[]
file_name_patterns: string[]
file_path_patterns: string[]
}
export interface AppConfigDto {
@@ -245,7 +231,6 @@ export interface AppConfigDto {
content_filter: ContentFilterConfig
persistence_queue_capacity: number
stats_interval_secs: number
run_duration_secs: number | null
index_batch_size: number
index_interval_millis: number
metadata_limits: {
@@ -277,12 +262,6 @@ export interface AppConfigDto {
find_node_max_in_flight: number
new_destinations_per_minute: number
}
disk_guard: {
enabled: boolean
check_interval_secs: number
minimum_free_bytes: number
resume_free_bytes: number
}
backup: {
enabled: boolean
directory: string
@@ -301,7 +280,6 @@ export interface AppConfigDto {
logging: {
directory: string
file_enabled: boolean
console_enabled: boolean
rotation: LogRotation
retain_files: number
file_prefix: string