Skip to content

Commit

Permalink
Log UI: select multiple areas (#15338)
Browse files Browse the repository at this point in the history
  • Loading branch information
naltatis committed Aug 12, 2024
1 parent 9fa15d9 commit d4ecb4b
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 28 deletions.
16 changes: 16 additions & 0 deletions assets/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,27 @@ const { protocol, hostname, port, pathname } = window.location;

const base = protocol + "//" + hostname + (port ? ":" + port : "") + pathname;

// override the way axios serializes arrays in query parameters (a=1&a=2&a=3 instead of a[]=1&a[]=2&a[]=3)
function customParamsSerializer(params) {
const queryString = Object.keys(params)
.filter((key) => params[key] !== null)
.map((key) => {
const value = params[key];
if (Array.isArray(value)) {
return value.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`).join("&");
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join("&");
return queryString;
}

const api = axios.create({
baseURL: base + "api/",
headers: {
Accept: "application/json",
},
paramsSerializer: customParamsSerializer,
});

// global error handling
Expand Down
126 changes: 126 additions & 0 deletions assets/js/components/MultiSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<template>
<div>
<button
class="form-select text-start text-nowrap"
type="button"
:id="id"
data-bs-toggle="dropdown"
aria-expanded="false"
data-bs-auto-close="outside"
>
<slot></slot>
</button>
<ul class="dropdown-menu dropdown-menu-end" ref="dropdown" :aria-labelledby="id">
<template v-if="selectAllLabel">
<li class="dropdown-item p-0">
<label class="form-check px-3 py-2">
<input
class="form-check-input ms-0 me-2"
type="checkbox"
value="all"
@change="toggleCheckAll()"
:checked="allOptionsSelected"
/>
<div class="form-check-label">{{ selectAllLabel }}</div>
</label>
</li>
<li><hr class="dropdown-divider" /></li>
</template>
<li v-for="option in options" :key="option.value" class="dropdown-item p-0">
<label class="form-check px-3 py-2" :for="option.value">
<input
class="form-check-input ms-0 me-2"
type="checkbox"
:id="option.value"
:value="option.value"
v-model="internalValue"
/>
<div class="form-check-label">
{{ option.name }}
</div>
</label>
</li>
</ul>
</div>
</template>

<script>
import Dropdown from "bootstrap/js/dist/dropdown";
export default {
name: "MultiSelect",
props: {
id: String,
value: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
selectAllLabel: String,
},
emits: ["open", "update:modelValue"],
data() {
return {
internalValue: [...this.value],
};
},
mounted() {
this.$refs.dropdown.addEventListener("show.bs.dropdown", this.open);
},
unmounted() {
this.$refs.dropdown?.removeEventListener("show.bs.dropdown", this.open);
},
computed: {
allOptionsSelected() {
return this.internalValue.length === this.options.length;
},
noneSelected() {
return this.internalValue.length === 0;
},
},
watch: {
options: {
immediate: true,
handler(newOptions) {
// If value is empty, set internalValue to include all options
if (this.value.length === 0) {
this.internalValue = newOptions.map((option) => option.value);
} else {
// Otherwise, keep selected options that still exist in the new options
this.internalValue = this.internalValue.filter((value) =>
newOptions.some((option) => option.value === value)
);
}
this.$nextTick(() => {
Dropdown.getOrCreateInstance(this.$refs.dropdown).update();
});
},
},
value: {
immediate: true,
handler(newValue) {
this.internalValue =
newValue.length === 0 && this.options.length > 0
? this.options.map((o) => o.value)
: [...newValue];
},
},
internalValue(newValue) {
if (this.allOptionsSelected || this.noneSelected) {
this.$emit("update:modelValue", []);
} else {
this.$emit("update:modelValue", newValue);
}
},
},
methods: {
open() {
this.$emit("open");
},
toggleCheckAll() {
if (this.allOptionsSelected) {
this.internalValue = [];
} else {
this.internalValue = this.options.map((option) => option.value);
}
},
},
};
</script>
7 changes: 7 additions & 0 deletions assets/js/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export default function setupRouter(i18n) {
path: "/log",
component: () => import("./views/Log.vue"),
beforeEnter: ensureAuth,
props: (route) => {
const { areas, level } = route.query;
return {
areas: areas ? areas.split(",") : undefined,
level,
};
},
},
],
});
Expand Down
93 changes: 65 additions & 28 deletions assets/js/views/Log.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,29 +47,26 @@
<div class="filterLevel col-6 col-lg-2">
<select
class="form-select"
v-model="level"
:aria-label="$t('log.levelLabel')"
@change="updateLogs()"
:value="level"
@input="changeLevel"
>
<option v-for="level in levels" :key="level" :value="level">
{{ level }}
<option v-for="l in levels" :key="l" :value="l">
{{ l.toUpperCase() }}
</option>
</select>
</div>
<div class="filterAreas col-6 col-lg-2">
<select
class="form-select"
v-model="area"
:aria-label="$t('log.areaLabel')"
@focus="updateAreas()"
@change="updateLogs()"
<MultiSelect
id="logAreasSelect"
:modelValue="areas"
:options="areaOptions"
:selectAllLabel="$t('log.selectAll')"
@update:modelValue="changeAreas"
@open="updateAreas()"
>
<option value="">{{ $t("log.areas") }}</option>
<hr />
<option v-for="area in areas" :key="area" :value="area">
{{ area }}
</option>
</select>
{{ areasLabel }}
</MultiSelect>
</div>
</div>
<hr class="my-0" />
Expand Down Expand Up @@ -112,29 +109,33 @@ import "@h2d2/shopicons/es/regular/download";
import TopHeader from "../components/TopHeader.vue";
import Play from "../components/MaterialIcon/Play.vue";
import Record from "../components/MaterialIcon/Record.vue";
import MultiSelect from "../components/MultiSelect.vue";
import api from "../api";
import store from "../store";
const LEVELS = ["FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"];
const DEFAULT_LEVEL = "DEBUG";
const LEVELS = ["fatal", "error", "warn", "info", "debug", "trace"];
const DEFAULT_LEVEL = "debug";
const DEFAULT_COUNT = 1000;
const levelMatcher = new RegExp(`\\[.*?\\] (${LEVELS.join("|")})`);
const levelMatcher = new RegExp(`\\[.*?\\] (${LEVELS.map((l) => l.toUpperCase()).join("|")})`);
export default {
name: "Log",
components: {
TopHeader,
Play,
Record,
MultiSelect,
},
props: {
areas: { type: Array, default: () => [] },
level: { type: String, default: DEFAULT_LEVEL },
},
data() {
return {
lines: [],
areas: [],
availableAreas: [],
search: "",
level: DEFAULT_LEVEL,
area: "",
timeout: null,
levels: LEVELS,
busy: false,
Expand Down Expand Up @@ -168,6 +169,18 @@ export default {
return { key, className, line };
});
},
areaOptions() {
return this.availableAreas.map((area) => ({ name: area, value: area }));
},
areasLabel() {
if (this.areas.length === 0) {
return this.$t("log.areas");
} else if (this.areas.length === 1) {
return this.areas[0];
} else {
return this.$t("log.nAreas", { count: this.areas.length });
}
},
showMoreButton() {
return this.lines.length === DEFAULT_COUNT;
},
Expand All @@ -179,16 +192,24 @@ export default {
if (this.level) {
params.append("level", this.level);
}
if (this.area) {
params.append("area", this.area);
}
this.areas.forEach((area) => {
params.append("area", area);
});
params.append("format", "txt");
return `./api/system/log?${params.toString()}`;
},
autoFollow() {
return this.timeout !== null;
},
},
watch: {
selectedAreas() {
this.updateLogs();
},
level() {
this.updateLogs();
},
},
methods: {
async updateLogs(showAll) {
// prevent concurrent requests
Expand All @@ -198,8 +219,8 @@ export default {
this.busy = true;
const response = await api.get("/system/log", {
params: {
level: this.level?.toLocaleLowerCase() || null,
area: this.area || null,
level: this.level || null,
area: this.areas.length ? this.areas : null,
count: showAll ? null : DEFAULT_COUNT,
},
});
Expand Down Expand Up @@ -232,7 +253,7 @@ export default {
async updateAreas() {
try {
const response = await api.get("/system/log/areas");
this.areas = response.data?.result || [];
this.availableAreas = response.data?.result || [];
} catch (e) {
console.error(e);
}
Expand Down Expand Up @@ -260,6 +281,22 @@ export default {
this.startInterval();
}
},
updateQuery({ level, areas }) {
let newLevel = level || this.level;
let newAreas = areas || this.areas;
// reset to default level
if (newLevel === DEFAULT_LEVEL) newLevel = undefined;
newAreas = newAreas.length ? newAreas.join(",") : undefined;
this.$router.push({
query: { level: newLevel, areas: newAreas },
});
},
changeLevel(event) {
this.updateQuery({ level: event.target.value });
},
changeAreas(areas) {
this.updateQuery({ areas });
},
},
};
</script>
Expand Down
2 changes: 2 additions & 0 deletions i18n/de.toml
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,10 @@ areaLabel = "Nach Bereich filtern"
areas = "Alle Bereiche"
download = "Komplettes Log herunterladen"
levelLabel = "Nach Log-Level filtern"
nAreas = "{count} Bereiche"
noResults = "Keine passenden Einträge gefunden."
search = "Suchen"
selectAll = "alle wählen"
showAll = "Alle Einträge anzeigen"
title = "Logs"
update = "Aktualisieren"
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,10 @@ areaLabel = "Filter by area"
areas = "All areas"
download = "Download complete log"
levelLabel = "Filter by log level"
nAreas = "{count} areas"
noResults = "No matching log entries."
search = "Search"
selectAll = "select all"
showAll = "Show all entries"
title = "Logs"
update = "Auto update"
Expand Down

0 comments on commit d4ecb4b

Please sign in to comment.