feat(app): emit structured download event for CSV exports (#30175)

This commit is contained in:
andig 2026-05-26 18:31:20 +02:00 • committed by GitHub
parent dcc8ed6f7e
commit 2e682c689f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 185 additions and 6 deletions

View file

@ -270,3 +270,20 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
- Implement proper caching strategies and connection pooling - Implement proper caching strategies and connection pooling
- Avoid blocking operations in main application loop - Avoid blocking operations in main application loop
- Include appropriate comments for complex business logic - Include appropriate comments for complex business logic
## Pull Request Descriptions
Structure PR descriptions in this order. No headlines. Be concise.
1. **References first line**: link related issues or PRs (`fixes #1123`, `replaces #222`, `pairs with org/repo#345`). PRs should almost always reference an issue or related PR — only skip in rare exceptions (e.g. trivial typo fixes).
2. **Intro**: one or a few concise sentences framing what the PR does and why it was created this way. The full problem description belongs in the linked issue, not here.
3. **Bullet list**: most significant changes or user-facing implications. Lead with the most significant.
4. **TODO section** (only if open points remain):
```
**TODO**
- [ ] item a
- [ ] item b
```
Avoid file paths, line numbers, or code listings reproduced from the diff. Include a code snippet only when it conveys the contract (event shape, API signature) more clearly than prose. No testing checklists, no co-author footers, no generator footers.

View file

@ -170,6 +170,7 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue"; import GenericModal from "../Helper/GenericModal.vue";
import api, { downloadFile } from "@/api"; import api, { downloadFile } from "@/api";
import { dispatchDownload } from "@/utils/native";
import PropertyFileField from "./PropertyFileField.vue"; import PropertyFileField from "./PropertyFileField.vue";
import FormRow from "./FormRow.vue"; import FormRow from "./FormRow.vue";
import { isLoggedIn } from "../Auth/auth"; import { isLoggedIn } from "../Auth/auth";
@ -296,6 +297,10 @@ export default defineComponent({
return r; return r;
}, },
async downloadBackup() { async downloadBackup() {
if (dispatchDownload("/api/system/backup", "POST", { password: this.password })) {
this.closeConfirmModal();
return;
}
const res = await this.call( const res = await this.call(
api.post( api.post(
"/system/backup", "/system/backup",

View file

@ -25,7 +25,12 @@
$t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) }) $t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) })
}}</span> }}</span>
</div> </div>
<a :href="csvLink" download class="alert-link text-nowrap"> <a
:href="csvLink"
download
class="alert-link text-nowrap"
@click="handleDownloadClick($event, csvLink)"
>
{{ $t("config.hems.downloadCsv") }} {{ $t("config.hems.downloadCsv") }}
</a> </a>
</div> </div>
@ -40,6 +45,7 @@
import YamlModal from "./YamlModal.vue"; import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/hems.yaml?raw"; import defaultYaml from "./defaultYaml/hems.yaml?raw";
import api from "../../api"; import api from "../../api";
import { handleDownloadClick } from "../../utils/native";
import formatter from "../../mixins/formatter"; import formatter from "../../mixins/formatter";
export default { export default {
@ -78,6 +84,7 @@ export default {
}, },
}, },
methods: { methods: {
handleDownloadClick,
async loadSessions() { async loadSessions() {
try { try {
const response = await api.get("gridsessions", { const response = await api.get("gridsessions", {

View file

@ -19,6 +19,7 @@ declare global {
} }
interface Window { interface Window {
ReactNativeWebView?: WebView; ReactNativeWebView?: WebView;
evccAppCapabilities?: string[];
} }
} }

View file

@ -9,6 +9,27 @@ export function appDetection() {
} }
} }
export function sendToApp(data: { type: string }) { export function hasAppCapability(capability: string): boolean {
return isApp() && window.evccAppCapabilities?.includes(capability) === true;
}
type AppMessage =
| { type: "online" | "offline" | "settings" }
| { type: "download"; url: string; method?: string; body?: unknown };
export function sendToApp(data: AppMessage) {
window.ReactNativeWebView?.postMessage(JSON.stringify(data)); window.ReactNativeWebView?.postMessage(JSON.stringify(data));
} }
export function handleDownloadClick(event: Event, url: string) {
if (dispatchDownload(url)) {
event.preventDefault();
}
}
export function dispatchDownload(url: string, method?: string, body?: unknown): boolean {
if (!hasAppCapability("download")) return false;
const absolute = new URL(url, window.location.href).toString();
sendToApp({ type: "download", url: absolute, method, body });
return true;
}

View file

@ -100,6 +100,7 @@
download download
class="text-muted small history-csv-link" class="text-muted small history-csv-link"
data-testid="history-csv-download" data-testid="history-csv-download"
@click="handleDownloadClick($event, csvLink)"
> >
{{ $t("main.history.downloadCsv") }} {{ $t("main.history.downloadCsv") }}
</a> </a>
@ -126,6 +127,7 @@ import { PERIODS } from "../components/Sessions/types";
import { GROUP_ORDER, groupColor } from "../components/History/groups"; import { GROUP_ORDER, groupColor } from "../components/History/groups";
import colors, { resolveColors, deviceColorMap } from "../colors"; import colors, { resolveColors, deviceColorMap } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue"; import LegendList from "../components/Sessions/LegendList.vue";
import { handleDownloadClick } from "@/utils/native";
import formatter, { POWER_UNIT } from "../mixins/formatter"; import formatter, { POWER_UNIT } from "../mixins/formatter";
import api from "../api"; import api from "../api";
import store from "../store"; import store from "../store";
@ -368,6 +370,7 @@ export default defineComponent({
}, },
methods: { methods: {
groupColor, groupColor,
handleDownloadClick,
legendsForGroup(group: string): Legend[] { legendsForGroup(group: string): Legend[] {
const items = this.entityLegends(group); const items = this.entityLegends(group);
const focused = this.focusedEntity[group] ?? null; const focused = this.focusedEntity[group] ?? null;

View file

@ -28,6 +28,7 @@
:aria-label="$t('log.download')" :aria-label="$t('log.download')"
:href="downloadUrl" :href="downloadUrl"
download download
@click="handleDownloadClick($event, downloadUrl)"
> >
<shopicon-regular-download <shopicon-regular-download
size="s" size="s"
@ -118,6 +119,7 @@ import store from "../store";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { Timeout } from "@/types/evcc"; import type { Timeout } from "@/types/evcc";
import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log"; import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log";
import { handleDownloadClick } from "@/utils/native";
const DEFAULT_COUNT = 1000; const DEFAULT_COUNT = 1000;
const levelMatcher = new RegExp(`\\[.*?\\] (${LOG_LEVELS.map((l) => l.toUpperCase()).join("|")})`); const levelMatcher = new RegExp(`\\[.*?\\] (${LOG_LEVELS.map((l) => l.toUpperCase()).join("|")})`);
@ -222,6 +224,7 @@ export default defineComponent({
this.stopInterval(); this.stopInterval();
}, },
methods: { methods: {
handleDownloadClick,
async updateLogs(showAll: boolean = false) { async updateLogs(showAll: boolean = false) {
// prevent concurrent requests // prevent concurrent requests
if (this.busy) return; if (this.busy) return;

View file

@ -159,6 +159,7 @@
:href="csvLink" :href="csvLink"
download download
data-testid="sessions-download" data-testid="sessions-download"
@click="handleDownloadClick($event, csvLink)"
> >
{{ csvLinkLabel }} {{ csvLinkLabel }}
</a> </a>
@ -211,6 +212,7 @@ import settings from "../settings";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue"; import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue"; import DateNavigator from "../components/Sessions/DateNavigator.vue";
import PeriodHeader from "../components/Sessions/PeriodHeader.vue"; import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
import { handleDownloadClick } from "@/utils/native";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue"; import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import TotalIcon from "../components/MaterialIcon/Total.vue"; import TotalIcon from "../components/MaterialIcon/Total.vue";
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types"; import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
@ -671,6 +673,7 @@ export default defineComponent({
this.loadSessions(); this.loadSessions();
}, },
methods: { methods: {
handleDownloadClick,
changePeriod(newPeriod: PERIODS) { changePeriod(newPeriod: PERIODS) {
let month: number | undefined = this.month; let month: number | undefined = this.month;
let year: number | undefined = this.year; let year: number | undefined = this.year;

View file

@ -1,6 +1,12 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { start, stop, baseUrl, restart } from "./evcc"; import { start, stop, baseUrl, restart } from "./evcc";
import { openMoreMenu, expectModalVisible, expectModalHidden } from "./utils"; import {
openMoreMenu,
expectModalVisible,
expectModalHidden,
enableAppContext,
expectAppEvent,
} from "./utils";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -247,3 +253,28 @@ test.describe("backup and restore", async () => {
await stop(); await stop();
}); });
}); });
test.describe("backup in app context", async () => {
test("download backup dispatches POST event with password body", async ({ page }) => {
await enableAppContext(page);
await start();
await page.goto("/#/config");
await page.getByRole("button", { name: "Backup & Restore" }).click();
const backupModal = page.getByTestId("backup-restore-modal");
await expectModalVisible(backupModal);
await backupModal.getByRole("button", { name: "Download backup..." }).click();
const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal");
await expectModalVisible(backupConfirmModal);
await backupConfirmModal.getByRole("button", { name: "Download backup" }).click();
expect(await expectAppEvent(page)).toMatchObject({
type: "download",
url: expect.stringContaining("/api/system/backup"),
method: "POST",
body: { password: "" },
});
await stop();
});
});

View file

@ -1,6 +1,13 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc"; import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils"; import {
expectModalVisible,
expectModalHidden,
editorClear,
editorPaste,
enableAppContext,
expectAppEvent,
} from "./utils";
import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator"; import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
@ -109,6 +116,25 @@ limit:
await stopSimulator(); await stopSimulator();
}); });
test.describe("grid sessions CSV in app context", () => {
test("dispatches download event", async ({ page }) => {
await enableAppContext(page);
await start(CONFIG, "hems.sql");
await page.goto("/#/config");
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal);
const csvLink = hemsModal.getByRole("link", { name: "Download CSV" });
await csvLink.click();
expect(await expectAppEvent(page)).toMatchObject({
type: "download",
url: expect.stringContaining("/api/gridsessions?format=csv&lang=en"),
});
});
});
test("external control with circuits", async ({ page }) => { test("external control with circuits", async ({ page }) => {
const GRID_CONFIG = "hems-grid.evcc.yaml"; const GRID_CONFIG = "hems-grid.evcc.yaml";
await start(GRID_CONFIG); await start(GRID_CONFIG);

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc"; import { start, stop, baseUrl } from "./evcc";
import { openMoreMenu } from "./utils"; import { openMoreMenu, enableAppContext, expectAppEvent } from "./utils";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
@ -49,3 +49,15 @@ test.describe("features", async () => {
await expect(page.getByTestId("log-content")).toContainText("UI local"); await expect(page.getByTestId("log-content")).toContainText("UI local");
}); });
}); });
test.describe("log download in app context", async () => {
test("dispatches download event", async ({ page }) => {
await enableAppContext(page);
await page.goto("/#/log");
await page.getByRole("link", { name: "Download complete log" }).click();
expect(await expectAppEvent(page)).toMatchObject({
type: "download",
url: expect.stringContaining("/api/system/log?level=debug&format=txt"),
});
});
});

View file

@ -1,6 +1,6 @@
import { test, expect, devices, type Page } from "@playwright/test"; import { test, expect, devices, type Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc"; import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden } from "./utils"; import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
@ -309,6 +309,26 @@ test.describe("csv export", async () => {
}); });
}); });
test.describe("csv export download", async () => {
test("in browser context", async ({ page }) => {
await page.goto("/#/sessions?period=total");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "Download total CSV" }).click();
const download = await downloadPromise;
expect(download.url()).toContain("/api/sessions?format=csv");
});
test("in app context", async ({ page }) => {
await enableAppContext(page);
await page.goto("/#/sessions?period=total");
await page.getByRole("link", { name: "Download total CSV" }).click();
expect(await expectAppEvent(page)).toMatchObject({
type: "download",
url: expect.stringContaining("/api/sessions?format=csv&lang=en"),
});
});
});
test.describe("session details", async () => { test.describe("session details", async () => {
test("show session details (session 5)", async ({ page }) => { test("show session details (session 5)", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");

View file

@ -130,3 +130,33 @@ export async function getDatalistOptions(input: Locator): Promise<string[]> {
return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value); return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value);
}); });
} }
type AppState = {
evccAppCapabilities: string[];
__appEvent: unknown;
ReactNativeWebView: { postMessage: (m: string) => void };
};
export async function enableAppContext(page: Page): Promise<void> {
await page.addInitScript(() => {
Object.defineProperty(navigator, "userAgent", {
value: "evcc/playwright",
configurable: true,
});
const w = window as unknown as AppState;
w.evccAppCapabilities = ["download"];
w.__appEvent = undefined;
w.ReactNativeWebView = {
postMessage: (msg: string) => {
w.__appEvent = JSON.parse(msg);
},
};
});
}
export async function expectAppEvent(page: Page): Promise<unknown> {
await expect
.poll(async () => page.evaluate(() => (window as unknown as AppState).__appEvent !== undefined))
.toBe(true);
return page.evaluate(() => (window as unknown as AppState).__appEvent);
}