Log Viewer: Allow retrieving logs from ring buffer (#13330)
This commit is contained in:
parent
75179b05bb
commit
334ffc15b1
40 changed files with 1232 additions and 358 deletions
|
|
@ -31,6 +31,8 @@
|
|||
--evcc-dark-yellow: #bbb400;
|
||||
--evcc-orange: #ff9000;
|
||||
--evcc-orange-rgb: 255, 144, 0;
|
||||
--evcc-red: #fc440f;
|
||||
--evcc-red-rgb: 252, 68, 15;
|
||||
--bs-gray-deep: #010322;
|
||||
--bs-gray-dark: #28293e;
|
||||
--bs-gray-medium: #93949e;
|
||||
|
|
@ -63,12 +65,16 @@
|
|||
--bs-warning: var(--evcc-orange);
|
||||
--bs-warning-rgb: var(--evcc-orange-rgb);
|
||||
|
||||
--bs-danger: var(--evcc-red);
|
||||
--bs-danger-rgb: var(--evcc-red-rgb);
|
||||
|
||||
--bs-body-font-size: 14px;
|
||||
--bs-font-sans-serif: Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-success: var(--evcc-primary);
|
||||
--bs-success-rgb: var(--bs-primary-rgb);
|
||||
--bs-code-color: var(--evcc-darkest-green);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
|
|
@ -82,6 +88,8 @@
|
|||
--evcc-accent3: var(--evcc-darker-green);
|
||||
--bs-primary: var(--evcc-dark-green);
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.175);
|
||||
--bs-code-color: var(--evcc-dark-green);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
|
|
@ -229,11 +237,20 @@ a:hover {
|
|||
--bs-btn-line-height: 2;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
--bs-btn-color: var(--evcc-default-text);
|
||||
--bs-btn-border-color: var(--evcc-default-text);
|
||||
--bs-btn-hover-bg: transparent;
|
||||
--bs-btn-hover-color: var(--bs-gray-medium);
|
||||
--bs-btn-hover-border-color: var(--bs-gray-medium);
|
||||
}
|
||||
|
||||
.dark .btn-outline-secondary {
|
||||
--bs-btn-color: var(--bs-gray-bright);
|
||||
--bs-btn-border-color: var(--bs-gray-bright);
|
||||
--bs-btn-hover-bg: var(--bs-gray-bright);
|
||||
--bs-btn-hover-color: var(--bs-gray-dark);
|
||||
--bs-btn-hover-bg: transparent;
|
||||
--bs-btn-hover-color: var(--bs-gray-medium);
|
||||
--bs-btn-hover-border-color: var(--bs-gray-medium);
|
||||
}
|
||||
|
||||
.accordion {
|
||||
|
|
@ -298,6 +315,9 @@ a:hover {
|
|||
.modal-xl {
|
||||
--bs-modal-width: 850px;
|
||||
}
|
||||
.w-lg-auto {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop.show {
|
||||
|
|
@ -376,6 +396,10 @@ small {
|
|||
background-color: var(--bs-primary);
|
||||
color: var(--bs-white);
|
||||
}
|
||||
.form-select {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dark .dropdown-menu {
|
||||
box-shadow: 0 0 8px var(--evcc-background);
|
||||
|
|
@ -398,6 +422,7 @@ small {
|
|||
--bs-table-color: var(--evcc-default-text);
|
||||
--bs-table-border-color: var(--evcc-gray);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: var(--evcc-gray) !important;
|
||||
}
|
||||
|
|
@ -438,13 +463,21 @@ input::-webkit-datetime-edit {
|
|||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--evcc-box);
|
||||
color: var(--evcc-default-text);
|
||||
}
|
||||
.form-control:disabled {
|
||||
color: var(--bs-gray-dark);
|
||||
}
|
||||
|
||||
.dark .form-control {
|
||||
background-color: var(--evcc-box);
|
||||
color: var(--evcc-default-text);
|
||||
}
|
||||
|
||||
.dark .form-control::placeholder {
|
||||
color: var(--bs-gray-medium);
|
||||
}
|
||||
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -515,3 +548,16 @@ html.app .modal-dialog {
|
|||
.btn-neutral:hover {
|
||||
outline: revert;
|
||||
}
|
||||
|
||||
.round-box {
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0 0 0 var(--evcc-gray-50);
|
||||
color: var(--evcc-default-text);
|
||||
background: var(--evcc-box);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--evcc-gray-50);
|
||||
transition: box-shadow var(--evcc-transition-fast) linear;
|
||||
}
|
||||
.round-box:hover {
|
||||
border-color: var(--evcc-gray);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Modal from "bootstrap/js/dist/modal";
|
|||
const auth = reactive({
|
||||
configured: true,
|
||||
loggedIn: false,
|
||||
nextUrl: null,
|
||||
});
|
||||
|
||||
export async function updateAuthStatus() {
|
||||
|
|
@ -28,6 +29,7 @@ export async function logout() {
|
|||
try {
|
||||
await api.post("/auth/logout");
|
||||
await updateAuthStatus();
|
||||
auth.nextUrl = null;
|
||||
} catch (e) {
|
||||
console.log("unable to logout", e);
|
||||
}
|
||||
|
|
@ -41,7 +43,14 @@ export function isConfigured() {
|
|||
return auth.configured;
|
||||
}
|
||||
|
||||
export function openLoginModal() {
|
||||
export function getAndClearNextUrl() {
|
||||
const nextUrl = auth.nextUrl;
|
||||
auth.nextUrl = null;
|
||||
return nextUrl;
|
||||
}
|
||||
|
||||
export function openLoginModal(nextUrl = null) {
|
||||
auth.nextUrl = nextUrl;
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("loginModal"));
|
||||
modal.show();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="group pt-4 px-4 pb-1">
|
||||
<div class="group round-box pt-4 px-4 pb-1">
|
||||
<dl class="row" data-testid="generalconfig-title">
|
||||
<dt class="col-sm-4">Title</dt>
|
||||
<dd class="col-sm-8">
|
||||
|
|
@ -14,7 +14,19 @@
|
|||
<TitleModal ref="titleModal" @changed="load" />
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="row">
|
||||
<dl class="row" data-testid="generalconfig-password">
|
||||
<dt class="col-sm-4">Password</dt>
|
||||
<dd class="col-sm-8">
|
||||
*******
|
||||
<a
|
||||
href="#"
|
||||
class="ms-2 d-inline-block text-muted"
|
||||
@click.prevent="openModal('passwordModal')"
|
||||
>edit</a
|
||||
>
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="row" data-testid="generalconfig-telemetry">
|
||||
<dt class="col-sm-4">Telemetry</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ telemetryEnabled ? "on" : "off" }}
|
||||
|
|
@ -27,17 +39,17 @@
|
|||
</a>
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="row wip" data-testid="generalconfig-password">
|
||||
<dt class="col-sm-4">Password</dt>
|
||||
<dl class="row" data-testid="generalconfig-experimental">
|
||||
<dt class="col-sm-4">Experimental</dt>
|
||||
<dd class="col-sm-8">
|
||||
*******
|
||||
<!-- TODO: update this once auth is released -->
|
||||
{{ hiddenFeatures ? "on" : "off" }}
|
||||
<a
|
||||
href="#"
|
||||
class="ms-2 d-inline-block text-muted"
|
||||
@click.prevent="todo() /* openModal('passwordModal')*/"
|
||||
>edit</a
|
||||
@click.prevent="openModal('globalSettingsModal')"
|
||||
>
|
||||
change
|
||||
</a>
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="row wip">
|
||||
|
|
@ -93,6 +105,9 @@ export default {
|
|||
telemetryEnabled() {
|
||||
return settings.telemetry === true;
|
||||
},
|
||||
hiddenFeatures() {
|
||||
return settings.hiddenFeatures === true;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async changed() {
|
||||
|
|
@ -124,29 +139,13 @@ export default {
|
|||
|
||||
<style scoped>
|
||||
.group {
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0 0 0 var(--evcc-gray-50);
|
||||
color: var(--evcc-default-text);
|
||||
background: var(--evcc-box);
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
min-height: 10rem;
|
||||
margin-bottom: 5rem;
|
||||
border: 1px solid var(--evcc-gray-50);
|
||||
transition: box-shadow var(--evcc-transition-fast) linear;
|
||||
}
|
||||
|
||||
.group:hover {
|
||||
border-color: var(--evcc-gray);
|
||||
}
|
||||
|
||||
.group:focus-within {
|
||||
box-shadow: 0 0 1rem 0 var(--evcc-gray-50);
|
||||
}
|
||||
|
||||
.wip {
|
||||
opacity: 0.2;
|
||||
display: none !important;
|
||||
}
|
||||
dt {
|
||||
margin-bottom: 0.5rem;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
class="d-block d-sm-flex justify-content-between align-items-stretch mb-4"
|
||||
>
|
||||
<a
|
||||
href="https://docs.evcc.io/"
|
||||
:href="docsUrl"
|
||||
target="_blank"
|
||||
class="btn btn-outline-primary w-100 w-sm-auto flex-grow-1 mb-3 mb-sm-0 me-sm-3"
|
||||
type="button"
|
||||
|
|
@ -46,6 +46,16 @@
|
|||
<p>
|
||||
{{ $t("help.secondaryActions") }}
|
||||
</p>
|
||||
<div
|
||||
class="d-block d-sm-flex justify-content-between align-items-baseline mb-3"
|
||||
>
|
||||
<p class="flex-sm-grow-1 opacity-50 me-sm-3">
|
||||
{{ $t("help.logsDescription") }}
|
||||
</p>
|
||||
<router-link to="/log" class="btn btn-outline-primary text-nowrap">
|
||||
{{ $t("help.logsButton") }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
class="d-block d-sm-flex justify-content-between align-items-baseline mb-3"
|
||||
>
|
||||
|
|
@ -56,11 +66,11 @@
|
|||
href="https://github.com/evcc-io/evcc/issues"
|
||||
target="_blank"
|
||||
class="btn btn-outline-primary text-nowrap"
|
||||
type="button"
|
||||
>
|
||||
{{ $t("help.issueButton") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-block d-sm-flex justify-content-between align-items-baseline mb-3"
|
||||
>
|
||||
|
|
@ -128,11 +138,17 @@
|
|||
|
||||
<script>
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import { docsPrefix } from "../i18n";
|
||||
import api from "../api";
|
||||
|
||||
export default {
|
||||
name: "HelpModal",
|
||||
props: {},
|
||||
computed: {
|
||||
docsUrl() {
|
||||
return `${docsPrefix()}/`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openHelpModal() {
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("helpModal"));
|
||||
|
|
@ -144,10 +160,7 @@ export default {
|
|||
},
|
||||
async restartConfirmed() {
|
||||
try {
|
||||
await api.post("shutdown");
|
||||
alert(
|
||||
"Server was stopped successfully. Waiting for the operating system to start evcc again."
|
||||
);
|
||||
await api.post("/system/shutdown");
|
||||
} catch (e) {
|
||||
alert("Unabled to restart server.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@
|
|||
@closed="closed"
|
||||
>
|
||||
<form v-if="modalVisible" @submit.prevent="login">
|
||||
<p v-if="error" class="text-danger">{{ $t("loginModal.error") }}{{ error }}</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="loginPassword" class="col-form-label">
|
||||
<div class="w-100">
|
||||
|
|
@ -27,9 +25,25 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-danger my-4">{{ $t("loginModal.error") }}{{ error }}</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="loading">
|
||||
<span
|
||||
v-if="loading"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ $t("loginModal.login") }}
|
||||
</button>
|
||||
<a
|
||||
v-if="resetHint"
|
||||
class="text-muted my-1 d-block text-center"
|
||||
:href="resetUrl"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t("loginModal.reset") }}
|
||||
</a>
|
||||
</form>
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
|
@ -38,7 +52,8 @@
|
|||
import GenericModal from "./GenericModal.vue";
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import api from "../api";
|
||||
import { updateAuthStatus } from "../auth";
|
||||
import { updateAuthStatus, getAndClearNextUrl } from "../auth";
|
||||
import { docsPrefix } from "../i18n";
|
||||
|
||||
export default {
|
||||
name: "LoginModal",
|
||||
|
|
@ -48,9 +63,15 @@ export default {
|
|||
modalVisible: false,
|
||||
password: "",
|
||||
loading: false,
|
||||
resetHint: false,
|
||||
error: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
resetUrl() {
|
||||
return `${docsPrefix()}/docs/faq#password-reset`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.modalVisible = true;
|
||||
|
|
@ -60,6 +81,7 @@ export default {
|
|||
this.password = "";
|
||||
this.loading = false;
|
||||
this.error = "";
|
||||
this.resetHint = false;
|
||||
},
|
||||
focus() {
|
||||
console.log(this.$refs.password);
|
||||
|
|
@ -70,21 +92,24 @@ export default {
|
|||
},
|
||||
async login() {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
|
||||
try {
|
||||
const data = { password: this.password };
|
||||
const res = await api.post("/auth/login", data, {
|
||||
validateStatus: (code) => [200, 401].includes(code),
|
||||
});
|
||||
this.resetHint = false;
|
||||
this.error = "";
|
||||
if (res.status === 200) {
|
||||
this.closeModal();
|
||||
await updateAuthStatus();
|
||||
this.$router.push({ path: "/config" });
|
||||
const target = getAndClearNextUrl();
|
||||
if (target) this.$router.push(target);
|
||||
this.password = "";
|
||||
}
|
||||
if (res.status === 401) {
|
||||
this.error = this.$t("loginModal.invalid");
|
||||
this.resetHint = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
@ -96,3 +121,9 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
form {
|
||||
/* remove body padding due to minimal content */
|
||||
margin-top: -1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
17
assets/js/components/MaterialIcon/Play.vue
Normal file
17
assets/js/components/MaterialIcon/Play.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 17.175V6.825q0-.425.3-.713t.7-.287q.125 0 .263.037t.262.113l8.15 5.175q.225.15.338.375t.112.475t-.112.475t-.338.375l-8.15 5.175q-.125.075-.262.113T9 18.175q-.4 0-.7-.288t-.3-.712m2-1.825L15.25 12L10 8.65z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import icon from "../../mixins/icon";
|
||||
|
||||
export default {
|
||||
name: "Play",
|
||||
mixins: [icon],
|
||||
};
|
||||
</script>
|
||||
17
assets/js/components/MaterialIcon/Record.vue
Normal file
17
assets/js/components/MaterialIcon/Record.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 17q-2.075 0-3.537-1.463T7 12t1.463-3.537T12 7t3.538 1.463T17 12t-1.463 3.538T12 17m8-5q0-1.05-.25-2.025t-.725-1.85q-.2-.375-.162-.8t.387-.675t.775-.15t.625.45q.65 1.125 1 2.388T22 12t-.363 2.675t-1.012 2.4q-.2.35-.612.438t-.763-.163t-.4-.675t.15-.8q.475-.875.738-1.838T20 12m-8-8q-1.075 0-2.037.263T8.125 5q-.375.2-.8.15t-.675-.4t-.162-.763t.437-.612q1.125-.65 2.4-1.012T12 2t2.675.363t2.4 1.012q.375.2.463.613t-.163.762t-.675.4t-.8-.15q-.875-.475-1.85-.737T12 4m-8 8q0 1.075.263 2.038T5 15.875q.2.375.15.8t-.4.675t-.763.163t-.612-.438q-.65-1.125-1.012-2.4T2 12t.35-2.662t1-2.388q.2-.35.625-.45t.775.15t.388.675t-.163.8Q4.5 9 4.25 9.975T4 12m8 8q1.05 0 2.025-.25t1.85-.725q.375-.2.788-.15t.662.4t.163.763t-.438.612q-1.125.65-2.387 1T12 22t-2.662-.35t-2.388-1q-.35-.2-.45-.625t.15-.775t.675-.387t.8.162q.875.475 1.85.725T12 20"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import icon from "../../mixins/icon";
|
||||
|
||||
export default {
|
||||
name: "Record",
|
||||
mixins: [icon],
|
||||
};
|
||||
</script>
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
data-bs-toggle="modal"
|
||||
data-bs-target="#notificationModal"
|
||||
class="btn btn-sm btn-link text-decoration-none link-light text-nowrap"
|
||||
data-testid="notification-icon"
|
||||
>
|
||||
<shopicon-regular-exclamationtriangle
|
||||
:class="iconClass"
|
||||
|
|
@ -65,12 +66,15 @@
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer d-flex justify-content-between gap-3">
|
||||
<router-link to="/log" class="btn btn-outline-secondary">
|
||||
{{ $t("notifications.logs") }}
|
||||
</router-link>
|
||||
<button
|
||||
type="button"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
class="btn btn-outline-secondary"
|
||||
class="btn btn-outline-primary"
|
||||
@click="clear"
|
||||
>
|
||||
{{ $t("notifications.dismissAll") }}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@
|
|||
>
|
||||
<shopicon-regular-cloud class="me-2"></shopicon-regular-cloud>
|
||||
{{ $t("offline.message") }}
|
||||
<button class="btn btn-sm btn-link text-dark" @click="reload">
|
||||
{{ $t("offline.reload") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -16,10 +13,11 @@ import "@h2d2/shopicons/es/regular/cloud";
|
|||
|
||||
export default {
|
||||
name: "OfflineIndicator",
|
||||
methods: {
|
||||
reload() {
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.fixed-bottom {
|
||||
/* $zindex-toast https://getbootstrap.com/docs/5.3/layout/z-index/ */
|
||||
z-index: 1090 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -171,11 +171,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div v-if="!priceConfigured || !co2Configured">
|
||||
<a
|
||||
href="https://docs.evcc.io/en/docs/reference/configuration/tariffs"
|
||||
class="evcc-gray"
|
||||
target="_blank"
|
||||
>
|
||||
<a :href="tariffLink" class="evcc-gray" target="_blank">
|
||||
{{ $t("footer.savings.configurePriceCo2") }}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -206,6 +202,7 @@ import CustomSelect from "./CustomSelect.vue";
|
|||
import co2Reference from "../co2Reference";
|
||||
import settings from "../settings";
|
||||
import api from "../api";
|
||||
import { docsPrefix } from "../i18n";
|
||||
|
||||
export default {
|
||||
name: "Savings",
|
||||
|
|
@ -227,6 +224,9 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
tariffLink() {
|
||||
return `${docsPrefix()}/docs/reference/configuration/tariffs`;
|
||||
},
|
||||
percent() {
|
||||
return Math.round(this.solarPercentage) || 0;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@
|
|||
<label for="telemetryEnabled">
|
||||
{{ $t("footer.telemetry.optIn") }}
|
||||
<i18n-t v-if="sponsor" tag="span" keypath="footer.telemetry.optInMoreDetails">
|
||||
<a
|
||||
href="https://docs.evcc.io/docs/faq/#telemetry--community-daten"
|
||||
target="_blank"
|
||||
>
|
||||
<a :href="docsLink" target="_blank">
|
||||
{{ $t("footer.telemetry.optInMoreDetailsLink") }}
|
||||
</a>
|
||||
</i18n-t>
|
||||
|
|
@ -30,6 +27,7 @@
|
|||
|
||||
<script>
|
||||
import api from "../api";
|
||||
import { docsPrefix } from "../i18n";
|
||||
import settings from "../settings";
|
||||
|
||||
function parseMarkdown(markdownText) {
|
||||
|
|
@ -52,6 +50,9 @@ export default {
|
|||
enabled() {
|
||||
return settings.telemetry;
|
||||
},
|
||||
docsLink() {
|
||||
return `${docsPrefix()}/docs/faq#telemetry`;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.update();
|
||||
|
|
|
|||
|
|
@ -2,8 +2,18 @@
|
|||
<header class="d-flex justify-content-between align-items-center py-3 py-md-4">
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
|
||||
<router-link class="evcc-default-text" to="/" data-testid="home-link">
|
||||
<shopicon-regular-home size="s" class="home"></shopicon-regular-home>
|
||||
<shopicon-regular-home size="s" class="icon"></shopicon-regular-home>
|
||||
</router-link>
|
||||
<div v-if="showConfig" class="d-flex">
|
||||
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
|
||||
<router-link to="/config" class="evcc-default-text text-decoration-none">
|
||||
<shopicon-regular-settings
|
||||
size="s"
|
||||
class="icon d-block d-sm-none"
|
||||
></shopicon-regular-settings>
|
||||
<span class="d-none d-sm-block">{{ $t("config.main.title") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
|
||||
<span class="text-truncate">{{ title }}</span>
|
||||
</h1>
|
||||
|
|
@ -13,6 +23,7 @@
|
|||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import "@h2d2/shopicons/es/regular/settings";
|
||||
import TopNavigation from "./TopNavigation.vue";
|
||||
import collector from "../mixins/collector";
|
||||
import store from "../store";
|
||||
|
|
@ -24,6 +35,7 @@ export default {
|
|||
TopNavigation,
|
||||
},
|
||||
props: {
|
||||
showConfig: Boolean,
|
||||
title: String,
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -36,7 +48,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
.icon {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -51,18 +51,18 @@
|
|||
{{ $t("batterySettings.modalTitle") }}
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="$hiddenFeatures()">
|
||||
<li>
|
||||
<router-link class="dropdown-item" to="/config">
|
||||
Device Configuration 🧪
|
||||
{{ $t("config.main.title") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
|
||||
<li v-if="showLogout">
|
||||
<button type="button" class="dropdown-item" @click="logout">
|
||||
{{ $t("header.logout") }}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<template v-if="providerLogins.length > 0">
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
|
|
|
|||
|
|
@ -98,3 +98,9 @@ export async function ensureCurrentLocaleMessages(i18n) {
|
|||
await loadLocaleMessages(i18n, locale);
|
||||
}
|
||||
}
|
||||
|
||||
export function docsPrefix() {
|
||||
const locale = getLocale();
|
||||
const path = locale === "de" ? "" : `/en`;
|
||||
return `https://docs.evcc.io${path}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import { openLoginModal, updateAuthStatus, isLoggedIn } from "./auth";
|
|||
|
||||
function hideAllModals() {
|
||||
[...document.querySelectorAll(".modal.show")].forEach((modal) => {
|
||||
// skip unclosable modals
|
||||
if (modal.getAttribute("data-bs-backdrop") === "static") return;
|
||||
|
||||
const modalInstance = Modal.getInstance(modal);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
|
|
@ -13,6 +16,15 @@ function hideAllModals() {
|
|||
});
|
||||
}
|
||||
|
||||
async function ensureAuth(to) {
|
||||
await updateAuthStatus();
|
||||
if (!isLoggedIn()) {
|
||||
openLoginModal(to.path);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function setupRouter(i18n) {
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
|
|
@ -21,13 +33,7 @@ export default function setupRouter(i18n) {
|
|||
{
|
||||
path: "/config",
|
||||
component: () => import("./views/Config.vue"),
|
||||
beforeEnter: async () => {
|
||||
await updateAuthStatus();
|
||||
if (!isLoggedIn()) {
|
||||
openLoginModal();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
beforeEnter: ensureAuth,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
|
|
@ -43,6 +49,15 @@ export default function setupRouter(i18n) {
|
|||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/log",
|
||||
component: () => import("./views/Log.vue"),
|
||||
beforeEnter: ensureAuth,
|
||||
},
|
||||
{
|
||||
path: "/error",
|
||||
component: () => import("./views/StartupError.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
router.beforeEach(async () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<HelpModal />
|
||||
<PasswordModal />
|
||||
<LoginModal />
|
||||
<OfflineIndicator v-if="offline" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
import store from "../store";
|
||||
import GlobalSettingsModal from "../components/GlobalSettingsModal.vue";
|
||||
import BatterySettingsModal from "../components/BatterySettingsModal.vue";
|
||||
import OfflineIndicator from "../components/OfflineIndicator.vue";
|
||||
import PasswordModal from "../components/PasswordModal.vue";
|
||||
import LoginModal from "../components/LoginModal.vue";
|
||||
import HelpModal from "../components/HelpModal.vue";
|
||||
|
|
@ -38,6 +40,7 @@ export default {
|
|||
BatterySettingsModal,
|
||||
PasswordModal,
|
||||
LoginModal,
|
||||
OfflineIndicator,
|
||||
},
|
||||
mixins: [collector],
|
||||
props: {
|
||||
|
|
@ -52,7 +55,7 @@ export default {
|
|||
return { title: siteTitle ? `${siteTitle} | evcc` : "evcc" };
|
||||
},
|
||||
watch: {
|
||||
version: function (prev, now) {
|
||||
version: function (now, prev) {
|
||||
if (!!prev && !!now) {
|
||||
console.log("new version detected. reloading browser", { now, prev });
|
||||
this.reload();
|
||||
|
|
@ -61,6 +64,15 @@ export default {
|
|||
offline: function () {
|
||||
updateAuthStatus();
|
||||
},
|
||||
startupErrors: function (now, prev) {
|
||||
if (now) {
|
||||
console.log("startup errors detected. redirecting to error page");
|
||||
this.$router.push("/error");
|
||||
} else {
|
||||
console.log("startup errors resolved. redirecting to home page");
|
||||
this.$router.push("/");
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
version: function () {
|
||||
|
|
@ -75,6 +87,9 @@ export default {
|
|||
batterySettingsProps() {
|
||||
return this.collectProps(BatterySettingsModal, store.state);
|
||||
},
|
||||
startupErrors: function () {
|
||||
return store.state.fatal?.length > 0;
|
||||
},
|
||||
},
|
||||
mounted: function () {
|
||||
this.connect();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
<template>
|
||||
<div class="root safe-area-inset">
|
||||
<div class="container px-4">
|
||||
<TopHeader title="Configuration 🧪" />
|
||||
<TopHeader :title="$t('config.main.title')" />
|
||||
<div class="wrapper">
|
||||
<div
|
||||
v-if="dirty"
|
||||
v-if="dirty || restarting"
|
||||
class="alert alert-secondary d-flex justify-content-between align-items-center my-4"
|
||||
role="alert"
|
||||
>
|
||||
<div v-if="restarting"><strong>Restarting evcc.</strong> Please wait ...</div>
|
||||
<div v-if="restarting">
|
||||
<strong>{{ $t("config.system.restartingMessage") }}</strong>
|
||||
{{ $t("config.system.restartingDescription") }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<strong>Configuration changed.</strong> Please restart to see the effect.
|
||||
<strong>{{ $t("config.system.restartRequiredMessage") }}</strong>
|
||||
{{ $t("config.system.restartRequiredDescription") }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -24,183 +28,211 @@
|
|||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span v-else> Restart </span>
|
||||
<span v-else>{{ $t("config.system.restart") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger my-4" role="alert">
|
||||
<strong>Highly experimental!</strong> Only play around with these settings if
|
||||
you know what you're doing. Otherwise you might have to reset or manually repair
|
||||
your database.
|
||||
</div>
|
||||
|
||||
<h2 class="my-4 mt-5">General</h2>
|
||||
<h2 class="my-4 mt-5">{{ $t("config.section.general") }}</h2>
|
||||
<GeneralConfig @site-changed="siteChanged" />
|
||||
|
||||
<h2 class="my-4 mt-5">Grid, PV & Battery Systems</h2>
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
:name="gridMeter?.config?.template || 'Grid meter'"
|
||||
:unconfigured="!gridMeter"
|
||||
:editable="!!gridMeter?.id"
|
||||
data-testid="grid"
|
||||
@configure="addMeter('grid')"
|
||||
@edit="editMeter(gridMeter.id, 'grid')"
|
||||
<h2 class="my-4 mt-5">{{ $t("config.section.system") }}</h2>
|
||||
<div class="round-box p-4 d-flex gap-4">
|
||||
<router-link to="/log" class="btn btn-outline-secondary">
|
||||
{{ $t("config.system.logs") }}
|
||||
</router-link>
|
||||
<button
|
||||
class="btn btn-outline-danger"
|
||||
:disabled="restarting || offline"
|
||||
@click="restart"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-powersupply></shopicon-regular-powersupply>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', gridMeter?.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-for="meter in pvMeters"
|
||||
:key="!!meter.name"
|
||||
:name="meter.config?.template || 'Solar system'"
|
||||
:editable="!!meter.id"
|
||||
data-testid="pv"
|
||||
@edit="editMeter(meter.id, 'pv')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-sun></shopicon-regular-sun>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', meter.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-for="meter in batteryMeters"
|
||||
:key="meter.name"
|
||||
:name="meter.config?.template || 'Battery storage'"
|
||||
:editable="!!meter.id"
|
||||
data-testid="battery"
|
||||
@edit="editMeter(meter.id, 'battery')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-batterythreequarters></shopicon-regular-batterythreequarters>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', meter.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton :title="$t('config.main.addPvBattery')" @add="addMeter" />
|
||||
</ul>
|
||||
<span
|
||||
v-if="restarting || offline"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ $t("config.system.restart") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="my-4 wip">Tariffs</h2>
|
||||
<div v-if="$hiddenFeatures()">
|
||||
<hr class="my-5" />
|
||||
|
||||
<ul class="p-0 config-list wip">
|
||||
<DeviceCard
|
||||
name="Grid"
|
||||
unconfigured
|
||||
data-testid="tariff-grid"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-money></shopicon-regular-money>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="Feed-in"
|
||||
unconfigured
|
||||
data-testid="tariff-feedin"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="CO₂ estimate"
|
||||
unconfigured
|
||||
data-testid="tariff-co2"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-eco1></shopicon-regular-eco1>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
</ul>
|
||||
<div class="alert alert-danger my-4" role="alert">
|
||||
<strong>Highly experimental!</strong> Only play around with these settings
|
||||
if you know what you're doing. Otherwise you might have to reset or manually
|
||||
repair your database.
|
||||
</div>
|
||||
|
||||
<h2 class="my-4 wip">Charge Points</h2>
|
||||
|
||||
<ul class="p-0 config-list wip">
|
||||
<DeviceCard
|
||||
name="Fake Carport"
|
||||
editable
|
||||
data-testid="chargepoint-1"
|
||||
@edit="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-cablecharge></shopicon-regular-cablecharge>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="{ power: 0 }" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton
|
||||
data-testid="add-loadpoint"
|
||||
:title="$t('config.main.addLoadpoint')"
|
||||
@click="todo"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4">Vehicles</h2>
|
||||
<div>
|
||||
<h2 class="my-4 mt-5">Grid, PV & Battery Systems</h2>
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
:name="vehicle.config?.title || vehicle.name"
|
||||
:editable="vehicle.id >= 0"
|
||||
data-testid="vehicle"
|
||||
@edit="editVehicle(vehicle.id)"
|
||||
:name="gridMeter?.config?.template || 'Grid meter'"
|
||||
:unconfigured="!gridMeter"
|
||||
:editable="!!gridMeter?.id"
|
||||
data-testid="grid"
|
||||
@configure="addMeter('grid')"
|
||||
@edit="editMeter(gridMeter.id, 'grid')"
|
||||
>
|
||||
<template #icon>
|
||||
<VehicleIcon :name="vehicle.config?.icon" />
|
||||
<shopicon-regular-powersupply></shopicon-regular-powersupply>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('vehicle', vehicle.name)" />
|
||||
<DeviceTags :tags="deviceTags('meter', gridMeter?.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-for="meter in pvMeters"
|
||||
:key="!!meter.name"
|
||||
:name="meter.config?.template || 'Solar system'"
|
||||
:editable="!!meter.id"
|
||||
data-testid="pv"
|
||||
@edit="editMeter(meter.id, 'pv')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-sun></shopicon-regular-sun>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', meter.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-for="meter in batteryMeters"
|
||||
:key="meter.name"
|
||||
:name="meter.config?.template || 'Battery storage'"
|
||||
:editable="!!meter.id"
|
||||
data-testid="battery"
|
||||
@edit="editMeter(meter.id, 'battery')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-batterythreequarters></shopicon-regular-batterythreequarters>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('meter', meter.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton :title="$t('config.main.addPvBattery')" @add="addMeter" />
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4 wip">Tariffs</h2>
|
||||
|
||||
<ul class="p-0 config-list wip">
|
||||
<DeviceCard
|
||||
name="Grid"
|
||||
unconfigured
|
||||
data-testid="tariff-grid"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-money></shopicon-regular-money>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="Feed-in"
|
||||
unconfigured
|
||||
data-testid="tariff-feedin"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="CO₂ estimate"
|
||||
unconfigured
|
||||
data-testid="tariff-co2"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-eco1></shopicon-regular-eco1>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4 wip">Charge Points</h2>
|
||||
|
||||
<ul class="p-0 config-list wip">
|
||||
<DeviceCard
|
||||
name="Fake Carport"
|
||||
editable
|
||||
data-testid="chargepoint-1"
|
||||
@edit="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-cablecharge></shopicon-regular-cablecharge>
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="{ power: 0 }" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton
|
||||
data-testid="add-vehicle"
|
||||
:title="$t('config.main.addVehicle')"
|
||||
@click="addVehicle"
|
||||
data-testid="add-loadpoint"
|
||||
:title="$t('config.main.addLoadpoint')"
|
||||
@click="todo"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<h2 class="my-4">Vehicles</h2>
|
||||
<div>
|
||||
<ul class="p-0 config-list">
|
||||
<DeviceCard
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
:name="vehicle.config?.title || vehicle.name"
|
||||
:editable="vehicle.id >= 0"
|
||||
data-testid="vehicle"
|
||||
@edit="editVehicle(vehicle.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<VehicleIcon :name="vehicle.config?.icon" />
|
||||
</template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="deviceTags('vehicle', vehicle.name)" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<AddDeviceButton
|
||||
data-testid="add-vehicle"
|
||||
:title="$t('config.main.addVehicle')"
|
||||
@click="addVehicle"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<h2 class="my-4 wip">Integrations</h2>
|
||||
|
||||
<ul class="p-0 config-list wip">
|
||||
<DeviceCard name="MQTT" unconfigured data-testid="mqtt" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-fastdelivery1></shopicon-regular-fastdelivery1>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="Notifications"
|
||||
unconfigured
|
||||
data-testid="eebus"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-sendit></shopicon-regular-sendit>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="InfluxDB"
|
||||
unconfigured
|
||||
data-testid="influx"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-diagram></shopicon-regular-diagram>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard name="EEBus" unconfigured data-testid="eebus" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-polygon></shopicon-regular-polygon>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
</ul>
|
||||
</div>
|
||||
<h2 class="my-4 wip">Integrations</h2>
|
||||
|
||||
<ul class="p-0 config-list wip">
|
||||
<DeviceCard name="MQTT" unconfigured data-testid="mqtt" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-fastdelivery1></shopicon-regular-fastdelivery1>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
name="Notifications"
|
||||
unconfigured
|
||||
data-testid="eebus"
|
||||
@configure="todo"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-sendit></shopicon-regular-sendit>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard name="InfluxDB" unconfigured data-testid="influx" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-diagram></shopicon-regular-diagram>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard name="EEBus" unconfigured data-testid="eebus" @configure="todo">
|
||||
<template #icon>
|
||||
<shopicon-regular-polygon></shopicon-regular-polygon>
|
||||
</template>
|
||||
</DeviceCard>
|
||||
</ul>
|
||||
|
||||
<hr class="my-5" />
|
||||
<VehicleModal :id="selectedVehicleId" @vehicle-changed="vehicleChanged" />
|
||||
<MeterModal
|
||||
:id="selectedMeterId"
|
||||
|
|
@ -416,7 +448,7 @@ export default {
|
|||
},
|
||||
async restart() {
|
||||
try {
|
||||
await api.post("shutdown");
|
||||
await api.post("/system/shutdown");
|
||||
this.restarting = true;
|
||||
} catch (e) {
|
||||
alert("Unabled to restart server.");
|
||||
|
|
@ -447,6 +479,14 @@ export default {
|
|||
deviceTags(type, id) {
|
||||
return this.deviceValues[type]?.[id] || [];
|
||||
},
|
||||
openModal(id) {
|
||||
const $el = document.getElementById(id);
|
||||
if ($el) {
|
||||
Modal.getOrCreateInstance($el).show();
|
||||
} else {
|
||||
console.error(`modal ${id} not found`);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -459,5 +499,6 @@ export default {
|
|||
}
|
||||
.wip {
|
||||
opacity: 0.2 !important;
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
321
assets/js/views/Log.vue
Normal file
321
assets/js/views/Log.vue
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<template>
|
||||
<div class="root safe-area-inset">
|
||||
<div class="container d-flex h-100 flex-column px-0 pb-4">
|
||||
<TopHeader showConfig :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">
|
||||
<button
|
||||
type="button"
|
||||
class="btn text-nowrap d-flex w-100 w-lg-auto justify-content-between"
|
||||
: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="ms-1 spin flex-shrink-0"
|
||||
:style="{ animationDuration: `${updateInterval}ms` }"
|
||||
/>
|
||||
<Play v-else class="ms-1 flex-shrink-0 play" />
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-outline-secondary"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
<div class="filterLevel col-6 col-lg-2">
|
||||
<select
|
||||
class="form-select"
|
||||
v-model="level"
|
||||
:aria-label="$t('log.levelLabel')"
|
||||
@change="updateLogs()"
|
||||
>
|
||||
<option v-for="level in levels" :key="level" :value="level">
|
||||
{{ level }}
|
||||
</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()"
|
||||
>
|
||||
<option value="">{{ $t("log.areas") }}</option>
|
||||
<hr />
|
||||
<option v-for="area in areas" :key="area" :value="area">
|
||||
{{ area }}
|
||||
</option>
|
||||
</select>
|
||||
</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 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.join("|")})`);
|
||||
|
||||
export default {
|
||||
name: "Log",
|
||||
components: {
|
||||
TopHeader,
|
||||
Play,
|
||||
Record,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lines: [],
|
||||
areas: [],
|
||||
search: "",
|
||||
level: DEFAULT_LEVEL,
|
||||
area: "",
|
||||
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 };
|
||||
});
|
||||
},
|
||||
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);
|
||||
}
|
||||
if (this.area) {
|
||||
params.append("area", this.area);
|
||||
}
|
||||
params.append("format", "txt");
|
||||
return `./api/system/log?${params.toString()}`;
|
||||
},
|
||||
autoFollow() {
|
||||
return this.timeout !== null;
|
||||
},
|
||||
},
|
||||
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?.toLocaleLowerCase() || null,
|
||||
area: this.area || 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.areas = 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();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
.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>
|
||||
|
|
@ -1,31 +1,19 @@
|
|||
<template>
|
||||
<div>
|
||||
<OfflineIndicator v-if="offline" />
|
||||
<StartupError v-if="startupErrors" v-bind="state" :offline="offline" />
|
||||
<Site v-else :notifications="notifications" v-bind="state"></Site>
|
||||
</div>
|
||||
<Site :notifications="notifications" v-bind="state"></Site>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Site from "../components/Site.vue";
|
||||
import StartupError from "../components/StartupError.vue";
|
||||
import OfflineIndicator from "../components/OfflineIndicator.vue";
|
||||
import store from "../store";
|
||||
|
||||
export default {
|
||||
name: "Main",
|
||||
components: { Site, StartupError, OfflineIndicator },
|
||||
components: { Site },
|
||||
props: {
|
||||
notifications: Array,
|
||||
offline: Boolean,
|
||||
},
|
||||
data: function () {
|
||||
return store;
|
||||
},
|
||||
computed: {
|
||||
startupErrors: function () {
|
||||
return this.state.fatal?.length > 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="container px-4">
|
||||
<div class="d-flex justify-content-between align-items-center my-3">
|
||||
<div class="container px-4 safe-area-inset">
|
||||
<div class="d-flex justify-content-between align-items-center my-3 my-md-4">
|
||||
<h1 class="d-block mt-0 d-flex">
|
||||
{{ $t("startupError.title") }}
|
||||
<shopicon-regular-car1 size="m" class="ms-2 icon"></shopicon-regular-car1>
|
||||
|
|
@ -41,6 +41,13 @@
|
|||
:disabled="offline"
|
||||
@click="shutdown"
|
||||
>
|
||||
<span
|
||||
v-if="offline"
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
{{ $t("startupError.restartButton") }}
|
||||
</button>
|
||||
</p>
|
||||
|
|
@ -88,7 +95,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
shutdown() {
|
||||
api.post("shutdown");
|
||||
api.post("/system/shutdown");
|
||||
},
|
||||
scrollTo(e) {
|
||||
const id = e.currentTarget.getAttribute("href").substring(1);
|
||||
15
cmd/root.go
15
cmd/root.go
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/evcc-io/evcc/server/modbus"
|
||||
"github.com/evcc-io/evcc/server/updater"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/auth"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/pipe"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
|
|
@ -246,11 +247,14 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
os.Exit(0)
|
||||
}()
|
||||
|
||||
auth := auth.New()
|
||||
|
||||
// show main ui
|
||||
if err == nil {
|
||||
httpd.RegisterSiteHandlers(site, cache)
|
||||
httpd.RegisterShutdownHandler(func() {
|
||||
log.FATAL.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
|
||||
httpd.RegisterSiteHandlers(site, auth, cache)
|
||||
httpd.RegisterAuthHandlers(auth)
|
||||
httpd.RegisterSystemHandler(auth, func() {
|
||||
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
|
||||
once.Do(func() { close(stopC) }) // signal loop to end
|
||||
})
|
||||
|
||||
|
|
@ -284,8 +288,9 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
site.Run(stopC, conf.Interval)
|
||||
}()
|
||||
} else {
|
||||
httpd.RegisterShutdownHandler(func() {
|
||||
log.FATAL.Println("evcc was stopped. OS should restart the service. Or restart manually.")
|
||||
httpd.RegisterAuthHandlers(auth)
|
||||
httpd.RegisterSystemHandler(auth, func() {
|
||||
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
|
||||
once.Do(func() { close(stopC) }) // signal loop to end
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const (
|
|||
TariffPriceLoadpoints = "tariffPriceLoadpoints"
|
||||
Vehicles = "vehicles"
|
||||
PasswordConfigured = "passwordConfigured"
|
||||
Interval = "interval"
|
||||
|
||||
// meters
|
||||
GridMeter = "gridMeter"
|
||||
|
|
|
|||
|
|
@ -902,6 +902,7 @@ func (site *Site) Run(stopC chan struct{}, interval time.Duration) {
|
|||
go site.loopLoadpoints(loadpointChan)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
site.publish(keys.Interval, interval.Seconds())
|
||||
site.update(<-loadpointChan) // start immediately
|
||||
|
||||
for {
|
||||
|
|
|
|||
34
i18n/de.toml
34
i18n/de.toml
|
|
@ -13,7 +13,7 @@ legendTitle = "Wie soll Sonnenstrom genutzt werden?"
|
|||
legendTopAutostart = "startet automatisch"
|
||||
legendTopName = "Batterieunterstütztes Laden"
|
||||
legendTopSubline = "ohne Unterbrechungen"
|
||||
modalTitle = "Batterieeinstellungen"
|
||||
modalTitle = "Hausbatterie"
|
||||
|
||||
[batterySettings.bufferStart]
|
||||
above = "wenn über {soc}"
|
||||
|
|
@ -76,6 +76,18 @@ validateSave = "Überprüfen & speichern"
|
|||
titleAdd = "Zähler (PV) hinzufügen"
|
||||
titleEdit = "Zähler (PV) bearbeiten"
|
||||
|
||||
[config.section]
|
||||
general = "Allgemein"
|
||||
system = "System"
|
||||
|
||||
[config.system]
|
||||
logs = "Logs"
|
||||
restart = "Neu starten"
|
||||
restartRequiredDescription = "Bitte neu starten, um die Änderungen zu übernehmen."
|
||||
restartRequiredMessage = "Konfiguration geändert."
|
||||
restartingDescription = "Bitte warten …"
|
||||
restartingMessage = "evcc wird neu gestartet."
|
||||
|
||||
[config.title]
|
||||
description = "Wird in der Hauptansicht und im Browser-Tab angezeigt."
|
||||
label = "Titel"
|
||||
|
|
@ -85,7 +97,6 @@ title = "Titel bearbeiten"
|
|||
failed = "fehlgeschlagen"
|
||||
label = "Status"
|
||||
running = "prüfe…"
|
||||
save = "Speichern"
|
||||
success = "erfolgreich"
|
||||
unknown = "unbekannt"
|
||||
validate = "prüfen"
|
||||
|
|
@ -102,7 +113,6 @@ scooter = "Scooter"
|
|||
template = "Hersteller"
|
||||
titleAdd = "Fahrzeug hinzufügen"
|
||||
titleEdit = "Fahrzeug bearbeiten"
|
||||
update = "Prüfen & speichern"
|
||||
validateSave = "Prüfen & speichern"
|
||||
|
||||
[footer]
|
||||
|
|
@ -175,6 +185,7 @@ blog = "Blog"
|
|||
docs = "Dokumentation"
|
||||
github = "GitHub"
|
||||
login = "Fahrzeug-Logins"
|
||||
logout = "Abmelden"
|
||||
nativeSettings = "Server ändern"
|
||||
needHelp = "Hilfe benötigt?"
|
||||
sessions = "Ladevorgänge"
|
||||
|
|
@ -184,6 +195,8 @@ discussionsButton = "GitHub Discussions"
|
|||
documentationButton = "Dokumentation"
|
||||
issueButton = "Bug melden"
|
||||
issueDescription = "Komisches Verhalten gefunden?"
|
||||
logsButton = "Logs ansehen"
|
||||
logsDescription = "Überprüfe die Logs auf Fehler."
|
||||
modalTitle = "Hilfe benötigt?"
|
||||
primaryActions = "Funktioniert etwas nicht so, wie es soll? Dies sind gute Anlaufstellen, um Hilfe zu erhalten."
|
||||
restartButton = "Neu starten"
|
||||
|
|
@ -197,12 +210,24 @@ description = "Unter normalen Umständen sollte ein Neustart nicht notwendig sei
|
|||
disclaimer = "Hinweis: evcc beendet sich und verlässt sich darauf, vom Betriebssystem neu gestartet zu werden."
|
||||
modalTitle = "Sicher, dass du neu starten möchtest?"
|
||||
|
||||
[log]
|
||||
areaLabel = "Nach Bereich filtern"
|
||||
areas = "Alle Bereiche"
|
||||
download = "Komplettes Log herunterladen"
|
||||
levelLabel = "Nach Log-Level filtern"
|
||||
noResults = "Keine passenden Einträge gefunden."
|
||||
search = "Suchen"
|
||||
showAll = "Alle Einträge anzeigen"
|
||||
title = "Logs"
|
||||
update = "Aktualisieren"
|
||||
|
||||
[loginModal]
|
||||
cancel = "Abbrechen"
|
||||
error = "Login fehlgeschlagen: "
|
||||
invalid = "Passwort ist ungültig."
|
||||
login = "Anmelden"
|
||||
password = "Passwort"
|
||||
reset = "Passwort zurücksetzen?"
|
||||
title = "Authentifizierung"
|
||||
|
||||
[main]
|
||||
|
|
@ -382,6 +407,7 @@ waitForVehicle = "Ladebereit. Warte auf Fahrzeug …"
|
|||
|
||||
[notifications]
|
||||
dismissAll = "Meldungen entfernen"
|
||||
logs = "Vollständigs Log ansehen"
|
||||
modalTitle = "Meldungen"
|
||||
|
||||
[offline]
|
||||
|
|
@ -451,7 +477,7 @@ allVehicles = "Alle Fahrzeuge"
|
|||
filter = "Filtern"
|
||||
|
||||
[settings]
|
||||
title = "Allgemeine Einstellungen"
|
||||
title = "Darstellung"
|
||||
|
||||
[settings.hiddenFeatures]
|
||||
label = "Experimentell"
|
||||
|
|
|
|||
31
i18n/en.toml
31
i18n/en.toml
|
|
@ -13,7 +13,7 @@ legendTitle = "How should solar energy be used?"
|
|||
legendTopAutostart = "starts automatically"
|
||||
legendTopName = "battery supported charging"
|
||||
legendTopSubline = "without interruptions"
|
||||
modalTitle = "Battery Settings"
|
||||
modalTitle = "Home Battery"
|
||||
|
||||
[batterySettings.bufferStart]
|
||||
above = "when above {soc}"
|
||||
|
|
@ -76,6 +76,18 @@ validateSave = "Validate & save"
|
|||
titleAdd = "Add Solar Meter"
|
||||
titleEdit = "Edit Solar Meter"
|
||||
|
||||
[config.section]
|
||||
general = "General"
|
||||
system = "System"
|
||||
|
||||
[config.system]
|
||||
logs = "Logs"
|
||||
restart = "Restart"
|
||||
restartRequiredDescription = "Please restart to see the effect."
|
||||
restartRequiredMessage = "Configuration changed."
|
||||
restartingDescription = "Please wait…"
|
||||
restartingMessage = "Restarting evcc."
|
||||
|
||||
[config.title]
|
||||
description = "Displayed on main screen and browser tab."
|
||||
label = "Title"
|
||||
|
|
@ -182,6 +194,8 @@ discussionsButton = "GitHub discussions"
|
|||
documentationButton = "Documentation"
|
||||
issueButton = "Report a bug"
|
||||
issueDescription = "Found a strange or wrong behavior?"
|
||||
logsButton = "View logs"
|
||||
logsDescription = "Check the logs for errors."
|
||||
modalTitle = "Need help?"
|
||||
primaryActions = "Something does not work the way it supposed to do? These are good places to get help."
|
||||
restartButton = "Restart"
|
||||
|
|
@ -195,12 +209,24 @@ description = "Under normal circumstances restarting should not be necessary. Pl
|
|||
disclaimer = "Note: evcc will terminate and rely on the operating system to restart the service."
|
||||
modalTitle = "Are you sure you want to restart?"
|
||||
|
||||
[log]
|
||||
areaLabel = "Filter by area"
|
||||
areas = "All areas"
|
||||
download = "Download complete log"
|
||||
levelLabel = "Filter by log level"
|
||||
noResults = "No matching log entries."
|
||||
search = "Search"
|
||||
showAll = "Show all entries"
|
||||
title = "Logs"
|
||||
update = "Auto update"
|
||||
|
||||
[loginModal]
|
||||
cancel = "Cancel"
|
||||
error = "Login failed: "
|
||||
invalid = "Password is invalid."
|
||||
login = "Login"
|
||||
password = "Password"
|
||||
reset = "Reset password?"
|
||||
title = "Authentication"
|
||||
|
||||
[main]
|
||||
|
|
@ -380,6 +406,7 @@ waitForVehicle = "Ready. Waiting for vehicle…"
|
|||
|
||||
[notifications]
|
||||
dismissAll = "Dismiss all"
|
||||
logs = "View full logs"
|
||||
modalTitle = "Notifications"
|
||||
|
||||
[offline]
|
||||
|
|
@ -449,7 +476,7 @@ allVehicles = "all vehicles"
|
|||
filter = "Filter"
|
||||
|
||||
[settings]
|
||||
title = "General Settings"
|
||||
title = "User Interface"
|
||||
|
||||
[settings.hiddenFeatures]
|
||||
label = "Experimental"
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func (s *HTTPd) Router() *mux.Router {
|
|||
}
|
||||
|
||||
// RegisterSiteHandlers connects the http handlers to the site
|
||||
func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
||||
func (s *HTTPd) RegisterSiteHandlers(site site.API, auth auth.Auth, cache *util.Cache) {
|
||||
router := s.Server.Handler.(*mux.Router)
|
||||
|
||||
// api
|
||||
|
|
@ -95,8 +95,6 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
|||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
auth := auth.New()
|
||||
|
||||
// site api
|
||||
routes := map[string]route{
|
||||
"health": {"GET", "/health", healthHandler(site)},
|
||||
|
|
@ -113,10 +111,6 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
|||
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
|
||||
"telemetry": {"GET", "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
|
||||
"telemetry2": {"POST", "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
||||
"password": {"PUT", "/auth/password", updatePasswordHandler(auth)},
|
||||
"auth": {"GET", "/auth/status", authStatusHandler(auth)},
|
||||
"login": {"POST", "/auth/login", loginHandler(auth)},
|
||||
"logout": {"POST", "/auth/logout", logoutHandler},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
|
|
@ -196,22 +190,50 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
|||
}
|
||||
}
|
||||
|
||||
// RegisterShutdownHandler connects the http handlers to the site
|
||||
func (s *HTTPd) RegisterShutdownHandler(callback func()) {
|
||||
// RegisterAuthHandlers provides authentication handlers
|
||||
func (s *HTTPd) RegisterAuthHandlers(auth auth.Auth) {
|
||||
router := s.Server.Handler.(*mux.Router)
|
||||
|
||||
// api
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
api := router.PathPrefix("/api/auth").Subrouter()
|
||||
api.Use(jsonHandler)
|
||||
api.Use(handlers.CompressHandler)
|
||||
api.Use(handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
// site api
|
||||
// auth api
|
||||
routes := map[string]route{
|
||||
"password": {"PUT", "/password", updatePasswordHandler(auth)},
|
||||
"auth": {"GET", "/status", authStatusHandler(auth)},
|
||||
"login": {"POST", "/login", loginHandler(auth)},
|
||||
"logout": {"POST", "/logout", logoutHandler},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSystemHandler provides system level handlers
|
||||
func (s *HTTPd) RegisterSystemHandler(auth auth.Auth, shutdown func()) {
|
||||
router := s.Server.Handler.(*mux.Router)
|
||||
|
||||
// api
|
||||
api := router.PathPrefix("/api/system").Subrouter()
|
||||
api.Use(jsonHandler)
|
||||
api.Use(ensureAuthHandler(auth))
|
||||
api.Use(handlers.CompressHandler)
|
||||
api.Use(handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
// system api
|
||||
routes := map[string]route{
|
||||
"log": {"GET", "/log", logHandler},
|
||||
"logareas": {"GET", "/log/areas", logAreasHandler},
|
||||
"shutdown": {"POST", "/shutdown", func(w http.ResponseWriter, r *http.Request) {
|
||||
callback()
|
||||
shutdown()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/server/assets"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/jq"
|
||||
"github.com/evcc-io/evcc/util/logstash"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/itchyny/gojq"
|
||||
"golang.org/x/text/language"
|
||||
|
|
@ -244,3 +246,35 @@ func socketHandler(hub *SocketHub) http.HandlerFunc {
|
|||
hub.ServeWebsocket(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func logAreasHandler(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResult(w, logstash.Areas())
|
||||
}
|
||||
|
||||
func logHandler(w http.ResponseWriter, r *http.Request) {
|
||||
a := r.URL.Query()["area"]
|
||||
l := logstash.LogLevelToThreshold(r.URL.Query().Get("level"))
|
||||
|
||||
var count int
|
||||
if v := r.URL.Query().Get("count"); v != "" {
|
||||
count, _ = strconv.Atoi(v)
|
||||
}
|
||||
|
||||
log := logstash.All(a, l, count)
|
||||
|
||||
if r.URL.Query().Get("format") == "txt" {
|
||||
filename := "evcc-" + time.Now().Format("20060102-150405") + `-` + strings.ToLower(l.String()) + ".log"
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
|
||||
for _, s := range log {
|
||||
if _, err := w.Write([]byte(s)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, log)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ test.afterEach(async () => {
|
|||
await stop();
|
||||
});
|
||||
|
||||
// TODO: activate this once auth is released
|
||||
test.skip("set initial password", async ({ page }) => {
|
||||
test("set initial password", async ({ page }) => {
|
||||
const modal = page.getByTestId("password-modal");
|
||||
|
||||
await expect(modal).toBeVisible();
|
||||
|
|
@ -34,7 +33,7 @@ test.skip("set initial password", async ({ page }) => {
|
|||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test.skip("login", async ({ page }) => {
|
||||
test("login", async ({ page }) => {
|
||||
// set initial password
|
||||
const modal = page.getByTestId("password-modal");
|
||||
await modal.getByLabel("New password").fill("secret");
|
||||
|
|
@ -43,10 +42,6 @@ test.skip("login", async ({ page }) => {
|
|||
|
||||
// go to config
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByLabel("Experimental 🧪").click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("link", { name: "Configuration" }).click();
|
||||
|
||||
// login modal
|
||||
|
|
@ -66,7 +61,7 @@ test.skip("login", async ({ page }) => {
|
|||
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
|
||||
});
|
||||
|
||||
test.skip("update password", async ({ page }) => {
|
||||
test("update password", async ({ page }) => {
|
||||
const oldPassword = "secret";
|
||||
const newPassword = "newsecret";
|
||||
|
||||
|
|
@ -103,4 +98,12 @@ test.skip("update password", async ({ page }) => {
|
|||
await loginNew.getByLabel("Password").fill(newPassword);
|
||||
await loginNew.getByRole("button", { name: "Login" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
|
||||
|
||||
// revert password
|
||||
await page.getByTestId("generalconfig-password").getByRole("link", { name: "edit" }).click();
|
||||
await modal.getByLabel("Current password").fill(newPassword);
|
||||
await modal.getByLabel("New password").fill(oldPassword);
|
||||
await modal.getByLabel("Repeat password").fill(oldPassword);
|
||||
await modal.getByRole("button", { name: "Update Password" }).click();
|
||||
await expect(page.getByTestId("password-modal")).not.toBeVisible();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,20 +12,24 @@ test.afterAll(async () => {
|
|||
await stop();
|
||||
});
|
||||
|
||||
async function login() {
|
||||
// TODO: uncomment this once auth is released
|
||||
// await page.locator("#loginPassword").fill("secret");
|
||||
// await page.getByRole("button", { name: "Login" }).click();
|
||||
async function login(page) {
|
||||
await page.locator("#loginPassword").fill("secret");
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
}
|
||||
|
||||
async function enableExperimental(page) {
|
||||
await page
|
||||
.getByTestId("generalconfig-experimental")
|
||||
.getByRole("link", { name: "change" })
|
||||
.click();
|
||||
await page.getByLabel("Experimental 🧪").click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
}
|
||||
|
||||
test.describe("basics", async () => {
|
||||
test("navigation to config", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByLabel("Experimental 🧪").click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("link", { name: "Configuration" }).click();
|
||||
await login(page);
|
||||
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
|
||||
|
|
@ -33,6 +37,7 @@ test.describe("basics", async () => {
|
|||
test("alert box should always be visible", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
await login(page);
|
||||
await enableExperimental(page);
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +46,7 @@ test.describe("vehicles", async () => {
|
|||
test("create, edit and delete vehicles", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
await login(page);
|
||||
await enableExperimental(page);
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(0);
|
||||
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||
|
|
@ -89,6 +95,7 @@ test.describe("vehicles", async () => {
|
|||
test("config should survive restart", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
await login(page);
|
||||
await enableExperimental(page);
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(0);
|
||||
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||
|
|
@ -122,6 +129,7 @@ test.describe("vehicles", async () => {
|
|||
|
||||
await page.goto("/#/config");
|
||||
await login(page);
|
||||
await enableExperimental(page);
|
||||
|
||||
await expect(page.getByTestId("vehicle")).toHaveCount(1);
|
||||
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||
|
|
@ -155,6 +163,7 @@ test.describe("meters", async () => {
|
|||
|
||||
await page.goto("/#/config");
|
||||
await login(page);
|
||||
await enableExperimental(page);
|
||||
|
||||
await expect(page.getByTestId("battery")).toHaveCount(0);
|
||||
|
||||
|
|
@ -208,6 +217,7 @@ test.describe("general", async () => {
|
|||
// change value in config
|
||||
await page.goto("/#/config");
|
||||
await login(page);
|
||||
await enableExperimental(page);
|
||||
|
||||
await expect(page.getByTestId("generalconfig-title")).toContainText("Hello World");
|
||||
await page.getByTestId("generalconfig-title").getByRole("link", { name: "edit" }).click();
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ async function _start(config) {
|
|||
|
||||
async function _stop() {
|
||||
console.log("shutting down evcc");
|
||||
await axios.post(BASE_URL + "/api/shutdown");
|
||||
const res = await axios.post(BASE_URL + "/api/auth/login", { password: "secret" });
|
||||
console.log(res.status, res.statusText);
|
||||
const cookie = res.headers["set-cookie"];
|
||||
await axios.post(BASE_URL + "/api/system/shutdown", {}, { headers: { cookie } });
|
||||
}
|
||||
|
||||
async function _clean() {
|
||||
|
|
|
|||
49
tests/logs.spec.js
Normal file
49
tests/logs.spec.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop } from "./evcc";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start("basics.evcc.yaml", "password.sql");
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
async function login(page) {
|
||||
await page.locator("#loginPassword").fill("secret");
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
}
|
||||
|
||||
test.describe("opening logs", async () => {
|
||||
test("via config", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("link", { name: "Configuration" }).click();
|
||||
await login(page);
|
||||
await page.getByRole("link", { name: "Logs" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible();
|
||||
});
|
||||
test("via notifications", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => window.app.raise({ message: "Fake Error" }));
|
||||
await page.getByTestId("notification-icon").click();
|
||||
await page.getByRole("link", { name: "View full logs" }).click();
|
||||
await login(page);
|
||||
await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible();
|
||||
});
|
||||
test("via need help", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("button", { name: "Need Help?" }).click();
|
||||
await page.getByRole("link", { name: "View logs" }).click();
|
||||
await login(page);
|
||||
await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("features", async () => {
|
||||
test("content", async ({ page }) => {
|
||||
await page.goto("/#/log");
|
||||
await login(page);
|
||||
await expect(page.getByTestId("log-content")).toContainText("starting ui and api at");
|
||||
});
|
||||
});
|
||||
|
|
@ -7,10 +7,9 @@ const SIMULATOR_CONFIG = "simulator.evcc.yaml";
|
|||
|
||||
const UI_ROUTES = ["/", "/#/sessions", "/#/config"];
|
||||
|
||||
async function login() {
|
||||
// TODO: uncomment this once auth is released
|
||||
// await page.locator("#loginPassword").fill("secret");
|
||||
// await page.getByRole("button", { name: "Login" }).click();
|
||||
async function login(page) {
|
||||
await page.locator("#loginPassword").fill("secret");
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
}
|
||||
|
||||
test.describe("Basics", async () => {
|
||||
|
|
@ -30,8 +29,8 @@ test.describe("Basics", async () => {
|
|||
}
|
||||
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await expect(page.getByRole("button", { name: "General Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Battery Settings" })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "User Interface" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Home Battery" })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Need help?" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
|
@ -45,12 +44,12 @@ test.describe("Basics", async () => {
|
|||
await expect(page.getByRole("heading", { name: "Need help?" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("General Settings", async ({ page }) => {
|
||||
test("User Interface", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("button", { name: "General Settings" }).click();
|
||||
await page.getByRole("button", { name: "User Interface" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "General Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "User Interface" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -73,21 +72,21 @@ test.describe("Advanced", async () => {
|
|||
}
|
||||
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await expect(page.getByRole("button", { name: "General Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Battery Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "User Interface" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Home Battery" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Need help?" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Battery Settings from top navigation", async ({ page }) => {
|
||||
test("Home Battery from top navigation", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByRole("button", { name: "Battery Settings" }).click();
|
||||
await page.getByRole("button", { name: "Home Battery" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Battery Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Home Battery" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Battery Settings from energyflow", async ({ page }) => {
|
||||
test("Home Battery from energyflow", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("energyflow").click();
|
||||
await page
|
||||
|
|
@ -95,6 +94,6 @@ test.describe("Advanced", async () => {
|
|||
.getByTestId("energyflow-entry-details")
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Battery Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Home Battery" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,9 +12,6 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// TODO: remove this once auth is released
|
||||
const disabled = true
|
||||
|
||||
const admin = "admin"
|
||||
|
||||
// Auth is the Auth api
|
||||
|
|
@ -59,21 +56,11 @@ func (a *auth) RemoveAdminPassword() {
|
|||
|
||||
// IsAdminPasswordConfigured checks if the admin password is already set
|
||||
func (a *auth) IsAdminPasswordConfigured() bool {
|
||||
// TODO: remove this once auth is released
|
||||
if disabled {
|
||||
return true
|
||||
}
|
||||
|
||||
return a.getAdminPasswordHash() != ""
|
||||
}
|
||||
|
||||
// SetAdminPassword sets the admin password if not already set
|
||||
func (a *auth) SetAdminPassword(password string) error {
|
||||
// TODO: remove this once auth is released
|
||||
if disabled {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
|
|
@ -138,11 +125,6 @@ func (a *auth) GenerateJwtToken(lifetime time.Duration) (string, error) {
|
|||
|
||||
// ValidateJwtToken validates the given JWT token
|
||||
func (a *auth) ValidateJwtToken(tokenString string) (bool, error) {
|
||||
// TODO: remove this once auth is released
|
||||
if disabled {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
jwtSecret, err := a.getJwtSecret()
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ import (
|
|||
)
|
||||
|
||||
func TestSetAdminPassword(t *testing.T) {
|
||||
t.Skip("skipped until auth is released")
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
|
|
@ -37,8 +35,6 @@ func TestRemoveAdminPassword(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestIsAdminPasswordValid(t *testing.T) {
|
||||
t.Skip("skipped until auth is released")
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
|
|
|
|||
42
util/log.go
42
util/log.go
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/evcc-io/evcc/util/logstash"
|
||||
jww "github.com/spf13/jwalterweatherman"
|
||||
)
|
||||
|
||||
|
|
@ -18,10 +19,10 @@ var (
|
|||
loggersMux sync.Mutex
|
||||
|
||||
// OutThreshold is the default console log level
|
||||
OutThreshold = jww.LevelError
|
||||
OutThreshold = jww.LevelWarn
|
||||
|
||||
// LogThreshold is the default log file level
|
||||
LogThreshold = jww.LevelWarn
|
||||
LogThreshold = jww.LevelTrace
|
||||
)
|
||||
|
||||
// LogAreaPadding of log areas
|
||||
|
|
@ -57,9 +58,9 @@ func newLogger(area string, lp int) *Logger {
|
|||
padded += " "
|
||||
}
|
||||
|
||||
level := LogLevelForArea(area)
|
||||
level := logLevelForArea(area)
|
||||
redactor := new(Redactor)
|
||||
notepad := jww.NewNotepad(level, level, redactor, io.Discard, padded, log.Ldate|log.Ltime)
|
||||
notepad := jww.NewNotepad(level, level, redactor, logstash.DefaultHandler, padded, log.Ldate|log.Ltime)
|
||||
|
||||
logger := &Logger{
|
||||
Notepad: notepad,
|
||||
|
|
@ -90,11 +91,11 @@ func Loggers(cb func(string, *Logger)) {
|
|||
}
|
||||
}
|
||||
|
||||
// LogLevelForArea gets the log level for given log area
|
||||
func LogLevelForArea(area string) jww.Threshold {
|
||||
// logLevelForArea gets the log level for given log area
|
||||
func logLevelForArea(area string) jww.Threshold {
|
||||
level, ok := levels[strings.ToLower(area)]
|
||||
if !ok {
|
||||
level = OutThreshold
|
||||
level = LogThreshold
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
|
@ -102,40 +103,19 @@ func LogLevelForArea(area string) jww.Threshold {
|
|||
// LogLevel sets log level for all loggers
|
||||
func LogLevel(defaultLevel string, areaLevels map[string]string) {
|
||||
// default level
|
||||
OutThreshold = LogLevelToThreshold(defaultLevel)
|
||||
LogThreshold = OutThreshold
|
||||
LogThreshold = logstash.LogLevelToThreshold(defaultLevel)
|
||||
|
||||
// area levels
|
||||
for area, level := range areaLevels {
|
||||
area = strings.ToLower(area)
|
||||
levels[area] = LogLevelToThreshold(level)
|
||||
levels[area] = logstash.LogLevelToThreshold(level)
|
||||
}
|
||||
|
||||
Loggers(func(name string, logger *Logger) {
|
||||
logger.SetStdoutThreshold(LogLevelForArea(name))
|
||||
logger.SetStdoutThreshold(logLevelForArea(name))
|
||||
})
|
||||
}
|
||||
|
||||
// LogLevelToThreshold converts log level string to a jww Threshold
|
||||
func LogLevelToThreshold(level string) jww.Threshold {
|
||||
switch strings.ToUpper(level) {
|
||||
case "FATAL":
|
||||
return jww.LevelFatal
|
||||
case "ERROR":
|
||||
return jww.LevelError
|
||||
case "WARN":
|
||||
return jww.LevelWarn
|
||||
case "INFO":
|
||||
return jww.LevelInfo
|
||||
case "DEBUG":
|
||||
return jww.LevelDebug
|
||||
case "TRACE":
|
||||
return jww.LevelTrace
|
||||
default:
|
||||
panic("invalid log level " + level)
|
||||
}
|
||||
}
|
||||
|
||||
var uiChan chan<- Param
|
||||
|
||||
type uiWriter struct {
|
||||
|
|
|
|||
25
util/logstash/element.go
Normal file
25
util/logstash/element.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package logstash
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
jww "github.com/spf13/jwalterweatherman"
|
||||
)
|
||||
|
||||
type element string
|
||||
|
||||
var re = regexp.MustCompile(`^\[([a-zA-Z0-9-]+)\s*\] (\w+) `)
|
||||
|
||||
func (e element) areaLevel() (string, jww.Threshold) {
|
||||
m := re.FindAllStringSubmatch(string(e), 1)
|
||||
if len(m) != 1 && len(m[0]) != 3 {
|
||||
return "", jww.LevelError
|
||||
}
|
||||
return m[0][1], LogLevelToThreshold(m[0][2])
|
||||
}
|
||||
|
||||
func (e element) match(areas []string, level jww.Threshold) bool {
|
||||
a, l := e.areaLevel()
|
||||
return (len(areas) == 0 || slices.Contains(areas, a)) && l >= level
|
||||
}
|
||||
27
util/logstash/levels.go
Normal file
27
util/logstash/levels.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package logstash
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
jww "github.com/spf13/jwalterweatherman"
|
||||
)
|
||||
|
||||
// LogLevelToThreshold converts log level string to a jww Threshold
|
||||
func LogLevelToThreshold(level string) jww.Threshold {
|
||||
switch strings.ToUpper(level) {
|
||||
case "FATAL":
|
||||
return jww.LevelFatal
|
||||
case "ERROR":
|
||||
return jww.LevelError
|
||||
case "WARN":
|
||||
return jww.LevelWarn
|
||||
case "INFO":
|
||||
return jww.LevelInfo
|
||||
case "DEBUG":
|
||||
return jww.LevelDebug
|
||||
case "TRACE":
|
||||
return jww.LevelTrace
|
||||
default:
|
||||
return jww.LevelError
|
||||
}
|
||||
}
|
||||
110
util/logstash/log.go
Normal file
110
util/logstash/log.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package logstash
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
jww "github.com/spf13/jwalterweatherman"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
var DefaultHandler = New(10000)
|
||||
|
||||
func Areas() []string {
|
||||
return DefaultHandler.Areas()
|
||||
}
|
||||
|
||||
func All(areas []string, level jww.Threshold, count int) []string {
|
||||
return DefaultHandler.All(areas, level, count)
|
||||
}
|
||||
|
||||
func Size() int64 {
|
||||
return DefaultHandler.Size()
|
||||
}
|
||||
|
||||
type logger struct {
|
||||
mu sync.RWMutex
|
||||
data *ring.Ring
|
||||
}
|
||||
|
||||
func New(size int) *logger {
|
||||
return &logger{
|
||||
data: ring.New(size),
|
||||
}
|
||||
}
|
||||
|
||||
var _ io.Writer = (*logger)(nil)
|
||||
|
||||
func (l *logger) Write(p []byte) (n int, err error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if !strings.HasPrefix(string(p), "[cache ]") {
|
||||
l.data.Value = element(string(p))
|
||||
l.data = l.data.Next()
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (l *logger) Size() int64 {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
r := l.data
|
||||
var size int64
|
||||
|
||||
for i := 0; i < r.Len(); i++ {
|
||||
if e, ok := r.Value.(element); ok {
|
||||
size += int64(len(e))
|
||||
}
|
||||
r = r.Next()
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
func (l *logger) Areas() []string {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
r := l.data
|
||||
|
||||
areas := make(map[string]struct{})
|
||||
for range r.Len() {
|
||||
r = r.Prev()
|
||||
if e, ok := r.Value.(element); ok && e != "" {
|
||||
if a, _ := e.areaLevel(); a != "" {
|
||||
areas[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys := maps.Keys(areas)
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (l *logger) All(areas []string, level jww.Threshold, count int) []string {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
r := l.data
|
||||
all := len(areas) == 0 && level == jww.LevelTrace
|
||||
|
||||
var res []string
|
||||
for range r.Len() {
|
||||
r = r.Next()
|
||||
if e, ok := r.Value.(element); ok && e != "" && (all || e.match(areas, level)) {
|
||||
res = append(res, string(e))
|
||||
if count > 0 && len(res) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
33
util/logstash/log_test.go
Normal file
33
util/logstash/log_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package logstash
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
jww "github.com/spf13/jwalterweatherman"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
log := New(10)
|
||||
|
||||
s1 := "[test1 ] TRACE test1"
|
||||
s2 := "[test2 ] ERROR test2"
|
||||
s3 := "[test1 ] TRACE test3"
|
||||
|
||||
// old to new
|
||||
log.Write([]byte(s1))
|
||||
log.Write([]byte(s2))
|
||||
log.Write([]byte(s3))
|
||||
|
||||
idx := log.data
|
||||
|
||||
assert.Equal(t, []string{s1, s2, s3}, log.All(nil, jww.LevelTrace, 0))
|
||||
assert.Equal(t, []string{s1, s2, s3}, log.All([]string{}, jww.LevelTrace, 0))
|
||||
|
||||
assert.Equal(t, []string{s1, s3}, log.All([]string{"test1"}, jww.LevelTrace, 0))
|
||||
assert.Equal(t, []string{s1, s2, s3}, log.All(nil, jww.LevelTrace, 0))
|
||||
assert.Nil(t, log.All(nil, jww.LevelFatal, 0))
|
||||
|
||||
assert.Equal(t, idx, log.data, "data should not be changed after All() call")
|
||||
assert.Equal(t, []string{"test1", "test2"}, log.Areas())
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ func (m *Monitor[T]) GetFunc(get func(T)) error {
|
|||
|
||||
// mark as waited once
|
||||
m.mu.Lock()
|
||||
// TODO fix and test
|
||||
m.updated.Add(time.Nanosecond)
|
||||
m.mu.Unlock()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue