@antelopejs/dms-database 0.0.13 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +30 -5
  2. package/dist/db/models/query_history.model.d.ts +1 -0
  3. package/dist/db/models/query_history.model.js +6 -0
  4. package/dist/db/models/query_history.model.js.map +1 -1
  5. package/dist/db/tables/query_history.table.d.ts +3 -0
  6. package/dist/db/tables/query_history.table.js +12 -0
  7. package/dist/db/tables/query_history.table.js.map +1 -1
  8. package/dist/db/tables/saved_queries.table.d.ts +1 -0
  9. package/dist/db/tables/saved_queries.table.js +4 -0
  10. package/dist/db/tables/saved_queries.table.js.map +1 -1
  11. package/dist/i18n/messages.d.ts +39 -0
  12. package/dist/i18n/messages.js +64 -0
  13. package/dist/i18n/messages.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/module.d.ts +11 -0
  18. package/dist/module.js +55 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/pages/data.d.ts +1 -1
  21. package/dist/pages/data.js +9 -6
  22. package/dist/pages/data.js.map +1 -1
  23. package/dist/pages/diagram.d.ts +9 -0
  24. package/dist/pages/diagram.js +45 -0
  25. package/dist/pages/diagram.js.map +1 -0
  26. package/dist/pages/index.d.ts +1 -0
  27. package/dist/pages/index.js +1 -0
  28. package/dist/pages/index.js.map +1 -1
  29. package/dist/pages/overview.d.ts +4 -1
  30. package/dist/pages/overview.js +113 -6
  31. package/dist/pages/overview.js.map +1 -1
  32. package/dist/pages/query.d.ts +1 -1
  33. package/dist/pages/query.js +22 -6
  34. package/dist/pages/query.js.map +1 -1
  35. package/dist/pages/schemas.d.ts +2 -1
  36. package/dist/pages/schemas.js +143 -6
  37. package/dist/pages/schemas.js.map +1 -1
  38. package/dist/routes/data.d.ts +8 -0
  39. package/dist/routes/data.js +84 -21
  40. package/dist/routes/data.js.map +1 -1
  41. package/dist/routes/index.d.ts +1 -0
  42. package/dist/routes/index.js +1 -0
  43. package/dist/routes/index.js.map +1 -1
  44. package/dist/routes/overview.d.ts +12 -0
  45. package/dist/routes/overview.js +55 -0
  46. package/dist/routes/overview.js.map +1 -0
  47. package/dist/routes/query.d.ts +5 -1
  48. package/dist/routes/query.js +162 -27
  49. package/dist/routes/query.js.map +1 -1
  50. package/dist/routes/tables.d.ts +15 -12
  51. package/dist/routes/tables.js +95 -55
  52. package/dist/routes/tables.js.map +1 -1
  53. package/dist/service/catalog.d.ts +3 -0
  54. package/dist/service/catalog.js +26 -0
  55. package/dist/service/catalog.js.map +1 -0
  56. package/dist/service/health.d.ts +3 -3
  57. package/dist/service/health.js +17 -14
  58. package/dist/service/health.js.map +1 -1
  59. package/dist/service/introspect.d.ts +3 -1
  60. package/dist/service/introspect.js +131 -43
  61. package/dist/service/introspect.js.map +1 -1
  62. package/dist/service/overview.d.ts +9 -0
  63. package/dist/service/overview.js +113 -0
  64. package/dist/service/overview.js.map +1 -0
  65. package/dist/service/queryInspection.d.ts +15 -0
  66. package/dist/service/queryInspection.js +107 -0
  67. package/dist/service/queryInspection.js.map +1 -0
  68. package/dist/service/references.d.ts +9 -0
  69. package/dist/service/references.js +52 -0
  70. package/dist/service/references.js.map +1 -0
  71. package/dist/service/schemaFilters.d.ts +26 -0
  72. package/dist/service/schemaFilters.js +42 -0
  73. package/dist/service/schemaFilters.js.map +1 -0
  74. package/dist/service/stagedSerialization.d.ts +2 -0
  75. package/dist/service/stagedSerialization.js +38 -10
  76. package/dist/service/stagedSerialization.js.map +1 -1
  77. package/dist/service/types.d.ts +11 -1
  78. package/dist/types/constants.d.ts +2 -0
  79. package/dist/types/constants.js +3 -1
  80. package/dist/types/constants.js.map +1 -1
  81. package/dist/utils/query.d.ts +0 -2
  82. package/dist/utils/query.js +0 -20
  83. package/dist/utils/query.js.map +1 -1
  84. package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
  85. package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
  86. package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
  87. package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
  88. package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
  89. package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
  90. package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
  91. package/frontend-vue/app/build/data/cellValues.ts +132 -0
  92. package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
  93. package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
  94. package/frontend-vue/app/build/data/recentTables.ts +48 -0
  95. package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
  96. package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
  97. package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
  98. package/frontend-vue/app/build/diagram/autosave.ts +72 -0
  99. package/frontend-vue/app/build/diagram/graph.ts +585 -0
  100. package/frontend-vue/app/build/diagram/history.ts +178 -0
  101. package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
  102. package/frontend-vue/app/build/diagram/sync.ts +137 -0
  103. package/frontend-vue/app/build/query/completion.ts +100 -0
  104. package/frontend-vue/app/build/query/csv.ts +32 -0
  105. package/frontend-vue/app/build/query/examples.ts +38 -0
  106. package/frontend-vue/app/build/query/runs.ts +168 -0
  107. package/frontend-vue/app/build/query/tableColumns.ts +35 -0
  108. package/frontend-vue/app/build/query/target.ts +143 -0
  109. package/frontend-vue/app/build/schemas/filterState.ts +128 -0
  110. package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
  111. package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
  112. package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
  113. package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
  114. package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
  115. package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
  116. package/frontend-vue/app/components/DataBrowser.vue +126 -0
  117. package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
  118. package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
  119. package/frontend-vue/app/components/DataFilterBar.vue +163 -0
  120. package/frontend-vue/app/components/DataGrid.vue +737 -499
  121. package/frontend-vue/app/components/DataGridCell.vue +125 -77
  122. package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
  123. package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
  124. package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
  125. package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
  126. package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
  127. package/frontend-vue/app/components/InstancePicker.vue +195 -0
  128. package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
  129. package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
  130. package/frontend-vue/app/components/QueryConsole.vue +726 -0
  131. package/frontend-vue/app/components/QueryLibrary.vue +314 -0
  132. package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
  133. package/frontend-vue/app/components/RowDrawer.vue +520 -0
  134. package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
  135. package/frontend-vue/app/components/SchemaPicker.vue +86 -0
  136. package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
  137. package/frontend-vue/app/components/TableInspector.vue +741 -0
  138. package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
  139. package/frontend-vue/dms.frontend.ts +5 -14
  140. package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
  141. package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
  142. package/frontend-vue/package.json +2 -1
  143. package/frontend-vue/pnpm-lock.yaml +2601 -858
  144. package/frontend-vue/pnpm-workspace.yaml +1 -1
  145. package/frontend-vue/tests/dataBrowser.test.ts +206 -0
  146. package/frontend-vue/tests/diagram.test.ts +516 -0
  147. package/frontend-vue/tests/helpers.test.ts +205 -0
  148. package/frontend-vue/tests/instanceOptions.test.ts +53 -0
  149. package/frontend-vue/tests/locales.test.ts +56 -0
  150. package/frontend-vue/tests/persistence.test.ts +106 -5
  151. package/frontend-vue/tests/pickerMenus.test.ts +254 -0
  152. package/frontend-vue/tests/queryConsole.test.ts +282 -0
  153. package/frontend-vue/tests/queryRuntime.test.ts +2 -2
  154. package/frontend-vue/tests/registration.test.ts +36 -25
  155. package/frontend-vue/tests/schemasFilters.test.ts +115 -0
  156. package/package.json +9 -6
  157. package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
  158. package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
  159. package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
  160. package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
  161. package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
  162. package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
  163. package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
  164. package/frontend-vue/app/composables/useQueryStore.ts +0 -117
  165. package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
  166. package/frontend-vue/app/custom-pages/database/data.vue +0 -41
  167. package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
  168. package/frontend-vue/app/custom-pages/database/query.vue +0 -436
  169. package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
  170. package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
  171. package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
  172. package/frontend-vue/app/utils/dmsTableView.ts +0 -83
  173. package/frontend-vue/app/utils/relativeTime.ts +0 -18
