diff --git a/assets/css/app.css b/assets/css/app.css index 45ce4f5c0..bb23c83da 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; + } +} diff --git a/assets/js/components/HelpModal.vue b/assets/js/components/HelpModal.vue index 64fe1e185..60aaaa5fa 100644 --- a/assets/js/components/HelpModal.vue +++ b/assets/js/components/HelpModal.vue @@ -62,13 +62,9 @@

{{ $t("help.issueDescription") }}

- + {{ $t("help.issueButton") }} - +
+ + + + diff --git a/assets/js/components/Helper/GenericModal.vue b/assets/js/components/Helper/GenericModal.vue index 942d883b9..d8ea926fe 100644 --- a/assets/js/components/Helper/GenericModal.vue +++ b/assets/js/components/Helper/GenericModal.vue @@ -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() { diff --git a/assets/js/components/Issue/AdditionalItem.vue b/assets/js/components/Issue/AdditionalItem.vue new file mode 100644 index 000000000..b9dcc5a23 --- /dev/null +++ b/assets/js/components/Issue/AdditionalItem.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/assets/js/components/Issue/SummaryModal.vue b/assets/js/components/Issue/SummaryModal.vue new file mode 100644 index 000000000..de78656e8 --- /dev/null +++ b/assets/js/components/Issue/SummaryModal.vue @@ -0,0 +1,128 @@ + + + diff --git a/assets/js/components/Issue/format.test.ts b/assets/js/components/Issue/format.test.ts new file mode 100644 index 000000000..1ce46aed7 --- /dev/null +++ b/assets/js/components/Issue/format.test.ts @@ -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} + ] +}`); + }); +}); diff --git a/assets/js/components/Issue/format.ts b/assets/js/components/Issue/format.ts new file mode 100644 index 000000000..019dfcc0f --- /dev/null +++ b/assets/js/components/Issue/format.ts @@ -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}`; +} diff --git a/assets/js/components/Issue/template.test.ts b/assets/js/components/Issue/template.test.ts new file mode 100644 index 000000000..d5a21d673 --- /dev/null +++ b/assets/js/components/Issue/template.test.ts @@ -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"); + }); + }); +}); diff --git a/assets/js/components/Issue/template.ts b/assets/js/components/Issue/template.ts new file mode 100644 index 000000000..5ef252b19 --- /dev/null +++ b/assets/js/components/Issue/template.ts @@ -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)}`; +} diff --git a/assets/js/components/Issue/types.d.ts b/assets/js/components/Issue/types.d.ts new file mode 100644 index 000000000..295c92c88 --- /dev/null +++ b/assets/js/components/Issue/types.d.ts @@ -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"; diff --git a/assets/js/router.ts b/assets/js/router.ts index a8d255df8..c40925a1b 100644 --- a/assets/js/router.ts +++ b/assets/js/router.ts @@ -94,6 +94,12 @@ export default function setupRouter(i18n: VueI18nInstance) { }; }, }, + { + path: "/issue", + component: () => import("./views/Issue.vue"), + beforeEnter: ensureAuth, + props: true, + }, ], }); router.beforeEach(async () => { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index ee21fc617..5a57e7d3a 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -77,6 +77,8 @@ export interface State { siteTitle?: string; vehicles: Record; authDisabled?: boolean; + config?: string; + database?: string; } export interface Config { diff --git a/assets/js/utils/log.ts b/assets/js/utils/log.ts new file mode 100644 index 000000000..bc01ff18a --- /dev/null +++ b/assets/js/utils/log.ts @@ -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]; diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 6452a68aa..093f55893 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -328,6 +328,9 @@ {{ $t("config.system.logs") }} + + {{ $t("help.issueButton") }} + +
+ + + + + + + + + + + + diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index c788a25a9..e5981e85b 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -87,7 +87,7 @@ @@ -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, 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 { diff --git a/cmd/helper.go b/cmd/helper.go index 9c5aebfe1..c09fd01aa 100644 --- a/cmd/helper.go +++ b/cmd/helper.go @@ -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 diff --git a/cmd/root.go b/cmd/root.go index 0cc93bdce..f9d39501b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) } diff --git a/core/keys/global.go b/core/keys/global.go index 1f9cf4b66..f0769f55e 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -15,6 +15,8 @@ const ( ModbusProxy = "modbusproxy" Tariffs = "tariffs" Version = "version" + Config = "config" + Database = "database" Fatal = "fatal" Startup = "startup" Plant = "plant" diff --git a/i18n/de.json b/i18n/de.json index 32a363ea4..e01bd47fd 100644 --- a/i18n/de.json +++ b/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", diff --git a/i18n/en.json b/i18n/en.json index 56295de23..0b3f02a1d 100644 --- a/i18n/en.json +++ b/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", diff --git a/server/http.go b/server/http.go index d601d1b7a..7990ced8d 100644 --- a/server/http.go +++ b/server/http.go @@ -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)}, diff --git a/server/http_config_yaml_handler.go b/server/http_config_yaml_handler.go new file mode 100644 index 000000000..e09e98d52 --- /dev/null +++ b/server/http_config_yaml_handler.go @@ -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)) + } +} diff --git a/tests/issue.evcc.yaml b/tests/issue.evcc.yaml new file mode 100644 index 000000000..b3d083409 --- /dev/null +++ b/tests/issue.evcc.yaml @@ -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 diff --git a/tests/issue.spec.ts b/tests/issue.spec.ts new file mode 100644 index 000000000..2a1df2702 --- /dev/null +++ b/tests/issue.spec.ts @@ -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 + }); +}); diff --git a/util/config_redactor.go b/util/config_redactor.go new file mode 100644 index 000000000..57731767d --- /dev/null +++ b/util/config_redactor.go @@ -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 +}