1309 lines
39 KiB
Rust
1309 lines
39 KiB
Rust
use std::collections::{HashSet, VecDeque};
|
||
use std::env;
|
||
use std::fs::{self, File};
|
||
use std::io::{self, BufRead, BufReader, IsTerminal, Read, Write};
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, Stdio};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::thread;
|
||
use std::time::Instant;
|
||
|
||
const DEFAULT_SAMPLES: usize = 5;
|
||
const DEFAULT_LIMIT: usize = 200;
|
||
const DEFAULT_SCAN_DEPTH: usize = 3;
|
||
const DEFAULT_DISCOVER_DEPTH: usize = 4;
|
||
const MAX_DISCOVER_DIRS: usize = 20_000;
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct Config {
|
||
roots: Vec<PathBuf>,
|
||
iccid: String,
|
||
date: Option<String>,
|
||
interface: Option<String>,
|
||
contains: Vec<String>,
|
||
lines_mode: bool,
|
||
count_only: bool,
|
||
list_roots: bool,
|
||
choose_roots: bool,
|
||
interactive: bool,
|
||
include_gz: bool,
|
||
samples: usize,
|
||
limit: usize,
|
||
threads: usize,
|
||
max_depth: usize,
|
||
max_files: Option<usize>,
|
||
}
|
||
|
||
impl Default for Config {
|
||
fn default() -> Self {
|
||
let threads = thread::available_parallelism()
|
||
.map(|n| n.get())
|
||
.unwrap_or(4)
|
||
.clamp(1, 16);
|
||
Self {
|
||
roots: Vec::new(),
|
||
iccid: String::new(),
|
||
date: None,
|
||
interface: None,
|
||
contains: Vec::new(),
|
||
lines_mode: false,
|
||
count_only: false,
|
||
list_roots: false,
|
||
choose_roots: false,
|
||
interactive: false,
|
||
include_gz: true,
|
||
samples: DEFAULT_SAMPLES,
|
||
limit: DEFAULT_LIMIT,
|
||
threads,
|
||
max_depth: DEFAULT_SCAN_DEPTH,
|
||
max_files: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct RootCandidate {
|
||
path: PathBuf,
|
||
log_files: usize,
|
||
gz_files: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct SampleLine {
|
||
file: PathBuf,
|
||
line_no: usize,
|
||
line: String,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct FileResult {
|
||
path: PathBuf,
|
||
matched: usize,
|
||
scanned_lines: usize,
|
||
samples: Vec<SampleLine>,
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct MatchFilter {
|
||
iccid: String,
|
||
date: Option<String>,
|
||
interface_terms: Vec<String>,
|
||
contains: Vec<String>,
|
||
}
|
||
|
||
fn main() {
|
||
let start = Instant::now();
|
||
let mut cfg = match parse_args() {
|
||
Ok(cfg) => cfg,
|
||
Err(msg) => {
|
||
eprintln!("{msg}");
|
||
print_usage(io::stderr());
|
||
std::process::exit(2);
|
||
}
|
||
};
|
||
|
||
if cfg.list_roots {
|
||
let roots = if cfg.roots.is_empty() {
|
||
discover_roots(DEFAULT_DISCOVER_DEPTH)
|
||
} else {
|
||
discover_log_dirs(&cfg.roots, cfg.max_depth, cfg.include_gz)
|
||
};
|
||
print_root_candidates(&roots);
|
||
return;
|
||
}
|
||
|
||
if cfg.interactive {
|
||
cfg = run_wizard(cfg).unwrap_or_else(|err| {
|
||
eprintln!("交互配置失败: {err}");
|
||
std::process::exit(2);
|
||
});
|
||
}
|
||
|
||
if cfg.iccid.is_empty() {
|
||
eprintln!("必须传入 --iccid,避免误扫大日志");
|
||
print_usage(io::stderr());
|
||
std::process::exit(2);
|
||
}
|
||
|
||
let roots_were_provided = !cfg.roots.is_empty() || !roots_from_args().is_empty();
|
||
if cfg.roots.is_empty() {
|
||
cfg.roots = resolve_default_roots();
|
||
}
|
||
|
||
if cfg.choose_roots || (!roots_were_provided && !cfg.interactive && io::stdin().is_terminal()) {
|
||
cfg.roots = choose_roots(&cfg.roots, cfg.max_depth, cfg.include_gz).unwrap_or_else(|err| {
|
||
eprintln!("选择目录失败: {err}");
|
||
std::process::exit(2);
|
||
});
|
||
}
|
||
|
||
if cfg.roots.is_empty() {
|
||
eprintln!("没有可用日志目录。先运行 --list-roots 查看自动发现结果,或确认日志目录已挂载。");
|
||
std::process::exit(2);
|
||
}
|
||
|
||
let filter = MatchFilter {
|
||
iccid: cfg.iccid.clone(),
|
||
date: cfg.date.clone(),
|
||
interface_terms: interface_terms(cfg.interface.as_deref()).unwrap_or_else(|err| {
|
||
eprintln!("{err}");
|
||
std::process::exit(2);
|
||
}),
|
||
contains: cfg.contains.clone(),
|
||
};
|
||
|
||
let mut files = collect_log_files(
|
||
&cfg.roots,
|
||
cfg.max_depth,
|
||
cfg.include_gz,
|
||
cfg.date.as_deref(),
|
||
);
|
||
files.sort_by(|a, b| b.modified_score.cmp(&a.modified_score));
|
||
if let Some(max_files) = cfg.max_files {
|
||
files.truncate(max_files);
|
||
}
|
||
|
||
if files.is_empty() {
|
||
eprintln!("没有找到可扫描日志文件。");
|
||
std::process::exit(1);
|
||
}
|
||
|
||
let scan_total = files.len();
|
||
let queue = Arc::new(Mutex::new(VecDeque::from(files)));
|
||
let results = Arc::new(Mutex::new(Vec::<FileResult>::new()));
|
||
let filter = Arc::new(filter);
|
||
let cfg_arc = Arc::new(cfg.clone());
|
||
|
||
let mut handles = Vec::new();
|
||
for _ in 0..cfg.threads {
|
||
let queue = Arc::clone(&queue);
|
||
let results = Arc::clone(&results);
|
||
let filter = Arc::clone(&filter);
|
||
let cfg_arc = Arc::clone(&cfg_arc);
|
||
handles.push(thread::spawn(move || loop {
|
||
let file = {
|
||
let mut guard = queue.lock().expect("文件队列锁异常");
|
||
guard.pop_front()
|
||
};
|
||
let Some(file) = file else {
|
||
break;
|
||
};
|
||
let result = scan_file(&file.path, &filter, &cfg_arc);
|
||
results.lock().expect("结果锁异常").push(result);
|
||
}));
|
||
}
|
||
|
||
for handle in handles {
|
||
if handle.join().is_err() {
|
||
eprintln!("有扫描线程异常退出");
|
||
}
|
||
}
|
||
|
||
let mut results = Arc::try_unwrap(results)
|
||
.expect("结果仍被引用")
|
||
.into_inner()
|
||
.expect("结果锁异常");
|
||
results.sort_by(|a, b| b.matched.cmp(&a.matched).then_with(|| a.path.cmp(&b.path)));
|
||
|
||
print_results(&cfg, &results, scan_total, start.elapsed().as_millis());
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct LogFile {
|
||
path: PathBuf,
|
||
modified_score: u64,
|
||
}
|
||
|
||
fn parse_args() -> Result<Config, String> {
|
||
let mut cfg = Config::default();
|
||
let raw_args: Vec<String> = env::args().skip(1).collect();
|
||
if raw_args.is_empty() && io::stdin().is_terminal() {
|
||
cfg.interactive = true;
|
||
}
|
||
let mut args = raw_args.into_iter().peekable();
|
||
while let Some(arg) = args.next() {
|
||
match arg.as_str() {
|
||
"--root" => cfg
|
||
.roots
|
||
.push(PathBuf::from(next_value(&mut args, "--root")?)),
|
||
"--roots" => add_roots(&mut cfg.roots, &next_value(&mut args, "--roots")?),
|
||
"--iccid" => cfg.iccid = next_value(&mut args, "--iccid")?,
|
||
"--date" => cfg.date = Some(next_value(&mut args, "--date")?),
|
||
"--interface" | "--api" => cfg.interface = Some(next_value(&mut args, "--interface")?),
|
||
"--contains" => cfg.contains.push(next_value(&mut args, "--contains")?),
|
||
"--lines" => cfg.lines_mode = true,
|
||
"--count-only" => cfg.count_only = true,
|
||
"--list-roots" => cfg.list_roots = true,
|
||
"--choose" => cfg.choose_roots = true,
|
||
"--interactive" | "-i" => cfg.interactive = true,
|
||
"--no-gz" => cfg.include_gz = false,
|
||
"--samples" => {
|
||
cfg.samples = parse_usize(&next_value(&mut args, "--samples")?, "--samples")?
|
||
}
|
||
"--limit" => cfg.limit = parse_usize(&next_value(&mut args, "--limit")?, "--limit")?,
|
||
"--threads" => {
|
||
cfg.threads =
|
||
parse_usize(&next_value(&mut args, "--threads")?, "--threads")?.clamp(1, 64)
|
||
}
|
||
"--max-depth" => {
|
||
cfg.max_depth = parse_usize(&next_value(&mut args, "--max-depth")?, "--max-depth")?
|
||
}
|
||
"--max-files" => {
|
||
cfg.max_files = Some(parse_usize(
|
||
&next_value(&mut args, "--max-files")?,
|
||
"--max-files",
|
||
)?)
|
||
}
|
||
"-h" | "--help" => {
|
||
print_usage(io::stdout());
|
||
std::process::exit(0);
|
||
}
|
||
_ => return Err(format!("未知参数: {arg}")),
|
||
}
|
||
}
|
||
if cfg.roots.is_empty() {
|
||
for root in roots_from_args() {
|
||
cfg.roots.push(root);
|
||
}
|
||
}
|
||
Ok(cfg)
|
||
}
|
||
|
||
fn next_value(
|
||
args: &mut std::iter::Peekable<impl Iterator<Item = String>>,
|
||
name: &str,
|
||
) -> Result<String, String> {
|
||
args.next().ok_or_else(|| format!("{name} 缺少参数"))
|
||
}
|
||
|
||
fn parse_usize(raw: &str, name: &str) -> Result<usize, String> {
|
||
raw.parse::<usize>()
|
||
.map_err(|_| format!("{name} 需要整数,当前值: {raw}"))
|
||
}
|
||
|
||
fn roots_from_args() -> Vec<PathBuf> {
|
||
let mut roots = Vec::new();
|
||
if let Ok(raw) = env::var("JUNHONG_WORKER_LOG_ROOTS") {
|
||
add_roots(&mut roots, &raw);
|
||
}
|
||
roots
|
||
}
|
||
|
||
fn add_roots(roots: &mut Vec<PathBuf>, raw: &str) {
|
||
for part in raw.split(':') {
|
||
let trimmed = part.trim();
|
||
if !trimmed.is_empty() {
|
||
roots.push(PathBuf::from(trimmed));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn run_wizard(mut cfg: Config) -> io::Result<Config> {
|
||
clear_screen();
|
||
print_banner();
|
||
|
||
let root_candidates = if cfg.roots.is_empty() {
|
||
discover_roots(DEFAULT_DISCOVER_DEPTH)
|
||
} else {
|
||
cfg.roots
|
||
.iter()
|
||
.filter(|p| p.is_dir())
|
||
.flat_map(|p| discover_log_dirs(&[p.clone()], cfg.max_depth, cfg.include_gz))
|
||
.collect()
|
||
};
|
||
|
||
if root_candidates.is_empty() {
|
||
println!("{}", color("没有自动发现日志目录。", "31;1"));
|
||
println!("可先确认日志挂载目录,或设置 JUNHONG_LOG_DISCOVERY_BASES 后重新运行。");
|
||
return Ok(cfg);
|
||
}
|
||
|
||
cfg.roots = choose_root_candidates_pretty(&root_candidates)?;
|
||
|
||
println!();
|
||
cfg.iccid = prompt_required("ICCID", "输入要查询的卡 ICCID")?;
|
||
|
||
println!();
|
||
cfg.date = choose_date_pretty()?;
|
||
|
||
println!();
|
||
cfg.interface = choose_interface_pretty()?;
|
||
|
||
println!();
|
||
let extra = prompt_optional("追加关键词过滤(可直接回车跳过)", None)?;
|
||
if !extra.is_empty() {
|
||
cfg.contains.push(extra);
|
||
}
|
||
|
||
println!();
|
||
cfg.lines_mode = prompt_yes_no("是否输出原始命中行?默认只看摘要,避免刷屏", false)?;
|
||
if cfg.lines_mode {
|
||
let raw_limit = prompt_optional("最多输出多少行", Some(&cfg.limit.to_string()))?;
|
||
if !raw_limit.is_empty() {
|
||
if let Ok(limit) = raw_limit.parse::<usize>() {
|
||
cfg.limit = limit;
|
||
}
|
||
}
|
||
}
|
||
|
||
cfg.include_gz = prompt_yes_no("是否扫描 .gz 压缩轮转日志", true)?;
|
||
|
||
println!();
|
||
println!("{}", color("即将扫描", "36;1"));
|
||
println!(" 目录数: {}", cfg.roots.len());
|
||
for root in &cfg.roots {
|
||
println!(" - {}", root.display());
|
||
}
|
||
println!(" ICCID: {}", cfg.iccid);
|
||
println!(" 日期: {}", cfg.date.as_deref().unwrap_or("不限"));
|
||
println!(" 接口: {}", cfg.interface.as_deref().unwrap_or("不限"));
|
||
println!(
|
||
" 输出: {}",
|
||
if cfg.lines_mode {
|
||
"原始行"
|
||
} else {
|
||
"摘要"
|
||
}
|
||
);
|
||
pause("按回车开始扫描")?;
|
||
clear_screen();
|
||
Ok(cfg)
|
||
}
|
||
|
||
fn clear_screen() {
|
||
if io::stdout().is_terminal() {
|
||
print!("\x1b[2J\x1b[H");
|
||
let _ = io::stdout().flush();
|
||
}
|
||
}
|
||
|
||
fn supports_color() -> bool {
|
||
io::stdout().is_terminal() && env::var_os("NO_COLOR").is_none()
|
||
}
|
||
|
||
fn color(text: &str, code: &str) -> String {
|
||
if supports_color() {
|
||
format!("\x1b[{code}m{text}\x1b[0m")
|
||
} else {
|
||
text.to_string()
|
||
}
|
||
}
|
||
|
||
fn print_banner() {
|
||
println!(
|
||
"{}",
|
||
color("╭────────────────────────────────────────────╮", "36;1")
|
||
);
|
||
println!(
|
||
"{}",
|
||
color("│ 君鸿 Worker 日志查询向导 │", "36;1")
|
||
);
|
||
println!(
|
||
"{}",
|
||
color("╰────────────────────────────────────────────╯", "36;1")
|
||
);
|
||
println!("{}", color("默认只输出摘要;需要原始行时再开启。", "2"));
|
||
println!();
|
||
}
|
||
|
||
fn choose_root_candidates_pretty(candidates: &[RootCandidate]) -> io::Result<Vec<PathBuf>> {
|
||
println!("{}", color("1. 日志目录", "36;1"));
|
||
println!("已自动发现以下候选目录,默认选择全部:");
|
||
println!();
|
||
for (index, item) in candidates.iter().enumerate() {
|
||
let marker = if item.gz_files > 0 {
|
||
"含压缩"
|
||
} else {
|
||
"普通"
|
||
};
|
||
println!(
|
||
" {} {:<48} {:>3} 个日志 {:>3} 个 gz {}",
|
||
color(&format!("{:>2}.", index + 1), "32;1"),
|
||
truncate_path(&item.path, 48),
|
||
item.log_files,
|
||
item.gz_files,
|
||
color(marker, "2")
|
||
);
|
||
}
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
color(
|
||
"回车=全部;输入 1,3-4=只选部分;all,!2,!5-7=排除部分;q=退出",
|
||
"2"
|
||
)
|
||
);
|
||
let input = prompt_optional("选择目录", Some("all"))?;
|
||
if input.eq_ignore_ascii_case("q") {
|
||
std::process::exit(0);
|
||
}
|
||
if input.is_empty() || input.eq_ignore_ascii_case("all") {
|
||
return Ok(candidates.iter().map(|c| c.path.clone()).collect());
|
||
}
|
||
|
||
let selected = select_candidate_paths(candidates, &input);
|
||
if selected.is_empty() {
|
||
println!("{}", color("没有选中有效目录,使用全部候选目录。", "33"));
|
||
return Ok(candidates.iter().map(|c| c.path.clone()).collect());
|
||
}
|
||
Ok(selected)
|
||
}
|
||
|
||
fn truncate_path(path: &Path, width: usize) -> String {
|
||
let text = path.display().to_string();
|
||
let count = text.chars().count();
|
||
if count <= width {
|
||
return text;
|
||
}
|
||
let keep = width.saturating_sub(1);
|
||
let suffix: String = text
|
||
.chars()
|
||
.rev()
|
||
.take(keep)
|
||
.collect::<String>()
|
||
.chars()
|
||
.rev()
|
||
.collect();
|
||
format!("…{suffix}")
|
||
}
|
||
|
||
fn prompt_required(label: &str, help: &str) -> io::Result<String> {
|
||
loop {
|
||
println!("{}", color(help, "2"));
|
||
let value = prompt_optional(label, None)?;
|
||
if !value.trim().is_empty() {
|
||
return Ok(value.trim().to_string());
|
||
}
|
||
println!("{}", color("不能为空。", "31"));
|
||
}
|
||
}
|
||
|
||
fn prompt_optional(label: &str, default: Option<&str>) -> io::Result<String> {
|
||
match default {
|
||
Some(default) => print!("{} [{}]: ", color(label, "33;1"), default),
|
||
None => print!("{}: ", color(label, "33;1")),
|
||
}
|
||
io::stdout().flush()?;
|
||
let mut input = String::new();
|
||
io::stdin().read_line(&mut input)?;
|
||
let value = input.trim().to_string();
|
||
if value.is_empty() {
|
||
Ok(default.unwrap_or("").to_string())
|
||
} else {
|
||
Ok(value)
|
||
}
|
||
}
|
||
|
||
fn prompt_yes_no(label: &str, default: bool) -> io::Result<bool> {
|
||
let default_text = if default { "Y/n" } else { "y/N" };
|
||
loop {
|
||
let value = prompt_optional(label, Some(default_text))?;
|
||
if value == default_text || value.is_empty() {
|
||
return Ok(default);
|
||
}
|
||
match value.to_ascii_lowercase().as_str() {
|
||
"y" | "yes" | "是" => return Ok(true),
|
||
"n" | "no" | "否" => return Ok(false),
|
||
_ => println!("{}", color("请输入 y 或 n。", "31")),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn pause(label: &str) -> io::Result<()> {
|
||
print!("{}...", color(label, "33;1"));
|
||
io::stdout().flush()?;
|
||
let mut input = String::new();
|
||
io::stdin().read_line(&mut input)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn choose_date_pretty() -> io::Result<Option<String>> {
|
||
println!("{}", color("2. 查询日期", "36;1"));
|
||
let today = command_output("date", &["+%F"]).unwrap_or_default();
|
||
let yesterday = command_output("date", &["-d", "yesterday", "+%F"])
|
||
.or_else(|| command_output("date", &["-v-1d", "+%F"]))
|
||
.unwrap_or_default();
|
||
let today_label = if today.is_empty() {
|
||
"今天".to_string()
|
||
} else {
|
||
format!("今天 {today}")
|
||
};
|
||
let yesterday_label = if yesterday.is_empty() {
|
||
"昨天".to_string()
|
||
} else {
|
||
format!("昨天 {yesterday}")
|
||
};
|
||
println!(" {} {}", color("1.", "32;1"), today_label);
|
||
println!(" {} {}", color("2.", "32;1"), yesterday_label);
|
||
println!(" {} 自定义日期", color("3.", "32;1"));
|
||
println!(" {} 不限制日期", color("4.", "32;1"));
|
||
let choice = prompt_optional("选择", Some("2"))?;
|
||
match choice.as_str() {
|
||
"" | "2" => Ok(if yesterday.is_empty() {
|
||
None
|
||
} else {
|
||
Some(yesterday)
|
||
}),
|
||
"1" => Ok(if today.is_empty() { None } else { Some(today) }),
|
||
"3" => {
|
||
let date = prompt_required("日期", "格式:YYYY-MM-DD,例如 2026-05-24")?;
|
||
Ok(Some(date))
|
||
}
|
||
"4" => Ok(None),
|
||
other if other.len() == 10 && other.chars().nth(4) == Some('-') => {
|
||
Ok(Some(other.to_string()))
|
||
}
|
||
_ => Ok(if yesterday.is_empty() {
|
||
None
|
||
} else {
|
||
Some(yesterday)
|
||
}),
|
||
}
|
||
}
|
||
|
||
fn choose_interface_pretty() -> io::Result<Option<String>> {
|
||
println!("{}", color("3. 接口类型", "36;1"));
|
||
println!(" {} 实名 realname", color("1.", "32;1"));
|
||
println!(" {} 流量 flow/carddata", color("2.", "32;1"));
|
||
println!(" {} 卡状态 card_status", color("3.", "32;1"));
|
||
println!(" {} 停机 stop", color("4.", "32;1"));
|
||
println!(" {} 复机 start", color("5.", "32;1"));
|
||
println!(" {} 不限制接口", color("6.", "32;1"));
|
||
println!(" {} 自定义 Gateway 路径", color("7.", "32;1"));
|
||
let choice = prompt_optional("选择", Some("1"))?;
|
||
let value = match choice.as_str() {
|
||
"" | "1" => Some("realname".to_string()),
|
||
"2" => Some("flow".to_string()),
|
||
"3" => Some("card_status".to_string()),
|
||
"4" => Some("stop".to_string()),
|
||
"5" => Some("start".to_string()),
|
||
"6" => None,
|
||
"7" => Some(prompt_required("Gateway 路径", "例如 /flow-card/realName")?),
|
||
other => Some(other.to_string()),
|
||
};
|
||
Ok(value)
|
||
}
|
||
|
||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||
let output = Command::new(program).args(args).output().ok()?;
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
if text.is_empty() {
|
||
None
|
||
} else {
|
||
Some(text)
|
||
}
|
||
}
|
||
|
||
fn print_usage<W: Write>(mut writer: W) {
|
||
let _ = writeln!(
|
||
writer,
|
||
r#"用法:
|
||
rustc scripts/ops/search-worker-logs.rs -O -o /tmp/jh-logq
|
||
/tmp/jh-logq
|
||
/tmp/jh-logq --iccid <ICCID> [选项]
|
||
|
||
核心选项:
|
||
--root <dir> 增加一个日志目录,可重复
|
||
--roots <a:b:c> 一次传多个日志目录
|
||
--list-roots 自动发现候选日志目录并退出;配合 --root/--roots 时只列指定范围
|
||
--choose 从扫描范围内的日志目录交互选择;无需手动输入目录路径
|
||
--iccid <ICCID> 必填,按 ICCID 过滤
|
||
--date <YYYY-MM-DD> 按日志行日期过滤,并按文件名日期剪枝
|
||
--interface <name/path> realname, flow, carddata, card_status, stop, start,或直接传 /flow-card/realName
|
||
--contains <text> 追加文本过滤,可重复
|
||
|
||
输出控制:
|
||
默认只输出汇总、命中文件和少量样例,不刷屏。
|
||
--lines 输出原始命中行
|
||
--limit <N> --lines 最大输出行数,默认 200
|
||
--samples <N> 汇总模式样例行数,默认 5
|
||
--count-only 只输出命中文件和行数
|
||
|
||
性能选项:
|
||
--threads <N> 并发扫描线程数,默认自动
|
||
--max-depth <N> 每个 root 下递归深度,默认 3
|
||
--max-files <N> 最多扫描 N 个文件
|
||
--no-gz 跳过 .gz 文件
|
||
|
||
环境变量:
|
||
JUNHONG_WORKER_LOG_ROOTS 默认日志目录,冒号分隔
|
||
"#
|
||
);
|
||
}
|
||
|
||
fn resolve_default_roots() -> Vec<PathBuf> {
|
||
let mut roots = roots_from_args();
|
||
if !roots.is_empty() {
|
||
return dedup_paths(roots);
|
||
}
|
||
|
||
for path in ["/opt/junhong_cmp/logs", "/app/logs", "./logs"] {
|
||
let p = PathBuf::from(path);
|
||
if p.is_dir() {
|
||
roots.push(p);
|
||
}
|
||
}
|
||
|
||
if roots.is_empty() {
|
||
roots = discover_roots(DEFAULT_DISCOVER_DEPTH)
|
||
.into_iter()
|
||
.map(|c| c.path)
|
||
.collect();
|
||
}
|
||
|
||
dedup_paths(roots)
|
||
}
|
||
|
||
fn dedup_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
|
||
let mut seen = HashSet::new();
|
||
let mut result = Vec::new();
|
||
for path in paths {
|
||
let key = path.to_string_lossy().to_string();
|
||
if seen.insert(key) {
|
||
result.push(path);
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
fn discover_roots(max_depth: usize) -> Vec<RootCandidate> {
|
||
let mut bases = Vec::new();
|
||
if let Ok(raw) = env::var("JUNHONG_LOG_DISCOVERY_BASES") {
|
||
add_roots(&mut bases, &raw);
|
||
} else {
|
||
for path in [".", "/opt/junhong_cmp", "/app", "/data", "/srv"] {
|
||
let p = PathBuf::from(path);
|
||
if p.is_dir() {
|
||
bases.push(p);
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut candidates = Vec::new();
|
||
let mut visited = HashSet::new();
|
||
let mut visited_dirs = 0usize;
|
||
for base in bases {
|
||
let mut queue = VecDeque::new();
|
||
queue.push_back((base, 0usize));
|
||
while let Some((dir, depth)) = queue.pop_front() {
|
||
if visited_dirs >= MAX_DISCOVER_DIRS {
|
||
break;
|
||
}
|
||
visited_dirs += 1;
|
||
|
||
let key = dir.to_string_lossy().to_string();
|
||
if !visited.insert(key) {
|
||
continue;
|
||
}
|
||
let Ok(entries) = fs::read_dir(&dir) else {
|
||
continue;
|
||
};
|
||
let mut log_files = 0usize;
|
||
let mut gz_files = 0usize;
|
||
let mut subdirs = Vec::new();
|
||
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
if depth < max_depth && !should_skip_dir(&path) {
|
||
subdirs.push(path);
|
||
}
|
||
continue;
|
||
}
|
||
if is_log_file(&path, true) {
|
||
log_files += 1;
|
||
if is_gz(&path) {
|
||
gz_files += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if log_files > 0 {
|
||
candidates.push(RootCandidate {
|
||
path: dir.clone(),
|
||
log_files,
|
||
gz_files,
|
||
});
|
||
}
|
||
for subdir in subdirs {
|
||
queue.push_back((subdir, depth + 1));
|
||
}
|
||
}
|
||
}
|
||
|
||
candidates.sort_by(|a, b| a.path.cmp(&b.path));
|
||
candidates
|
||
}
|
||
|
||
fn discover_log_dirs(roots: &[PathBuf], max_depth: usize, include_gz: bool) -> Vec<RootCandidate> {
|
||
let mut candidates = Vec::new();
|
||
let mut visited = HashSet::new();
|
||
let mut visited_dirs = 0usize;
|
||
|
||
for root in roots {
|
||
if !root.is_dir() {
|
||
continue;
|
||
}
|
||
|
||
let mut queue = VecDeque::new();
|
||
queue.push_back((root.clone(), 0usize));
|
||
while let Some((dir, depth)) = queue.pop_front() {
|
||
if visited_dirs >= MAX_DISCOVER_DIRS {
|
||
break;
|
||
}
|
||
visited_dirs += 1;
|
||
|
||
let key = dir.to_string_lossy().to_string();
|
||
if !visited.insert(key) {
|
||
continue;
|
||
}
|
||
|
||
let (log_files, gz_files) = count_immediate_logs(&dir, include_gz);
|
||
if log_files > 0 {
|
||
candidates.push(RootCandidate {
|
||
path: dir.clone(),
|
||
log_files,
|
||
gz_files,
|
||
});
|
||
}
|
||
|
||
if depth >= max_depth {
|
||
continue;
|
||
}
|
||
|
||
let Ok(entries) = fs::read_dir(&dir) else {
|
||
continue;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() && !should_skip_dir(&path) {
|
||
queue.push_back((path, depth + 1));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
candidates.sort_by(|a, b| a.path.cmp(&b.path));
|
||
candidates
|
||
}
|
||
|
||
fn should_skip_dir(path: &Path) -> bool {
|
||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||
return false;
|
||
};
|
||
matches!(
|
||
name,
|
||
".git" | ".omx" | "node_modules" | "vendor" | "tmp" | "cache"
|
||
)
|
||
}
|
||
|
||
fn print_root_candidates(candidates: &[RootCandidate]) {
|
||
if candidates.is_empty() {
|
||
println!("没有发现候选日志目录。可设置 JUNHONG_LOG_DISCOVERY_BASES 后重试。");
|
||
return;
|
||
}
|
||
println!("发现 {} 个候选日志目录:", candidates.len());
|
||
for (index, item) in candidates.iter().enumerate() {
|
||
println!(
|
||
"{:>2}. {} (日志文件: {}, gz: {})",
|
||
index + 1,
|
||
item.path.display(),
|
||
item.log_files,
|
||
item.gz_files
|
||
);
|
||
}
|
||
}
|
||
|
||
fn choose_roots(roots: &[PathBuf], max_depth: usize, include_gz: bool) -> io::Result<Vec<PathBuf>> {
|
||
let candidates = discover_log_dirs(roots, max_depth, include_gz);
|
||
|
||
print_root_candidates(&candidates);
|
||
if candidates.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
|
||
print!("选择目录编号,例如 1,3-4,或 all,!2,!5-7: ");
|
||
io::stdout().flush()?;
|
||
let mut input = String::new();
|
||
io::stdin().read_line(&mut input)?;
|
||
let input = input.trim();
|
||
if input.eq_ignore_ascii_case("all") || input.is_empty() {
|
||
return Ok(candidates.into_iter().map(|c| c.path).collect());
|
||
}
|
||
|
||
let selected = select_candidate_paths(&candidates, input);
|
||
Ok(selected)
|
||
}
|
||
|
||
fn select_candidate_paths(candidates: &[RootCandidate], input: &str) -> Vec<PathBuf> {
|
||
let indexes = parse_selection_indexes(input, candidates.len());
|
||
indexes
|
||
.into_iter()
|
||
.filter_map(|idx| candidates.get(idx - 1).map(|c| c.path.clone()))
|
||
.collect()
|
||
}
|
||
|
||
fn parse_selection_indexes(input: &str, len: usize) -> Vec<usize> {
|
||
if len == 0 {
|
||
return Vec::new();
|
||
}
|
||
|
||
let mut selected = vec![false; len];
|
||
let mut touched = false;
|
||
for raw_part in input.split(',') {
|
||
let part = raw_part.trim();
|
||
if part.is_empty() {
|
||
continue;
|
||
}
|
||
if part.eq_ignore_ascii_case("all") {
|
||
selected.fill(true);
|
||
touched = true;
|
||
continue;
|
||
}
|
||
|
||
let (exclude, range_part) = match part.strip_prefix('!') {
|
||
Some(rest) => (true, rest.trim()),
|
||
None => (false, part),
|
||
};
|
||
for idx in parse_index_range(range_part, len) {
|
||
selected[idx - 1] = !exclude;
|
||
touched = true;
|
||
}
|
||
}
|
||
|
||
if !touched {
|
||
return Vec::new();
|
||
}
|
||
|
||
selected
|
||
.into_iter()
|
||
.enumerate()
|
||
.filter_map(|(idx, is_selected)| if is_selected { Some(idx + 1) } else { None })
|
||
.collect()
|
||
}
|
||
|
||
fn parse_index_range(raw: &str, len: usize) -> Vec<usize> {
|
||
if let Some((start, end)) = raw.split_once('-') {
|
||
let start = start.trim().parse::<usize>().unwrap_or(0);
|
||
let end = end.trim().parse::<usize>().unwrap_or(0);
|
||
if start == 0 || end == 0 {
|
||
return Vec::new();
|
||
}
|
||
let start = start.min(len);
|
||
let end = end.min(len);
|
||
let (start, end) = if start <= end {
|
||
(start, end)
|
||
} else {
|
||
(end, start)
|
||
};
|
||
return (start..=end).collect();
|
||
}
|
||
|
||
let idx = raw.trim().parse::<usize>().unwrap_or(0);
|
||
if idx == 0 || idx > len {
|
||
Vec::new()
|
||
} else {
|
||
vec![idx]
|
||
}
|
||
}
|
||
|
||
fn count_immediate_logs(dir: &Path, include_gz: bool) -> (usize, usize) {
|
||
let Ok(entries) = fs::read_dir(dir) else {
|
||
return (0, 0);
|
||
};
|
||
let mut log_files = 0usize;
|
||
let mut gz_files = 0usize;
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_file() && is_log_file(&path, include_gz) {
|
||
log_files += 1;
|
||
if is_gz(&path) {
|
||
gz_files += 1;
|
||
}
|
||
}
|
||
}
|
||
(log_files, gz_files)
|
||
}
|
||
|
||
fn collect_log_files(
|
||
roots: &[PathBuf],
|
||
max_depth: usize,
|
||
include_gz: bool,
|
||
date: Option<&str>,
|
||
) -> Vec<LogFile> {
|
||
let mut result = Vec::new();
|
||
let mut seen = HashSet::new();
|
||
|
||
for root in roots {
|
||
if root.is_file() {
|
||
maybe_add_file(root, include_gz, date, &mut result, &mut seen);
|
||
continue;
|
||
}
|
||
if !root.is_dir() {
|
||
eprintln!("跳过不存在的目录: {}", root.display());
|
||
continue;
|
||
}
|
||
|
||
let mut queue = VecDeque::new();
|
||
queue.push_back((root.clone(), 0usize));
|
||
while let Some((dir, depth)) = queue.pop_front() {
|
||
let Ok(entries) = fs::read_dir(&dir) else {
|
||
continue;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_dir() {
|
||
if depth < max_depth && !should_skip_dir(&path) {
|
||
queue.push_back((path, depth + 1));
|
||
}
|
||
continue;
|
||
}
|
||
maybe_add_file(&path, include_gz, date, &mut result, &mut seen);
|
||
}
|
||
}
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
fn maybe_add_file(
|
||
path: &Path,
|
||
include_gz: bool,
|
||
date: Option<&str>,
|
||
result: &mut Vec<LogFile>,
|
||
seen: &mut HashSet<String>,
|
||
) {
|
||
if !is_log_file(path, include_gz) {
|
||
return;
|
||
}
|
||
if let Some(date) = date {
|
||
if !filename_can_contain_date(path, date) {
|
||
return;
|
||
}
|
||
}
|
||
let key = path.to_string_lossy().to_string();
|
||
if !seen.insert(key) {
|
||
return;
|
||
}
|
||
let modified_score = path
|
||
.metadata()
|
||
.and_then(|m| m.modified())
|
||
.ok()
|
||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
result.push(LogFile {
|
||
path: path.to_path_buf(),
|
||
modified_score,
|
||
});
|
||
}
|
||
|
||
fn is_log_file(path: &Path, include_gz: bool) -> bool {
|
||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||
return false;
|
||
};
|
||
let lower = name.to_ascii_lowercase();
|
||
if lower.ends_with(".gz") {
|
||
return include_gz && lower.contains("log");
|
||
}
|
||
lower.ends_with(".log")
|
||
|| lower.contains(".log.")
|
||
|| lower.ends_with("-json.log")
|
||
|| lower == "stdout"
|
||
|| lower == "stderr"
|
||
}
|
||
|
||
fn is_gz(path: &Path) -> bool {
|
||
path.extension()
|
||
.and_then(|s| s.to_str())
|
||
.map(|s| s.eq_ignore_ascii_case("gz"))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn filename_can_contain_date(path: &Path, date: &str) -> bool {
|
||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||
return true;
|
||
};
|
||
let compact = date.replace('-', "");
|
||
let dates = extract_filename_dates(name);
|
||
dates.is_empty() || dates.iter().any(|d| d == date || d == &compact)
|
||
}
|
||
|
||
fn extract_filename_dates(name: &str) -> Vec<String> {
|
||
let bytes = name.as_bytes();
|
||
let mut dates = Vec::new();
|
||
for i in 0..bytes.len() {
|
||
if i + 10 <= bytes.len()
|
||
&& bytes[i..].get(4) == Some(&b'-')
|
||
&& bytes[i..].get(7) == Some(&b'-')
|
||
&& bytes[i..i + 4].iter().all(u8::is_ascii_digit)
|
||
&& bytes[i + 5..i + 7].iter().all(u8::is_ascii_digit)
|
||
&& bytes[i + 8..i + 10].iter().all(u8::is_ascii_digit)
|
||
{
|
||
dates.push(name[i..i + 10].to_string());
|
||
}
|
||
if i + 8 <= bytes.len() && bytes[i..i + 8].iter().all(u8::is_ascii_digit) {
|
||
let year = &name[i..i + 4];
|
||
if year.starts_with("20") {
|
||
dates.push(name[i..i + 8].to_string());
|
||
}
|
||
}
|
||
}
|
||
dates
|
||
}
|
||
|
||
fn interface_terms(interface: Option<&str>) -> Result<Vec<String>, String> {
|
||
let Some(interface) = interface else {
|
||
return Ok(Vec::new());
|
||
};
|
||
let terms = match interface {
|
||
"realname" => vec!["/flow-card/realName", "realStatus", "实名"],
|
||
"flow" | "carddata" => vec!["/flow-card/flow", "gateway_flow", "流量", "\"used\""],
|
||
"card_status" | "status" => vec!["/flow-card/status", "cardStatus", "卡状态"],
|
||
"stop" => vec!["/flow-card/cardStop", "cardStop", "停机"],
|
||
"start" | "resume" => vec!["/flow-card/cardStart", "cardStart", "复机"],
|
||
other if other.starts_with('/') => vec![other],
|
||
other => return Err(format!("未知接口别名: {other}")),
|
||
};
|
||
Ok(terms.into_iter().map(String::from).collect())
|
||
}
|
||
|
||
fn scan_file(path: &Path, filter: &MatchFilter, cfg: &Config) -> FileResult {
|
||
let mut result = FileResult {
|
||
path: path.to_path_buf(),
|
||
matched: 0,
|
||
scanned_lines: 0,
|
||
samples: Vec::new(),
|
||
error: None,
|
||
};
|
||
|
||
let scan_result = if is_gz(path) {
|
||
scan_gzip_file(path, filter, cfg, &mut result)
|
||
} else {
|
||
scan_plain_file(path, filter, cfg, &mut result)
|
||
};
|
||
|
||
if let Err(err) = scan_result {
|
||
result.error = Some(err);
|
||
}
|
||
result
|
||
}
|
||
|
||
fn scan_plain_file(
|
||
path: &Path,
|
||
filter: &MatchFilter,
|
||
cfg: &Config,
|
||
result: &mut FileResult,
|
||
) -> Result<(), String> {
|
||
let file = File::open(path).map_err(|err| format!("打开失败: {err}"))?;
|
||
let reader = BufReader::new(file);
|
||
scan_reader(path, reader, filter, cfg, result)
|
||
}
|
||
|
||
fn scan_gzip_file(
|
||
path: &Path,
|
||
filter: &MatchFilter,
|
||
cfg: &Config,
|
||
result: &mut FileResult,
|
||
) -> Result<(), String> {
|
||
let mut child = Command::new("gzip")
|
||
.arg("-cd")
|
||
.arg("--")
|
||
.arg(path)
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|err| format!("启动 gzip 失败: {err}"))?;
|
||
let stdout = child
|
||
.stdout
|
||
.take()
|
||
.ok_or_else(|| String::from("读取 gzip stdout 失败"))?;
|
||
let reader = BufReader::new(stdout);
|
||
let scan_result = scan_reader(path, reader, filter, cfg, result);
|
||
let output = child
|
||
.wait_with_output()
|
||
.map_err(|err| format!("等待 gzip 失败: {err}"))?;
|
||
if !output.status.success() {
|
||
let mut msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||
if msg.is_empty() {
|
||
msg = format!("gzip 退出码 {:?}", output.status.code());
|
||
}
|
||
return Err(msg);
|
||
}
|
||
scan_result
|
||
}
|
||
|
||
fn scan_reader<R: Read>(
|
||
path: &Path,
|
||
reader: BufReader<R>,
|
||
filter: &MatchFilter,
|
||
cfg: &Config,
|
||
result: &mut FileResult,
|
||
) -> Result<(), String> {
|
||
for (idx, line) in reader.lines().enumerate() {
|
||
let line = line.map_err(|err| format!("读取失败: {err}"))?;
|
||
result.scanned_lines += 1;
|
||
if matches_line(&line, filter) {
|
||
result.matched += 1;
|
||
let sample_cap = if cfg.lines_mode {
|
||
cfg.limit
|
||
} else {
|
||
cfg.samples
|
||
};
|
||
if sample_cap == 0 || result.samples.len() < sample_cap.min(1_000) {
|
||
result.samples.push(SampleLine {
|
||
file: path.to_path_buf(),
|
||
line_no: idx + 1,
|
||
line,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn matches_line(line: &str, filter: &MatchFilter) -> bool {
|
||
if !line.contains(&filter.iccid) {
|
||
return false;
|
||
}
|
||
if let Some(date) = &filter.date {
|
||
if !line.contains(date) {
|
||
return false;
|
||
}
|
||
}
|
||
if !filter.interface_terms.is_empty()
|
||
&& !filter
|
||
.interface_terms
|
||
.iter()
|
||
.any(|term| line.contains(term))
|
||
{
|
||
return false;
|
||
}
|
||
for term in &filter.contains {
|
||
if !line.contains(term) {
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
fn print_results(cfg: &Config, results: &[FileResult], scan_total: usize, elapsed_ms: u128) {
|
||
let total_matches: usize = results.iter().map(|r| r.matched).sum();
|
||
let total_lines: usize = results.iter().map(|r| r.scanned_lines).sum();
|
||
let matched_files: Vec<&FileResult> = results.iter().filter(|r| r.matched > 0).collect();
|
||
let errors: Vec<&FileResult> = results.iter().filter(|r| r.error.is_some()).collect();
|
||
|
||
if cfg.count_only {
|
||
for item in matched_files {
|
||
println!("{}\t{}", item.matched, item.path.display());
|
||
}
|
||
if !errors.is_empty() {
|
||
eprintln!(
|
||
"有 {} 个文件读取失败,去掉 --count-only 可看详情",
|
||
errors.len()
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
println!("扫描完成: {} ms", elapsed_ms);
|
||
println!(
|
||
"文件: 扫描 {} 个,命中 {} 个;行: 扫描 {} 行,命中 {} 行",
|
||
scan_total,
|
||
matched_files.len(),
|
||
total_lines,
|
||
total_matches
|
||
);
|
||
println!("ICCID: {}", cfg.iccid);
|
||
if let Some(date) = &cfg.date {
|
||
println!("日期: {}", date);
|
||
}
|
||
if let Some(interface) = &cfg.interface {
|
||
println!("接口过滤: {}", interface);
|
||
}
|
||
|
||
if !errors.is_empty() {
|
||
println!();
|
||
println!("读取失败文件(最多 10 个):");
|
||
for item in errors.iter().take(10) {
|
||
println!(
|
||
"- {}: {}",
|
||
item.path.display(),
|
||
item.error.as_deref().unwrap_or("")
|
||
);
|
||
}
|
||
}
|
||
|
||
if total_matches == 0 {
|
||
println!();
|
||
println!("没有命中。建议先去掉 --interface 只按 ICCID+日期查,确认这张卡当天是否有任何轮询日志。");
|
||
return;
|
||
}
|
||
|
||
println!();
|
||
println!("命中文件(最多 30 个):");
|
||
for item in matched_files.iter().take(30) {
|
||
println!("{:>8} {}", item.matched, item.path.display());
|
||
}
|
||
if matched_files.len() > 30 {
|
||
println!(
|
||
"... 还有 {} 个命中文件,使用 --count-only 查看完整列表",
|
||
matched_files.len() - 30
|
||
);
|
||
}
|
||
|
||
let mut samples = Vec::new();
|
||
for item in results {
|
||
for sample in &item.samples {
|
||
samples.push(sample.clone());
|
||
}
|
||
}
|
||
samples.sort_by(|a, b| a.file.cmp(&b.file).then(a.line_no.cmp(&b.line_no)));
|
||
|
||
if cfg.lines_mode {
|
||
println!();
|
||
println!("原始命中行(最多 {} 行):", cfg.limit);
|
||
for sample in samples.into_iter().take(cfg.limit) {
|
||
println!(
|
||
"{}:{}:{}",
|
||
sample.file.display(),
|
||
sample.line_no,
|
||
sample.line
|
||
);
|
||
}
|
||
if total_matches > cfg.limit {
|
||
println!(
|
||
"... 已按 --limit={} 截断,实际命中 {} 行",
|
||
cfg.limit, total_matches
|
||
);
|
||
}
|
||
} else if cfg.samples > 0 {
|
||
println!();
|
||
println!("样例(最多 {} 条;完整原始行用 --lines):", cfg.samples);
|
||
for sample in samples.into_iter().take(cfg.samples) {
|
||
println!(
|
||
"{}:{}:{}",
|
||
sample.file.display(),
|
||
sample.line_no,
|
||
truncate_line(&sample.line, 260)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn truncate_line(line: &str, max_chars: usize) -> String {
|
||
let mut out = String::new();
|
||
for (idx, ch) in line.chars().enumerate() {
|
||
if idx >= max_chars {
|
||
out.push_str("...");
|
||
return out;
|
||
}
|
||
out.push(ch);
|
||
}
|
||
out
|
||
}
|