dsh-lh-data 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 +332 -0
- package/cordis.patch.yml +7 -0
- package/lib/admin-contract.d.ts +274 -0
- package/lib/admin-http.d.ts +31 -0
- package/lib/admin-validate.d.ts +87 -0
- package/lib/admin.d.ts +60 -0
- package/lib/client.js +2969 -0
- package/lib/client.js.map +1 -0
- package/lib/datasource/columns.d.ts +13 -0
- package/lib/datasource/connection.d.ts +33 -0
- package/lib/datasource/connector/base.d.ts +21 -0
- package/lib/datasource/connector/mysql.d.ts +23 -0
- package/lib/datasource/connector/postgresql.d.ts +23 -0
- package/lib/datasource/crypto.d.ts +13 -0
- package/lib/datasource/driver.d.ts +39 -0
- package/lib/datasource/errors.d.ts +25 -0
- package/lib/datasource/importer.d.ts +38 -0
- package/lib/datasource/source-sql.d.ts +11 -0
- package/lib/datasource/source-store.d.ts +28 -0
- package/lib/datasource/types.d.ts +119 -0
- package/lib/db.d.ts +91 -0
- package/lib/http-common.d.ts +31 -0
- package/lib/http.d.ts +13 -0
- package/lib/index.d.ts +50 -0
- package/lib/index.js +5735 -0
- package/lib/parse.d.ts +41 -0
- package/lib/preview.d.ts +75 -0
- package/lib/render.d.ts +100 -0
- package/lib/scope-registry.d.ts +47 -0
- package/lib/scope.d.ts +56 -0
- package/lib/sql.d.ts +133 -0
- package/lib/store.d.ts +174 -0
- package/lib/table.d.ts +38 -0
- package/lib/tooling.d.ts +267 -0
- package/lib/tools/datasource.d.ts +16 -0
- package/lib/tools/import.d.ts +21 -0
- package/lib/tools/read.d.ts +77 -0
- package/lib/tools/registry.d.ts +12 -0
- package/lib/tools/write.d.ts +37 -0
- package/lib/view.d.ts +138 -0
- package/package.json +61 -0
- package/src/admin-contract.ts +351 -0
- package/src/admin-http.ts +254 -0
- package/src/admin-validate.ts +393 -0
- package/src/admin.ts +558 -0
- package/src/client/index.ts +403 -0
- package/src/client/settings/CreateForm.tsx +186 -0
- package/src/client/settings/DataSourceForm.tsx +278 -0
- package/src/client/settings/DataSourcesPanel.tsx +301 -0
- package/src/client/settings/DatasetEditor.tsx +245 -0
- package/src/client/settings/DatasetTable.tsx +110 -0
- package/src/client/settings/DatasetsPanel.tsx +207 -0
- package/src/client/settings/RowsPanel.tsx +196 -0
- package/src/client/settings/Section.tsx +41 -0
- package/src/client/settings/SourceTablesPanel.tsx +226 -0
- package/src/client/settings/api.ts +154 -0
- package/src/client/settings/styles.ts +125 -0
- package/src/datasource/columns.ts +56 -0
- package/src/datasource/connection.ts +124 -0
- package/src/datasource/connector/base.ts +54 -0
- package/src/datasource/connector/mysql.ts +187 -0
- package/src/datasource/connector/postgresql.ts +212 -0
- package/src/datasource/crypto.ts +61 -0
- package/src/datasource/driver.ts +108 -0
- package/src/datasource/errors.ts +69 -0
- package/src/datasource/importer.ts +262 -0
- package/src/datasource/index.ts +62 -0
- package/src/datasource/source-sql.ts +64 -0
- package/src/datasource/source-store.ts +152 -0
- package/src/datasource/types.ts +133 -0
- package/src/db.ts +277 -0
- package/src/http-common.ts +91 -0
- package/src/http.ts +130 -0
- package/src/index.ts +486 -0
- package/src/parse.ts +213 -0
- package/src/preview.ts +198 -0
- package/src/render.ts +294 -0
- package/src/scope-registry.ts +111 -0
- package/src/scope.ts +159 -0
- package/src/sql.ts +491 -0
- package/src/store.ts +412 -0
- package/src/table.ts +160 -0
- package/src/tooling.ts +551 -0
- package/src/tools/datasource.ts +378 -0
- package/src/tools/import.ts +282 -0
- package/src/tools/read.ts +536 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/write.ts +241 -0
- package/src/view.ts +371 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据源新建 / 编辑表单。
|
|
3
|
+
*
|
|
4
|
+
* 两步提交:**先测连、后保存**。连接参数(类型 / 主机 / 端口 / 库 / 用户 / 密码)任一改动
|
|
5
|
+
* 都会把测试结果打回「未测试」,未测通时保存按钮禁用;服务端在保存时也会再测一次,
|
|
6
|
+
* 避免前端被绕过。编辑态下密码留空表示「保留原密码」。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import type { ReactElement } from 'react'
|
|
11
|
+
import {
|
|
12
|
+
ADMIN_SOURCE_DEFAULT_PORTS,
|
|
13
|
+
ADMIN_SOURCE_TYPES,
|
|
14
|
+
ADMIN_SOURCE_TYPE_LABELS,
|
|
15
|
+
type ConnectionTestView,
|
|
16
|
+
type DataSourceView,
|
|
17
|
+
type TestDataSourceRequest,
|
|
18
|
+
} from '../../admin-contract'
|
|
19
|
+
import { createDataSource, patchDataSource, testDataSource } from './api'
|
|
20
|
+
import { c, s } from './styles'
|
|
21
|
+
|
|
22
|
+
export interface DataSourceFormProps {
|
|
23
|
+
/** 传入即编辑态;null / undefined 为新建。 */
|
|
24
|
+
initial?: DataSourceView | null
|
|
25
|
+
onSaved: (view: DataSourceView) => void
|
|
26
|
+
onCancel: () => void
|
|
27
|
+
onError: (error: Error) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type TestState = 'idle' | 'testing' | 'ok' | 'fail'
|
|
31
|
+
|
|
32
|
+
/** 连接参数的具体形态:测连、新建、编辑三处共用同一份草稿。 */
|
|
33
|
+
interface ConnectionFields {
|
|
34
|
+
type: string
|
|
35
|
+
host: string
|
|
36
|
+
port: number
|
|
37
|
+
database: string
|
|
38
|
+
username: string
|
|
39
|
+
password: string
|
|
40
|
+
sslMode: string | null
|
|
41
|
+
poolMax: number | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function DataSourceForm(props: DataSourceFormProps): ReactElement {
|
|
45
|
+
const { initial, onSaved, onCancel, onError } = props
|
|
46
|
+
const editing = initial !== null && initial !== undefined
|
|
47
|
+
const defaultType = initial?.type ?? 'mysql'
|
|
48
|
+
|
|
49
|
+
const [name, setName] = useState(initial?.name ?? '')
|
|
50
|
+
const [type, setType] = useState(defaultType)
|
|
51
|
+
const [host, setHost] = useState(initial?.host ?? '127.0.0.1')
|
|
52
|
+
const [port, setPort] = useState(String(initial?.port ?? ADMIN_SOURCE_DEFAULT_PORTS[defaultType]))
|
|
53
|
+
const [database, setDatabase] = useState(initial?.database ?? '')
|
|
54
|
+
const [username, setUsername] = useState(initial?.username ?? '')
|
|
55
|
+
const [password, setPassword] = useState('')
|
|
56
|
+
const [sslMode, setSslMode] = useState(initial?.sslMode ?? '')
|
|
57
|
+
const [poolMax, setPoolMax] = useState(initial?.poolMax === null ? '' : String(initial?.poolMax ?? ''))
|
|
58
|
+
const [description, setDescription] = useState(initial?.description ?? '')
|
|
59
|
+
const [testState, setTestState] = useState<TestState>('idle')
|
|
60
|
+
const [testResult, setTestResult] = useState<ConnectionTestView | null>(null)
|
|
61
|
+
const [saving, setSaving] = useState(false)
|
|
62
|
+
|
|
63
|
+
const requiredFilled =
|
|
64
|
+
name.trim().length > 0 && host.trim().length > 0 && database.trim().length > 0 && username.trim().length > 0
|
|
65
|
+
|
|
66
|
+
/** 连接参数变了,之前的测试结论就作废。 */
|
|
67
|
+
function changeConnection(next: () => void): void {
|
|
68
|
+
next()
|
|
69
|
+
setTestState('idle')
|
|
70
|
+
setTestResult(null)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function draft(): ConnectionFields {
|
|
74
|
+
const parsedPort = Number.parseInt(port, 10)
|
|
75
|
+
const parsedPool = Number.parseInt(poolMax, 10)
|
|
76
|
+
return {
|
|
77
|
+
type,
|
|
78
|
+
host: host.trim(),
|
|
79
|
+
port: Number.isFinite(parsedPort) ? parsedPort : ADMIN_SOURCE_DEFAULT_PORTS[type],
|
|
80
|
+
database: database.trim(),
|
|
81
|
+
username: username.trim(),
|
|
82
|
+
password,
|
|
83
|
+
sslMode: sslMode.trim().length > 0 ? sslMode.trim() : null,
|
|
84
|
+
poolMax: Number.isFinite(parsedPool) ? parsedPool : null,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleTest(): Promise<void> {
|
|
89
|
+
if (!requiredFilled || testState === 'testing') return
|
|
90
|
+
setTestState('testing')
|
|
91
|
+
try {
|
|
92
|
+
const result = await testDataSource(draft())
|
|
93
|
+
setTestResult(result)
|
|
94
|
+
setTestState(result.success ? 'ok' : 'fail')
|
|
95
|
+
} catch (error) {
|
|
96
|
+
setTestState('fail')
|
|
97
|
+
onError(error instanceof Error ? error : new Error('测试连接失败'))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleSave(): Promise<void> {
|
|
102
|
+
if (testState !== 'ok' || saving) return
|
|
103
|
+
setSaving(true)
|
|
104
|
+
try {
|
|
105
|
+
const connection = draft()
|
|
106
|
+
const saved = editing
|
|
107
|
+
? await patchDataSource(initial.id, {
|
|
108
|
+
name: name.trim(),
|
|
109
|
+
...connection,
|
|
110
|
+
// 密码留空 = 保留原密码;填了才替换。
|
|
111
|
+
...(password.length > 0 ? {} : { password: undefined }),
|
|
112
|
+
description: description.trim().length > 0 ? description.trim() : null,
|
|
113
|
+
test: true,
|
|
114
|
+
})
|
|
115
|
+
: await createDataSource({
|
|
116
|
+
...connection,
|
|
117
|
+
name: name.trim(),
|
|
118
|
+
description: description.trim().length > 0 ? description.trim() : null,
|
|
119
|
+
test: true,
|
|
120
|
+
})
|
|
121
|
+
onSaved(saved)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
onError(error instanceof Error ? error : new Error('保存失败'))
|
|
124
|
+
} finally {
|
|
125
|
+
setSaving(false)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const testHint = testState === 'ok'
|
|
130
|
+
? `连接成功(${testResult?.latency ?? 0}ms${testResult?.version === null ? '' : ` · ${testResult?.version}`})`
|
|
131
|
+
: testState === 'fail'
|
|
132
|
+
? (testResult?.error ?? '连接失败')
|
|
133
|
+
: '保存前请先测试连接'
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div style={s.panel}>
|
|
137
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
|
|
138
|
+
<strong style={{ fontSize: 13 }}>{editing ? '编辑数据源' : '新建数据源'}</strong>
|
|
139
|
+
<span style={s.muted}>
|
|
140
|
+
{editing && initial.hasPassword ? '已保存过密码,留空即保留' : '密码加密存储,不会回显'}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div style={s.grid2}>
|
|
145
|
+
<div style={s.field}>
|
|
146
|
+
<label style={s.label}>名称</label>
|
|
147
|
+
<input style={s.input} value={name} disabled={saving} onChange={e => setName(e.target.value)} />
|
|
148
|
+
</div>
|
|
149
|
+
<div style={s.field}>
|
|
150
|
+
<label style={s.label}>类型</label>
|
|
151
|
+
<select
|
|
152
|
+
style={s.input}
|
|
153
|
+
value={type}
|
|
154
|
+
disabled={saving}
|
|
155
|
+
onChange={e => changeConnection(() => {
|
|
156
|
+
setType(e.target.value)
|
|
157
|
+
setPort(String(ADMIN_SOURCE_DEFAULT_PORTS[e.target.value]))
|
|
158
|
+
})}
|
|
159
|
+
>
|
|
160
|
+
{ADMIN_SOURCE_TYPES.map(item => (
|
|
161
|
+
<option key={item} value={item}>{ADMIN_SOURCE_TYPE_LABELS[item]}</option>
|
|
162
|
+
))}
|
|
163
|
+
</select>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div style={s.grid2}>
|
|
168
|
+
<div style={s.field}>
|
|
169
|
+
<label style={s.label}>主机</label>
|
|
170
|
+
<input
|
|
171
|
+
style={s.input}
|
|
172
|
+
value={host}
|
|
173
|
+
disabled={saving}
|
|
174
|
+
onChange={e => changeConnection(() => setHost(e.target.value))}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
<div style={s.field}>
|
|
178
|
+
<label style={s.label}>端口</label>
|
|
179
|
+
<input
|
|
180
|
+
style={s.input}
|
|
181
|
+
value={port}
|
|
182
|
+
inputMode="numeric"
|
|
183
|
+
disabled={saving}
|
|
184
|
+
onChange={e => changeConnection(() => setPort(e.target.value))}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div style={s.grid2}>
|
|
190
|
+
<div style={s.field}>
|
|
191
|
+
<label style={s.label}>数据库名</label>
|
|
192
|
+
<input
|
|
193
|
+
style={s.input}
|
|
194
|
+
value={database}
|
|
195
|
+
disabled={saving}
|
|
196
|
+
onChange={e => changeConnection(() => setDatabase(e.target.value))}
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
<div style={s.field}>
|
|
200
|
+
<label style={s.label}>用户名</label>
|
|
201
|
+
<input
|
|
202
|
+
style={s.input}
|
|
203
|
+
value={username}
|
|
204
|
+
disabled={saving}
|
|
205
|
+
onChange={e => changeConnection(() => setUsername(e.target.value))}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div style={s.grid2}>
|
|
211
|
+
<div style={s.field}>
|
|
212
|
+
<label style={s.label}>{editing ? '重设密码(留空保留)' : '密码'}</label>
|
|
213
|
+
<input
|
|
214
|
+
style={s.input}
|
|
215
|
+
type="password"
|
|
216
|
+
value={password}
|
|
217
|
+
disabled={saving}
|
|
218
|
+
onChange={e => changeConnection(() => setPassword(e.target.value))}
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
<div style={s.field}>
|
|
222
|
+
<label style={s.label}>SSL 模式(可选)</label>
|
|
223
|
+
<input
|
|
224
|
+
style={s.input}
|
|
225
|
+
value={sslMode}
|
|
226
|
+
disabled={saving}
|
|
227
|
+
placeholder={type === 'mysql' ? 'disabled / preferred / required' : 'disable / require / verify-full'}
|
|
228
|
+
onChange={e => changeConnection(() => setSslMode(e.target.value))}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div style={s.grid2}>
|
|
234
|
+
<div style={s.field}>
|
|
235
|
+
<label style={s.label}>连接池上限(可选)</label>
|
|
236
|
+
<input
|
|
237
|
+
style={s.input}
|
|
238
|
+
value={poolMax}
|
|
239
|
+
inputMode="numeric"
|
|
240
|
+
disabled={saving}
|
|
241
|
+
onChange={e => changeConnection(() => setPoolMax(e.target.value))}
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
<div style={s.field}>
|
|
245
|
+
<label style={s.label}>描述(可选)</label>
|
|
246
|
+
<input style={s.input} value={description} disabled={saving} onChange={e => setDescription(e.target.value)} />
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div
|
|
251
|
+
style={{
|
|
252
|
+
...s.banner,
|
|
253
|
+
marginBottom: 10,
|
|
254
|
+
borderColor: testState === 'ok' ? c.ok : testState === 'fail' ? c.danger : c.border,
|
|
255
|
+
color: testState === 'ok' ? c.ok : testState === 'fail' ? c.danger : c.fg2,
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<span>{testHint}</span>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
style={{ ...s.button, ...(!requiredFilled || testState === 'testing' ? s.disabledButton : null) }}
|
|
265
|
+
disabled={!requiredFilled || testState === 'testing'}
|
|
266
|
+
onClick={() => void handleTest()}
|
|
267
|
+
>{testState === 'testing' ? '测试中…' : '测试连接'}</button>
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
style={{ ...s.primaryButton, ...(testState !== 'ok' || saving ? s.disabledButton : null) }}
|
|
271
|
+
disabled={testState !== 'ok' || saving}
|
|
272
|
+
onClick={() => void handleSave()}
|
|
273
|
+
>{saving ? '保存中…' : '保存'}</button>
|
|
274
|
+
<button type="button" style={s.button} disabled={saving} onClick={onCancel}>取消</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「数据源」页签:列表(名称 / 类型 / 地址 / 库名 / 连通状态 / 最近检测)+ 工具条
|
|
3
|
+
* (搜索 / 新建 / 刷新)+ 选中项的详情面板。
|
|
4
|
+
*
|
|
5
|
+
* 详情面板上部位只读连接摘要(密码位只显示「已设置 · 不可查看」),下部位
|
|
6
|
+
* `SourceTablesPanel`:浏览远端表并把选中的表导入到某个工作区。
|
|
7
|
+
*
|
|
8
|
+
* 写操作(新建 / 编辑 / 删除 / 导入)在只读模式或接口关闭时整体置灰 —— 判定由服务端
|
|
9
|
+
* 返回的 `READ_ONLY` / `ADMIN_DISABLED` / `SOURCE_DISABLED` 错误码驱动,不靠前端猜。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
13
|
+
import type { ReactElement } from 'react'
|
|
14
|
+
import {
|
|
15
|
+
ADMIN_SOURCE_TYPE_LABELS,
|
|
16
|
+
type DataSourceView,
|
|
17
|
+
type ScopeView,
|
|
18
|
+
} from '../../admin-contract'
|
|
19
|
+
import { AdminApiError, deleteDataSource, listDataSources, listScopes, testDataSource } from './api'
|
|
20
|
+
import { DataSourceForm } from './DataSourceForm'
|
|
21
|
+
import { SourceTablesPanel } from './SourceTablesPanel'
|
|
22
|
+
import { fmtAgo } from './DatasetTable'
|
|
23
|
+
import { c, s, SOURCE_STATUS_COLOR, SOURCE_STATUS_TEXT, TYPE_BADGE } from './styles'
|
|
24
|
+
|
|
25
|
+
type Mode = 'browse' | 'create' | 'edit'
|
|
26
|
+
|
|
27
|
+
const READ_ONLY_CODES = ['READ_ONLY', 'ADMIN_DISABLED', 'SOURCE_DISABLED']
|
|
28
|
+
|
|
29
|
+
export function DataSourcesPanel(): ReactElement {
|
|
30
|
+
const [items, setItems] = useState<DataSourceView[]>([])
|
|
31
|
+
const [scopes, setScopes] = useState<ScopeView[]>([])
|
|
32
|
+
const [query, setQuery] = useState('')
|
|
33
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
34
|
+
const [mode, setMode] = useState<Mode>('browse')
|
|
35
|
+
const [loading, setLoading] = useState(false)
|
|
36
|
+
const [writable, setWritable] = useState(true)
|
|
37
|
+
const [error, setError] = useState<string | null>(null)
|
|
38
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
39
|
+
const [testingId, setTestingId] = useState<string | null>(null)
|
|
40
|
+
const [confirmId, setConfirmId] = useState<string | null>(null)
|
|
41
|
+
|
|
42
|
+
const loadScopes = useCallback(async (): Promise<void> => {
|
|
43
|
+
try {
|
|
44
|
+
setScopes((await listScopes()).scopes)
|
|
45
|
+
} catch {
|
|
46
|
+
// 工作区列表拿不到不阻断数据源浏览;导入时再提示。
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const loadList = useCallback(async (): Promise<void> => {
|
|
51
|
+
setLoading(true)
|
|
52
|
+
try {
|
|
53
|
+
setItems((await listDataSources()).sources)
|
|
54
|
+
setError(null)
|
|
55
|
+
} catch (failure) {
|
|
56
|
+
setError(failure instanceof Error ? failure.message : '加载数据源失败')
|
|
57
|
+
if (failure instanceof AdminApiError && READ_ONLY_CODES.includes(failure.code)) setWritable(false)
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false)
|
|
60
|
+
}
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
void loadScopes()
|
|
65
|
+
void loadList()
|
|
66
|
+
}, [loadScopes, loadList])
|
|
67
|
+
|
|
68
|
+
const selected = items.find(item => item.id === selectedId) ?? null
|
|
69
|
+
const needle = query.trim().toLowerCase()
|
|
70
|
+
const visible = needle.length === 0
|
|
71
|
+
? items
|
|
72
|
+
: items.filter(item => (
|
|
73
|
+
item.name.toLowerCase().includes(needle)
|
|
74
|
+
|| item.type.toLowerCase().includes(needle)
|
|
75
|
+
|| item.host.toLowerCase().includes(needle)
|
|
76
|
+
|| item.database.toLowerCase().includes(needle)
|
|
77
|
+
))
|
|
78
|
+
|
|
79
|
+
async function handleTest(id: string): Promise<void> {
|
|
80
|
+
if (testingId !== null) return
|
|
81
|
+
setTestingId(id)
|
|
82
|
+
setNotice(null)
|
|
83
|
+
try {
|
|
84
|
+
const result = await testDataSource({ source: id })
|
|
85
|
+
setNotice(result.success
|
|
86
|
+
? `连接成功(${result.latency}ms${result.version === null ? '' : ` · ${result.version}`})`
|
|
87
|
+
: `连接失败:${result.error ?? '未知错误'}`)
|
|
88
|
+
await loadList()
|
|
89
|
+
} catch (failure) {
|
|
90
|
+
setError(failure instanceof Error ? failure.message : '测试连接失败')
|
|
91
|
+
} finally {
|
|
92
|
+
setTestingId(null)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function handleDelete(id: string): Promise<void> {
|
|
97
|
+
setConfirmId(null)
|
|
98
|
+
try {
|
|
99
|
+
await deleteDataSource(id)
|
|
100
|
+
if (selectedId === id) {
|
|
101
|
+
setSelectedId(null)
|
|
102
|
+
setMode('browse')
|
|
103
|
+
}
|
|
104
|
+
await loadList()
|
|
105
|
+
} catch (failure) {
|
|
106
|
+
setError(failure instanceof Error ? failure.message : '删除失败')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function handleSaved(view: DataSourceView): Promise<void> {
|
|
111
|
+
setMode('browse')
|
|
112
|
+
setNotice(`已保存数据源「${view.name}」`)
|
|
113
|
+
await loadList()
|
|
114
|
+
setSelectedId(view.id)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleError(failure: Error): void {
|
|
118
|
+
setError(failure.message)
|
|
119
|
+
if (failure instanceof AdminApiError && READ_ONLY_CODES.includes(failure.code)) setWritable(false)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div>
|
|
124
|
+
<div style={s.toolbar}>
|
|
125
|
+
<input
|
|
126
|
+
style={s.search}
|
|
127
|
+
placeholder="按名称 / 类型 / 主机 / 库名搜索…"
|
|
128
|
+
value={query}
|
|
129
|
+
onChange={e => setQuery(e.target.value)}
|
|
130
|
+
/>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
style={{ ...s.primaryButton, ...(!writable ? s.disabledButton : null) }}
|
|
134
|
+
disabled={!writable}
|
|
135
|
+
onClick={() => { setMode('create'); setSelectedId(null) }}
|
|
136
|
+
title={writable ? '新建数据源' : '只读模式、接口已关闭或数据源功能未启用'}
|
|
137
|
+
>+ 新建数据源</button>
|
|
138
|
+
<button type="button" style={s.button} disabled={loading} onClick={() => void loadList()}>刷新</button>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{error !== null
|
|
142
|
+
? (
|
|
143
|
+
<div style={s.banner}>
|
|
144
|
+
<span>{error}</span>
|
|
145
|
+
<span style={{ ...s.link, marginLeft: 'auto' }} onClick={() => setError(null)}>忽略</span>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
: null}
|
|
149
|
+
|
|
150
|
+
{notice !== null
|
|
151
|
+
? (
|
|
152
|
+
<div style={{ ...s.banner, borderColor: c.ok, color: c.ok }}>
|
|
153
|
+
<span>{notice}</span>
|
|
154
|
+
<span style={{ ...s.link, marginLeft: 'auto' }} onClick={() => setNotice(null)}>忽略</span>
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
: null}
|
|
158
|
+
|
|
159
|
+
{confirmId !== null
|
|
160
|
+
? (
|
|
161
|
+
<div style={s.banner}>
|
|
162
|
+
<span>确认删除这个数据源?该操作不可恢复。</span>
|
|
163
|
+
<button type="button" style={{ ...s.dangerButton, marginLeft: 'auto' }} onClick={() => void handleDelete(confirmId)}>删除</button>
|
|
164
|
+
<button type="button" style={s.button} onClick={() => setConfirmId(null)}>取消</button>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
: null}
|
|
168
|
+
|
|
169
|
+
<div style={s.scroll}>
|
|
170
|
+
<table style={s.table}>
|
|
171
|
+
<thead>
|
|
172
|
+
<tr>
|
|
173
|
+
<th style={s.th}>名称</th>
|
|
174
|
+
<th style={s.th}>类型</th>
|
|
175
|
+
<th style={s.th}>地址</th>
|
|
176
|
+
<th style={s.th}>库名</th>
|
|
177
|
+
<th style={s.th}>状态</th>
|
|
178
|
+
<th style={s.th}>最近检测</th>
|
|
179
|
+
<th style={s.th}>操作</th>
|
|
180
|
+
</tr>
|
|
181
|
+
</thead>
|
|
182
|
+
<tbody>
|
|
183
|
+
{visible.length === 0 && !loading
|
|
184
|
+
? (
|
|
185
|
+
<tr>
|
|
186
|
+
<td style={{ ...s.td, color: c.fg2, textAlign: 'center', padding: '18px 8px' }} colSpan={7}>
|
|
187
|
+
还没有数据源,点击「新建数据源」登记一个 MySQL / PostgreSQL 连接
|
|
188
|
+
</td>
|
|
189
|
+
</tr>
|
|
190
|
+
)
|
|
191
|
+
: visible.map(item => (
|
|
192
|
+
<tr
|
|
193
|
+
key={item.id}
|
|
194
|
+
style={{ ...s.row, ...(item.id === selectedId ? s.selectedRow : null) }}
|
|
195
|
+
onClick={() => { setSelectedId(item.id); setMode('browse') }}
|
|
196
|
+
>
|
|
197
|
+
<td style={s.td} title={item.description ?? ''}>{item.name}</td>
|
|
198
|
+
<td style={s.td}>
|
|
199
|
+
<span
|
|
200
|
+
style={{
|
|
201
|
+
display: 'inline-block', padding: '0 6px', borderRadius: 4, fontSize: 11,
|
|
202
|
+
color: TYPE_BADGE[item.type] ?? c.fg2, border: `1px solid ${TYPE_BADGE[item.type] ?? c.border}`,
|
|
203
|
+
}}
|
|
204
|
+
>{ADMIN_SOURCE_TYPE_LABELS[item.type] ?? item.type}</span>
|
|
205
|
+
</td>
|
|
206
|
+
<td style={s.td} title={`${item.host}:${item.port}`}>{item.host}:{item.port}</td>
|
|
207
|
+
<td style={s.td}>{item.database}</td>
|
|
208
|
+
<td style={s.td}>
|
|
209
|
+
<span style={{ ...s.dot, background: SOURCE_STATUS_COLOR[item.status] ?? c.fg2 }} />
|
|
210
|
+
{SOURCE_STATUS_TEXT[item.status] ?? item.status}
|
|
211
|
+
</td>
|
|
212
|
+
<td style={{ ...s.td, color: c.fg2 }}>
|
|
213
|
+
{item.lastCheckedAt === null ? '—' : fmtAgo(item.lastCheckedAt)}
|
|
214
|
+
</td>
|
|
215
|
+
<td style={{ ...s.td, whiteSpace: 'nowrap' }}>
|
|
216
|
+
<span
|
|
217
|
+
style={{ ...s.link, marginRight: 8, ...(testingId !== null ? s.disabledButton : null) }}
|
|
218
|
+
onClick={event => {
|
|
219
|
+
event.stopPropagation()
|
|
220
|
+
if (testingId === null) void handleTest(item.id)
|
|
221
|
+
}}
|
|
222
|
+
>{testingId === item.id ? '测试中…' : '测试'}</span>
|
|
223
|
+
<span
|
|
224
|
+
style={{ ...s.link, marginRight: 8, ...(!writable ? s.disabledButton : null) }}
|
|
225
|
+
onClick={event => {
|
|
226
|
+
event.stopPropagation()
|
|
227
|
+
if (!writable) return
|
|
228
|
+
setSelectedId(item.id)
|
|
229
|
+
setMode('edit')
|
|
230
|
+
}}
|
|
231
|
+
>编辑</span>
|
|
232
|
+
<span
|
|
233
|
+
style={{ ...s.link, color: c.danger, ...(!writable ? s.disabledButton : null) }}
|
|
234
|
+
onClick={event => {
|
|
235
|
+
event.stopPropagation()
|
|
236
|
+
if (writable) setConfirmId(item.id)
|
|
237
|
+
}}
|
|
238
|
+
>删除</span>
|
|
239
|
+
</td>
|
|
240
|
+
</tr>
|
|
241
|
+
))}
|
|
242
|
+
</tbody>
|
|
243
|
+
</table>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{mode === 'create'
|
|
247
|
+
? (
|
|
248
|
+
<DataSourceForm
|
|
249
|
+
onSaved={view => void handleSaved(view)}
|
|
250
|
+
onCancel={() => setMode('browse')}
|
|
251
|
+
onError={handleError}
|
|
252
|
+
/>
|
|
253
|
+
)
|
|
254
|
+
: mode === 'edit' && selected !== null
|
|
255
|
+
? (
|
|
256
|
+
<DataSourceForm
|
|
257
|
+
initial={selected}
|
|
258
|
+
onSaved={view => void handleSaved(view)}
|
|
259
|
+
onCancel={() => setMode('browse')}
|
|
260
|
+
onError={handleError}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
: selected !== null
|
|
264
|
+
? (
|
|
265
|
+
<>
|
|
266
|
+
<div style={s.panel}>
|
|
267
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
268
|
+
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
|
|
269
|
+
<span style={s.muted}>
|
|
270
|
+
{ADMIN_SOURCE_TYPE_LABELS[selected.type] ?? selected.type} · 最近更新 {fmtAgo(selected.updatedAt)}
|
|
271
|
+
</span>
|
|
272
|
+
</div>
|
|
273
|
+
<div style={s.grid2}>
|
|
274
|
+
<div><span style={s.muted}>地址:</span>{selected.host}:{selected.port}</div>
|
|
275
|
+
<div><span style={s.muted}>库名:</span>{selected.database}</div>
|
|
276
|
+
<div><span style={s.muted}>用户名:</span>{selected.username}</div>
|
|
277
|
+
<div>
|
|
278
|
+
<span style={s.muted}>密码:</span>
|
|
279
|
+
{selected.hasPassword ? '已设置 · 不可查看' : '未设置'}
|
|
280
|
+
</div>
|
|
281
|
+
<div><span style={s.muted}>SSL:</span>{selected.sslMode ?? '—'}</div>
|
|
282
|
+
<div><span style={s.muted}>连接池上限:</span>{selected.poolMax ?? '—'}</div>
|
|
283
|
+
</div>
|
|
284
|
+
{selected.description !== null
|
|
285
|
+
? <div style={{ ...s.muted, marginTop: 6 }}>{selected.description}</div>
|
|
286
|
+
: null}
|
|
287
|
+
{selected.lastError !== null
|
|
288
|
+
? <div style={{ ...s.muted, marginTop: 6, color: c.danger }}>最近错误:{selected.lastError}</div>
|
|
289
|
+
: null}
|
|
290
|
+
</div>
|
|
291
|
+
<SourceTablesPanel source={selected} scopes={scopes} onError={handleError} />
|
|
292
|
+
</>
|
|
293
|
+
)
|
|
294
|
+
: (
|
|
295
|
+
<div style={{ ...s.muted, marginTop: 12 }}>
|
|
296
|
+
{loading ? '加载中…' : '选择一个数据源查看详情与远端表,或点击「新建数据源」。'}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
}
|