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 +7 -0
- logcatx/analyzer.py +143 -0
- logcatx/cli.py +77 -0
- logcatx/miner.py +75 -0
- logcatx/parser.py +76 -0
- logcatx/server.py +236 -0
- logcatx/static/app.js +322 -0
- logcatx/static/index.html +95 -0
- logcatx/static/style.css +51 -0
- logcatx/static/synth-demo.log +16601 -0
- logcatx/static/vendor/echarts.min.js +45 -0
- logcatx-0.0.1.dist-info/METADATA +93 -0
- logcatx-0.0.1.dist-info/RECORD +16 -0
- logcatx-0.0.1.dist-info/WHEEL +4 -0
- logcatx-0.0.1.dist-info/entry_points.txt +2 -0
- logcatx-0.0.1.dist-info/licenses/LICENSE +202 -0
logcatx/__init__.py
ADDED
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")
|