Skip to content

Commit 93afd47

Browse files
committed
feat(viewer): add pinned logs with rotation protection and compare limit to 5
1 parent 44d4d26 commit 93afd47

8 files changed

Lines changed: 311 additions & 20 deletions

File tree

src/controllers/viewer-controller.js

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '../viewer-filters.js';
2020
import { logToHar } from '../services/har-service.js';
2121
import { logToPython } from '../services/python-service.js';
22+
import { pinLog, unpinLog, isPinned, getPinnedSet } from '../pinned.js';
2223

2324
export function createViewerController(config) {
2425
return {
@@ -29,19 +30,21 @@ export function createViewerController(config) {
2930
const baseUrlFilters = normalizeBaseUrlFilters(parseCsvParam(req.query.baseUrl));
3031
const aliasFilters = normalizeAliasFilters(parseCsvParam(req.query.alias));
3132
const methodFilters = normalizeMethodFilters(parseCsvParam(req.query.method));
33+
const pinnedFilter = req.query.pinned === '1';
3234
const { aliasByHost, aliasHostMap, aliasNameMap } = buildAliasMaps(config.aliases);
35+
const pinnedSet = getPinnedSet();
36+
3337
const { logs, total } = await getViewerIndexData(
3438
config.outputDir,
3539
{
36-
limit,
37-
offset,
40+
limit: pinnedFilter ? 1000 : limit,
41+
offset: pinnedFilter ? 0 : offset,
3842
baseUrls: baseUrlFilters,
3943
aliases: aliasFilters,
4044
methods: methodFilters,
4145
aliasHostMap,
4246
}
4347
);
44-
const totalPages = Math.ceil(total / limit);
4548

4649
const processedLogs = logs.map((log) => {
4750
const baseUrl = normalizeBaseUrlValue(log?.request?.url);
@@ -51,6 +54,8 @@ export function createViewerController(config) {
5154
(baseUrl ? aliasByHost[baseUrl] : null) ||
5255
null;
5356
const messageCount = getRequestMessageCount(log);
57+
const logId = `${log._viewer_provider}/${log._viewer_file}`;
58+
const logPinned = pinnedSet.has(logId);
5459
try {
5560
const url = new URL(log.request.url);
5661
const hidden = shouldHideFromViewer(url.pathname);
@@ -61,6 +66,7 @@ export function createViewerController(config) {
6166
_base_url: baseUrl,
6267
_alias: aliasLabel,
6368
_message_count: messageCount,
69+
_pinned: logPinned,
6470
};
6571
} catch {
6672
return {
@@ -69,20 +75,32 @@ export function createViewerController(config) {
6975
_base_url: baseUrl,
7076
_alias: aliasLabel,
7177
_message_count: messageCount,
78+
_pinned: logPinned,
7279
};
7380
}
7481
});
7582

83+
// Filter to pinned only if requested
84+
const filteredLogs = pinnedFilter
85+
? processedLogs.filter((log) => log._pinned)
86+
: processedLogs;
87+
const filteredTotal = pinnedFilter ? filteredLogs.length : total;
88+
const paginatedLogs = pinnedFilter
89+
? filteredLogs.slice(offset, offset + limit)
90+
: filteredLogs;
91+
const totalPages = Math.ceil(filteredTotal / limit);
92+
7693
const html = await renderViewer(
7794
{
78-
logs: processedLogs,
95+
logs: paginatedLogs,
7996
limit,
8097
page,
8198
totalPages,
82-
total,
99+
total: filteredTotal,
83100
baseUrlFilters,
84101
aliasFilters,
85102
methodFilters,
103+
pinnedFilter,
86104
aliasByHost,
87105
}
88106
);
@@ -140,7 +158,7 @@ export function createViewerController(config) {
140158

141159
if (invalid.length || selections.length < 2) {
142160
const message = invalid.length
143-
? 'Invalid compare selection. Choose two or three valid log entries.'
161+
? 'Invalid compare selection. Choose two to five valid log entries.'
144162
: 'Select at least two logs to compare.';
145163
const html = await renderViewerCompare({
146164
logs: [],
@@ -306,6 +324,46 @@ export function createViewerController(config) {
306324
res.status(500).json({ error: 'Download Python error', message: error.message });
307325
}
308326
},
327+
328+
pin: async (req, res) => {
329+
try {
330+
const { provider, filename } = req.params;
331+
const logId = `${provider}/${filename}`;
332+
const log = await loadViewerLog(config.outputDir, provider, filename);
333+
if (!log) {
334+
res.status(404).json({ error: 'Not found' });
335+
return;
336+
}
337+
const result = pinLog(logId);
338+
res.json({ success: true, ...result });
339+
} catch (error) {
340+
console.error('Viewer pin error:', error.message);
341+
res.status(500).json({ error: 'Pin error', message: error.message });
342+
}
343+
},
344+
345+
unpin: async (req, res) => {
346+
try {
347+
const { provider, filename } = req.params;
348+
const logId = `${provider}/${filename}`;
349+
const result = unpinLog(logId);
350+
res.json({ success: true, ...result });
351+
} catch (error) {
352+
console.error('Viewer unpin error:', error.message);
353+
res.status(500).json({ error: 'Unpin error', message: error.message });
354+
}
355+
},
356+
357+
getPinStatus: async (req, res) => {
358+
try {
359+
const { provider, filename } = req.params;
360+
const logId = `${provider}/${filename}`;
361+
res.json({ pinned: isPinned(logId), logId });
362+
} catch (error) {
363+
console.error('Viewer pin status error:', error.message);
364+
res.status(500).json({ error: 'Pin status error', message: error.message });
365+
}
366+
},
309367
};
310368
}
311369

src/logger.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import yaml from 'js-yaml';
44
import { sanitizeBody, sanitizeHeaders, sanitizeUrl } from './redact.js';
55
import { filterLogs } from './viewer-filters.js';
66
import { loadConfig } from './config.js';
7+
import { getPinnedSet } from './pinned.js';
78

89
function generateFilename() {
910
const now = new Date();
@@ -34,13 +35,13 @@ async function getAllLogFiles(outputDir) {
3435
const rootEntries = await readdir(outputDir, { withFileTypes: true });
3536
for (const entry of rootEntries) {
3637
if (entry.isFile() && entry.name.endsWith('.yaml')) {
37-
files.push({ path: join(outputDir, entry.name), name: entry.name });
38+
files.push({ path: join(outputDir, entry.name), name: entry.name, logId: `unknown/${entry.name}` });
3839
} else if (entry.isDirectory()) {
3940
try {
4041
const subFiles = await readdir(join(outputDir, entry.name));
4142
for (const subFile of subFiles) {
4243
if (subFile.endsWith('.yaml')) {
43-
files.push({ path: join(outputDir, entry.name, subFile), name: subFile });
44+
files.push({ path: join(outputDir, entry.name, subFile), name: subFile, logId: `${entry.name}/${subFile}` });
4445
}
4546
}
4647
} catch {
@@ -60,12 +61,17 @@ async function rotateLogsIfNeeded(outputDir) {
6061
if (!maxLogs || maxLogs <= 0) return;
6162

6263
const files = await getAllLogFiles(outputDir);
63-
if (files.length <= maxLogs) return;
64+
const pinnedSet = getPinnedSet();
65+
66+
// Separate pinned and unpinned files
67+
const unpinnedFiles = files.filter((f) => !pinnedSet.has(f.logId));
68+
69+
if (unpinnedFiles.length <= maxLogs) return;
6470

6571
// Sort by filename (which contains timestamp) - oldest first
66-
files.sort((a, b) => a.name.localeCompare(b.name));
72+
unpinnedFiles.sort((a, b) => a.name.localeCompare(b.name));
6773

68-
const toDelete = files.slice(0, files.length - maxLogs);
74+
const toDelete = unpinnedFiles.slice(0, unpinnedFiles.length - maxLogs);
6975
for (const file of toDelete) {
7076
try {
7177
await unlink(file.path);

src/pinned.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2+
import { dirname, join } from 'node:path';
3+
import yaml from 'js-yaml';
4+
import { getBaseDir } from './paths.js';
5+
6+
function getPinnedFilePath() {
7+
return join(getBaseDir(), 'pinned.yaml');
8+
}
9+
10+
export function loadPinned() {
11+
const pinnedPath = getPinnedFilePath();
12+
if (!existsSync(pinnedPath)) {
13+
return [];
14+
}
15+
try {
16+
const content = readFileSync(pinnedPath, 'utf-8');
17+
const parsed = yaml.load(content);
18+
return Array.isArray(parsed) ? parsed : [];
19+
} catch {
20+
return [];
21+
}
22+
}
23+
24+
function savePinned(pinned) {
25+
const pinnedPath = getPinnedFilePath();
26+
mkdirSync(dirname(pinnedPath), { recursive: true });
27+
const content = yaml.dump(pinned, {
28+
indent: 2,
29+
lineWidth: -1,
30+
noRefs: true,
31+
});
32+
writeFileSync(pinnedPath, content, 'utf-8');
33+
}
34+
35+
export function isPinned(logId) {
36+
const pinned = loadPinned();
37+
return pinned.includes(logId);
38+
}
39+
40+
export function pinLog(logId) {
41+
const pinned = loadPinned();
42+
if (!pinned.includes(logId)) {
43+
pinned.push(logId);
44+
savePinned(pinned);
45+
}
46+
return { pinned: true, logId };
47+
}
48+
49+
export function unpinLog(logId) {
50+
const pinned = loadPinned();
51+
const index = pinned.indexOf(logId);
52+
if (index !== -1) {
53+
pinned.splice(index, 1);
54+
savePinned(pinned);
55+
}
56+
return { pinned: false, logId };
57+
}
58+
59+
export function getPinnedSet() {
60+
return new Set(loadPinned());
61+
}

src/routes/viewer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export function createViewerRouter(config) {
1717
router.get('/:provider/:filename/har', controller.downloadHar);
1818
router.get('/:provider/:filename/python', controller.downloadPython);
1919
router.delete('/:provider/:filename', controller.delete);
20+
router.post('/:provider/:filename/pin', controller.pin);
21+
router.delete('/:provider/:filename/pin', controller.unpin);
22+
router.get('/:provider/:filename/pin', controller.getPinStatus);
2023
router.all('*', (req, res) => {
2124
res.status(404).json({ error: 'Not found' });
2225
});

src/services/viewer-service.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function buildCompareData(logs, { baselineIndex = 0 } = {}) {
124124
return { sections, baselineIndex: clampedBaseline };
125125
}
126126

127-
export function parseCompareLogSelection(value, { max = 3 } = {}) {
127+
export function parseCompareLogSelection(value, { max = 5 } = {}) {
128128
const selections = [];
129129
const invalid = [];
130130
const seen = new Set();

0 commit comments

Comments
 (0)