UI: add problem reporting (GitHub discussion/issue) (#23707)
This commit is contained in:
parent
a3af19a023
commit
b7a4e309ab
27 changed files with 1753 additions and 59 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
assets/js/components/Helper/CopyButton.vue
Normal file
125
assets/js/components/Helper/CopyButton.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
// focus first input or select if autofocus is enabled
|
||||
if (this.autofocus) {
|
||||
this.$nextTick(() => {
|
||||
const firstInput = this.$refs["modalBody"]?.querySelector("input, select, button");
|
||||
const firstInput =
|
||||
this.$refs["modalBody"]?.querySelector("input, select, button");
|
||||
if (firstInput instanceof HTMLElement) {
|
||||
firstInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.isModalVisible = true;
|
||||
},
|
||||
handleHide() {
|
||||
|
|
|
|||
165
assets/js/components/Issue/AdditionalItem.vue
Normal file
165
assets/js/components/Issue/AdditionalItem.vue
Normal 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>
|
||||
128
assets/js/components/Issue/SummaryModal.vue
Normal file
128
assets/js/components/Issue/SummaryModal.vue
Normal 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>
|
||||
63
assets/js/components/Issue/format.test.ts
Normal file
63
assets/js/components/Issue/format.test.ts
Normal 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}
|
||||
]
|
||||
}`);
|
||||
});
|
||||
});
|
||||
50
assets/js/components/Issue/format.ts
Normal file
50
assets/js/components/Issue/format.ts
Normal 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}`;
|
||||
}
|
||||
120
assets/js/components/Issue/template.test.ts
Normal file
120
assets/js/components/Issue/template.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
77
assets/js/components/Issue/template.ts
Normal file
77
assets/js/components/Issue/template.ts
Normal 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
28
assets/js/components/Issue/types.d.ts
vendored
Normal 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";
|
||||
|
|
@ -94,6 +94,12 @@ export default function setupRouter(i18n: VueI18nInstance) {
|
|||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/issue",
|
||||
component: () => import("./views/Issue.vue"),
|
||||
beforeEnter: ensureAuth,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
router.beforeEach(async () => {
|
||||
|
|
|
|||
|
|
@ -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
4
assets/js/utils/log.ts
Normal 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];
|
||||
|
|
@ -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
562
assets/js/views/Issue.vue
Normal 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... 2. Click on... 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ const (
|
|||
ModbusProxy = "modbusproxy"
|
||||
Tariffs = "tariffs"
|
||||
Version = "version"
|
||||
Config = "config"
|
||||
Database = "database"
|
||||
Fatal = "fatal"
|
||||
Startup = "startup"
|
||||
Plant = "plant"
|
||||
|
|
|
|||
54
i18n/de.json
54
i18n/de.json
|
|
@ -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",
|
||||
|
|
|
|||
56
i18n/en.json
56
i18n/en.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
34
server/http_config_yaml_handler.go
Normal file
34
server/http_config_yaml_handler.go
Normal 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
21
tests/issue.evcc.yaml
Normal 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
175
tests/issue.spec.ts
Normal 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
39
util/config_redactor.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue