mega-tail 0.1.0
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.
- package/README.md +71 -0
- package/bin/mega-tail.js +508 -0
- package/mega-tail +278 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# mega-tail
|
|
2
|
+
|
|
3
|
+
Tail dynamic logs in a directory tree.
|
|
4
|
+
|
|
5
|
+
`mega-tail` follows appended lines across all matching log files under a root directory, and automatically starts following newly created log files as they appear.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Recursive discovery under a root directory
|
|
10
|
+
- Live follow of existing files
|
|
11
|
+
- Auto-discovery of new files
|
|
12
|
+
- Per-line prefix with source file path and detection timestamp
|
|
13
|
+
- Handles truncation/rotation safely
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Run via `npx` (no prior install needed):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx mega-tail /var/log/myapp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run local Python script:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
./mega-tail <directory>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run local Node script:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
node bin/mega-tail.js <directory>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
./mega-tail /var/log/myapp
|
|
39
|
+
node bin/mega-tail.js /var/log/myapp
|
|
40
|
+
npx mega-tail /var/log/myapp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Sample output:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
[subdir1/somelog.log] [2026-02-05 16:09:07.333] 2026-02-02: 12:23:23.123 [DEBUG] my log message....
|
|
47
|
+
[subdir2/somelog2.log] [2026-02-05 16:09:07.337] 2026-02-02: 12:23:23.223 [DEBUG] my otherlog message....
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx mega-tail --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Key options:
|
|
57
|
+
|
|
58
|
+
- `--glob <pattern>`: Add include glob (repeatable)
|
|
59
|
+
- `--poll-interval <seconds>`: Read loop interval
|
|
60
|
+
- `--scan-interval <seconds>`: New-file scan interval
|
|
61
|
+
- `-n, --initial-lines <N>`: Show last N lines on startup
|
|
62
|
+
- `--color auto|always|never`: Color mode
|
|
63
|
+
|
|
64
|
+
## Publishing to npm
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm login
|
|
68
|
+
npm whoami
|
|
69
|
+
npm version patch
|
|
70
|
+
npm publish --access public
|
|
71
|
+
```
|
package/bin/mega-tail.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { performance } = require("node:perf_hooks");
|
|
8
|
+
|
|
9
|
+
const DEFAULT_GLOBS = ["*.log", "*.log.*"];
|
|
10
|
+
const DEFAULT_POLL_INTERVAL = 0.2;
|
|
11
|
+
const DEFAULT_SCAN_INTERVAL = 1.0;
|
|
12
|
+
|
|
13
|
+
const C_RESET = "\u001b[0m";
|
|
14
|
+
const C_TIMESTAMP = "\u001b[38;5;81m";
|
|
15
|
+
const C_FILE = "\u001b[38;5;245m";
|
|
16
|
+
const C_CONTENT = "\u001b[38;5;252m";
|
|
17
|
+
const C_INFO = "\u001b[38;5;244m";
|
|
18
|
+
|
|
19
|
+
function usage() {
|
|
20
|
+
return [
|
|
21
|
+
"mega-tail - Tail dynamic log files in a directory tree.",
|
|
22
|
+
"",
|
|
23
|
+
"Usage:",
|
|
24
|
+
" mega-tail <directory> [options]",
|
|
25
|
+
"",
|
|
26
|
+
"Options:",
|
|
27
|
+
" --glob <pattern> Add include glob (repeatable).",
|
|
28
|
+
` --poll-interval <seconds> Read loop interval (default: ${DEFAULT_POLL_INTERVAL}).`,
|
|
29
|
+
` --scan-interval <seconds> New-file scan interval (default: ${DEFAULT_SCAN_INTERVAL}).`,
|
|
30
|
+
" -n, --initial-lines <N> Show last N lines on startup (default: 0).",
|
|
31
|
+
" --color auto|always|never Color mode (default: auto).",
|
|
32
|
+
" -h, --help Show help.",
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(message) {
|
|
37
|
+
process.stderr.write(`${message}\n`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const args = {
|
|
43
|
+
directory: null,
|
|
44
|
+
globs: [],
|
|
45
|
+
pollInterval: DEFAULT_POLL_INTERVAL,
|
|
46
|
+
scanInterval: DEFAULT_SCAN_INTERVAL,
|
|
47
|
+
initialLines: 0,
|
|
48
|
+
color: "auto",
|
|
49
|
+
help: false,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
53
|
+
const token = argv[i];
|
|
54
|
+
|
|
55
|
+
if (token === "-h" || token === "--help") {
|
|
56
|
+
args.help = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (token === "--glob") {
|
|
61
|
+
const value = argv[i + 1];
|
|
62
|
+
if (!value) {
|
|
63
|
+
throw new Error("Error: --glob requires a value");
|
|
64
|
+
}
|
|
65
|
+
args.globs.push(value);
|
|
66
|
+
i += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (token === "--poll-interval") {
|
|
71
|
+
const value = Number(argv[i + 1]);
|
|
72
|
+
if (!Number.isFinite(value)) {
|
|
73
|
+
throw new Error("Error: --poll-interval requires a numeric value");
|
|
74
|
+
}
|
|
75
|
+
args.pollInterval = value;
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (token === "--scan-interval") {
|
|
81
|
+
const value = Number(argv[i + 1]);
|
|
82
|
+
if (!Number.isFinite(value)) {
|
|
83
|
+
throw new Error("Error: --scan-interval requires a numeric value");
|
|
84
|
+
}
|
|
85
|
+
args.scanInterval = value;
|
|
86
|
+
i += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (token === "-n" || token === "--initial-lines") {
|
|
91
|
+
const raw = argv[i + 1];
|
|
92
|
+
const value = Number(raw);
|
|
93
|
+
if (!Number.isInteger(value)) {
|
|
94
|
+
throw new Error("Error: --initial-lines requires an integer value");
|
|
95
|
+
}
|
|
96
|
+
args.initialLines = value;
|
|
97
|
+
i += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (token === "--color") {
|
|
102
|
+
const value = argv[i + 1];
|
|
103
|
+
if (!value) {
|
|
104
|
+
throw new Error("Error: --color requires a value");
|
|
105
|
+
}
|
|
106
|
+
args.color = value;
|
|
107
|
+
i += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (token.startsWith("-")) {
|
|
112
|
+
throw new Error(`Error: unknown option: ${token}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (args.directory !== null) {
|
|
116
|
+
throw new Error("Error: only one directory argument is allowed");
|
|
117
|
+
}
|
|
118
|
+
args.directory = token;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!args.help && args.directory === null) {
|
|
122
|
+
throw new Error("Error: directory is required");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return args;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function colorEnabled(mode) {
|
|
129
|
+
if (mode === "always") {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (mode === "never") {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function paint(text, color, enabled) {
|
|
139
|
+
if (!enabled) {
|
|
140
|
+
return text;
|
|
141
|
+
}
|
|
142
|
+
return `${color}${text}${C_RESET}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function pad2(value) {
|
|
146
|
+
return String(value).padStart(2, "0");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pad3(value) {
|
|
150
|
+
return String(value).padStart(3, "0");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function detectionTimestamp() {
|
|
154
|
+
const now = new Date();
|
|
155
|
+
const yyyy = now.getFullYear();
|
|
156
|
+
const mm = pad2(now.getMonth() + 1);
|
|
157
|
+
const dd = pad2(now.getDate());
|
|
158
|
+
const hh = pad2(now.getHours());
|
|
159
|
+
const min = pad2(now.getMinutes());
|
|
160
|
+
const ss = pad2(now.getSeconds());
|
|
161
|
+
const ms = pad3(now.getMilliseconds());
|
|
162
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}.${ms}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeRegexChar(ch) {
|
|
166
|
+
return /[\\^$.*+?()[\]{}|]/.test(ch) ? `\\${ch}` : ch;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function globToRegex(glob) {
|
|
170
|
+
let out = "^";
|
|
171
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
172
|
+
const ch = glob[i];
|
|
173
|
+
if (ch === "*") {
|
|
174
|
+
out += ".*";
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (ch === "?") {
|
|
178
|
+
out += ".";
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (ch === "[") {
|
|
182
|
+
let j = i + 1;
|
|
183
|
+
if (j < glob.length && glob[j] === "!") {
|
|
184
|
+
j += 1;
|
|
185
|
+
}
|
|
186
|
+
if (j < glob.length && glob[j] === "]") {
|
|
187
|
+
j += 1;
|
|
188
|
+
}
|
|
189
|
+
while (j < glob.length && glob[j] !== "]") {
|
|
190
|
+
j += 1;
|
|
191
|
+
}
|
|
192
|
+
if (j >= glob.length) {
|
|
193
|
+
out += "\\[";
|
|
194
|
+
} else {
|
|
195
|
+
let classContent = glob.slice(i + 1, j);
|
|
196
|
+
if (classContent.startsWith("!")) {
|
|
197
|
+
classContent = `^${classContent.slice(1)}`;
|
|
198
|
+
}
|
|
199
|
+
classContent = classContent.replace(/\\/g, "\\\\");
|
|
200
|
+
out += `[${classContent}]`;
|
|
201
|
+
i = j;
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
out += escapeRegexChar(ch);
|
|
206
|
+
}
|
|
207
|
+
out += "$";
|
|
208
|
+
return new RegExp(out);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function discoverLogFiles(root, globRegexes) {
|
|
212
|
+
const matches = new Set();
|
|
213
|
+
const stack = [root];
|
|
214
|
+
|
|
215
|
+
while (stack.length > 0) {
|
|
216
|
+
const current = stack.pop();
|
|
217
|
+
let entries;
|
|
218
|
+
try {
|
|
219
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
const full = path.join(current, entry.name);
|
|
226
|
+
if (entry.isDirectory()) {
|
|
227
|
+
stack.push(full);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!entry.isFile()) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const lower = entry.name.toLowerCase();
|
|
235
|
+
if (globRegexes.some((regex) => regex.test(lower))) {
|
|
236
|
+
matches.add(full);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return matches;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readLastLines(filePath, count) {
|
|
245
|
+
if (count <= 0) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let content;
|
|
250
|
+
try {
|
|
251
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
252
|
+
} catch {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const lines = content.split(/\r\n|\n|\r/);
|
|
257
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
258
|
+
lines.pop();
|
|
259
|
+
}
|
|
260
|
+
return lines.slice(-count);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function relativeDisplay(root, filePath) {
|
|
264
|
+
const rel = path.relative(root, filePath);
|
|
265
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
266
|
+
return filePath;
|
|
267
|
+
}
|
|
268
|
+
return rel.split(path.sep).join("/");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function formatOutput(relPath, line, useColor) {
|
|
272
|
+
const fileBlock = paint(`[${relPath}]`, C_FILE, useColor);
|
|
273
|
+
const tsBlock = paint(`[${detectionTimestamp()}]`, C_TIMESTAMP, useColor);
|
|
274
|
+
const content = paint(line, C_CONTENT, useColor);
|
|
275
|
+
return `${fileBlock} ${tsBlock} ${content}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function drainNewLines(filePath, state) {
|
|
279
|
+
let stats;
|
|
280
|
+
try {
|
|
281
|
+
stats = fs.statSync(filePath);
|
|
282
|
+
} catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const inode = stats.ino ?? null;
|
|
287
|
+
const size = stats.size;
|
|
288
|
+
|
|
289
|
+
if (state.inode === null) {
|
|
290
|
+
state.inode = inode;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const rotated = state.inode !== inode;
|
|
294
|
+
const truncated = size < state.position;
|
|
295
|
+
if (rotated || truncated) {
|
|
296
|
+
state.inode = inode;
|
|
297
|
+
state.position = 0;
|
|
298
|
+
state.partial = "";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (size <= state.position) {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const toRead = size - state.position;
|
|
306
|
+
let payload = Buffer.alloc(0);
|
|
307
|
+
let fd = null;
|
|
308
|
+
try {
|
|
309
|
+
fd = fs.openSync(filePath, "r");
|
|
310
|
+
payload = Buffer.allocUnsafe(toRead);
|
|
311
|
+
const bytesRead = fs.readSync(fd, payload, 0, toRead, state.position);
|
|
312
|
+
state.position += bytesRead;
|
|
313
|
+
payload = payload.subarray(0, bytesRead);
|
|
314
|
+
} catch {
|
|
315
|
+
return [];
|
|
316
|
+
} finally {
|
|
317
|
+
if (fd !== null) {
|
|
318
|
+
try {
|
|
319
|
+
fs.closeSync(fd);
|
|
320
|
+
} catch {
|
|
321
|
+
// ignore close failures
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (payload.length === 0) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const chunk = state.partial + payload.toString("utf8");
|
|
331
|
+
const parts = chunk.split(/(\r\n|\n|\r)/);
|
|
332
|
+
const lines = [];
|
|
333
|
+
|
|
334
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
335
|
+
lines.push(parts[i]);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
state.partial = parts.length % 2 === 1 ? parts[parts.length - 1] : "";
|
|
339
|
+
return lines;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function expandHome(inputPath) {
|
|
343
|
+
if (inputPath === "~") {
|
|
344
|
+
return os.homedir();
|
|
345
|
+
}
|
|
346
|
+
if (inputPath.startsWith(`~${path.sep}`)) {
|
|
347
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
348
|
+
}
|
|
349
|
+
return inputPath;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sleep(ms) {
|
|
353
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function main() {
|
|
357
|
+
let args;
|
|
358
|
+
try {
|
|
359
|
+
args = parseArgs(process.argv);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
fail(String(error.message || error));
|
|
362
|
+
process.stderr.write("\n");
|
|
363
|
+
process.stderr.write(`${usage()}\n`);
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (args.help) {
|
|
368
|
+
process.stdout.write(`${usage()}\n`);
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const root = path.resolve(expandHome(args.directory));
|
|
373
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
374
|
+
fail(`Error: not a directory: ${root}`);
|
|
375
|
+
return 1;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (args.pollInterval <= 0 || args.scanInterval <= 0) {
|
|
379
|
+
fail("Error: poll and scan intervals must be positive");
|
|
380
|
+
return 1;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (args.initialLines < 0) {
|
|
384
|
+
fail("Error: --initial-lines must be >= 0");
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!["auto", "always", "never"].includes(args.color)) {
|
|
389
|
+
fail("Error: --color must be one of: auto, always, never");
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const useColor = colorEnabled(args.color);
|
|
394
|
+
const globs = args.globs.length > 0 ? args.globs : DEFAULT_GLOBS;
|
|
395
|
+
const globRegexes = globs.map((glob) => globToRegex(glob.toLowerCase()));
|
|
396
|
+
const tracked = new Map();
|
|
397
|
+
|
|
398
|
+
const existingFiles = Array.from(discoverLogFiles(root, globRegexes)).sort();
|
|
399
|
+
for (const filePath of existingFiles) {
|
|
400
|
+
let stats;
|
|
401
|
+
try {
|
|
402
|
+
stats = fs.statSync(filePath);
|
|
403
|
+
} catch {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
tracked.set(filePath, {
|
|
408
|
+
position: stats.size,
|
|
409
|
+
inode: stats.ino ?? null,
|
|
410
|
+
partial: "",
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
if (args.initialLines > 0) {
|
|
414
|
+
const rel = relativeDisplay(root, filePath);
|
|
415
|
+
for (const line of readLastLines(filePath, args.initialLines)) {
|
|
416
|
+
process.stdout.write(`${formatOutput(rel, line, useColor)}\n`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const info =
|
|
422
|
+
`Monitoring ${tracked.size} files under ${root} ` +
|
|
423
|
+
`(globs: ${globs.join(", ")} | poll=${args.pollInterval}s | scan=${args.scanInterval}s). ` +
|
|
424
|
+
"Press Ctrl+C to stop.";
|
|
425
|
+
process.stdout.write(`${paint(info, C_INFO, useColor)}\n`);
|
|
426
|
+
|
|
427
|
+
let stopped = false;
|
|
428
|
+
const stop = () => {
|
|
429
|
+
stopped = true;
|
|
430
|
+
};
|
|
431
|
+
process.on("SIGINT", stop);
|
|
432
|
+
process.on("SIGTERM", stop);
|
|
433
|
+
|
|
434
|
+
let lastScan = performance.now() / 1000;
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
while (!stopped) {
|
|
438
|
+
const now = performance.now() / 1000;
|
|
439
|
+
if (now - lastScan >= args.scanInterval) {
|
|
440
|
+
const currentFiles = discoverLogFiles(root, globRegexes);
|
|
441
|
+
const trackedFiles = new Set(tracked.keys());
|
|
442
|
+
|
|
443
|
+
const newFiles = Array.from(currentFiles)
|
|
444
|
+
.filter((filePath) => !trackedFiles.has(filePath))
|
|
445
|
+
.sort();
|
|
446
|
+
|
|
447
|
+
for (const newPath of newFiles) {
|
|
448
|
+
let stats;
|
|
449
|
+
try {
|
|
450
|
+
stats = fs.statSync(newPath);
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
tracked.set(newPath, {
|
|
456
|
+
position: 0,
|
|
457
|
+
inode: stats.ino ?? null,
|
|
458
|
+
partial: "",
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const rel = relativeDisplay(root, newPath);
|
|
462
|
+
process.stdout.write(`${paint(`[watch] ${rel}`, C_INFO, useColor)}\n`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const filePath of trackedFiles) {
|
|
466
|
+
if (!currentFiles.has(filePath)) {
|
|
467
|
+
tracked.delete(filePath);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
lastScan = now;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const trackedPaths = Array.from(tracked.keys()).sort();
|
|
475
|
+
for (const filePath of trackedPaths) {
|
|
476
|
+
const state = tracked.get(filePath);
|
|
477
|
+
if (!state) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const lines = drainNewLines(filePath, state);
|
|
482
|
+
if (lines.length === 0) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const rel = relativeDisplay(root, filePath);
|
|
487
|
+
for (const line of lines) {
|
|
488
|
+
process.stdout.write(`${formatOutput(rel, line, useColor)}\n`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (stopped) {
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
await sleep(args.pollInterval * 1000);
|
|
496
|
+
}
|
|
497
|
+
} finally {
|
|
498
|
+
process.off("SIGINT", stop);
|
|
499
|
+
process.off("SIGTERM", stop);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
process.stdout.write(`${paint("Stopping mega-tail.", C_INFO, useColor)}\n`);
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
main().then((code) => {
|
|
507
|
+
process.exitCode = code;
|
|
508
|
+
});
|
package/mega-tail
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
mega-tail - Follow dynamic log files under a directory tree.
|
|
4
|
+
|
|
5
|
+
Behavior:
|
|
6
|
+
- Follows appended lines from all matching log files under the target directory.
|
|
7
|
+
- Detects and starts following newly created log files as they appear.
|
|
8
|
+
- Handles truncation/rotation by checking inode and file size changes.
|
|
9
|
+
- Prefixes each line with relative file path and detection timestamp.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import fnmatch
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from collections import deque
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
DEFAULT_GLOBS = ("*.log", "*.log.*")
|
|
26
|
+
DEFAULT_POLL_INTERVAL = 0.2
|
|
27
|
+
DEFAULT_SCAN_INTERVAL = 1.0
|
|
28
|
+
|
|
29
|
+
# ANSI colors
|
|
30
|
+
C_RESET = "\033[0m"
|
|
31
|
+
C_TIMESTAMP = "\033[38;5;81m" # highlighted cyan
|
|
32
|
+
C_FILE = "\033[38;5;245m" # dim gray
|
|
33
|
+
C_CONTENT = "\033[38;5;252m" # brighter gray
|
|
34
|
+
C_INFO = "\033[38;5;244m"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FileState:
|
|
39
|
+
position: int
|
|
40
|
+
inode: int | None
|
|
41
|
+
partial: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_args() -> argparse.Namespace:
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
prog="mega-tail",
|
|
47
|
+
description="Tail dynamic log files in a directory tree.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument("directory", help="Root directory to monitor recursively")
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--glob",
|
|
52
|
+
dest="globs",
|
|
53
|
+
action="append",
|
|
54
|
+
help="Filename glob to include (repeatable). Default: *.log and *.log.*",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--poll-interval",
|
|
58
|
+
type=float,
|
|
59
|
+
default=DEFAULT_POLL_INTERVAL,
|
|
60
|
+
help=f"Seconds between reads of tracked files (default: {DEFAULT_POLL_INTERVAL})",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--scan-interval",
|
|
64
|
+
type=float,
|
|
65
|
+
default=DEFAULT_SCAN_INTERVAL,
|
|
66
|
+
help=f"Seconds between directory scans for new/deleted files (default: {DEFAULT_SCAN_INTERVAL})",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"-n",
|
|
70
|
+
"--initial-lines",
|
|
71
|
+
type=int,
|
|
72
|
+
default=0,
|
|
73
|
+
help="Show last N lines from each existing file at startup (default: 0)",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--color",
|
|
77
|
+
choices=("auto", "always", "never"),
|
|
78
|
+
default="auto",
|
|
79
|
+
help="Color mode (default: auto)",
|
|
80
|
+
)
|
|
81
|
+
return parser.parse_args()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def color_enabled(mode: str) -> bool:
|
|
85
|
+
if mode == "always":
|
|
86
|
+
return True
|
|
87
|
+
if mode == "never":
|
|
88
|
+
return False
|
|
89
|
+
return sys.stdout.isatty() and os.environ.get("TERM", "") != "dumb"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def paint(text: str, color: str, enabled: bool) -> str:
|
|
93
|
+
if not enabled:
|
|
94
|
+
return text
|
|
95
|
+
return f"{color}{text}{C_RESET}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detection_timestamp() -> str:
|
|
99
|
+
now = datetime.now().astimezone()
|
|
100
|
+
return now.strftime("%Y-%m-%d %H:%M:%S.") + f"{now.microsecond // 1000:03d}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def discover_log_files(root: Path, globs: list[str]) -> set[Path]:
|
|
104
|
+
lowered_globs = [g.lower() for g in globs]
|
|
105
|
+
matches: set[Path] = set()
|
|
106
|
+
for dirpath, _, filenames in os.walk(root):
|
|
107
|
+
base = Path(dirpath)
|
|
108
|
+
for filename in filenames:
|
|
109
|
+
name = filename.lower()
|
|
110
|
+
if any(fnmatch.fnmatch(name, pattern) for pattern in lowered_globs):
|
|
111
|
+
matches.add(base / filename)
|
|
112
|
+
return matches
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_last_lines(path: Path, count: int) -> list[str]:
|
|
116
|
+
if count <= 0:
|
|
117
|
+
return []
|
|
118
|
+
lines = deque(maxlen=count)
|
|
119
|
+
try:
|
|
120
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
121
|
+
for line in handle:
|
|
122
|
+
lines.append(line.rstrip("\r\n"))
|
|
123
|
+
except OSError:
|
|
124
|
+
return []
|
|
125
|
+
return list(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def relative_display(root: Path, path: Path) -> str:
|
|
129
|
+
try:
|
|
130
|
+
return path.relative_to(root).as_posix()
|
|
131
|
+
except ValueError:
|
|
132
|
+
return str(path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def format_output(relpath: str, line: str, use_color: bool) -> str:
|
|
136
|
+
file_block = paint(f"[{relpath}]", C_FILE, use_color)
|
|
137
|
+
ts_block = paint(f"[{detection_timestamp()}]", C_TIMESTAMP, use_color)
|
|
138
|
+
content = paint(line, C_CONTENT, use_color)
|
|
139
|
+
return f"{file_block} {ts_block} {content}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def drain_new_lines(path: Path, state: FileState) -> list[str]:
|
|
143
|
+
try:
|
|
144
|
+
stat = path.stat()
|
|
145
|
+
except OSError:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
inode = stat.st_ino
|
|
149
|
+
size = stat.st_size
|
|
150
|
+
|
|
151
|
+
if state.inode is None:
|
|
152
|
+
state.inode = inode
|
|
153
|
+
|
|
154
|
+
rotated = state.inode != inode
|
|
155
|
+
truncated = size < state.position
|
|
156
|
+
if rotated or truncated:
|
|
157
|
+
state.inode = inode
|
|
158
|
+
state.position = 0
|
|
159
|
+
state.partial = ""
|
|
160
|
+
|
|
161
|
+
if size <= state.position:
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
with path.open("rb") as handle:
|
|
166
|
+
handle.seek(state.position)
|
|
167
|
+
payload = handle.read()
|
|
168
|
+
state.position = handle.tell()
|
|
169
|
+
except OSError:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
if not payload:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
# Preserve a trailing partial line and emit only complete lines.
|
|
176
|
+
chunk = state.partial + payload.decode("utf-8", errors="replace")
|
|
177
|
+
parts = chunk.splitlines(keepends=True)
|
|
178
|
+
lines: list[str] = []
|
|
179
|
+
state.partial = ""
|
|
180
|
+
|
|
181
|
+
for part in parts:
|
|
182
|
+
if part.endswith("\n") or part.endswith("\r"):
|
|
183
|
+
lines.append(part.rstrip("\r\n"))
|
|
184
|
+
else:
|
|
185
|
+
state.partial = part
|
|
186
|
+
|
|
187
|
+
return lines
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def install_signal_handlers() -> None:
|
|
191
|
+
def _stop(_signum, _frame):
|
|
192
|
+
raise KeyboardInterrupt
|
|
193
|
+
|
|
194
|
+
signal.signal(signal.SIGINT, _stop)
|
|
195
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> int:
|
|
199
|
+
args = parse_args()
|
|
200
|
+
|
|
201
|
+
root = Path(args.directory).expanduser().resolve()
|
|
202
|
+
if not root.is_dir():
|
|
203
|
+
print(f"Error: not a directory: {root}", file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
|
|
206
|
+
if args.poll_interval <= 0 or args.scan_interval <= 0:
|
|
207
|
+
print("Error: poll and scan intervals must be positive", file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
|
|
210
|
+
if args.initial_lines < 0:
|
|
211
|
+
print("Error: --initial-lines must be >= 0", file=sys.stderr)
|
|
212
|
+
return 1
|
|
213
|
+
|
|
214
|
+
use_color = color_enabled(args.color)
|
|
215
|
+
globs = args.globs if args.globs else list(DEFAULT_GLOBS)
|
|
216
|
+
tracked: dict[Path, FileState] = {}
|
|
217
|
+
|
|
218
|
+
existing_files = discover_log_files(root, globs)
|
|
219
|
+
for path in sorted(existing_files):
|
|
220
|
+
try:
|
|
221
|
+
stat = path.stat()
|
|
222
|
+
except OSError:
|
|
223
|
+
continue
|
|
224
|
+
tracked[path] = FileState(position=stat.st_size, inode=stat.st_ino)
|
|
225
|
+
if args.initial_lines > 0:
|
|
226
|
+
rel = relative_display(root, path)
|
|
227
|
+
for line in read_last_lines(path, args.initial_lines):
|
|
228
|
+
print(format_output(rel, line, use_color), flush=True)
|
|
229
|
+
|
|
230
|
+
info = (
|
|
231
|
+
f"Monitoring {len(tracked)} files under {root} "
|
|
232
|
+
f"(globs: {', '.join(globs)} | poll={args.poll_interval}s | scan={args.scan_interval}s). "
|
|
233
|
+
"Press Ctrl+C to stop."
|
|
234
|
+
)
|
|
235
|
+
print(paint(info, C_INFO, use_color), flush=True)
|
|
236
|
+
|
|
237
|
+
install_signal_handlers()
|
|
238
|
+
last_scan = time.monotonic()
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
while True:
|
|
242
|
+
now = time.monotonic()
|
|
243
|
+
if now - last_scan >= args.scan_interval:
|
|
244
|
+
current_files = discover_log_files(root, globs)
|
|
245
|
+
tracked_files = set(tracked.keys())
|
|
246
|
+
|
|
247
|
+
for new_path in sorted(current_files - tracked_files):
|
|
248
|
+
try:
|
|
249
|
+
stat = new_path.stat()
|
|
250
|
+
except OSError:
|
|
251
|
+
continue
|
|
252
|
+
# Start new files at byte 0 so early writes are not missed.
|
|
253
|
+
tracked[new_path] = FileState(position=0, inode=stat.st_ino)
|
|
254
|
+
rel = relative_display(root, new_path)
|
|
255
|
+
watch_msg = f"[watch] {rel}"
|
|
256
|
+
print(paint(watch_msg, C_INFO, use_color), flush=True)
|
|
257
|
+
|
|
258
|
+
for removed_path in tracked_files - current_files:
|
|
259
|
+
tracked.pop(removed_path, None)
|
|
260
|
+
|
|
261
|
+
last_scan = now
|
|
262
|
+
|
|
263
|
+
for path in sorted(tracked.keys()):
|
|
264
|
+
lines = drain_new_lines(path, tracked[path])
|
|
265
|
+
if not lines:
|
|
266
|
+
continue
|
|
267
|
+
rel = relative_display(root, path)
|
|
268
|
+
for line in lines:
|
|
269
|
+
print(format_output(rel, line, use_color), flush=True)
|
|
270
|
+
|
|
271
|
+
time.sleep(args.poll_interval)
|
|
272
|
+
except KeyboardInterrupt:
|
|
273
|
+
print(paint("Stopping mega-tail.", C_INFO, use_color), flush=True)
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
raise SystemExit(main())
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mega-tail",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tail dynamic log files in a directory tree.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mega-tail": "bin/mega-tail.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/mega-tail.js",
|
|
11
|
+
"README.md",
|
|
12
|
+
"mega-tail"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"tail",
|
|
19
|
+
"logs",
|
|
20
|
+
"cli",
|
|
21
|
+
"monitoring"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/orlenko/mega-tail.git"
|
|
26
|
+
}
|
|
27
|
+
}
|