631 lines
17 KiB
Vue
631 lines
17 KiB
Vue
<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-3">
|
|
<h4>
|
|
{{ $tt("issue.subTitle") }}
|
|
</h4>
|
|
</div>
|
|
|
|
<p class="text-muted mb-4">
|
|
🇬🇧 Please write your issue in English so everyone can participate.
|
|
</p>
|
|
|
|
<!-- 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="row mb-4">
|
|
<div class="col-12 col-md-4 mb-4 mb-md-0">
|
|
<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="col-12 col-md-4 mb-4 mb-md-0">
|
|
<label for="system" class="form-label">
|
|
{{ $t("issue.system") }}
|
|
</label>
|
|
<input
|
|
id="system"
|
|
v-model="systemString"
|
|
type="text"
|
|
class="form-control"
|
|
readonly
|
|
/>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<label for="timezone" class="form-label">
|
|
{{ $t("issue.timezone") }}
|
|
</label>
|
|
<input
|
|
id="timezone"
|
|
v-model="timezoneString"
|
|
type="text"
|
|
class="form-control"
|
|
readonly
|
|
/>
|
|
</div>
|
|
</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>
|
|
{{ $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";
|
|
import type { State } from "@/types/evcc";
|
|
|
|
// Keys that should be expanded (1-level expansion for arrays and objects)
|
|
const EXPAND_KEYS = [
|
|
"battery",
|
|
"charger",
|
|
"forecast",
|
|
"loadpoints",
|
|
"messenger",
|
|
"meter",
|
|
"pv",
|
|
"tariff",
|
|
"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 || ""}`;
|
|
},
|
|
systemString(): string {
|
|
return store.state.system || "";
|
|
},
|
|
timezoneString(): string {
|
|
return store.state.timezone || "";
|
|
},
|
|
configPath(): string | undefined {
|
|
return store.state.config;
|
|
},
|
|
databasePath(): string | undefined {
|
|
return store.state.database;
|
|
},
|
|
issueData(): IssueData {
|
|
return {
|
|
...this.issue,
|
|
version: this.versionString,
|
|
system: this.systemString,
|
|
timezone: this.timezoneString,
|
|
};
|
|
},
|
|
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",
|
|
validateStatus: (code) => [200, 404].includes(code),
|
|
});
|
|
|
|
// Handle 404 silently when evcc.yaml doesn't exist
|
|
if (response.status === 404) {
|
|
this.sections.yamlConfig.content = "no yaml configuration";
|
|
return;
|
|
}
|
|
|
|
// Remove empty lines from config
|
|
this.sections.yamlConfig.content = response.data
|
|
.split("\n")
|
|
.filter((line: string) => line.trim() !== "")
|
|
.join("\n");
|
|
} catch (error: any) {
|
|
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/messenger",
|
|
"config/devices/meter",
|
|
"config/devices/tariff",
|
|
"config/devices/vehicle",
|
|
];
|
|
|
|
const endpoints = [
|
|
"config/site",
|
|
...deviceEndpoints,
|
|
"config/circuits",
|
|
"config/eebus",
|
|
"config/hems",
|
|
"config/messaging",
|
|
"config/messagingEvents",
|
|
"config/tariffs",
|
|
"config/tariff",
|
|
];
|
|
|
|
const configs: any = {};
|
|
|
|
for (const endpoint of endpoints) {
|
|
try {
|
|
// Add private=false for device endpoints to hide private data in bug reports
|
|
const response = await api.get(endpoint, { params: { private: false } });
|
|
if (response.data && Object.keys(response.data).length > 0) {
|
|
let key = endpoint.replace("config/", "").replace("devices/", "");
|
|
// avoid collision with config/devices/tariff
|
|
if (key === "tariff" && !deviceEndpoints.includes(endpoint)) {
|
|
key = "tariffRefs";
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
// read essential config data from state
|
|
["modbusproxy", "mqtt", "influx", "shm", "interval", "experimental"].forEach(
|
|
(key) => {
|
|
const value = store.state[key as keyof State];
|
|
if (value !== undefined && value !== null) {
|
|
configs[key] = value;
|
|
}
|
|
}
|
|
);
|
|
|
|
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>
|
|
@import "../../css/breakpoints.css";
|
|
|
|
.log-count-input::-webkit-outer-spin-button,
|
|
.log-count-input::-webkit-inner-spin-button {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
@media (--md-and-up) {
|
|
.log-lines-input {
|
|
width: 170px;
|
|
}
|
|
.log-areas-select {
|
|
width: 220px;
|
|
}
|
|
}
|
|
</style>
|