Log UI: select multiple areas (#15338)
This commit is contained in:
parent
9fa15d9a7e
commit
d4ecb4b2db
6 changed files with 218 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
126
assets/js/components/MultiSelect.vue
Normal file
126
assets/js/components/MultiSelect.vue
Normal file
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
@ -112,14 +109,15 @@ 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",
|
||||
|
|
@ -127,14 +125,17 @@ export default {
|
|||
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,
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
@ -179,9 +192,9 @@ 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()}`;
|
||||
},
|
||||
|
|
@ -189,6 +202,14 @@ export default {
|
|||
return this.timeout !== null;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedAreas() {
|
||||
this.updateLogs();
|
||||
},
|
||||
level() {
|
||||
this.updateLogs();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async updateLogs(showAll) {
|
||||
// prevent concurrent requests
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue