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 @@ }} + + @@ -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 @@