UI: add problem reporting (GitHub discussion/issue) (#23707)

This commit is contained in:
Michael Geers 2025-09-21 12:46:54 +02:00 • committed by GitHub
parent a3af19a023
commit b7a4e309ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1753 additions and 59 deletions

View file

@ -682,3 +682,13 @@ input::-webkit-date-and-time-value {
text-overflow: ellipsis;
}
}
.textarea--tiny {
font-size: 8px;
}
@media (min-width: 576px) {
.textarea--tiny {
font-size: 0.875em;
}
}

View file

@ -62,13 +62,9 @@
<p class="flex-sm-grow-1 opacity-50 me-sm-3">
{{ $t("help.issueDescription") }}
</p>
<a
href="https://github.com/evcc-io/evcc/issues"
target="_blank"
class="btn btn-outline-primary text-nowrap"
>
<router-link to="/issue" class="btn btn-outline-primary text-nowrap">
{{ $t("help.issueButton") }}
</a>
</router-link>
</div>
<div

View file

@ -0,0 +1,125 @@
<template>
<slot :copy="handleCopy" :copied="copied" :copying="copying"></slot>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "CopyButton",
props: {
content: {
type: String,
required: true,
},
targetElement: {
type: Object,
default: null,
},
},
data() {
return {
copied: false,
copying: false,
};
},
methods: {
async handleCopy() {
if (this.copying) return;
this.copying = true;
try {
// Modern browser API - works in secure contexts and PWAs
if (this.hasClipboardAPI()) {
await navigator.clipboard.writeText(this.content);
this.showCopiedFeedback();
} else {
// Fallback method for HTTP/non-secure contexts
this.fallbackCopy();
}
} catch (err) {
console.error("Failed to copy to clipboard:", err);
this.fallbackCopy();
} finally {
this.copying = false;
}
},
fallbackCopy() {
try {
let targetEl = this.targetElement as HTMLTextAreaElement;
let createdElement = false;
if (!targetEl) {
// Create a temporary textarea element if no target provided
targetEl = document.createElement("textarea");
targetEl["value"] = this.content;
targetEl["style"]["position"] = "fixed";
targetEl["style"]["left"] = "-999999px";
targetEl["style"]["top"] = "-999999px";
document.body.appendChild(targetEl as Node);
createdElement = true;
}
// Select and copy the text
targetEl["focus"]();
targetEl["select"]();
// For input/textarea elements, also try setSelectionRange
if (targetEl["setSelectionRange"]) {
targetEl["setSelectionRange"](0, targetEl["value"].length);
} else if ((targetEl as any)["createTextRange"]) {
// IE fallback
const range = (targetEl as any)["createTextRange"]();
range.select();
}
const successful = document.execCommand("copy");
if (createdElement) {
document.body.removeChild(targetEl as Node);
}
if (successful) {
this.showCopiedFeedback();
} else {
alert("Copy failed. Please copy manually.");
}
} catch (err) {
console.error("Fallback copy failed:", err);
alert("Copy failed. Please copy manually.");
}
},
isSecureContext() {
return (
window.isSecureContext ||
window.location.protocol === "https:" ||
window.location.hostname === "localhost"
);
},
isPWA() {
// Check if running as PWA (installed web app)
return (
window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as any).standalone === true || // iOS Safari
document.referrer.includes("android-app://")
); // Android
},
hasClipboardAPI() {
// PWAs get clipboard access even over HTTP in many cases
return navigator.clipboard && (this.isSecureContext() || this.isPWA());
},
showCopiedFeedback() {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
},
},
});
</script>

View file

