Skip to content

Commit a4a9919

Browse files
author
Timothy Dodd
committed
Log view updates
1 parent 323b2f1 commit a4a9919

9 files changed

Lines changed: 429 additions & 72 deletions

File tree

src/LogMkAgent/Services/LogWatcher.cs

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@ private async Task ReadNewLinesAsync(PodInfo info, CancellationToken stoppingTok
324324
var foundRecent = false;
325325
var linesProcessed = 0;
326326
var logLines = new List<LogLine>();
327+
328+
// Multi-line buffering
329+
LogLine? pendingLogLine = null;
330+
var multiLineBuffer = new StringBuilder();
331+
var lastTimestamp = DateTimeOffset.MinValue;
327332

328333
try
329334
{
@@ -346,12 +351,30 @@ private async Task ReadNewLinesAsync(PodInfo info, CancellationToken stoppingTok
346351
if (string.IsNullOrWhiteSpace(line))
347352
continue;
348353

349-
var processedLine = ProcessLogLine(line, info, podSettings, deploymentSettings, ref foundRecent);
350-
if (processedLine != null)
354+
// Check if this line is a continuation of the previous log entry
355+
if (IsLineContinuation(line, info, lastTimestamp))
351356
{
352-
logLines.Add(processedLine);
353-
linesProcessed++;
357+
// Add to the buffer for the current pending log entry
358+
if (pendingLogLine != null)
359+
{
360+
multiLineBuffer.AppendLine(line);
361+
continue;
362+
}
363+
}
354364

365+
// If we have a pending log entry, finalize it
366+
if (pendingLogLine != null)
367+
{
368+
// Append any buffered continuation lines
369+
if (multiLineBuffer.Length > 0)
370+
{
371+
pendingLogLine.Line += Environment.NewLine + multiLineBuffer.ToString().TrimEnd();
372+
multiLineBuffer.Clear();
373+
}
374+
375+
logLines.Add(pendingLogLine);
376+
linesProcessed++;
377+
355378
// Batch processing to avoid overwhelming the system
356379
if (logLines.Count >= 100)
357380
{
@@ -362,6 +385,29 @@ private async Task ReadNewLinesAsync(PodInfo info, CancellationToken stoppingTok
362385
logLines.Clear();
363386
}
364387
}
388+
389+
// Process the new log line
390+
var processedLine = ProcessLogLine(line, info, podSettings, deploymentSettings, ref foundRecent);
391+
if (processedLine != null)
392+
{
393+
pendingLogLine = processedLine;
394+
lastTimestamp = processedLine.TimeStamp;
395+
}
396+
else
397+
{
398+
pendingLogLine = null;
399+
}
400+
}
401+
402+
// Finalize any pending log entry
403+
if (pendingLogLine != null)
404+
{
405+
if (multiLineBuffer.Length > 0)
406+
{
407+
pendingLogLine.Line += Environment.NewLine + multiLineBuffer.ToString().TrimEnd();
408+
}
409+
logLines.Add(pendingLogLine);
410+
linesProcessed++;
365411
}
366412

367413
// Process remaining lines
@@ -564,6 +610,53 @@ private static int MinNonNegative(params int[] values)
564610
var validValues = values.Where(v => v >= 0);
565611
return validValues.Any() ? validValues.Min() : -1;
566612
}
613+
614+
private bool IsLineContinuation(string line, PodInfo info, DateTimeOffset lastTimestamp)
615+
{
616+
if (string.IsNullOrWhiteSpace(line))
617+
return false;
618+
619+
// Parse container format to get the actual log content
620+
var cleanLine = RemoveANSIEscapeRegex.Replace(line, string.Empty);
621+
var processedLine = ParseContainerLogFormat(line, cleanLine);
622+
623+
// Check if line has a timestamp at the beginning
624+
var timestamp = ParseTimestamp(line);
625+
if (timestamp.HasValue)
626+
{
627+
// If timestamps are very close (within 100ms), it might be a continuation
628+
var timeDiff = Math.Abs((timestamp.Value - lastTimestamp).TotalMilliseconds);
629+
if (timeDiff > 100)
630+
return false;
631+
}
632+
633+
// Common patterns for continuation lines:
634+
// 1. Stack trace lines starting with "at "
635+
if (processedLine.TrimStart().StartsWith("at ", StringComparison.OrdinalIgnoreCase))
636+
return true;
637+
638+
// 2. Lines starting with whitespace (indented)
639+
if (processedLine.Length > 0 && char.IsWhiteSpace(processedLine[0]))
640+
return true;
641+
642+
// 3. Lines that look like stack trace file references
643+
if (processedLine.Contains("file:///") && processedLine.Contains(".mjs:"))
644+
return true;
645+
646+
// 4. Lines that are just closing braces or brackets
647+
if (processedLine.Trim() == "}" || processedLine.Trim() == "]" || processedLine.Trim() == ")")
648+
return true;
649+
650+
// 5. Lines without any log level indicators
651+
var logLevel = GetLogLevelCached(processedLine);
652+
if (logLevel == LogLevel.Any && !timestamp.HasValue)
653+
{
654+
// No log level and no timestamp - likely a continuation
655+
return true;
656+
}
657+
658+
return false;
659+
}
567660

568661
public override void Dispose()
569662
{

src/LogMkWeb/src/app/_components/dropdown/dropdown.component.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CommonModule } from '@angular/common';
2-
import { Component, ElementRef, HostListener, viewChild, forwardRef, signal, inject, input, output, computed } from '@angular/core';
2+
import { Component, ElementRef, HostListener, viewChild, forwardRef, signal, inject, input, output, computed, effect } from '@angular/core';
33
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
44
import { LucideAngularModule } from 'lucide-angular';
55

@@ -150,6 +150,18 @@ export class DropdownComponent implements ControlValueAccessor {
150150

151151
private onChange = (value: any) => {};
152152
private onTouched = () => {};
153+
private lastWrittenValue: any = null;
154+
155+
constructor() {
156+
// Effect to update selected items when options change
157+
effect(() => {
158+
const options = this.processedOptions();
159+
if (options.length > 0 && this.lastWrittenValue !== null) {
160+
// Re-apply the last written value now that we have options
161+
this.writeValue(this.lastWrittenValue);
162+
}
163+
});
164+
}
153165

154166
isSelected(option: DropdownOption): boolean {
155167
if (this.multiple()) {
@@ -220,17 +232,27 @@ export class DropdownComponent implements ControlValueAccessor {
220232

221233
// ControlValueAccessor implementation
222234
writeValue(value: any): void {
235+
this.lastWrittenValue = value;
236+
223237
if (this.multiple()) {
224238
const values = Array.isArray(value) ? value : [];
225239
const options = this.processedOptions();
226-
const selectedItems = values.map(v =>
227-
options.find(opt => opt.value === v)
228-
).filter(Boolean) as DropdownOption[];
229-
this.selectedItems.set(selectedItems);
240+
241+
// If options aren't loaded yet, store the raw values
242+
if (options.length === 0 && values.length > 0) {
243+
// Create temporary options from the values
244+
const tempItems = values.map(v => ({ value: v, label: v }));
245+
this.selectedItems.set(tempItems);
246+
} else {
247+
const selectedItems = values.map(v =>
248+
options.find(opt => opt.value === v) || { value: v, label: v }
249+
).filter(Boolean) as DropdownOption[];
250+
this.selectedItems.set(selectedItems);
251+
}
230252
} else {
231253
this.selectedValue.set(value);
232254
const option = this.processedOptions().find(opt => opt.value === value);
233-
this.selectedLabel.set(option?.label || '');
255+
this.selectedLabel.set(option?.label || value || '');
234256
}
235257
}
236258

src/LogMkWeb/src/app/_pages/main-log-page/_services/log-filter-state.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface FilterState {
55
selectedPod: string[] | null;
66
searchString: string;
77
selectedTimeRange: string | null; // Store as ISO string for localStorage
8+
customTimeRange: { start: string; end: string } | null; // Custom time range from graph clicks
89
}
910

1011
@Injectable({
@@ -17,6 +18,7 @@ export class LogFilterState {
1718
selectedPod = signal<string[] | null>(null);
1819
searchString = signal<string>('');
1920
selectedTimeRange = signal<Date | null>(null);
21+
customTimeRange = signal<{ start: Date; end: Date } | null>(null);
2022

2123
constructor() {
2224
this.loadFromStorage();
@@ -37,6 +39,14 @@ export class LogFilterState {
3739
if (state.selectedTimeRange) {
3840
this.selectedTimeRange.set(new Date(state.selectedTimeRange));
3941
}
42+
43+
// Convert custom time range back to Date objects
44+
if (state.customTimeRange) {
45+
this.customTimeRange.set({
46+
start: new Date(state.customTimeRange.start),
47+
end: new Date(state.customTimeRange.end)
48+
});
49+
}
4050
}
4151
} catch (error) {
4252
console.warn('Failed to load filter state from localStorage:', error);
@@ -46,11 +56,16 @@ export class LogFilterState {
4656
private setupAutoSave(): void {
4757
// Save to localStorage whenever any filter changes
4858
effect(() => {
59+
const customRange = this.customTimeRange();
4960
const state: FilterState = {
5061
selectedLogLevel: this.selectedLogLevel(),
5162
selectedPod: this.selectedPod(),
5263
searchString: this.searchString(),
53-
selectedTimeRange: this.selectedTimeRange()?.toISOString() || null
64+
selectedTimeRange: this.selectedTimeRange()?.toISOString() || null,
65+
customTimeRange: customRange ? {
66+
start: customRange.start.toISOString(),
67+
end: customRange.end.toISOString()
68+
} : null
5469
};
5570

5671
try {
@@ -66,5 +81,21 @@ export class LogFilterState {
6681
this.selectedPod.set(null);
6782
this.searchString.set('');
6883
this.selectedTimeRange.set(null);
84+
this.customTimeRange.set(null);
85+
}
86+
87+
setCustomTimeRange(start: Date, end: Date): void {
88+
this.customTimeRange.set({ start, end });
89+
// Clear the regular time range when setting custom range
90+
this.selectedTimeRange.set(null);
91+
}
92+
93+
// Get the effective time range (custom takes priority over regular)
94+
getEffectiveTimeRange(): Date | { start: Date; end: Date } | null {
95+
const custom = this.customTimeRange();
96+
if (custom) {
97+
return custom;
98+
}
99+
return this.selectedTimeRange();
69100
}
70101
}

src/LogMkWeb/src/app/_pages/main-log-page/log-filter-controls/log-filter-controls.component.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
2+
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
33
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
44
import { FormsModule } from '@angular/forms';
55
import { startOfToday, subDays, subHours, subMonths } from 'date-fns'; // Import date-fns for date manipulations
@@ -51,15 +51,28 @@ import { LogFilterState } from '../_services/log-filter-state';
5151
</div>
5252
<div>
5353
<lucide-icon name="clock"></lucide-icon>
54-
<app-dropdown
55-
id="time-filter-select"
56-
[items]="timeFilters"
57-
[ngModel]="selectedTimeFilter()"
58-
(ngModelChange)="selectedTimeFilter.set($event)"
59-
bindLabel="label"
60-
[bindValue]="null"
61-
placeholder="Select time range"
62-
></app-dropdown>
54+
@if (customTimeRangeDisplay()) {
55+
<div class="d-flex align-items-center gap-2 form-control bg-primary text-white">
56+
<span class="flex-grow-1 text-truncate" title="{{ customTimeRangeDisplay() }}">
57+
📊 {{ customTimeRangeDisplay() }}
58+
</span>
59+
<button class="btn btn-sm btn-outline-light ms-auto"
60+
(click)="clearCustomTimeRange()"
61+
title="Clear chart selection">
62+
63+
</button>
64+
</div>
65+
} @else {
66+
<app-dropdown
67+
id="time-filter-select"
68+
[items]="timeFilters"
69+
[ngModel]="selectedTimeFilter()"
70+
(ngModelChange)="selectedTimeFilter.set($event)"
71+
bindLabel="label"
72+
[bindValue]="null"
73+
placeholder="Select time range"
74+
></app-dropdown>
75+
}
6376
</div>
6477
`,
6578
styleUrl: './log-filter-controls.component.scss',
@@ -73,6 +86,25 @@ export class LogFilterControlsComponent {
7386
pods = signal<string[]>([]);
7487
searchString = signal<string>('');
7588
selectedTimeFilter = signal<TimeFilter | null>(null);
89+
90+
customTimeRangeDisplay = computed(() => {
91+
const customRange = this.logFilterState.customTimeRange();
92+
if (!customRange) return null;
93+
94+
const start = customRange.start.toLocaleString(undefined, {
95+
month: 'short',
96+
day: 'numeric',
97+
hour: '2-digit',
98+
minute: '2-digit'
99+
});
100+
const end = customRange.end.toLocaleString(undefined, {
101+
month: 'short',
102+
day: 'numeric',
103+
hour: '2-digit',
104+
minute: '2-digit'
105+
});
106+
return `${start} - ${end}`;
107+
});
76108
timeFilters: TimeFilter[] = [
77109
{ label: 'Any', value: null },
78110
{ label: 'Last Hour', value: subHours(startOfToday(), 1) },
@@ -115,6 +147,10 @@ export class LogFilterControlsComponent {
115147
this.pods.set(pods.map((p) => p.name));
116148
});
117149
}
150+
151+
clearCustomTimeRange(): void {
152+
this.logFilterState.customTimeRange.set(null);
153+
}
118154
}
119155

120156
export interface TimeFilter {

src/LogMkWeb/src/app/_pages/main-log-page/log-stats/log-stats.component.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
width: 100%;
44
height: 140px;
55

6-
canvas {
6+
.chart-canvas {
77
width: 100%;
8+
cursor: pointer;
9+
10+
&:hover {
11+
opacity: 0.9;
12+
}
813
}
914
}

0 commit comments

Comments
 (0)