diff --git a/assets/components.d.ts b/assets/components.d.ts
index 8e54aceb..6e4aadbe 100644
--- a/assets/components.d.ts
+++ b/assets/components.d.ts
@@ -105,6 +105,8 @@ declare module 'vue' {
'Mdi:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
+ 'Mdi:arrowCollapse': typeof import('~icons/mdi/arrow-collapse')['default']
+ 'Mdi:arrowExpand': typeof import('~icons/mdi/arrow-expand')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
@@ -169,6 +171,7 @@ declare module 'vue' {
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
'Ph:database': typeof import('~icons/ph/database')['default']
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
+ 'Ph:downloadSimple': typeof import('~icons/ph/download-simple')['default']
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
'Ph:stack': typeof import('~icons/ph/stack')['default']
diff --git a/assets/components/LogViewer/LogAnalytics.vue b/assets/components/LogViewer/LogAnalytics.vue
index 621b5760..7c1311ef 100644
--- a/assets/components/LogViewer/LogAnalytics.vue
+++ b/assets/components/LogViewer/LogAnalytics.vue
@@ -48,6 +48,25 @@
}}
+
+
+
+
+ {{ $t("analytics.export") }}
+
+
+
@@ -238,5 +257,54 @@ whenever(evaluating, () => {
const page = computed(() =>
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
) as unknown as ComputedRef>>;
+
+const canExport = computed(() => state.value === "ready" && !evaluating.value && results.value.numRows > 0);
+
+function stringify(value: unknown): string {
+ if (value === null || value === undefined) return "";
+ if (typeof value === "bigint") return value.toString();
+ if (typeof value === "object") return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
+ return String(value);
+}
+
+function toCSV(table: Table>, columns: string[]): string {
+ const escape = (value: string) => (/[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);
+ const lines = [columns.map(escape).join(",")];
+ for (const row of table) {
+ lines.push(columns.map((column) => escape(stringify((row as Record)[column]))).join(","));
+ }
+ return lines.join("\n");
+}
+
+function toJSON(table: Table>, columns: string[]): string {
+ const rows = [];
+ for (const row of table) {
+ const record: Record = {};
+ for (const column of columns) {
+ const value = (row as Record)[column];
+ record[column] = typeof value === "bigint" ? value.toString() : value;
+ }
+ rows.push(record);
+ }
+ return JSON.stringify(rows, null, 2);
+}
+
+function exportResults(format: "csv" | "json") {
+ const table = results.value as unknown as Table>;
+ if (table.numRows === 0) return;
+
+ const columns = Object.keys(table.get(0) as Record);
+ const content = format === "csv" ? toCSV(table, columns) : toJSON(table, columns);
+ const type = format === "csv" ? "text/csv;charset=utf-8" : "application/json";
+ const name = container.name.replace(/[^\w.-]+/g, "-");
+
+ const url = URL.createObjectURL(new Blob([content], { type }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${name}-query.${format}`;
+ link.click();
+ URL.revokeObjectURL(url);
+ (document.activeElement as HTMLElement | null)?.blur();
+}
diff --git a/assets/components/common/SideDrawer.vue b/assets/components/common/SideDrawer.vue
index 32bcc7c7..65c57ad8 100644
--- a/assets/components/common/SideDrawer.vue
+++ b/assets/components/common/SideDrawer.vue
@@ -1,16 +1,29 @@