@antelopejs/dms-database 0.1.2 → 0.2.1
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.
- package/README.md +73 -49
- package/dist/db/models/diagram_layouts.model.d.ts +1 -0
- package/dist/db/models/diagram_notes.model.d.ts +1 -0
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/saved_queries.model.d.ts +1 -0
- package/dist/routes/data.d.ts +1 -3
- package/dist/routes/data.js +6 -1
- package/dist/routes/data.js.map +1 -1
- package/frontend-vue/app/build/composables/useDataBrowserGrid.ts +4 -38
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +7 -4
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +1 -2
- package/frontend-vue/app/build/composables/useDiagramPersistence.ts +49 -16
- package/frontend-vue/app/build/composables/useQueryStore.ts +59 -27
- package/frontend-vue/app/build/composables/useTableCounts.ts +50 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +46 -6
- package/frontend-vue/app/build/data/useStagedSave.ts +42 -12
- package/frontend-vue/app/build/query/runs.ts +7 -27
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +10 -33
- package/frontend-vue/app/components/ConnectionStatus.vue +14 -11
- package/frontend-vue/app/components/DataBrowserSidebar.vue +20 -42
- package/frontend-vue/app/components/DataBrowserTabBar.vue +1 -1
- package/frontend-vue/app/components/DataGrid.vue +73 -91
- package/frontend-vue/app/components/InstancePicker.vue +63 -55
- package/frontend-vue/app/components/QueryConsole.vue +47 -37
- package/frontend-vue/app/components/QueryLibrary.vue +20 -17
- package/frontend-vue/app/components/RowDrawer.vue +15 -18
- package/frontend-vue/app/components/SchemaDiagram.vue +7 -7
- package/frontend-vue/app/components/TableInspector.vue +116 -84
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +1388 -2803
- package/frontend-vue/tests/dataBrowser.test.ts +59 -1
- package/frontend-vue/tests/fixtures/inertia.ts +8 -0
- package/frontend-vue/tests/fixtures/serverRender.ts +219 -0
- package/frontend-vue/tests/fixtures/vueI18n.ts +4 -0
- package/frontend-vue/tests/helpers.test.ts +1 -0
- package/frontend-vue/tests/locales.test.ts +24 -0
- package/frontend-vue/tests/onDemandReads.test.ts +106 -0
- package/frontend-vue/tests/serverRender.test.ts +235 -0
- package/frontend-vue/vitest.config.ts +24 -0
- package/package.json +31 -14
- package/skills/dms-database/SKILL.md +35 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import { isFilterToken } from './useDataBrowserGrid'
|
|
3
|
+
import { DEFAULT_INSTANCE_VALUE } from './useDataBrowserTabs'
|
|
4
|
+
|
|
5
|
+
const TABLES_ENDPOINT = '/api/database/browse/tables'
|
|
6
|
+
|
|
7
|
+
interface TableEntry {
|
|
8
|
+
name: string
|
|
9
|
+
elementCount: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The rows of each table of a schema in one instance, for the data browser's
|
|
14
|
+
* table list. A server render reads them and hands them over with the page;
|
|
15
|
+
* another schema or instance is read once picked. No count shows while they
|
|
16
|
+
* load, nor once they failed: `failed` says so.
|
|
17
|
+
*/
|
|
18
|
+
export function useTableCounts(
|
|
19
|
+
schema: Ref<string | null>,
|
|
20
|
+
instance: Ref<string>,
|
|
21
|
+
) {
|
|
22
|
+
const { data, status } = useApiData<{ items: TableEntry[] }>(
|
|
23
|
+
() => (schema.value ? TABLES_ENDPOINT : null),
|
|
24
|
+
{
|
|
25
|
+
query: () => {
|
|
26
|
+
const query: Record<string, string> = {
|
|
27
|
+
filter_schema: isFilterToken(schema.value ?? ''),
|
|
28
|
+
}
|
|
29
|
+
if (instance.value !== DEFAULT_INSTANCE_VALUE)
|
|
30
|
+
query.filter_instance = isFilterToken(instance.value)
|
|
31
|
+
return query
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
/** Rows by table name. */
|
|
38
|
+
counts: computed<Record<string, number>>(() =>
|
|
39
|
+
status.value === 'success'
|
|
40
|
+
? Object.fromEntries(
|
|
41
|
+
(data.value?.items ?? []).map((item) => [
|
|
42
|
+
item.name,
|
|
43
|
+
item.elementCount,
|
|
44
|
+
]),
|
|
45
|
+
)
|
|
46
|
+
: {},
|
|
47
|
+
),
|
|
48
|
+
failed: computed(() => status.value === 'error'),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -20,11 +20,11 @@ function statusOf(error: unknown): number | undefined {
|
|
|
20
20
|
return failure?.statusCode ?? failure?.status
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export async function writeRows(
|
|
23
|
+
export async function writeRows<T = unknown>(
|
|
24
24
|
rows: Iterable<[string, Record<string, unknown>]>,
|
|
25
|
-
write: (rowId: string, body: Record<string, unknown>) => Promise<
|
|
26
|
-
/** Called once a row is settled: written, or gone. */
|
|
27
|
-
onSettled?: (rowId: string) => void,
|
|
25
|
+
write: (rowId: string, body: Record<string, unknown>) => Promise<T>,
|
|
26
|
+
/** Called once a row is settled: written, with the write's answer, or gone. */
|
|
27
|
+
onSettled?: (rowId: string, answer?: T) => void,
|
|
28
28
|
): Promise<RowWriteOutcome> {
|
|
29
29
|
const outcome: RowWriteOutcome = {
|
|
30
30
|
written: [],
|
|
@@ -37,8 +37,9 @@ export async function writeRows(
|
|
|
37
37
|
outcome.left.push(rowId)
|
|
38
38
|
continue
|
|
39
39
|
}
|
|
40
|
+
let answer: T | undefined
|
|
40
41
|
try {
|
|
41
|
-
await write(rowId, body)
|
|
42
|
+
answer = await write(rowId, body)
|
|
42
43
|
outcome.written.push(rowId)
|
|
43
44
|
} catch (error) {
|
|
44
45
|
if (statusOf(error) !== NOT_FOUND) {
|
|
@@ -49,7 +50,7 @@ export async function writeRows(
|
|
|
49
50
|
}
|
|
50
51
|
outcome.gone.push(rowId)
|
|
51
52
|
}
|
|
52
|
-
onSettled?.(rowId)
|
|
53
|
+
onSettled?.(rowId, answer)
|
|
53
54
|
}
|
|
54
55
|
return outcome
|
|
55
56
|
}
|
|
@@ -62,3 +63,42 @@ export function changesOf<T extends { rowId: string }>(
|
|
|
62
63
|
const rows = new Set(rowIds)
|
|
63
64
|
return changes.filter((change) => rows.has(change.rowId))
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
/** How the rows of a grid page are narrowed and ordered. */
|
|
68
|
+
export interface PageRefinement {
|
|
69
|
+
sortKey: string | null
|
|
70
|
+
search: string
|
|
71
|
+
/** The fields a column filter narrows, with a value. */
|
|
72
|
+
filtered: string[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether writing `fields` may take rows in or out of the page, or move them
|
|
77
|
+
* in it: a field it is sorted or filtered by, or any field while a search
|
|
78
|
+
* runs (it matches every column). The page is then read again; otherwise
|
|
79
|
+
* the rows written stay where they are, as the server answered them.
|
|
80
|
+
*/
|
|
81
|
+
export function mayMoveRows(
|
|
82
|
+
fields: Iterable<string>,
|
|
83
|
+
refinement: PageRefinement,
|
|
84
|
+
): boolean {
|
|
85
|
+
if (refinement.search !== '') return true
|
|
86
|
+
const narrowing = new Set(refinement.filtered)
|
|
87
|
+
if (refinement.sortKey) narrowing.add(refinement.sortKey)
|
|
88
|
+
for (const field of fields) if (narrowing.has(field)) return true
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `rows` with the row the server answered a write with in place of the one of
|
|
94
|
+
* the same id: the fields it holds replace theirs, the browse-only ones stay.
|
|
95
|
+
*/
|
|
96
|
+
export function withWrittenRow(
|
|
97
|
+
rows: Record<string, unknown>[],
|
|
98
|
+
written: Record<string, unknown>,
|
|
99
|
+
idOf: (row: Record<string, unknown>) => string | null,
|
|
100
|
+
): Record<string, unknown>[] {
|
|
101
|
+
const id = idOf(written)
|
|
102
|
+
if (id === null) return rows
|
|
103
|
+
return rows.map((row) => (idOf(row) === id ? { ...row, ...written } : row))
|
|
104
|
+
}
|
|
@@ -12,9 +12,10 @@ import { changesOf, type RowWriteOutcome, writeRows } from './stagedWrites'
|
|
|
12
12
|
// Writes the staged edits of a tab after the user reviewed them, then offers
|
|
13
13
|
// to take them back for a few seconds, in the save bar of the current tab
|
|
14
14
|
// (another tab ends the offer). Each row is written with one call of the
|
|
15
|
-
// edit route
|
|
16
|
-
//
|
|
17
|
-
// changes, and a failure stops the
|
|
15
|
+
// edit route, which answers with the row as stored; an undo writes the
|
|
16
|
+
// previous values back the same way. Only the rows written are announced and
|
|
17
|
+
// undone: a row gone meanwhile drops its changes, and a failure stops the
|
|
18
|
+
// save with the rows left still staged.
|
|
18
19
|
|
|
19
20
|
const EDIT_ENDPOINT = '/api/database/browse/edit'
|
|
20
21
|
const UNDO_WINDOW_MS = 10_000
|
|
@@ -27,9 +28,19 @@ interface SaveNotice {
|
|
|
27
28
|
scope: string
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** What a save or an undo wrote, once it is settled. */
|
|
32
|
+
export interface SavedChanges {
|
|
33
|
+
/** The fields written. */
|
|
34
|
+
fields: string[]
|
|
35
|
+
/** Rows gone meanwhile, which the page may still show. */
|
|
36
|
+
gone: number
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
interface StagedSaveOptions {
|
|
31
40
|
tab: () => BrowserTab
|
|
32
|
-
|
|
41
|
+
/** A row written, as the server answered: shown at once in place of the one read. */
|
|
42
|
+
onWritten: (tab: BrowserTab, row: Record<string, unknown>) => void
|
|
43
|
+
onSaved: (saved: SavedChanges) => void
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
/** The server's reason when it gave one, else the error's own message. */
|
|
@@ -41,6 +52,10 @@ function messageOf(error: unknown): string {
|
|
|
41
52
|
return error instanceof Error ? error.message : String(error)
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
function fieldsOf(changes: StagedChange[]): string[] {
|
|
56
|
+
return [...new Set(changes.map((change) => change.field))]
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
export function useStagedSave(options: StagedSaveOptions) {
|
|
45
60
|
const { t, n } = useI18n()
|
|
46
61
|
const { $authFetch } = useAuthFetch()
|
|
@@ -85,12 +100,13 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
85
100
|
return `${tab.schema}.${tab.table} ${badge}`
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
|
|
103
|
+
/** Writes a row, and answers with it as the server now stores it. */
|
|
104
|
+
function writeRow(
|
|
89
105
|
tab: BrowserTab,
|
|
90
106
|
rowId: string,
|
|
91
107
|
body: Record<string, unknown>,
|
|
92
|
-
) {
|
|
93
|
-
|
|
108
|
+
): Promise<Record<string, unknown>> {
|
|
109
|
+
return $authFetch<Record<string, unknown>>(EDIT_ENDPOINT, {
|
|
94
110
|
method: 'PUT',
|
|
95
111
|
query: { ...browseSelectionQuery(tab), id: rowId },
|
|
96
112
|
body,
|
|
@@ -135,8 +151,13 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
135
151
|
)
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
|
|
139
|
-
|
|
154
|
+
/** Shows a row written as the server answered it. */
|
|
155
|
+
function showWritten(tab: BrowserTab, row: Record<string, unknown> | undefined) {
|
|
156
|
+
if (row) options.onWritten(tab, row)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Each row written (or gone) leaves the staged edits at once, its answer
|
|
160
|
+
// shown in its place: a failure further on keeps only the rows not written.
|
|
140
161
|
function saveAll(tab: BrowserTab): Promise<RowWriteOutcome> {
|
|
141
162
|
const rows = staged
|
|
142
163
|
.rowIds(tab.id)
|
|
@@ -147,7 +168,10 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
147
168
|
return writeRows(
|
|
148
169
|
rows,
|
|
149
170
|
(rowId, body) => writeRow(tab, rowId, body),
|
|
150
|
-
(rowId) =>
|
|
171
|
+
(rowId, row) => {
|
|
172
|
+
showWritten(tab, row)
|
|
173
|
+
staged.discard(tab.id, [rowId])
|
|
174
|
+
},
|
|
151
175
|
)
|
|
152
176
|
}
|
|
153
177
|
|
|
@@ -202,6 +226,7 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
202
226
|
const outcome = await writeRows(
|
|
203
227
|
undoBodies(current.changes),
|
|
204
228
|
(rowId, body) => writeRow(tab, rowId, body),
|
|
229
|
+
(_rowId, row) => showWritten(tab, row),
|
|
205
230
|
)
|
|
206
231
|
const restored = changesOf(current.changes, outcome.written)
|
|
207
232
|
const gone = changesOf(current.changes, outcome.gone)
|
|
@@ -243,7 +268,10 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
243
268
|
),
|
|
244
269
|
color: 'warning',
|
|
245
270
|
})
|
|
246
|
-
options.onSaved(
|
|
271
|
+
options.onSaved({
|
|
272
|
+
fields: fieldsOf(current.changes),
|
|
273
|
+
gone: outcome.gone.length,
|
|
274
|
+
})
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
function announce(tab: BrowserTab, saved: StagedChange[]) {
|
|
@@ -259,6 +287,7 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
259
287
|
// the dialog closed, what was written is announced and undoable.
|
|
260
288
|
const saved: StagedChange[] = []
|
|
261
289
|
let settled = false
|
|
290
|
+
let gone = 0
|
|
262
291
|
try {
|
|
263
292
|
await confirm({
|
|
264
293
|
title: t(
|
|
@@ -281,6 +310,7 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
281
310
|
try {
|
|
282
311
|
const outcome = await saveAll(tab)
|
|
283
312
|
saved.push(...changesOf(changes, outcome.written))
|
|
313
|
+
gone += outcome.gone.length
|
|
284
314
|
if (outcome.written.length + outcome.gone.length > 0) settled = true
|
|
285
315
|
return partialOutcome(outcome, changes, saved.length)
|
|
286
316
|
} finally {
|
|
@@ -289,7 +319,7 @@ export function useStagedSave(options: StagedSaveOptions) {
|
|
|
289
319
|
},
|
|
290
320
|
})
|
|
291
321
|
} finally {
|
|
292
|
-
if (settled) options.onSaved()
|
|
322
|
+
if (settled) options.onSaved({ fields: fieldsOf(saved), gone })
|
|
293
323
|
if (saved.length > 0) announce(tab, saved)
|
|
294
324
|
}
|
|
295
325
|
}
|
|
@@ -33,19 +33,20 @@ export interface DayGroup<T> {
|
|
|
33
33
|
items: T[]
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Entries by calendar day, newest day first, in their given order. `dayOf`
|
|
38
|
+
* names a date's day: the reader's (`regionalDayKey`), so that a server
|
|
39
|
+
* render groups as the browser does.
|
|
40
|
+
*/
|
|
41
41
|
export function groupByDay<T>(
|
|
42
42
|
entries: T[],
|
|
43
43
|
dateOf: (entry: T) => string,
|
|
44
|
+
dayOf: (date: Date) => string,
|
|
44
45
|
): DayGroup<T>[] {
|
|
45
46
|
const groups = new Map<string, DayGroup<T>>()
|
|
46
47
|
for (const entry of entries) {
|
|
47
48
|
const date = new Date(dateOf(entry))
|
|
48
|
-
const key =
|
|
49
|
+
const key = dayOf(date)
|
|
49
50
|
const group = groups.get(key) ?? { key, date, items: [] }
|
|
50
51
|
group.items.push(entry)
|
|
51
52
|
groups.set(key, group)
|
|
@@ -53,27 +54,6 @@ export function groupByDay<T>(
|
|
|
53
54
|
return [...groups.values()]
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
const DAY_MS = 86_400_000
|
|
57
|
-
|
|
58
|
-
/** "Today", "Yesterday" or the date, in the interface language. */
|
|
59
|
-
export function dayLabel(
|
|
60
|
-
date: Date,
|
|
61
|
-
locale: string,
|
|
62
|
-
words: { today: string; yesterday: string },
|
|
63
|
-
): string {
|
|
64
|
-
const today = new Date()
|
|
65
|
-
today.setHours(0, 0, 0, 0)
|
|
66
|
-
const day = new Date(date)
|
|
67
|
-
day.setHours(0, 0, 0, 0)
|
|
68
|
-
const days = Math.round((today.getTime() - day.getTime()) / DAY_MS)
|
|
69
|
-
if (days === 0) return words.today
|
|
70
|
-
if (days === 1) return words.yesterday
|
|
71
|
-
return new Intl.DateTimeFormat(locale, {
|
|
72
|
-
day: 'numeric',
|
|
73
|
-
month: 'short',
|
|
74
|
-
}).format(date)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
57
|
export type UnknownKind = 'schema' | 'instance' | 'table'
|
|
78
58
|
|
|
79
59
|
/** A name a query used that its schema does not know. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed
|
|
1
|
+
import { computed } from 'vue'
|
|
2
2
|
import {
|
|
3
3
|
readFilterState,
|
|
4
4
|
sourceQuery,
|
|
@@ -16,47 +16,24 @@ interface SourceResponse {
|
|
|
16
16
|
all?: number
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
type ListingStatus = 'idle' | 'pending' | 'success' | 'error'
|
|
20
|
-
|
|
21
19
|
export function useSchemasListing() {
|
|
22
20
|
const route = useDmsRoute()
|
|
23
|
-
const { $authFetch } = useAuthFetch()
|
|
24
21
|
const state = computed<SchemasFilterState>(() => readFilterState(route.query))
|
|
25
|
-
const query = computed(() => sourceQuery(state.value))
|
|
26
|
-
|
|
27
|
-
// The counts of the last answer stay while the next one loads.
|
|
28
|
-
const total = ref(0)
|
|
29
|
-
const all = ref<number | null>(null)
|
|
30
|
-
const status = ref<ListingStatus>('idle')
|
|
31
|
-
let latest = 0
|
|
32
22
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (request !== latest) return
|
|
41
|
-
total.value = response.total
|
|
42
|
-
all.value = response.all ?? null
|
|
43
|
-
status.value = 'success'
|
|
44
|
-
} catch {
|
|
45
|
-
if (request === latest) status.value = 'error'
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// In the browser only: the server renders no count.
|
|
50
|
-
if (typeof window !== 'undefined') {
|
|
51
|
-
watch(() => JSON.stringify(query.value), load, { immediate: true })
|
|
52
|
-
}
|
|
23
|
+
// Rendered on the server and handed over, the counts only (not the rows
|
|
24
|
+
// the route lists); the counts of the last answer stay while the next one
|
|
25
|
+
// loads.
|
|
26
|
+
const { data, status } = useApiData<SourceResponse>(TABLES_SOURCE, {
|
|
27
|
+
query: () => sourceQuery(state.value),
|
|
28
|
+
pick: ['total', 'all'],
|
|
29
|
+
})
|
|
53
30
|
|
|
54
31
|
return {
|
|
55
32
|
state,
|
|
56
33
|
/** Tables the bar's filters list. */
|
|
57
|
-
total,
|
|
34
|
+
total: computed(() => data.value?.total ?? 0),
|
|
58
35
|
/** Registered tables before any filter; null until known. */
|
|
59
|
-
all,
|
|
36
|
+
all: computed(() => data.value?.all ?? null),
|
|
60
37
|
status,
|
|
61
38
|
}
|
|
62
39
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useDatabaseHealth } from '../build/composables/useDatabaseHealth'
|
|
3
|
-
import {
|
|
3
|
+
import { useNow } from '@vueuse/core'
|
|
4
4
|
|
|
5
5
|
// The overview's health hero: whether the database answers, how fast, and
|
|
6
6
|
// what it holds. The probe runs again every PROBE_INTERVAL_MS while the page
|
|
@@ -10,10 +10,16 @@ const SECOND_MS = 1000
|
|
|
10
10
|
const MINUTE_S = 60
|
|
11
11
|
|
|
12
12
|
const { t, n, locale } = useI18n()
|
|
13
|
-
const { health, isLoading, error, refresh } = useDatabaseHealth(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const { health, isLoading, error, refresh } = useDatabaseHealth({
|
|
14
|
+
refreshInterval: PROBE_INTERVAL_MS,
|
|
15
|
+
})
|
|
16
|
+
const clock = useNow({ interval: SECOND_MS })
|
|
17
|
+
// The probe's own time until the page is mounted: the server renders the age
|
|
18
|
+
// a few seconds before the browser hydrates it, and both must write the same.
|
|
19
|
+
const mounted = ref(false)
|
|
20
|
+
onMounted(() => {
|
|
21
|
+
mounted.value = true
|
|
22
|
+
})
|
|
17
23
|
|
|
18
24
|
// A failed health request is a lost connection too: the route answers
|
|
19
25
|
// whenever the backend is up.
|
|
@@ -24,12 +30,9 @@ const isDown = computed(
|
|
|
24
30
|
const checkedAgo = computed(() => {
|
|
25
31
|
const checkedAt = health.value.checkedAt
|
|
26
32
|
if (!checkedAt) return ''
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(now.value.getTime() - new Date(checkedAt).getTime()) / SECOND_MS,
|
|
31
|
-
),
|
|
32
|
-
)
|
|
33
|
+
const checked = new Date(checkedAt).getTime()
|
|
34
|
+
const now = mounted.value ? clock.value.getTime() : checked
|
|
35
|
+
const seconds = Math.max(0, Math.round((now - checked) / SECOND_MS))
|
|
33
36
|
return seconds < MINUTE_S
|
|
34
37
|
? t('dms_database.overview.connection.checked_seconds', { count: seconds })
|
|
35
38
|
: t('dms_database.overview.connection.checked_minutes', {
|
|
@@ -6,10 +6,11 @@ import {
|
|
|
6
6
|
tabInstanceOf,
|
|
7
7
|
useDataBrowserTabs,
|
|
8
8
|
} from '../build/composables/useDataBrowserTabs'
|
|
9
|
-
import {
|
|
9
|
+
import { useTableCounts } from '../build/composables/useTableCounts'
|
|
10
10
|
import type { InstanceChoice } from '../build/data/instanceOptions'
|
|
11
11
|
import { onKeyStroke } from '@vueuse/core'
|
|
12
12
|
import type { SchemaSummary } from '../build/composables/useDatabaseSchemas'
|
|
13
|
+
import { instanceFromUrl } from '../build/utils/databaseLinks'
|
|
13
14
|
import InstancePicker from './InstancePicker.vue'
|
|
14
15
|
import SchemaPicker from './SchemaPicker.vue'
|
|
15
16
|
|
|
@@ -20,13 +21,6 @@ import SchemaPicker from './SchemaPicker.vue'
|
|
|
20
21
|
// table open in a tab carries a hollow ring, the active one included: the
|
|
21
22
|
// fill of the row says which is shown.
|
|
22
23
|
|
|
23
|
-
const TABLES_ENDPOINT = '/api/database/browse/tables'
|
|
24
|
-
|
|
25
|
-
interface TableEntry {
|
|
26
|
-
name: string
|
|
27
|
-
elementCount: number
|
|
28
|
-
}
|
|
29
|
-
|
|
30
24
|
const props = defineProps<{
|
|
31
25
|
schemas: SchemaSummary[]
|
|
32
26
|
/** The schema list is being read. */
|
|
@@ -38,7 +32,6 @@ const props = defineProps<{
|
|
|
38
32
|
const emit = defineEmits<{ retry: [] }>()
|
|
39
33
|
|
|
40
34
|
const { t, n } = useI18n()
|
|
41
|
-
const { $authFetch } = useAuthFetch()
|
|
42
35
|
const { tabs, activeId, activeTab, activations, openTab } = useDataBrowserTabs()
|
|
43
36
|
const route = useDmsRoute()
|
|
44
37
|
|
|
@@ -49,13 +42,17 @@ const filterInput = useTemplateRef<{ inputRef?: HTMLInputElement }>(
|
|
|
49
42
|
'filterInput',
|
|
50
43
|
)
|
|
51
44
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
45
|
+
// The schema a link names (?schema=X) leads, else the first one; once a tab
|
|
46
|
+
// is active, it leads. A table link (?schema=X&table=Y) opens its tab once
|
|
47
|
+
// mounted, in the instance it names: that scope is shown from the start, so
|
|
48
|
+
// the server renders the counts the browser keeps. A schema or instance
|
|
49
|
+
// picked here holds until the user goes to a tab (another one, or the same
|
|
50
|
+
// again).
|
|
55
51
|
const linkedSchema =
|
|
56
|
-
typeof route.query.schema === 'string'
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
typeof route.query.schema === 'string' ? route.query.schema : null
|
|
53
|
+
const linkedInstance = route.query.table
|
|
54
|
+
? tabInstanceOf(instanceFromUrl(route.query.instance))
|
|
55
|
+
: DEFAULT_INSTANCE_VALUE
|
|
59
56
|
|
|
60
57
|
watch(
|
|
61
58
|
[activeId, activations],
|
|
@@ -74,6 +71,7 @@ watch(
|
|
|
74
71
|
if (schemaId.value || list.length === 0) return
|
|
75
72
|
const linked = list.find((schema) => schema.id === linkedSchema)
|
|
76
73
|
schemaId.value = linked?.id ?? list[0]?.id ?? null
|
|
74
|
+
if (linked) instance.value = linkedInstance
|
|
77
75
|
},
|
|
78
76
|
{ immediate: true },
|
|
79
77
|
)
|
|
@@ -111,32 +109,7 @@ const instanceName = computed(() => {
|
|
|
111
109
|
})
|
|
112
110
|
|
|
113
111
|
// --- tables and their counts in the picked instance ---
|
|
114
|
-
const counts =
|
|
115
|
-
const countsFailed = ref(false)
|
|
116
|
-
|
|
117
|
-
watch(
|
|
118
|
-
[schemaId, instance],
|
|
119
|
-
async ([id, picked]) => {
|
|
120
|
-
counts.value = {}
|
|
121
|
-
countsFailed.value = false
|
|
122
|
-
if (!id) return
|
|
123
|
-
const query: Record<string, string> = { filter_schema: isFilterToken(id) }
|
|
124
|
-
if (picked !== DEFAULT_INSTANCE_VALUE)
|
|
125
|
-
query.filter_instance = isFilterToken(picked)
|
|
126
|
-
try {
|
|
127
|
-
const res = await $authFetch<{ items: TableEntry[] }>(TABLES_ENDPOINT, {
|
|
128
|
-
query,
|
|
129
|
-
})
|
|
130
|
-
if (schemaId.value !== id || instance.value !== picked) return
|
|
131
|
-
counts.value = Object.fromEntries(
|
|
132
|
-
res.items.map((item) => [item.name, item.elementCount]),
|
|
133
|
-
)
|
|
134
|
-
} catch {
|
|
135
|
-
if (schemaId.value === id) countsFailed.value = true
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
{ immediate: true },
|
|
139
|
-
)
|
|
112
|
+
const { counts, failed: countsFailed } = useTableCounts(schemaId, instance)
|
|
140
113
|
|
|
141
114
|
const openIds = computed(() => new Set(tabs.value.map((tab) => tab.id)))
|
|
142
115
|
|
|
@@ -327,7 +300,12 @@ onKeyStroke('t', (event) => {
|
|
|
327
300
|
<span v-if="countsFailed">
|
|
328
301
|
{{ t('dms_database.data.scope.counts_failed') }}
|
|
329
302
|
</span>
|
|
330
|
-
<i18n-t
|
|
303
|
+
<i18n-t
|
|
304
|
+
v-else
|
|
305
|
+
keypath="dms_database.data.scope.counts_for"
|
|
306
|
+
tag="span"
|
|
307
|
+
scope="global"
|
|
308
|
+
>
|
|
331
309
|
<template #instance>
|
|
332
310
|
<span class="text-primary font-mono">{{ instanceName }}</span>
|
|
333
311
|
</template>
|
|
@@ -248,7 +248,7 @@ async function close(tab: BrowserTab) {
|
|
|
248
248
|
v-if="hasPreview"
|
|
249
249
|
class="text-dimmed hidden shrink-0 self-center whitespace-nowrap px-3 pb-1 pt-1.5 text-[11px] lg:inline"
|
|
250
250
|
>
|
|
251
|
-
<i18n-t keypath="dms_database.data.tabs.hint" tag="span">
|
|
251
|
+
<i18n-t keypath="dms_database.data.tabs.hint" tag="span" scope="global">
|
|
252
252
|
<template #italic>
|
|
253
253
|
<em>{{ t('dms_database.data.tabs.italic') }}</em>
|
|
254
254
|
</template>
|