@@ -1,204 +1,634 @@
1
1
  <script setup lang="ts">
2
- import type { ExecuteResult } from "../composables/useQueryStore";
2
+ import { useClipboard } from '@vueuse/core'
3
+ import { downloadText, resultColumns, toCsv } from '../build/query/csv'
4
+ import {
5
+ filledColumns,
6
+ numericColumns as numericOnly,
7
+ } from '../build/query/tableColumns'
8
+ import { latestOrderField } from '../build/query/examples'
9
+ import {
10
+ closestName,
11
+ unknownName,
12
+ type UnknownKind,
13
+ type UnknownName,
14
+ } from '../build/query/runs'
15
+ import {
16
+ describeTarget,
17
+ rowInstance,
18
+ type QueryTarget,
19
+ } from '../build/query/target'
20
+ import type { SchemaSummary } from '../build/composables/useDatabaseSchemas'
21
+ import type { ExecuteResult } from '../build/composables/useQueryStore'
22
+ import {
23
+ DATABASE_PAGES,
24
+ tableAccess,
25
+ tableLink,
26
+ } from '../build/utils/databaseLinks'
3
27
 
4
- type ResultMode = "table" | "json" | "chart";
28
+ // The result of the last run (D-13): what it read or changed and where, the
29
+ // rows as a table, JSON or a chart with picked axes, copied or exported, and
30
+ // whether every row is shown. A failed run says what failed and, for a
31
+ // mistyped table, offers the right one.
5
32
 
6
- interface Props {
7
- result: ExecuteResult | null;
8
- mode: ResultMode;
33
+ type ResultMode = 'table' | 'json' | 'chart'
34
+
35
+ export interface QueryFailure {
36
+ message: string
37
+ /** How long the server ran it; null when it never reached the server. */
38
+ durationMs: number | null
39
+ /** The unknown name the failure is about, when the console found it. */
40
+ unknown?: UnknownName | null
41
+ }
42
+
43
+ /** A named instance holding rows of the table, with how many. */
44
+ export interface InstanceRows {
45
+ id: string
46
+ count: number
9
47
  }
10
48
 
11
- const props = defineProps<Props>();
49
+ const props = defineProps<{
50
+ result: ExecuteResult | null
51
+ failure: QueryFailure | null
52
+ target: QueryTarget
53
+ schemas: SchemaSummary[]
54
+ /** A run is on its way: what shows is the previous one's. */
55
+ running?: boolean
56
+ /**
57
+ * Named instances with rows of a table the read found empty in the
58
+ * default instance.
59
+ */
60
+ elsewhere?: InstanceRows[]
61
+ }>()
12
62
 
13
63
  const emit = defineEmits<{
14
- "update:mode": [value: ResultMode];
15
- }>();
64
+ 'use-source': [source: string]
65
+ 'replace-name': [kind: UnknownKind, from: string, to: string]
66
+ 'use-instance': [instance: string]
67
+ }>()
16
68
 
17
- const { t } = useI18n();
69
+ const MAX_TABLE_ROWS = 200
70
+ const EXAMPLE_ROWS = 10
71
+ const CHART_ROWS = 50
72
+ // Bars past this many are drawn sideways, a fixed height each, so their
73
+ // labels stay apart.
74
+ const UPRIGHT_BARS = 20
75
+ const BAR_HEIGHT = 22
76
+ const CHART_HEIGHT = 280
18
77
 
19
- const internalMode = computed<ResultMode>({
20
- get: () => props.mode,
21
- set: (value) => emit("update:mode", value),
22
- });
78
+ const { t, n } = useI18n()
79
+ const { copy } = useClipboard()
80
+ const toast = useToast()
23
81
 
82
+ const mode = ref<ResultMode>('table')
24
83
  const modeItems = computed(() => [
25
- { label: t("dms_database.query.table"), value: "table" as const },
26
- { label: t("dms_database.query.json"), value: "json" as const },
27
- { label: t("dms_database.query.chart"), value: "chart" as const },
28
- ]);
84
+ {
85
+ label: t('dms_database.query.result.table'),
86
+ value: 'table',
87
+ icon: 'i-ph-table',
88
+ },
89
+ {
90
+ label: t('dms_database.query.result.json'),
91
+ value: 'json',
92
+ icon: 'i-ph-brackets-curly',
93
+ },
94
+ {
95
+ label: t('dms_database.query.result.chart'),
96
+ value: 'chart',
97
+ icon: 'i-ph-chart-bar',
98
+ },
99
+ ])
29
100
 
30
- const columns = computed<string[]>(() => {
31
- const rows = props.result?.rows ?? [];
32
- const first = rows[0];
33
- if (!first || typeof first !== "object") return [];
34
- return Object.keys(first as Record<string, unknown>);
35
- });
101
+ const rows = computed(() => props.result?.rows ?? [])
102
+ const columns = computed(() => resultColumns(rows.value))
103
+ const tableColumns = computed(() => filledColumns(rows.value, columns.value))
104
+ const rightAligned = computed(
105
+ () => new Set(numericOnly(rows.value, tableColumns.value)),
106
+ )
107
+ const shownRows = computed(() => rows.value.slice(0, MAX_TABLE_ROWS))
108
+ const targetLabel = computed(() => describeTarget(props.target))
109
+ const tableCut = computed(
110
+ () => mode.value === 'table' && rows.value.length > MAX_TABLE_ROWS,
111
+ )
36
112
 
37
- const rows = computed(() => props.result?.rows ?? []);
113
+ function renderCell(value: unknown): string {
114
+ if (value === null || value === undefined) return 'null'
115
+ if (typeof value === 'object') return JSON.stringify(value)
116
+ return String(value)
117
+ }
38
118
 
