Ghost

struxadotcloud/struxa

public
Fork0
CodeIssues0Pull requests0
1branch
struxaappswebsrcapp(panel)servers[id]databasespage.tsx
feat: global unslop6531421a month ago367 lines · 16.3 KB
1"use client";
2
3import { useEffect, useState
4import { use }
5import { useRouter }
6import { useQuery, useMutation
7import { Database, Copy
8import type { LucideIcon
9import {
10 Dialog,
11 DialogPopup,
12 DialogHeader,
13 DialogFooter,
14 DialogTitle,
15 DialogDescription,
16 DialogClose,
17} from "@struxa/ui/components/dialog";
18import { useTranslations }
19import { toast }
20import { orpc }
21import { authClient }
22import Loader from "@/components/loader
23import { formatConnectionString }
24import { ConfirmDialog }
25
26type DatabaseEngineType
27
28function StatRow({ icon
29 return (
30 <div className="flex flex-col border-b border-border last:border-b-0
31 <div className="flex flex-col gap-1.5 px-4 pt-3 pb-2.5
32 <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground
33 <Icon className="
34 {label}
35 </div>
36 {children}
37 </div>
38 </div>
39 );
40}
41
42type DbRow
43 id: string
44 database: string
45 username: string
46 password: string
47 remote: string
48 host: {
49};
50
51function DatabaseRow({ db
52 db: DbRow
53 onRotate: (id
54 onDelete: (id
55 rotating: boolean
56}) {
57 const t =
58 const tt =
59 const [open,
60 const [visible,
61 const [pw,
62
63 useEffect(() =>
64
65 function copy(text
66
67 const isRedis =
68
69 return (
70 <div className="border-b border-border last:border-b-0
71 <button
72 type="button"
73 onClick={() =>
74 className="flex w-full items-center gap-3 px-4 py-3 hover:bg-muted/40 transition-colors text-left"
75 >
76 <ChevronRight className={
77 <Database className="
78 <span className="font-mono text-sm font-medium text-foreground flex-1
79 <span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground
80 {tt(db.host
81 </span>
82 <div className="flex items-center gap-2
83 <button
84 type="button"
85 onClick={() =>
86 disabled={rotating}
87 className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
88 >
89 <RefreshCw className={
90 {rotating ? t
91 </button>
92 <button
93 type="button"
94 onClick={() =>
95 className="flex items-center gap-1.5 rounded-lg border border-destructive/30 px-3 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
96 >
97 <Trash2 className="
98 {t("delete")
99 </button>
100 </div>
101 </button>
102
103 {open && (
104 <div className="grid grid-cols-1 border-t border-border bg-muted/20 sm:grid-cols-2
105 <div className="border-b border-border px-4 py-3 sm:border-b-0 sm:border-r
106 <p className="mb-1.5 text-xs font-medium text-muted-foreground
107 <div className="flex items-center gap-2
108 <span className="font-mono text-sm text-foreground
109 <button type="button
110 <Copy className="
111 </button>
112 </div>
113 </div>
114 <div className="px-4 py-3
115 <p className="mb-1.5 text-xs font-medium text-muted-foreground
116 <div className="flex items-center gap-2
117 <span className="font-mono text-sm text-foreground
118 <button type="button
119 {visible ? <EyeOff
120 </button>
121 <button type="button
122 <Copy className="
123 </button>
124 </div>
125 </div>
126 <div className="border-t border-border px-4 py-3 sm:border-r
127 <p className="mb-1.5 text-xs font-medium text-muted-foreground
128 <div className="flex items-center gap-2
129 <span className="font-mono text-sm text-foreground
130 <button type="button
131 <Copy className="
132 </button>
133 </div>
134 </div>
135 <div className="border-t border-border px-4 py-3
136 <p className="mb-1.5 text-xs font-medium text-muted-foreground
137 <span className="font-mono text-sm text-foreground
138 </div>
139 <div className="col-span-1 border-t border-border px-4 py-3 sm:col-span-2
140 <p className="mb-1.5 text-xs font-medium text-muted-foreground
141 {isRedis ? t
142 </p>
143 <div className="flex items-center gap-2
144 <span className="font-mono text-xs text-muted-foreground
145 {formatConnectionString(db.host
146 </span>
147 <button
148 type="button"
149 onClick={() =>
150 copy(formatConnectionString(db
151 }
152 className="rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors shrink-0"
153 >
154 <Copy className="
155 </button>
156 </div>
157 </div>
158 </div>
159 )}
160 </div>
161 );
162}
163
164export default function DatabasesPage
165 const t =
166 const tc =
167 const tt =
168 const router =
169 const { data:
170 const { id
171 const queryClient =
172 const [showCreate,
173 const [dbName,
174 const [dbType,
175 const [confirmRotateId,
176
177 useEffect(() =>
178 if (!isPending &&
179 }, [isPending,
180
181 const { data:
182 const serverId =
183
184 const { data:
185 ...orpc.databases.
186 enabled: !!serverId,
187 });
188
189 const { data:
190 ...orpc.databaseHosts.
191 enabled: showCreate &&
192 });
193
194 const createMutation =
195 const rotateMutation =
196 ...orpc.databases.
197 onSuccess: () =>
198 void queryClient.invalidateQueries(
199 toast.success(tc
200 },
201 });
202 const deleteMutation =
203 ...orpc.databases.
204 onSuccess: () =>
205 void queryClient.invalidateQueries(
206 toast.success(tc
207 },
208 });
209
210 function closeCreate() {
211 setShowCreate(false);
212 setDbName("");
213 setDbType("");
214 }
215
216 async function handleCreate
217 if (!serverId ||
218 await createMutation.mutateAsync
219 void queryClient.invalidateQueries(
220 toast.success(tc
221 closeCreate();
222 }
223
224 if (isPending ||
225
226 return (
227 <>
228 <Dialog open={
229 <DialogPopup showCloseButton={
230 <DialogHeader>
231 <DialogTitle>{t(
232 <DialogDescription>{t(
233 </DialogHeader>
234 <div className="flex flex-col gap-4 px-5 py-4
235 <div>
236 <label className="mb-1.5 block text-xs font-medium text-foreground
237 <input
238 autoFocus
239 value={dbName}
240 onChange={(e)
241 placeholder="my_database"
242 className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground/50 focus:border-ring transition-colors"
243 onKeyDown={(e)
244 />
245 <p className="mt-1 text-xs text-muted-foreground
246 </div>
247 <div>
248 <label className="mb-1.5 block text-xs font-medium text-foreground
249 {availableTypes.length ===
250 <p className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground
251 {t("noTypesAvailable")
252 </p>
253 ) : (
254 <div className="flex flex-wrap gap-1.5
255 {availableTypes.map((
256 <button
257 key={type}
258 type="button"
259 onClick={() =>
260 className={`rounded-lg border px-3 py-2 text-sm transition-colors ${
261 dbType === type
262 ? "border-ring bg-muted/40 text-foreground"
263 : "border-border text-muted-foreground hover:bg-muted/40 hover:text-foreground"
264 }`}
265 >
266 {tt(type)}
267 </button>
268 ))}
269 </div>
270 )}
271 </div>
272 </div>
273 <DialogFooter>
274 <DialogClose
275 className="rounded-lg px-4 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
276 disabled={createMutation.isPending
277 >
278 {t("cancel")
279 </DialogClose>
280 <button
281 type="button"
282 onClick={() =>
283 disabled={!dbName.
284 className="rounded-lg bg-foreground px-4 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-40"
285 >
286 {createMutation.isPending ?
287 </button>
288 </DialogFooter>
289 </DialogPopup>
290 </Dialog>
291
292 <ConfirmDialog
293 open={!!confirmRotateId}
294 onOpenChange={(open)
295 title={t("
296 description={t("
297 loading={rotateMutation.isPending
298 onConfirm={async ()
299 if (!confirmRotateId ||
300 await rotateMutation.mutateAsync
301 setConfirmRotateId(null);
302 }}
303 />
304
305 <div className="flex flex-1 flex-col gap-3 overflow-auto px-4 py-4 md:flex-row md:overflow-hidden
306 <div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card
307 <div className="flex items-center justify-between border-b border-border px-4 py-3
308 <p className="text-sm font-medium text-foreground
309 <button
310 type="button"
311 onClick={() =>
312 className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
313 >
314 <Plus className="
315 {t("newDatabase")
316 </button>
317 </div>
318 <div className="flex-1 overflow-y-auto
319 {dbPending ? (
320 <div className="flex items-center justify-center py-12 text-sm text-muted-foreground
321 ) : databases.length
322 <div className="flex flex-col items-center justify-center py-12 gap-2
323 <Database className="
324 <p className="text-sm text-muted-foreground
325 <button
326 type="button"
327 onClick={() =>
328 className="mt-1 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
329 >
330 {t("createFirst")
331 </button>
332 </div>
333 ) : (
334 databases.map((db
335 <DatabaseRow
336 key={db.id
337 db={db as
338 onRotate={(dbId)
339 onDelete={(dbId)
340 rotating={rotateMutation.isPending
341 />
342 ))
343 )}
344 </div>
345 </div>
346
347 <aside className="flex w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:w-[240px]
348 <div className="overflow-y-auto
349 <StatRow icon={
350 <span className="text-xl font-bold text-foreground
351 </StatRow>
352 <StatRow icon={
353 <span className="font-mono text-sm font-semibold text-foreground leading-snug
354 {(databases[0]
355 </span>
356 </StatRow>
357 <StatRow icon={
358 <p className="text-xs text-muted-foreground leading-relaxed
359 {t("statNoteText")
360 </p>
361 </StatRow>
362 </div>
363 </aside>
364 </div>
365 </>
366 );
367}
}
from
"
react
"
;
from
"
react
"
;
from
"
next/navigation
"
;
,
useQueryClient
}
from
"
@tanstack/react-query
"
;
,
RefreshCw
,
Trash2
,
Eye
,
EyeOff
,
Plus
,
ChevronRight
}
from
"
lucide-react
"
;
}
from
"
lucide-react
"
;
from
"
next-intl
"
;
from
"
sonner
"
;
from
"
@/utils/orpc
"
;
from
"
@/lib/auth-client
"
;
"
;
from
"
@/lib/db-connection-string
"
;
from
"
@/components/confirm-dialog
"
;
=
"
mysql
"
|
"
mariadb
"
|
"
postgresql
"
|
"
mongodb
"
|
"
redis
"
;
:
Icon
,
label
,
children
}
:
{
icon
:
LucideIcon
;
label
:
string
;
children
:
React
.
ReactNode
})
{
"
>
"
>
"
>
h-3 w-3
"
/>
=
{
;
;
;
;
|
null
;
host
:
string
;
port
:
number
;
type
:
DatabaseEngineType
}
;
,
onRotate
,
onDelete
,
rotating
}
:
{
;
:
string
)
=>
void
;
:
string
)
=>
void
;
;
useTranslations
(
"
panel.databases
"
)
;
useTranslations
(
"
common.databaseTypes
"
)
;
setOpen
]
=
useState
(
false
)
;
setVisible
]
=
useState
(
false
)
;
setPw
]
=
useState
(
db
.
password
)
;
setPw
(
db
.
password
)
,
[
db
.
password
])
;
:
string
)
{
void
navigator
.
clipboard
.
writeText
(
text
)
;
}
db
.
host
.
type
===
"
redis
"
;
"
>
setOpen
(
(
v
)
=>
!
v
)
}
`
h-3.5 w-3.5 shrink-0 text-muted-foreground/50 transition-transform
${
open
?
"
rotate-90
"
:
""
}
`
}
/>
h-4 w-4 shrink-0 text-muted-foreground
"
/>
"
>
{
db
.
database
}
</
span
>
"
>
.
type
)
}
"
onClick
=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
onRotate
(
db
.
id
)
}
`
h-3 w-3
${
rotating
?
"
animate-spin
"
:
""
}
`
}
/>
(
"
rotating
"
)
:
t
(
"
rotatePassword
"
)
}
onDelete
(
db
.
id
)
}
h-3 w-3
"
/>
}
"
>
"
>
"
>
{
t
(
"
fieldUsername
"
)
}
</
p
>
"
>
"
>
{
db
.
username
}
</
span
>
"
onClick
=
{
()
=>
copy
(
db
.
username
)
}
className
=
"
rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors
"
>
h-3.5 w-3.5
"
/>
"
>
"
>
{
t
(
"
fieldPassword
"
)
}
</
p
>
"
>
"
>
{
visible
?
pw
:
"
••••••••••••
"
}
</
span
>
"
onClick
=
{
()
=>
setVisible
(
(
v
)
=>
!
v
)
}
className
=
"
rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors
"
>
className
=
"
h-3.5 w-3.5
"
/>
:
<
Eye
className
=
"
h-3.5 w-3.5
"
/>
}
"
onClick
=
{
()
=>
copy
(
pw
)
}
className
=
"
rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors
"
>
h-3.5 w-3.5
"
/>
"
>
"
>
{
t
(
"
fieldHost
"
)
}
</
p
>
"
>
"
>
{
db
.
host
.
host
}
</
span
>
"
onClick
=
{
()
=>
copy
(
db
.
host
.
host
)
}
className
=
"
rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors
"
>
h-3.5 w-3.5
"
/>
"
>
"
>
{
t
(
"
fieldPort
"
)
}
</
p
>
"
>
{
db
.
host
.
port
}
</
span
>
"
>
"
>
(
"
fieldConnectionStringNoKeyspace
"
)
:
t
(
"
fieldConnectionString
"
)
}
"
>
"
>
.
type
,
{
username
:
db
.
username
,
password
:
"
***
"
,
host
:
db
.
host
.
host
,
port
:
db
.
host
.
port
,
database
:
db
.
database
}
)
}
.
host
.
type
,
{
username
:
db
.
username
,
password
:
pw
,
host
:
db
.
host
.
host
,
port
:
db
.
host
.
port
,
database
:
db
.
database
}
))
h-3.5 w-3.5
"
/>
(
{
params
}
:
{
params
:
Promise
<
{
id
:
string
}
>
})
{
useTranslations
(
"
panel.databases
"
)
;
useTranslations
(
"
common
"
)
;
useTranslations
(
"
common.databaseTypes
"
)
;
useRouter
()
;
session
,
isPending
}
=
authClient
.
useSession
()
;
}
=
use
(
params
)
;
useQueryClient
()
;
setShowCreate
]
=
useState
(
false
)
;
setDbName
]
=
useState
(
""
)
;
setDbType
]
=
useState
<
DatabaseEngineType
|
""
>
(
""
)
;
setConfirmRotateId
]
=
useState
<
string
|
null
>
(
null
)
;
{
!
session
)
router
.
replace
(
"
/login
"
)
;
session
,
router
])
;
server
}
=
useQuery
(
orpc
.
servers
.
get
.
queryOptions
(
{
input
:
{
id
}
}
))
;
server
?.
id
;
databases
=
[]
,
isPending
:
dbPending
}
=
useQuery
(
{
list
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
)
,
availableTypes
=
[]
}
=
useQuery
(
{
listAvailableTypes
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
)
,
!!
serverId
,
useMutation
(
orpc
.
databases
.
create
.
mutationOptions
())
;
useMutation
(
{
rotatePassword
.
mutationOptions
()
,
{
orpc
.
databases
.
list
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
))
;
(
"
saved
"
))
;
useMutation
(
{
delete
.
mutationOptions
()
,
{
orpc
.
databases
.
list
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
))
;
(
"
deleted
"
))
;
()
{
!
dbName
.
trim
()
||
!
dbType
)
return
;
(
{
serverId
,
type
:
dbType
,
database
:
dbName
.
trim
()
}
)
;
orpc
.
databases
.
list
.
queryOptions
(
{
input
:
{
serverId
}
}
))
;
(
"
created
"
))
;
!
session
)
return
<
Loader
/>
;
showCreate
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
closeCreate
()
;
}
}
>
false
}
>
"
createTitle
"
)
}
</
DialogTitle
>
"
createDescription
"
)
}
</
DialogDescription
>
"
>
"
>
{
t
(
"
databaseNameLabel
"
)
}
</
label
>
=>
setDbName
(
e
.
target
.
value
.
replace
(
/
[
^
a-zA-Z0-9
_
]
/
g
,
""
))
}
=>
{
if
(
e
.
key
===
"
Enter
"
&&
dbType
)
void
handleCreate
()
;
if
(
e
.
key
===
"
Escape
"
)
closeCreate
()
;
}
}
"
>
{
t
(
"
databaseNameHint
"
)
}
</
p
>
"
>
{
t
(
"
databaseTypeLabel
"
)
}
</
label
>
0
?
(
"
>
}
"
>
type
)
=>
(
setDbType
(
type
)
}
}
}
void
handleCreate
()
}
trim
()
||
!
dbType
||
createMutation
.
isPending
}
t
(
"
creating
"
)
:
t
(
"
createDatabase
"
)
}
=>
{
if
(
!
open
)
setConfirmRotateId
(
null
)
;
}
}
rotateConfirmTitle
"
)
}
rotateConfirmDesc
"
)
}
}
=>
{
!
serverId
)
return
;
(
{
serverId
,
databaseId
:
confirmRotateId
}
)
;
"
>
"
>
"
>
"
>
{
t
(
"
sectionTitle
"
)
}
</
p
>
setShowCreate
(
true
)
}
h-3.5 w-3.5
"
/>
}
"
>
"
>
{
t
(
"
loading
"
)
}
</
div
>
===
0
?
(
"
>
h-8 w-8 text-muted-foreground/30
"
/>
"
>
{
t
(
"
empty
"
)
}
</
p
>
setShowCreate
(
true
)
}
}
)
=>
(
}
DbRow
}
=>
setConfirmRotateId
(
dbId
)
}
=>
serverId
&&
deleteMutation
.
mutate
(
{
serverId
,
databaseId
:
dbId
}
)
}
&&
rotateMutation
.
variables
?.
databaseId
===
db
.
id
}
"
>
"
>
Database
}
label
=
{
t
(
"
statDatabases
"
)
}
>
"
>
{
databases
.
length
}
</
span
>
Database
}
label
=
{
t
(
"
statHost
"
)
}
>
"
>
as
DbRow
|
undefined
)
?.
host
.
host
??
"
-
"
}
Database
}
label
=
{
t
(
"
statNote
"
)
}
>
"
>
}