feat: 精简配置管理与运行默认值
This commit is contained in:
+1
-1
@@ -67,4 +67,4 @@ EXPOSE 12313/udp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
ENTRYPOINT ["/dht-search/dht-search"]
|
||||
CMD ["--config", "/dht-search/config.toml", "--http-listen", "0.0.0.0:8080", "--web-dir", "/dht-search/web", "--console-logging", "--no-file-logging"]
|
||||
CMD ["--config", "/dht-search/config.toml", "--http-listen", "0.0.0.0:8080", "--web-dir", "/dht-search/web"]
|
||||
|
||||
@@ -183,6 +183,8 @@
|
||||
- [x] 将采集索引持久化和验证运行状态集中到系统诊断页并仅在页面打开时每秒刷新
|
||||
- [x] 将诊断时间范围放入历史趋势区域并精简图表卡片的单行摘要信息
|
||||
- [x] 在 Web 顶栏提供持久化的 DHT 即时启停开关并保留离线索引搜索能力
|
||||
- [x] 将配置页精简为设置与过滤两个页签并合并全部常用参数
|
||||
- [x] 使用数值与单位选择编辑 Metadata 大小并自动换算内部字节数
|
||||
- [x] 实现浏览器持久化明暗主题
|
||||
- [x] 为加载空结果接口错误和失败重试提供明确界面状态
|
||||
- [x] 将生产静态资源交给 Axum 提供并支持单页回退
|
||||
@@ -221,18 +223,16 @@
|
||||
|
||||
- [ ] 统计真实数据的 infohash 重复率和内容重复率
|
||||
- [x] 在统一 `config.toml` 中定义文件名和文件路径隐藏规则
|
||||
- [x] 支持精确前缀后缀包含通配符和正则匹配并限制规则复杂度
|
||||
- [x] 使用文件名和文件路径双文本框按行管理不区分大小写的通配符规则
|
||||
- [x] 保留 RocksDB 原始文件列表并为详情统计搜索和内容聚合生成有效内容视图
|
||||
- [x] 使用规则指纹在配置变化时重算内容组并从 RocksDB 重建 Tantivy
|
||||
- [x] 默认隐藏 BitComet padding 文件以及 `.pad` 和 `.____padding_file` 填充目录
|
||||
- [x] 全部文件被隐藏的 Metadata 只保留原始记录且不进入公开索引
|
||||
- [ ] 根据真实垃圾数据决定是否增加种子名称扩展名和大小准入规则
|
||||
- [ ] 增加按规则 ID 分类的隐藏文件命中指标
|
||||
- [x] 定义可配置的 Metadata 最大大小文件数名称路径长度和目录层级限制
|
||||
- [x] 识别空名称控制字符异常路径大小溢出总大小不一致和文件数量攻击
|
||||
- [ ] 设计可解释的名称标准化规则
|
||||
- [ ] 为模糊相似结果生成聚合候选但不自动删除
|
||||
- [ ] 支持黑名单规则版本和命中原因
|
||||
- [x] 使用带规则指纹的 RocksDB 轻量拒绝记录阻止相同异常 infohash 重复下载
|
||||
- [x] 保留按原因分类的过滤指标但避免保存名称和大文件列表
|
||||
- [ ] 增加误判测试和边界数据集
|
||||
@@ -290,8 +290,11 @@
|
||||
- [x] 记录 Tantivy IndexWriter 内存和 commit 延迟
|
||||
- [ ] 根据实测调整批量大小队列容量和并发
|
||||
- [x] 增加带排空阶段恢复滞回和探测失败保护的磁盘只读降级策略
|
||||
- [x] 固定每分钟检查磁盘并按文件系统容量自动计算保护和恢复阈值
|
||||
- [x] 增加在线 RocksDB 检查点保留上限只读校验和带旧库保留的离线恢复
|
||||
- [x] 增加可配置的终端日志滚动文件日志和保留文件上限
|
||||
- [x] 默认关闭自动检查点并从 Web 隐藏全部备份调度参数
|
||||
- [x] 始终保留终端日志并仅在 Web 暴露滚动文件日志开关
|
||||
- [x] 移除 Docker 对文件日志的命令行覆盖并隐藏无关的基础设施覆盖提示
|
||||
- [x] 验证间歇运行和正常退出恢复
|
||||
- [x] 完成本机约七小时真实持续运行并确认采集索引和搜索服务可用
|
||||
- [ ] 验证二十四小时和七天连续运行
|
||||
|
||||
+3
-40
@@ -3,18 +3,11 @@
|
||||
data_dir = "data"
|
||||
persistence_queue_capacity = 8192
|
||||
stats_interval_secs = 10
|
||||
# run_duration_secs = 3600
|
||||
index_batch_size = 1024
|
||||
index_interval_millis = 5000
|
||||
|
||||
[disk_guard]
|
||||
enabled = true
|
||||
check_interval_secs = 10
|
||||
minimum_free_bytes = 5368709120
|
||||
resume_free_bytes = 6442450944
|
||||
|
||||
[backup]
|
||||
enabled = true
|
||||
enabled = false
|
||||
directory = "data/backups"
|
||||
interval_secs = 21600
|
||||
retain_checkpoints = 3
|
||||
@@ -31,7 +24,6 @@ queue_capacity = 128
|
||||
[logging]
|
||||
directory = "data/logs"
|
||||
file_enabled = true
|
||||
console_enabled = false
|
||||
rotation = "daily"
|
||||
retain_files = 7
|
||||
file_prefix = "dht-search"
|
||||
@@ -44,37 +36,8 @@ max_path_bytes = 4096
|
||||
max_path_depth = 64
|
||||
|
||||
[content_filter]
|
||||
version = 1
|
||||
|
||||
[[content_filter.file_rules]]
|
||||
id = "bitcomet-padding-file"
|
||||
enabled = true
|
||||
field = "file-name"
|
||||
match = "prefix"
|
||||
value = "_____padding_file_"
|
||||
case_sensitive = false
|
||||
action = "hide"
|
||||
reason = "BitComet 分片边界填充文件"
|
||||
|
||||
[[content_filter.file_rules]]
|
||||
id = "generic-pad-directory"
|
||||
enabled = true
|
||||
field = "file-path"
|
||||
match = "regex"
|
||||
value = '(^|/)\.pad/'
|
||||
case_sensitive = false
|
||||
action = "hide"
|
||||
reason = "客户端分片边界填充目录"
|
||||
|
||||
[[content_filter.file_rules]]
|
||||
id = "libtorrent-padding-directory"
|
||||
enabled = true
|
||||
field = "file-path"
|
||||
match = "regex"
|
||||
value = '(^|/)\.____padding_file/'
|
||||
case_sensitive = false
|
||||
action = "hide"
|
||||
reason = "libtorrent 分片边界填充目录"
|
||||
file_name_patterns = ["*_____padding_file_*"]
|
||||
file_path_patterns = ["*.pad/*", "*.____padding_file/*"]
|
||||
|
||||
[dht]
|
||||
enabled = false
|
||||
|
||||
+14
-20
@@ -26,13 +26,13 @@ cargo run -p dht-search -- --config config.toml
|
||||
|
||||
相对目录以 `config.toml` 所在目录为基准解析
|
||||
|
||||
也可以通过命令行覆盖数据目录和本次运行时长
|
||||
也可以通过命令行覆盖数据目录
|
||||
|
||||
```powershell
|
||||
cargo run -p dht-search -- --data-dir D:\data\dht-search --run-duration-secs 3600
|
||||
cargo run -p dht-search -- --data-dir D:\data\dht-search
|
||||
```
|
||||
|
||||
不设置 `run-duration-secs` 时服务持续运行直到收到 Ctrl+C SIGINT 或 SIGTERM
|
||||
服务持续运行直到收到 Ctrl+C SIGINT 或 SIGTERM
|
||||
|
||||
生产 Web 页面需要先在 `src/web` 目录执行 `bun run build` Axum 会从 `http.web_dir` 提供构建结果
|
||||
|
||||
@@ -86,35 +86,29 @@ Windows 下索引每五秒批量提交 临时文件占用会自动指数退避
|
||||
|
||||
### 磁盘空间保护
|
||||
|
||||
应用默认每十秒检查 `data_dir` 所在磁盘的剩余空间 低于保护阈值时先停止接收新 Metadata DHT 状态更新索引任务和可用性验证任务 已经进入有界持久化队列的记录会继续排空 随后进入只读保护
|
||||
应用固定每分钟检查 `data_dir` 所在文件系统的总容量和剩余空间 低于保护阈值时先停止接收新 Metadata DHT 状态更新索引任务和可用性验证任务 已经进入有界持久化队列的记录会继续排空 随后进入只读保护
|
||||
|
||||
只读保护期间 RocksDB 和 Tantivy 不再产生业务写入 现有搜索详情健康检查和 Web 页面仍然可用 剩余空间达到独立恢复阈值后自动恢复采集 使用两个阈值可以避免临界空间附近反复暂停和恢复
|
||||
只读保护始终启用且不需要用户配置 保护阈值取文件系统总容量的 5% 并限制在 512 MiB 到 20 GiB 之间 恢复缓冲取总容量的 1% 并限制在 256 MiB 到 5 GiB 之间 文件系统扩容后会自动采用新阈值
|
||||
|
||||
| 配置项 | 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `disk_guard.enabled` | `true` | 是否启用磁盘空间保护 |
|
||||
| `disk_guard.check_interval_secs` | `10` | 剩余空间检查间隔 |
|
||||
| `disk_guard.minimum_free_bytes` | `5368709120` | 低于 5 GiB 时停止接收新任务 |
|
||||
| `disk_guard.resume_free_bytes` | `6442450944` | 恢复到 6 GiB 时重新接受写入 |
|
||||
只读保护期间 RocksDB 和 Tantivy 不再产生业务写入 现有搜索详情健康检查和 Web 页面仍然可用 剩余空间达到自动恢复阈值后重新接受写入 使用两个阈值可以避免临界空间附近反复暂停和恢复
|
||||
|
||||
磁盘空间探测失败时采用保守策略进入保护状态 `/stats` 返回 `disk_state` `disk_available_bytes` 阈值 活跃写入数 探测失败数 状态转换数和拒绝任务数 Web 运行状态使用绿色或黄色状态点展示正常与保护状态
|
||||
|
||||
### 日志轮转和保留
|
||||
|
||||
应用会在读取配置后初始化日志 默认只写入 `data/logs` 的滚动文件而不重复输出到终端 因此用脚本或后台进程启动时不需要再把标准错误重定向到长期增长的日志文件
|
||||
应用会在读取配置后初始化日志并始终输出到终端 因此 Docker 可以直接通过 `docker logs` 读取运行日志 文件日志默认同时写入 `data/logs` 并允许从 Web 高级设置关闭
|
||||
|
||||
| 配置项 | 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `logging.directory` | `data/logs` | 日志文件目录 相对主配置文件解析 |
|
||||
| `logging.file_enabled` | `true` | 启用滚动文件日志 |
|
||||
| `logging.console_enabled` | `false` | 同时输出到当前终端 |
|
||||
| `logging.rotation` | `daily` | 轮转周期 支持 `minutely` `hourly` `daily` 和 `never` |
|
||||
| `logging.retain_files` | `7` | 最多保留的匹配日志文件数量 |
|
||||
| `logging.file_prefix` | `dht-search` | 日志文件名前缀 |
|
||||
|
||||
默认按天轮转时保留 7 个文件约等于保留最近 7 天 日志组件只清理同目录中同时匹配前缀和 `.log` 后缀的普通文件 不删除目录和符号链接 清理失败会输出错误但不会让服务退出
|
||||
|
||||
开发时需要直接观察终端日志可以设置 `console_enabled = true` 文件日志和终端日志不能同时关闭
|
||||
关闭文件日志不会影响终端输出
|
||||
|
||||
### RocksDB 检查点备份和恢复
|
||||
|
||||
@@ -122,13 +116,13 @@ RocksDB 是唯一权威数据源 应用使用 RocksDB 原生 Checkpoint API 在
|
||||
|
||||
| 配置项 | 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `backup.enabled` | `true` | 是否启用自动检查点 |
|
||||
| `backup.enabled` | `false` | 是否启用自动检查点 默认关闭且不在 Web 展示 |
|
||||
| `backup.directory` | `data/backups` | 检查点目录 相对主配置文件解析 |
|
||||
| `backup.interval_secs` | `21600` | 每 6 小时创建一次检查点 |
|
||||
| `backup.retain_checkpoints` | `3` | 保留最近 3 个自动检查点 |
|
||||
| `backup.create_on_start` | `true` | 每次启动后立即创建一次检查点 |
|
||||
|
||||
备份目录与数据目录位于同一磁盘时 RocksDB 会尽量通过硬链接减少复制开销 放到其他磁盘时可能复制全部数据库文件 创建前会检查备份磁盘剩余空间 磁盘保护期间自动跳过而不会阻塞服务
|
||||
需要自动检查点时可以直接修改 `config.toml` 备份目录与数据目录位于同一磁盘时 RocksDB 会尽量通过硬链接减少复制开销 放到其他磁盘时可能复制全部数据库文件 创建前会检查备份磁盘剩余空间 磁盘保护期间自动跳过而不会阻塞服务
|
||||
|
||||
`/stats` 返回检查点成功失败跳过清理数量 最近成功时间耗时和记录数量 自动清理只处理名称严格匹配 `checkpoint-` 加二十位时间戳的直接子目录 不会删除手工目录文件或符号链接
|
||||
|
||||
@@ -191,10 +185,10 @@ Web 右上角采集开关通过 `/crawler` 即时停止或重新创建 DHT 运
|
||||
|
||||
`/stats` 返回 `metadata_filtered` 总数以及 `metadata_filtered_*` 分类计数 Web 运行状态展示本次运行的过滤总数
|
||||
|
||||
可以通过命令行覆盖独立数据目录并运行五分钟测试 不会污染正式数据目录
|
||||
可以通过命令行覆盖独立数据目录进行测试 不会污染正式数据目录
|
||||
|
||||
```powershell
|
||||
cargo run -p dht-search -- --config config.toml --data-dir data-filter-test --run-duration-secs 300
|
||||
cargo run -p dht-search -- --config config.toml --data-dir data-filter-test
|
||||
Invoke-RestMethod http://127.0.0.1:8080/stats | ConvertTo-Json -Depth 5
|
||||
```
|
||||
|
||||
@@ -204,9 +198,9 @@ Invoke-RestMethod http://127.0.0.1:8080/stats | ConvertTo-Json -Depth 5
|
||||
|
||||
RocksDB 始终保存完整原始 Metadata 隐藏规则不会删除文件或种子 修改或回滚规则后应用会根据规则指纹重新计算内容组并从 RocksDB 重建 Tantivy
|
||||
|
||||
每条规则包含稳定 `id` 开关 匹配字段 匹配方式 值 大小写选项和可读原因 当前字段支持 `file-name` 与 `file-path` 匹配方式支持 `exact` `prefix` `suffix` `contains` `wildcard` 和 `regex` 动作只允许安全的 `hide`
|
||||
配置只包含 `file_name_patterns` 和 `file_path_patterns` 两组不区分大小写的通配符 Web 使用左右两个多行文本框编辑并按行切分 空行和重复规则自动忽略
|
||||
|
||||
通配符中 `*` 表示任意长度字符 `?` 表示一个字符并匹配完整字段 正则表达式使用 Rust `regex` 语法 文件路径在匹配前统一使用 `/` 分隔符
|
||||
通配符中 `*` 表示任意长度字符 `?` 表示一个字符并匹配完整字段 文件路径在匹配前统一使用 `/` 分隔符
|
||||
|
||||
如果一个 Metadata 的全部文件都被隐藏 原始记录仍保留在 RocksDB 但不会进入搜索索引或公开详情
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ mod tests {
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{
|
||||
config::{AppConfigDto, ConfigService, DiskGuardConfig, TomlConfigStore},
|
||||
config::{AppConfigDto, ConfigService, TomlConfigStore},
|
||||
crawler::pipeline::PersistencePipeline,
|
||||
diagnostics::DiagnosticsRuntime,
|
||||
disk_guard::DiskGuard,
|
||||
@@ -144,10 +144,7 @@ mod tests {
|
||||
let search = SearchEngine::open(directory.path().join("tantivy")).unwrap();
|
||||
search.index_pending(repository.as_ref(), 10, 20).unwrap();
|
||||
let repository_trait: Arc<dyn TorrentRepository> = repository.clone();
|
||||
let disk_guard = DiskGuard::new(&DiskGuardConfig {
|
||||
enabled: false,
|
||||
..DiskGuardConfig::default()
|
||||
});
|
||||
let disk_guard = DiskGuard::new();
|
||||
let persistence = PersistencePipeline::start(
|
||||
repository_trait.clone(),
|
||||
4,
|
||||
@@ -295,8 +292,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let original_revision = config_snapshot["revision"].as_str().unwrap().to_owned();
|
||||
let mut invalid_filter = config_snapshot["config"].clone();
|
||||
invalid_filter["content_filter"]["file_rules"][0]["match"] = serde_json::json!("regex");
|
||||
invalid_filter["content_filter"]["file_rules"][0]["value"] = serde_json::json!("(");
|
||||
invalid_filter["content_filter"]["file_name_patterns"][0] =
|
||||
serde_json::json!("x".repeat(1_025));
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
||||
+1
-14
@@ -22,7 +22,7 @@ use crate::{
|
||||
pub(crate) async fn run(config: AppConfig, config_service: ConfigService) -> Result<(), AppError> {
|
||||
std::fs::create_dir_all(&config.data_dir)?;
|
||||
let _data_lock = backup::acquire_data_lock(&config.data_dir)?;
|
||||
let disk_guard = DiskGuard::new(&config.disk_guard);
|
||||
let disk_guard = DiskGuard::new();
|
||||
let database_path = config.data_dir.join("rocksdb");
|
||||
let metadata_limits = config.metadata_limits();
|
||||
let content_filter = Arc::new(config.content_filter()?);
|
||||
@@ -50,7 +50,6 @@ pub(crate) async fn run(config: AppConfig, config_service: ConfigService) -> Res
|
||||
let disk_task = tokio::spawn(disk_guard::run(
|
||||
disk_guard.clone(),
|
||||
config.data_dir.clone(),
|
||||
config.disk_guard.clone(),
|
||||
ingress.clone(),
|
||||
disk_cancel.clone(),
|
||||
));
|
||||
@@ -148,23 +147,11 @@ pub(crate) async fn run(config: AppConfig, config_service: ConfigService) -> Res
|
||||
api_cancel.clone(),
|
||||
));
|
||||
|
||||
let run_duration = async {
|
||||
match config.run_duration_secs {
|
||||
Some(seconds) => tokio::time::sleep(Duration::from_secs(seconds)).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
tokio::pin!(run_duration);
|
||||
|
||||
let run_result = tokio::select! {
|
||||
_ = shutdown::signal() => {
|
||||
tracing::info!("收到退出信号");
|
||||
Ok(())
|
||||
}
|
||||
_ = &mut run_duration => {
|
||||
tracing::info!("达到配置的运行时长");
|
||||
Ok(())
|
||||
}
|
||||
fatal = &mut persistence.fatal => {
|
||||
let message = fatal.unwrap_or_else(|_| "持久化 worker 意外停止".to_owned());
|
||||
Err(AppError::PersistenceWorker(message))
|
||||
|
||||
+20
-40
@@ -16,8 +16,7 @@ use clap::Parser;
|
||||
use crate::error::AppError;
|
||||
|
||||
pub(crate) use model::{
|
||||
AppConfigDto, BackupConfig, DiagnosticsConfig, DiskGuardConfig, LogRotation, LoggingConfig,
|
||||
VerificationConfig,
|
||||
AppConfigDto, BackupConfig, DiagnosticsConfig, LogRotation, LoggingConfig, VerificationConfig,
|
||||
};
|
||||
pub(crate) use runtime::AppConfig;
|
||||
pub(crate) use service::{ConfigService, ConfigServiceError, ConfigSnapshot, ConfigUpdateRequest};
|
||||
@@ -35,12 +34,8 @@ pub(crate) struct Cli {
|
||||
#[arg(long)]
|
||||
web_dir: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
console_logging: bool,
|
||||
#[arg(long)]
|
||||
no_file_logging: bool,
|
||||
#[arg(long)]
|
||||
run_duration_secs: Option<u64>,
|
||||
#[arg(long)]
|
||||
restore_checkpoint: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -71,18 +66,10 @@ impl Cli {
|
||||
dto.http.web_dir = web_dir;
|
||||
command_line_overrides.push("http.web_dir".to_owned());
|
||||
}
|
||||
if self.console_logging {
|
||||
dto.logging.console_enabled = true;
|
||||
command_line_overrides.push("logging.console_enabled".to_owned());
|
||||
}
|
||||
if self.no_file_logging {
|
||||
dto.logging.file_enabled = false;
|
||||
command_line_overrides.push("logging.file_enabled".to_owned());
|
||||
}
|
||||
if self.run_duration_secs.is_some() {
|
||||
dto.run_duration_secs = self.run_duration_secs;
|
||||
command_line_overrides.push("run_duration_secs".to_owned());
|
||||
}
|
||||
let base = config_path
|
||||
.parent()
|
||||
.ok_or_else(|| AppError::Config("配置文件没有父目录".to_owned()))?;
|
||||
@@ -168,9 +155,7 @@ mod tests {
|
||||
data_dir: None,
|
||||
http_listen: None,
|
||||
web_dir: None,
|
||||
console_logging: false,
|
||||
no_file_logging: false,
|
||||
run_duration_secs: None,
|
||||
restore_checkpoint: None,
|
||||
}
|
||||
.load()
|
||||
@@ -199,9 +184,7 @@ mod tests {
|
||||
data_dir: None,
|
||||
http_listen: None,
|
||||
web_dir: None,
|
||||
console_logging: false,
|
||||
no_file_logging: false,
|
||||
run_duration_secs: None,
|
||||
restore_checkpoint: None,
|
||||
}
|
||||
.load()
|
||||
@@ -224,9 +207,7 @@ mod tests {
|
||||
data_dir: None,
|
||||
http_listen: Some(listen),
|
||||
web_dir: Some(PathBuf::from("/dht-search/web")),
|
||||
console_logging: true,
|
||||
no_file_logging: true,
|
||||
run_duration_secs: None,
|
||||
restore_checkpoint: None,
|
||||
}
|
||||
.load()
|
||||
@@ -236,14 +217,8 @@ mod tests {
|
||||
assert!(startup.app.http.web_dir.ends_with("dht-search/web"));
|
||||
assert_eq!(
|
||||
startup.config_service.snapshot().command_line_overrides,
|
||||
[
|
||||
"http.listen",
|
||||
"http.web_dir",
|
||||
"logging.console_enabled",
|
||||
"logging.file_enabled"
|
||||
]
|
||||
["http.listen", "http.web_dir", "logging.file_enabled"]
|
||||
);
|
||||
assert!(startup.app.logging.console_enabled);
|
||||
assert!(!startup.app.logging.file_enabled);
|
||||
}
|
||||
|
||||
@@ -264,24 +239,29 @@ mod tests {
|
||||
#[test]
|
||||
fn invalid_embedded_content_filter_is_rejected() {
|
||||
let mut dto = AppConfigDto::default();
|
||||
dto.content_filter.file_rules[0].match_kind = crate::domain::FileMatchKind::Regex;
|
||||
dto.content_filter.file_rules[0].value = "(".to_owned();
|
||||
dto.content_filter.file_name_patterns[0] = "x".repeat(1_025);
|
||||
assert!(matches!(resolve(dto), Err(AppError::ContentFilter(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_resume_threshold_must_exceed_minimum() {
|
||||
fn content_filter_patterns_ignore_blank_duplicate_and_case() {
|
||||
let mut dto = AppConfigDto::default();
|
||||
dto.disk_guard.resume_free_bytes = dto.disk_guard.minimum_free_bytes;
|
||||
assert!(matches!(resolve(dto), Err(AppError::Config(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_requires_at_least_one_output() {
|
||||
let mut dto = AppConfigDto::default();
|
||||
dto.logging.file_enabled = false;
|
||||
dto.logging.console_enabled = false;
|
||||
assert!(matches!(resolve(dto), Err(AppError::Config(_))));
|
||||
dto.content_filter.file_name_patterns = vec![
|
||||
String::new(),
|
||||
"*PADDING_FILE*".to_owned(),
|
||||
"*padding_file*".to_owned(),
|
||||
];
|
||||
let config = resolve(dto).unwrap();
|
||||
assert_eq!(config.content_filter.to_domain().file_rules.len(), 3);
|
||||
let filter = config.content_filter().unwrap();
|
||||
assert!(filter.is_hidden(&crate::domain::TorrentFile {
|
||||
path: "release/Padding_File_1".to_owned(),
|
||||
size: 1,
|
||||
}));
|
||||
assert!(filter.is_hidden(&crate::domain::TorrentFile {
|
||||
path: "release/.pad/1".to_owned(),
|
||||
size: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,15 +12,13 @@ use crate::domain::{
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct AppConfigDto {
|
||||
pub(crate) data_dir: PathBuf,
|
||||
pub(crate) content_filter: ContentFilterConfig,
|
||||
pub(crate) content_filter: ContentFilterConfigDto,
|
||||
pub(crate) persistence_queue_capacity: usize,
|
||||
pub(crate) stats_interval_secs: u64,
|
||||
pub(crate) run_duration_secs: Option<u64>,
|
||||
pub(crate) index_batch_size: usize,
|
||||
pub(crate) index_interval_millis: u64,
|
||||
pub(crate) metadata_limits: MetadataLimitsConfig,
|
||||
pub(crate) dht: DhtConfig,
|
||||
pub(crate) disk_guard: DiskGuardConfig,
|
||||
pub(crate) backup: BackupConfig,
|
||||
pub(crate) diagnostics: DiagnosticsConfig,
|
||||
pub(crate) logging: LoggingConfig,
|
||||
@@ -28,6 +26,32 @@ pub(crate) struct AppConfigDto {
|
||||
pub(crate) verification: VerificationConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct ContentFilterConfigDto {
|
||||
pub(crate) file_name_patterns: Vec<String>,
|
||||
pub(crate) file_path_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl ContentFilterConfigDto {
|
||||
pub(crate) fn to_domain(&self) -> ContentFilterConfig {
|
||||
let mut file_rules = pattern_rules(
|
||||
"file-name",
|
||||
FileMatchField::FileName,
|
||||
&self.file_name_patterns,
|
||||
);
|
||||
file_rules.extend(pattern_rules(
|
||||
"file-path",
|
||||
FileMatchField::FilePath,
|
||||
&self.file_path_patterns,
|
||||
));
|
||||
ContentFilterConfig {
|
||||
version: 1,
|
||||
file_rules,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct DhtConfig {
|
||||
@@ -60,21 +84,11 @@ pub(crate) struct HttpConfig {
|
||||
pub(crate) web_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct DiskGuardConfig {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) check_interval_secs: u64,
|
||||
pub(crate) minimum_free_bytes: u64,
|
||||
pub(crate) resume_free_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(crate) struct LoggingConfig {
|
||||
pub(crate) directory: PathBuf,
|
||||
pub(crate) file_enabled: bool,
|
||||
pub(crate) console_enabled: bool,
|
||||
pub(crate) rotation: LogRotation,
|
||||
pub(crate) retain_files: usize,
|
||||
pub(crate) file_prefix: String,
|
||||
@@ -148,12 +162,10 @@ impl Default for AppConfigDto {
|
||||
content_filter: default_content_filter(),
|
||||
persistence_queue_capacity: 8_192,
|
||||
stats_interval_secs: 10,
|
||||
run_duration_secs: None,
|
||||
index_batch_size: 1_024,
|
||||
index_interval_millis: 5_000,
|
||||
metadata_limits: MetadataLimitsConfig::default(),
|
||||
dht: DhtConfig::default(),
|
||||
disk_guard: DiskGuardConfig::default(),
|
||||
backup: BackupConfig::default(),
|
||||
diagnostics: DiagnosticsConfig::default(),
|
||||
logging: LoggingConfig::default(),
|
||||
@@ -163,44 +175,50 @@ impl Default for AppConfigDto {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_content_filter() -> ContentFilterConfig {
|
||||
ContentFilterConfig {
|
||||
version: 1,
|
||||
file_rules: vec![
|
||||
FileFilterRule {
|
||||
id: "bitcomet-padding-file".to_owned(),
|
||||
enabled: true,
|
||||
field: FileMatchField::FileName,
|
||||
match_kind: FileMatchKind::Prefix,
|
||||
value: "_____padding_file_".to_owned(),
|
||||
case_sensitive: false,
|
||||
action: FileRuleAction::Hide,
|
||||
reason: "BitComet 分片边界填充文件".to_owned(),
|
||||
},
|
||||
FileFilterRule {
|
||||
id: "generic-pad-directory".to_owned(),
|
||||
enabled: true,
|
||||
field: FileMatchField::FilePath,
|
||||
match_kind: FileMatchKind::Regex,
|
||||
value: r"(^|/)\.pad/".to_owned(),
|
||||
case_sensitive: false,
|
||||
action: FileRuleAction::Hide,
|
||||
reason: "客户端分片边界填充目录".to_owned(),
|
||||
},
|
||||
FileFilterRule {
|
||||
id: "libtorrent-padding-directory".to_owned(),
|
||||
enabled: true,
|
||||
field: FileMatchField::FilePath,
|
||||
match_kind: FileMatchKind::Regex,
|
||||
value: r"(^|/)\.____padding_file/".to_owned(),
|
||||
case_sensitive: false,
|
||||
action: FileRuleAction::Hide,
|
||||
reason: "libtorrent 分片边界填充目录".to_owned(),
|
||||
},
|
||||
],
|
||||
fn default_content_filter() -> ContentFilterConfigDto {
|
||||
ContentFilterConfigDto {
|
||||
file_name_patterns: vec!["*_____padding_file_*".to_owned()],
|
||||
file_path_patterns: vec!["*.pad/*".to_owned(), "*.____padding_file/*".to_owned()],
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern_rules(
|
||||
id_prefix: &str,
|
||||
field: FileMatchField,
|
||||
patterns: &[String],
|
||||
) -> Vec<FileFilterRule> {
|
||||
let mut patterns: Vec<_> = patterns
|
||||
.iter()
|
||||
.map(|pattern| pattern.trim())
|
||||
.filter(|pattern| !pattern.is_empty())
|
||||
.map(|pattern| {
|
||||
if field == FileMatchField::FilePath {
|
||||
pattern.replace('\\', "/").to_lowercase()
|
||||
} else {
|
||||
pattern.to_lowercase()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
patterns.sort_unstable();
|
||||
patterns.dedup();
|
||||
patterns
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
let fingerprint = blake3::hash(format!("{id_prefix}\0{value}").as_bytes());
|
||||
FileFilterRule {
|
||||
id: format!("{id_prefix}-{fingerprint}"),
|
||||
enabled: true,
|
||||
field,
|
||||
match_kind: FileMatchKind::Wildcard,
|
||||
value,
|
||||
case_sensitive: false,
|
||||
action: FileRuleAction::Hide,
|
||||
reason: String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Default for MetadataLimitsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -249,23 +267,11 @@ impl Default for HttpConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiskGuardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
check_interval_secs: 10,
|
||||
minimum_free_bytes: 5 * 1024 * 1024 * 1024,
|
||||
resume_free_bytes: 6 * 1024 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoggingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
directory: PathBuf::from("data/logs"),
|
||||
file_enabled: true,
|
||||
console_enabled: false,
|
||||
rotation: LogRotation::Daily,
|
||||
retain_files: 7,
|
||||
file_prefix: "dht-search".to_owned(),
|
||||
@@ -276,7 +282,7 @@ impl Default for LoggingConfig {
|
||||
impl Default for BackupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
directory: PathBuf::from("data/backups"),
|
||||
interval_secs: 6 * 60 * 60,
|
||||
retain_checkpoints: 3,
|
||||
|
||||
@@ -28,7 +28,7 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn content_filter(&self) -> Result<ContentFilter, AppError> {
|
||||
ContentFilter::compile(self.content_filter.clone()).map_err(AppError::from)
|
||||
ContentFilter::compile(self.content_filter.to_domain()).map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub(crate) fn dht_options(&self) -> DHTOptions {
|
||||
@@ -95,7 +95,7 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
ContentFilter::compile(self.content_filter.clone())?;
|
||||
ContentFilter::compile(self.content_filter.to_domain())?;
|
||||
if self.persistence_queue_capacity == 0 {
|
||||
return Err(AppError::Config(
|
||||
"persistence_queue_capacity 必须大于零".to_owned(),
|
||||
@@ -106,11 +106,6 @@ impl AppConfig {
|
||||
"stats_interval_secs 必须大于零".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.run_duration_secs == Some(0) {
|
||||
return Err(AppError::Config(
|
||||
"run_duration_secs 必须大于零或不设置".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.index_batch_size == 0 || self.index_interval_millis == 0 {
|
||||
return Err(AppError::Config(
|
||||
"索引批量大小和执行间隔必须大于零".to_owned(),
|
||||
@@ -141,14 +136,6 @@ impl AppConfig {
|
||||
"验证队列容量并发尝试数租约和轮询间隔必须大于零".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.disk_guard.check_interval_secs == 0
|
||||
|| self.disk_guard.minimum_free_bytes == 0
|
||||
|| self.disk_guard.resume_free_bytes <= self.disk_guard.minimum_free_bytes
|
||||
{
|
||||
return Err(AppError::Config(
|
||||
"磁盘检查间隔必须大于零且恢复阈值必须大于保护阈值".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.backup.interval_secs == 0 || self.backup.retain_checkpoints == 0 {
|
||||
return Err(AppError::Config(
|
||||
"备份间隔和检查点保留数量必须大于零".to_owned(),
|
||||
@@ -171,11 +158,6 @@ impl AppConfig {
|
||||
"检查点目录不能位于 RocksDB 数据库目录内部".to_owned(),
|
||||
));
|
||||
}
|
||||
if !self.logging.file_enabled && !self.logging.console_enabled {
|
||||
return Err(AppError::Config(
|
||||
"文件日志和终端日志不能同时关闭".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.logging.file_enabled && self.logging.retain_files == 0 {
|
||||
return Err(AppError::Config("日志保留文件数量必须大于零".to_owned()));
|
||||
}
|
||||
|
||||
@@ -246,13 +246,8 @@ mod tests {
|
||||
use dht_crawler::FileInfo;
|
||||
|
||||
use super::*;
|
||||
use crate::config::DiskGuardConfig;
|
||||
|
||||
fn disk_guard() -> DiskGuard {
|
||||
DiskGuard::new(&DiskGuardConfig {
|
||||
enabled: false,
|
||||
..DiskGuardConfig::default()
|
||||
})
|
||||
DiskGuard::new()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -420,12 +415,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn read_only_guard_rejects_new_metadata_without_blocking_shutdown() {
|
||||
let repository = Arc::new(MemoryRepository::default());
|
||||
let guard = DiskGuard::new(&DiskGuardConfig {
|
||||
enabled: true,
|
||||
check_interval_secs: 1,
|
||||
minimum_free_bytes: 100,
|
||||
resume_free_bytes: 200,
|
||||
});
|
||||
let guard = DiskGuard::with_thresholds_for_test(100, 200);
|
||||
guard.observe_available(99, 0);
|
||||
let pipeline =
|
||||
PersistencePipeline::start(repository.clone(), 1, MetadataLimits::default(), guard);
|
||||
|
||||
@@ -334,9 +334,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::DiskGuardConfig, crawler::pipeline::PersistencePipeline, disk_guard::DiskGuard,
|
||||
};
|
||||
use crate::{crawler::pipeline::PersistencePipeline, disk_guard::DiskGuard};
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_persists_a_queryable_snapshot_and_stops_cleanly() {
|
||||
@@ -344,10 +342,7 @@ mod tests {
|
||||
let repository =
|
||||
Arc::new(RocksTorrentRepository::open(directory.path().join("rocksdb")).unwrap());
|
||||
let repository_trait: Arc<dyn TorrentRepository> = repository.clone();
|
||||
let disk_guard = DiskGuard::new(&DiskGuardConfig {
|
||||
enabled: false,
|
||||
..DiskGuardConfig::default()
|
||||
});
|
||||
let disk_guard = DiskGuard::new();
|
||||
let persistence = PersistencePipeline::start(
|
||||
repository_trait,
|
||||
4,
|
||||
|
||||
@@ -12,9 +12,16 @@ use std::{
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{config::DiskGuardConfig, crawler::pipeline::PersistenceIngress};
|
||||
use crate::crawler::pipeline::PersistenceIngress;
|
||||
|
||||
const UNKNOWN_AVAILABLE_BYTES: u64 = u64::MAX;
|
||||
const CHECK_INTERVAL_SECS: u64 = 60;
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
const GIB: u64 = 1024 * MIB;
|
||||
const MINIMUM_FLOOR: u64 = 512 * MIB;
|
||||
const MINIMUM_CEILING: u64 = 20 * GIB;
|
||||
const RESUME_BUFFER_FLOOR: u64 = 256 * MIB;
|
||||
const RESUME_BUFFER_CEILING: u64 = 5 * GIB;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DiskMode {
|
||||
@@ -52,9 +59,8 @@ pub(crate) struct DiskGuard {
|
||||
}
|
||||
|
||||
struct DiskGuardInner {
|
||||
enabled: bool,
|
||||
minimum_free_bytes: u64,
|
||||
resume_free_bytes: u64,
|
||||
minimum_free_bytes: AtomicU64,
|
||||
resume_free_bytes: AtomicU64,
|
||||
state: Mutex<GateState>,
|
||||
available_bytes: AtomicU64,
|
||||
probe_failed: AtomicBool,
|
||||
@@ -75,11 +81,10 @@ pub(crate) struct DiskWritePermit {
|
||||
pub(crate) async fn run(
|
||||
guard: DiskGuard,
|
||||
data_dir: PathBuf,
|
||||
config: DiskGuardConfig,
|
||||
persistence: PersistenceIngress,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(config.check_interval_secs));
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(CHECK_INTERVAL_SECS));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -92,12 +97,12 @@ pub(crate) async fn run(
|
||||
}
|
||||
|
||||
impl DiskGuard {
|
||||
pub(crate) fn new(config: &DiskGuardConfig) -> Self {
|
||||
pub(crate) fn new() -> Self {
|
||||
let (minimum_free_bytes, resume_free_bytes) = automatic_thresholds(100 * GIB);
|
||||
Self {
|
||||
inner: Arc::new(DiskGuardInner {
|
||||
enabled: config.enabled,
|
||||
minimum_free_bytes: config.minimum_free_bytes,
|
||||
resume_free_bytes: config.resume_free_bytes,
|
||||
minimum_free_bytes: AtomicU64::new(minimum_free_bytes),
|
||||
resume_free_bytes: AtomicU64::new(resume_free_bytes),
|
||||
state: Mutex::new(GateState {
|
||||
mode: DiskMode::Normal,
|
||||
active_writes: 0,
|
||||
@@ -112,12 +117,37 @@ impl DiskGuard {
|
||||
}
|
||||
|
||||
pub(crate) fn probe(&self, path: &Path, persistence_queue: usize) {
|
||||
match fs2::available_space(path) {
|
||||
Ok(available) => self.observe_available(available, persistence_queue),
|
||||
Err(error) => self.observe_probe_error(&error, persistence_queue),
|
||||
match (fs2::total_space(path), fs2::available_space(path)) {
|
||||
(Ok(total), Ok(available)) => {
|
||||
let (minimum, resume) = automatic_thresholds(total);
|
||||
self.inner
|
||||
.minimum_free_bytes
|
||||
.store(minimum, Ordering::Relaxed);
|
||||
self.inner
|
||||
.resume_free_bytes
|
||||
.store(resume, Ordering::Relaxed);
|
||||
self.observe_available(available, persistence_queue);
|
||||
}
|
||||
(Err(error), _) | (_, Err(error)) => {
|
||||
self.observe_probe_error(&error, persistence_queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_thresholds_for_test(minimum: u64, resume: u64) -> Self {
|
||||
let guard = Self::new();
|
||||
guard
|
||||
.inner
|
||||
.minimum_free_bytes
|
||||
.store(minimum, Ordering::Relaxed);
|
||||
guard
|
||||
.inner
|
||||
.resume_free_bytes
|
||||
.store(resume, Ordering::Relaxed);
|
||||
guard
|
||||
}
|
||||
|
||||
pub(crate) fn begin_admission(&self) -> Option<DiskWritePermit> {
|
||||
let permit = self.begin_write(false);
|
||||
if permit.is_none() {
|
||||
@@ -153,8 +183,8 @@ impl DiskGuard {
|
||||
DiskGuardSnapshot {
|
||||
mode: state.mode,
|
||||
available_bytes: (available != UNKNOWN_AVAILABLE_BYTES).then_some(available),
|
||||
minimum_free_bytes: self.inner.minimum_free_bytes,
|
||||
resume_free_bytes: self.inner.resume_free_bytes,
|
||||
minimum_free_bytes: self.inner.minimum_free_bytes.load(Ordering::Relaxed),
|
||||
resume_free_bytes: self.inner.resume_free_bytes.load(Ordering::Relaxed),
|
||||
active_writes: state.active_writes,
|
||||
probe_failed: self.inner.probe_failed.load(Ordering::Relaxed),
|
||||
probe_failures: self.inner.probe_failures.load(Ordering::Relaxed),
|
||||
@@ -164,17 +194,6 @@ impl DiskGuard {
|
||||
}
|
||||
|
||||
fn begin_write(&self, allow_draining: bool) -> Option<DiskWritePermit> {
|
||||
if !self.inner.enabled {
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.active_writes = state.active_writes.saturating_add(1);
|
||||
return Some(DiskWritePermit {
|
||||
inner: self.inner.clone(),
|
||||
});
|
||||
}
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
@@ -196,13 +215,12 @@ impl DiskGuard {
|
||||
.available_bytes
|
||||
.store(available, Ordering::Relaxed);
|
||||
self.inner.probe_failed.store(false, Ordering::Relaxed);
|
||||
if !self.inner.enabled {
|
||||
return;
|
||||
}
|
||||
if available < self.inner.minimum_free_bytes {
|
||||
let minimum_free_bytes = self.inner.minimum_free_bytes.load(Ordering::Relaxed);
|
||||
let resume_free_bytes = self.inner.resume_free_bytes.load(Ordering::Relaxed);
|
||||
if available < minimum_free_bytes {
|
||||
self.transition_to(DiskMode::Draining, Some(available), None);
|
||||
self.finish_draining(persistence_queue);
|
||||
} else if available >= self.inner.resume_free_bytes {
|
||||
} else if available >= resume_free_bytes {
|
||||
self.transition_to(DiskMode::Normal, Some(available), None);
|
||||
} else {
|
||||
self.finish_draining(persistence_queue);
|
||||
@@ -215,9 +233,6 @@ impl DiskGuard {
|
||||
.store(UNKNOWN_AVAILABLE_BYTES, Ordering::Relaxed);
|
||||
self.inner.probe_failed.store(true, Ordering::Relaxed);
|
||||
self.inner.probe_failures.fetch_add(1, Ordering::Relaxed);
|
||||
if !self.inner.enabled {
|
||||
return;
|
||||
}
|
||||
self.transition_to(DiskMode::Draining, None, Some(error));
|
||||
self.finish_draining(persistence_queue);
|
||||
}
|
||||
@@ -283,6 +298,12 @@ impl DiskGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn automatic_thresholds(total_bytes: u64) -> (u64, u64) {
|
||||
let minimum = (total_bytes / 20).clamp(MINIMUM_FLOOR, MINIMUM_CEILING);
|
||||
let resume_buffer = (total_bytes / 100).clamp(RESUME_BUFFER_FLOOR, RESUME_BUFFER_CEILING);
|
||||
(minimum, minimum.saturating_add(resume_buffer))
|
||||
}
|
||||
|
||||
impl Drop for DiskWritePermit {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self
|
||||
@@ -299,12 +320,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn guard() -> DiskGuard {
|
||||
DiskGuard::new(&DiskGuardConfig {
|
||||
enabled: true,
|
||||
check_interval_secs: 1,
|
||||
minimum_free_bytes: 100,
|
||||
resume_free_bytes: 200,
|
||||
})
|
||||
DiskGuard::with_thresholds_for_test(100, 200)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -343,4 +359,11 @@ mod tests {
|
||||
assert!(guard.begin_new_write().is_none());
|
||||
assert!(guard.begin_drain_write().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_scale_with_capacity_and_remain_bounded() {
|
||||
assert_eq!(automatic_thresholds(20 * GIB), (GIB, GIB + 256 * MIB));
|
||||
assert_eq!(automatic_thresholds(100 * GIB), (5 * GIB, 6 * GIB));
|
||||
assert_eq!(automatic_thresholds(1024 * GIB), (20 * GIB, 25 * GIB));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ pub async fn run_cli() -> std::process::ExitCode {
|
||||
let mut logging = startup.app.logging.clone();
|
||||
if restore_requested {
|
||||
logging.file_enabled = false;
|
||||
logging.console_enabled = true;
|
||||
}
|
||||
let _telemetry = match telemetry::init(&logging) {
|
||||
Ok(guard) => guard,
|
||||
|
||||
@@ -15,12 +15,10 @@ pub(crate) struct TelemetryGuard {
|
||||
pub(crate) fn init(config: &LoggingConfig) -> Result<TelemetryGuard, String> {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("warn,dht_search=info,dht_crawler=info"));
|
||||
let console_layer = config.console_enabled.then(|| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stderr()))
|
||||
.with_writer(std::io::stderr)
|
||||
});
|
||||
let console_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stderr()))
|
||||
.with_writer(std::io::stderr);
|
||||
let (file_layer, file_guard) = if config.file_enabled {
|
||||
let appender = build_file_appender(config)?;
|
||||
let (writer, guard) = tracing_appender::non_blocking(appender);
|
||||
|
||||
@@ -15,8 +15,6 @@ use dht_crawler::DHTServer;
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::config::DiskGuardConfig;
|
||||
use crate::{
|
||||
config::VerificationConfig,
|
||||
disk_guard::{DiskGuard, DiskWritePermit},
|
||||
@@ -104,10 +102,7 @@ impl VerificationIngress {
|
||||
repository,
|
||||
capacity,
|
||||
VerificationStats::default(),
|
||||
DiskGuard::new(&DiskGuardConfig {
|
||||
enabled: false,
|
||||
..DiskGuardConfig::default()
|
||||
}),
|
||||
DiskGuard::new(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
|
||||
- 仅在系统诊断页打开时每秒刷新服务状态
|
||||
- 独立展示进程 RocksDB Tantivy DHT 和队列诊断数据
|
||||
- 使用懒加载 ECharts 展示内存存储压力 Metadata 吞吐以及 HTTP 请求错误趋势
|
||||
- 使用 reka-ui 页签按职责分组的完整配置查看编辑校验保存和重启提示
|
||||
- 使用设置与过滤两个精简页签管理常用配置 Metadata 单位换算和按行通配符
|
||||
- 搜索诊断配置使用互不冲突的单页路由
|
||||
- 加载 空结果 接口错误 重试和移动端适配
|
||||
|
||||
|
||||
@@ -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="每行一条通配符 *_____padding_file_*" @input="syncContentFilter" /></label>
|
||||
<label class="field-label">文件路径过滤<textarea v-model="filePathPatterns" class="filter-textarea" spellcheck="false" placeholder="每行一条通配符 *.pad/*" @input="syncContentFilter" /></label>
|
||||
</div>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user