39
- const jsonText = computed(() =>
40
- props.result ? JSON.stringify(props.result.rows, null, 2) : "",
41
- );
119
+ // --- links to the data browser ---
120
+ // A row of a read across every instance opens in its own instance.
121
+ const browsable = computed(() =>
122
+ Boolean(props.target.schema && props.target.table),
123
+ )
42
124
 
43
- function renderCell(value: unknown): string {
44
- if (value === null || value === undefined) return "—";
45
- if (typeof value === "object") return JSON.stringify(value);
46
- return String(value);
125
+ function rowLink(row: Record<string, unknown>): string | null {
126
+ const id = row._id
127
+ if (!browsable.value || (typeof id !== 'string' && typeof id !== 'number'))
128
+ return null
129
+ const instance = rowInstance(props.target, row)
130
+ if (instance === null) return null
131
+ return tableLink('data', {
132
+ schema: props.target.schema ?? '',
133
+ table: props.target.table ?? '',
134
+ instance: instance || undefined,
135
+ match: { field: '_id', value: String(id) },
136
+ })
47
137
  }
48
138
 
49
- function isNumeric(value: unknown): value is number {
50
- return typeof value === "number" && Number.isFinite(value);
139
+ const tableBrowserLink = computed(() =>
140
+ browsable.value && props.target.instance !== '*'
141
+ ? tableLink('data', {
142
+ schema: props.target.schema ?? '',
143
+ table: props.target.table ?? '',
144
+ instance: props.target.instance || undefined,
145
+ })
146
+ : null,
147
+ )
148
+
149
+ // --- copy and export ---
150
+ async function copyJson() {
151
+ await copy(JSON.stringify(rows.value, null, 2))
152
+ toast.add({
153
+ title: t('dms_database.query.result.copied', rows.value.length),
154
+ color: 'success',
155
+ icon: 'i-ph-check',
156
+ })
51
157
  }
52
158
 
53
- // --- chart derivation (first numeric column, labelled by first string column) ---
54
- const numericColumn = computed<string | null>(() => {
55
- const first = rows.value[0] as Record<string, unknown> | undefined;
56
- if (!first) return null;
57
- return columns.value.find((c) => isNumeric(first[c])) ?? null;
58
- });
59
-
60
- const labelColumn = computed<string | null>(() => {
61
- const first = rows.value[0] as Record<string, unknown> | undefined;
62
- if (!first) return null;
63
- return (
64
- columns.value.find(
65
- (c) => c !== numericColumn.value && typeof first[c] === "string",
66
- ) ?? null
67
- );
68
- });
69
-
70
- const chartAvailable = computed(
71
- () => rows.value.length > 0 && numericColumn.value !== null,
72
- );
159
+ function exportCsv() {
160
+ const name = props.target.table ?? 'query'
161
+ downloadText(`${name}.csv`, toCsv(rows.value), 'text/csv;charset=utf-8')
162
+ }
163
+
164
+ // --- chart with picked axes ---
165
+ const numericColumns = computed(() =>
166
+ columns.value.filter((column) =>
167
+ rows.value.some((row) => typeof row[column] === 'number'),
168
+ ),
169
+ )
170
+ const xColumn = ref<string | undefined>()
171
+ const yColumn = ref<string | undefined>()
172
+
173
+ watch(
174
+ columns,
175
+ () => {
176
+ yColumn.value = numericColumns.value.includes(yColumn.value ?? '')
177
+ ? yColumn.value
178
+ : numericColumns.value[0]
179
+ xColumn.value = columns.value.includes(xColumn.value ?? '')
180
+ ? xColumn.value
181
+ : columns.value.find((column) => column !== yColumn.value)
182
+ },
183
+ { immediate: true },
184
+ )
73
185
 
186
+ const chartRows = computed(() => rows.value.slice(0, CHART_ROWS))
187
+ const sideways = computed(() => chartRows.value.length > UPRIGHT_BARS)
188
+ const chartHeight = computed(
189
+ () =>
190
+ `${sideways.value ? Math.max(CHART_HEIGHT, chartRows.value.length * BAR_HEIGHT) : CHART_HEIGHT}px`,
191
+ )
74
192
  const chartDataset = computed(() => {
75
- const col = numericColumn.value;
76
- if (!col) return [];
77
- const label = labelColumn.value;
193
+ const y = yColumn.value
194
+ if (!y) return []
78
195
  return [
79
196
  {
80
- name: col,
81
- data: rows.value.map((row, index) => {
82
- const record = row as Record<string, unknown>;
83
- return {
84
- x: label ? String(record[label]) : index + 1,
85
- y: isNumeric(record[col]) ? (record[col] as number) : 0,
86
- };
87
- }),
197
+ name: y,
198
+ data: chartRows.value.map((row, index) => ({
199
+ x: xColumn.value ? renderCell(row[xColumn.value]) : String(index + 1),
200
+ y: typeof row[y] === 'number' ? (row[y] as number) : 0,
201
+ })),
88
202
  },
89
- ];
90
- });
203
+ ]
204
+ })
205
+
206
+ // --- first run and failures ---
207
+ const examples = computed(() => {
208
+ const schema = props.schemas.find((candidate) => candidate.tables.length > 0)
209
+ if (!schema) return []
210
+ const [first, second] = schema.tables
211
+ const firstTable = `${schema.id}.${first?.name}`
212
+ const access = tableAccess({ schema: schema.id, table: first?.name ?? '' })
213
+ // "Latest" rows only when a date orders them; else the first ones.
214
+ const dated = first ? latestOrderField(first) : null
215
+ const items = [
216
+ dated
217
+ ? {
218
+ label: t('dms_database.query.result.example_latest', {
219
+ table: firstTable,
220
+ }),
221
+ source: `${access}.orderBy(${JSON.stringify(dated)}, "desc").slice(0, ${EXAMPLE_ROWS})`,
222
+ }
223
+ : {
224
+ label: t('dms_database.query.result.example_first', {
225
+ table: firstTable,
226
+ }),
227
+ source: `${access}.slice(0, ${EXAMPLE_ROWS})`,
228
+ },
229
+ ]
230
+ const counted = second ?? first
231
+ if (counted) {
232
+ items.push({
233
+ label: t('dms_database.query.result.example_count', {
234
+ table: `${schema.id}.${counted.name}`,
235
+ }),
236
+ source: `${tableAccess({ schema: schema.id, table: counted.name })}.count()`,
237
+ })
238
+ }
239
+ return items
240
+ })
241
+
242
+ const exampleActions = computed(() =>
243
+ examples.value.map((example) => ({
244
+ label: example.label,
245
+ color: 'neutral' as const,
246
+ variant: 'outline' as const,
247
+ class: 'font-mono',
248
+ onClick: () => emit('use-source', example.source),
249
+ })),
250
+ )
251
+
252
+ // One button per instance holding rows: `eu · 160` reruns the read there.
253
+ const elsewhereActions = computed(() =>
254
+ (props.elsewhere ?? []).map((instance) => ({
255
+ label: `${instance.id} · ${n(instance.count)}`,
256
+ color: 'neutral' as const,
257
+ variant: 'outline' as const,
258
+ class: 'font-mono',
259
+ onClick: () => emit('use-instance', instance.id),
260
+ })),
261
+ )
262
+
263
+ // A mistyped schema, instance or table, and the closest name the workspace
264
+ // has: the console offers to use it, as for a table.
265
+ const mistyped = computed<UnknownName | null>(() =>
266
+ props.failure
267
+ ? (props.failure.unknown ?? unknownName(props.failure.message))
268
+ : null,
269
+ )
270
+ const suggestion = computed(() => {
271
+ const unknown = mistyped.value
272
+ if (!unknown) return null
273
+ if (unknown.kind === 'schema')
274
+ return closestName(
275
+ unknown.name,
276
+ props.schemas.map((schema) => schema.id),
277
+ )
278
+ const schema = props.schemas.find(
279
+ (candidate) => candidate.id === props.target.schema,
280
+ )
281
+ if (!schema) return null
282
+ return closestName(
283
+ unknown.name,
284
+ unknown.kind === 'table'
285
+ ? schema.tables.map((table) => table.name)
286
+ : (schema.instances ?? []),
287
+ )
288
+ })
91
289
  </script>
