Skip to content

Commit 1e5264a

Browse files
authoredOct 4, 2024··
feat: adds support for levels (#3306)
1 parent 157b2cb commit 1e5264a

File tree

11 files changed

+181
-71
lines changed

11 files changed

+181
-71
lines changed
 

Diff for: ‎assets/auto-imports.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ declare global {
1616
const DEFAULT_SETTINGS: typeof import('./stores/settings')['DEFAULT_SETTINGS']
1717
const EffectScope: typeof import('vue')['EffectScope']
1818
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
19+
const allLevels: typeof import('./composable/logContext')['allLevels']
1920
const arrayEquals: typeof import('./utils/index')['arrayEquals']
2021
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
2122
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
@@ -392,6 +393,7 @@ declare module 'vue' {
392393
readonly DEFAULT_SETTINGS: UnwrapRef<typeof import('./stores/settings')['DEFAULT_SETTINGS']>
393394
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
394395
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
396+
readonly allLevels: UnwrapRef<typeof import('./composable/logContext')['allLevels']>
395397
readonly arrayEquals: UnwrapRef<typeof import('./utils/index')['arrayEquals']>
396398
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
397399
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>

Diff for: ‎assets/components.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ declare module 'vue' {
6565
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
6666
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
6767
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
68+
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
6869
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
6970
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
7071
'Mdi:key': typeof import('~icons/mdi/key')['default']

Diff for: ‎assets/components/ContainerViewer/ContainerActionsToolbar.vue

+75-40
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<div class="dropdown dropdown-end dropdown-hover">
3-
<label tabindex="0" class="btn btn-ghost btn-sm gap-0.5 px-2">
3+
<label tabindex="0" class="btn btn-ghost btn-sm w-10 gap-0.5 px-2">
44
<carbon:circle-solid class="w-2.5 text-red" v-if="streamConfig.stderr" />
55
<carbon:circle-solid class="w-2.5 text-blue" v-if="streamConfig.stdout" />
66
</label>
@@ -28,46 +28,73 @@
2828
</li>
2929
<li class="line"></li>
3030
<li>
31-
<a
32-
@click="
33-
streamConfig.stdout = true;
34-
streamConfig.stderr = true;
35-
"
36-
>
37-
<div class="flex size-4 gap-0.5">
38-
<template v-if="streamConfig.stderr && streamConfig.stdout">
39-
<carbon:circle-solid class="w-2 text-red" />
40-
<carbon:circle-solid class="w-2 text-blue" />
41-
</template>
42-
</div>
43-
{{ $t("toolbar.show-all") }}
44-
</a>
31+
<details>
32+
<summary>
33+
<div class="flex w-4">
34+
<carbon:circle-solid class="w-2.5 text-red" v-if="streamConfig.stderr" />
35+
<carbon:circle-solid class="w-2.5 text-blue" v-if="streamConfig.stdout" />
36+
</div>
37+
Streams
38+
</summary>
39+
<ul class="menu">
40+
<li>
41+
<a
42+
@click="
43+
streamConfig.stdout = true;
44+
streamConfig.stderr = true;
45+
"
46+
>
47+
<mdi:check class="w-4" v-if="streamConfig.stdout == true && streamConfig.stderr == true" />
48+
<div v-else class="w-4"></div>
49+
{{ $t("toolbar.show-all") }}
50+
</a>
51+
</li>
52+
<li>
53+
<a
54+
@click="
55+
streamConfig.stdout = true;
56+
streamConfig.stderr = false;
57+
"
58+
>
59+
<mdi:check class="w-4" v-if="streamConfig.stdout == true && streamConfig.stderr == false" />
60+
<div v-else class="w-4"></div>
61+
{{ $t("toolbar.show", { std: "STDOUT" }) }}
62+
</a>
63+
</li>
64+
<li>
65+
<a
66+
@click="
67+
streamConfig.stdout = false;
68+
streamConfig.stderr = true;
69+
"
70+
>
71+
<mdi:check class="w-4" v-if="streamConfig.stdout == false && streamConfig.stderr == true" />
72+
<div v-else class="w-4"></div>
73+
{{ $t("toolbar.show", { std: "STDERR" }) }}
74+
</a>
75+
</li>
76+
</ul>
77+
</details>
4578
</li>
4679
<li>
47-
<a
48-
@click="
49-
streamConfig.stdout = true;
50-
streamConfig.stderr = false;
51-
"
52-
>
53-
<div class="flex size-4 flex-col gap-1">
54-
<carbon:circle-solid class="w-2 text-blue" v-if="!streamConfig.stderr && streamConfig.stdout" />
55-
</div>
56-
{{ $t("toolbar.show", { std: "STDOUT" }) }}
57-
</a>
58-
</li>
59-
<li>
60-
<a
61-
@click="
62-
streamConfig.stdout = false;
63-
streamConfig.stderr = true;
64-
"
65-
>
66-
<div class="flex size-4 flex-col gap-1">
67-
<carbon:circle-solid class="w-2 text-red" v-if="streamConfig.stderr && !streamConfig.stdout" />
68-
</div>
69-
{{ $t("toolbar.show", { std: "STDERR" }) }}
70-
</a>
80+
<details>
81+
<summary>
82+
<mdi:gauge />
83+
Levels
84+
</summary>
85+
<ul class="menu">
86+
<li v-for="level in allLevels">
87+
<a class="capitalize" @click="levels.has(level) ? levels.delete(level) : levels.add(level)">
88+
<mdi:check class="w-4" v-if="levels.has(level)" />
89+
<div v-else class="w-4"></div>
90+
91+
<div class="flex">
92+
<div class="badge" :data-level="level">{{ level }}</div>
93+
</div>
94+
</a>
95+
</li>
96+
</ul>
97+
</details>
7198
</li>
7299

73100
<!-- Container Actions (Enabled via config) -->
@@ -108,11 +135,12 @@
108135

109136
<script lang="ts" setup>
110137
import { Container } from "@/models/Container";
138+
import { allLevels } from "@/composable/logContext";
111139
import LogAnalytics from "../LogViewer/LogAnalytics.vue";
112140
113141
const { showSearch } = useSearchFilter();
114142
const { enableActions } = config;
115-
const { streamConfig, hasComplexLogs } = useLoggingContext();
143+
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
116144
const showDrawer = useDrawer();
117145
118146
const { container } = defineProps<{ container: Container }>();
@@ -151,4 +179,11 @@ li.line {
151179
a {
152180
@apply whitespace-nowrap;
153181
}
182+
183+
.menu li ul {
184+
margin-inline-start: 0;
185+
&:before {
186+
display: none;
187+
}
188+
}
154189
</style>

Diff for: ‎assets/components/LogViewer/EventSource.spec.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createRouter, createWebHistory } from "vue-router";
1111
import { default as Component } from "./EventSource.vue";
1212
import LogViewer from "@/components/LogViewer/LogViewer.vue";
1313
import { Container } from "@/models/Container";
14+
import { Level } from "@/models/LogEntry";
1415

1516
vi.mock("@/stores/config", () => ({
1617
__esModule: true,
@@ -82,6 +83,7 @@ describe("<ContainerEventSource />", () => {
8283
containers: computed(() => [{ id: "abc", image: "test:v123", host: "localhost" }]),
8384
streamConfig: reactive({ stdout: true, stderr: true }),
8485
hasComplexLogs: ref(false),
86+
levels: new Set<Level>(["info"]),
8587
},
8688
},
8789
},
@@ -97,7 +99,7 @@ describe("<ContainerEventSource />", () => {
9799
});
98100
}
99101

100-
const sourceUrl = "/api/hosts/localhost/containers/abc/logs/stream?stdout=1&stderr=1";
102+
const sourceUrl = "/api/hosts/localhost/containers/abc/logs/stream?stdout=1&stderr=1&levels=info";
101103

102104
test("renders loading correctly", async () => {
103105
const wrapper = createLogEventSource();

Diff for: ‎assets/composable/eventStreams.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
104104
buffer.value = [];
105105
}
106106

107-
const { streamConfig, hasComplexLogs } = useLoggingContext();
107+
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
108108

109109
const params = computed(() => {
110110
const params = Object.entries(toValue(streamConfig))
@@ -115,6 +115,8 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
115115
params["filter"] = debouncedSearchFilter.value;
116116
}
117117

118+
params["levels"] = [...levels.value].join(",");
119+
118120
return params;
119121
});
120122

@@ -194,7 +196,11 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
194196

195197
onScopeDispose(() => close());
196198

197-
watch(messages, () => (hasComplexLogs.value = messages.value.some((m) => m instanceof ComplexLogEntry)));
199+
watch(messages, () => {
200+
if (messages.value.length > 1) {
201+
hasComplexLogs.value = messages.value.some((m) => m instanceof ComplexLogEntry);
202+
}
203+
});
198204

199205
return { messages, loadOlderLogs, isLoadingMore, hasComplexLogs };
200206
}

Diff for: ‎assets/composable/logContext.ts

+16
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import { Container } from "@/models/Container";
2+
import { Level } from "@/models/LogEntry";
23

34
type LogContext = {
45
streamConfig: { stdout: boolean; stderr: boolean };
56
containers: Container[];
67
loadingMore: boolean;
78
hasComplexLogs: boolean;
9+
levels: Set<Level>;
810
};
911

12+
export const allLevels: Level[] = [
13+
"info",
14+
"debug",
15+
"warn",
16+
"error",
17+
"fatal",
18+
"trace",
19+
"warning",
20+
"critical",
21+
"unknown",
22+
];
23+
1024
// export for testing
1125
export const loggingContextKey = Symbol("loggingContext") as InjectionKey<LogContext>;
1226
const searchParams = new URLSearchParams(window.location.search);
@@ -21,6 +35,7 @@ export const provideLoggingContext = (containers: Ref<Container[]>) => {
2135
containers,
2236
loadingMore: false,
2337
hasComplexLogs: false,
38+
levels: new Set<Level>(allLevels),
2439
}),
2540
);
2641
};
@@ -33,6 +48,7 @@ export const useLoggingContext = () => {
3348
containers: [],
3449
loadingMore: false,
3550
hasComplexLogs: false,
51+
levels: new Set<Level>(allLevels),
3652
}),
3753
);
3854