@ -47,6 +47,7 @@ export default defineComponent({
uncloseable: Boolean,
fade: String,
size: String,
autofocus: { type: Boolean, default: true },
},
emits: ["open", "opened", "close", "closed"],
data() {
@ -95,13 +96,16 @@ export default defineComponent({
handleShown() {
console.log(this.dataTestid, "> shown");
this.$emit("opened");
// focus first input or select
this.$nextTick(() => {
const firstInput = this.$refs["modalBody"]?.querySelector("input, select, button");
if (firstInput instanceof HTMLElement) {
firstInput.focus();
}
});
// focus first input or select if autofocus is enabled
if (this.autofocus) {
this.$nextTick(() => {
const firstInput =
this.$refs["modalBody"]?.querySelector("input, select, button");
if (firstInput instanceof HTMLElement) {
firstInput.focus();
}
});
}
this.isModalVisible = true;
},
handleHide() {

View file

@ -0,0 +1,165 @@
<template>
<div class="mb-5" :data-testid="`${id}-additional-item`">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-baseline gap-2">
<strong>{{ title }}</strong>
<button
type="button"
class="btn btn-link btn-sm p-0 text-muted content"
:class="{ 'content--dimmed': !included }"
@click="openModal"
>
{{ $t("issue.additional.showDetails") }}
</button>
</div>
<div class="form-check form-switch mb-0">
<input
:id="id"
v-model="isIncluded"
class="form-check-input"
:class="switchClass"
type="checkbox"
role="switch"
/>
<label class="form-check-label" :for="id">
{{ $t("issue.additional.include") }}
</label>
</div>
</div>
<div class="content" :class="{ 'content--dimmed': !included }">
<slot name="description" :openModal="openModal"></slot>
</div>
<!-- Modal using GenericModal -->
<GenericModal
:id="modalId"
:title="title"
size="lg"
:autofocus="false"
:data-testid="`${id}-modal`"
@closed="modalClosed"
>
<!-- Custom controls slot inside modal -->
<slot name="controls"></slot>
<textarea
ref="textarea"
:value="localContent"
class="form-control font-monospace textarea--tiny"
:rows="textareaRows"
style="white-space: pre; overflow-wrap: normal; resize: vertical"
@input="handleLocalInput"
></textarea>
<div class="d-flex justify-content-end mt-3">
<button
v-if="hasChanges"
type="button"
class="btn btn-primary"
@click="applyAndClose"
>
{{ $t("config.general.applyAndClose") }}
</button>
<button v-else type="button" class="btn btn-secondary" @click="closeModal">
{{ $t("config.general.close") }}
</button>
</div>
</GenericModal>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Modal from "bootstrap/js/dist/modal";
import GenericModal from "../Helper/GenericModal.vue";
import type { HelpType } from "./types";
export default defineComponent({
name: "IssueAdditionalItem",
components: {
GenericModal,
},
props: {
title: { type: String, required: true },
id: { type: String, required: true },
included: { type: Boolean, required: true },
content: { type: String, default: "" },
description: { type: String, default: "" },
helpType: { type: String as PropType<HelpType> },
},
emits: ["update:included", "update:content"],
data() {
return {
localContent: "",
};
},
computed: {
isIncluded: {
get(): boolean {
return this.included;
},
set(value: boolean) {
this.$emit("update:included", value);
},
},
modalId(): string {
return `${this.id}Modal`;
},
textareaRows(): number {
if (!this.localContent) return 26;
const lines = this.localContent.split("\n").length;
return Math.max(26, lines + 1);
},
hasChanges(): boolean {
return this.localContent !== this.content;
},
switchClass(): string {
return this.included && this.helpType === "issue" ? "bg-danger border-danger" : "";
},
},
watch: {
content(newContent: string) {
this.localContent = newContent;
},
},
methods: {
openModal() {
this.localContent = this.content;
const modalElement = document.getElementById(this.modalId) as HTMLElement;
if (modalElement) {
Modal.getOrCreateInstance(modalElement).show();
}
},
modalClosed() {
// Reset local content if there are unsaved changes
this.localContent = this.content;
},
handleLocalInput(event: Event) {
const target = event.target as HTMLTextAreaElement;
this.localContent = target.value;
},
applyAndClose() {
this.$emit("update:content", this.localContent);
this.closeModal();
},
closeModal() {
const modalElement = document.getElementById(this.modalId) as HTMLElement;
if (modalElement) {
Modal.getOrCreateInstance(modalElement).hide();
}
},
},
});
</script>
<style scoped>
.content {
opacity: 1;
transition: opacity 0.3s ease;
}
.content--dimmed {
opacity: 0.5;
}
</style>

View file

@ -0,0 +1,128 @@
<template>
<GenericModal
id="issueSummaryModal"
data-testid="issue-summary-modal"
:title="$t('issue.summary.title')"
:size="isTwoStepMode ? 'lg' : 'md'"
:autofocus="false"
>
<!-- Instructions (only shown in two-step mode) -->
<div v-if="isTwoStepMode" class="alert alert-secondary mb-4">
<strong>{{ $t("issue.summary.instructions") }}</strong>
</div>
<!-- Step 1: Create Issue -->
<div :class="{ 'mb-4': isTwoStepMode }">
<h6 v-if="isTwoStepMode" class="mb-2">
{{ $tt("issue.summary.stepOne") }}
</h6>
<p class="text-muted small mb-3">
{{
isTwoStepMode
? $t("issue.summary.step1Description")
: $t("issue.summary.singleStepDescription")
}}
</p>
<div class="d-flex justify-content-start">
<a
:href="githubUrl"
target="_blank"
class="btn"
:class="buttonClass"
@click="clearSessionStorage"
>
{{ $tt("issue.summary.confirmationButton") }}
</a>
</div>
</div>
<hr v-if="isTwoStepMode" class="my-4" />
<!-- Step 2: Copy Additional Information (only shown in two-step mode) -->
<div v-if="isTwoStepMode" class="mb-4">
<h6 class="mb-2">{{ $t("issue.summary.stepTwo") }}</h6>
<p class="text-muted small mb-3">{{ $t("issue.summary.step2Description") }}</p>
<div class="d-flex justify-content-start mb-4">
<CopyButton :content="additional" :targetElement="$refs['summaryTextarea']">
<template #default="{ copy, copied, copying }">
<button
type="button"
class="btn"
:class="
helpType === 'discussion'
? 'btn-outline-success'
: 'btn-outline-danger'
"
:disabled="copying"
@click="copy"
>
{{
copied ? $t("issue.summary.copied") : $t("issue.summary.copyButton")
}}
</button>
</template>
</CopyButton>
</div>
<div class="mb-2">
<textarea
ref="summaryTextarea"
:value="additional"
class="form-control font-monospace border-secondary textarea--tiny"
:rows="additionalRows"
readonly
style="white-space: pre; overflow-wrap: normal"
data-testid="issue-summary-modal-textarea"
></textarea>
</div>
</div>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import GenericModal from "@/components/Helper/GenericModal.vue";
import CopyButton from "@/components/Helper/CopyButton.vue";
import { generateGitHubContent, generateGitHubUrl } from "./template";
import type { GitHubContent, HelpType, IssueData, Sections } from "./types";
export default defineComponent({
name: "SummaryModal",
components: {
GenericModal,
CopyButton,
},
props: {
helpType: { type: String as PropType<HelpType>, required: true },
buttonClass: { type: String, required: true },
issueData: { type: Object as PropType<IssueData>, required: true },
sections: { type: Object as PropType<Sections>, required: true },
},
emits: ["submitted"],
computed: {
content(): GitHubContent {
return generateGitHubContent(this.issueData, this.sections);
},
githubUrl(): string {
return generateGitHubUrl(this.helpType, this.issueData.title, this.content.body);
},
additional(): string {
return this.content.additional || "";
},
isTwoStepMode(): boolean {
return !!this.content.additional;
},
additionalRows(): number {
const lines = this.additional.split("\n").length;
return Math.max(26, lines);
},
},
methods: {
clearSessionStorage() {
this.$emit("submitted");
},
$tt(key: string): string {
return this.$t(`${key}${this.helpType === "discussion" ? "Discussion" : "Issue"}`);
},
},
});
</script>

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { formatJson } from "./format";
describe("formatJson", () => {
it("formats basic object", () => {
const obj = { foo: "bar", baz: 123 };
const result = formatJson(obj);
expect(result).toBe(`{
"foo": "bar",
"baz": 123
}`);
});
it("expands arrays with expand keys", () => {
const obj = {
items: ["foo", "bar"],
other: ["baz", "qux"],
};
const result = formatJson(obj, ["items"]);
expect(result).toBe(`{
"items": [
"foo",
"bar"
],
"other": ["baz","qux"]
}`);
});
it("expands objects with expand keys", () => {
const obj = {
config: { alpha: 1, beta: 2 },
other: { gamma: 3 },
};
const result = formatJson(obj, ["config"]);
expect(result).toBe(`{
"config": {
"alpha": 1,
"beta": 2
},
"other": {"gamma":3}
}`);
});
it("keeps nested objects in arrays single-lined", () => {
const obj = {
items: [
{ foo: "bar", baz: 123 },
{ qux: "test", num: 456 },
],
};
const result = formatJson(obj, ["items"]);
expect(result).toBe(`{
"items": [
{"foo":"bar","baz":123},
{"qux":"test","num":456}
]
}`);
});
});

View file

@ -0,0 +1,50 @@
export function formatJson(obj: any, expandKeys: string[] = []): string {
if (!obj || typeof obj !== "object") {
return JSON.stringify(obj, null, 2);
}
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
let valueStr: string;
// Check if this key should be expanded (only if not empty)
if (
expandKeys.includes(key) &&
(Array.isArray(value) || (typeof value === "object" && value !== null))
) {
if (Array.isArray(value)) {
if (value.length === 0) {
// Keep empty arrays compact
valueStr = "[]";
} else {
const arrayItems = value.map((item) => {
const itemStr = JSON.stringify(item);
return ` ${itemStr.replace(/\\n/g, "\n")}`;
});
valueStr = `[\n${arrayItems.join(",\n")}\n ]`;
}
} else {
// Object expansion
const objEntries = Object.entries(value);
if (objEntries.length === 0) {
// Keep empty objects compact
valueStr = "{}";
} else {
const objItems = objEntries.map(([k, v]) => {
const itemStr = JSON.stringify(v);
return ` ${JSON.stringify(k)}: ${itemStr.replace(/\\n/g, "\n")}`;
});
valueStr = `{\n${objItems.join(",\n")}\n }`;
}
}
} else {
// Single line for everything else
valueStr = JSON.stringify(value).replace(/\\n/g, "\n");
}
lines.push(` ${JSON.stringify(key)}: ${valueStr}`);
}
return `{\n${lines.join(",\n")}\n}`;
}

View file

@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import { generateGitHubContent } from "./template";
import type { IssueData, Sections } from "./types";
describe("Issue Utils", () => {
const mockIssueData: IssueData = {
title: "Test Issue",
description: "This is a test description",
steps: "1. Do something\n2. See error",
version: "v1.0.0",
};
const mockSections: Sections = {
yamlConfig: { included: true, content: "key: value\nother: test" },
uiConfig: { included: true, content: '{"setting": "value"}' },
state: { included: false, content: "" },
logs: { included: true, content: "2023-01-01 ERROR: Something went wrong" },
};
describe("generateGitHubContent", () => {
it("generates body with additional content", () => {
const result = generateGitHubContent(mockIssueData, mockSections);
expect(result.body).toBe(`## Description
This is a test description
## Steps to Reproduce
1. Do something
2. See error
## Configuration (YAML)
\`\`\`yaml
key: value
other: test
\`\`\`
## Configuration (UI)
\`\`\`json5
{"setting": "value"}
\`\`\`
## Logs
\`\`\`
2023-01-01 ERROR: Something went wrong
\`\`\`
## Version
v1.0.0`);
expect(result.additional).toBeUndefined();
});
it("uses placeholder when exceeding limit", () => {
const longContent = "x".repeat(8000);
const longSections: Sections = {
...mockSections,
yamlConfig: {
included: true,
content: longContent,
},
};
const result = generateGitHubContent(mockIssueData, longSections);
expect(result.body).toContain("⚠️ RETURN TO EVCC TAB → COPY STEP 2 → PASTE HERE");
expect(result.body).not.toContain("## Configuration (YAML)");
expect(result.additional).toBeDefined();
expect(result.additional).toContain(longContent);
});
it("handles empty steps", () => {
const issueWithoutSteps: IssueData = {
...mockIssueData,
steps: "",
};
const result = generateGitHubContent(issueWithoutSteps, mockSections);
expect(result.body).toContain("## Steps to Reproduce");
expect(result.body).toContain("## Steps to Reproduce\n\n\n\n## Configuration");
expect(result.body).toContain("## Configuration (YAML)");
});
it("includes only enabled sections", () => {
const selectiveSections: Sections = {
yamlConfig: { included: true, content: "yaml: content" },
uiConfig: { included: false, content: "ui: content" },
state: { included: true, content: "state: content" },
logs: { included: false, content: "log: content" },
};
const result = generateGitHubContent(mockIssueData, selectiveSections);
expect(result.body).toContain("## Configuration (YAML)");
expect(result.body).toContain("## System State");
expect(result.body).not.toContain("## Configuration (UI)");
expect(result.body).not.toContain("## Logs");
});
it("handles all sections disabled", () => {
const emptySections: Sections = {
yamlConfig: { included: false, content: "" },
uiConfig: { included: false, content: "" },
state: { included: false, content: "" },
logs: { included: false, content: "" },
};
const result = generateGitHubContent(mockIssueData, emptySections);
expect(result.body).not.toContain("## Configuration");
expect(result.body).not.toContain("## System State");
expect(result.body).not.toContain("## Logs");
});
});
});

View file

@ -0,0 +1,77 @@
import type { IssueData, Sections, GitHubContent, Template, HelpType } from "./types";
// Constants
const PLACEHOLDER = "⚠️ RETURN TO EVCC TAB → COPY STEP 2 → PASTE HERE";
const MAX_BODY_LENGTH = 8000;
function toString(sections: Template): string {
return sections
.map((section) => (Array.isArray(section) ? section.join("\n") : section))
.join("\n\n");
}
export function generateGitHubContent(issue: IssueData, sections: Sections): GitHubContent {
const additional = generateAdditional(sections);
// First attempt: generate body with summary details included
let body = generateBody(issue, additional);
// Check if it fits within the limit
if (encodeURIComponent(issue.title + body).length <= MAX_BODY_LENGTH) {
return { body };
}
// If too long, generate body with placeholder and return additional separately
body = generateBody(issue, PLACEHOLDER);
return { body, additional };
}
function generateBody(issue: IssueData, additional: string): string {
const sections: Template = [
"## Description",
issue.description,
"## Steps to Reproduce",
issue.steps,
additional,
"## Version",
issue.version,
];
return toString(sections);
}
function generateAdditional(sections: Sections): string {
const result: Template = [];
if (sections.yamlConfig.included) {
result.push("## Configuration (YAML)");
result.push(["```yaml", sections.yamlConfig.content, "```"]);
}
if (sections.uiConfig.included) {
result.push("## Configuration (UI)");
result.push(["```json5", sections.uiConfig.content, "```"]);
}
if (sections.state.included) {
result.push("## System State");
result.push(["```json5", sections.state.content, "```"]);
}
if (sections.logs.included) {
result.push("## Logs");
result.push(["```", sections.logs.content, "```"]);
}
return toString(result);
}
// Generates GitHub URL for issues or discussions
export function generateGitHubUrl(type: HelpType, title: string, body: string): string {
const baseUrl =
type === "discussion"
? "https://github.com/evcc-io/evcc/discussions/new?category=need-help&"
: "https://github.com/evcc-io/evcc/issues/new?";
return `${baseUrl}title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
}

28
assets/js/components/Issue/types.d.ts vendored Normal file
View file

@ -0,0 +1,28 @@
export interface IssueData {
title: string;
description: string;
steps: string;
version: string;
}
export interface SectionData {
included: boolean;
content: string;
}
export interface Sections {
yamlConfig: SectionData;
uiConfig: SectionData;
state: SectionData;
logs: SectionData;
}
export interface GitHubContent {
body: string;
additional?: string;
}
// First level items are joint with empty line (\n\n), second level with line wrap (\n)
export type Template = (string | string[])[];
export type HelpType = "discussion" | "issue";

View file

@ -94,6 +94,12 @@ export default function setupRouter(i18n: VueI18nInstance) {
};
},
},
{
path: "/issue",
component: () => import("./views/Issue.vue"),
beforeEnter: ensureAuth,
props: true,
},
],
});
router.beforeEach(async () => {

View file

@ -77,6 +77,8 @@ export interface State {
siteTitle?: string;
vehicles: Record<string, Vehicle>;
authDisabled?: boolean;
config?: string;
database?: string;
}
export interface Config {

4
assets/js/utils/log.ts Normal file
View file

@ -0,0 +1,4 @@
export const LOG_LEVELS = ["fatal", "error", "warn", "info", "debug", "trace"] as const;
export const DEFAULT_LOG_LEVEL = "debug";
export type LogLevel = (typeof LOG_LEVELS)[number];

View file

@ -328,6 +328,9 @@
<router-link to="/log" class="btn btn-outline-secondary">
{{ $t("config.system.logs") }}
</router-link>
<router-link to="/issue" class="btn btn-outline-secondary">
{{ $t("help.issueButton") }}
</router-link>
<button
class="btn btn-outline-secondary text-truncate"
@click="openModal('backupRestoreModal')"

562
assets/js/views/Issue.vue Normal file
View file

@ -0,0 +1,562 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader :title="$t('issue.title')" />
<div class="row">
<main class="col-12">
<div class="mb-5">
<p class="text-muted">{{ $t("issue.description") }}</p>
</div>
<!-- Help Type Selection -->
<div class="mb-5">
<h5 class="mb-3">{{ $t("issue.helpType.title") }}</h5>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="form-check">
<input
id="helpTypeHelp"
v-model="helpType"
type="radio"
value="discussion"
class="form-check-input"
name="helpType"
/>
<label for="helpTypeHelp" class="form-check-label">
<strong>{{ $t("issue.helpType.discussion") }}</strong>
<br />
<small class="text-muted">
{{ $t("issue.helpType.discussionDescription") }}
</small>
</label>
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-check">
<input
id="helpTypeBug"
v-model="helpType"
type="radio"
value="issue"
class="form-check-input"
name="helpType"
/>
<label for="helpTypeBug" class="form-check-label">
<strong>{{ $t("issue.helpType.issue") }}</strong>
<br />
<small class="text-muted">
{{ $t("issue.helpType.issueDescription") }}
</small>
</label>
</div>
</div>
</div>
</div>
<form v-if="helpType" @submit.prevent="handleFormSubmit">
<!-- Essential Form Section -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>
{{ $tt("issue.subTitle") }}
</h4>
</div>
<!-- Two Column Layout -->
<div class="row mb-5 g-5">
<!-- Left Column: Form Fields -->
<div class="col-12 col-lg-6">
<div class="mb-4">
<label for="issueTitle" class="form-label">
{{ $t("issue.issueTitle") }} *
</label>
<input
id="issueTitle"
v-model="issue.title"
type="text"
class="form-control"
placeholder="Brief description of the problem"
required
/>
</div>
<div class="mb-4">
<label for="issueDescription" class="form-label">
{{ $t("issue.issueDescription") }} *
</label>
<textarea
id="issueDescription"
v-model="issue.description"
class="form-control"
rows="6"
placeholder="Describe what you expected to happen and what actually happened..."
required
></textarea>
</div>
<div class="mb-4">
<label for="stepsToReproduce" class="form-label">
{{ $t("issue.stepsToReproduce") }} *
</label>
<textarea
id="stepsToReproduce"
v-model="issue.steps"
class="form-control"
rows="6"
placeholder="1. Go to...&#10;2. Click on...&#10;3. See error..."
required
></textarea>
</div>
<div class="mb-4">
<label for="version" class="form-label">
{{ $t("issue.version") }}
</label>
<input
id="version"
v-model="versionString"
type="text"
class="form-control"
required
readonly
/>
</div>
<div class="text-end">
<small class="text-muted">* required</small>
</div>
</div>
<!-- Right Column: Toggleable Sections -->
<div class="col-12 col-lg-6">
<div class="mb-4">
<h5>{{ $t("issue.additional.title") }}</h5>
<p class="text-muted small">
{{ $t("issue.additional.description") }}
</p>
</div>
<!-- Additional Items -->
<IssueAdditionalItem
id="issueYamlConfig"
:included="sections.yamlConfig.included"
:title="$t('issue.additional.yamlConfig')"
:content="sections.yamlConfig.content"
:helpType="helpType"
@update:included="sections.yamlConfig.included = $event"
@update:content="sections.yamlConfig.content = $event"
>
<template #description>
<p class="text-muted small">
{{ $t("issue.additional.yamlConfigDescription") }}<br />
<span v-if="configPath"
>{{ $t("issue.additional.source") }}:
<code>{{ configPath }}</code></span
>
</p>
</template>
</IssueAdditionalItem>
<IssueAdditionalItem
id="issueUiConfig"
:included="sections.uiConfig.included"
:title="$t('issue.additional.uiConfig')"
:content="sections.uiConfig.content"
:helpType="helpType"
@update:included="sections.uiConfig.included = $event"
@update:content="sections.uiConfig.content = $event"
>
<template #description>
<p class="text-muted small">
{{ $t("issue.additional.uiConfigDescription") }}<br />
<span v-if="databasePath">
{{ $t("issue.additional.source") }}:
<code>{{ databasePath }}</code>
</span>
</p>
</template>
</IssueAdditionalItem>
<!-- Logs Section with Special Controls -->
<IssueAdditionalItem
id="issueLogs"
:included="sections.logs.included"
:title="$t('issue.additional.logs')"
:content="sections.logs.content"
:helpType="helpType"
@update:included="sections.logs.included = $event"
@update:content="sections.logs.content = $event"
>
<template #description="{ openModal }">
<p class="text-muted small">
{{ $t("issue.additional.logsDescription") }}<br />
Params:
<button
type="button"
class="btn btn-link btn-sm p-0 text-decoration-underline small"
@click="openModal"
>
{{ logLevel }}, {{ logCount }} lines,
{{ logAreasLabel }}</button
><br />
{{ $t("issue.additional.source") }}:
<router-link to="/log">{{ $t("log.title") }}</router-link>
</p>
</template>
<template #controls>
<div class="d-flex gap-3 mb-3">
<div class="flex-shrink-0">
<select
id="logLevel"
v-model="logLevel"
class="form-select"
>
<option
v-for="level in logLevels"
:key="level"
:value="level"
>
{{ level.toUpperCase() }}
</option>
</select>
</div>
<div class="flex-shrink-0">
<div class="input-group log-lines-input">
<input
v-model.number="logCount"
type="number"
class="form-control text-end log-count-input"
min="0"
step="25"
/>
<span class="input-group-text">
{{ $t("issue.additional.lines") }}
</span>
</div>
</div>
<div class="flex-grow-1">
<MultiSelect
v-model="logAreas"
:options="logAreaOptions"
:placeholder="$t('log.areas')"
:selectAllLabel="$t('log.selectAll')"
>
{{ logAreasLabel }}
</MultiSelect>
</div>
</div>
</template>
</IssueAdditionalItem>
<IssueAdditionalItem
id="issueState"
:included="sections.state.included"
:title="$t('issue.additional.state')"
:content="sections.state.content"
:helpType="helpType"
@update:included="sections.state.included = $event"
@update:content="sections.state.content = $event"
>
<template #description>
<p class="text-muted small">
{{ $t("issue.additional.stateDescription") }}<br />
{{ $t("issue.additional.source") }}:
<a :href="apiStateUrl" target="_blank">{{ apiStateUrl }}</a>
</p>
</template>
</IssueAdditionalItem>
</div>
</div>
<!-- Essential Section Actions -->
<div class="d-flex justify-content-end gap-3 mb-5">
<button type="submit" class="btn" :class="buttonClass">
{{ buttonText }}
</button>
</div>
</form>
</main>
</div>
<!-- Issue Summary Modal -->
<SummaryModal
:help-type="helpType"
:button-class="buttonClass"
:issue-data="issueData"
:sections="sections"
@submitted="clearSessionStorage"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import TopHeader from "@/components/Top/Header.vue";
import MultiSelect from "@/components/Helper/MultiSelect.vue";
import IssueAdditionalItem from "@/components/Issue/AdditionalItem.vue";
import SummaryModal from "@/components/Issue/SummaryModal.vue";
import Modal from "bootstrap/js/dist/modal";
import api from "@/api";
import store from "@/store";
import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log";
import { formatJson } from "@/components/Issue/format";
import type { HelpType, IssueData } from "@/components/Issue/types";
// Keys that should be expanded (1-level expansion for arrays and objects)
const EXPAND_KEYS = [
"battery",
"forecast",
"loadpoints",
"pv",
"vehicles",
"statistics",
"charger",
"site",
"meter",
"vehicle",
];
type SectionType = "yamlConfig" | "uiConfig" | "logs" | "state";
interface SectionData {
content: string;
included: boolean;
}
export default defineComponent({
name: "Issue",
components: {
TopHeader,
MultiSelect,
IssueAdditionalItem,
SummaryModal,
},
data() {
return {
// Help type selection
helpType: (sessionStorage.getItem("issue.helpType") as HelpType) || "discussion",
// Essential form data
issue: {
title: sessionStorage.getItem("issue.title") || "",
description: sessionStorage.getItem("issue.description") || "",
steps: sessionStorage.getItem("issue.steps") || "",
},
// Section data with included flags
sections: {
yamlConfig: { content: "", included: true },
uiConfig: { content: "", included: true },
logs: { content: "", included: true },
state: { content: "", included: false },
} as Record<SectionType, SectionData>,
// Log configuration
logLevels: [...LOG_LEVELS],
logLevel: DEFAULT_LOG_LEVEL,
logCount: 25,
logAreas: [] as string[],
logAvailableAreas: [] as string[],
};
},
computed: {
versionString(): string {
return `v${store.state.version || ""}`;
},
configPath(): string | undefined {
return store.state.config;
},
databasePath(): string | undefined {
return store.state.database;
},
issueData(): IssueData {
return { ...this.issue, version: this.versionString };
},
logAreaOptions() {
return this.logAvailableAreas.map((area) => ({ name: area, value: area }));
},
logAreasLabel() {
if (this.logAreas.length === 0) {
return this.$t("log.areas");
}
return this.logAreas.join(", ");
},
apiStateUrl(): string {
const url = window.location.href.split("#")[0];
return `${url}api/state`;
},
buttonClass(): string {
return this.helpType === "discussion" ? "btn-success" : "btn-danger";
},
buttonText(): string {
return this.$tt("issue.createButton");
},
},
watch: {
logLevel() {
this.loadLogs();
},
logCount() {
this.loadLogs();
},
logAreas() {
this.loadLogs();
},
"issue.title"(newValue: string) {
sessionStorage.setItem("issue.title", newValue);
},
"issue.description"(newValue: string) {
sessionStorage.setItem("issue.description", newValue);
},
"issue.steps"(newValue: string) {
sessionStorage.setItem("issue.steps", newValue);
},
helpType(newValue: string) {
sessionStorage.setItem("issue.helpType", newValue);
},
},
async mounted() {
this.loadYamlConfig();
this.loadUiConfig();
this.loadState();
this.loadLogs();
this.updateAreas();
},
methods: {
// Type-dependent translation helper
$tt(key: string): string {
const suffix = this.helpType === "discussion" ? "Discussion" : "Issue";
return this.$t(`${key}${suffix}`);
},
async loadYamlConfig() {
try {
const response = await api.get("config/evcc.yaml", {
responseType: "text",
});
// Remove empty lines from config
this.sections.yamlConfig.content = response.data
.split("\n")
.filter((line: string) => line.trim() !== "")
.join("\n");
} catch (error) {
console.error("Failed to fetch config:", error);
this.sections.yamlConfig.content = "Failed to load configuration";
}
},
async loadUiConfig() {
try {
const deviceEndpoints = [
"config/loadpoints",
"config/devices/charger",
"config/devices/meter",
"config/devices/vehicle",
];
const endpoints = [
"config/site",
...deviceEndpoints,
"config/circuits",
"config/eebus",
"config/hems",
"config/messaging",
"config/modbusproxy",
"config/tariffs",
];
const configs: any = {};
for (const endpoint of endpoints) {
try {
const response = await api.get(endpoint);
if (response.data && Object.keys(response.data).length > 0) {
const key = endpoint.replace("config/", "").replace("devices/", "");
let data = response.data;
// Filter out entries without id property for device endpoints
if (deviceEndpoints.includes(endpoint)) {
if (Array.isArray(data)) {
data = data.filter((e) => e.id);
}
}
configs[key] = data;
}
} catch (error) {
console.error(`Failed to fetch ${endpoint}:`, error);
}
}
this.sections.uiConfig.content = formatJson(configs, EXPAND_KEYS);
} catch (error) {
console.error("Failed to fetch API config:", error);
this.sections.uiConfig.content = "Failed to load API configuration";
}
},
async loadLogs() {
try {
const params: any = {
level: this.logLevel,
count: this.logCount,
area: this.logAreas.length ? this.logAreas : null,
};
const response = await api.get("/system/log", { params });
const logs = response.data || [];
this.sections.logs.content = logs
.filter((entry: string) => entry && entry.trim())
.map((entry: string) => entry.trim())
.join("\n");
} catch (error) {
console.error("Failed to fetch logs:", error);
this.sections.logs.content = "Failed to load logs";
}
},
async updateAreas() {
try {
const response = await api.get("/system/log/areas");
this.logAvailableAreas = response.data || [];
} catch (error) {
console.error("Failed to load log areas:", error);
}
},
async loadState() {
try {
const response = await api.get("state");
this.sections.state.content = formatJson(response.data, EXPAND_KEYS);
} catch (error) {
console.error("Failed to fetch state:", error);
this.sections.state.content = "Failed to load system state";
}
},
handleFormSubmit() {
const modalElement = document.getElementById("issueSummaryModal") as HTMLElement;
if (modalElement) {
Modal.getOrCreateInstance(modalElement).show();
}
},
clearSessionStorage() {
sessionStorage.removeItem("issue.title");
sessionStorage.removeItem("issue.description");
sessionStorage.removeItem("issue.steps");
sessionStorage.removeItem("issue.helpType");
},
},
});
</script>
<style scoped>
.log-count-input::-webkit-outer-spin-button,
.log-count-input::-webkit-inner-spin-button {
margin-left: 0.5rem;
}
@media (min-width: 768px) {
.log-lines-input {
width: 170px;
}
.log-areas-select {
width: 220px;
}
}
</style>

View file

@ -87,7 +87,7 @@
</div>
<code
v-if="filteredLines.length"
class="d-block evcc-default-text flex-grow-1"
class="d-block evcc-default-text flex-grow-1 textarea--tiny"
data-testid="log-content"
@copy="onCopy"
>
@ -116,12 +116,10 @@ import api from "../api";
import store from "../store";
import { defineComponent, type PropType } from "vue";
import type { Timeout } from "@/types/evcc";
const LEVELS = ["fatal", "error", "warn", "info", "debug", "trace"];
const DEFAULT_LEVEL = "debug";
import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log";
const DEFAULT_COUNT = 1000;
const levelMatcher = new RegExp(`\\[.*?\\] (${LEVELS.map((l) => l.toUpperCase()).join("|")})`);
const levelMatcher = new RegExp(`\\[.*?\\] (${LOG_LEVELS.map((l) => l.toUpperCase()).join("|")})`);
export default defineComponent({
name: "Log",
@ -133,7 +131,7 @@ export default defineComponent({
},
props: {
areas: { type: Array as PropType<string[]>, default: () => [] },
level: { type: String, default: DEFAULT_LEVEL },
level: { type: String, default: DEFAULT_LOG_LEVEL },
},
data() {
return {
@ -141,7 +139,7 @@ export default defineComponent({
availableAreas: [] as string[],
search: "",
timeout: null as Timeout,
levels: LEVELS,
levels: LOG_LEVELS,
busy: false,
};
},
@ -294,7 +292,7 @@ export default defineComponent({
const newAreas = a || this.areas;
// reset to default level
const level = newLevel === DEFAULT_LEVEL ? undefined : newLevel;
const level = newLevel === DEFAULT_LOG_LEVEL ? undefined : newLevel;
const areas = newAreas.length ? newAreas.join(",") : undefined;
this.$router.push({ query: { level, areas } });
@ -356,14 +354,6 @@ export default defineComponent({
animation-fill-mode: forwards;
animation-timing-function: ease-out;
text-indent: 1rem hanging;
/* smaller exception for mobile */
font-size: 8px;
}
@media (min-width: 576px) {
.log {
/* default code size */
font-size: 0.875em;
}
}
.log-warn {

View file

@ -3,11 +3,8 @@ package cmd
import (
"errors"
"fmt"
"maps"
"net"
"os"
"regexp"
"slices"
"strings"
"github.com/evcc-io/evcc/cmd/shutdown"
@ -52,33 +49,12 @@ func unwrap(err error) (res []string) {
return
}
var redactSecrets = []string{
"mac", // infrastructure
"sponsortoken", "plant", // global settings
"apikey", "user", "password", "pin", // users
"token", "access", "refresh", "accesstoken", "refreshtoken", // tokens, including template variations
"ain", "secret", "serial", "deviceid", "machineid", "idtag", // devices
"app", "chats", "recipients", // push messaging
"vin", // vehicles
"lat", "lon", "zip", // solar forecast
}
// redact redacts a configuration string
func redact(src string) string {
return regexp.
MustCompile(fmt.Sprintf(`(?i)\b(%s)\b.*?:.*`, strings.Join(redactSecrets, "|"))).
ReplaceAllString(src, "$1: *****")
return util.RedactConfigString(src)
}
func redactMap(src map[string]any) map[string]any {
res := maps.Clone(src)
for k := range res {
if slices.Contains(redactSecrets, k) {
res[k] = "*****"
}
}
return res
return util.RedactConfigMap(src)
}
// fatal logs a fatal error and runs shutdown functions before terminating

View file

@ -16,6 +16,7 @@ import (
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/mcp"
"github.com/evcc-io/evcc/server/updater"
"github.com/evcc-io/evcc/util"
@ -291,6 +292,11 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.Network, Val: conf.Network}
valueChan <- util.Param{Key: keys.Sponsor, Val: sponsor.Status()}
// publish system infos
valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()}
valueChan <- util.Param{Key: keys.Config, Val: cfgFile}
valueChan <- util.Param{Key: keys.Database, Val: db.FilePath}
// run shutdown functions on stop
var once sync.Once
stopC := make(chan struct{})
@ -337,11 +343,10 @@ func runRoot(cmd *cobra.Command, args []string) {
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
err = errors.New("restart required") // https://gokrazy.org/development/process-interface/
once.Do(func() { close(stopC) }) // signal loop to end
})
}, cfgFile)
// show and check version, reduce api load during development
if util.Version != util.DevVersion {
valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()}
go updater.Run(log, httpd, valueChan)
}

View file

@ -15,6 +15,8 @@ const (
ModbusProxy = "modbusproxy"
Tariffs = "tariffs"
Version = "version"
Config = "config"
Database = "database"
Fatal = "fatal"
Startup = "startup"
Plant = "plant"

View file

@ -155,7 +155,9 @@
"optional": "optional"
},
"general": {
"applyAndClose": "Übernehmen & schließen",
"cancel": "Abbrechen",
"close": "Schließen",
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
"customOption": "Benutzerdefiniertes Gerät",
"delete": "Löschen",
@ -169,6 +171,7 @@
"password": "Passwort",
"readFromFile": "Aus Datei lesen",
"remove": "Entfernen",
"required": "erforderlich",
"save": "Speichern",
"selectFile": "Durchsuchen",
"showAdvancedSettings": "Erweiterte Einstellungen anzeigen",
@ -677,6 +680,57 @@
"restartDescription": "Schon versucht, das Gerät aus- und wieder einzuschalten?",
"secondaryActions": "Noch keine Lösung gefunden? Hier sind noch ein paar weitere Möglichkeiten."
},
"issue": {
"additional": {
"description": "Konfiguration und Logs hinzufügen, um uns bei der schnellen Reproduktion zu helfen. Wir empfehlen, so viele Informationen wie möglich zu teilen. Zustand ist normalerweise nicht erforderlich.",
"include": "einbeziehen",
"lines": "Zeilen",
"logs": "Logs",
"logsDescription": "Aktuelle Log-Einträge, die bei der Identifizierung des Problems helfen können.",
"showDetails": "Details anzeigen",
"source": "Quelle",
"state": "Zustand",
"stateDescription": "Kompletter Laufzeitzustand inklusive Ladepunkt-, Geräte- und Energieinformationen. Nur auf Nachfrage hinzufügen.",
"title": "Zusätzliche Informationen",
"uiConfig": "Konfiguration (UI)",
"uiConfigDescription": "Konfigurationseinstellungen, die über die Web-Oberfläche vorgenommen wurden.",
"yamlConfig": "Konfiguration (YAML)",
"yamlConfigDescription": "Deine komplette Konfigurationsdatei."
},
"additionalContext": "Zusätzlicher Kontext",
"additionalContextPlaceholder": "Alle zusätzlichen Informationen, die hilfreich sein könnten...\n- Konfigurationsdetails\n- Was du versucht hast\n- Umgebungsdetails",
"createButtonDiscussion": "GitHub Diskussion starten...",
"createButtonIssue": "GitHub Issue erstellen...",
"description": "Deine Installation funktioniert nicht wie erwartet? Nutze diese Seite um Hilfe zu bekommen oder Probleme zu melden. Gib genügend Details an, damit wir das Problem verstehen und reproduzieren können. Halte deine Beschreibung prägnant, klar und leicht verständlich.",
"helpType": {
"discussion": "Brauche Hilfe bei meiner Einrichtung",
"discussionDescription": "Community-Diskussionen bieten Antworten.",
"issue": "Habe einen Fehler gefunden",
"issueDescription": "Ich bin sicher, dass etwas kaputt ist und repariert werden muss.",
"title": "Um welches Problem geht es?"
},
"issueDescription": "Beschreibung",
"issueTitle": "Titel",
"stepsToReproduce": "Schritte zur Reproduktion",
"subTitleDiscussion": "Beschreibe dein Problem",
"subTitleIssue": "Beschreibe das Problem",
"summary": {
"confirmationButtonDiscussion": "GitHub Diskussion starten",
"confirmationButtonIssue": "GitHub Issue erstellen",
"copied": "Kopiert!",
"copyButton": "Zusätzliche Informationen kopieren",
"instructions": "Aufgrund von GitHubs URL-Größenbeschränkungen ist dies ein zweistufiger Prozess:",
"singleStepDescription": "Klicke den Button unten, um GitHub mit einem vorausgefüllten Formular mit deinen Problemdetails zu öffnen. Sensible Daten wurden automatisch entfernt, aber bitte überprüfe vor dem Teilen nochmals.",
"step1Description": "Klicke den Button unten, um einen grundlegenden GitHub-Eintrag mit Titel, Beschreibung und Details zu erstellen.",
"step2Description": "Nach der Erstellung des Eintrags, kehre hierher zurück, um die zusätzlichen Informationen unten zu kopieren und in dein GitHub-Formular einzufügen. Sensible Daten wurden entfernt, aber bitte überprüfe vor dem Teilen nochmals.",
"stepOneDiscussion": "Schritt 1: Basis-Diskussion erstellen",
"stepOneIssue": "Schritt 1: Basis-Issue erstellen",
"stepTwo": "Schritt 2: Zusätzliche Informationen kopieren",
"title": "GitHub Problem-Zusammenfassung"
},
"title": "Problem melden",
"version": "Version"
},
"log": {
"areaLabel": "Nach Bereich filtern",
"areas": "Alle Bereiche",

View file

@ -155,7 +155,9 @@
"optional": "optional"
},
"general": {
"applyAndClose": "Apply & close",
"cancel": "Cancel",
"close": "Close",
"customHelp": "Create a user-defined device using evcc's plugin system.",
"customOption": "User-defined device",
"delete": "Delete",
@ -169,6 +171,7 @@
"password": "Password",
"readFromFile": "Read from file",
"remove": "Remove",
"required": "required",
"save": "Save",
"selectFile": "Browse",
"showAdvancedSettings": "Show advanced settings",
@ -660,7 +663,7 @@
"help": {
"discussionsButton": "GitHub discussions",
"documentationButton": "Documentation",
"issueButton": "Report a bug",
"issueButton": "Report a problem",
"issueDescription": "Found a strange or wrong behavior?",
"logsButton": "View logs",
"logsDescription": "Check the logs for errors.",
@ -677,6 +680,57 @@
"restartDescription": "Tried turning it off and on again?",
"secondaryActions": "Still not able to solve your problem? Here are some more heavy-handed options."
},
"issue": {
"additional": {
"description": "Include configuration and logs to help us reproduce the issue quickly. We encourage sharing as much as possible. State is usually not needed.",
"include": "include",
"lines": "lines",
"logs": "Logs",
"logsDescription": "Recent log entries that may help identify the issue.",
"showDetails": "show details",
"source": "Source",
"state": "State",
"stateDescription": "Complete runtime state including charging point, device, and energy information. Include only if requested.",
"title": "Additional Information",
"uiConfig": "Configuration (UI)",
"uiConfigDescription": "Configuration settings made through the web interface.",
"yamlConfig": "Configuration (YAML)",
"yamlConfigDescription": "Your complete configuration file."
},
"additionalContext": "Additional context",
"additionalContextPlaceholder": "Any additional information that might be helpful...\n- Configuration details\n- What you tried\n- Environment details",
"createButtonDiscussion": "Start GitHub Discussion...",
"createButtonIssue": "Create GitHub Issue...",
"description": "Your installation is not working as expected? Use this page to get help or report issues. Provide enough detail to help us understand and reproduce the problem, while keeping your description concise, clear and easy to follow.",
"helpType": {
"discussion": "Need help with my setup",
"discussionDescription": "Community discussions provide answers.",
"issue": "Found a bug",
"issueDescription": "I'm certain something is broken and needs to be fixed.",
"title": "What problem are we talking about?"
},
"issueDescription": "Description",
"issueTitle": "Title",
"stepsToReproduce": "Steps to reproduce",
"subTitleDiscussion": "Describe your problem",
"subTitleIssue": "Describe the issue",
"summary": {
"confirmationButtonDiscussion": "Start GitHub Discussion",
"confirmationButtonIssue": "Create GitHub Issue",
"copied": "Copied!",
"copyButton": "Copy additional information",
"instructions": "Due to GitHub's URL size limitations, this is a two-step process:",
"singleStepDescription": "Click the button below to open GitHub with a pre-filled form containing your problem details. Sensitive data has been automatically redacted, but please double-check before sharing.",
"step1Description": "Click the button below to create a basic GitHub entry with your title, description, and details.",
"step2Description": "After creating the entry, return here to copy the additional information below and paste it into your GitHub form. Sensitive data has been redacted, but please double-check before sharing.",
"stepOneDiscussion": "Step 1: Create basic discussion",
"stepOneIssue": "Step 1: Create basic issue",
"stepTwo": "Step 2: Copy additional information",
"title": "GitHub Problem Summary"
},
"title": "Report a problem",
"version": "Version"
},
"log": {
"areaLabel": "Filter by area",
"areas": "All areas",

View file

@ -221,7 +221,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
}
// RegisterSystemHandler provides system level handlers
func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Param, cache *util.ParamCache, auth auth.Auth, shutdown func()) {
func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Param, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string) {
router := s.Server.Handler.(*mux.Router)
// api
@ -280,6 +280,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
"device": {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
"evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)},
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},

View file

@ -0,0 +1,34 @@
package server
import (
"fmt"
"net/http"
"os"
"github.com/evcc-io/evcc/util"
)
// configYamlHandler returns the redacted evcc.yaml configuration file
func configYamlHandler(configFilePath string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Use the provided config file path
if configFilePath == "" {
jsonError(w, http.StatusNotFound, fmt.Errorf("no config file found"))
return
}
// Read the config file
src, err := os.ReadFile(configFilePath)
if err != nil {
jsonError(w, http.StatusInternalServerError, fmt.Errorf("failed to read config file: %w", err))
return
}
// Redact sensitive information
redacted := util.RedactConfigString(string(src))
// Return the redacted content as plain text
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(redacted))
}
}

21
tests/issue.evcc.yaml Normal file
View file

@ -0,0 +1,21 @@
interval: 10s
site:
title: Hello World
meters:
pv: carport_pv
loadpoints:
- title: Carport
charger: charger
chargers:
- name: charger
type: template
template: demo-charger
meters:
- name: carport_pv
type: template
template: demo-meter
power: 1234

175
tests/issue.spec.ts Normal file
View file

@ -0,0 +1,175 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalVisible, expectModalHidden } from "./utils";
test.use({ baseURL: baseUrl() });
test.afterEach(async () => {
await stop();
});
const REDACT_CONFIG = "sponsor.evcc.yaml";
const CONFIG = "issue.evcc.yaml";
test.describe("issue creation", () => {
test("verify evcc.yaml redaction", async ({ page }) => {
await start(REDACT_CONFIG);
await page.goto("/#/issue");
// get configuration (yaml)
await page
.getByTestId("issueYamlConfig-additional-item")
.getByRole("button", { name: "show details" })
.click();
const modal = page.getByTestId("issueYamlConfig-modal");
await expectModalVisible(modal);
const configContent = await modal.getByRole("textbox").inputValue();
// check for redation
expect(configContent).toContain("sponsortoken: *****");
expect(configContent).toContain("user: *****");
expect(configContent).toContain("password: *****");
// ensure redacted values are not present
expect(configContent).not.toContain("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
expect(configContent).not.toContain("test@example.org");
expect(configContent).not.toContain("none");
// verify other poarts
expect(configContent).toContain("site:");
expect(configContent).toContain("loadpoints:");
});
test("create issue via ui", async ({ page }) => {
await start(CONFIG);
await page.goto("/#/config");
// Enable experimental features
await enableExperimental(page, false);
// Create a battery meter
await page.getByRole("button", { name: "Add solar or battery" }).click();
await page.getByRole("button", { name: "Add battery meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByLabel("Title").fill("BigBlueBattery");
await meterModal.getByLabel("Manufacturer").selectOption("Demo battery");
await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal);
await expect(page.getByTestId("battery")).toBeVisible();
// Restart to apply changes
await restart(CONFIG);
await page.reload();
// Navigate to issue creation from config page
await page.getByRole("link", { name: "Report a problem" }).click();
await expect(page.getByRole("heading", { name: "Report a problem" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start GitHub Discussion..." })).toBeVisible();
await page.getByRole("radio", { name: "Found a bug" }).click();
await expect(page.getByRole("button", { name: "Create GitHub Issue..." })).toBeVisible();
// Fill out the issue form
await page.getByLabel("Title").fill("Kaboom");
await page
.getByLabel("Description")
.fill("This is a test issue created from the config page workflow");
await page
.getByLabel("Steps to reproduce")
.fill("1. Go to config\n2. Enable experimental\n3. Add battery\n4. Report issue");
// check yaml data
const yamlItem = page.getByTestId("issueYamlConfig-additional-item");
await yamlItem.getByRole("button", { name: "show details" }).click();
const yamlModal = page.getByTestId("issueYamlConfig-modal");
await expectModalVisible(yamlModal);
await expect(yamlModal.getByRole("textbox")).toHaveValue(/carport_pv/);
await yamlModal.getByRole("button", { name: "Close" }).first().click();
await expectModalHidden(yamlModal);
// check ui data
const uiItem = page.getByTestId("issueUiConfig-additional-item");
await uiItem.getByRole("button", { name: "show details" }).click();
const uiModal = page.getByTestId("issueUiConfig-modal");
await expectModalVisible(uiModal);
await expect(uiModal.getByRole("textbox")).toHaveValue(/BigBlueBattery/);
await uiModal.getByRole("button", { name: "Close" }).first().click();
await expectModalHidden(uiModal);
// check log data
const logsItem = page.getByTestId("issueLogs-additional-item");
await logsItem.getByRole("button", { name: "show details" }).click();
const logsModal = page.getByTestId("issueLogs-modal");
await expectModalVisible(logsModal);
await expect(logsModal.getByRole("textbox")).toHaveValue(/DEBUG/);
await logsModal.getByRole("button", { name: "Close" }).first().click();
await expectModalHidden(logsModal);
// check state
const stateItem = page.getByTestId("issueState-additional-item");
await stateItem.getByRole("button", { name: "show details" }).click();
const stateModal = page.getByTestId("issueState-modal");
await expectModalVisible(stateModal);
await expect(stateModal.getByRole("textbox")).toHaveValue(/"telemetry":/);
await stateModal.getByRole("button", { name: "Close" }).first().click();
await expectModalHidden(stateModal);
const stateSwitch = stateItem.getByRole("switch", { name: "include" });
await stateSwitch.check();
await expect(stateSwitch).toBeChecked();
await page.getByRole("button", { name: "Create GitHub Issue..." }).click();
// 2-step process
const summaryModal = page.getByTestId("issue-summary-modal");
await expectModalVisible(summaryModal);
await expect(summaryModal.getByRole("heading", { name: /GitHub Problem/ })).toBeVisible();
await expect(summaryModal.getByRole("heading", { name: /Step 1:/ })).toBeVisible();
await expect(summaryModal.getByRole("heading", { name: /Step 2:/ })).toBeVisible();
// check info in textarea
const textarea = summaryModal.getByTestId("issue-summary-modal-textarea");
await expect(textarea).toBeVisible();
const textareaContent = await textarea.inputValue();
expect(textareaContent).toContain("carport_pv"); // from evcc.yaml
expect(textareaContent).toContain("BigBlueBattery"); // from ui config
expect(textareaContent).toContain("DEBUG"); // from logs
expect(textareaContent).toContain('"telemetry":'); // from state
// check only basics in github link
let href = await summaryModal
.getByRole("link", { name: "Create GitHub Issue" })
.getAttribute("href");
expect(href).toContain("https://github.com/evcc-io/evcc/issues/new?title=Kaboom&body=");
expect(href).not.toContain("BigBlueBattery"); // from ui config
expect(href).not.toContain("carport_pv"); // from evcc.yaml
// close modal
await summaryModal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(summaryModal);
// replace long state with short custom message
await stateItem.getByRole("button", { name: "show details" }).click();
await expectModalVisible(stateModal);
await stateModal.getByRole("textbox").fill("MyFancyState");
await stateModal.getByRole("button", { name: "Apply & close" }).first().click();
await expectModalHidden(stateModal);
// single-step process
await page.getByRole("button", { name: "Create GitHub Issue..." }).click();
await expectModalVisible(summaryModal);
await expect(summaryModal.getByRole("heading", { name: /Step 1:/ })).not.toBeVisible();
await expect(summaryModal.getByRole("heading", { name: /Step 2:/ })).not.toBeVisible();
// verify contents in github link
href = await summaryModal
.getByRole("link", { name: "Create GitHub Issue" })
.getAttribute("href");
expect(href).toContain("https://github.com/evcc-io/evcc/issues/new?title=Kaboom&body=");
expect(href).toContain("BigBlueBattery"); // from ui config
expect(href).toContain("carport_pv"); // from evcc.yaml
expect(href).toContain("DEBUG"); // from logs
expect(href).toContain("MyFancyState"); // from state
});
});

39
util/config_redactor.go Normal file
View file

@ -0,0 +1,39 @@
package util
import (
"fmt"
"maps"
"regexp"
"slices"
"strings"
)
// configRedactSecrets defines keys that should be redacted from configuration files
var configRedactSecrets = []string{
"mac", // infrastructure
"sponsortoken", "plant", // global settings
"apikey", "user", "password", "pin", // users
"token", "access", "refresh", "accesstoken", "refreshtoken", // tokens, including template variations
"ain", "secret", "serial", "deviceid", "machineid", "idtag", // devices
"app", "chats", "recipients", // push messaging
"vin", // vehicles
"lat", "lon", "zip", // solar forecast
}
var configRedactRegex = regexp.MustCompile(fmt.Sprintf(`(?i)\b(%s)\b.*?:.*`, strings.Join(configRedactSecrets, "|")))
// RedactConfigString redacts a configuration string by replacing sensitive values with *****
func RedactConfigString(src string) string {
return configRedactRegex.ReplaceAllString(src, "$1: *****")
}
// RedactConfigMap redacts sensitive keys in a configuration map
func RedactConfigMap(src map[string]any) map[string]any {
res := maps.Clone(src)
for k := range res {
if slices.Contains(configRedactSecrets, k) {
res[k] = "*****"
}
}
return res
}