logcatx 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
logcatx/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Junbo Zheng
3
+
4
+ """logcatx: local web dashboard for offline embedded dual-core log analysis."""
5
+
6
+ # Single source of truth — pyproject.toml reads this via [tool.hatch.version].
7
+ __version__ = "0.0.1"
logcatx/analyzer.py ADDED
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Junbo Zheng
3
+
4
+ """Deterministic statistics over parsed lines and mined templates."""
5
+
6
+ from collections import Counter, defaultdict
7
+
8
+ ERROR_LEVELS = {"ERROR", "ALERT", "EMERG", "CRIT"}
9
+
10
+
11
+ def overview(lines, unparsed, templates, blank=0):
12
+ total = len(lines) + len(unparsed) + blank
13
+ level_counts = Counter(ln.level for ln in lines)
14
+ module_counts = Counter(f"[{ln.core}] [{ln.module}]" for ln in lines)
15
+ err_tpls = [
16
+ t for t in templates if any(lv in ERROR_LEVELS for lv in t.level_counts)
17
+ ]
18
+ err_tpls.sort(key=lambda t: -t.count)
19
+ times = [ln.time for ln in lines]
20
+ return {
21
+ "total": total,
22
+ "parsed": len(lines),
23
+ "unparsed": len(unparsed),
24
+ "time_start": min(times).isoformat() if times else None,
25
+ "time_end": max(times).isoformat() if times else None,
26
+ "level_counts": dict(level_counts),
27
+ "module_top": module_counts.most_common(15),
28
+ "error_top": [
29
+ {"id": t.id, "text": t.text, "namespace": t.namespace, "count": t.count}
30
+ for t in err_tpls[:20]
31
+ ],
32
+ }
33
+
34
+
35
+ def _buckets(lines, bucket_seconds):
36
+ if not lines:
37
+ return []
38
+ start = min(ln.time for ln in lines).timestamp()
39
+ end = max(ln.time for ln in lines).timestamp()
40
+ n = int((end - start) // bucket_seconds) + 1
41
+ return [
42
+ {"start": start + i * bucket_seconds, "level_counts": {}} for i in range(n)
43
+ ], start
44
+
45
+
46
+ def timeline(lines, bucket_seconds):
47
+ if not lines:
48
+ return {"bucket_seconds": bucket_seconds, "buckets": []}
49
+ buckets, start = _buckets(lines, bucket_seconds)
50
+ for ln in lines:
51
+ idx = int((ln.time.timestamp() - start) // bucket_seconds)
52
+ b = buckets[idx]
53
+ b["level_counts"][ln.level] = b["level_counts"].get(ln.level, 0) + 1
54
+ return {"bucket_seconds": bucket_seconds, "buckets": buckets}
55
+
56
+
57
+ def template_series(templates, lines, bucket_seconds):
58
+ """Per-template count per bucket, keyed by template id."""
59
+ if not lines or not templates:
60
+ return {}
61
+ buckets, start = _buckets(lines, bucket_seconds)
62
+ n = len(buckets)
63
+ series = {t.id: [0] * n for t in templates}
64
+ by_tpl = defaultdict(list)
65
+ for ln in lines:
66
+ if ln.template_id >= 0:
67
+ by_tpl[ln.template_id].append(ln)
68
+ for t in templates:
69
+ for ln in by_tpl.get(t.id, []):
70
+ idx = int((ln.time.timestamp() - start) // bucket_seconds)
71
+ series[t.id][idx] += 1
72
+ return series
73
+
74
+
75
+ def bursts(series, sigma=5, min_count=10):
76
+ """Templates whose bucket count jumps beyond mean + sigma*std.
77
+
78
+ Stats are leave-one-out: the candidate bucket is excluded from its own
79
+ baseline, otherwise a single spike inflates std enough to hide itself.
80
+ """
81
+ hits = []
82
+ for tid, counts in series.items():
83
+ if len(counts) < 3:
84
+ continue
85
+ total = sum(counts)
86
+ n = len(counts)
87
+ for i, c in enumerate(counts):
88
+ if c < min_count:
89
+ continue
90
+ others = total - c
91
+ mean = others / (n - 1)
92
+ var = sum((x - mean) ** 2 for j, x in enumerate(counts) if j != i) / (n - 1)
93
+ std = var**0.5
94
+ if c > mean + sigma * std:
95
+ hits.append(
96
+ {
97
+ "template_id": tid,
98
+ "bucket_index": i,
99
+ "count": c,
100
+ "mean": mean,
101
+ "sigma": std,
102
+ }
103
+ )
104
+ hits.sort(key=lambda h: -h["count"])
105
+ return hits
106
+
107
+
108
+ def cooccur(lines, window=1):
109
+ """Groups of >=2 distinct ERROR-ish template ids within the same second."""
110
+ groups = []
111
+ by_sec = defaultdict(list)
112
+ for ln in lines:
113
+ if ln.level in ERROR_LEVELS and ln.template_id >= 0:
114
+ by_sec[ln.time.replace(microsecond=0)].append(ln)
115
+ for sec, ls in sorted(by_sec.items()):
116
+ tids = sorted({ln.template_id for ln in ls})
117
+ if len(tids) >= 2:
118
+ groups.append(
119
+ {
120
+ "line_no": ls[0].line_no,
121
+ "time": sec.isoformat(),
122
+ "template_ids": tids,
123
+ }
124
+ )
125
+ return groups
126
+
127
+
128
+ def auto_bucket(lines, max_buckets=1000):
129
+ if not lines:
130
+ return 60
131
+ span = (max(ln.time for ln in lines) - min(ln.time for ln in lines)).total_seconds()
132
+ if span <= 3600:
133
+ b = 60
134
+ elif span <= 6 * 3600:
135
+ b = 300
136
+ elif span <= 24 * 3600:
137
+ b = 600
138
+ else:
139
+ b = 3600
140
+ # clock-reset artifacts can fake a huge span; keep the bucket count sane
141
+ while span / b > max_buckets:
142
+ b *= 2
143
+ return b
logcatx/cli.py ADDED
@@ -0,0 +1,77 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Junbo Zheng
3
+
4
+ """CLI entrypoint: logcatx <logfile> [--port N] [--no-browser]."""
5
+
6
+ import argparse
7
+ import logging
8
+ import socket
9
+ import threading
10
+ import webbrowser
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ import uvicorn
14
+
15
+ from .server import create_app, load_session
16
+
17
+
18
+ def _version() -> str:
19
+ # Installed metadata is the source of truth; fall back to the in-tree
20
+ # __init__ string for zero-install runs (main.py shim, editable checkouts).
21
+ try:
22
+ return version("logcatx")
23
+ except PackageNotFoundError:
24
+ from . import __version__
25
+
26
+ return __version__
27
+
28
+
29
+ def _port_free(port):
30
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
31
+ return s.connect_ex(("127.0.0.1", port)) != 0
32
+
33
+
34
+ def main(argv: list[str] | None = None) -> int:
35
+ logging.basicConfig(
36
+ level=logging.DEBUG,
37
+ datefmt="%Y-%m-%d %H:%M:%S",
38
+ format=(
39
+ "%(asctime)-19s.%(msecs)03d %(levelname)-8s "
40
+ "%(filename)s %(lineno)-3d %(process)d %(message)s"
41
+ ),
42
+ )
43
+ p = argparse.ArgumentParser(
44
+ prog="logcatx",
45
+ description=f"logcatx {_version()} — offline log analysis dashboard.",
46
+ )
47
+ p.add_argument(
48
+ "-V",
49
+ "--version",
50
+ action="version",
51
+ version=f"logcatx {_version()}",
52
+ help="Show the version and exit",
53
+ )
54
+ p.add_argument("logfile")
55
+ p.add_argument("--port", type=int, default=8000)
56
+ p.add_argument("--no-browser", action="store_true")
57
+ args = p.parse_args(argv)
58
+
59
+ session = load_session(args.logfile)
60
+ app = create_app(session)
61
+
62
+ port = args.port
63
+ for _ in range(10):
64
+ if _port_free(port):
65
+ break
66
+ port += 1
67
+
68
+ if not args.no_browser:
69
+ url = f"http://127.0.0.1:{port}/"
70
+ threading.Timer(1.0, lambda: webbrowser.open(url)).start()
71
+
72
+ uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
logcatx/miner.py ADDED
@@ -0,0 +1,75 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Junbo Zheng
3
+
4
+ """In-house log template miner (Drain-style, Vela-tailored).
5
+
6
+ Clustering key insight vs stock Drain: the [core] [module] prefix is a
7
+ namespace, never merged across — that is what produced Drain3's catch-all
8
+ blobs on real Vela logs.
9
+ """
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ MASKS = [
15
+ (re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b"), "<MAC>"),
16
+ (re.compile(r"0x[0-9A-Fa-f]+"), "<HEX>"),
17
+ (re.compile(r"(?:/[\w.-]+)+"), "<PATH>"),
18
+ (re.compile(r"\d+\.\d+"), "<NUM>"),
19
+ (re.compile(r"\d+"), "<NUM>"),
20
+ ]
21
+
22
+
23
+ @dataclass
24
+ class Template:
25
+ id: int
26
+ namespace: str
27
+ text: str
28
+ tokens: list = field(default_factory=list)
29
+ count: int = 0
30
+ level_counts: dict = field(default_factory=dict)
31
+ first_line: int = -1
32
+ last_line: int = -1
33
+ line_nos: list = field(default_factory=list)
34
+
35
+
36
+ def _mask(msg):
37
+ for rx, tag in MASKS:
38
+ msg = rx.sub(tag, msg)
39
+ return msg
40
+
41
+
42
+ def mine(lines, sim_th=0.4, max_scan=20):
43
+ """Cluster masked messages into templates; backfills line.template_id.
44
+
45
+ Index key is (namespace, token_count, first token) — the Drain prefix
46
+ idea flattened — so buckets stay small on varied text (a flat
47
+ (namespace, len) bucket degrades to O(n^2) at a million lines).
48
+ """
49
+ clusters = []
50
+ index = {}
51
+ for ln in lines:
52
+ ns = f"[{ln.core}] [{ln.module}]"
53
+ toks = _mask(ln.message).split()
54
+ key = (ns, len(toks), toks[0] if toks else "")
55
+ bucket = index.setdefault(key, [])
56
+ hit = None
57
+ for t in bucket[:max_scan]:
58
+ same = sum(1 for a, b in zip(t.tokens, toks) if a == b)
59
+ if same / max(len(toks), 1) >= sim_th:
60
+ hit = t
61
+ break
62
+ if hit is None:
63
+ hit = Template(len(clusters), ns, " ".join(toks), list(toks))
64
+ clusters.append(hit)
65
+ bucket.append(hit)
66
+ # differing positions generalize to <*>
67
+ hit.tokens = [a if a == b else "<*>" for a, b in zip(hit.tokens, toks)]
68
+ hit.text = " ".join(hit.tokens)
69
+ hit.count += 1
70
+ hit.level_counts[ln.level] = hit.level_counts.get(ln.level, 0) + 1
71
+ hit.line_nos.append(ln.line_no)
72
+ hit.first_line = hit.line_nos[0]
73
+ hit.last_line = ln.line_no
74
+ ln.template_id = hit.id
75
+ return clusters
logcatx/parser.py ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Junbo Zheng
3
+
4
+ """Vela dual-core log line parser.
5
+
6
+ Line format (fixed for v1):
7
+ [MM/DD HH:MM:SS] [tid] [LEVEL] [core] [module] message
8
+ The year is not part of the line; it is inferred (see parse_file).
9
+ """
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+
15
+ LINE_RE = re.compile(
16
+ r"^\[(\d{2}/\d{2}) (\d{2}:\d{2}:\d{2})\]\s*\[\s*(\d+)\]\s*"
17
+ r"\[\s*(\w+)\s*\]\s*\[\s*(\w+)\s*\]\s*(?:\[\s*([^\]]+)\s*\]\s*)?(.*)$"
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class LogLine:
23
+ time: datetime
24
+ tid: int
25
+ level: str
26
+ core: str
27
+ module: str
28
+ message: str
29
+ line_no: int
30
+ template_id: int = -1
31
+
32
+
33
+ @dataclass
34
+ class RawLine:
35
+ text: str
36
+ line_no: int
37
+
38
+
39
+ @dataclass
40
+ class ParseResult:
41
+ lines: list = field(default_factory=list)
42
+ unparsed: list = field(default_factory=list)
43
+ blank: int = 0
44
+ errors: int = 0
45
+
46
+
47
+ def parse_file(path, year=None):
48
+ """Parse a Vela log file.
49
+
50
+ Lines that do not match the format are kept in `unparsed` (never dropped).
51
+ The year rolls over when month/day goes backwards (Dec 31 -> Jan 01).
52
+ """
53
+ year = year or datetime.now().year
54
+ res = ParseResult()
55
+ with open(path, encoding="utf-8", errors="replace") as f:
56
+ prev = None
57
+ for no, raw in enumerate(f, 1):
58
+ line = raw.rstrip("\n")
59
+ m = LINE_RE.match(line)
60
+ if not m:
61
+ if line.strip():
62
+ res.unparsed.append(RawLine(line, no))
63
+ else:
64
+ res.blank += 1
65
+ continue
66
+ mmdd, hms, tid, level, core, module, msg = m.groups()
67
+ mon, day = map(int, mmdd.split("/"))
68
+ h, mi, s = map(int, hms.split(":"))
69
+ # Only a true Dec -> Jan transition rolls the year; a reboot clock
70
+ # reset (e.g. 09/21 -> 01/01 mid-log) must not.
71
+ if prev and prev.month == 12 and mon == 1:
72
+ year += 1
73
+ t = datetime(year, mon, day, h, mi, s)
74
+ prev = t
75
+ res.lines.append(LogLine(t, int(tid), level, core, module or "", msg, no))
76
+ return res
logcatx/server.py ADDED
@@ -0,0 +1,236 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Junbo Zheng
3
+
4
+ """FastAPI session server: everything in-memory, no persistence."""
5
+
6
+ import os
7
+ from dataclasses import dataclass, field
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.responses import FileResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from .analyzer import (
14
+ ERROR_LEVELS,
15
+ auto_bucket,
16
+ bursts,
17
+ cooccur,
18
+ overview,
19
+ template_series,
20
+ timeline,
21
+ )
22
+ from .miner import mine
23
+ from .parser import parse_file
24
+
25
+ MAX_PAGE = 1000
26
+
27
+
28
+ def _clamp(limit):
29
+ # non-positive limit means "everything" -> MAX_PAGE; never a negative slice
30
+ return MAX_PAGE if limit <= 0 else min(limit, MAX_PAGE)
31
+
32
+
33
+ @dataclass
34
+ class Session:
35
+ parse: object = None
36
+ templates: list = field(default_factory=list)
37
+ name: str = ""
38
+ # line_no -> parsed line or raw line, for context expansion
39
+ by_line: dict = field(default_factory=dict)
40
+
41
+
42
+ def load_session(path):
43
+ res = parse_file(path)
44
+ tpls = mine(res.lines)
45
+ s = Session(parse=res, templates=tpls, name=path)
46
+ for ln in res.lines:
47
+ s.by_line[ln.line_no] = ln
48
+ for raw in res.unparsed:
49
+ s.by_line[raw.line_no] = raw
50
+ return s
51
+
52
+
53
+ def create_app(session: Session) -> FastAPI:
54
+ app = FastAPI()
55
+ s = session
56
+
57
+ @app.get("/api/overview")
58
+ def api_overview():
59
+ return overview(
60
+ s.parse.lines, s.parse.unparsed, s.templates, blank=s.parse.blank
61
+ )
62
+
63
+ @app.get("/api/trend")
64
+ def api_trend(template_ids: str = "", bucket: int = 0):
65
+ """Frequency-over-time series for user-selected templates (any level)."""
66
+ b = bucket if bucket > 0 else auto_bucket(s.parse.lines)
67
+ ids = []
68
+ for part in template_ids.split(","):
69
+ part = part.strip()
70
+ if part.isdigit():
71
+ ids.append(int(part))
72
+ tpls = [t for t in s.templates if t.id in ids]
73
+ series = template_series(tpls, s.parse.lines, b)
74
+ return {
75
+ "bucket_seconds": b,
76
+ "series": {str(t.id): series[t.id] for t in tpls},
77
+ "templates": {str(t.id): t.text for t in tpls},
78
+ }
79
+
80
+ @app.get("/api/templates")
81
+ def api_templates(
82
+ search: str = "",
83
+ sort: str = "count",
84
+ order: str = "desc",
85
+ limit: int = 100,
86
+ offset: int = 0,
87
+ ):
88
+ items = list(s.templates)
89
+ if search:
90
+ items = [
91
+ t
92
+ for t in items
93
+ if search.lower() in t.text.lower()
94
+ or search.lower() in t.namespace.lower()
95
+ ]
96
+ reverse = order == "desc"
97
+ keyf = (lambda t: t.text.lower()) if sort == "text" else (lambda t: t.count)
98
+ items.sort(key=keyf, reverse=reverse)
99
+ total = len(items)
100
+ page = items[max(0, offset) : max(0, offset) + _clamp(limit)]
101
+ return {
102
+ "total": total,
103
+ "items": [
104
+ {
105
+ "id": t.id,
106
+ "namespace": t.namespace,
107
+ "text": t.text,
108
+ "count": t.count,
109
+ "level_counts": t.level_counts,
110
+ "first_line": t.first_line,
111
+ "last_line": t.last_line,
112
+ }
113
+ for t in page
114
+ ],
115
+ }
116
+
117
+ @app.get("/api/timeline")
118
+ def api_timeline(bucket: int = 0):
119
+ b = bucket if bucket > 0 else auto_bucket(s.parse.lines)
120
+ tl = timeline(s.parse.lines, b)
121
+ err_tpls = [
122
+ t for t in s.templates if any(lv in ERROR_LEVELS for lv in t.level_counts)
123
+ ]
124
+ err_tpls.sort(key=lambda t: -t.count)
125
+ series = template_series(err_tpls[:200], s.parse.lines, b)
126
+ return {**tl, "per_template": series, "bursts": bursts(series)}
127
+
128
+ @app.get("/api/logs")
129
+ def api_logs(
130
+ level: str = "",
131
+ core: str = "",
132
+ module: str = "",
133
+ template_id: int = -1,
134
+ q: str = "",
135
+ line: int = -1,
136
+ start: float = -1,
137
+ end: float = -1,
138
+ context: int = 0,
139
+ limit: int = 100,
140
+ offset: int = 0,
141
+ ):
142
+ if line >= 0:
143
+ lo = max(1, line - context)
144
+ hi = line + context
145
+ rows = [s.by_line[i] for i in range(lo, hi + 1) if i in s.by_line]
146
+ return {
147
+ "total": len(rows),
148
+ "items": [
149
+ {
150
+ "line_no": r.line_no,
151
+ "text": _render(r),
152
+ "parsed": hasattr(r, "level"),
153
+ "context": True,
154
+ }
155
+ for r in rows
156
+ ],
157
+ }
158
+ items = s.parse.lines
159
+ if level:
160
+ items = [ln for ln in items if ln.level == level]
161
+ if core:
162
+ items = [ln for ln in items if ln.core == core]
163
+ if module:
164
+ items = [ln for ln in items if module.lower() in ln.module.lower()]
165
+ if template_id >= 0:
166
+ items = [ln for ln in items if ln.template_id == template_id]
167
+ if q:
168
+ items = [ln for ln in items if q.lower() in ln.message.lower()]
169
+ if start >= 0:
170
+ items = [ln for ln in items if ln.time.timestamp() >= start]
171
+ if end >= 0:
172
+ items = [ln for ln in items if ln.time.timestamp() < end]
173
+ # unparsed lines ride along unless a parsed-only filter is active;
174
+ # they are part of the file and must stay browsable
175
+ unparsed = s.parse.unparsed
176
+ if q:
177
+ unparsed = [u for u in unparsed if q.lower() in u.text.lower()]
178
+ if level or core or module or template_id >= 0 or start >= 0 or end >= 0:
179
+ unparsed = []
180
+ merged = sorted(
181
+ [
182
+ {
183
+ "line_no": ln.line_no,
184
+ "time": ln.time.strftime("%m/%d %H:%M:%S"),
185
+ "tid": ln.tid,
186
+ "level": ln.level,
187
+ "core": ln.core,
188
+ "module": ln.module,
189
+ "message": ln.message,
190
+ "template_id": ln.template_id,
191
+ "parsed": True,
192
+ "context": False,
193
+ }
194
+ for ln in items
195
+ ]
196
+ + [
197
+ {
198
+ "line_no": u.line_no,
199
+ "text": u.text,
200
+ "parsed": False,
201
+ "context": False,
202
+ }
203
+ for u in unparsed
204
+ ],
205
+ key=lambda x: x["line_no"],
206
+ )
207
+ total = len(merged)
208
+ page = merged[max(0, offset) : max(0, offset) + _clamp(limit)]
209
+ return {"total": total, "items": page}
210
+
211
+ @app.get("/api/cooccur")
212
+ def api_cooccur():
213
+ return {"groups": cooccur(s.parse.lines)}
214
+
215
+ app.mount("/static", StaticFiles(directory=_static_dir()), name="static")
216
+
217
+ @app.get("/")
218
+ def index():
219
+ return FileResponse(os.path.join(_static_dir(), "index.html"))
220
+
221
+ return app
222
+
223
+
224
+ def _render(r):
225
+ if hasattr(r, "level"): # parsed LogLine
226
+ return (
227
+ f"[{r.time.strftime('%m/%d %H:%M:%S')}] [{r.tid:2d}] "
228
+ f"[{r.level:>5}] [{r.core}] [{r.module}] {r.message}"
229
+ )
230
+ return r.text
231
+
232
+
233
+ def _static_dir():
234
+ import os
235
+
236
+ return os.path.join(os.path.dirname(__file__), "static")