92
290
 
93
291
  <template>
94
- <DmsCard>
95
- <!-- status bar -->
96
- <div class="flex flex-wrap items-center gap-2.5 mb-4">
292
+ <DmsCard :padded="false" class="overflow-hidden">
293
+ <div
294
+ class="border-default flex flex-wrap items-center gap-2.5 border-b px-4 py-2.5"
295
+ >
97
296
  <UIcon
98
297
  :name="
99
- result?.error ? 'i-ph-x-circle-fill' : 'i-ph-check-circle-fill'
298
+ running
299
+ ? 'i-ph-circle-notch'
300
+ : failure
301
+ ? 'i-ph-x-circle'
302
+ : 'i-ph-check-circle'
303
+ "
304
+ :class="
305
+ running
306
+ ? 'text-dimmed animate-spin'
307
+ : failure
308
+ ? 'text-error'
309
+ : result
310
+ ? 'text-success'
311
+ : 'text-dimmed'
100
312
  "
101
- :class="result?.error ? 'text-error' : 'text-success'"
102
313
  class="size-4"
103
314
  />
104
- <b class="text-highlighted text-[15px]">
105
- {{ $t("dms_database.query.result") }}
315
+ <b class="text-highlighted text-sm">
316
+ {{ t('dms_database.query.result.title') }}
106
317
  </b>
