Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/upstream-check/watch.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"keywords": ["SetMaxPower"],
"paths": ["core/circuit/", "core/site.go", "server/"]
},
{
"name": "Log-Datei (Tagesdateien)",
"files": ["util/logstash/file_custom.go", "core/site_logfile.go"],
"prs": [32018],
"keywords": ["log file", "logfile", "LogFile", "logrotate", "lumberjack", "rotat"],
"paths": ["util/log.go", "util/logstash/"]
},
{
"name": "Grundlagen, auf denen der Fork aufbaut",
"files": ["core/loadpoint_lm.go"],
Expand Down
61 changes: 61 additions & 0 deletions assets/js/components/LogFile/LogFileButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<template>
<button
type="button"
class="btn flex-grow-0 d-flex align-items-center gap-2 text-nowrap"
:class="
state?.error
? 'btn-outline-danger'
: state?.enabled
? 'btn-secondary'
: 'btn-outline-secondary'
"
:aria-label="$t('logFile.title')"
data-testid="logfile-button"
@click="open"
>
<shopicon-regular-note size="s" class="icon flex-shrink-0"></shopicon-regular-note>
<span class="d-none d-xl-inline" data-testid="logfile-status">{{ status }}</span>
</button>
<LogFileModal ref="modal" @changed="load" />
</template>

<script lang="ts">
import "@h2d2/shopicons/es/regular/note";
import { defineComponent } from "vue";
import Modal from "bootstrap/js/dist/modal";
import api from "@/api";
import type { LogFileState } from "@/types/evcc-lm";
import LogFileModal from "./LogFileModal.vue";

// Custom extension: opens the log file dialog from the log page, see
// util/logstash/file_custom.go. The button shows whether the file is written.
export default defineComponent({
name: "LogFileButton",
components: { LogFileModal },
data() {
return { state: null as LogFileState | null };
},
computed: {
status() {
if (this.state?.error) return this.$t("logFile.failed");
return this.state?.enabled ? this.state.level : this.$t("logFile.off");
},
},
mounted() {
this.load();
},
methods: {
async load() {
try {
this.state = (await api.get("logfile")).data;
} catch (e) {
console.error(e);
}
},
open() {
const el = document.getElementById("logFileModal") as HTMLElement;
Modal.getOrCreateInstance(el).show();
},
},
});
</script>
200 changes: 200 additions & 0 deletions assets/js/components/LogFile/LogFileModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<template>
<GenericModal
id="logFileModal"
ref="modal"
:title="$t('logFile.title')"
data-testid="logfile-modal"
@open="open"
>
<p>{{ $t("logFile.description") }}</p>
<p v-if="error" class="text-danger" data-testid="logfile-error">{{ error }}</p>

<form class="container mx-0 px-0" @submit.prevent="save">
<div class="form-check form-switch mb-3">
<input
id="logFileEnabled"
v-model="enabled"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="logfile-enabled"
/>
<label class="form-check-label" for="logFileEnabled">
{{ $t("logFile.enabledLabel") }}
</label>
</div>

<FormRow id="logFileLevel" :label="$t('logFile.levelLabel')">
<select
id="logFileLevel"
v-model="level"
class="form-select"
data-testid="logfile-level"
>
<option v-for="l in levels" :key="l" :value="l">{{ l.toUpperCase() }}</option>
</select>
</FormRow>

<FormRow id="logFileDays" :label="$t('logFile.daysLabel')">
<div class="input-group">
<input
id="logFileDays"
v-model.number="days"
type="number"
step="any"
class="form-control"
data-testid="logfile-days"
/>
<span class="input-group-text">{{ $t("logFile.daysUnit") }}</span>
</div>
</FormRow>

<dl v-if="state" class="small mb-0 mt-4" data-testid="logfile-state">
<div class="d-flex gap-2">
<dt class="fw-normal evcc-gray">{{ $t("logFile.folder") }}</dt>
<dd class="mb-1 text-break" data-testid="logfile-dir">
{{ state.dir || "-" }}
</dd>
</div>
<div class="d-flex gap-2">
<dt class="fw-normal evcc-gray">{{ $t("logFile.files") }}</dt>
<dd class="mb-1" data-testid="logfile-files">{{ state.files }}</dd>
</div>
<div class="d-flex gap-2">
<dt class="fw-normal evcc-gray">{{ $t("logFile.size") }}</dt>
<dd class="mb-1" data-testid="logfile-size">{{ formatSize(state.size) }}</dd>
</div>
<div v-if="state.error" class="d-flex gap-2 text-danger">
<dt class="fw-normal">{{ $t("logFile.failed") }}</dt>
<dd class="mb-1" data-testid="logfile-failure">{{ state.error }}</dd>
</div>
</dl>

<div class="mt-4 d-flex justify-content-between gap-2 flex-column flex-sm-row">
<button
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.general.cancel") }}
</button>

<button
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving || !changed"
data-testid="logfile-save"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import FormRow from "../Config/FormRow.vue";
import api from "@/api";
import type { LogFileState } from "@/types/evcc-lm";

const MIN_DAYS = 1;
const MAX_DAYS = 90;
const LEVELS = ["error", "warn", "info", "debug", "trace"];

function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}

