Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add log observability

Lyric 8d8f5920 3c93b0ff

+1897 -6
+5
assets/config/config.example.yaml
··· 106 106 format: "text" 107 107 # Include source file:line in each log record. 108 108 add_source: false 109 + file: 110 + # Local JSONL log output directory. Empty means <file_state_dir>/logs. 111 + dir: "" 112 + # Delete log files older than this duration. Files are named by local date. 113 + max_age: "168h" 109 114 # If true, logs the model's "thought" fields (may contain sensitive info). 110 115 include_thoughts: true 111 116 # If true, logs tool_call parameters (redacted and truncated).
+409
docs/feat/feat_20260424_log_observability.md
··· 1 + --- 2 + date: 2026-04-24 3 + title: Log Observability(日志观测) 4 + status: draft 5 + --- 6 + 7 + # Log Observability(日志观测) 8 + 9 + ## 1) 背景 10 + 11 + 当前日志主要通过 `slog` 输出到 stderr。桌面端和 Console Web 可以看到任务状态、审计日志、统计数据,但不能直接看到当前进程的运行日志。 12 + 13 + 这导致几个实际问题: 14 + 15 + 1. 桌面端出错时,用户很难在界面里看到最近发生了什么。 16 + 2. Console API、runtime、provider、tool 调用相关的错误分散在进程输出里,不便排查。 17 + 3. 现有 `logging.*` 已经定义了级别、格式、脱敏和截断策略,但缺少文件输出和保留策略。 18 + 19 + 本需求要补齐一个最小可用的日志观测能力: 20 + 21 + - 后端继续把日志输出到 stderr。 22 + - 同一份 `slog` 记录再写一份到本地日志文件。 23 + - Console Web 能读取并显示最新日志。 24 + 25 + ## 2) 目标 26 + 27 + 1. 在配置里支持日志输出目录。 28 + 2. 当日志输出目录为空时,使用 `<file_state_dir>/logs/`。 29 + 3. 日志按天写入,每天一个文件。 30 + 4. 日志文件有最大保存时间,默认 7 天,可配置。 31 + 5. 桌面端通过 Console API 读取最新日志。 32 + 6. Console Web 增加日志视图,显示最新日志内容。 33 + 7. 不改变现有 stderr 输出行为。 34 + 35 + 默认行为: 36 + 37 + - 文件日志默认启用,不增加 `logging.file.enabled` 开关。 38 + - 只要进程使用 `internal/logutil` 构建 logger,就会写入文件日志。 39 + 40 + 适用范围: 41 + 42 + - 使用 `internal/logutil` 构建 logger 的 CLI、Console 后端和桌面端后端进程。 43 + - 嵌入方如果显式传入自己的 logger,不强制接管。 44 + 45 + ## 3) 非目标 46 + 47 + 第一版不做这些事: 48 + 49 + 1. 不做全文搜索和索引。 50 + 2. 不做日志级别筛选。 51 + 3. 不做多进程集中采集。 52 + 4. 不做跨 endpoint 的日志聚合视图。 53 + 5. 不做 WebSocket/SSE 实时日志流。 54 + 6. 不提供任意文件读取能力。 55 + 7. 不改变 `logging.include_*`、`logging.redact_keys` 等现有日志内容策略。 56 + 57 + ## 4) 配置需求 58 + 59 + 在现有 `logging` 段下增加文件输出配置: 60 + 61 + ```yaml 62 + logging: 63 + level: "info" 64 + format: "text" 65 + add_source: false 66 + 67 + file: 68 + # Empty means <file_state_dir>/logs. 69 + dir: "" 70 + # Go duration string. Default is 7 days. 71 + max_age: "168h" 72 + ``` 73 + 74 + 字段语义: 75 + 76 + - `logging.file.dir` 77 + - 类型:string 78 + - 为空时使用 `<file_state_dir>/logs/` 79 + - 非空时使用用户指定目录 80 + - 相对路径按现有配置路径规则处理,具体规则应与仓库内其他本地路径配置保持一致 81 + 82 + - `logging.file.max_age` 83 + - 类型:duration string 84 + - 默认值:`168h` 85 + - 必须是正数 86 + - 早于 `now - max_age` 的日志文件会被清理 87 + 88 + 需要同步更新: 89 + 90 + 1. `internal/configdefaults/defaults.go` 91 + 2. `assets/config/config.example.yaml` 92 + 3. 相关配置读取结构 93 + 94 + 文件日志固定使用 JSONL,不新增 `logging.file.format`。`logging.format` 继续只控制 stderr 输出。 95 + 96 + ## 5) 日志文件需求 97 + 98 + ### 5.1 输出行为 99 + 100 + 日志输出应同时写入: 101 + 102 + 1. 原有 stderr handler。 103 + 2. 当前日期对应的日志文件 handler。 104 + 105 + 文件输出和 stderr 输出使用同一套: 106 + 107 + - `logging.level` 108 + - `logging.add_source` 109 + - 已有日志脱敏与截断逻辑 110 + 111 + 格式规则: 112 + 113 + - stderr 继续使用 `logging.format: text|json`。 114 + - 文件日志固定使用 JSONL,也就是一行一条 `slog.JSONHandler` 记录。 115 + 116 + 这样做的原因是:stderr 主要给人看,文件日志主要给系统读取。JSONL 适合 tail、分页和后续字段筛选,也不需要正则解析 text log。 117 + 118 + ### 5.2 文件目录 119 + 120 + 默认目录: 121 + 122 + ```text 123 + <file_state_dir>/logs/ 124 + ``` 125 + 126 + 目录不存在时自动创建。 127 + 128 + 权限要求: 129 + 130 + - 日志目录建议使用仅当前用户可读写的权限。 131 + - 日志文件建议使用仅当前用户可读写的权限。 132 + 133 + 原因是日志可能包含任务内容、错误信息、工具调用摘要和运行环境信息。 134 + 135 + ### 5.3 文件命名 136 + 137 + 每天一个文件,文件名建议: 138 + 139 + ```text 140 + mistermorph-YYYY-MM-DD.jsonl 141 + ``` 142 + 143 + 日期使用进程本地时区。 144 + 145 + 示例: 146 + 147 + ```text 148 + mistermorph-2026-04-24.jsonl 149 + ``` 150 + 151 + 文件内固定为 JSONL。Console Web 第一版可以先按原始行显示,不强制做结构化表格。 152 + 153 + ### 5.4 日切换 154 + 155 + 当日期变化时,新日志写入新日期文件。 156 + 157 + 实现上不要求后台定时任务。可以在写日志时检查当前日期: 158 + 159 + 1. 当前日期未变:继续写当前文件。 160 + 2. 当前日期已变:关闭旧文件,打开新文件。 161 + 3. 打开新文件后执行一次清理。 162 + 163 + ### 5.5 保留策略 164 + 165 + 保留策略只清理日志系统自己创建的文件。 166 + 167 + 清理范围: 168 + 169 + - 只处理 `logging.file.dir` 下匹配 `mistermorph-YYYY-MM-DD.jsonl` 的文件。 170 + - 不删除同目录下的其他文件。 171 + 172 + 清理时机: 173 + 174 + 1. 进程启动时执行一次。 175 + 2. 日切换时执行一次。 176 + 177 + 清理规则: 178 + 179 + - 按文件名日期清理,不按文件 mtime 清理。 180 + - 超过 `logging.file.max_age` 的文件应删除。 181 + - 删除失败时记录 warn,不阻断主进程。 182 + 183 + ## 6) Runtime API + Console Proxy 需求 184 + 185 + 新增一个 runtime API,用于读取当前 runtime 的最新日志。 186 + 187 + 建议路径: 188 + 189 + ```text 190 + GET /logs/latest 191 + ``` 192 + 193 + Console Web 通过现有 `/api/proxy` 调用: 194 + 195 + ```text 196 + GET /api/proxy?endpoint=<endpoint_ref>&uri=/logs/latest 197 + ``` 198 + 199 + 原因: 200 + 201 + - 日志查看是 runtime 能力,和 `/audit/logs`、`/stats/llm/usage` 一样。 202 + - 本地桌面端可以看本地 runtime 日志。 203 + - 远端 endpoint 如果升级支持该 API,也可以通过同一套 Console UI 查看。 204 + - 老版本 endpoint 不支持时返回 `404`,前端显示“不支持日志查看”。 205 + 206 + 允许查看已鉴权远端 endpoint 的日志。访问控制依赖现有 Console session、endpoint auth 和部署侧访问边界。 207 + 208 + ### 6.1 鉴权 209 + 210 + 要求: 211 + 212 + - Console 侧仍使用现有 session 鉴权保护 `/api/proxy`。 213 + - runtime 侧使用现有 endpoint auth。 214 + - 未登录或 endpoint auth 失败返回 `401`。 215 + - 不挂到 `/health` 这类公开接口下。 216 + 217 + ### 6.2 查询参数 218 + 219 + ```text 220 + GET /logs/latest?limit=300 221 + GET /logs/latest?limit=300&cursor=<opaque_cursor> 222 + ``` 223 + 224 + 参数: 225 + 226 + - `limit` 227 + - 默认:`300` 228 + - 最小:`1` 229 + - 最大:`1000` 230 + - 表示最多返回多少行 231 + 232 + - `cursor` 233 + - 默认:空 234 + - 空表示读取当前最新日志尾部 235 + - 非空表示继续读取更早的日志 236 + - cursor 是不透明字符串,前端不解析 237 + 238 + cursor 内部可以编码 `file` 和 `before` 行号。这样跨日切换时,前端仍然能继续读取上一份文件的旧日志。 239 + 240 + 分页允许跨天: 241 + 242 + - 当前文件没有更早日志时,可以继续读取上一天的 `mistermorph-YYYY-MM-DD.jsonl`。 243 + - 继续向上加载时,可以按日期倒序读取更早文件,直到达到保留范围内最早日志。 244 + 245 + ### 6.3 响应结构 246 + 247 + 建议响应: 248 + 249 + ```json 250 + { 251 + "file": "mistermorph-2026-04-24.jsonl", 252 + "exists": true, 253 + "size_bytes": 123456, 254 + "mod_time": "2026-04-24T10:30:00Z", 255 + "limit": 300, 256 + "total_lines": 1200, 257 + "from": 901, 258 + "to": 1200, 259 + "has_older": true, 260 + "older_cursor": "opaque", 261 + "lines": [ 262 + "{\"time\":\"2026-04-24T10:29:59Z\",\"level\":\"INFO\",\"msg\":\"run_start\"}", 263 + "{\"time\":\"2026-04-24T10:30:00Z\",\"level\":\"INFO\",\"msg\":\"final\"}" 264 + ] 265 + } 266 + ``` 267 + 268 + 注意: 269 + 270 + - 响应不返回日志目录绝对路径。 271 + - `file` 只返回 basename。 272 + - 当没有日志文件时,返回 `200`,`exists: false`,`lines: []`。 273 + - 当配置无法解析或日志目录无法访问时,返回 `500`,并给出简短错误。 274 + - `older_cursor` 只在 `has_older: true` 时返回。 275 + 276 + ### 6.4 最新文件选择 277 + 278 + “最新日志”定义为: 279 + 280 + 1. 优先读取当前日期对应的日志文件。 281 + 2. 如果当前日期文件不存在,则读取日志目录下最新的 `mistermorph-YYYY-MM-DD.jsonl`。 282 + 3. 如果没有匹配文件,则返回空结果。 283 + 284 + ## 7) Console Web 需求 285 + 286 + 新增 Logs 视图,用来显示最新日志。 287 + 288 + 建议: 289 + 290 + - 路由:`/logs` 291 + - 入口:`Settings -> Console -> Logs`,点击后进入独立日志视图 292 + - 数据源:`runtimeApiFetch("/logs/latest")` 293 + - 通过当前 endpoint 读取日志 294 + 295 + ### 7.1 页面内容 296 + 297 + 页面至少显示: 298 + 299 + 1. 当前日志文件名。 300 + 2. 文件更新时间。 301 + 3. 文件大小。 302 + 4. 最新日志行。 303 + 5. 行数选择,例如 `100 / 300 / 1000`。 304 + 6. 默认自动刷新。 305 + 8. 加载更早日志的按钮。 306 + 307 + ### 7.2 展示要求 308 + 309 + 日志内容按原始行显示: 310 + 311 + - 使用等宽字体。 312 + - 保留空格。 313 + - 长行允许横向滚动或软换行,但不能撑坏页面。 314 + - 新日志默认显示在底部。 315 + - 用户手动向上查看旧日志时,自动刷新不应强制滚回底部。 316 + 317 + 分页和无限加载: 318 + 319 + - 初次打开读取最新 `limit` 行,并滚到底部。 320 + - 用户滚到顶部或点击“加载更早”时,使用 `older_cursor` 读取更早日志。 321 + - 新旧日志拼接时保持当前滚动位置,不让页面跳动。 322 + - 自动刷新只在用户接近底部时滚到底部。 323 + - 用户正在看旧日志时,自动刷新只提示有新日志,不强制跳转。 324 + 325 + ### 7.3 空状态和错误状态 326 + 327 + 空状态: 328 + 329 + - 当 `exists: false` 时,展示“暂无日志”。 330 + - 当 endpoint 返回 `404` 时,展示“当前 endpoint 不支持日志查看”。 331 + 332 + 错误状态: 333 + 334 + - 鉴权失败走现有登录流程。 335 + - 其他错误展示错误消息和重试按钮。 336 + 337 + ### 7.4 安全要求 338 + 339 + 前端不展示本地绝对路径。 340 + 341 + 日志内容本身可能包含敏感信息,因此: 342 + 343 + - Logs 视图只能给已登录 Console 用户使用。 344 + - 不提供公开分享链接。 345 + - 不把日志内容写入浏览器本地长期存储。 346 + 347 + ## 8) 桌面端行为 348 + 349 + 桌面端当前通过本地 Console 后端提供 API 和 SPA。第一版不需要新增 Wails binding。 350 + 351 + 桌面端只需要满足: 352 + 353 + 1. 启动本地 Console 后端时,后端按配置写日志文件。 354 + 2. Wails WebView 通过已有代理访问 `/api/proxy?endpoint=...&uri=/logs/latest`。 355 + 3. Logs 视图展示当前本地后端的最新日志。 356 + 357 + 如果后续出现“桌面壳自身日志”和“后端日志”需要同时展示的问题,再单独设计。第一版只处理后端日志。 358 + 359 + ## 9) 实施建议 360 + 361 + ### Phase A:文件日志 362 + 363 + - [x] 扩展 `internal/logutil.LoggerConfig`。 364 + - [x] 读取 `logging.file.dir` 和 `logging.file.max_age`。 365 + - [x] 实现一个 fan-out `slog.Handler`,同时写 stderr 和文件。 366 + - [x] 实现按日打开文件与旧文件清理。 367 + - [x] 增加单元测试: 368 + - 默认目录解析 369 + - 自定义目录解析 370 + - 日切换 371 + - 超期清理 372 + - 不删除非日志文件 373 + 374 + ### Phase B:Console API 375 + 376 + - [x] 在 runtime API 中增加 `GET /logs/latest`。 377 + - [x] 实现最新日志文件选择。 378 + - [x] 实现尾部读取与 cursor 分页。 379 + - [x] 响应中只返回 basename,不返回绝对路径。 380 + - [x] 增加 handler 测试: 381 + - 未登录返回 `401` 382 + - 无日志文件返回空结果 383 + - 默认读取最新 300 行 384 + - `limit` 边界 385 + - cursor 翻页 386 + - 不返回绝对路径 387 + 388 + ### Phase C:Console Web 389 + 390 + - [x] 新增 `LogsView`。 391 + - [x] 新增路由 `/logs`。 392 + - [x] 在 `Settings -> Console` 中增加 Logs 入口。 393 + - [x] 通过 `runtimeApiFetch("/logs/latest")` 接入 runtime API。 394 + - [x] 增加默认自动刷新、行数选择、加载更早或向上无限加载。 395 + - [x] 构建验证:`pnpm build`。 396 + 397 + ## 10) 验收标准 398 + 399 + 1. 未配置 `logging.file.dir` 时,日志写入 `<file_state_dir>/logs/`。 400 + 2. 配置 `logging.file.dir` 后,日志写入指定目录。 401 + 3. 每天只写当天对应的日志文件。 402 + 4. 超过 `logging.file.max_age` 的旧日志会被清理。 403 + 5. stderr 日志仍然存在。 404 + 6. `GET /logs/latest` 只能通过已鉴权的 Console proxy 和 endpoint auth 访问。 405 + 7. API 响应不包含本地绝对路径。 406 + 8. Console Web 的 Logs 视图能显示最新日志。 407 + 9. Logs 视图刷新后能看到新写入的日志行。 408 + 10. 没有日志文件时,UI 显示空状态而不是报错。 409 + 11. Logs 视图可以通过 cursor 加载更早日志。
+2
internal/configdefaults/defaults.go
··· 38 38 v.SetDefault("file_cache.max_files", 1000) 39 39 v.SetDefault("file_cache.max_total_bytes", int64(512*1024*1024)) 40 40 v.SetDefault("user_agent", "mistermorph/1.0 (+https://github.com/quailyquaily)") 41 + v.SetDefault("logging.file.dir", "") 42 + v.SetDefault("logging.file.max_age", 7*24*time.Hour) 41 43 42 44 v.SetDefault("skills.enabled", true) 43 45 v.SetDefault("skills.dir_name", "skills")
+308
internal/daemonruntime/server.go
··· 5 5 "context" 6 6 "crypto/sha256" 7 7 "crypto/subtle" 8 + "encoding/base64" 8 9 "encoding/hex" 9 10 "encoding/json" 10 11 "errors" ··· 106 107 auditMinLineLimit int64 = 1 107 108 auditMaxLineLimit int64 = 500 108 109 auditMaxCursorLines int64 = 200 * 1000 110 + logDefaultLineLimit int64 = 300 111 + logMinLineLimit int64 = 1 112 + logMaxLineLimit int64 = 1000 113 + logMaxCursorLines int64 = 1000 * 1000 109 114 contactsMaxPageSize int64 = 2000 110 115 contactsMaxOffset int64 = 200 * 1000 111 116 ) ··· 113 118 var ( 114 119 memoryDayPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) 115 120 memoryFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.md$`) 121 + logFilenamePattern = regexp.MustCompile(`^mistermorph-\d{4}-\d{2}-\d{2}\.jsonl$`) 116 122 ) 117 123 118 124 type auditFileItem struct { ··· 136 142 From int64 `json:"from"` 137 143 To int64 `json:"to"` 138 144 HasOlder bool `json:"has_older"` 145 + Lines []string `json:"lines"` 146 + } 147 + 148 + type logChunk struct { 149 + File string `json:"file,omitempty"` 150 + Exists bool `json:"exists"` 151 + SizeBytes int64 `json:"size_bytes"` 152 + ModTime string `json:"mod_time,omitempty"` 153 + Limit int64 `json:"limit"` 154 + TotalLines int64 `json:"total_lines"` 155 + From int64 `json:"from"` 156 + To int64 `json:"to"` 157 + HasOlder bool `json:"has_older"` 158 + OlderCursor string `json:"older_cursor,omitempty"` 139 159 Lines []string `json:"lines"` 140 160 } 141 161 ··· 727 747 _ = json.NewEncoder(w).Encode(chunk) 728 748 }) 729 749 750 + mux.HandleFunc("/logs/latest", func(w http.ResponseWriter, r *http.Request) { 751 + if r.Method != http.MethodGet { 752 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 753 + return 754 + } 755 + if !checkAuth(r, authToken) { 756 + http.Error(w, "unauthorized", http.StatusUnauthorized) 757 + return 758 + } 759 + limit, err := parseInt64QueryParamInRange(r.URL.Query().Get("limit"), logDefaultLineLimit, logMinLineLimit, logMaxLineLimit) 760 + if err != nil { 761 + http.Error(w, "invalid limit", http.StatusBadRequest) 762 + return 763 + } 764 + chunk, err := readLatestLogChunk(resolveRuntimeLogDir(), strings.TrimSpace(r.URL.Query().Get("cursor")), limit) 765 + if err != nil { 766 + if badRequest, ok := badRequestMessage(err); ok { 767 + http.Error(w, badRequest, http.StatusBadRequest) 768 + return 769 + } 770 + http.Error(w, err.Error(), http.StatusInternalServerError) 771 + return 772 + } 773 + w.Header().Set("Content-Type", "application/json") 774 + _ = json.NewEncoder(w).Encode(chunk) 775 + }) 776 + 730 777 mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) { 731 778 if !checkAuth(r, authToken) { 732 779 http.Error(w, "unauthorized", http.StatusUnauthorized) ··· 1290 1337 } 1291 1338 guardDir := pathutil.ResolveStateChildDir(stateDir, strings.TrimSpace(viper.GetString("guard.dir_name")), "guard") 1292 1339 return filepath.Join(guardDir, "audit", "guard_audit.jsonl") 1340 + } 1341 + 1342 + func resolveRuntimeLogDir() string { 1343 + configured := strings.TrimSpace(viper.GetString("logging.file.dir")) 1344 + if configured != "" { 1345 + return pathutil.ExpandHomePath(configured) 1346 + } 1347 + return filepath.Clean(filepath.Join(pathutil.ResolveStateDir(viper.GetString("file_state_dir")), "logs")) 1293 1348 } 1294 1349 1295 1350 func describeFile(name, p string) map[string]any { ··· 2009 2064 } 2010 2065 chunk.Lines = lines 2011 2066 return chunk, nil 2067 + } 2068 + 2069 + type logCursor struct { 2070 + File string `json:"file"` 2071 + Before int64 `json:"before"` 2072 + } 2073 + 2074 + type logFileRef struct { 2075 + Name string 2076 + Path string 2077 + Date time.Time 2078 + } 2079 + 2080 + func readLatestLogChunk(dirPath string, cursorRaw string, limit int64) (logChunk, error) { 2081 + chunk := logChunk{ 2082 + Limit: limit, 2083 + Lines: []string{}, 2084 + } 2085 + dirPath = strings.TrimSpace(dirPath) 2086 + if dirPath == "" { 2087 + return chunk, fmt.Errorf("log directory is not configured") 2088 + } 2089 + if limit <= 0 { 2090 + limit = logDefaultLineLimit 2091 + chunk.Limit = limit 2092 + } 2093 + 2094 + files, err := listLogFiles(dirPath) 2095 + if err != nil { 2096 + return chunk, err 2097 + } 2098 + if len(files) == 0 { 2099 + return chunk, nil 2100 + } 2101 + 2102 + targetIndex := 0 2103 + before := int64(0) 2104 + if cursorRaw != "" { 2105 + cursor, err := decodeLogCursor(cursorRaw) 2106 + if err != nil { 2107 + return chunk, BadRequest("invalid cursor") 2108 + } 2109 + targetIndex = -1 2110 + for i, item := range files { 2111 + if item.Name == cursor.File { 2112 + targetIndex = i 2113 + break 2114 + } 2115 + } 2116 + if targetIndex < 0 { 2117 + return chunk, BadRequest("invalid cursor") 2118 + } 2119 + before = cursor.Before 2120 + if before < 0 || before > logMaxCursorLines { 2121 + return chunk, BadRequest("invalid cursor") 2122 + } 2123 + } else { 2124 + today := "mistermorph-" + time.Now().Local().Format("2006-01-02") + ".jsonl" 2125 + for i, item := range files { 2126 + if item.Name == today { 2127 + targetIndex = i 2128 + break 2129 + } 2130 + } 2131 + } 2132 + 2133 + for i := targetIndex; i < len(files); i++ { 2134 + item := files[i] 2135 + page, err := readLogFilePage(item.Path, before, limit) 2136 + if err != nil { 2137 + return chunk, err 2138 + } 2139 + page.File = item.Name 2140 + chunk = page 2141 + if len(chunk.Lines) > 0 || i == len(files)-1 { 2142 + if !chunk.HasOlder && i < len(files)-1 { 2143 + chunk.HasOlder = true 2144 + chunk.OlderCursor = encodeLogCursor(logCursor{File: files[i+1].Name}) 2145 + } 2146 + return chunk, nil 2147 + } 2148 + before = 0 2149 + } 2150 + return chunk, nil 2151 + } 2152 + 2153 + func listLogFiles(dirPath string) ([]logFileRef, error) { 2154 + entries, err := os.ReadDir(dirPath) 2155 + if err != nil { 2156 + if errors.Is(err, os.ErrNotExist) { 2157 + return []logFileRef{}, nil 2158 + } 2159 + return nil, err 2160 + } 2161 + files := make([]logFileRef, 0, len(entries)) 2162 + for _, entry := range entries { 2163 + if entry.IsDir() { 2164 + continue 2165 + } 2166 + name := strings.TrimSpace(entry.Name()) 2167 + if !logFilenamePattern.MatchString(name) { 2168 + continue 2169 + } 2170 + date, err := time.ParseInLocation("2006-01-02", strings.TrimSuffix(strings.TrimPrefix(name, "mistermorph-"), ".jsonl"), time.Local) 2171 + if err != nil { 2172 + continue 2173 + } 2174 + files = append(files, logFileRef{ 2175 + Name: name, 2176 + Path: filepath.Join(dirPath, name), 2177 + Date: date, 2178 + }) 2179 + } 2180 + sort.SliceStable(files, func(i, j int) bool { 2181 + if !files[i].Date.Equal(files[j].Date) { 2182 + return files[i].Date.After(files[j].Date) 2183 + } 2184 + return files[i].Name > files[j].Name 2185 + }) 2186 + return files, nil 2187 + } 2188 + 2189 + func readLogFilePage(filePath string, before int64, limit int64) (logChunk, error) { 2190 + chunk := logChunk{ 2191 + Exists: false, 2192 + Limit: limit, 2193 + Lines: []string{}, 2194 + } 2195 + if before < 0 { 2196 + before = 0 2197 + } 2198 + if limit <= 0 { 2199 + limit = logDefaultLineLimit 2200 + chunk.Limit = limit 2201 + } 2202 + need := before + limit 2203 + if need < limit || need > logMaxCursorLines+logMaxLineLimit { 2204 + need = logMaxCursorLines + logMaxLineLimit 2205 + } 2206 + 2207 + fd, err := os.Open(filePath) 2208 + if err != nil { 2209 + if errors.Is(err, os.ErrNotExist) { 2210 + return chunk, nil 2211 + } 2212 + return chunk, err 2213 + } 2214 + defer fd.Close() 2215 + 2216 + fi, err := fd.Stat() 2217 + if err != nil { 2218 + return chunk, err 2219 + } 2220 + if fi.IsDir() { 2221 + return chunk, fmt.Errorf("log path is a directory") 2222 + } 2223 + chunk.Exists = true 2224 + chunk.SizeBytes = fi.Size() 2225 + chunk.ModTime = fi.ModTime().UTC().Format(time.RFC3339) 2226 + if fi.Size() <= 0 { 2227 + return chunk, nil 2228 + } 2229 + 2230 + scanner := bufio.NewScanner(fd) 2231 + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) 2232 + tail := make([]string, int(need)) 2233 + var total int64 2234 + for scanner.Scan() { 2235 + line := strings.TrimSuffix(scanner.Text(), "\r") 2236 + if line == "" { 2237 + continue 2238 + } 2239 + tail[int(total%need)] = line 2240 + total++ 2241 + } 2242 + if err := scanner.Err(); err != nil { 2243 + return chunk, err 2244 + } 2245 + chunk.TotalLines = total 2246 + if total <= 0 { 2247 + return chunk, nil 2248 + } 2249 + if before > total { 2250 + before = total 2251 + } 2252 + end := total - before 2253 + if end < 0 { 2254 + end = 0 2255 + } 2256 + start := end - limit 2257 + if start < 0 { 2258 + start = 0 2259 + } 2260 + pageCount := end - start 2261 + chunk.From = 0 2262 + chunk.To = 0 2263 + if pageCount <= 0 { 2264 + return chunk, nil 2265 + } 2266 + 2267 + tailCount := total 2268 + if tailCount > need { 2269 + tailCount = need 2270 + } 2271 + tailStart := total - tailCount 2272 + localStart := start - tailStart 2273 + localEnd := end - tailStart 2274 + lines := make([]string, 0, int(pageCount)) 2275 + for i := localStart; i < localEnd; i++ { 2276 + idx := (tailStart + i) % need 2277 + if idx < 0 { 2278 + idx += need 2279 + } 2280 + lines = append(lines, tail[int(idx)]) 2281 + } 2282 + chunk.Lines = lines 2283 + chunk.From = start + 1 2284 + chunk.To = end 2285 + if start > 0 { 2286 + chunk.HasOlder = true 2287 + chunk.OlderCursor = encodeLogCursor(logCursor{ 2288 + File: filepath.Base(filePath), 2289 + Before: before + pageCount, 2290 + }) 2291 + } 2292 + return chunk, nil 2293 + } 2294 + 2295 + func encodeLogCursor(cursor logCursor) string { 2296 + if strings.TrimSpace(cursor.File) == "" { 2297 + return "" 2298 + } 2299 + data, err := json.Marshal(cursor) 2300 + if err != nil { 2301 + return "" 2302 + } 2303 + return base64.RawURLEncoding.EncodeToString(data) 2304 + } 2305 + 2306 + func decodeLogCursor(raw string) (logCursor, error) { 2307 + var cursor logCursor 2308 + data, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw)) 2309 + if err != nil { 2310 + return cursor, err 2311 + } 2312 + if err := json.Unmarshal(data, &cursor); err != nil { 2313 + return cursor, err 2314 + } 2315 + cursor.File = strings.TrimSpace(filepath.Base(cursor.File)) 2316 + if cursor.File == "." || cursor.File == "" || !logFilenamePattern.MatchString(cursor.File) { 2317 + return cursor, fmt.Errorf("invalid cursor file") 2318 + } 2319 + return cursor, nil 2012 2320 } 2013 2321 2014 2322 func IsContextDeadline(ctx context.Context, err error) bool {
+162
internal/daemonruntime/server_logs_test.go
··· 1 + package daemonruntime 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + "testing" 11 + 12 + "github.com/spf13/viper" 13 + ) 14 + 15 + func TestLogsLatestRoutePaginatesAcrossFiles(t *testing.T) { 16 + stateDir := t.TempDir() 17 + logDir := filepath.Join(stateDir, "logs") 18 + if err := os.MkdirAll(logDir, 0o700); err != nil { 19 + t.Fatalf("MkdirAll(logDir) error = %v", err) 20 + } 21 + writeLogFixture(t, logDir, "mistermorph-2026-04-23.jsonl", []string{`{"msg":"old-1"}`, `{"msg":"old-2"}`}) 22 + writeLogFixture(t, logDir, "mistermorph-2026-04-24.jsonl", []string{`{"msg":"new-1"}`, `{"msg":"new-2"}`, `{"msg":"new-3"}`, `{"msg":"new-4"}`}) 23 + 24 + restore := setViperForLogRouteTest(stateDir, "") 25 + defer restore() 26 + 27 + mux := http.NewServeMux() 28 + RegisterRoutes(mux, RoutesOptions{AuthToken: "token"}) 29 + 30 + first := requestLogChunk(t, mux, "/logs/latest?limit=2", "token") 31 + if first.File != "mistermorph-2026-04-24.jsonl" { 32 + t.Fatalf("first.File = %q, want latest file", first.File) 33 + } 34 + if strings.Join(first.Lines, "\n") != `{"msg":"new-3"}`+"\n"+`{"msg":"new-4"}` { 35 + t.Fatalf("first lines = %#v", first.Lines) 36 + } 37 + if !first.HasOlder || first.OlderCursor == "" { 38 + t.Fatalf("first page missing older cursor: %+v", first) 39 + } 40 + 41 + second := requestLogChunk(t, mux, "/logs/latest?limit=2&cursor="+first.OlderCursor, "token") 42 + if second.File != "mistermorph-2026-04-24.jsonl" { 43 + t.Fatalf("second.File = %q, want same file", second.File) 44 + } 45 + if strings.Join(second.Lines, "\n") != `{"msg":"new-1"}`+"\n"+`{"msg":"new-2"}` { 46 + t.Fatalf("second lines = %#v", second.Lines) 47 + } 48 + if !second.HasOlder || second.OlderCursor == "" { 49 + t.Fatalf("second page missing cross-file cursor: %+v", second) 50 + } 51 + 52 + third := requestLogChunk(t, mux, "/logs/latest?limit=2&cursor="+second.OlderCursor, "token") 53 + if third.File != "mistermorph-2026-04-23.jsonl" { 54 + t.Fatalf("third.File = %q, want previous file", third.File) 55 + } 56 + if strings.Join(third.Lines, "\n") != `{"msg":"old-1"}`+"\n"+`{"msg":"old-2"}` { 57 + t.Fatalf("third lines = %#v", third.Lines) 58 + } 59 + if third.HasOlder || third.OlderCursor != "" { 60 + t.Fatalf("third page should be oldest: %+v", third) 61 + } 62 + } 63 + 64 + func TestLogsLatestRouteDoesNotExposeAbsolutePath(t *testing.T) { 65 + stateDir := t.TempDir() 66 + logDir := filepath.Join(stateDir, "logs") 67 + if err := os.MkdirAll(logDir, 0o700); err != nil { 68 + t.Fatalf("MkdirAll(logDir) error = %v", err) 69 + } 70 + writeLogFixture(t, logDir, "mistermorph-2026-04-24.jsonl", []string{`{"msg":"hello"}`}) 71 + 72 + restore := setViperForLogRouteTest(stateDir, "") 73 + defer restore() 74 + 75 + mux := http.NewServeMux() 76 + RegisterRoutes(mux, RoutesOptions{AuthToken: "token"}) 77 + 78 + req := httptest.NewRequest(http.MethodGet, "/logs/latest", nil) 79 + req.Header.Set("Authorization", "Bearer token") 80 + rec := httptest.NewRecorder() 81 + mux.ServeHTTP(rec, req) 82 + if rec.Code != http.StatusOK { 83 + t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 84 + } 85 + if strings.Contains(rec.Body.String(), stateDir) || strings.Contains(rec.Body.String(), logDir) { 86 + t.Fatalf("response exposed absolute path: %s", rec.Body.String()) 87 + } 88 + } 89 + 90 + func TestLogsLatestRouteRequiresAuth(t *testing.T) { 91 + mux := http.NewServeMux() 92 + RegisterRoutes(mux, RoutesOptions{AuthToken: "token"}) 93 + 94 + req := httptest.NewRequest(http.MethodGet, "/logs/latest", nil) 95 + rec := httptest.NewRecorder() 96 + mux.ServeHTTP(rec, req) 97 + if rec.Code != http.StatusUnauthorized { 98 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) 99 + } 100 + } 101 + 102 + func TestLogsLatestRouteEmptyWhenNoLogFiles(t *testing.T) { 103 + stateDir := t.TempDir() 104 + restore := setViperForLogRouteTest(stateDir, "") 105 + defer restore() 106 + 107 + mux := http.NewServeMux() 108 + RegisterRoutes(mux, RoutesOptions{AuthToken: "token"}) 109 + 110 + chunk := requestLogChunk(t, mux, "/logs/latest", "token") 111 + if chunk.Exists || len(chunk.Lines) != 0 { 112 + t.Fatalf("chunk = %+v, want empty missing log state", chunk) 113 + } 114 + } 115 + 116 + func requestLogChunk(t *testing.T, mux http.Handler, path string, token string) logChunk { 117 + t.Helper() 118 + req := httptest.NewRequest(http.MethodGet, path, nil) 119 + if token != "" { 120 + req.Header.Set("Authorization", "Bearer "+token) 121 + } 122 + rec := httptest.NewRecorder() 123 + mux.ServeHTTP(rec, req) 124 + if rec.Code != http.StatusOK { 125 + t.Fatalf("%s status = %d, want %d (%s)", path, rec.Code, http.StatusOK, rec.Body.String()) 126 + } 127 + var chunk logChunk 128 + if err := json.Unmarshal(rec.Body.Bytes(), &chunk); err != nil { 129 + t.Fatalf("json.Unmarshal() error = %v", err) 130 + } 131 + return chunk 132 + } 133 + 134 + func writeLogFixture(t *testing.T, dir string, name string, lines []string) { 135 + t.Helper() 136 + content := strings.Join(lines, "\n") 137 + if content != "" { 138 + content += "\n" 139 + } 140 + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { 141 + t.Fatalf("WriteFile(%s) error = %v", name, err) 142 + } 143 + } 144 + 145 + func setViperForLogRouteTest(stateDir string, logDir string) func() { 146 + prevState, hadState := viper.Get("file_state_dir"), viper.IsSet("file_state_dir") 147 + prevLogDir, hadLogDir := viper.Get("logging.file.dir"), viper.IsSet("logging.file.dir") 148 + viper.Set("file_state_dir", stateDir) 149 + viper.Set("logging.file.dir", logDir) 150 + return func() { 151 + if hadState { 152 + viper.Set("file_state_dir", prevState) 153 + } else { 154 + viper.Set("file_state_dir", nil) 155 + } 156 + if hadLogDir { 157 + viper.Set("logging.file.dir", prevLogDir) 158 + } else { 159 + viper.Set("logging.file.dir", nil) 160 + } 161 + } 162 + }
+260 -6
internal/logutil/logutil.go
··· 1 1 package logutil 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 6 + "io" 5 7 "log/slog" 6 8 "os" 9 + "path/filepath" 7 10 "strings" 11 + "sync" 12 + "time" 8 13 9 14 "github.com/quailyquaily/mistermorph/agent" 15 + "github.com/quailyquaily/mistermorph/internal/pathutil" 10 16 "github.com/spf13/viper" 11 17 ) 12 18 19 + const ( 20 + DefaultFileLogMaxAge = 7 * 24 * time.Hour 21 + fileLogDateLayout = "2006-01-02" 22 + fileLogPrefix = "mistermorph-" 23 + fileLogSuffix = ".jsonl" 24 + ) 25 + 13 26 type ConfigReader interface { 14 27 GetString(string) string 15 28 GetBool(string) bool ··· 19 32 } 20 33 21 34 type LoggerConfig struct { 22 - Level string 23 - Format string 24 - AddSource bool 35 + Level string 36 + Format string 37 + AddSource bool 38 + FileDir string 39 + FileMaxAge string 40 + FileStateDir string 25 41 } 26 42 27 43 type LogOptionsConfig struct { ··· 41 57 return LoggerConfig{} 42 58 } 43 59 return LoggerConfig{ 44 - Level: r.GetString("logging.level"), 45 - Format: r.GetString("logging.format"), 46 - AddSource: r.GetBool("logging.add_source"), 60 + Level: r.GetString("logging.level"), 61 + Format: r.GetString("logging.format"), 62 + AddSource: r.GetBool("logging.add_source"), 63 + FileDir: r.GetString("logging.file.dir"), 64 + FileMaxAge: r.GetString("logging.file.max_age"), 65 + FileStateDir: r.GetString("file_state_dir"), 47 66 } 48 67 } 49 68 ··· 120 139 return nil, fmt.Errorf("unknown logging.format: %s", cfg.Format) 121 140 } 122 141 142 + fileMaxAge, err := ParseFileLogMaxAge(cfg.FileMaxAge) 143 + if err != nil { 144 + return nil, err 145 + } 146 + fileWriter, err := newDailyLogWriter(dailyLogWriterConfig{ 147 + Dir: ResolveFileLogDir(cfg.FileStateDir, cfg.FileDir), 148 + MaxAge: fileMaxAge, 149 + FileBase: fileLogPrefix, 150 + }) 151 + if err != nil { 152 + return nil, err 153 + } 154 + h = multiHandler{slog.NewJSONHandler(fileWriter, opts), h} 155 + 123 156 return slog.New(h), nil 124 157 } 125 158 159 + func ResolveFileLogDir(fileStateDir, configuredDir string) string { 160 + configuredDir = strings.TrimSpace(configuredDir) 161 + if configuredDir != "" { 162 + return pathutil.ExpandHomePath(configuredDir) 163 + } 164 + return filepath.Clean(filepath.Join(pathutil.ResolveStateDir(fileStateDir), "logs")) 165 + } 166 + 167 + func ParseFileLogMaxAge(raw string) (time.Duration, error) { 168 + raw = strings.TrimSpace(raw) 169 + if raw == "" { 170 + return DefaultFileLogMaxAge, nil 171 + } 172 + d, err := time.ParseDuration(raw) 173 + if err != nil { 174 + return 0, fmt.Errorf("invalid logging.file.max_age: %w", err) 175 + } 176 + if d <= 0 { 177 + return 0, fmt.Errorf("invalid logging.file.max_age: must be positive") 178 + } 179 + return d, nil 180 + } 181 + 126 182 func parseSlogLevel(s string) (slog.Level, error) { 127 183 switch strings.ToLower(strings.TrimSpace(s)) { 128 184 case "", "info": ··· 137 193 return slog.LevelInfo, fmt.Errorf("unknown logging.level: %s", s) 138 194 } 139 195 } 196 + 197 + type multiHandler []slog.Handler 198 + 199 + func (h multiHandler) Enabled(ctx context.Context, level slog.Level) bool { 200 + for _, child := range h { 201 + if child != nil && child.Enabled(ctx, level) { 202 + return true 203 + } 204 + } 205 + return false 206 + } 207 + 208 + func (h multiHandler) Handle(ctx context.Context, record slog.Record) error { 209 + var firstErr error 210 + for _, child := range h { 211 + if child == nil || !child.Enabled(ctx, record.Level) { 212 + continue 213 + } 214 + if err := child.Handle(ctx, record.Clone()); err != nil && firstErr == nil { 215 + firstErr = err 216 + } 217 + } 218 + return firstErr 219 + } 220 + 221 + func (h multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 222 + out := make(multiHandler, 0, len(h)) 223 + for _, child := range h { 224 + if child != nil { 225 + out = append(out, child.WithAttrs(attrs)) 226 + } 227 + } 228 + return out 229 + } 230 + 231 + func (h multiHandler) WithGroup(name string) slog.Handler { 232 + out := make(multiHandler, 0, len(h)) 233 + for _, child := range h { 234 + if child != nil { 235 + out = append(out, child.WithGroup(name)) 236 + } 237 + } 238 + return out 239 + } 240 + 241 + type dailyLogWriterConfig struct { 242 + Dir string 243 + MaxAge time.Duration 244 + Now func() time.Time 245 + FileBase string 246 + } 247 + 248 + type dailyLogWriter struct { 249 + mu sync.Mutex 250 + dir string 251 + maxAge time.Duration 252 + now func() time.Time 253 + fileBase string 254 + date string 255 + file *os.File 256 + } 257 + 258 + func newDailyLogWriter(cfg dailyLogWriterConfig) (*dailyLogWriter, error) { 259 + dir := strings.TrimSpace(cfg.Dir) 260 + if dir == "" { 261 + return nil, fmt.Errorf("logging.file.dir resolved to empty path") 262 + } 263 + now := cfg.Now 264 + if now == nil { 265 + now = time.Now 266 + } 267 + maxAge := cfg.MaxAge 268 + if maxAge <= 0 { 269 + maxAge = DefaultFileLogMaxAge 270 + } 271 + fileBase := strings.TrimSpace(cfg.FileBase) 272 + if fileBase == "" { 273 + fileBase = fileLogPrefix 274 + } 275 + w := &dailyLogWriter{ 276 + dir: filepath.Clean(dir), 277 + maxAge: maxAge, 278 + now: now, 279 + fileBase: fileBase, 280 + } 281 + if err := os.MkdirAll(w.dir, 0o700); err != nil { 282 + return nil, fmt.Errorf("create log dir: %w", err) 283 + } 284 + w.mu.Lock() 285 + defer w.mu.Unlock() 286 + if err := w.cleanupLocked(now()); err != nil { 287 + return nil, err 288 + } 289 + return w, nil 290 + } 291 + 292 + func (w *dailyLogWriter) Write(p []byte) (int, error) { 293 + if w == nil { 294 + return 0, fmt.Errorf("log writer is nil") 295 + } 296 + w.mu.Lock() 297 + defer w.mu.Unlock() 298 + now := w.now() 299 + if err := w.ensureFileLocked(now); err != nil { 300 + return 0, err 301 + } 302 + n, err := w.file.Write(p) 303 + if err != nil { 304 + return n, err 305 + } 306 + if n != len(p) { 307 + return n, io.ErrShortWrite 308 + } 309 + return n, nil 310 + } 311 + 312 + func (w *dailyLogWriter) Close() error { 313 + if w == nil { 314 + return nil 315 + } 316 + w.mu.Lock() 317 + defer w.mu.Unlock() 318 + return w.closeLocked() 319 + } 320 + 321 + func (w *dailyLogWriter) ensureFileLocked(now time.Time) error { 322 + date := now.Local().Format(fileLogDateLayout) 323 + if w.file != nil && w.date == date { 324 + return nil 325 + } 326 + if err := w.closeLocked(); err != nil { 327 + return err 328 + } 329 + if err := os.MkdirAll(w.dir, 0o700); err != nil { 330 + return fmt.Errorf("create log dir: %w", err) 331 + } 332 + filePath := filepath.Join(w.dir, w.fileBase+date+fileLogSuffix) 333 + f, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) 334 + if err != nil { 335 + return fmt.Errorf("open log file: %w", err) 336 + } 337 + w.file = f 338 + w.date = date 339 + if err := w.cleanupLocked(now); err != nil { 340 + return err 341 + } 342 + return nil 343 + } 344 + 345 + func (w *dailyLogWriter) closeLocked() error { 346 + if w.file == nil { 347 + return nil 348 + } 349 + f := w.file 350 + w.file = nil 351 + w.date = "" 352 + if err := f.Close(); err != nil { 353 + return fmt.Errorf("close log file: %w", err) 354 + } 355 + return nil 356 + } 357 + 358 + func (w *dailyLogWriter) cleanupLocked(now time.Time) error { 359 + entries, err := os.ReadDir(w.dir) 360 + if err != nil { 361 + return fmt.Errorf("read log dir: %w", err) 362 + } 363 + cutoff := now.Local().Add(-w.maxAge) 364 + cutoffDate := time.Date(cutoff.Year(), cutoff.Month(), cutoff.Day(), 0, 0, 0, 0, cutoff.Location()) 365 + for _, entry := range entries { 366 + if entry.IsDir() { 367 + continue 368 + } 369 + date, ok := parseLogFilenameDate(w.fileBase, entry.Name(), cutoff.Location()) 370 + if !ok || !date.Before(cutoffDate) { 371 + continue 372 + } 373 + if err := os.Remove(filepath.Join(w.dir, entry.Name())); err != nil { 374 + fmt.Fprintf(os.Stderr, "warn: remove old log file failed file=%q error=%v\n", entry.Name(), err) 375 + } 376 + } 377 + return nil 378 + } 379 + 380 + func parseLogFilenameDate(fileBase, name string, loc *time.Location) (time.Time, bool) { 381 + if loc == nil { 382 + loc = time.Local 383 + } 384 + if !strings.HasPrefix(name, fileBase) || !strings.HasSuffix(name, fileLogSuffix) { 385 + return time.Time{}, false 386 + } 387 + rawDate := strings.TrimSuffix(strings.TrimPrefix(name, fileBase), fileLogSuffix) 388 + date, err := time.ParseInLocation(fileLogDateLayout, rawDate, loc) 389 + if err != nil { 390 + return time.Time{}, false 391 + } 392 + return date, true 393 + }
+109
internal/logutil/logutil_test.go
··· 1 + package logutil 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + "github.com/quailyquaily/mistermorph/internal/pathutil" 11 + ) 12 + 13 + func TestResolveFileLogDir_DefaultsUnderStateDir(t *testing.T) { 14 + got := ResolveFileLogDir(filepath.Join(t.TempDir(), "state"), "") 15 + if !strings.HasSuffix(got, filepath.Join("state", "logs")) { 16 + t.Fatalf("ResolveFileLogDir() = %q, want to end with state/logs", got) 17 + } 18 + } 19 + 20 + func TestResolveFileLogDir_CustomDir(t *testing.T) { 21 + dir := filepath.Join(t.TempDir(), "custom-logs") 22 + got := ResolveFileLogDir("", dir) 23 + if got != pathutil.ExpandHomePath(dir) { 24 + t.Fatalf("ResolveFileLogDir() = %q, want %q", got, pathutil.ExpandHomePath(dir)) 25 + } 26 + } 27 + 28 + func TestParseFileLogMaxAge(t *testing.T) { 29 + got, err := ParseFileLogMaxAge("168h") 30 + if err != nil { 31 + t.Fatalf("ParseFileLogMaxAge() error = %v", err) 32 + } 33 + if got != 7*24*time.Hour { 34 + t.Fatalf("ParseFileLogMaxAge() = %s, want 168h", got) 35 + } 36 + if _, err := ParseFileLogMaxAge("0s"); err == nil { 37 + t.Fatalf("ParseFileLogMaxAge(0s) expected error") 38 + } 39 + } 40 + 41 + func TestDailyLogWriter_RotatesByLocalDate(t *testing.T) { 42 + dir := t.TempDir() 43 + now := time.Date(2026, 4, 24, 23, 59, 0, 0, time.Local) 44 + writer, err := newDailyLogWriter(dailyLogWriterConfig{ 45 + Dir: dir, 46 + MaxAge: DefaultFileLogMaxAge, 47 + Now: func() time.Time { return now }, 48 + FileBase: "test-", 49 + }) 50 + if err != nil { 51 + t.Fatalf("newDailyLogWriter() error = %v", err) 52 + } 53 + defer writer.Close() 54 + 55 + if _, err := writer.Write([]byte("first\n")); err != nil { 56 + t.Fatalf("Write(first) error = %v", err) 57 + } 58 + now = now.Add(2 * time.Minute) 59 + if _, err := writer.Write([]byte("second\n")); err != nil { 60 + t.Fatalf("Write(second) error = %v", err) 61 + } 62 + 63 + first, err := os.ReadFile(filepath.Join(dir, "test-2026-04-24.jsonl")) 64 + if err != nil { 65 + t.Fatalf("read first file: %v", err) 66 + } 67 + second, err := os.ReadFile(filepath.Join(dir, "test-2026-04-25.jsonl")) 68 + if err != nil { 69 + t.Fatalf("read second file: %v", err) 70 + } 71 + if strings.TrimSpace(string(first)) != "first" || strings.TrimSpace(string(second)) != "second" { 72 + t.Fatalf("rotated contents = %q / %q", first, second) 73 + } 74 + } 75 + 76 + func TestDailyLogWriter_CleansByFilenameDateOnly(t *testing.T) { 77 + dir := t.TempDir() 78 + files := map[string]string{ 79 + "test-2026-04-01.jsonl": "old\n", 80 + "test-2026-04-23.jsonl": "new\n", 81 + "notes.txt": "keep\n", 82 + } 83 + for name, content := range files { 84 + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { 85 + t.Fatalf("write fixture %s: %v", name, err) 86 + } 87 + } 88 + 89 + writer, err := newDailyLogWriter(dailyLogWriterConfig{ 90 + Dir: dir, 91 + MaxAge: 7 * 24 * time.Hour, 92 + Now: func() time.Time { return time.Date(2026, 4, 24, 12, 0, 0, 0, time.Local) }, 93 + FileBase: "test-", 94 + }) 95 + if err != nil { 96 + t.Fatalf("newDailyLogWriter() error = %v", err) 97 + } 98 + defer writer.Close() 99 + 100 + if _, err := os.Stat(filepath.Join(dir, "test-2026-04-01.jsonl")); !os.IsNotExist(err) { 101 + t.Fatalf("old log stat error = %v, want not exist", err) 102 + } 103 + if _, err := os.Stat(filepath.Join(dir, "test-2026-04-23.jsonl")); err != nil { 104 + t.Fatalf("new log should remain: %v", err) 105 + } 106 + if _, err := os.Stat(filepath.Join(dir, "notes.txt")); err != nil { 107 + t.Fatalf("unrelated file should remain: %v", err) 108 + } 109 + }
+51
web/console/src/i18n/index.js
··· 279 279 audit_risk_medium: "Medium", 280 280 audit_risk_high: "High", 281 281 audit_risk_critical: "Critical", 282 + logs_title: "Logs", 283 + logs_stream: "Runtime Log", 284 + logs_unknown_file: "No log file", 285 + logs_meta_empty: "No log metadata", 286 + logs_updated: "Updated {value}", 287 + logs_size: "{value}", 288 + logs_range: "Lines {from}-{to} / {total}", 289 + logs_line_count: "Line count", 290 + logs_empty: "No logs yet", 291 + logs_unsupported: "Current endpoint does not support log viewing", 292 + logs_load_older: "Load older", 293 + logs_no_older: "No older logs", 294 + logs_new_available: "New log lines are available", 295 + logs_latest: "Latest", 282 296 283 297 chat_title: "Chat", 284 298 chat_intro: "Write something here to start a new chat.", ··· 594 608 settings_console_preferences_hint: "Adjust console language and session actions.", 595 609 settings_language_title: "Language", 596 610 settings_language_hint: "Choose the console interface language.", 611 + settings_logs_title: "Logs", 612 + settings_logs_hint: "Open the latest runtime log stream.", 613 + settings_logs_open: "Open Logs", 597 614 settings_session_title: "Session", 598 615 settings_session_hint: "Sign out of this console session.", 599 616 placeholder_select_file: "Select file", ··· 930 947 audit_risk_medium: "中", 931 948 audit_risk_high: "高", 932 949 audit_risk_critical: "严重", 950 + logs_title: "日志", 951 + logs_stream: "运行时日志", 952 + logs_unknown_file: "没有日志文件", 953 + logs_meta_empty: "暂无日志信息", 954 + logs_updated: "更新于 {value}", 955 + logs_size: "{value}", 956 + logs_range: "第 {from}-{to} 行 / 共 {total} 行", 957 + logs_line_count: "日志行数", 958 + logs_empty: "暂无日志", 959 + logs_unsupported: "当前 endpoint 不支持日志查看", 960 + logs_load_older: "加载更早日志", 961 + logs_no_older: "没有更早日志", 962 + logs_new_available: "有新的日志行", 963 + logs_latest: "最新", 933 964 934 965 chat_title: "聊天", 935 966 chat_intro: "在这里输入内容,开始一段新对话。", ··· 1241 1272 settings_console_preferences_hint: "调整 Console 语言和当前会话操作。", 1242 1273 settings_language_title: "语言", 1243 1274 settings_language_hint: "选择 Console 界面语言。", 1275 + settings_logs_title: "日志", 1276 + settings_logs_hint: "查看最新运行时日志。", 1277 + settings_logs_open: "打开日志", 1244 1278 settings_session_title: "会话", 1245 1279 settings_session_hint: "退出当前 Console 会话。", 1246 1280 placeholder_select_file: "选择文件", ··· 1581 1615 audit_risk_medium: "中", 1582 1616 audit_risk_high: "高", 1583 1617 audit_risk_critical: "重大", 1618 + logs_title: "ログ", 1619 + logs_stream: "ランタイムログ", 1620 + logs_unknown_file: "ログファイルなし", 1621 + logs_meta_empty: "ログ情報はありません", 1622 + logs_updated: "{value} 更新", 1623 + logs_size: "{value}", 1624 + logs_range: "{from}-{to} 行 / 全 {total} 行", 1625 + logs_line_count: "行数", 1626 + logs_empty: "ログはまだありません", 1627 + logs_unsupported: "現在の endpoint はログ表示に対応していません", 1628 + logs_load_older: "古いログを読み込む", 1629 + logs_no_older: "古いログはありません", 1630 + logs_new_available: "新しいログ行があります", 1631 + logs_latest: "最新", 1584 1632 1585 1633 chat_title: "チャット", 1586 1634 chat_intro: "ここに入力すると、新しいチャットが始まります。", ··· 1886 1934 settings_console_preferences_hint: "Console の表示言語とセッション操作を調整します。", 1887 1935 settings_language_title: "言語", 1888 1936 settings_language_hint: "Console の表示言語を選択します。", 1937 + settings_logs_title: "ログ", 1938 + settings_logs_hint: "最新のランタイムログを表示します。", 1939 + settings_logs_open: "ログを開く", 1889 1940 settings_session_title: "セッション", 1890 1941 settings_session_hint: "現在の Console セッションからログアウトします。", 1891 1942 placeholder_select_file: "ファイルを選択",
+2
web/console/src/router/index.js
··· 27 27 ChatView, 28 28 ContactsView, 29 29 LoginView, 30 + LogsView, 30 31 MemoryView, 31 32 OverviewView, 32 33 RepairView, ··· 100 101 { path: "/tasks", component: TasksView }, 101 102 { path: "/stats", component: StatsView }, 102 103 { path: "/audit", component: AuditView }, 104 + { path: "/logs", component: LogsView }, 103 105 { path: "/memory", component: MemoryView }, 104 106 { path: "/files", component: StateFilesView }, 105 107 { path: "/contacts", component: ContactsView },
+217
web/console/src/views/LogsView.css
··· 1 + .logs-page .page-body { 2 + min-height: 0; 3 + overflow: hidden; 4 + } 5 + 6 + .logs-shell { 7 + flex: 1 1 auto; 8 + min-width: 0; 9 + min-height: 0; 10 + display: grid; 11 + grid-template-rows: auto auto auto minmax(0, 1fr); 12 + gap: 12px; 13 + padding: 0 var(--content-gutter-x) var(--content-gutter-y); 14 + } 15 + 16 + .logs-head { 17 + display: flex; 18 + align-items: flex-end; 19 + justify-content: space-between; 20 + gap: 16px; 21 + min-width: 0; 22 + padding-top: clamp(8px, 1vw, 14px); 23 + } 24 + 25 + .logs-title-block { 26 + min-width: 0; 27 + display: grid; 28 + gap: 4px; 29 + } 30 + 31 + .logs-current-file { 32 + margin: 0; 33 + min-width: 0; 34 + font-size: clamp(1.08rem, 1.02rem + 0.25vw, 1.28rem); 35 + line-height: 1.18; 36 + overflow: hidden; 37 + text-overflow: ellipsis; 38 + white-space: nowrap; 39 + } 40 + 41 + .logs-meta { 42 + margin: 0; 43 + min-width: 0; 44 + color: var(--text-2); 45 + font-size: 12px; 46 + line-height: 1.4; 47 + } 48 + 49 + .logs-limit-group { 50 + flex: 0 0 auto; 51 + display: inline-flex; 52 + align-items: center; 53 + gap: 4px; 54 + padding: 3px; 55 + border: 1px solid var(--line-soft); 56 + border-radius: 8px; 57 + background: color-mix(in srgb, var(--q-bg-paper) 94%, transparent); 58 + } 59 + 60 + .logs-limit-button { 61 + min-width: 46px; 62 + font-family: var(--font-mono); 63 + font-variant-numeric: tabular-nums; 64 + font-feature-settings: "zero" 1; 65 + } 66 + 67 + .logs-limit-button.is-active { 68 + background: color-mix(in srgb, var(--q-accent, var(--text-1)) 14%, transparent); 69 + color: var(--text-1); 70 + } 71 + 72 + .logs-newer-note { 73 + display: flex; 74 + align-items: center; 75 + justify-content: flex-end; 76 + gap: 8px; 77 + min-height: 28px; 78 + padding: 0; 79 + color: var(--text-2); 80 + font-size: 12px; 81 + line-height: 1.4; 82 + } 83 + 84 + .logs-newer-note span { 85 + min-width: 0; 86 + overflow-wrap: anywhere; 87 + } 88 + 89 + .logs-empty { 90 + min-height: min(42vh, 420px); 91 + display: grid; 92 + place-items: center; 93 + border: 1px dashed var(--line); 94 + border-radius: 8px; 95 + background: color-mix(in srgb, var(--q-bg-paper) 93%, transparent); 96 + } 97 + 98 + .logs-empty-title { 99 + margin: 0; 100 + color: var(--text-2); 101 + font-size: 14px; 102 + } 103 + 104 + .logs-stream { 105 + min-width: 0; 106 + min-height: 0; 107 + overflow-x: hidden; 108 + overflow-y: auto; 109 + scrollbar-gutter: stable; 110 + display: block; 111 + padding: 10px 0 18px; 112 + border-top: 1px solid var(--line-soft); 113 + } 114 + 115 + .logs-older-row { 116 + display: flex; 117 + justify-content: center; 118 + padding: 6px 0 14px; 119 + } 120 + 121 + .logs-line-row { 122 + min-width: 0; 123 + display: grid; 124 + grid-template-columns: minmax(132px, 0.22fr) minmax(0, 1fr); 125 + column-gap: 14px; 126 + padding: 5px 0; 127 + border-bottom: 1px solid color-mix(in srgb, var(--line-soft) 52%, transparent); 128 + } 129 + 130 + .logs-file-marker { 131 + grid-column: 1 / -1; 132 + position: sticky; 133 + top: 0; 134 + z-index: 2; 135 + padding: 7px 0 6px; 136 + background: color-mix(in srgb, var(--q-bg) 92%, transparent); 137 + color: var(--text-2); 138 + font-size: 11px; 139 + line-height: 1.2; 140 + font-family: var(--font-mono); 141 + border-bottom: 1px solid var(--line-soft); 142 + } 143 + 144 + .logs-line-meta { 145 + min-width: 0; 146 + display: flex; 147 + align-items: center; 148 + gap: 7px; 149 + color: var(--text-2); 150 + font-size: 11px; 151 + line-height: 1.4; 152 + overflow: hidden; 153 + } 154 + 155 + .logs-line-meta time { 156 + font-family: var(--font-mono); 157 + font-variant-numeric: tabular-nums; 158 + font-feature-settings: "zero" 1; 159 + white-space: nowrap; 160 + } 161 + 162 + .logs-line-msg { 163 + min-width: 0; 164 + overflow: hidden; 165 + text-overflow: ellipsis; 166 + white-space: nowrap; 167 + } 168 + 169 + .logs-line { 170 + margin: 0; 171 + min-width: 0; 172 + max-width: 100%; 173 + overflow: hidden; 174 + white-space: pre-wrap; 175 + overflow-wrap: anywhere; 176 + word-break: break-all; 177 + color: var(--text-1); 178 + font-size: 12px; 179 + line-height: 1.55; 180 + font-family: var(--font-mono); 181 + font-variant-ligatures: none; 182 + } 183 + 184 + .logs-line code { 185 + font-family: inherit; 186 + white-space: inherit; 187 + overflow-wrap: inherit; 188 + } 189 + 190 + @media (max-width: 820px) { 191 + .logs-shell { 192 + padding-inline: var(--content-gutter-x); 193 + } 194 + 195 + .logs-head { 196 + align-items: stretch; 197 + flex-direction: column; 198 + } 199 + 200 + .logs-limit-group { 201 + width: 100%; 202 + justify-content: stretch; 203 + } 204 + 205 + .logs-limit-button { 206 + flex: 1 1 0; 207 + } 208 + 209 + .logs-line-row { 210 + grid-template-columns: minmax(0, 1fr); 211 + row-gap: 3px; 212 + } 213 + 214 + .logs-line-meta { 215 + flex-wrap: wrap; 216 + } 217 + }
+346
web/console/src/views/LogsView.js
··· 1 + import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; 2 + import "./LogsView.css"; 3 + 4 + import AppPage from "../components/AppPage"; 5 + import { endpointState, formatBytes, formatTime, runtimeApiFetch, safeJSON, translate } from "../core/context"; 6 + 7 + const LIMIT_OPTIONS = [100, 300, 1000]; 8 + const AUTO_REFRESH_MS = 4000; 9 + 10 + let entrySeq = 0; 11 + 12 + function logLevelType(level) { 13 + switch (String(level || "").trim().toLowerCase()) { 14 + case "debug": 15 + return "default"; 16 + case "warn": 17 + case "warning": 18 + return "warning"; 19 + case "error": 20 + return "danger"; 21 + default: 22 + return "primary"; 23 + } 24 + } 25 + 26 + function formatLogStamp(raw) { 27 + const text = String(raw || "").trim(); 28 + if (!text) { 29 + return ""; 30 + } 31 + return formatTime(text); 32 + } 33 + 34 + function toLogEntry(file, line) { 35 + const parsed = safeJSON(line, null); 36 + const level = typeof parsed?.level === "string" ? parsed.level : ""; 37 + const time = typeof parsed?.time === "string" ? parsed.time : ""; 38 + const msg = typeof parsed?.msg === "string" ? parsed.msg : ""; 39 + entrySeq += 1; 40 + return { 41 + id: `${file || "log"}:${entrySeq}`, 42 + file: String(file || "").trim(), 43 + line: String(line || ""), 44 + level, 45 + time, 46 + msg, 47 + }; 48 + } 49 + 50 + const LogsView = { 51 + components: { 52 + AppPage, 53 + }, 54 + setup() { 55 + const t = translate; 56 + const err = ref(""); 57 + const unsupported = ref(false); 58 + const loading = ref(false); 59 + const loadingOlder = ref(false); 60 + const limit = ref(300); 61 + const hasNewer = ref(false); 62 + const entries = ref([]); 63 + const currentFile = ref(""); 64 + const modTime = ref(""); 65 + const sizeBytes = ref(0); 66 + const totalLines = ref(0); 67 + const fromLine = ref(0); 68 + const toLine = ref(0); 69 + const hasOlder = ref(false); 70 + const olderCursor = ref(""); 71 + const logPane = ref(null); 72 + let refreshTimer = null; 73 + 74 + const metaText = computed(() => { 75 + const parts = []; 76 + if (modTime.value) { 77 + parts.push(t("logs_updated", { value: formatTime(modTime.value) })); 78 + } 79 + if (sizeBytes.value > 0) { 80 + parts.push(t("logs_size", { value: formatBytes(sizeBytes.value) })); 81 + } 82 + if (totalLines.value > 0) { 83 + parts.push(t("logs_range", { from: fromLine.value || 0, to: toLine.value || 0, total: totalLines.value })); 84 + } 85 + return parts.join(" · "); 86 + }); 87 + 88 + const emptyText = computed(() => { 89 + if (unsupported.value) { 90 + return t("logs_unsupported"); 91 + } 92 + if (!endpointState.selectedRef) { 93 + return t("msg_select_endpoint"); 94 + } 95 + return t("logs_empty"); 96 + }); 97 + 98 + function isNearBottom() { 99 + const el = logPane.value; 100 + if (!el) { 101 + return true; 102 + } 103 + return el.scrollHeight - el.scrollTop - el.clientHeight < 48; 104 + } 105 + 106 + async function scrollToBottom() { 107 + await nextTick(); 108 + const el = logPane.value; 109 + if (el) { 110 + el.scrollTop = el.scrollHeight; 111 + } 112 + } 113 + 114 + function applyLatestPayload(payload) { 115 + const file = String(payload?.file || "").trim(); 116 + currentFile.value = file; 117 + modTime.value = String(payload?.mod_time || "").trim(); 118 + sizeBytes.value = Number(payload?.size_bytes || 0); 119 + totalLines.value = Number(payload?.total_lines || 0); 120 + fromLine.value = Number(payload?.from || 0); 121 + toLine.value = Number(payload?.to || 0); 122 + hasOlder.value = Boolean(payload?.has_older); 123 + olderCursor.value = String(payload?.older_cursor || "").trim(); 124 + entries.value = Array.isArray(payload?.lines) ? payload.lines.map((line) => toLogEntry(file, line)) : []; 125 + } 126 + 127 + async function loadLatest({ keepPosition = false } = {}) { 128 + if (!endpointState.selectedRef) { 129 + err.value = t("msg_select_endpoint"); 130 + return; 131 + } 132 + const shouldStick = !keepPosition && isNearBottom(); 133 + loading.value = true; 134 + err.value = ""; 135 + unsupported.value = false; 136 + try { 137 + const data = await runtimeApiFetch(`/logs/latest?limit=${encodeURIComponent(limit.value)}`); 138 + applyLatestPayload(data); 139 + hasNewer.value = false; 140 + if (shouldStick) { 141 + await scrollToBottom(); 142 + } 143 + } catch (e) { 144 + if (e?.status === 404) { 145 + unsupported.value = true; 146 + entries.value = []; 147 + err.value = ""; 148 + return; 149 + } 150 + err.value = e?.message || t("msg_load_failed"); 151 + } finally { 152 + loading.value = false; 153 + } 154 + } 155 + 156 + async function loadOlder() { 157 + const cursor = String(olderCursor.value || "").trim(); 158 + if (!cursor || loadingOlder.value) { 159 + return; 160 + } 161 + const el = logPane.value; 162 + const previousHeight = el ? el.scrollHeight : 0; 163 + const previousTop = el ? el.scrollTop : 0; 164 + loadingOlder.value = true; 165 + err.value = ""; 166 + try { 167 + const data = await runtimeApiFetch( 168 + `/logs/latest?limit=${encodeURIComponent(limit.value)}&cursor=${encodeURIComponent(cursor)}` 169 + ); 170 + const file = String(data?.file || "").trim(); 171 + const olderEntries = Array.isArray(data?.lines) ? data.lines.map((line) => toLogEntry(file, line)) : []; 172 + entries.value = olderEntries.concat(entries.value); 173 + hasOlder.value = Boolean(data?.has_older); 174 + olderCursor.value = String(data?.older_cursor || "").trim(); 175 + await nextTick(); 176 + if (el) { 177 + el.scrollTop = el.scrollHeight - previousHeight + previousTop; 178 + } 179 + } catch (e) { 180 + err.value = e?.message || t("msg_load_failed"); 181 + } finally { 182 + loadingOlder.value = false; 183 + } 184 + } 185 + 186 + function onScroll() { 187 + const el = logPane.value; 188 + if (!el || loadingOlder.value) { 189 + return; 190 + } 191 + if (el.scrollTop <= 8 && hasOlder.value && olderCursor.value) { 192 + loadOlder(); 193 + } 194 + } 195 + 196 + function onLimitSelect(value) { 197 + limit.value = Number(value || 300); 198 + loadLatest(); 199 + } 200 + 201 + function startAutoRefresh() { 202 + stopAutoRefresh(); 203 + refreshTimer = window.setInterval(() => { 204 + if (isNearBottom()) { 205 + loadLatest(); 206 + } else { 207 + hasNewer.value = true; 208 + } 209 + }, AUTO_REFRESH_MS); 210 + } 211 + 212 + function stopAutoRefresh() { 213 + if (refreshTimer) { 214 + window.clearInterval(refreshTimer); 215 + refreshTimer = null; 216 + } 217 + } 218 + 219 + function showFileMarker(item, index) { 220 + if (!item?.file) { 221 + return false; 222 + } 223 + if (index === 0) { 224 + return true; 225 + } 226 + return entries.value[index - 1]?.file !== item.file; 227 + } 228 + 229 + watch( 230 + () => endpointState.selectedRef, 231 + () => { 232 + entries.value = []; 233 + currentFile.value = ""; 234 + olderCursor.value = ""; 235 + hasOlder.value = false; 236 + loadLatest(); 237 + } 238 + ); 239 + 240 + onMounted(() => { 241 + loadLatest(); 242 + startAutoRefresh(); 243 + }); 244 + 245 + onUnmounted(() => { 246 + stopAutoRefresh(); 247 + }); 248 + 249 + return { 250 + t, 251 + err, 252 + unsupported, 253 + loading, 254 + loadingOlder, 255 + limit, 256 + limits: LIMIT_OPTIONS, 257 + hasNewer, 258 + entries, 259 + currentFile, 260 + hasOlder, 261 + olderCursor, 262 + logPane, 263 + metaText, 264 + emptyText, 265 + logLevelType, 266 + formatLogStamp, 267 + loadLatest, 268 + loadOlder, 269 + onScroll, 270 + onLimitSelect, 271 + showFileMarker, 272 + }; 273 + }, 274 + template: ` 275 + <AppPage :title="t('logs_title')" class="logs-page"> 276 + <section class="logs-shell"> 277 + <header class="logs-head"> 278 + <div class="logs-title-block"> 279 + <h3 class="logs-current-file">{{ currentFile || t("logs_unknown_file") }}</h3> 280 + <p class="logs-meta">{{ metaText || t("logs_meta_empty") }}</p> 281 + </div> 282 + <div class="logs-limit-group" :aria-label="t('logs_line_count')"> 283 + <QButton 284 + v-for="item in limits" 285 + :key="item" 286 + :class="['plain sm logs-limit-button', { 'is-active': limit === item }]" 287 + @click="onLimitSelect(item)" 288 + > 289 + {{ item }} 290 + </QButton> 291 + </div> 292 + </header> 293 + 294 + <QProgress v-if="loading && entries.length === 0" :infinite="true" /> 295 + <QFence v-if="err" type="danger" icon="QIconCloseCircle" :text="err" /> 296 + 297 + <div v-if="hasNewer" class="logs-newer-note"> 298 + <span>{{ t("logs_new_available") }}</span> 299 + <QButton class="plain xs" @click="loadLatest">{{ t("logs_latest") }}</QButton> 300 + </div> 301 + 302 + <div v-if="entries.length === 0 && !loading" class="logs-empty"> 303 + <p class="logs-empty-title">{{ emptyText }}</p> 304 + </div> 305 + 306 + <div 307 + v-else 308 + ref="logPane" 309 + class="logs-stream" 310 + role="log" 311 + aria-live="polite" 312 + @scroll.passive="onScroll" 313 + > 314 + <div class="logs-older-row"> 315 + <QButton 316 + class="plain sm" 317 + :disabled="!hasOlder || !olderCursor" 318 + :loading="loadingOlder" 319 + @click="loadOlder" 320 + > 321 + {{ hasOlder ? t("logs_load_older") : t("logs_no_older") }} 322 + </QButton> 323 + </div> 324 + 325 + <div 326 + v-for="(item, index) in entries" 327 + :key="item.id" 328 + class="logs-line-row" 329 + > 330 + <div v-if="showFileMarker(item, index)" class="logs-file-marker"> 331 + <span>{{ item.file }}</span> 332 + </div> 333 + <div class="logs-line-meta"> 334 + <QBadge :type="logLevelType(item.level)" size="sm">{{ item.level || "INFO" }}</QBadge> 335 + <time v-if="item.time">{{ formatLogStamp(item.time) }}</time> 336 + <span v-if="item.msg" class="logs-line-msg">{{ item.msg }}</span> 337 + </div> 338 + <pre class="logs-line"><code>{{ item.line }}</code></pre> 339 + </div> 340 + </div> 341 + </section> 342 + </AppPage> 343 + `, 344 + }; 345 + 346 + export default LogsView;
+10
web/console/src/views/SettingsView.css
··· 475 475 min-width: 180px; 476 476 } 477 477 478 + .settings-console-action { 479 + gap: 7px; 480 + justify-content: center; 481 + } 482 + 483 + .settings-console-action-icon { 484 + width: 13px; 485 + height: 13px; 486 + } 487 + 478 488 @media (max-width: 920px) { 479 489 .settings-workbench { 480 490 grid-template-columns: 1fr;
+15
web/console/src/views/SettingsView.js
··· 1516 1516 router.push("/settings/credits"); 1517 1517 } 1518 1518 1519 + function openLogsPage() { 1520 + router.push("/logs"); 1521 + } 1522 + 1519 1523 function selectSection(id) { 1520 1524 selectedSectionID.value = String(id || "").trim(); 1521 1525 if (isMobile.value) { ··· 1667 1671 sectionClass, 1668 1672 showIndexView, 1669 1673 openCreditsPage, 1674 + openLogsPage, 1670 1675 apiBasePickerOpen, 1671 1676 modelPickerOpen, 1672 1677 modelPickerLoading, ··· 2404 2409 <p class="settings-card-note">{{ t("settings_language_hint") }}</p> 2405 2410 </div> 2406 2411 <QLanguageSelector class="settings-console-control" :lang="lang" :presist="true" @change="onLanguageChange" /> 2412 + </div> 2413 + <div class="settings-console-row"> 2414 + <div class="settings-card-copy"> 2415 + <h4 class="settings-card-title">{{ t("settings_logs_title") }}</h4> 2416 + <p class="settings-card-note">{{ t("settings_logs_hint") }}</p> 2417 + </div> 2418 + <QButton class="outlined settings-console-control settings-console-action" @click="openLogsPage"> 2419 + <QIconCode class="icon settings-console-action-icon" /> 2420 + {{ t("settings_logs_open") }} 2421 + </QButton> 2407 2422 </div> 2408 2423 <div class="settings-console-row settings-console-row-end"> 2409 2424 <div class="settings-card-copy">
+1
web/console/src/views/index.js
··· 10 10 export { default as TasksView } from "./TasksView"; 11 11 export { default as StatsView } from "./StatsView"; 12 12 export { default as AuditView } from "./AuditView"; 13 + export { default as LogsView } from "./LogsView"; 13 14 export { default as MemoryView } from "./MemoryView"; 14 15 export { default as StateFilesView } from "./StateFilesView"; 15 16 export { default as ContactsView } from "./ContactsView";