107
- <template v-if="result && !result.error">
108
- <UBadge color="success" variant="soft" size="sm" class="font-mono">
109
- {{ $t("dms_database.query.rowCount", { count: rows.length }, rows.length) }}
318
+ <span v-if="running" class="text-muted text-xs" role="status">
319
+ {{ t('dms_database.query.result.running') }}
320
+ </span>
321
+ <template v-else-if="result">
322
+ <UBadge
323
+ :color="result.mutation ? 'info' : 'success'"
324
+ variant="soft"
325
+ size="sm"
326
+ class="font-mono"
327
+ >
328
+ {{
329
+ result.mutation
330
+ ? t(
331
+ 'dms_database.query.result.changed',
332
+ { count: n(result.rowCount) },
333
+ result.rowCount,
334
+ )
335
+ : t(
336
+ 'dms_database.query.result.rows',
337
+ { count: n(result.rowCount) },
338
+ result.rowCount,
339
+ )
340
+ }}
110
341
  </UBadge>
111
342
  <span class="text-dimmed font-mono text-xs">
112
- {{ $t("dms_database.query.runtime", { ms: result.durationMs }) }}
343
+ {{ n(result.durationMs) }} ms ·
344
+ {{
345
+ result.mutation
346
+ ? t('dms_database.query.result.wrote')
347
+ : t('dms_database.query.result.read_only')
348
+ }}
349
+ <template v-if="targetLabel">· {{ targetLabel }}</template>
113
350
  </span>
114
351
  </template>
115
- <DmsSegmented
116
- v-model="internalMode"
117
- :items="modeItems"
118
- :aria-label="$t('dms_database.query.result')"
119
- class="ml-auto"
120
- />
121
- </div>
122
-
123
- <!-- body -->
124
- <div class="min-h-64">
125
352
  <div
126
- v-if="!result"
127
- class="flex flex-col items-center justify-center gap-3 py-16 text-dimmed"
353
+ v-if="result && rows.length > 0 && !running"
354
+ class="ml-auto flex flex-wrap items-center gap-1.5"
128
355
  >
129
- <UIcon name="i-ph-code" class="text-4xl text-dimmed/60" />
130
- <p class="text-sm">{{ $t("dms_database.query.emptyResult") }}</p>
356
+ <DmsSegmented
357
+ v-model="mode"
358
+ :items="modeItems"
359
+ size="sm"
360
+ :aria-label="t('dms_database.query.result.title')"
361
+ />
362
+ <UTooltip :text="t('dms_database.query.result.copy')">
363
+ <UButton
364
+ icon="i-ph-copy"
365
+ color="neutral"
366
+ variant="ghost"
367
+ size="sm"
368
+ :aria-label="t('dms_database.query.result.copy')"
369
+ @click="copyJson"
370
+ />
371
+ </UTooltip>
372
+ <UButton
373
+ icon="i-ph-download-simple"
374
+ color="neutral"
375
+ variant="outline"
376
+ size="sm"
377
+ :label="t('dms_database.query.result.export')"
378
+ @click="exportCsv"
379
+ />
131
380
  </div>
381
+ </div>
132
382
 