// Custom extension: switch the log file on, its level and retention, see
// util/logstash/file_custom.go. No min, max or step on the input: the browser
// would block the submit without a message, the range is checked in save.
export default defineComponent({
name: "LogFileModal",
components: { FormRow, GenericModal },
emits: ["changed"],
data() {
return {
saving: false,
error: "",
state: null as LogFileState | null,
enabled: false,
level: "debug",
days: 14,
levels: LEVELS,
};
},
computed: {
changed() {
const s = this.state;
return (
!s || s.enabled !== this.enabled || s.level !== this.level || s.days !== this.days
);
},
},
methods: {
formatSize,
async open() {
this.saving = false;
this.error = "";
try {
this.apply((await api.get("logfile")).data);
} catch (e) {
this.error = this.message(e);
}
},
apply(state: LogFileState) {
this.state = state;
this.enabled = state.enabled;
this.level = state.level;
this.days = state.days;
},
message(e: any): string {
return e?.response?.data?.error || e?.message || this.$t("logFile.invalid");
},
async save() {
if (!Number.isInteger(this.days) || this.days < MIN_DAYS || this.days > MAX_DAYS) {
this.error = this.$t("logFile.invalidDays", { min: MIN_DAYS, max: MAX_DAYS });
return;
}

this.saving = true;
this.error = "";

try {
const res = await api.post("logfile", {
enabled: this.enabled,
level: this.level,
days: this.days,
});
this.apply(res.data);
this.$emit("changed");
} catch (e) {
this.error = this.message(e);
}

this.saving = false;
},
},
});
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
dt {
min-width: 5rem;
}
</style>
16 changes: 16 additions & 0 deletions assets/js/types/evcc-lm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,19 @@ export interface LmConfigLoadpoint {
phaseScale3pDelay?: number; // ns before scaling up, 0/empty = enable delay
phaseScale1pDelay?: number; // ns before scaling down, 0/empty = disable delay
}

/** Log file setting and state, see core/site_logfile.go. */
export interface LogFileState {
enabled: boolean;
/** Level of the file: error, warn, info, debug or trace. */
level: string;
/** Days the files are kept. */
days: number;
/** Folder of the files, set by evcc. */
dir: string;
files: number;
/** Bytes of all files. */
size: number;
/** Why the file is off, empty = none. */
error?: string;
}
4 changes: 4 additions & 0 deletions assets/js/views/Log.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
></shopicon-regular-download>
</a>
</div>
<LogFileButton />
<!-- custom: log file, see util/logstash/file_custom.go -->
</div>
<div class="col-6 offset-lg-1 col-lg-4 mb-4 mb-lg-0">
<input
Expand Down Expand Up @@ -115,6 +117,7 @@ import Header from "../components/Top/Header.vue";
import Play from "../components/MaterialIcon/Play.vue";
import ProgressRing from "../components/MaterialIcon/ProgressRing.vue";
import MultiSelect from "../components/Helper/MultiSelect.vue";
import LogFileButton from "../components/LogFile/LogFileButton.vue";
import api from "../api";
import store from "../store";
import { defineComponent, type PropType } from "vue";
Expand All @@ -132,6 +135,7 @@ export default defineComponent({
Play,
ProgressRing,
MultiSelect,
LogFileButton,
},
props: {
areas: { type: Array as PropType<string[]>, default: () => [] },
Expand Down
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/evcc-io/evcc/ui"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/logstash"
"github.com/evcc-io/evcc/util/pipe"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/telemetry"
Expand Down Expand Up @@ -556,6 +557,8 @@ func runRoot(cmd *cobra.Command, args []string) {
case <-time.After(max(conf.Interval, 5*time.Second)):
}

logstash.CloseFile() // custom: write out the log file, see util/logstash/file_custom.go

// exit code 1 on error
os.Exit(cast.ToInt(err != nil))
}
3 changes: 3 additions & 0 deletions core/fork_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ var forkHooks = []struct {
{"api/globalconfig/types.go", "TariffRefs.IsConfigured", []string{"FeedInEeg"}},
{"api/globalconfig/types.go", "TariffRefs.Used", []string{"FeedInEeg"}},
{"charger/switchsocket.go", "NewSwitchSocketFromConfig", []string{"RatedPower"}},
{"util/log.go", "newLogger", []string{"Output"}},
{"cmd/root.go", "runRoot", []string{"CloseFile"}},
}

// forkUiHooks lists the fork's mounts in evcc's ui files
Expand All @@ -62,6 +64,7 @@ var forkUiHooks = []struct {
{"assets/js/components/Config/LoadpointModal.vue", []string{"<PhaseSwitchFields", "emptyUnsetPhaseSwitch", "chargerIsStages"}},
{"assets/js/components/Loadpoints/Loadpoint.vue", []string{"chargerStages: Boolean"}},
{"assets/js/components/Loadpoints/Mode.vue", []string{"chargerStages: Boolean", "this.switchDevice || this.chargerStages"}},
{"assets/js/views/Log.vue", []string{"<LogFileButton", "LogFileButton,"}},
{"assets/js/components/Config/TariffCard.vue", []string{"<FeedInEegSummary"}},
{"assets/js/components/Config/TariffModal.vue", []string{"feedInEeg", "tariff-planner-optimizer"}},
{"assets/js/views/Energy.vue", []string{"gridChartSeries", ":feed-in-eeg=\"feedInEeg\"", "loadFeedInSplit(requestKey)", "withoutFeedInEeg("}},
Expand Down
3 changes: 3 additions & 0 deletions core/keys/site_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,7 @@ const (
// export under a second feed-in tariff, see core/site_feedin_eeg.go
FeedInEegEntity = "feedInEegEntity" // Home Assistant energy counter of the EEG export
TariffFeedInEeg = "tariffFeedInEeg" // published: current EEG price

// log in daily files, see core/site_logfile.go
LogFile = "logFile"
)
Loading
Loading