@antelopejs/dms-database 0.1.2 → 0.2.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.
- package/README.md +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/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 +43 -89
- 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 +2 -1
- package/frontend-vue/pnpm-lock.yaml +883 -2587
- 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 +4 -4
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ instance, `all` for every instance, otherwise the instance's name.
|
|
|
25
25
|
- **Query console**: AQL with completion of the workspace's schemas, a library of past, saved and
|
|
26
26
|
shared queries, and a dry run before any query that changes data.
|
|
27
27
|
|
|
28
|
-
The module requires `@antelopejs/dms` 0.
|
|
28
|
+
The module requires `@antelopejs/dms` 0.9 (`@antelopejs/interface-dms` 0.7) and `@antelopejs/dms-frontend` 0.6.
|
|
29
29
|
|
|
30
30
|
## Installation
|
|
31
31
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type BrowserTab, DEFAULT_INSTANCE_VALUE } from './useDataBrowserTabs'
|
|
2
|
-
// Per-tab grid state (paging, sort, filters, column widths)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Per-tab grid state (paging, sort, filters, column widths), keyed by
|
|
3
|
+
// BrowserTab.id. The rows themselves are `useApiData`'s: a tab switched back
|
|
4
|
+
// to shows its last page at once and reads it again.
|
|
5
5
|
|
|
6
6
|
export interface ColumnFilterState {
|
|
7
7
|
mode: 'is' | 'contains'
|
|
@@ -31,9 +31,6 @@ export interface GridData {
|
|
|
31
31
|
rows: Record<string, unknown>[]
|
|
32
32
|
total: number
|
|
33
33
|
primaryKey: string | null
|
|
34
|
-
// Serialised query parameters that produced this payload; lets the grid
|
|
35
|
-
// skip refetching when nothing changed.
|
|
36
|
-
paramsKey: string
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
export const GRID_PAGE_SIZES = [25, 50, 100, 200]
|
|
@@ -55,32 +52,15 @@ export function useDataBrowserGrid() {
|
|
|
55
52
|
'dms-database-grid-states',
|
|
56
53
|
() => ({}),
|
|
57
54
|
)
|
|
58
|
-
const cache = useDmsState<Record<string, GridData>>(
|
|
59
|
-
'dms-database-grid-cache',
|
|
60
|
-
() => ({}),
|
|
61
|
-
)
|
|
62
55
|
|
|
63
56
|
function getState(tabId: string): GridState {
|
|
64
57
|
if (!states.value[tabId]) states.value[tabId] = defaultGridState()
|
|
65
58
|
return states.value[tabId]
|
|
66
59
|
}
|
|
67
60
|
|
|
68
|
-
function getCache(tabId: string): GridData | null {
|
|
69
|
-
return cache.value[tabId] ?? null
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function setCache(tabId: string, data: GridData) {
|
|
73
|
-
cache.value[tabId] = data
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function invalidate(tabId: string) {
|
|
77
|
-
Reflect.deleteProperty(cache.value, tabId)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
61
|
// Forget everything about a closed tab.
|
|
81
62
|
function drop(tabId: string) {
|
|
82
63
|
Reflect.deleteProperty(states.value, tabId)
|
|
83
|
-
Reflect.deleteProperty(cache.value, tabId)
|
|
84
64
|
}
|
|
85
65
|
|
|
86
66
|
// A tab switched to another instance keeps what the user narrowed the
|
|
@@ -94,10 +74,9 @@ export function useDataBrowserGrid() {
|
|
|
94
74
|
columnWidths: { ...from.columnWidths },
|
|
95
75
|
page: 0,
|
|
96
76
|
}
|
|
97
|
-
Reflect.deleteProperty(cache.value, toId)
|
|
98
77
|
}
|
|
99
78
|
|
|
100
|
-
return { getState,
|
|
79
|
+
return { getState, drop, carry }
|
|
101
80
|
}
|
|
102
81
|
|
|
103
82
|
// --- query helpers shared by the grid ---
|
|
@@ -162,16 +141,3 @@ export function browseListQuery(
|
|
|
162
141
|
}
|
|
163
142
|
return query
|
|
164
143
|
}
|
|
165
|
-
|
|
166
|
-
// Everything that changes the server payload (column widths deliberately not).
|
|
167
|
-
export function browseParamsKey(tab: BrowserTab, state: GridState): string {
|
|
168
|
-
return JSON.stringify([
|
|
169
|
-
tab.id,
|
|
170
|
-
state.page,
|
|
171
|
-
state.pageSize,
|
|
172
|
-
state.sortKey,
|
|
173
|
-
state.sortDirection,
|
|
174
|
-
state.search,
|
|
175
|
-
activeFilterEntries(state),
|
|
176
|
-
])
|
|
177
|
-
}
|
|
@@ -34,11 +34,14 @@ const EMPTY_HEALTH: OverviewHealth = {
|
|
|
34
34
|
storage: [],
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const { data, pending, error, refresh } =
|
|
37
|
+
/** `refreshInterval` probes again on that interval while the page shows it. */
|
|
38
|
+
export function useDatabaseHealth(options: { refreshInterval?: number } = {}) {
|
|
39
|
+
const { data, pending, error, refresh } = useApiData<OverviewHealth>(
|
|
40
40
|
HEALTH_ENDPOINT,
|
|
41
|
-
{
|
|
41
|
+
{
|
|
42
|
+
default: () => ({ ...EMPTY_HEALTH }),
|
|
43
|
+
refreshInterval: options.refreshInterval,
|
|
44
|
+
},
|
|
42
45
|
)
|
|
43
46
|
|
|
44
47
|
return {
|
|
@@ -110,8 +110,7 @@ export function inboundRelationsIn(
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
export function useDatabaseSchemas() {
|
|
113
|
-
const {
|
|
114
|
-
const { data, pending, error, status, refresh } = useFetchAuth<{
|
|
113
|
+
const { data, pending, error, status, refresh } = useApiData<{
|
|
115
114
|
schemas: SchemaSummary[]
|
|
116
115
|
}>(SCHEMAS_ENDPOINT, { default: () => EMPTY_SCHEMAS })
|
|
117
116
|
|
|
@@ -32,6 +32,13 @@ export type DiagramNoteUpdate = Partial<
|
|
|
32
32
|
Pick<DiagramNote, 'text' | 'x' | 'y' | 'width' | 'height' | 'color'>
|
|
33
33
|
>
|
|
34
34
|
|
|
35
|
+
/** A schema's diagram as stored: its layout and its notes. */
|
|
36
|
+
export interface StoredDiagram {
|
|
37
|
+
schema: string
|
|
38
|
+
positions: DiagramPositions
|
|
39
|
+
notes: DiagramNote[]
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
const BASE = '/api/database/diagram'
|
|
36
43
|
// A request that hangs fails after this (ofetch's own `timeout`), so the
|
|
37
44
|
// canvas shows the error and its "Try again" instead of "Saving…" forever.
|
|
@@ -40,12 +47,47 @@ const REQUEST_TIMEOUT_MS = 15_000
|
|
|
40
47
|
export function useDiagramPersistence() {
|
|
41
48
|
const { $authFetch } = useAuthFetch()
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
50
|
+
// The schema whose diagram is read: in the browser only, when the canvas
|
|
51
|
+
// asks, since it draws and edits a working copy of it.
|
|
52
|
+
const readSchema = shallowRef<string | null>(null)
|
|
53
|
+
const stored = useApiData<StoredDiagram>(
|
|
54
|
+
() =>
|
|
55
|
+
readSchema.value ? `dms-database:diagram:${readSchema.value}` : null,
|
|
56
|
+
async ({ $authFetch: read, signal }) => {
|
|
57
|
+
const schema = readSchema.value as string
|
|
58
|
+
const [layout, notes] = await Promise.all([
|
|
59
|
+
read<{ positions: DiagramPositions }>(
|
|
60
|
+
`${BASE}/layout/${encodeURIComponent(schema)}`,
|
|
61
|
+
{ timeout: REQUEST_TIMEOUT_MS, signal },
|
|
62
|
+
),
|
|
63
|
+
read<{ items: DiagramNote[] }>(`${BASE}/notes`, {
|
|
64
|
+
query: { schema },
|
|
65
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
66
|
+
signal,
|
|
67
|
+
}),
|
|
68
|
+
])
|
|
69
|
+
return {
|
|
70
|
+
schema,
|
|
71
|
+
positions: layout.positions ?? {},
|
|
72
|
+
notes: notes.items ?? [],
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
immediate: false,
|
|
77
|
+
server: false,
|
|
78
|
+
watch: false,
|
|
79
|
+
refreshOnPageRefresh: false,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
/** Reads a schema's layout and notes; throws when they cannot be read. */
|
|
84
|
+
async function readDiagram(schema: string): Promise<StoredDiagram> {
|
|
85
|
+
readSchema.value = schema
|
|
86
|
+
await stored.execute()
|
|
87
|
+
const diagram = stored.data.value
|
|
88
|
+
if (stored.error.value || diagram?.schema !== schema)
|
|
89
|
+
throw stored.error.value ?? new Error('Diagram not read')
|
|
90
|
+
return diagram
|
|
49
91
|
}
|
|
50
92
|
|
|
51
93
|
async function saveLayout(
|
|
@@ -62,14 +104,6 @@ export function useDiagramPersistence() {
|
|
|
62
104
|
)
|
|
63
105
|
}
|
|
64
106
|
|
|
65
|
-
async function fetchNotes(schema: string): Promise<DiagramNote[]> {
|
|
66
|
-
const res = await $authFetch<{ items: DiagramNote[] }>(`${BASE}/notes`, {
|
|
67
|
-
query: { schema },
|
|
68
|
-
timeout: REQUEST_TIMEOUT_MS,
|
|
69
|
-
})
|
|
70
|
-
return res.items ?? []
|
|
71
|
-
}
|
|
72
|
-
|
|
73
107
|
async function createNote(payload: DiagramNoteCreate): Promise<DiagramNote> {
|
|
74
108
|
return $authFetch<DiagramNote>(`${BASE}/notes`, {
|
|
75
109
|
method: 'POST',
|
|
@@ -97,9 +131,8 @@ export function useDiagramPersistence() {
|
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
return {
|
|
100
|
-
|
|
134
|
+
readDiagram,
|
|
101
135
|
saveLayout,
|
|
102
|
-
fetchNotes,
|
|
103
136
|
createNote,
|
|
104
137
|
updateNote,
|
|
105
138
|
deleteNote,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export type QueryLanguage = 'aql'
|
|
2
|
-
export type QueryScope = 'me' | 'shared'
|
|
3
2
|
|
|
4
3
|
export interface SavedQuery {
|
|
5
4
|
id: string
|
|
@@ -68,31 +67,61 @@ import { HISTORY_KEPT } from '../query/runs'
|
|
|
68
67
|
|
|
69
68
|
const QUERY_BASE = '/api/database/query'
|
|
70
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The query console's library and requests. The library (the caller's saved
|
|
72
|
+
* queries, the team's shared ones, the runs the server keeps) is read as the
|
|
73
|
+
* console sets up: a server render reads it and hands it over with the page.
|
|
74
|
+
* Every write is a request of its own, after which the console refreshes
|
|
75
|
+
* the part of the library it changed.
|
|
76
|
+
*/
|
|
71
77
|
export function useQueryStore() {
|
|
72
78
|
const { $authFetch } = useAuthFetch()
|
|
73
79
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const saved = useApiData<{ items: SavedQuery[] }>(`${QUERY_BASE}/saved`, {
|
|
81
|
+
query: { scope: 'me' },
|
|
82
|
+
})
|
|
83
|
+
const shared = useApiData<{ items: SavedQuery[] }>(`${QUERY_BASE}/saved`, {
|
|
84
|
+
query: { scope: 'shared' },
|
|
85
|
+
})
|
|
86
|
+
const history = useApiData<{ items: HistoryEntry[] }>(
|
|
87
|
+
`${QUERY_BASE}/history`,
|
|
88
|
+
{ query: { limit: HISTORY_KEPT } },
|
|
89
|
+
)
|
|
90
|
+
const reads = [saved, shared, history]
|
|
91
|
+
|
|
92
|
+
/** The saved queries and the shared ones, read again. */
|
|
93
|
+
async function refreshSaved(): Promise<void> {
|
|
94
|
+
await Promise.all([saved.refresh(), shared.refresh()])
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
// A run a link names (`?history=`), read when the console opens it.
|
|
98
|
+
const entryId = shallowRef<string | null>(null)
|
|
99
|
+
const entry = useApiData<HistoryEntry>(
|
|
100
|
+
() =>
|
|
101
|
+
entryId.value
|
|
102
|
+
? `${QUERY_BASE}/history/${encodeURIComponent(entryId.value)}`
|
|
103
|
+
: null,
|
|
104
|
+
{
|
|
105
|
+
immediate: false,
|
|
106
|
+
server: false,
|
|
107
|
+
watch: false,
|
|
108
|
+
refreshOnPageRefresh: false,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
85
112
|
async function fetchHistoryEntry(id: string): Promise<HistoryEntry> {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
113
|
+
entryId.value = id
|
|
114
|
+
await entry.execute()
|
|
115
|
+
if (entry.error.value || !entry.data.value)
|
|
116
|
+
throw entry.error.value ?? new Error('History entry not read')
|
|
117
|
+
return entry.data.value
|
|
89
118
|
}
|
|
90
119
|
|
|
91
120
|
async function clearHistory(): Promise<void> {
|
|
92
121
|
await $authFetch<{ success: boolean }>(`${QUERY_BASE}/history`, {
|
|
93
122
|
method: 'DELETE',
|
|
94
123
|
})
|
|
95
|
-
|
|
124
|
+
history.data.value = { items: [] }
|
|
96
125
|
}
|
|
97
126
|
|
|
98
127
|
async function dryRun(query: Record<string, unknown>): Promise<DryRunResult> {
|
|
@@ -102,15 +131,6 @@ export function useQueryStore() {
|
|
|
102
131
|
})
|
|
103
132
|
}
|
|
104
133
|
|
|
105
|
-
/** Every run the server keeps, at once. */
|
|
106
|
-
async function fetchHistory(): Promise<void> {
|
|
107
|
-
const response = await $authFetch<{ items: HistoryEntry[] }>(
|
|
108
|
-
`${QUERY_BASE}/history`,
|
|
109
|
-
{ query: { limit: HISTORY_KEPT } },
|
|
110
|
-
)
|
|
111
|
-
historyItems.value = response.items ?? []
|
|
112
|
-
}
|
|
113
|
-
|
|
114
134
|
async function executeQuery(
|
|
115
135
|
query: Record<string, unknown>,
|
|
116
136
|
source: string,
|
|
@@ -147,10 +167,22 @@ export function useQueryStore() {
|
|
|
147
167
|
}
|
|
148
168
|
|
|
149
169
|
return {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
170
|
+
/** The caller's saved queries. A failed refresh keeps the last list. */
|
|
171
|
+
savedQueries: computed(() => saved.data.value?.items ?? []),
|
|
172
|
+
/** The queries shared with the team, the caller's own included. */
|
|
173
|
+
sharedQueries: computed(() => shared.data.value?.items ?? []),
|
|
174
|
+
/** Every run the server keeps, newest first. */
|
|
175
|
+
historyItems: computed(() => history.data.value?.items ?? []),
|
|
176
|
+
/** The library's first read is still running. */
|
|
177
|
+
libraryLoading: computed(() =>
|
|
178
|
+
reads.some(
|
|
179
|
+
(read) => read.data.value === null && read.status.value !== 'error',
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
/** Settles once the library's first read did, failed or not. */
|
|
183
|
+
libraryRead: Promise.all(reads).then(() => undefined),
|
|
184
|
+
refreshSaved,
|
|
185
|
+
refreshHistory: () => history.refresh(),
|
|
154
186
|
fetchHistoryEntry,
|
|
155
187
|
clearHistory,
|
|
156
188
|
dryRun,
|
|
@@ -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
|
+
}
|
|
@@ -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>
|