Diff for: ‎assets/models/LogEntry.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@ export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue
99
export type JSONObject = { [x: string]: JSONValue };
1010
export type Position = "start" | "end" | "middle" | undefined;
1111
export type Std = "stdout" | "stderr";
12-
export type Level = "debug" | "info" | "warn" | "error" | "fatal" | "trace" | "unknown";
12+
export type Level =
13+
| "error"
14+
| "warn"
15+
| "warning"
16+
| "info"
17+
| "debug"
18+
| "trace"
19+
| "severe"
20+
| "critical"
21+
| "fatal"
22+
| "unknown";
1323
export interface LogEvent {
1424
readonly m: string | JSONObject;
1525
readonly ts: number;

Diff for: ‎internal/docker/level_guesser.go

+16-7
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
orderedmap "github.com/wk8/go-ordered-map/v2"
99
)
1010

11-
var keyValueRegex = regexp.MustCompile(`level=(\w+)`)
11+
var SupportedLogLevels map[string]struct{}
12+
13+
// Changing this also needs to change the logContext.ts file
1214
var logLevels = []string{"error", "warn", "warning", "info", "debug", "trace", "severe", "critical", "fatal"}
1315
var plainLevels = map[string]*regexp.Regexp{}
1416
var bracketLevels = map[string]*regexp.Regexp{}
@@ -21,33 +23,40 @@ func init() {
2123
for _, level := range logLevels {
2224
bracketLevels[level] = regexp.MustCompile("(?i)\\[ ?" + level + " ?\\]")
2325
}
26+
27+
SupportedLogLevels = make(map[string]struct{}, len(logLevels)+1)
28+
for _, level := range logLevels {
29+
SupportedLogLevels[level] = struct{}{}
30+
}
31+
SupportedLogLevels["unknown"] = struct{}{}
2432
}
2533

