evcc-io/assets/js/views/Log.vue

372 lines
8.9 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
v-model="search"
type="search"
class="form-control search"
:placeholder="$t('log.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:model-value="changeAreas"
@open="updateAreas()"
>
{{ areasLabel }}
</MultiSelect>
</div>
</div>
<hr class="my-0" />
<div
ref="log"
class="overflow-y-scroll pt-2 pb-4 flex-grow-1 d-flex flex-column"
@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 textarea--tiny"
data-testid="log-content"
@copy="onCopy"
>
<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 lang="ts">
import "@h2d2/shopicons/es/regular/download";
import Header from "../components/Top/Header.vue";
import Play from "../components/MaterialIcon/Play.vue";
import Record from "../components/MaterialIcon/Record.vue";
import MultiSelect from "../components/Helper/MultiSelect.vue";
import api from "../api";
import store from "../store";
import { defineComponent, type PropType } from "vue";
import type { Timeout } from "@/types/evcc";
import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log";
const DEFAULT_COUNT = 1000;
const levelMatcher = new RegExp(`\\[.*?\\] (${LOG_LEVELS.map((l) => l.toUpperCase()).join("|")})`);
export default defineComponent({
name: "Log",
components: {
TopHeader: Header,
Play,
Record,
MultiSelect,
},
props: {
areas: { type: Array as PropType<string[]>, default: () => [] },
level: { type: String, default: DEFAULT_LOG_LEVEL },
},
data() {
return {
lines: [] as string[],
availableAreas: [] as string[],
search: "",
timeout: null as Timeout,
levels: LOG_LEVELS,
busy: false,
};
},
head() {
return { title: this.$t("log.title") };
},
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 match = levelMatcher.exec(line)?.[1];
const className = `log log-${match?.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: {
areas() {
this.updateLogs();
},
level() {
this.updateLogs();
},
},
mounted() {
this.startInterval();
this.updateAreas();
},
unmounted() {
this.stopInterval();
},
methods: {
async updateLogs(showAll: boolean = false) {
// 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 || [];
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 || [];
} catch (e) {
console.error(e);
}
},
onScroll(e: Event) {
const t = e.target as HTMLElement;
// disable follow when not at the bottom
if (this.autoFollow && t && t.scrollTop + t.clientHeight < t.scrollHeight) {
this.stopInterval();
}
},
scrollToTop() {
const log = this.$refs["log"] as HTMLElement;
log.scrollTop = 0;
},
scrollToBottom() {
const log = this.$refs["log"] as HTMLElement;
log.scrollTop = log.scrollHeight;
},
toggleAutoFollow() {
if (this.autoFollow) {
this.stopInterval();
} else {
this.scrollToBottom();
this.startInterval();
}
},
updateQuery({ level: l, areas: a }: { level?: string; areas?: string[] }) {
const newLevel = l || this.level;
const newAreas = a || this.areas;
// reset to default level
const level = newLevel === DEFAULT_LOG_LEVEL ? undefined : newLevel;
const areas = newAreas.length ? newAreas.join(",") : undefined;
this.$router.push({ query: { level, areas } });
},
changeLevel(event: Event) {
this.updateQuery({ level: (event.target as HTMLSelectElement).value });
},
changeAreas(areas: string[]) {
this.updateQuery({ areas });
},
onCopy(e: Event) {
const selection = window.getSelection()?.toString();
const event = e as ClipboardEvent;
event.clipboardData?.setData("text/plain", "```\n" + selection + "\n```");
event.preventDefault();
},
},
});
</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: var(--transition-duration-fast);
animation-fill-mode: forwards;
animation-timing-function: ease-out;
text-indent: 1rem hanging;
}
.log-warn {
color: var(--bs-warning);
}
.log-error,
.log-fatal {
color: var(--bs-danger);
}
.log-debug {
--opacity: 0.7;
}
.log-trace {
--opacity: 0.5;
}
</style>