@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.
- package/README.md +30 -5
- package/dist/db/models/query_history.model.d.ts +1 -0
- package/dist/db/models/query_history.model.js +6 -0
- package/dist/db/models/query_history.model.js.map +1 -1
- package/dist/db/tables/query_history.table.d.ts +3 -0
- package/dist/db/tables/query_history.table.js +12 -0
- package/dist/db/tables/query_history.table.js.map +1 -1
- package/dist/db/tables/saved_queries.table.d.ts +1 -0
- package/dist/db/tables/saved_queries.table.js +4 -0
- package/dist/db/tables/saved_queries.table.js.map +1 -1
- package/dist/i18n/messages.d.ts +39 -0
- package/dist/i18n/messages.js +64 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -7
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +11 -0
- package/dist/module.js +55 -0
- package/dist/module.js.map +1 -0
- package/dist/pages/data.d.ts +1 -1
- package/dist/pages/data.js +9 -6
- package/dist/pages/data.js.map +1 -1
- package/dist/pages/diagram.d.ts +9 -0
- package/dist/pages/diagram.js +45 -0
- package/dist/pages/diagram.js.map +1 -0
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/overview.d.ts +4 -1
- package/dist/pages/overview.js +113 -6
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/query.d.ts +1 -1
- package/dist/pages/query.js +22 -6
- package/dist/pages/query.js.map +1 -1
- package/dist/pages/schemas.d.ts +2 -1
- package/dist/pages/schemas.js +143 -6
- package/dist/pages/schemas.js.map +1 -1
- package/dist/routes/data.d.ts +8 -0
- package/dist/routes/data.js +84 -21
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/overview.d.ts +12 -0
- package/dist/routes/overview.js +55 -0
- package/dist/routes/overview.js.map +1 -0
- package/dist/routes/query.d.ts +5 -1
- package/dist/routes/query.js +162 -27
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/tables.d.ts +15 -12
- package/dist/routes/tables.js +95 -55
- package/dist/routes/tables.js.map +1 -1
- package/dist/service/catalog.d.ts +3 -0
- package/dist/service/catalog.js +26 -0
- package/dist/service/catalog.js.map +1 -0
- package/dist/service/health.d.ts +3 -3
- package/dist/service/health.js +17 -14
- package/dist/service/health.js.map +1 -1
- package/dist/service/introspect.d.ts +3 -1
- package/dist/service/introspect.js +131 -43
- package/dist/service/introspect.js.map +1 -1
- package/dist/service/overview.d.ts +9 -0
- package/dist/service/overview.js +113 -0
- package/dist/service/overview.js.map +1 -0
- package/dist/service/queryInspection.d.ts +15 -0
- package/dist/service/queryInspection.js +107 -0
- package/dist/service/queryInspection.js.map +1 -0
- package/dist/service/references.d.ts +9 -0
- package/dist/service/references.js +52 -0
- package/dist/service/references.js.map +1 -0
- package/dist/service/schemaFilters.d.ts +26 -0
- package/dist/service/schemaFilters.js +42 -0
- package/dist/service/schemaFilters.js.map +1 -0
- package/dist/service/stagedSerialization.d.ts +2 -0
- package/dist/service/stagedSerialization.js +38 -10
- package/dist/service/stagedSerialization.js.map +1 -1
- package/dist/service/types.d.ts +11 -1
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/constants.js +3 -1
- package/dist/types/constants.js.map +1 -1
- package/dist/utils/query.d.ts +0 -2
- package/dist/utils/query.js +0 -20
- package/dist/utils/query.js.map +1 -1
- package/frontend-vue/app/{composables → build/composables}/useDataBrowserGrid.ts +65 -50
- package/frontend-vue/app/build/composables/useDataBrowserTabs.ts +366 -0
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +50 -0
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +140 -0
- package/frontend-vue/app/{composables → build/composables}/useDiagramPersistence.ts +47 -39
- package/frontend-vue/app/build/composables/useQueryRuntime.ts +217 -0
- package/frontend-vue/app/build/composables/useQueryStore.ts +162 -0
- package/frontend-vue/app/build/data/cellValues.ts +132 -0
- package/frontend-vue/app/build/data/instanceOptions.ts +36 -0
- package/frontend-vue/app/build/data/pickerMenus.ts +175 -0
- package/frontend-vue/app/build/data/recentTables.ts +48 -0
- package/frontend-vue/app/build/data/stagedEdits.ts +117 -0
- package/frontend-vue/app/build/data/stagedWrites.ts +64 -0
- package/frontend-vue/app/build/data/useStagedSave.ts +298 -0
- package/frontend-vue/app/build/diagram/autosave.ts +72 -0
- package/frontend-vue/app/build/diagram/graph.ts +585 -0
- package/frontend-vue/app/build/diagram/history.ts +178 -0
- package/frontend-vue/app/build/diagram/shortcuts.ts +62 -0
- package/frontend-vue/app/build/diagram/sync.ts +137 -0
- package/frontend-vue/app/build/query/completion.ts +100 -0
- package/frontend-vue/app/build/query/csv.ts +32 -0
- package/frontend-vue/app/build/query/examples.ts +38 -0
- package/frontend-vue/app/build/query/runs.ts +168 -0
- package/frontend-vue/app/build/query/tableColumns.ts +35 -0
- package/frontend-vue/app/build/query/target.ts +143 -0
- package/frontend-vue/app/build/schemas/filterState.ts +128 -0
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +62 -0
- package/frontend-vue/app/build/utils/databaseLinks.ts +98 -0
- package/frontend-vue/app/build/utils/fieldTypes.ts +198 -0
- package/frontend-vue/app/build/utils/relativeTime.ts +19 -0
- package/frontend-vue/app/{utils → build/utils}/stagedSerialization.ts +7 -1
- package/frontend-vue/app/components/ConnectionStatus.vue +122 -0
- package/frontend-vue/app/components/DataBrowser.vue +126 -0
- package/frontend-vue/app/components/DataBrowserSidebar.vue +293 -210
- package/frontend-vue/app/components/DataBrowserTabBar.vue +185 -93
- package/frontend-vue/app/components/DataFilterBar.vue +163 -0
- package/frontend-vue/app/components/DataGrid.vue +737 -499
- package/frontend-vue/app/components/DataGridCell.vue +125 -77
- package/frontend-vue/app/components/DataGridHeaderCell.vue +119 -81
- package/frontend-vue/app/components/DiagramNoteNode.vue +81 -63
- package/frontend-vue/app/components/DiagramRelationEdge.vue +57 -27
- package/frontend-vue/app/components/DiagramStubNode.vue +16 -17
- package/frontend-vue/app/components/DiagramTableNode.vue +106 -68
- package/frontend-vue/app/components/InstancePicker.vue +195 -0
- package/frontend-vue/app/components/JsonCellPanel.vue +30 -29
- package/frontend-vue/app/components/QueryBuilderPanel.vue +290 -238
- package/frontend-vue/app/components/QueryConsole.vue +726 -0
- package/frontend-vue/app/components/QueryLibrary.vue +314 -0
- package/frontend-vue/app/components/QueryResultPanel.vue +556 -126
- package/frontend-vue/app/components/RowDrawer.vue +520 -0
- package/frontend-vue/app/components/SchemaDiagram.vue +1016 -1051
- package/frontend-vue/app/components/SchemaPicker.vue +86 -0
- package/frontend-vue/app/components/SchemasFilterBar.vue +201 -0
- package/frontend-vue/app/components/TableInspector.vue +741 -0
- package/frontend-vue/app/config/shortcuts-registry.ts +65 -0
- package/frontend-vue/dms.frontend.ts +5 -14
- package/frontend-vue/i18n/locales/database-en-GB.json +526 -193
- package/frontend-vue/i18n/locales/database-fr-FR.json +528 -195
- package/frontend-vue/package.json +2 -1
- package/frontend-vue/pnpm-lock.yaml +2601 -858
- package/frontend-vue/pnpm-workspace.yaml +1 -1
- package/frontend-vue/tests/dataBrowser.test.ts +206 -0
- package/frontend-vue/tests/diagram.test.ts +516 -0
- package/frontend-vue/tests/helpers.test.ts +205 -0
- package/frontend-vue/tests/instanceOptions.test.ts +53 -0
- package/frontend-vue/tests/locales.test.ts +56 -0
- package/frontend-vue/tests/persistence.test.ts +106 -5
- package/frontend-vue/tests/pickerMenus.test.ts +254 -0
- package/frontend-vue/tests/queryConsole.test.ts +282 -0
- package/frontend-vue/tests/queryRuntime.test.ts +2 -2
- package/frontend-vue/tests/registration.test.ts +36 -25
- package/frontend-vue/tests/schemasFilters.test.ts +115 -0
- package/package.json +9 -6
- package/frontend-vue/app/components/QueryFavoritesPanel.vue +0 -127
- package/frontend-vue/app/components/QueryHistoryPanel.vue +0 -134
- package/frontend-vue/app/components/SchemaTableInspector.vue +0 -278
- package/frontend-vue/app/composables/useDataBrowserTabs.ts +0 -255
- package/frontend-vue/app/composables/useDatabaseHealth.ts +0 -50
- package/frontend-vue/app/composables/useDatabaseSchemas.ts +0 -92
- package/frontend-vue/app/composables/useQueryRuntime.ts +0 -118
- package/frontend-vue/app/composables/useQueryStore.ts +0 -117
- package/frontend-vue/app/composables/useTableViewRowClick.ts +0 -22
- package/frontend-vue/app/custom-pages/database/data.vue +0 -41
- package/frontend-vue/app/custom-pages/database/overview.vue +0 -367
- package/frontend-vue/app/custom-pages/database/query.vue +0 -436
- package/frontend-vue/app/custom-pages/database/schemas.vue +0 -218
- package/frontend-vue/app/utils/databaseFieldModifiers.ts +0 -33
- package/frontend-vue/app/utils/databaseFieldTypeIcons.ts +0 -224
- package/frontend-vue/app/utils/dmsTableView.ts +0 -83
- package/frontend-vue/app/utils/relativeTime.ts +0 -18
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { FieldDescriptor } from '../composables/useDatabaseSchemas'
|
|
2
|
+
|
|
3
|
+
// How a column's type reads in the inspector, the diagram and the grid: an
|
|
4
|
+
// icon and a mono label. Colour never encodes the type (D-09): it is kept for
|
|
5
|
+
// structure, primary keys and relations, which `columnRole` names.
|
|
6
|
+
|
|
7
|
+
interface FieldTypeView {
|
|
8
|
+
icon: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type DescriptorOf<K extends FieldDescriptor['kind']> = Extract<
|
|
13
|
+
FieldDescriptor,
|
|
14
|
+
{ kind: K }
|
|
15
|
+
>
|
|
16
|
+
type DescriptorViews = {
|
|
17
|
+
[K in FieldDescriptor['kind']]: (descriptor: DescriptorOf<K>) => FieldTypeView
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const UNKNOWN: FieldTypeView = { icon: 'i-ph-question', label: 'unknown' }
|
|
21
|
+
const STRING_ICON = 'i-ph-text-aa'
|
|
22
|
+
const NUMBER_ICON = 'i-ph-hash'
|
|
23
|
+
const BOOLEAN_ICON = 'i-ph-toggle-right'
|
|
24
|
+
const LIST_ICON = 'i-ph-brackets-square'
|
|
25
|
+
const OBJECT_ICON = 'i-ph-brackets-curly'
|
|
26
|
+
const ENUM_ICON = 'i-ph-list-checks'
|
|
27
|
+
const UNION_ICON = 'i-ph-split'
|
|
28
|
+
|
|
29
|
+
const NAMED_FIELDS: Record<string, FieldTypeView> = {
|
|
30
|
+
email: { icon: 'i-ph-at', label: 'email' },
|
|
31
|
+
password: { icon: 'i-ph-lock', label: 'password' },
|
|
32
|
+
secret: { icon: 'i-ph-lock', label: 'secret' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LITERAL_ICONS: Record<string, string> = {
|
|
36
|
+
string: STRING_ICON,
|
|
37
|
+
number: NUMBER_ICON,
|
|
38
|
+
boolean: BOOLEAN_ICON,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function literalLabel(value: string | number | boolean): string {
|
|
42
|
+
return typeof value === 'string' ? `"${value}"` : String(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function label(descriptor: FieldDescriptor): string {
|
|
46
|
+
return describeField(descriptor).label
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const VIEWS: DescriptorViews = {
|
|
50
|
+
string: () => ({ icon: STRING_ICON, label: 'string' }),
|
|
51
|
+
number: () => ({ icon: NUMBER_ICON, label: 'number' }),
|
|
52
|
+
boolean: () => ({ icon: BOOLEAN_ICON, label: 'boolean' }),
|
|
53
|
+
date: () => ({ icon: 'i-ph-calendar-blank', label: 'date' }),
|
|
54
|
+
any: () => ({ icon: 'i-ph-asterisk', label: 'any' }),
|
|
55
|
+
unknown: () => UNKNOWN,
|
|
56
|
+
null: () => ({ icon: 'i-ph-circle-dashed', label: 'null' }),
|
|
57
|
+
undefined: () => ({ icon: 'i-ph-circle-dashed', label: 'undefined' }),
|
|
58
|
+
literal: (d) => ({
|
|
59
|
+
icon: LITERAL_ICONS[typeof d.value] ?? STRING_ICON,
|
|
60
|
+
label: literalLabel(d.value),
|
|
61
|
+
}),
|
|
62
|
+
array: (d) => ({
|
|
63
|
+
icon: LIST_ICON,
|
|
64
|
+
label: `${d.element ? label(d.element) : 'any'}[]`,
|
|
65
|
+
}),
|
|
66
|
+
tuple: (d) => ({
|
|
67
|
+
icon: LIST_ICON,
|
|
68
|
+
label: `[${d.elements.map(label).join(', ')}]`,
|
|
69
|
+
}),
|
|
70
|
+
object: (d) => ({
|
|
71
|
+
icon: OBJECT_ICON,
|
|
72
|
+
label: d.partial ? 'partial object' : 'object',
|
|
73
|
+
}),
|
|
74
|
+
record: (d) => ({
|
|
75
|
+
icon: OBJECT_ICON,
|
|
76
|
+
label: `Record<${d.key ? label(d.key) : 'string'}, ${label(d.value)}>`,
|
|
77
|
+
}),
|
|
78
|
+
union: (d) => ({
|
|
79
|
+
icon:
|
|
80
|
+
d.members.length > 0 && d.members.every((m) => m.kind === 'literal')
|
|
81
|
+
? ENUM_ICON
|
|
82
|
+
: UNION_ICON,
|
|
83
|
+
label: d.members.map(label).join(' | '),
|
|
84
|
+
}),
|
|
85
|
+
intersection: (d) => ({
|
|
86
|
+
icon: UNION_ICON,
|
|
87
|
+
label: d.members.map(label).join(' & '),
|
|
88
|
+
}),
|
|
89
|
+
keyof: (d) => ({
|
|
90
|
+
icon: ENUM_ICON,
|
|
91
|
+
label:
|
|
92
|
+
d.keys.length > 0 ? d.keys.map((k) => `"${k}"`).join(' | ') : 'keyof',
|
|
93
|
+
}),
|
|
94
|
+
refinement: (d) => ({ icon: describeField(d.base).icon, label: d.name }),
|
|
95
|
+
recursive: (d) => ({ icon: UNKNOWN.icon, label: d.name }),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isDescriptor(value: unknown): value is FieldDescriptor {
|
|
99
|
+
return (
|
|
100
|
+
value !== null &&
|
|
101
|
+
typeof value === 'object' &&
|
|
102
|
+
typeof (value as { kind?: unknown }).kind === 'string' &&
|
|
103
|
+
(value as { kind: string }).kind in VIEWS
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Icon and label of a schema-declared type. */
|
|
108
|
+
export function describeField(
|
|
109
|
+
descriptor: unknown,
|
|
110
|
+
fieldName = '',
|
|
111
|
+
): FieldTypeView {
|
|
112
|
+
const named = NAMED_FIELDS[fieldName.toLowerCase()]
|
|
113
|
+
if (named) return named
|
|
114
|
+
if (!isDescriptor(descriptor)) return UNKNOWN
|
|
115
|
+
const view = VIEWS[descriptor.kind] as (d: FieldDescriptor) => FieldTypeView
|
|
116
|
+
return view(descriptor)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const RUNTIME_KINDS = new Set([
|
|
120
|
+
'string',
|
|
121
|
+
'number',
|
|
122
|
+
'boolean',
|
|
123
|
+
'array',
|
|
124
|
+
'object',
|
|
125
|
+
'null',
|
|
126
|
+
'undefined',
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Icon and label of a type sampled at run time by the browse `/columns`
|
|
131
|
+
* route (string, number, boolean, object, array, null), so sampled and
|
|
132
|
+
* declared columns share one vocabulary.
|
|
133
|
+
*/
|
|
134
|
+
export function describeRuntimeType(
|
|
135
|
+
type: string,
|
|
136
|
+
fieldName: string,
|
|
137
|
+
): FieldTypeView {
|
|
138
|
+
if (type === 'date') return describeField({ kind: 'date' }, fieldName)
|
|
139
|
+
if (type === 'object')
|
|
140
|
+
return describeField({ kind: 'object', fields: {} }, fieldName)
|
|
141
|
+
if (type === 'array') return describeField({ kind: 'array' }, fieldName)
|
|
142
|
+
return describeField(
|
|
143
|
+
RUNTIME_KINDS.has(type)
|
|
144
|
+
? ({ kind: type } as FieldDescriptor)
|
|
145
|
+
: { kind: 'unknown' },
|
|
146
|
+
fieldName,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const PRIMARY_KEY_NAMES = new Set(['id', '_id'])
|
|
151
|
+
const PRIMARY_KEY_INDEXES = ['pk', 'primary'] as const
|
|
152
|
+
|
|
153
|
+
export function isPrimaryKey(
|
|
154
|
+
fieldName: string,
|
|
155
|
+
indexes: Record<string, { fields?: string[] }>,
|
|
156
|
+
): boolean {
|
|
157
|
+
if (PRIMARY_KEY_NAMES.has(fieldName)) return true
|
|
158
|
+
return PRIMARY_KEY_INDEXES.some((name) =>
|
|
159
|
+
indexes[name]?.fields?.includes(fieldName),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What a column is for the structure: its colour role in every view. */
|
|
164
|
+
export type ColumnRole = 'key' | 'relation' | 'plain'
|
|
165
|
+
|
|
166
|
+
/** The CSS colour class of each role: warning for keys, primary for relations. */
|
|
167
|
+
export const COLUMN_ROLE_CLASSES: Record<ColumnRole, string> = {
|
|
168
|
+
key: 'text-warning',
|
|
169
|
+
relation: 'text-primary',
|
|
170
|
+
plain: 'text-muted',
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface ModifierView {
|
|
174
|
+
icon: string
|
|
175
|
+
/** i18n key of the modifier's name. */
|
|
176
|
+
labelKey?: string
|
|
177
|
+
/** The modifier id, shown when it has no name of its own. */
|
|
178
|
+
id: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const MODIFIERS: Record<string, Omit<ModifierView, 'id'>> = {
|
|
182
|
+
HashModifier: {
|
|
183
|
+
icon: 'i-ph-fingerprint',
|
|
184
|
+
labelKey: 'dms_database.modifiers.hashed',
|
|
185
|
+
},
|
|
186
|
+
LocalizationModifier: {
|
|
187
|
+
icon: 'i-ph-translate',
|
|
188
|
+
labelKey: 'dms_database.modifiers.localized',
|
|
189
|
+
},
|
|
190
|
+
EncryptionModifier: {
|
|
191
|
+
icon: 'i-ph-lock-key',
|
|
192
|
+
labelKey: 'dms_database.modifiers.encrypted',
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function describeModifier(id: string): ModifierView {
|
|
197
|
+
return { id, ...(MODIFIERS[id] ?? { icon: 'i-ph-sliders' }) }
|
|
198
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const MINUTE_MS = 60_000
|
|
2
|
+
const MINUTES_PER_HOUR = 60
|
|
3
|
+
const HOURS_PER_DAY = 24
|
|
4
|
+
|
|
5
|
+
// Human "3 minutes ago" style label for an ISO timestamp, relative to now, in
|
|
6
|
+
// the interface language. Not named `formatRelativeTime`: the DMS core layer
|
|
7
|
+
// auto-imports a helper of that name, and the two would shadow each other.
|
|
8
|
+
export function formatDatabaseRelativeTime(
|
|
9
|
+
iso: string,
|
|
10
|
+
locale?: string,
|
|
11
|
+
): string {
|
|
12
|
+
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
|
13
|
+
const minutes = Math.round((new Date(iso).getTime() - Date.now()) / MINUTE_MS)
|
|
14
|
+
if (Math.abs(minutes) < MINUTES_PER_HOUR)
|
|
15
|
+
return formatter.format(minutes, 'minute')
|
|
16
|
+
const hours = Math.round(minutes / MINUTES_PER_HOUR)
|
|
17
|
+
if (Math.abs(hours) < HOURS_PER_DAY) return formatter.format(hours, 'hour')
|
|
18
|
+
return formatter.format(Math.round(hours / HOURS_PER_DAY), 'day')
|
|
19
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CROSS_INSTANCE,
|
|
2
3
|
Datum,
|
|
3
4
|
Query,
|
|
4
5
|
Schema,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
ValueProxy,
|
|
11
12
|
} from '@antelopejs/interface-database/staged-query'
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
interface QueryStage {
|
|
14
15
|
stage: string
|
|
15
16
|
options?: unknown
|
|
16
17
|
args: unknown[]
|
|
@@ -37,7 +38,12 @@ function classNameOf(obj: object): string | undefined {
|
|
|
37
38
|
return undefined
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// CROSS_INSTANCE is a symbol, which JSON drops: it travels as this sentinel,
|
|
42
|
+
// which the server turns back into the symbol.
|
|
43
|
+
export const CROSS_INSTANCE_SENTINEL = '__CROSS_INSTANCE__'
|
|
44
|
+
|
|
40
45
|
export function encodeStaged(node: unknown): unknown {
|
|
46
|
+
if (node === CROSS_INSTANCE) return CROSS_INSTANCE_SENTINEL
|
|
41
47
|
if (node === null || node === undefined) return node
|
|
42
48
|
if (typeof node !== 'object') return node
|
|
43
49
|
if (Array.isArray(node)) return node.map(encodeStaged)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useDatabaseHealth } from '../build/composables/useDatabaseHealth'
|
|
3
|
+
import { useIntervalFn, useNow } from '@vueuse/core'
|
|
4
|
+
|
|
5
|
+
// The overview's health hero: whether the database answers, how fast, and
|
|
6
|
+
// what it holds. The probe runs again every PROBE_INTERVAL_MS while the page
|
|
7
|
+
// is open, so a lost connection shows up without a reload.
|
|
8
|
+
const PROBE_INTERVAL_MS = 30_000
|
|
9
|
+
const SECOND_MS = 1000
|
|
10
|
+
const MINUTE_S = 60
|
|
11
|
+
|
|
12
|
+
const { t, n, locale } = useI18n()
|
|
13
|
+
const { health, isLoading, error, refresh } = useDatabaseHealth()
|
|
14
|
+
const now = useNow({ interval: SECOND_MS })
|
|
15
|
+
|
|
16
|
+
useIntervalFn(() => refresh(), PROBE_INTERVAL_MS)
|
|
17
|
+
|
|
18
|
+
// A failed health request is a lost connection too: the route answers
|
|
19
|
+
// whenever the backend is up.
|
|
20
|
+
const isDown = computed(
|
|
21
|
+
() => Boolean(error.value) || health.value.status === 'down',
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const checkedAgo = computed(() => {
|
|
25
|
+
const checkedAt = health.value.checkedAt
|
|
26
|
+
if (!checkedAt) return ''
|
|
27
|
+
const seconds = Math.max(
|
|
28
|
+
0,
|
|
29
|
+
Math.round(
|
|
30
|
+
(now.value.getTime() - new Date(checkedAt).getTime()) / SECOND_MS,
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
return seconds < MINUTE_S
|
|
34
|
+
? t('dms_database.overview.connection.checked_seconds', { count: seconds })
|
|
35
|
+
: t('dms_database.overview.connection.checked_minutes', {
|
|
36
|
+
count: Math.round(seconds / MINUTE_S),
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const since = computed(() => {
|
|
41
|
+
const parts = [health.value.driver, checkedAgo.value].filter(Boolean)
|
|
42
|
+
return parts.join(' · ')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function compact(value: number): string {
|
|
46
|
+
return new Intl.NumberFormat(locale.value, {
|
|
47
|
+
notation: 'compact',
|
|
48
|
+
maximumFractionDigits: 1,
|
|
49
|
+
}).format(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const healthyMetrics = computed(() => {
|
|
53
|
+
const h = health.value
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
label: t('dms_database.overview.connection.latency'),
|
|
57
|
+
value: h.latencyMs ?? '—',
|
|
58
|
+
unit: h.latencyMs === null ? undefined : 'ms',
|
|
59
|
+
sub: t('dms_database.overview.connection.latency_sub'),
|
|
60
|
+
tone: 'success' as const,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
label: t('dms_database.overview.connection.tables'),
|
|
64
|
+
value: n(h.tableCount),
|
|
65
|
+
sub: t('dms_database.overview.connection.tables_sub', h.schemaCount),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
label: t('dms_database.overview.connection.rows'),
|
|
69
|
+
value: compact(h.totalRows),
|
|
70
|
+
sub: t('dms_database.overview.connection.rows_sub', h.relationCount),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: t('dms_database.overview.connection.indexes'),
|
|
74
|
+
value: n(h.indexCount),
|
|
75
|
+
sub: t('dms_database.overview.connection.indexes_sub'),
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const downMetrics = computed(() => [
|
|
81
|
+
{
|
|
82
|
+
label: t('dms_database.overview.connection.last_error'),
|
|
83
|
+
value:
|
|
84
|
+
health.value.error ?? t('dms_database.overview.connection.no_answer'),
|
|
85
|
+
tone: 'error' as const,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
label: t('dms_database.overview.connection.next_probe'),
|
|
89
|
+
value: Math.round(PROBE_INTERVAL_MS / SECOND_MS),
|
|
90
|
+
unit: 's',
|
|
91
|
+
sub: t('dms_database.overview.connection.next_probe_sub'),
|
|
92
|
+
},
|
|
93
|
+
])
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<div class="grid gap-2">
|
|
98
|
+
<DmsStatusSummary
|
|
99
|
+
:status="isDown ? 'down' : 'ok'"
|
|
100
|
+
:status-label="t('dms_database.overview.connection.label')"
|
|
101
|
+
:status-value="
|
|
102
|
+
isDown
|
|
103
|
+
? t('dms_database.overview.connection.down')
|
|
104
|
+
: t('dms_database.overview.connection.healthy')
|
|
105
|
+
"
|
|
106
|
+
:since="since"
|
|
107
|
+
:metrics="isDown ? downMetrics : healthyMetrics"
|
|
108
|
+
:class="isLoading && !health.checkedAt ? 'animate-pulse' : undefined"
|
|
109
|
+
/>
|
|
110
|
+
<div v-if="isDown" class="flex justify-end">
|
|
111
|
+
<UButton
|
|
112
|
+
color="neutral"
|
|
113
|
+
variant="outline"
|
|
114
|
+
size="sm"
|
|
115
|
+
icon="i-ph-arrows-clockwise"
|
|
116
|
+
:loading="isLoading"
|
|
117
|
+
:label="t('dms_database.overview.connection.retry')"
|
|
118
|
+
@click="refresh()"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
instanceBadge,
|
|
4
|
+
useDataBrowserTabs,
|
|
5
|
+
} from '../build/composables/useDataBrowserTabs'
|
|
6
|
+
import { useDatabaseSchemas } from '../build/composables/useDatabaseSchemas'
|
|
7
|
+
import { formatDatabaseRelativeTime } from '../build/utils/relativeTime'
|
|
8
|
+
import { type RecentTable, readRecentTables } from '../build/data/recentTables'
|
|
9
|
+
import { useStagedEdits } from '../build/data/stagedEdits'
|
|
10
|
+
import DataBrowserSidebar from './DataBrowserSidebar.vue'
|
|
11
|
+
import DataBrowserTabBar from './DataBrowserTabBar.vue'
|
|
12
|
+
import DataGrid from './DataGrid.vue'
|
|
13
|
+
|
|
14
|
+
// The data browser: a table list scoped to one schema and instance, the open
|
|
15
|
+
// tables as tabs, and the grid of the active one. Edits are staged per tab
|
|
16
|
+
// and written only when saved; leaving with staged edits asks first.
|
|
17
|
+
|
|
18
|
+
const { t, locale } = useI18n()
|
|
19
|
+
const {
|
|
20
|
+
schemas,
|
|
21
|
+
findTable,
|
|
22
|
+
isLoading: schemasLoading,
|
|
23
|
+
error: schemasError,
|
|
24
|
+
refresh: refreshSchemas,
|
|
25
|
+
} = useDatabaseSchemas()
|
|
26
|
+
const { tabs, activeTab, restore, openTab, openFromRoute } =
|
|
27
|
+
useDataBrowserTabs()
|
|
28
|
+
const staged = useStagedEdits()
|
|
29
|
+
const route = useDmsRoute()
|
|
30
|
+
|
|
31
|
+
onMounted(() => restore())
|
|
32
|
+
// A link from this page to another table (a relation) keeps the page mounted.
|
|
33
|
+
watch(
|
|
34
|
+
() => route.query,
|
|
35
|
+
() => openFromRoute(),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const hasStagedEdits = computed(() =>
|
|
39
|
+
tabs.value.some((tab) => staged.count(tab.id) > 0),
|
|
40
|
+
)
|
|
41
|
+
useUnsavedChanges({ dirty: hasStagedEdits })
|
|
42
|
+
|
|
43
|
+
// Schema-introspected columns of the active table: the grid falls back to
|
|
44
|
+
// them when sampling finds no row, and reads its relation columns from them.
|
|
45
|
+
const activeTableSummary = computed(() =>
|
|
46
|
+
activeTab.value
|
|
47
|
+
? findTable(activeTab.value.schema, activeTab.value.table)
|
|
48
|
+
: undefined,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// Read once mounted: the server renders without the browser's storage, and
|
|
52
|
+
// a list only the client has would not match its markup.
|
|
53
|
+
const recent = ref<RecentTable[]>([])
|
|
54
|
+
onMounted(() => {
|
|
55
|
+
recent.value = readRecentTables()
|
|
56
|
+
})
|
|
57
|
+
watch(activeTab, () => {
|
|
58
|
+
recent.value = readRecentTables()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function instanceLabel(instance: string): string {
|
|
62
|
+
return instanceBadge(instance, t('dms_database.data.scope.all')) ?? ''
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div
|
|
68
|
+
class="border-default bg-default flex min-h-0 flex-1 overflow-hidden rounded-xl border"
|
|
69
|
+
>
|
|
70
|
+
<DataBrowserSidebar
|
|
71
|
+
:schemas="schemas"
|
|
72
|
+
:loading="schemasLoading"
|
|
73
|
+
:error="Boolean(schemasError)"
|
|
74
|
+
@retry="refreshSchemas()"
|
|
75
|
+
/>
|
|
76
|
+
<section class="flex min-w-0 flex-1 flex-col">
|
|
77
|
+
<DataBrowserTabBar />
|
|
78
|
+
<DataGrid
|
|
79
|
+
v-if="activeTab"
|
|
80
|
+
:key="activeTab.id"
|
|
81
|
+
:tab="activeTab"
|
|
82
|
+
:table="activeTableSummary"
|
|
83
|
+
/>
|
|
84
|
+
<div v-else class="grid flex-1 place-items-center p-6">
|
|
85
|
+
<DmsEmptyState
|
|
86
|
+
icon="i-ph-rows"
|
|
87
|
+
:title="t('dms_database.data.empty.pick_title')"
|
|
88
|
+
:description="
|
|
89
|
+
recent.length
|
|
90
|
+
? t('dms_database.data.empty.pick_description')
|
|
91
|
+
: t('dms_database.data.empty.pick_description_first')
|
|
92
|
+
"
|
|
93
|
+
>
|
|
94
|
+
<template v-if="recent.length" #actions>
|
|
95
|
+
<div class="grid w-full max-w-sm gap-1.5">
|
|
96
|
+
<DmsEyebrow :label="t('dms_database.data.empty.recent')" />
|
|
97
|
+
<button
|
|
98
|
+
v-for="entry in recent"
|
|
99
|
+
:key="`${entry.schema}.${entry.instance}.${entry.table}`"
|
|
100
|
+
type="button"
|
|
101
|
+
class="border-default hover:bg-elevated flex items-center gap-2 rounded-md border px-3 py-2 text-left font-mono text-[12.5px]"
|
|
102
|
+
@click="
|
|
103
|
+
openTab(entry.schema, entry.instance, entry.table, false)
|
|
104
|
+
"
|
|
105
|
+
>
|
|
106
|
+
<UIcon name="i-ph-table" class="text-primary size-3.5" />
|
|
107
|
+
<span class="text-toned">
|
|
108
|
+
{{ entry.schema }}.{{ entry.table }}
|
|
109
|
+
</span>
|
|
110
|
+
<span
|
|
111
|
+
v-if="instanceLabel(entry.instance)"
|
|
112
|
+
class="text-primary text-[11px]"
|
|
113
|
+
>
|
|
114
|
+
{{ instanceLabel(entry.instance) }}
|
|
115
|
+
</span>
|
|
116
|
+
<span class="text-dimmed ml-auto font-sans text-xs">
|
|
117
|
+
{{ formatDatabaseRelativeTime(entry.openedAt, locale) }}
|
|
118
|
+
</span>
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|
|
122
|
+
</DmsEmptyState>
|
|
123
|
+
</div>
|
|
124
|
+
</section>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|