2634
func guessLogLevel(logEvent *LogEvent) string {
2735
switch value := logEvent.Message.(type) {
2836
case string:
2937
value = stripANSI(value)
3038
for _, level := range logLevels {
39+
// Look for the level at the beginning of the message
3140
if plainLevels[level].MatchString(value) {
3241
return level
3342
}
3443

44+
// Look for the level in brackets
3545
if bracketLevels[level].MatchString(value) {
3646
return level
3747
}
3848

49+
// Look for the level in the middle of the message that are uppercase
3950
if strings.Contains(value, " "+strings.ToUpper(level)+" ") {
4051
return level
4152
}
4253
}
4354

44-
if matches := keyValueRegex.FindStringSubmatch(value); matches != nil {
45-
return matches[1]
46-
}
55+
return "unknown"
4756

4857
case *orderedmap.OrderedMap[string, any]:
4958
if value == nil {
50-
return ""
59+
return "unknown"
5160
}
5261
if level, ok := value.Get("level"); ok {
5362
if level, ok := level.(string); ok {
@@ -61,7 +70,7 @@ func guessLogLevel(logEvent *LogEvent) string {
6170

6271
case *orderedmap.OrderedMap[string, string]:
6372
if value == nil {
64-
return ""
73+
return "unknown"
6574
}
6675
if level, ok := value.Get("level"); ok {
6776
return strings.ToLower(level)
@@ -79,5 +88,5 @@ func guessLogLevel(logEvent *LogEvent) string {
7988
log.Debug().Type("type", value).Msg("unknown logEvent type")
8089
}
8190

82-
return ""
91+
return "unknown"
8392
}

Diff for: ‎internal/docker/level_guesser_test.go

+3-4
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,14 @@ func TestGuessLogLevel(t *testing.T) {
2020
{"debug Something happened", "debug"},
2121
{"TRACE: Something happened", "trace"},
2222
{"FATAL: Something happened", "fatal"},
23-
{"level=error Something went wrong", "error"},
2423
{"[ERROR] Something went wrong", "error"},
2524
{"[error] Something went wrong", "error"},
2625
{"[ ERROR ] Something went wrong", "error"},
2726
{"[error] Something went wrong", "error"},
2827
{"[test] [error] Something went wrong", "error"},
2928
{"[foo] [ ERROR] Something went wrong", "error"},
3029
{"123 ERROR Something went wrong", "error"},
31-
{"123 Something went wrong", ""},
30+
{"123 Something went wrong", "unknown"},
3231
{orderedmap.New[string, string](
3332
orderedmap.WithInitialData(
3433
orderedmap.Pair[string, string]{Key: "key", Value: "value"},
@@ -53,8 +52,8 @@ func TestGuessLogLevel(t *testing.T) {
5352
orderedmap.Pair[string, any]{Key: "severity", Value: "info"},
5453
),
5554
), "info"},
56-
{nilOrderedMap, ""},
57-
{nil, ""},
55+
{nilOrderedMap, "unknown"},
56+
{nil, "unknown"},
5857
}
5958

6059
for _, test := range tests {

Diff for: ‎internal/web/logs.go

+44-14
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
141141
}
142142
}
143143

144+
levels := make(map[string]struct{})
145+
if r.URL.Query().Has("levels") {
146+
for _, level := range strings.Split(r.URL.Query().Get("levels"), ",") {
147+
levels[level] = struct{}{}
148+
}
149+
} else {
150+
levels = docker.SupportedLogLevels
151+
}
152+
144153
encoder := json.NewEncoder(w)
145154
for {
146155
if buffer.Len() > minimum {
@@ -157,25 +166,30 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
157166
}
158167

159168
for event := range events {
160-
if regex != nil {
161-
if search.Search(regex, event) {
162-
buffer.Push(event)
169+
if everything {
170+
if err := encoder.Encode(event); err != nil {
171+
log.Error().Err(err).Msg("error encoding log event")
163172
}
164-
} else {
165-
if onlyComplex {
166-
if _, ok := event.Message.(string); ok {
167-
continue
168-
}
173+
continue
174+
}
175+
176+
if onlyComplex {
177+
if _, ok := event.Message.(string); ok {
178+
continue
169179
}
180+
}
170181

171-
if everything {
172-
if err := encoder.Encode(event); err != nil {
173-
log.Error().Err(err).Msg("error encoding log event")
174-
}
175-
} else {
176-
buffer.Push(event)
182+
if regex != nil {
183+
if !search.Search(regex, event) {
184+
continue
177185
}
178186
}
187+
188+
if _, ok := levels[event.Level]; !ok {
189+
continue
190+
}
191+
192+
buffer.Push(event)
179193
}
180194

181195
if everything || from.Before(containerService.Container.Created) {
@@ -272,6 +286,15 @@ func streamLogsForContainers(w http.ResponseWriter, r *http.Request, multiHostCl
272286
events := make(chan *docker.ContainerEvent, 1)
273287
backfill := make(chan []*docker.LogEvent)
274288

289+
levels := make(map[string]struct{})
290+
if r.URL.Query().Has("levels") {
291+
for _, level := range strings.Split(r.URL.Query().Get("levels"), ",") {
292+
levels[level] = struct{}{}
293+
}
294+
} else {
295+
levels = docker.SupportedLogLevels
296+
}
297+
275298
if r.URL.Query().Has("filter") {
276299
var err error
277300
regex, err = search.ParseRegex(r.URL.Query().Get("filter"))
@@ -309,6 +332,9 @@ func streamLogsForContainers(w http.ResponseWriter, r *http.Request, multiHostCl
309332
}
310333

311334
for log := range logs {
335+
if _, ok := levels[log.Level]; !ok {
336+
continue
337+
}
312338
if search.Search(regex, log) {
313339
events = append(events, log)
314340
}
@@ -369,6 +395,10 @@ loop:
369395
continue
370396
}
371397
}
398+
399+
if _, ok := levels[logEvent.Level]; !ok {
400+
continue
401+
}
372402
sseWriter.Message(logEvent)
373403
case container := <-newContainers:
374404
events <- &docker.ContainerEvent{ActorID: container.ID, Name: "container-started", Host: container.Host}

Diff for: ‎locales/en.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ toolbar:
22
clear: Clear
33
download: Download
44
search: Search
5-
show: Show only {std}
6-
show-all: Show all streams
5+
show: Show {std}
6+
show-all: Show all
77
stop: Stop
88
start: Start
99
restart: Restart

0 commit comments

Comments
 (0)
Please sign in to comment.