Jiang commited on
Commit
40c3225
·
unverified ·
2 Parent(s): 082ede4 9522142

Merge pull request #3 from east-and-west-magic/optimize-refresh

Browse files
Files changed (5) hide show
  1. CLAUDE.md +189 -0
  2. logging_helper.py +222 -55
  3. main.py +22 -3
  4. static/index.html +242 -9
  5. utils.py +40 -0
CLAUDE.md ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ 此文件为 Claude Code (claude.ai/code) 在此存储库中工作时提供指导。
4
+
5
+ ## 项目概述
6
+
7
+ **LogDisplayer** 是一个基于 FastAPI 的日志聚合和显示系统,可以从多个端点/源收集日志,将其存储在本地,并同步到 Hugging Face 数据集。它提供了一个 Web UI,用于查看和管理带有 JWT 令牌用户认证的日志。
8
+
9
+ **技术栈:**
10
+ - 后端:FastAPI + Uvicorn(Python 3.10+)
11
+ - 数据存储:Hugging Face Datasets、Pandas
12
+ - 云同步:Hugging Face Hub API
13
+ - 后台任务:APScheduler
14
+ - 前端:Jinja2 模板(HTML/CSS/JavaScript)
15
+ - 部署:Docker
16
+
17
+ ## 开发设置与命令
18
+
19
+ ### 前置要求
20
+ - Python 3.10+
21
+ - pip 包管理器
22
+ - 环境变量:`hf_token`(Hugging Face 令牌)、`SECRET_KEY`(用于 JWT 解析)
23
+
24
+ ### 安装依赖
25
+ ```bash
26
+ pip install -r requirements.txt
27
+ ```
28
+
29
+ ### 运行应用程序
30
+ ```bash
31
+ # 标准开发运行
32
+ uvicorn main:app --host 0.0.0.0 --port 7860
33
+
34
+ # 带自动重载的开发运行
35
+ uvicorn main:app --reload --host 0.0.0.0 --port 7860
36
+ ```
37
+
38
+ 应用将在 `http://localhost:7860` 可用
39
+
40
+ ### Docker 开发
41
+ ```bash
42
+ # 构建 Docker 镜像
43
+ docker build -t log-displayer .
44
+
45
+ # 运行 Docker 容器
46
+ docker run -p 7860:7860 \
47
+ -e hf_token="your_hf_token" \
48
+ -e SECRET_KEY="your_secret_key" \
49
+ log-displayer
50
+ ```
51
+
52
+ ### 测试
53
+ 当前没有配置正式的测试框架。手动测试脚本位于 `scratch/`:
54
+ - `scratch/test_dataset_to_dict.py` - 测试数据集转换
55
+ - `scratch/test_glob.py` - 测试文件搜索
56
+
57
+ 运行手动测试:
58
+ ```bash
59
+ python scratch/test_dataset_to_dict.py
60
+ python scratch/test_glob.py
61
+ ```
62
+
63
+ ## 架构概览
64
+
65
+ ### 核心组件
66
+
67
+ **1. main.py(FastAPI 应用)**
68
+ - 初始化 FastAPI 应用,配置 CORS 中间件
69
+ - 定义 3 个主要端点:
70
+ - `POST /{end}` - 接受日志,包含消息体、可选的令牌头和源头
71
+ - `GET /healthcheck` - 健康检查端点
72
+ - `GET /` 或 `GET ""` - 使用所有日志渲染 HTML 模板
73
+ - 实例化和管理 `LoggingHelper` 实例
74
+
75
+ **2. logging_helper.py(日志管理引擎)**
76
+ - `LoggingHelper` 类处理所有日志持久化和同步
77
+ - **关键方法:**
78
+ - `addlog(log)` - 将日志添加到内存缓冲区
79
+ - `pull()` - 从 Hugging Face 下载今天的日志
80
+ - `push()` - 将缓冲的日志上传到 Hugging Face 数据集(标记缓存需要刷新)
81
+ - `push_yesterday()` - 归档昨天的日志
82
+ - `refresh()` - **[优化]** 返回所有日志作为排序的字典列表,使用 DataFrame 缓存机制避免重复加载
83
+ - `_load_all_logs()` - **[新增]** 从磁盘加载所有日志文件并合并成 DataFrame
84
+ - **后台同步:** 使用 APScheduler 定期推送日志(默认:60 秒间隔)
85
+ - **文件组织:** 日志在 HF 中组织为 `{year}/{month}/{day}/*.json`
86
+ - **缓冲策略:** 内存中的 Hugging Face 数据集字典,按文件路径和需要推送状态跟踪
87
+ - **缓存策略:** DataFrame 缓存 + 智能失效。只在 push() 完成或首次加载时重新读取磁盘文件
88
+
89
+ **3. utils.py(辅助函数)**
90
+ - `beijing()` - 返回 Asia/Shanghai 时区的当前时间
91
+ - `parse_token(token)` - 解码 JWT 令牌以提取 uid 和用户名
92
+ - `decode_jwt(token)` - 使用 SECRET_KEY 解码 JWT
93
+ - `md5(text)` - 生成 MD5 哈希(用于日志文件名)
94
+ - `json_to_str(obj)` - 将 JSON 转换为紧凑字符串格式
95
+
96
+ **4. static/index.html(前端模板)**
97
+ - 带有中文 UI 的 Jinja2 模板
98
+ - 显示带有排序和过滤的日志表格
99
+ - 显示列:类型、来源、用户、时间戳、内容
100
+
101
+ ### 数据流
102
+
103
+ ```
104
+ 日志 POST 请求
105
+ → main.py add_log()
106
+ → parse_token() 获取用户信息
107
+ → logging_helper.addlog()(添加到缓冲区)
108
+ → APScheduler 每 60 秒触发 push()
109
+ → logging_helper.push()(保存到本地 JSON,上传到 HF)
110
+ → 设置 cache_needs_refresh = True
111
+
112
+ 日志显示请求(带缓存优化)
113
+ → GET / 或 GET ""
114
+ → logging_helper.refresh()
115
+ → 调用 push()(如无新日志,快速返回)
116
+ → 检查缓存:
117
+ - 如果 cache_needs_refresh == True 或缓存为空 → _load_all_logs()(从磁盘加载)
118
+ - 否则 → 直接返回缓存的 DataFrame
119
+ → 返回排序的字典列表
120
+ → Jinja2 渲染 HTML 模板
121
+ ```
122
+
123
+ ### 环境变量
124
+
125
+ 必需:
126
+ - `hf_token` - Hugging Face API 令牌,用于认证
127
+ - `SECRET_KEY` - 用于 JWT 解码的密钥(用于解析用户令牌)
128
+
129
+ ### 关键设计模式
130
+
131
+ 1. **两级缓冲:** 内存缓冲 + 磁盘存储。日志在 Python 对象中缓冲,定期写入 JSON,然后推送到 Hugging Face。
132
+ 2. **基于日期的组织:** 日志自动组织到年/月/日目录中,便于归档数据管理。
133
+ 3. **后台同步:** APScheduler 确保定期推送日志,而不会阻止主请求处理程序。
134
+ 4. **无状态端点:** 每个请求都是独立的;用户信息在每次调用时从 JWT 令牌中提取。
135
+ 5. **DataFrame 缓存(性能优化):** `refresh()` 方法缓存合并后的 DataFrame。只有在 `push()` 完成后才重新加载磁盘文件,避免每次刷新都重复读取和解析所有 JSON 文件。
136
+
137
+ ## 重要文件与职责
138
+
139
+ | 文件 | 行数 | 用途 |
140
+ |------|------|------|
141
+ | [main.py](main.py) | 74 | FastAPI 应用初始化、端点定义 |
142
+ | [logging_helper.py](logging_helper.py) | 235 | 核心日志持久化、缓冲、HF 同步和缓存机制 |
143
+ | [utils.py](utils.py) | 64 | 时区、JWT 解析、哈希工具函数 |
144
+ | [static/index.html](static/index.html) | ~400 | Jinja2 Web UI 模板 |
145
+ | [requirements.txt](requirements.txt) | 10 | Python 依赖 |
146
+ | [Dockerfile](Dockerfile) | - | Docker 镜像定义 |
147
+ | [data/logs/](data/logs/) | - | 本地日志文件存储 |
148
+
149
+ ## 性能优化说明
150
+
151
+ ### 首页刷新优化(v1.1)
152
+
153
+ **问题:** 之前每次刷新首页都需要从磁盘重新加载所有 JSON 日志文件,在日志数量较多时会导致加载时间过长。
154
+
155
+ **解决方案:** 实现了 DataFrame 缓存机制。
156
+
157
+ **具体改进:**
158
+
159
+ 1. **DataFrame 内存缓存** - 在 LoggingHelper 中添加 `cached_df` 变量存储合并后的 DataFrame
160
+ 2. **智能缓存失效** - 只有在调用 `push()` 方法写入新日志到磁盘后,才设置 `cache_needs_refresh = True` 标记
161
+ 3. **增量加载** - 新增 `_load_all_logs()` 私有方法,只在必要时(首次加载或 push 完成后)从磁盘重新加载数据
162
+
163
+ **性能改进:**
164
+ - **首次刷新:** 需要加载所有 JSON 文件(不可避免)
165
+ - **后续刷新(无新日志):** 直接返回缓存,避免磁盘 I/O,响应时间从秒级降低到毫秒级
166
+ - **后续刷新(有新日志):** push() 完成后重新加载,但由于 push() 已经处理完新日志,只需一次加载即可
167
+
168
+ **相关代码变更:**
169
+ - [logging_helper.py:43-45](logging_helper.py#L43-L45) - 添加缓存变量初始化
170
+ - [logging_helper.py:172](logging_helper.py#L172) - push() 方法中标记缓存失效
171
+ - [logging_helper.py:199-216](logging_helper.py#L199-L216) - 新增 _load_all_logs() 方法
172
+ - [logging_helper.py:218-234](logging_helper.py#L218-L234) - 优化后的 refresh() 方法
173
+
174
+ ## 常见开发任务
175
+
176
+ ### 添加新的日志类型
177
+ 1. POST 到 `/{end}`,其中 `{end}` 是日志类型(例如 `/web`、`/mobile`、`/api`)
178
+ 2. LoggingHelper 自动在缓冲区中创建新条目,按日期组织
179
+
180
+ ### 调试日志
181
+ - 查看 uvicorn 控制台输出,了解 add_log() 和 push() 中的打印语句
182
+ - 查看 `data/logs/{year}/{month}/{day}/` 中的本地 JSON 文件以获取存储的日志
183
+ - 检查 `data/logs/` 中下载的 HF 数据集
184
+
185
+ ### 修改同步间隔
186
+ 在 `logging_helper.py` 初始化(main.py 第 25-28 行)中调整 `synchronize_interval` 参数(以秒为单位)
187
+
188
+ ### 扩展 JWT 有效负载
189
+ 修改 utils.py 中的 `parse_token()` 以从 JWT 有效负载中提取其他字段,然后更新 main.py 中 add_log() 中的日志架构
logging_helper.py CHANGED
@@ -5,15 +5,17 @@ a module of logs saving and backuping
5
  import os
6
  import datasets as ds
7
  from apscheduler.schedulers.background import BackgroundScheduler
8
- from tqdm import tqdm
9
  from utils import beijing, md5, json_to_str
10
  from huggingface_hub import HfApi
11
  import pandas as pd
12
- import glob
 
13
 
14
  hf = HfApi()
15
  hf.token = os.environ.get("hf_token")
16
 
 
 
17
 
18
  class LoggingHelper:
19
 
@@ -22,6 +24,7 @@ class LoggingHelper:
22
  repo_id: str,
23
  local_dir: str = "data/logs",
24
  synchronize_interval: int = 60,
 
25
  ):
26
  """
27
  :param repo_id: the repo_id of the dataset in huggingface
@@ -29,6 +32,7 @@ class LoggingHelper:
29
  :param synchronize_interval: the interval of synchronizing between local and huggingface
30
 
31
  """
 
32
  self.local_dir = local_dir
33
  self.repo_id = repo_id
34
  self.synchronize_interval = synchronize_interval
@@ -36,10 +40,15 @@ class LoggingHelper:
36
  self.scheduler = BackgroundScheduler()
37
  self.buffer = dict[str, ds.Dataset]()
38
  self.need_push = dict[str, bool]()
 
39
  self.today = beijing().date()
40
  ds.disable_progress_bar()
41
  self.dataframe: pd.DataFrame
 
 
42
  self.pull()
 
 
43
  self.start_synchronize()
44
 
45
  def addlog(self, log: dict):
@@ -51,8 +60,10 @@ class LoggingHelper:
51
  self.buffer[remotepath] = self.buffer[remotepath].add_item(log) # type: ignore
52
  else:
53
  self.buffer[remotepath] = ds.Dataset.from_dict({})
 
54
  self.buffer[remotepath] = self.buffer[remotepath].add_item(log) # type: ignore
55
  self.need_push[remotepath] = True
 
56
  print("[addlog] Added a log to buffer")
57
 
58
  def remotedir(self):
@@ -62,36 +73,6 @@ class LoggingHelper:
62
  day = now.day.__str__()
63
  return "/".join([year, month, day])
64
 
65
- def pull(self):
66
- try:
67
- self.download()
68
- remotedir = self.remotedir()
69
- print(f"[pull] today dir: {remotedir}")
70
- filenames = hf.list_repo_files(
71
- repo_id=self.repo_id,
72
- repo_type=self.repo_type,
73
- )
74
- files_to_load = [
75
- filename
76
- for filename in filenames
77
- if filename not in self.buffer
78
- and filename.startswith(remotedir)
79
- and filename.endswith(".json")
80
- ]
81
- print(f"[pull] total {len(files_to_load)} to load")
82
- for filename in tqdm(files_to_load):
83
- print()
84
- path = os.sep.join([self.local_dir, filename])
85
- with open(path, "r") as f:
86
- data = f.read()
87
- if len(data) != 0:
88
- self.buffer[filename] = ds.Dataset.from_json(path) # type: ignore
89
- self.need_push[filename] = False
90
- return True
91
- except Exception as e:
92
- print(f"[pull] {type(e)}: {e}")
93
- return False
94
-
95
  def push_yesterday(self) -> bool:
96
  try:
97
  year = self.today.year.__str__()
@@ -102,9 +83,6 @@ class LoggingHelper:
102
  for filename in self.buffer.keys():
103
  if not filename.startswith(remotedir):
104
  continue
105
- if not self.need_push[filename]:
106
- del self.buffer[filename]
107
- del self.need_push[filename]
108
  files_to_push.append(filename)
109
  if len(files_to_push) == 0:
110
  return True
@@ -169,18 +147,160 @@ class LoggingHelper:
169
  print(f"[push] {type(e)}: {e}")
170
  return False
171
 
172
- def download(self):
173
- print("[download] Starting downloading")
174
  try:
175
  res = hf.snapshot_download(
176
  repo_id=self.repo_id,
177
- repo_type="dataset",
178
  local_dir=self.local_dir,
179
  )
180
- print(f"[download] Downloaded to {res}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  except Exception as e:
182
- print(f"[download] {type(e)}: {e}")
183
- print("[download] Done")
184
 
185
  def start_synchronize(self):
186
  self.scheduler.add_job(
@@ -188,20 +308,67 @@ class LoggingHelper:
188
  "interval",
189
  seconds=self.synchronize_interval,
190
  )
 
 
 
 
 
 
 
191
  self.scheduler.start()
192
 
193
- def refresh(self) -> list[dict]:
194
- self.push()
195
- files = glob.glob("**/*.json", root_dir=self.local_dir, recursive=True)
196
- filepathes = [os.sep.join([self.local_dir, file]) for file in files]
197
- datasets = []
198
- for path in tqdm(filepathes):
199
- path = str(path)
200
- datasets.append(ds.Dataset.from_json(path))
201
- df = pd.DataFrame()
202
- if datasets:
203
- dataset: ds.Dataset = ds.concatenate_datasets(datasets)
204
- df = dataset.to_pandas()
205
- assert isinstance(df, pd.DataFrame)
206
- df = df.sort_values(by="timestamp", ascending=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  return df.to_dict(orient="records")
 
5
  import os
6
  import datasets as ds
7
  from apscheduler.schedulers.background import BackgroundScheduler
 
8
  from utils import beijing, md5, json_to_str
9
  from huggingface_hub import HfApi
10
  import pandas as pd
11
+ from datetime import datetime, date, timedelta
12
+ from zoneinfo import ZoneInfo
13
 
14
  hf = HfApi()
15
  hf.token = os.environ.get("hf_token")
16
 
17
+ TIMEZONE = ZoneInfo("Asia/Shanghai")
18
+
19
 
20
  class LoggingHelper:
21
 
 
24
  repo_id: str,
25
  local_dir: str = "data/logs",
26
  synchronize_interval: int = 60,
27
+ cache_days: int = 30,
28
  ):
29
  """
30
  :param repo_id: the repo_id of the dataset in huggingface
 
32
  :param synchronize_interval: the interval of synchronizing between local and huggingface
33
 
34
  """
35
+ self.cache_days = cache_days
36
  self.local_dir = local_dir
37
  self.repo_id = repo_id
38
  self.synchronize_interval = synchronize_interval
 
40
  self.scheduler = BackgroundScheduler()
41
  self.buffer = dict[str, ds.Dataset]()
42
  self.need_push = dict[str, bool]()
43
+ self.timestamps = dict[str, str]()
44
  self.today = beijing().date()
45
  ds.disable_progress_bar()
46
  self.dataframe: pd.DataFrame
47
+ self.dataframe_refresh_needed = True
48
+ # 首先下载所有数据
49
  self.pull()
50
+ # 加载最近30天的日志数据到内存
51
+ self.load_logs()
52
  self.start_synchronize()
53
 
54
  def addlog(self, log: dict):
 
60
  self.buffer[remotepath] = self.buffer[remotepath].add_item(log) # type: ignore
61
  else:
62
  self.buffer[remotepath] = ds.Dataset.from_dict({})
63
+ self.timestamps[remotepath] = beijing().isoformat(timespec="microseconds")
64
  self.buffer[remotepath] = self.buffer[remotepath].add_item(log) # type: ignore
65
  self.need_push[remotepath] = True
66
+ self.dataframe_refresh_needed = True
67
  print("[addlog] Added a log to buffer")
68
 
69
  def remotedir(self):
 
73
  day = now.day.__str__()
74
  return "/".join([year, month, day])
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def push_yesterday(self) -> bool:
77
  try:
78
  year = self.today.year.__str__()
 
83
  for filename in self.buffer.keys():
84
  if not filename.startswith(remotedir):
85
  continue
 
 
 
86
  files_to_push.append(filename)
87
  if len(files_to_push) == 0:
88
  return True
 
147
  print(f"[push] {type(e)}: {e}")
148
  return False
149
 
150
+ def pull(self):
151
+ print("[pull] Starting downloading")
152
  try:
153
  res = hf.snapshot_download(
154
  repo_id=self.repo_id,
155
+ repo_type=self.repo_type,
156
  local_dir=self.local_dir,
157
  )
158
+ print(f"[pull] Downloaded to {res}")
159
+ remotepathes = hf.list_repo_files(
160
+ repo_id=self.repo_id, repo_type=self.repo_type
161
+ )
162
+ jsonfiles = [f for f in remotepathes if f.endswith(".json")]
163
+ print(f"[pull] {len(jsonfiles)} files found in remote repo")
164
+ print("[pull] Parsing timestamps")
165
+ for remotepath in jsonfiles:
166
+ try:
167
+ parts = remotepath.split("/")
168
+ year, month, day = parts[0], parts[1], parts[2]
169
+ date_obj = date(int(year), int(month), int(day))
170
+ timestamp = (
171
+ datetime.combine(date_obj, datetime.min.time())
172
+ .astimezone(TIMEZONE)
173
+ .isoformat(timespec="microseconds")
174
+ )
175
+ self.timestamps[remotepath] = timestamp
176
+ except Exception as e:
177
+ print(f"[pull] Error parsing timestamp of {remotepath}: {e}")
178
+ continue
179
+ print("[pull] Done")
180
+ except Exception as e:
181
+ print(f"[pull] {type(e)}: {e}")
182
+ print("[pull] Done")
183
+
184
+ def get_pathes_between(self, from_date: date, to_date: date) -> dict[str, str]:
185
+ """
186
+ 获取指定日期范围内的路径列表
187
+
188
+ :param from_date: 开始日期(格式:YYYY-MM-DD 或 datetime.date),含该日期
189
+ :param to_date: 结束日期(格式:YYYY-MM-DD 或 datetime.date),含该日期
190
+ :return: 日期范围内的路径列表,格式为 ["YYYY/MM/DD", ...]
191
+ """
192
+ pathes = {}
193
+ current_date = from_date
194
+ while current_date <= to_date:
195
+ key = f"{current_date.year}/{current_date.month}/{current_date.day}"
196
+ value = datetime.combine(current_date, datetime.min.time()).isoformat(
197
+ timespec="microseconds"
198
+ )
199
+ pathes[key] = value
200
+ current_date += timedelta(days=1)
201
+ return pathes
202
+
203
+ def load_logs(
204
+ self, from_timestamp: str | None = None, to_timestamp: str | None = None
205
+ ):
206
+ """
207
+ 在启动时加载最近30天的日志数据到内存buffer
208
+ """
209
+ try:
210
+
211
+ start_timestamp = self.cutoff_timestamp()
212
+ end_timestamp = (
213
+ beijing()
214
+ .replace(hour=23, minute=59, second=59, microsecond=999999)
215
+ .isoformat(timespec="microseconds")
216
+ )
217
+ from_timestamp = from_timestamp or start_timestamp
218
+ to_timestamp = to_timestamp or end_timestamp
219
+ total_files_loaded = 0
220
+ for remotepath, timestamp in self.timestamps.items():
221
+ if timestamp < from_timestamp or timestamp > to_timestamp:
222
+ continue
223
+ localpath = "/".join([self.local_dir, remotepath])
224
+ print(f"[load_logs] Loading file {localpath}")
225
+ # 检查该文件是否存在
226
+ if not os.path.exists(localpath):
227
+ print(f"[load_logs] File not found: {localpath}")
228
+ continue
229
+ try:
230
+ # 检查文件是否为空
231
+ if os.path.getsize(localpath) == 0:
232
+ print(f"[load_logs] Skipping empty file: {remotepath}")
233
+ continue
234
+ if remotepath in self.buffer:
235
+ print(f"[load_logs] File already loaded: {remotepath}")
236
+ continue
237
+ # 加载JSON数据到Dataset
238
+ dataset = ds.Dataset.from_json(localpath)
239
+ if isinstance(dataset, ds.Dataset):
240
+ self.buffer[remotepath] = dataset
241
+ self.need_push[remotepath] = False
242
+ self.timestamps[remotepath] = timestamp
243
+ total_files_loaded += 1
244
+ except Exception as e:
245
+ print(f"[load_logs] Error loading {remotepath}: {e}")
246
+ continue
247
+ if total_files_loaded > 0:
248
+ self.dataframe_refresh_needed = True
249
+ print(f"[load_logs] Successfully loaded {total_files_loaded} log files")
250
+ print(f"[load_logs] Total datasets in buffer: {len(self.buffer)}")
251
+ except Exception as e:
252
+ print(f"[load_logs] Error: {type(e)}: {e}")
253
+
254
+ def cutoff_timestamp(self) -> str:
255
+ """
256
+ 计算用于清理日志的截止时间戳
257
+
258
+ :return: 截止时间戳,格式为 ISO 8601 字符串
259
+ """
260
+ cutoff_date = self.today - timedelta(days=self.cache_days)
261
+ cutoff_timestamp = (
262
+ datetime.combine(cutoff_date, datetime.min.time())
263
+ .astimezone(TIMEZONE)
264
+ .isoformat(timespec="microseconds")
265
+ )
266
+ return cutoff_timestamp
267
+
268
+ def cleanup_old_logs(self):
269
+ """
270
+ 清理buffer中超过30天的日志数据
271
+
272
+ 保留逻辑:保留最近cache_days天的日志
273
+ 删除逻辑:删除早于 (today - cache_days) 的所有日志
274
+ """
275
+ try:
276
+ print("[cleanup_old_logs] Starting cleanup of old logs")
277
+ # 计算应该保留的最早日期(含这一天)
278
+ start_timestamp = self.cutoff_timestamp()
279
+ removed_count = 0
280
+ for filepath in list(self.buffer.keys()):
281
+ # filepath 格式类似 "2025/9/23/xx.json"
282
+ # 提取日期部分 "2025/9/23"
283
+ try:
284
+ timestamp = self.timestamps[filepath]
285
+ # 如果文件日期早于截断日期,则删除
286
+ if timestamp >= start_timestamp:
287
+ continue
288
+ del self.buffer[filepath]
289
+ del self.need_push[filepath]
290
+ removed_count += 1
291
+ print(f"[cleanup_old_logs] Removed {filepath}")
292
+ except (ValueError, IndexError) as e:
293
+ print(f"[cleanup_old_logs] Error parsing filepath {filepath}: {e}")
294
+ continue
295
+
296
+ print(f"[cleanup_old_logs] Cleaned up {removed_count} old log files")
297
+ print(
298
+ f"[cleanup_old_logs] Remaining datasets in buffer: {len(self.buffer)}"
299
+ )
300
+ print("[cleanup_old_logs] Done")
301
+
302
  except Exception as e:
303
+ print(f"[cleanup_old_logs] Error: {type(e)}: {e}")
 
304
 
305
  def start_synchronize(self):
306
  self.scheduler.add_job(
 
308
  "interval",
309
  seconds=self.synchronize_interval,
310
  )
311
+ # 添加每日清理任务,在每天凌晨2点执行
312
+ self.scheduler.add_job(
313
+ self.cleanup_old_logs,
314
+ "cron",
315
+ hour=2,
316
+ minute=0,
317
+ )
318
  self.scheduler.start()
319
 
320
+ def refresh_dataframe(self) -> pd.DataFrame:
321
+ """内存中所有日志数据合并为一个DataFrame"""
322
+ datasets = list(self.buffer.values())
323
+ merged_dataset = ds.concatenate_datasets(datasets)
324
+ self.dataframe = merged_dataset.to_pandas() # type: ignore
325
+ print(f"[refresh_dataframe] Loaded {len(self.dataframe)} logs") # type: ignore
326
+ self.dataframe_refresh_needed = False
327
+ return self.dataframe # type: ignore
328
+
329
+ def refresh(self, from_date: str | None, to_date: str | None) -> list[dict]:
330
+ """
331
+ 获取刷新后的日志列表,支持查询任意时间范围的日志(包括超过30天前的日志)
332
+
333
+ 当查询超过30天前的日志时,会动态从磁盘加载相应数据。
334
+ 基于timestamp字段进行日期过滤。时间戳格式为 ISO 8601 格式(如 "2025-09-08T16:01:07.526954+08:00")
335
+
336
+ :param from_date: 开始日期(格式:YYYY-MM-DD 或 datetime.date),含该日期的所有日志
337
+ :param to_date: 结束日期(格式:YYYY-MM-DD 或 datetime.date),含该日期的所有日志
338
+ :return: 按时间戳降序排列的日志字典列表
339
+ """
340
+ from_timestamp = None
341
+ if from_date is not None:
342
+ from_datetime = datetime.strptime(from_date, "%Y-%m-%d").astimezone(
343
+ TIMEZONE
344
+ )
345
+ from_timestamp = from_datetime.isoformat(timespec="microseconds")
346
+ to_timestamp = None
347
+ if to_date is not None:
348
+ to_datetime = (
349
+ datetime.strptime(to_date, "%Y-%m-%d")
350
+ .astimezone(TIMEZONE)
351
+ .replace(hour=23, minute=59, second=59, microsecond=999999)
352
+ )
353
+ to_timestamp = to_datetime.isoformat(timespec="microseconds")
354
+
355
+ print(
356
+ f"[refresh] Starting to load logs from {from_timestamp} to {to_timestamp}"
357
+ )
358
+ # 如果查询范围超出缓存范围,则加载相应的日志文件
359
+ self.load_logs(from_timestamp=from_timestamp, to_timestamp=to_timestamp)
360
+ if self.dataframe_refresh_needed:
361
+ self.refresh_dataframe()
362
+ df = self.dataframe
363
+ print(f"[refresh] Filtering logs from {from_date} to {to_date}")
364
+ # 创建日期范围过滤条件
365
+ filter_condition = pd.Series([True] * len(df), index=df.index)
366
+ if from_timestamp is not None:
367
+ filter_condition = filter_condition & (df["timestamp"] >= from_timestamp)
368
+ if to_timestamp is not None:
369
+ filter_condition = filter_condition & (df["timestamp"] <= to_timestamp)
370
+ df = df[filter_condition]
371
+ # 按timestamp降序排序(最新日志在前)
372
+ df = df.sort_values(by="timestamp", ascending=False)
373
+ print(f"[refresh] Returning {len(df)} logs")
374
  return df.to_dict(orient="records")
main.py CHANGED
@@ -1,8 +1,9 @@
1
- from fastapi import FastAPI, Body, Header, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from utils import beijing, parse_token
4
  from logging_helper import LoggingHelper
5
  from fastapi.templating import Jinja2Templates
 
6
 
7
 
8
  app = FastAPI(
@@ -65,8 +66,26 @@ templates = Jinja2Templates(directory="static")
65
 
66
  @app.get("")
67
  @app.get("/")
68
- async def root(request: Request):
69
- data = logger.refresh()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  return templates.TemplateResponse(
71
  "index.html",
72
  {"request": request, "data": data},
 
1
+ from fastapi import FastAPI, Body, Header, Request, Query
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from utils import beijing, parse_token
4
  from logging_helper import LoggingHelper
5
  from fastapi.templating import Jinja2Templates
6
+ import datetime
7
 
8
 
9
  app = FastAPI(
 
66
 
67
  @app.get("")
68
  @app.get("/")
69
+ async def root(
70
+ request: Request,
71
+ from_date: str | None = Query(None),
72
+ to_date: str | None = Query(None),
73
+ ):
74
+ """
75
+ 首页端点,支持日期范围查询
76
+
77
+ 查询参数:
78
+ - from_date: 开始日期(格式:YYYY-MM-DD),不指定时默认加载今天
79
+ - to_date: 结束日期(格式:YYYY-MM-DD),不指定时默认为今天
80
+ """
81
+ # 如果没有指定日期范围,默认加载今天的日志
82
+ if from_date is None and to_date is None:
83
+ today = beijing().date().strftime("%Y-%m-%d")
84
+ from_date = today # 今天的日志
85
+ to_date = today
86
+ print(f"[root] No date range specified, using today: {from_date} to {to_date}")
87
+
88
+ data = logger.refresh(from_date=from_date, to_date=to_date)
89
  return templates.TemplateResponse(
90
  "index.html",
91
  {"request": request, "data": data},
static/index.html CHANGED
@@ -134,6 +134,71 @@
134
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
135
  }
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  .main {
138
  padding: 30px;
139
  }
@@ -228,8 +293,6 @@
228
  line-height: 1.4;
229
  }
230
 
231
-
232
-
233
  .timestamp {
234
  color: #6c757d;
235
  font-size: 0.9rem;
@@ -324,6 +387,19 @@
324
  min-width: auto;
325
  }
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  .stats {
328
  justify-content: center;
329
  }
@@ -360,7 +436,24 @@
360
  <button class="btn btn-secondary" onclick="refreshLogs()">刷新</button>
361
  </div>
362
 
 
 
 
 
 
 
 
 
 
 
363
 
 
 
 
 
 
 
 
364
  </div>
365
 
366
  <div class="main">
@@ -383,7 +476,7 @@
383
  <div class="no-data" id="noData" style="display: none;">
384
  <div>📋</div>
385
  <h3>暂无数据</h3>
386
- <p>没有找到匹配的错误日志</p>
387
  </div>
388
 
389
  <div class="pagination" id="pagination">
@@ -414,13 +507,124 @@
414
  let totalPages = 1;
415
  let filteredLogs = [...data];
416
  let searchTerm = '';
 
417
 
418
  // 初始化页面
419
  function initPage() {
 
 
 
 
 
 
 
 
 
 
 
 
420
  renderLogs();
421
  updatePagination();
422
  }
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  // 改变每页显示数量
425
  function changePageSize() {
426
  const select = document.getElementById('pageSize');
@@ -533,11 +737,11 @@
533
  // 搜索日志
534
  function searchLogs() {
535
  searchTerm = document.getElementById('searchInput').value.trim();
536
-
537
  if (searchTerm === '') {
538
  filteredLogs = [...data];
539
  } else {
540
- filteredLogs = data.filter(log =>
541
  log.username.toLowerCase().includes(searchTerm.toLowerCase())
542
  );
543
  }
@@ -549,9 +753,25 @@
549
 
550
  // 刷新日志
551
  function refreshLogs() {
552
- // 模拟刷新数据
553
- console.log('刷新日志数据...');
554
- initPage();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
  }
556
 
557
  // 监听回车键搜索
@@ -561,8 +781,21 @@
561
  }
562
  });
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  // 页面加载完成后初始化
565
  document.addEventListener('DOMContentLoaded', initPage);
566
  </script>
567
  </body>
568
- </html>
 
134
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
135
  }
136
 
137
+ .date-filter-container {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 15px;
141
+ margin-top: 20px;
142
+ flex-wrap: wrap;
143
+ }
144
+
145
+ .date-input-group {
146
+ display: flex;
147
+ align-items: center;
148
+ gap: 8px;
149
+ }
150
+
151
+ .date-input-group label {
152
+ font-weight: 500;
153
+ color: #495057;
154
+ min-width: 60px;
155
+ }
156
+
157
+ .date-input-group input[type="date"] {
158
+ padding: 10px 15px;
159
+ border: 2px solid #e9ecef;
160
+ border-radius: 8px;
161
+ font-size: 14px;
162
+ background: white;
163
+ cursor: pointer;
164
+ transition: all 0.3s ease;
165
+ }
166
+
167
+ .date-input-group input[type="date"]:focus {
168
+ outline: none;
169
+ border-color: #667eea;
170
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
171
+ }
172
+
173
+ .date-shortcuts {
174
+ display: flex;
175
+ gap: 8px;
176
+ flex-wrap: wrap;
177
+ }
178
+
179
+ .date-shortcut-btn {
180
+ padding: 8px 15px;
181
+ border: 1px solid #e9ecef;
182
+ background: white;
183
+ border-radius: 8px;
184
+ cursor: pointer;
185
+ font-size: 13px;
186
+ color: #495057;
187
+ transition: all 0.3s ease;
188
+ }
189
+
190
+ .date-shortcut-btn:hover {
191
+ background: #667eea;
192
+ color: white;
193
+ border-color: #667eea;
194
+ }
195
+
196
+ .date-shortcut-btn.active {
197
+ background: #667eea;
198
+ color: white;
199
+ border-color: #667eea;
200
+ }
201
+
202
  .main {
203
  padding: 30px;
204
  }
 
293
  line-height: 1.4;
294
  }
295
 
 
 
296
  .timestamp {
297
  color: #6c757d;
298
  font-size: 0.9rem;
 
387
  min-width: auto;
388
  }
389
 
390
+ .date-filter-container {
391
+ flex-direction: column;
392
+ align-items: stretch;
393
+ }
394
+
395
+ .date-input-group {
396
+ flex-direction: column;
397
+ }
398
+
399
+ .date-input-group label {
400
+ min-width: auto;
401
+ }
402
+
403
  .stats {
404
  justify-content: center;
405
  }
 
436
  <button class="btn btn-secondary" onclick="refreshLogs()">刷新</button>
437
  </div>
438
 
439
+ <div class="date-filter-container">
440
+ <div class="date-input-group">
441
+ <label for="fromDate">开始日期:</label>
442
+ <input type="date" id="fromDate">
443
+ </div>
444
+ <div class="date-input-group">
445
+ <label for="toDate">结束日期:</label>
446
+ <input type="date" id="toDate">
447
+ </div>
448
+ <button class="btn btn-primary" onclick="filterByDate()">过滤</button>
449
 
450
+ <div class="date-shortcuts">
451
+ <button class="date-shortcut-btn" onclick="setDateRange('today')">今天</button>
452
+ <button class="date-shortcut-btn" onclick="setDateRange('week')">最近7天</button>
453
+ <button class="date-shortcut-btn" onclick="setDateRange('month')">最近30天</button>
454
+ <button class="date-shortcut-btn" onclick="setDateRange('all')">全部</button>
455
+ </div>
456
+ </div>
457
  </div>
458
 
459
  <div class="main">
 
476
  <div class="no-data" id="noData" style="display: none;">
477
  <div>📋</div>
478
  <h3>暂无数据</h3>
479
+ <p>没有找到匹配的日志</p>
480
  </div>
481
 
482
  <div class="pagination" id="pagination">
 
507
  let totalPages = 1;
508
  let filteredLogs = [...data];
509
  let searchTerm = '';
510
+ let currentDateFilter = null; // 记录当前的日期过滤范围
511
 
512
  // 初始化页面
513
  function initPage() {
514
+ // 从URL参数中读取日期范围
515
+ const urlParams = new URLSearchParams(window.location.search);
516
+ const fromDate = urlParams.get('from_date');
517
+ const toDate = urlParams.get('to_date');
518
+
519
+ if (fromDate) {
520
+ document.getElementById('fromDate').value = fromDate;
521
+ }
522
+ if (toDate) {
523
+ document.getElementById('toDate').value = toDate;
524
+ }
525
+
526
  renderLogs();
527
  updatePagination();
528
  }
529
 
530
+ // 获取今天的日期(YYYY-MM-DD格式)
531
+ function getTodayDate() {
532
+ const today = new Date();
533
+ return today.toISOString().split('T')[0];
534
+ }
535
+
536
+ // 获取指定天数前的日期
537
+ function getDateBefore(days) {
538
+ const date = new Date();
539
+ date.setDate(date.getDate() - days);
540
+ return date.toISOString().split('T')[0];
541
+ }
542
+
543
+ // 设置日期范围快捷选项
544
+ function setDateRange(range) {
545
+ const today = getTodayDate();
546
+
547
+ switch(range) {
548
+ case 'today':
549
+ document.getElementById('fromDate').value = today;
550
+ document.getElementById('toDate').value = today;
551
+ break;
552
+ case 'week':
553
+ document.getElementById('fromDate').value = getDateBefore(6);
554
+ document.getElementById('toDate').value = today;
555
+ break;
556
+ case 'month':
557
+ document.getElementById('fromDate').value = getDateBefore(29);
558
+ document.getElementById('toDate').value = today;
559
+ break;
560
+ case 'all':
561
+ document.getElementById('fromDate').value = '';
562
+ document.getElementById('toDate').value = '';
563
+ break;
564
+ }
565
+
566
+ // 更新快捷按钮的active状态
567
+ updateShortcutButtons(range);
568
+ }
569
+
570
+ // 更新快捷按钮的样式
571
+ function updateShortcutButtons(active) {
572
+ const buttons = document.querySelectorAll('.date-shortcut-btn');
573
+ buttons.forEach((btn, index) => {
574
+ const ranges = ['today', 'week', 'month', 'all'];
575
+ btn.classList.remove('active');
576
+ if (ranges[index] === active) {
577
+ btn.classList.add('active');
578
+ }
579
+ });
580
+ }
581
+
582
+ // 按日期过滤
583
+ function filterByDate() {
584
+ const fromDate = document.getElementById('fromDate').value;
585
+ const toDate = document.getElementById('toDate').value;
586
+
587
+ if (!fromDate && !toDate) {
588
+ // 如果两个都为空,加载所有数据
589
+ currentDateFilter = null;
590
+ refreshFromServer(null, null);
591
+ return;
592
+ }
593
+
594
+ if (fromDate && toDate) {
595
+ if (fromDate > toDate) {
596
+ alert('开始日期不能晚于结束日期');
597
+ return;
598
+ }
599
+ }
600
+
601
+ // 记录当前的日期过滤
602
+ currentDateFilter = { from_date: fromDate, to_date: toDate };
603
+
604
+ // 刷新并带上日期参数
605
+ refreshFromServer(fromDate, toDate);
606
+ }
607
+
608
+ // 从服务器刷新数据,支持日期参数
609
+ function refreshFromServer(fromDate, toDate) {
610
+ let url = window.location.pathname;
611
+ const params = new URLSearchParams();
612
+
613
+ if (fromDate) {
614
+ params.append('from_date', fromDate);
615
+ }
616
+ if (toDate) {
617
+ params.append('to_date', toDate);
618
+ }
619
+
620
+ if (params.toString()) {
621
+ url += '?' + params.toString();
622
+ }
623
+
624
+ // 重新加载页面
625
+ window.location.href = url;
626
+ }
627
+
628
  // 改变每页显示数量
629
  function changePageSize() {
630
  const select = document.getElementById('pageSize');
 
737
  // 搜索日志
738
  function searchLogs() {
739
  searchTerm = document.getElementById('searchInput').value.trim();
740
+
741
  if (searchTerm === '') {
742
  filteredLogs = [...data];
743
  } else {
744
+ filteredLogs = data.filter(log =>
745
  log.username.toLowerCase().includes(searchTerm.toLowerCase())
746
  );
747
  }
 
753
 
754
  // 刷新日志
755
  function refreshLogs() {
756
+ // 如果有日期过滤,使用带日期参数的刷新
757
+ if (currentDateFilter) {
758
+ refreshFromServer(currentDateFilter.from_date, currentDateFilter.to_date);
759
+ } else {
760
+ // 获取当前的URL参数
761
+ const urlParams = new URLSearchParams(window.location.search);
762
+ const fromDate = urlParams.get('from_date');
763
+ const toDate = urlParams.get('to_date');
764
+
765
+ if (fromDate || toDate) {
766
+ refreshFromServer(fromDate, toDate);
767
+ } else {
768
+ // 模拟刷新数据
769
+ console.log('刷新日志数据...');
770
+ currentPage = 1;
771
+ renderLogs();
772
+ updatePagination();
773
+ }
774
+ }
775
  }
776
 
777
  // 监听回车键搜索
 
781
  }
782
  });
783
 
784
+ // 监听日期输入框的回车键
785
+ document.getElementById('fromDate').addEventListener('keypress', function(e) {
786
+ if (e.key === 'Enter') {
787
+ filterByDate();
788
+ }
789
+ });
790
+
791
+ document.getElementById('toDate').addEventListener('keypress', function(e) {
792
+ if (e.key === 'Enter') {
793
+ filterByDate();
794
+ }
795
+ });
796
+
797
  // 页面加载完成后初始化
798
  document.addEventListener('DOMContentLoaded', initPage);
799
  </script>
800
  </body>
801
+ </html>
utils.py CHANGED
@@ -61,3 +61,43 @@ def md5(text: list[str | bytes] | str | bytes | None = None) -> str:
61
 
62
  def json_to_str(obj: dict | list) -> str:
63
  return json.dumps(obj, separators=(",", ":"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  def json_to_str(obj: dict | list) -> str:
63
  return json.dumps(obj, separators=(",", ":"))
64
+
65
+
66
+ def validate_date_format(date_str: str, format_str: str = "%Y-%m-%d") -> bool:
67
+ """
68
+ 验证日期字符串的格式是否正确
69
+
70
+ :param date_str: 要验证的日期字符串
71
+ :param format_str: 期望的日期格式(默认:YYYY-MM-DD)
72
+ :return: 如果格式正确返回 True,否则返回 False
73
+ """
74
+ if not date_str:
75
+ return True # 空值被认为是有效的(可选参数)
76
+
77
+ try:
78
+ from datetime import datetime as dt
79
+ dt.strptime(date_str, format_str)
80
+ return True
81
+ except ValueError:
82
+ return False
83
+
84
+
85
+ def parse_date_range(from_date: str | None, to_date: str | None) -> tuple[str | None, str | None] | tuple[str, str]:
86
+ """
87
+ 解析和验证日期范围
88
+
89
+ :param from_date: 开始日期(格式:YYYY-MM-DD)
90
+ :param to_date: 结束日期(格式:YYYY-MM-DD)
91
+ :return: 验证后的日期范围元组 (from_date, to_date)
92
+ :raises ValueError: 如果日期格式不正确或范围无效
93
+ """
94
+ if from_date and not validate_date_format(from_date):
95
+ raise ValueError(f"Invalid from_date format: {from_date}")
96
+
97
+ if to_date and not validate_date_format(to_date):
98
+ raise ValueError(f"Invalid to_date format: {to_date}")
99
+
100
+ if from_date and to_date and from_date > to_date:
101
+ raise ValueError(f"from_date ({from_date}) cannot be after to_date ({to_date})")
102
+
103
+ return from_date, to_date