133
- <div
134
- v-else-if="result.error"
135
- class="rounded-lg border border-error/40 bg-error/10 px-4 py-3 text-sm text-error whitespace-pre-wrap"
136
- >
137
- <p class="font-semibold mb-1">
138
- {{ $t("dms_database.query.errorTitle") }}
139
- </p>
140
- <p>{{ result.error }}</p>
383
+ <div
384
+ class="min-h-56 transition-opacity"
385
+ :class="running ? 'pointer-events-none opacity-50' : ''"
386
+ :aria-busy="running"
387
+ >
388
+ <div v-if="failure" class="grid gap-3 p-4">
389
+ <div class="border-error/40 bg-error/10 rounded-lg border px-4 py-3">
390
+ <p class="text-error text-sm font-semibold">
391
+ {{ t('dms_database.query.result.failed_title') }}
392
+ </p>
393
+ <p class="text-muted text-xs">
394
+ {{
395
+ failure.durationMs === null
396
+ ? t('dms_database.query.result.failed_local')
397
+ : t('dms_database.query.result.failed_detail', {
398
+ ms: n(failure.durationMs),
399
+ })
400
+ }}
401
+ </p>
402
+ <p
403
+ class="text-toned mt-2 whitespace-pre-wrap font-mono text-[12.5px]"
404
+ >
405
+ {{ failure.message }}
406
+ </p>
407
+ </div>
408
+ <div v-if="mistyped" class="flex flex-wrap gap-2">
409
+ <UButton
410
+ v-if="suggestion"
411
+ size="sm"
412
+ icon="i-ph-arrow-right"
413
+ :label="
414
+ t('dms_database.query.result.replace', { table: suggestion })
415
+ "
416
+ @click="
417
+ emit('replace-name', mistyped.kind, mistyped.name, suggestion)
418
+ "
419
+ />
420
+ <UButton
421
+ v-if="target.schema && mistyped.kind === 'table'"
422
+ size="sm"
423
+ color="neutral"
424
+ variant="ghost"
425
+ :label="
426
+ t('dms_database.query.result.see_tables', {
427
+ schema: target.schema,
428
+ })
429
+ "
430
+ :to="`${DATABASE_PAGES.schemas}?tab=schema-${target.schema}`"
431
+ />
432
+ </div>
433
+ </div>
434
+
435
+ <div v-else-if="!result" class="grid place-items-center p-8">
436
+ <DmsEmptyState
437
+ icon="i-ph-play"
438
+ :title="t('dms_database.query.result.first_title')"
439
+ :description="
440
+ examples.length
441
+ ? t('dms_database.query.result.first_description')
442
+ : undefined
443
+ "
444
+ :actions="exampleActions"
445
+ />
446
+ </div>
447
+
448
+ <div v-else-if="rows.length === 0" class="grid place-items-center p-8">
449
+ <DmsEmptyState
450
+ v-if="!result.mutation && elsewhereActions.length"
451
+ size="sm"
452
+ icon="i-ph-stack"
453
+ :title="t('dms_database.query.result.elsewhere_title')"
454
+ :description="t('dms_database.query.result.elsewhere_description')"
455
+ :actions="elsewhereActions"
456
+ />
457
+ <DmsEmptyState
458
+ v-else
459
+ size="sm"
460
+ :title="
461
+ result.mutation
462
+ ? t('dms_database.query.result.done')
463
+ : t('dms_database.query.result.no_rows')
464
+ "
465
+ />
141
466
  </div>
142
467
 
143
- <!-- table -->
144
- <div v-else-if="internalMode === 'table'" class="overflow-x-auto -mx-1">
468
+ <div v-else-if="mode === 'table'" class="max-h-[28rem] overflow-auto">
145
469
  <table class="w-full text-sm">
146
- <thead>
147
- <tr class="border-b border-default text-left">
470
+ <thead class="bg-elevated sticky top-0">
471
+ <tr class="border-default border-b text-left">
148
472
  <th
149
- v-for="column in columns"
473
+ v-for="column in tableColumns"
150
474
  :key="column"
151
- class="px-3 py-2 font-mono text-xs font-semibold text-dimmed"
475
+ class="text-dimmed whitespace-nowrap px-3 py-2 font-mono text-xs font-semibold"
476
+ :class="rightAligned.has(column) ? 'text-right' : ''"
152
477
  >
153
478
  {{ column }}
154
479
  </th>
480
+ <th v-if="browsable" class="w-8" />
155
481
  </tr>
156
482
  </thead>
157
483
  <tbody>
158
484
  <tr
159
- v-for="(row, rowIndex) in rows"
485
+ v-for="(row, rowIndex) in shownRows"
160
486
  :key="rowIndex"
161
- class="border-b border-default/60 last:border-0 hover:bg-elevated/40 transition-colors"
487
+ class="group/result border-default/60 hover:bg-elevated/40 border-b last:border-0"
162
488
  >
163
489
  <td
164
- v-for="(column, colIndex) in columns"
490
+ v-for="column in tableColumns"
165
491
  :key="column"
166
- class="px-3 py-2 font-mono whitespace-nowrap"
167
- :class="colIndex === 0 ? 'text-primary' : 'text-toned'"
492
+ class="max-w-80 truncate whitespace-nowrap px-3 py-1.5 font-mono text-[12.5px]"
493
+ :class="[
494
+ row[column] === null || row[column] === undefined
495
+ ? 'text-dimmed'
496
+ : 'text-toned',
497
+ typeof row[column] === 'number'
498
+ ? 'text-right tabular-nums'
499
+ : '',
500
+ ]"
168
501
  >
169
- {{ renderCell((row as Record<string, unknown>)[column]) }}
502
+ {{ renderCell(row[column]) }}
503
+ </td>
504
+ <td v-if="browsable" class="px-1">
505
+ <UButton
506
+ v-if="rowLink(row)"
507
+ :to="rowLink(row) ?? undefined"
508
+ icon="i-ph-arrow-square-out"
509
+ color="neutral"
510
+ variant="ghost"
511
+ size="xs"
512
+ class="opacity-0 focus-visible:opacity-100 group-hover/result:opacity-100"
513
+ :aria-label="t('dms_database.query.result.open_row')"
514
+ :title="t('dms_database.query.result.open_row')"
515
+ />
170
516
  </td>
171
517
  </tr>
172
518
  </tbody>
173
519
  </table>
