Log Viewer: Allow retrieving logs from ring buffer (#13330)

This commit is contained in:
andig 2024-04-14 09:50:08 +02:00 • committed by GitHub
parent 75179b05bb
commit 334ffc15b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1232 additions and 358 deletions

View file

@ -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);
}

View file

@ -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();
}

View file

@ -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;

View file

@ -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.");
}

View file

@ -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>

View 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>

View 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>

View file

@ -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") }}

View file

@ -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>

View file

@ -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;
},

View file

@ -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();

View file

@ -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;

View file

@ -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>

View file

@ -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}`;
}

View file

@ -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 () => {

View file

@ -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();

View file

@ -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
View 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>

View file

@ -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>

View file

@ -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);

View file

@ -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
})

View file

@ -29,6 +29,7 @@ const (
TariffPriceLoadpoints = "tariffPriceLoadpoints"
Vehicles = "vehicles"
PasswordConfigured = "passwordConfigured"
Interval = "interval"
// meters
GridMeter = "gridMeter"

View file

@ -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 {

View file

@ -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"

View file

@ -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"

View file

@ -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)
}},
}

View file

@ -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)
}

View file

@ -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();
});

View file

@ -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();

View file

@ -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
View 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");
});
});

View file

@ -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();
});
});

View file

@ -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

View file

@ -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()

View file

@ -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
View 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
View 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
View 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
View 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())
}

View file

@ -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()