369 lines
8.4 KiB
Vue
369 lines
8.4 KiB
Vue
<template>
|
|
<div class="root safe-area-inset">
|
|
<div class="container d-flex h-100 flex-column px-0 pb-4">
|
|
<TopHeader :title="$t('log.title')" class="mx-4" />
|
|
<div class="logs d-flex flex-column overflow-hidden flex-grow-1 px-4 mx-2 mx-sm-4">
|
|
<div class="flex-grow-0 row py-4">
|
|
<div class="col-6 col-lg-3 mb-4 mb-lg-0 d-flex gap-2">
|
|
<div class="btn-group w-100 w-lg-auto d-flex">
|
|
<button
|
|
type="button"
|
|
class="btn text-nowrap d-flex gap-2 flex-grow-1 text-nowrap text-truncate"
|
|
:class="autoFollow ? 'btn-secondary' : 'btn-outline-secondary'"
|
|
@click="toggleAutoFollow"
|
|
>
|
|
<span class="text-nowrap text-truncate">
|
|
{{ $t("log.update") }}
|
|
</span>
|
|
<Record
|
|
v-if="autoFollow"
|
|
ref="spin"
|
|
class="spin flex-shrink-0"
|
|
:style="{ animationDuration: `${updateInterval}ms` }"
|
|
/>
|
|
<Play v-else class="flex-shrink-0 play" />
|
|
</button>
|
|
<a
|
|
class="btn btn-outline-secondary flex-grow-0"
|
|
:aria-label="$t('log.download')"
|
|
:href="downloadUrl"
|
|
download
|
|
>
|
|
<shopicon-regular-download
|
|
size="s"
|
|
class="icon"
|
|
></shopicon-regular-download>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 offset-lg-1 col-lg-4 mb-4 mb-lg-0">
|
|
<input
|
|
type="search"
|
|
class="form-control search"
|
|
:placeholder="$t('log.search')"
|
|
v-model="search"
|
|
data-testid="log-search"
|
|
/>
|
|
</div>
|
|
<div class="filterLevel col-6 col-lg-2">
|
|
<select
|
|
class="form-select"
|
|
:aria-label="$t('log.levelLabel')"
|
|
:value="level"
|
|
@input="changeLevel"
|
|
>
|
|
<option v-for="l in levels" :key="l" :value="l">
|
|
{{ l.toUpperCase() }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="filterAreas col-6 col-lg-2">
|
|
<MultiSelect
|
|
id="logAreasSelect"
|
|
:modelValue="areas"
|
|
:options="areaOptions"
|
|
:selectAllLabel="$t('log.selectAll')"
|
|
@update:modelValue="changeAreas"
|
|
@open="updateAreas()"
|
|
>
|
|
{{ areasLabel }}
|
|
</MultiSelect>
|
|
</div>
|
|
</div>
|
|
<hr class="my-0" />
|
|
<div
|
|
class="overflow-y-scroll pt-2 pb-4 flex-grow-1 d-flex flex-column"
|
|
ref="log"
|
|
@scroll="onScroll"
|
|
>
|
|
<div v-if="showMoreButton" class="my-2">
|
|
<button
|
|
class="btn btn-link btn-sm evcc-default-text px-0"
|
|
type="button"
|
|
@click="updateLogs(true)"
|
|
>
|
|
{{ $t("log.showAll") }}
|
|
</button>
|
|
</div>
|
|
<code
|
|
v-if="filteredLines.length"
|
|
class="d-block evcc-default-text flex-grow-1"
|
|
data-testid="log-content"
|
|
>
|
|
<div
|
|
v-for="{ line, className, key } in lineEntries"
|
|
:key="key"
|
|
:class="className"
|
|
>
|
|
{{ line }}
|
|
</div>
|
|
</code>
|
|
<p v-else class="my-4">{{ $t("log.noResults") }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
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 DEFAULT_COUNT = 1000;
|
|
|
|
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: [],
|
|
availableAreas: [],
|
|
search: "",
|
|
timeout: null,
|
|
levels: LEVELS,
|
|
busy: false,
|
|
};
|
|
},
|
|
mounted() {
|
|
this.startInterval();
|
|
this.updateAreas();
|
|
},
|
|
unmounted() {
|
|
this.stopInterval();
|
|
},
|
|
computed: {
|
|
filteredLines() {
|
|
return this.lines.filter(
|
|
(line) =>
|
|
!this.search || line.toLowerCase().includes(this.search.toLocaleLowerCase())
|
|
);
|
|
},
|
|
lineEntries() {
|
|
const occurrences = new Map();
|
|
return this.filteredLines.map((line) => {
|
|
// generate a unique key per line for performant dom updates
|
|
let key = line.substring(0, 50);
|
|
const count = occurrences.get(key) || 0;
|
|
occurrences.set(key, count + 1);
|
|
key = `${key}-${count + 1}`;
|
|
|
|
const className = `log log-${levelMatcher.exec(line)?.[1].toLowerCase() || "none"}`;
|
|
|
|
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;
|
|
},
|
|
updateInterval() {
|
|
return (store.state?.interval || 10) * 1000;
|
|
},
|
|
downloadUrl() {
|
|
const params = new URLSearchParams();
|
|
if (this.level) {
|
|
params.append("level", this.level);
|
|
}
|
|
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
|
|
if (this.busy) return;
|
|
|
|
try {
|
|
this.busy = true;
|
|
const response = await api.get("/system/log", {
|
|
params: {
|
|
level: this.level || null,
|
|
area: this.areas.length ? this.areas : null,
|
|
count: showAll ? null : DEFAULT_COUNT,
|
|
},
|
|
});
|
|
this.lines = response.data?.result || [];
|
|
this.$nextTick(() => {
|
|
if (showAll) {
|
|
this.scrollToTop();
|
|
} else {
|
|
this.scrollToBottom();
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
this.busy = false;
|
|
},
|
|
startInterval() {
|
|
this.stopInterval();
|
|
this.updateLogs();
|
|
this.timeout = setInterval(() => {
|
|
this.updateLogs();
|
|
}, this.updateInterval);
|
|
},
|
|
stopInterval() {
|
|
if (this.timeout) {
|
|
clearTimeout(this.timeout);
|
|
this.timeout = null;
|
|
}
|
|
},
|
|
async updateAreas() {
|
|
try {
|
|
const response = await api.get("/system/log/areas");
|
|
this.availableAreas = response.data?.result || [];
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
},
|
|
onScroll(e) {
|
|
// disable follow when not at the bottom
|
|
if (
|
|
this.autoFollow &&
|
|
e.target.scrollTop + e.target.clientHeight < e.target.scrollHeight
|
|
) {
|
|
this.stopInterval();
|
|
}
|
|
},
|
|
scrollToTop() {
|
|
this.$refs.log.scrollTop = 0;
|
|
},
|
|
scrollToBottom() {
|
|
this.$refs.log.scrollTop = this.$refs.log.scrollHeight;
|
|
},
|
|
toggleAutoFollow() {
|
|
if (this.autoFollow) {
|
|
this.stopInterval();
|
|
} else {
|
|
this.scrollToBottom();
|
|
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>
|
|
<style scoped>
|
|
.logs {
|
|
border-radius: 2rem;
|
|
background: var(--evcc-box);
|
|
}
|
|
.root {
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
}
|
|
.btn {
|
|
--bs-btn-border-width: 1px;
|
|
}
|
|
.play {
|
|
transform: scale(1.2);
|
|
}
|
|
.spin {
|
|
animation: rotation 3s infinite ease-in-out;
|
|
}
|
|
@keyframes rotation {
|
|
from {
|
|
transform: rotate(0deg) scale(1, -1);
|
|
}
|
|
to {
|
|
transform: rotate(1440deg) scale(1, -1);
|
|
}
|
|
}
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0.3;
|
|
}
|
|
to {
|
|
opacity: var(--opacity);
|
|
}
|
|
}
|
|
.log {
|
|
--opacity: 1;
|
|
opacity: var(--opacity);
|
|
animation-name: fadeIn;
|
|
animation-duration: 1s;
|
|
animation-fill-mode: forwards;
|
|
animation-timing-function: ease-out;
|
|
text-indent: 1rem hanging;
|
|
/* smaller exception for mobile */
|
|
font-size: 8px;
|
|
}
|
|
@media (min-width: 576px) {
|
|
.log {
|
|
/* default code size */
|
|
font-size: 0.875em;
|
|
}
|
|
}
|
|
|
|
.log-warn {
|
|
color: var(--bs-warning);
|
|
}
|
|
.log-error,
|
|
.log-fatal {
|
|
color: var(--bs-danger);
|
|
}
|
|
.log-debug {
|
|
--opacity: 0.6;
|
|
}
|
|
.log-trace {
|
|
--opacity: 0.4;
|
|
}
|
|
</style>
|