174
520
  </div>
175
521
 
176
- <!-- json -->
177
522
  <pre
178
- v-else-if="internalMode === 'json'"
179
- class="text-xs rounded-lg border border-default bg-default p-4 overflow-auto max-h-80 whitespace-pre text-toned"
180
- >{{ jsonText }}</pre
523
+ v-else-if="mode === 'json'"
524
+ class="text-toned max-h-[28rem] overflow-auto whitespace-pre p-4 font-mono text-xs"
525
+ >{{ JSON.stringify(rows, null, 2) }}</pre
181
526
  >
182
527
 
183
- <!-- chart -->
184
- <div v-else>
528
+ <div v-else class="grid gap-3 p-4">
529
+ <div class="flex flex-wrap items-center gap-2">
530
+ <DmsEyebrow :label="t('dms_database.query.result.bars')" />
531
+ <USelect
532
+ v-model="xColumn"
533
+ :items="columns"
534
+ size="xs"
535
+ class="min-w-36 font-mono"
536
+ :aria-label="t('dms_database.query.result.x_axis')"
537
+ >
538
+ <template #leading>
539
+ <span class="text-dimmed text-[11px]">x</span>
540
+ </template>
541
+ </USelect>
542
+ <USelect
543
+ v-model="yColumn"
544
+ :items="numericColumns"
545
+ size="xs"
546
+ class="min-w-36 font-mono"
547
+ :aria-label="t('dms_database.query.result.y_axis')"
548
+ >
549
+ <template #leading>
550
+ <span class="text-dimmed text-[11px]">y</span>
551
+ </template>
552
+ </USelect>
553
+ <span v-if="rows.length > CHART_ROWS" class="text-dimmed text-xs">
554
+ {{
555
+ t('dms_database.query.result.chart_first', {
556
+ count: CHART_ROWS,
557
+ total: n(rows.length),
558
+ })
559
+ }}
560
+ </span>
561
+ </div>
185
562
  <DmsChart
186
- v-if="chartAvailable"
187
- type="area"
563
+ v-if="yColumn"
564
+ :key="sideways ? 'sideways' : 'upright'"
565
+ page-id="query"
566
+ component-id="query-result-chart"
567
+ type="bar"
188
568
  :static-dataset="chartDataset"
189
569
  color="primary"
190
- height="280px"
570
+ :height="chartHeight"
571
+ :orientation="sideways ? 'horizontal' : 'vertical'"
191
572
  :show-legend="false"
192
573
  xaxis-type="category"
193
574
  />
194
- <div
575
+ <DmsEmptyState
195
576
  v-else
196
- class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-center"
197
- >
198
- <UIcon name="i-ph-chart-line" class="text-4xl text-dimmed/60" />
199
- <p class="text-sm">{{ $t("dms_database.query.chartUnavailable") }}</p>
200
- </div>
577
+ size="sm"
578
+ icon="i-ph-chart-bar"
579
+ :title="t('dms_database.query.result.chart_unavailable')"
580
+ />
201
581
  </div>
202
582
  </div>
583
+
584
+ <div
585
+ v-if="result && rows.length > 0"
586
+ class="border-default text-muted flex flex-wrap items-center gap-2 border-t px-4 py-2 text-xs"
587
+ >
588
+ <UIcon
589
+ :name="result.truncated ? 'i-ph-warning' : 'i-ph-info'"
590
+ :class="result.truncated ? 'text-warning' : ''"
591
+ class="size-3.5"
592
+ />
593
+ <!-- The server cut the result and the table shows fewer still: both
594
+ limits are said. -->
595
+ <span v-if="result.truncated">
596
+ {{
597
+ t('dms_database.query.result.truncated', {
598
+ shown: n(rows.length),
599
+ total: n(result.rowCount),
600
+ })
601
+ }}
602
+ </span>
603
+ <span v-if="tableCut">
604
+ {{
605
+ t('dms_database.query.result.table_first', {
606
+ shown: n(MAX_TABLE_ROWS),
607
+ total: n(rows.length),
608
+ })
609
+ }}
610
+ </span>
611
+ <span v-if="!result.truncated && !tableCut">
612
+ {{
613
+ t(
614
+ 'dms_database.query.result.all_shown',
615
+ { count: n(rows.length) },
616
+ rows.length,
617
+ )
618
+ }}
619
+ </span>
620
+ <UButton
621
+ v-if="tableBrowserLink"
622
+ class="ml-auto"
623
+ size="xs"
624
+ color="neutral"
625
+ variant="ghost"
626
+ icon="i-ph-rows"
627
+ :to="tableBrowserLink"
628
+ :label="
629
+ t('dms_database.query.result.open_table', { target: targetLabel })
630
+ "
631
+ />
632
+ </div>
203
633
  </DmsCard>
204
634
  </template>