Skip to content

Commit d79717a

Browse files
committed
feat: log viewer component
1 parent f760c2e commit d79717a

File tree

10 files changed

+545
-7
lines changed

10 files changed

+545
-7
lines changed

api/src/graphql/schema/types/logs/logs.graphql

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ type Query {
88
Get the content of a specific log file
99
@param path Path to the log file
1010
@param lines Number of lines to read from the end of the file (default: 100)
11+
@param startLine Optional starting line number (1-indexed)
1112
"""
12-
logFile(path: String!, lines: Int): LogFileContent!
13+
logFile(path: String!, lines: Int, startLine: Int): LogFileContent!
1314
}
1415

1516
type Subscription {
@@ -63,4 +64,9 @@ type LogFileContent {
6364
Total number of lines in the file
6465
"""
6566
totalLines: Int!
67+
68+
"""
69+
Starting line number of the content (1-indexed)
70+
"""
71+
startLine: Int
6672
}

api/src/unraid-api/graph/resolvers/logs/logs.resolver.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,12 @@ export class LogsResolver {
2626
resource: Resource.LOGS,
2727
possession: AuthPossession.ANY,
2828
})
29-
async logFile(@Args('path') path: string, @Args('lines') lines?: number) {
30-
return this.logsService.getLogFileContent(path, lines);
29+
async logFile(
30+
@Args('path') path: string,
31+
@Args('lines') lines?: number,
32+
@Args('startLine') startLine?: number
33+
) {
34+
return this.logsService.getLogFileContent(path, lines, startLine);
3135
}
3236

3337
@Subscription('logFile')

api/src/unraid-api/graph/resolvers/logs/logs.service.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface LogFileContent {
2020
path: string;
2121
content: string;
2222
totalLines: number;
23+
startLine?: number;
2324
}
2425

2526
@Injectable()
@@ -68,22 +69,35 @@ export class LogsService {
6869
* Get the content of a log file
6970
* @param path Path to the log file
7071
* @param lines Number of lines to read from the end of the file (default: 100)
72+
* @param startLine Optional starting line number (1-indexed)
7173
*/
72-
async getLogFileContent(path: string, lines = this.DEFAULT_LINES): Promise<LogFileContent> {
74+
async getLogFileContent(
75+
path: string,
76+
lines = this.DEFAULT_LINES,
77+
startLine?: number
78+
): Promise<LogFileContent> {
7379
try {
7480
// Validate that the path is within the log directory
7581
const normalizedPath = join(this.logBasePath, basename(path));
7682

7783
// Count total lines
7884
const totalLines = await this.countFileLines(normalizedPath);
7985

80-
// Read the last N lines
81-
const content = await this.readLastLines(normalizedPath, lines);
86+
let content: string;
87+
88+
if (startLine !== undefined) {
89+
// Read from specific starting line
90+
content = await this.readLinesFromPosition(normalizedPath, startLine, lines);
91+
} else {
92+
// Read the last N lines (default behavior)
93+
content = await this.readLastLines(normalizedPath, lines);
94+
}
8295

8396
return {
8497
path: normalizedPath,
8598
content,
8699
totalLines,
100+
startLine: startLine !== undefined ? startLine : Math.max(1, totalLines - lines + 1)
87101
};
88102
} catch (error: unknown) {
89103
this.logger.error(`Error reading log file: ${error}`);
@@ -266,4 +280,52 @@ export class LogsService {
266280
});
267281
});
268282
}
283+
284+
/**
285+
* Read lines from a specific position in the file
286+
* @param filePath Path to the file
287+
* @param startLine Starting line number (1-indexed)
288+
* @param lineCount Number of lines to read
289+
*/
290+
private async readLinesFromPosition(
291+
filePath: string,
292+
startLine: number,
293+
lineCount: number
294+
): Promise<string> {
295+
return new Promise((resolve, reject) => {
296+
let currentLine = 0;
297+
let content = '';
298+
let linesRead = 0;
299+
300+
const stream = createReadStream(filePath);
301+
const rl = createInterface({
302+
input: stream,
303+
crlfDelay: Infinity,
304+
});
305+
306+
rl.on('line', (line) => {
307+
currentLine++;
308+
309+
// Skip lines before the starting position
310+
if (currentLine >= startLine) {
311+
// Only read the requested number of lines
312+
if (linesRead < lineCount) {
313+
content += line + '\n';
314+
linesRead++;
315+
} else {
316+
// We've read enough lines, close the stream
317+
rl.close();
318+
}
319+
}
320+
});
321+
322+
rl.on('close', () => {
323+
resolve(content);
324+
});
325+
326+
rl.on('error', (err) => {
327+
reject(err);
328+
});
329+
});
330+
}
269331
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue';
3+
import { useQuery } from '@vue/apollo-composable';
4+
import {
5+
Select,
6+
SelectContent,
7+
SelectItem,
8+
SelectTrigger,
9+
SelectValue,
10+
Input,
11+
Label,
12+
Switch
13+
} from '@unraid/ui';
14+
import { GET_LOG_FILES } from './log.query';
15+
import SingleLogViewer from './SingleLogViewer.vue';
16+
17+
// Component state
18+
const selectedLogFile = ref<string>('');
19+
const lineCount = ref<number>(100);
20+
const autoScroll = ref<boolean>(true);
21+
22+
// Fetch log files
23+
const { result: logFilesResult, loading: loadingLogFiles, error: logFilesError } = useQuery(GET_LOG_FILES);
24+
25+
const logFiles = computed(() => {
26+
return logFilesResult.value?.logFiles || [];
27+
});
28+
29+
// Format file size for display
30+
const formatFileSize = (bytes: number): string => {
31+
if (bytes === 0) return '0 Bytes';
32+
33+
const k = 1024;
34+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
35+
const i = Math.floor(Math.log(bytes) / Math.log(k));
36+
37+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
38+
};
39+
</script>
40+
41+
<template>
42+
<div class="flex flex-col h-full min-h-[400px] bg-background text-foreground rounded-lg border border-border overflow-hidden">
43+
<div class="p-4 border-b border-border">
44+
<h2 class="text-lg font-semibold mb-4">Log Viewer</h2>
45+
46+
<div class="flex flex-wrap gap-4 items-end">
47+
<div class="flex-1 min-w-[200px]">
48+
<Label for="log-file-select">Log File</Label>
49+
<Select v-model="selectedLogFile">
50+
<SelectTrigger class="w-full">
51+
<SelectValue placeholder="Select a log file" />
52+
</SelectTrigger>
53+
<SelectContent>
54+
<SelectItem v-for="file in logFiles" :key="file.path" :value="file.path">
55+
{{ file.name }} ({{ formatFileSize(file.size) }})
56+
</SelectItem>
57+
</SelectContent>
58+
</Select>
59+
</div>
60+
61+
<div>
62+
<Label for="line-count">Lines</Label>
63+
<Input
64+
id="line-count"
65+
v-model.number="lineCount"
66+
type="number"
67+
min="10"
68+
max="1000"
69+
class="w-24"
70+
/>
71+
</div>
72+
73+
<div>
74+
<Label for="auto-scroll">Auto-scroll</Label>
75+
<Switch
76+
id="auto-scroll"
77+
v-model:checked="autoScroll"
78+
/>
79+
</div>
80+
</div>
81+
</div>
82+
83+
<div class="flex-1 overflow-hidden relative">
84+
<div v-if="loadingLogFiles" class="flex items-center justify-center h-full p-4 text-center text-muted-foreground">
85+
Loading log files...
86+
</div>
87+
88+
<div v-else-if="logFilesError" class="flex items-center justify-center h-full p-4 text-center text-destructive">
89+
Error loading log files: {{ logFilesError.message }}
90+
</div>
91+
92+
<div v-else-if="logFiles.length === 0" class="flex items-center justify-center h-full p-4 text-center text-muted-foreground">
93+
No log files found.
94+
</div>
95+
96+
<div v-else-if="!selectedLogFile" class="flex items-center justify-center h-full p-4 text-center text-muted-foreground">
97+
Please select a log file to view.
98+
</div>
99+
100+
<SingleLogViewer
101+
v-else
102+
:log-file-path="selectedLogFile"
103+
:line-count="lineCount"
104+
:auto-scroll="autoScroll"
105+
class="h-full"
106+
/>
107+
</div>
108+
</div>
109+
</template>
110+
111+
<style lang="postcss">
112+
/* Import unraid-ui globals first */
113+
</style>
114+

0 commit comments

Comments
 (0)