Ghost

struxadotcloud/struxa

public
Fork0
CodeIssues0Pull requests0
1branch
struxaappswebsrcapp(panel)servers[id]settingspage.tsx
feat: global unslop6531421a month ago749 lines · 29.4 KB
1"use client";
2
3import { useEffect, useState
4import { use }
5import { useRouter }
6import { useQuery, useMutation
7import { Server, Copy
8import type { LucideIcon
9import {
10 DropdownMenu,
11 DropdownMenuContent,
12 DropdownMenuItem,
13 DropdownMenuTrigger,
14} from "@struxa/ui/components/dropdown-menu";
15import {
16 Dialog,
17 DialogPopup,
18 DialogHeader,
19 DialogFooter,
20 DialogTitle,
21 DialogDescription,
22} from "@struxa/ui/components/dialog";
23import { Button }
24import { toast }
25import { useTranslations }
26import { orpc, queryClient
27import { authClient }
28import Loader from "@/components/loader
29import { cn }
30import { useMediaQuery }
31import { Sheet, SheetPopup
32
33function StatRow({
34 icon: Icon,
35 label,
36 children,
37}: {
38 icon: LucideIcon
39 label: string
40 children: React
41}) {
42 return (
43 <div className="flex flex-col border-b border-border last:border-b-0
44 <div className="flex flex-col gap-1.5 px-4 pt-3 pb-2.5
45 <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground
46 <Icon className="
47 {label}
48 </div>
49 {children}
50 </div>
51 </div>
52 );
53}
54
55function SectionCard({
56 title,
57 description,
58 children,
59}: {
60 title: string
61 description?: string
62 children: React
63}) {
64 return (
65 <div className="rounded-xl border border-border bg-card
66 <div className="border-b border-border px-4 py-3
67 <p className="text-sm font-semibold text-foreground
68 {description && <p
69 </div>
70 <div>{children}</
71 </div>
72 );
73}
74
75function SettingRow({
76 label,
77 description,
78 children,
79}: {
80 label: string
81 description?: string
82 children: React
83}) {
84 return (
85 <div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0 hover:bg-muted/40 transition-colors
86 <div className="flex flex-col gap-0.5 max-w-xs
87 <span className="text-sm font-medium text-foreground
88 {description && <span
89 </div>
90 {children}
91 </div>
92 );
93}
94
95function Toggle({
96 checked,
97 onChange,
98 disabled,
99}: {
100 checked: boolean
101 onChange: (v
102 disabled?: boolean
103}) {
104 return (
105 <button
106 type="button"
107 role="switch"
108 aria-checked={checked}
109 onClick={() =>
110 disabled={disabled}
111 className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
112 checked ? "bg-green-500"
113 }`}
114 >
115 <span
116 className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
117 checked ? "translate-x-[18px]"
118 }`}
119 />
120 </button>
121 );
122}
123
124function CustomSelect({
125 value,
126 options,
127 onChange,
128 disabled,
129}: {
130 value: string
131 options: {
132 onChange: (v
133 disabled?: boolean
134}) {
135 const selected =
136 return (
137 <DropdownMenu>
138 <DropdownMenuTrigger
139 disabled={disabled}
140 className="flex w-56 items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors hover:border-border/80 hover:bg-muted data-[popup-open]:border-ring disabled:cursor-not-allowed disabled:opacity-50"
141 >
142 <span className="truncate
143 <ChevronDown className="
144 </DropdownMenuTrigger>
145 <DropdownMenuContent align="
146 {options.map((
147 <DropdownMenuItem
148 key={opt.value
149 onClick={() =>
150 className="flex cursor-pointer items-center gap-2.5 text-sm"
151 >
152 <span
153 className={`h-1.5 w-1.5 shrink-0 rounded-full ${opt
154 />
155 {opt.label}
156 </DropdownMenuItem>
157 ))}
158 </DropdownMenuContent>
159 </DropdownMenu>
160 );
161}
162
163function DebouncedInput({
164 defaultValue,
165 onSave,
166 disabled,
167 placeholder,
168 className,
169}: {
170 defaultValue: string
171 onSave: (v
172 disabled?: boolean
173 placeholder?: string
174 className?: string
175}) {
176 const [value,
177 const timerRef =
178
179 function handleChange(e
180 const v =
181 setValue(v);
182 if (timerRef.current
183 timerRef.current =
184 }
185
186 return (
187 <input
188 value={value}
189 onChange={handleChange}
190 disabled={disabled}
191 placeholder={placeholder}
192 className={className}
193 />
194 );
195}
196
197function isBoolVariable(rules:
198 return !!(rules
199}
200
201type ServerVariable
202 variableId: string
203 variableValue: string
204 variable: {
205 name: string
206 description: string
207 envVariable: string
208 defaultValue: string
209 userEditable: boolean
210 rules: string
211 };
212};
213
214export default function SettingsPage
215 const t =
216 const tBilling =
217 const router =
218 const { data:
219 const { id
220 const [reinstallConfirm,
221 const [reinstallStep,
222 const [selectedEggId,
223 const [extendOpen,
224 const [extendPriceId,
225 const isMobile =
226 const tCommon =
227
228 useEffect(() =>
229 if (!isPending &&
230 }, [isPending,
231
232 const { data:
233 orpc.servers.get
234 );
235
236 const { data:
237 orpc.servers.listPlanEggs
238 );
239
240 const subscriptionQuery =
241 ...orpc.billing.
242 enabled: !!(server?.
243 });
244
245 useEffect(() =>
246 if (!extendPriceId &&
247 setExtendPriceId(subscriptionQuery.data
248 }
249 }, [subscriptionQuery.data
250
251 const extendMutation =
252 ...orpc.billing.
253 meta: { customError: true
254 onSuccess: () =>
255 setExtendOpen(false);
256 setExtendPriceId(null);
257 void queryClient.invalidateQueries(
258 toast.success(t
259 },
260 onError: (error:
261 if (error.data
262 toast.error(t
263 } else {
264 toast.error(error
265 }
266 },
267 });
268
269 const reinstallMutation =
270 ...orpc.servers.
271 onSuccess: () =>
272 setReinstallConfirm(false);
273 setReinstallStep(1);
274 setSelectedEggId(null);
275 void queryClient.invalidateQueries(
276 toast.success(t
277 },
278 });
279
280 const renameMutation =
281 onSuccess: () =>
282 void queryClient.invalidateQueries(
283 toast.success(tCommon
284 },
285 }));
286 const updateVariableMutation =
287 onSuccess: () =>
288 void queryClient.invalidateQueries(
289 toast.success(tCommon
290 },
291 }));
292 const updateImageMutation =
293 onSuccess: () =>
294 void queryClient.invalidateQueries(
295 toast.success(tCommon
296 },
297 }));
298
299 function saveImage(image
300 updateImageMutation.mutate({
301 }
302
303 function saveServerName(name
304 renameMutation.mutate({
305 }
306
307 function saveVariable(envVariable
308 updateVariableMutation.mutate({
309 }
310
311 if (isPending ||
312 if (serverPending) return
313
314 const node =
315 const sftp =
316 host: node?.fqdn
317 port: node?.daemonSFTP
318 username: `${session?.
319 };
320
321 function copy(text
322 void navigator.clipboard.
323 }
324
325 function closeReinstall() {
326 setReinstallConfirm(false);
327 setReinstallStep(1);
328 setSelectedEggId(null);
329 }
330
331 const serverVars =
332
333 const egg =
334 let dockerImageOptions:
335 try {
336 const parsed =
337 dockerImageOptions = Object.
338 } catch {}
339
340 const currentImage =
341
342 const eggPickerBody =
343 <div className="px-5 py-4 flex flex-col gap-1.5
344 {planEggs.map((
345 const selected =
346 return (
347 <button
348 key={egg.id
349 type="button"
350 onClick={() =>
351 className={cn(
352 "flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-all",
353 selected ? "border-foreground bg-foreground/5"
354 )}
355 >
356 <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted
357 <Server className="
358 </div>
359 <span className="flex-1 text-sm font-medium
360 <div className={cn
361 "flex h-4 w-4 items-center justify-center rounded-full border transition-colors",
362 selected ? "border-foreground bg-foreground"
363 )}>
364 {selected && <div
365 </div>
366 </button>
367 );
368 })}
369 </div>
370 );
371
372 const confirmBody =
373 <div className="px-5 py-4
374 <div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3
375 <AlertTriangle className="
376 <p className="text-xs text-destructive/80 leading-relaxed
377 </div>
378 </div>
379 );
380
381 const reinstallModalContent =
382 <>
383 <DialogHeader>
384 <DialogTitle>{t(
385 <DialogDescription>{t(
386 </DialogHeader>
387 {eggPickerBody}
388 <DialogFooter>
389 <Button variant="
390 <Button size="
391 </DialogFooter>
392 </>
393 ) : (
394 <>
395 <DialogHeader>
396 <DialogTitle>{t(
397 </DialogHeader>
398 {confirmBody}
399 <DialogFooter>
400 <Button variant="
401 {t("cancel")
402 </Button>
403 <Button
404 variant="destructive"
405 size="sm"
406 disabled={reinstallMutation.isPending
407 onClick={() =>
408 >
409 {reinstallMutation.isPending ?
410 </Button>
411 </DialogFooter>
412 </>
413 );
414
415 const reinstallSheetContent =
416 <>
417 <SheetHeader>
418 <SheetTitle>{t(
419 <SheetDescription>{t(
420 </SheetHeader>
421 {eggPickerBody}
422 <DialogFooter>
423 <Button variant="
424 <Button size="
425 </DialogFooter>
426 </>
427 ) : (
428 <>
429 <SheetHeader>
430 <SheetTitle>{t(
431 </SheetHeader>
432 {confirmBody}
433 <DialogFooter>
434 <Button variant="
435 {t("cancel")
436 </Button>
437 <Button
438 variant="destructive"
439 size="sm"
440 disabled={reinstallMutation.isPending
441 onClick={() =>
442 >
443 {reinstallMutation.isPending ?
444 </Button>
445 </DialogFooter>
446 </>
447 );
448
449 return (
450 <>
451 <div className="flex flex-1 flex-col gap-3 overflow-auto px-4 py-4 md:flex-row md:overflow-hidden
452 <div className="flex flex-1 flex-col gap-3 overflow-y-auto
453 <SectionCard title={
454 <SettingRow label={
455 <div className="flex items-center gap-2
456 <DebouncedInput
457 defaultValue={server?.name
458 onSave={saveServerName}
459 className="w-48 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground outline-none focus:border-ring transition-colors"
460 />
461 </div>
462 </SettingRow>
463 <SettingRow label={
464 {dockerImageOptions.length >
465 <div className="flex items-center gap-2
466 <CustomSelect
467 value={currentImage}
468 options={dockerImageOptions.map
469 onChange={saveImage}
470 disabled={updateImageMutation.isPending
471 />
472 </div>
473 ) : (
474 <span className="font-mono text-sm text-muted-foreground max-w-xs truncate
475 {currentImage || "-
476 </span>
477 )}
478 </SettingRow>
479 </SectionCard>
480
481 <SectionCard title={
482 <div className="grid grid-cols-1 sm:grid-cols-3
483 {[
484 { label: t(
485 { label: t(
486 { label: t(
487 ].map(({
488 <div key={label
489 <p className="mb-1.5 text-xs font-medium text-muted-foreground
490 <div className="flex min-w-0 items-center gap-2
491 <span className="min-w-0 break-all font-mono text-sm text-foreground
492 {copyVal && (
493 <button
494 type="button"
495 onClick={() =>
496 className="shrink-0 rounded p-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
497 >
498 <Copy className="
499 </button>
500 )}
501 </div>
502 </div>
503 ))}
504 </div>
505 <div className="flex items-center justify-between border-t border-border px-4 py-3
506 <p className="text-xs text-muted-foreground
507 <Button
508 variant="outline"
509 size="sm"
510 onClick={() =>
511 >
512 <ExternalLink />
513 {t("sftpConnect")
514 </Button>
515 </div>
516 </SectionCard>
517
518 <SectionCard title={
519 <div className="border-b border-border px-4 py-3
520 <p className="mb-1 text-xs font-medium text-muted-foreground
521 <p className="font-mono text-xs text-foreground/70 leading-relaxed break-all
522 {server?.startup ??
523 </p>
524 </div>
525 {serverVars.map((
526 const isBool =
527 const currentValue =
528
529 if (isBool) {
530 return (
531 <SettingRow
532 key={sv.variable
533 label={sv.variable
534 description={sv.variable
535 >
536 <div className="flex items-center gap-2
537 <Toggle
538 checked={currentValue ===
539 onChange={(v)
540 disabled={
541 !sv.variable.userEditable
542 (updateVariableMutation.isPending &&
543 updateVariableMutation.variables?.envVariable
544 }
545 />
546 {!sv.variable.
547 <span className="text-xs text-muted-foreground
548 )}
549 </div>
550 </SettingRow>
551 );
552 }
553
554 return (
555 <SettingRow
556 key={sv.variable
557 label={sv.variable
558 description={sv.variable
559 >
560 <div className="flex items-center gap-2
561 {sv.variable.userEditable
562 <DebouncedInput
563 defaultValue={currentValue}
564 onSave={(v)
565 placeholder={sv.variable
566 className="w-36 rounded-lg border border-border bg-background px-3 py-1.5 font-mono text-sm text-foreground outline-none focus:border-ring transition-colors"
567 />
568 ) : (
569 <input
570 defaultValue={currentValue}
571 readOnly
572 className="w-36 rounded-lg border border-border bg-muted px-3 py-1.5 font-mono text-sm text-foreground outline-none opacity-60 cursor-default"
573 />
574 )}
575 {!sv.variable.
576 <span className="text-xs text-muted-foreground
577 )}
578 </div>
579 </SettingRow>
580 );
581 })}
582 </SectionCard>
583
584 {server?.subscriptionId &&
585 <SectionCard title={
586 {subscriptionQuery.data ?
587 const sub =
588 const periodEnd =
589 const expiringSoon =
590 const formatCents =
591 new Intl.NumberFormat
592
593 const extendDialogBody = (
594 <div className="px-5 py-4 flex flex-col gap-2
595 {expiringSoon && (
596 <div className="flex items-center gap-2 rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 py-2 mb-1
597 <AlertTriangle className="
598 <span className="text-xs text-amber-600
599 </div>
600 )}
601 {sub.availablePrices.map
602 const selected =
603 return (
604 <button
605 key={price.id
606 type="button"
607 onClick={() =>
608 className={cn(
609 "flex items-center justify-between rounded-xl border px-4 py-3 text-left transition-all",
610 selected ? "border-foreground bg-foreground/5"
611 )}
612 >
613 <span className="text-sm font-medium
614 <div className="flex items-center gap-2
615 <span className="text-sm font-semibold
616 <div className={cn
617 "flex h-4 w-4 items-center justify-center rounded-full border transition-colors",
618 selected ? "border-foreground bg-foreground"
619 )}>
620 {selected && <div
621 </div>
622 </div>
623 </button>
624 );
625 })}
626 </div>
627 );
628
629 const extendDialogFooter = (
630 <DialogFooter>
631 <Button variant="
632 <Button
633 size="sm"
634 disabled={!extendPriceId
635 onClick={() =>
636 >
637 {extendMutation.isPending ?
638 </Button>
639 </DialogFooter>
640 );
641
642 return (
643 <>
644 <SettingRow label={
645 <div className="flex items-center gap-2
646 {expiringSoon && <AlertTriangle
647 <span className={cn
648 {periodEnd ? periodEnd
649 </span>
650 </div>
651 </SettingRow>
652 <SettingRow label={
653 <button
654 type="button"
655 onClick={() =>
656 className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
657 >
658 {t("subscriptionExtendButton")
659 </button>
660 </SettingRow>
661
662 {isMobile ? (
663 <Sheet open={
664 <SheetPopup side="
665 <SheetHeader>
666 <SheetTitle>{t(
667 <SheetDescription>{t(
668 </SheetHeader>
669 {extendDialogBody}
670 {extendDialogFooter}
671 </SheetPopup>
672 </Sheet>
673 ) : (
674 <Dialog open={
675 <DialogPopup showCloseButton={
676 <DialogHeader>
677 <DialogTitle>{t(
678 <DialogDescription>{t(
679 </DialogHeader>
680 {extendDialogBody}
681 {extendDialogFooter}
682 </DialogPopup>
683 </Dialog>
684 )}
685 </>
686 );
687 })() : null}
688 </SectionCard>
689 )}
690
691 <SectionCard title={
692 <SettingRow
693 label={t("
694 description={t("
695 >
696 <button
697 type="button"
698 onClick={() =>
699 className="rounded-lg border border-destructive/30 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
700 >
701 {t("reinstall")
702 </button>
703 </SettingRow>
704 </SectionCard>
705
706 {isMobile ? (
707 <Sheet open={
708 <SheetPopup side="
709 {reinstallSheetContent}
710 </SheetPopup>
711 </Sheet>
712 ) : (
713 <Dialog open={
714 <DialogPopup showCloseButton={
715 {reinstallModalContent}
716 </DialogPopup>
717 </Dialog>
718 )}
719 </div>
720
721 <aside className="flex w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:w-[240px]
722 <div className="overflow-y-auto
723 <StatRow icon={
724 <span className="font-mono text-xs text-muted-foreground break-all
725 </StatRow>
726 <StatRow icon={
727 <span className="text-sm font-semibold text-foreground
728 </StatRow>
729 <StatRow icon={
730 <span className="text-sm font-semibold text-foreground
731 </StatRow>
732 <StatRow icon={
733 <div className="flex items-center gap-2
734 <span className="font-mono text-sm text-foreground
735 <button
736 type="button"
737 onClick={() =>
738 className="rounded p-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
739 >
740 <Copy className="
741 </button>
742 </div>
743 </StatRow>
744 </div>
745 </aside>
746 </div>
747 </>
748 );
749}
,
useRef
}
from
"
react
"
;
from
"
react
"
;
from
"
next/navigation
"
;
}
from
"
@tanstack/react-query
"
;
,
Terminal
,
Globe
,
ChevronDown
,
ExternalLink
,
AlertTriangle
}
from
"
lucide-react
"
;
}
from
"
lucide-react
"
;
from
"
@struxa/ui/components/button
"
;
from
"
sonner
"
;
from
"
next-intl
"
;
}
from
"
@/utils/orpc
"
;
from
"
@/lib/auth-client
"
;
"
;
from
"
@struxa/ui/lib/utils
"
;
from
"
@struxa/ui/hooks/use-media-query
"
;
,
SheetHeader
,
SheetTitle
,
SheetDescription
}
from
"
@struxa/ui/components/sheet
"
;
;
;
.
ReactNode
;
"
>
"
>
"
>
h-3 w-3
"
/>
;
;
.
ReactNode
;
"
>
"
>
"
>
{
title
}
</
p
>
className
=
"
mt-0.5 text-xs text-muted-foreground
"
>
{
description
}
</
p
>
}
div
>
;
;
.
ReactNode
;
"
>
"
>
"
>
{
label
}
</
span
>
className
=
"
text-xs text-muted-foreground
"
>
{
description
}
</
span
>
}
;
:
boolean
)
=>
void
;
;
!
disabled
&&
onChange
(
!
checked
)
}
:
"
bg-muted
"
:
"
translate-x-0.5
"
;
value
:
string
;
label
:
string
}
[]
;
:
string
)
=>
void
;
;
options
.
find
(
(
o
)
=>
o
.
value
===
value
)
;
"
>
{
selected
?.
label
??
value
}
</
span
>
ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground
"
/>
start
"
className
=
"
shadow-md
"
>
opt
)
=>
(
}
onChange
(
opt
.
value
)
}
.
value
===
value
?
"
bg-blue-500
"
:
"
bg-transparent
"
}
`
}
;
:
string
)
=>
void
;
;
;
;
setValue
]
=
useState
(
defaultValue
)
;
useRef
<
ReturnType
<
typeof
setTimeout
>
|
null
>
(
null
)
;
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
{
e
.
target
.
value
;
)
clearTimeout
(
timerRef
.
current
)
;
setTimeout
(
()
=>
onSave
(
v
)
,
800
)
;
string
|
null
|
undefined
)
:
boolean
{
&&
(
rules
.
includes
(
"
in:0,1
"
)
||
rules
.
includes
(
"
in:1,0
"
)))
;
=
{
;
|
null
;
;
|
null
;
;
|
null
;
;
|
null
;
(
{
params
}
:
{
params
:
Promise
<
{
id
:
string
}
>
})
{
useTranslations
(
"
panel.settings
"
)
;
useTranslations
(
"
panel.billing
"
)
;
useRouter
()
;
session
,
isPending
}
=
authClient
.
useSession
()
;
}
=
use
(
params
)
;
setReinstallConfirm
]
=
useState
(
false
)
;
setReinstallStep
]
=
useState
<
1
|
2
>
(
1
)
;
setSelectedEggId
]
=
useState
<
string
|
null
>
(
null
)
;
setExtendOpen
]
=
useState
(
false
)
;
setExtendPriceId
]
=
useState
<
string
|
null
>
(
null
)
;
useMediaQuery
(
"
max-sm
"
)
;
useTranslations
(
"
common
"
)
;
{
!
session
)
router
.
replace
(
"
/login
"
)
;
session
,
router
])
;
server
,
isPending
:
serverPending
}
=
useQuery
(
.
queryOptions
(
{
input
:
{
id
}
}
)
,
planEggs
=
[]
}
=
useQuery
(
.
queryOptions
(
{
input
:
{
serverId
:
id
}
}
)
,
useQuery
(
{
getServerSubscription
.
queryOptions
(
{
input
:
{
serverId
:
id
}
}
)
,
subscriptionId
)
,
{
subscriptionQuery
.
data
?.
availablePrices
[
0
])
{
.
availablePrices
[
0
]
.
id
)
;
,
extendPriceId
])
;
useMutation
(
{
extendSubscription
.
mutationOptions
()
,
}
,
{
orpc
.
billing
.
getServerSubscription
.
queryOptions
(
{
input
:
{
serverId
:
id
}
}
))
;
(
"
subscriptionExtendSuccess
"
))
;
Error
&
{
data
?
:
{
code
?
:
string
}
})
=>
{
?.
code
===
"
INSUFFICIENT_FUNDS
"
)
{
(
"
subscriptionExtendInsufficientFunds
"
))
;
.
message
)
;
useMutation
(
{
reinstall
.
mutationOptions
()
,
{
orpc
.
servers
.
get
.
queryOptions
(
{
input
:
{
id
}
}
))
;
(
"
reinstallTriggeredToast
"
))
;
useMutation
(
orpc
.
servers
.
rename
.
mutationOptions
(
{
{
orpc
.
servers
.
get
.
queryOptions
(
{
input
:
{
id
}
}
))
;
(
"
saved
"
))
;
useMutation
(
orpc
.
servers
.
updateStartupVariable
.
mutationOptions
(
{
{
orpc
.
servers
.
get
.
queryOptions
(
{
input
:
{
id
}
}
))
;
(
"
saved
"
))
;
useMutation
(
orpc
.
servers
.
updateDockerImage
.
mutationOptions
(
{
{
orpc
.
servers
.
get
.
queryOptions
(
{
input
:
{
id
}
}
))
;
(
"
saved
"
))
;
:
string
)
{
serverId
:
id
,
image
}
)
;
:
string
)
{
serverId
:
id
,
name
}
)
;
:
string
,
value
:
string
)
{
serverId
:
id
,
envVariable
,
value
}
)
;
!
session
)
return
<
Loader
/>
;
<
Loader
/>
;
server
?.
node
as
{
name
?
:
string
;
fqdn
?
:
string
;
daemonSFTP
?
:
number
}
|
undefined
;
{
??
"
-
"
,
??
2022
,
user
.
email
?.
split
(
"
@
"
)[
0
]
??
"
user
"
}
.
${
server
?.
uuidShort
??
""
}
`
,
:
string
)
{
writeText
(
text
)
;
(
server
?.
serverVariables
??
[])
as
ServerVariable
[]
;
server
?.
egg
as
{
name
?
:
string
;
dockerImages
?
:
string
|
null
}
|
undefined
;
{
tag
:
string
;
alias
:
string
}
[]
=
[]
;
JSON
.
parse
(
egg
?.
dockerImages
??
"
{}
"
)
as
Record
<
string
,
string
>
;
entries
(
parsed
)
.
map
(
(
[
alias
,
tag
]
)
=>
(
{
tag
,
alias
}
))
;
server
?.
image
??
""
;
(
"
>
egg
)
=>
{
selectedEggId
===
egg
.
id
;
}
setSelectedEggId
(
egg
.
id
)
}
:
"
border-border hover:border-foreground/30
"
,
"
>
size-3.5 text-muted-foreground
"
/>
"
>
{
egg
.
name
}
</
span
>
(
:
"
border-border
"
,
className
=
"
h-1.5 w-1.5 rounded-full bg-background
"
/>
}
(
"
>
"
>
mt-0.5 size-4 shrink-0 text-destructive
"
/>
"
>
{
t
(
"
reinstallDialogDescription
"
)
}
</
p
>
planEggs
.
length
>
0
&&
reinstallStep
===
1
?
(
"
selectEggTitle
"
)
}
</
DialogTitle
>
"
selectEggDescription
"
)
}
</
DialogDescription
>
outline
"
size
=
"
sm
"
onClick
=
{
closeReinstall
}
>
{
t
(
"
cancel
"
)
}
</
Button
>
sm
"
disabled
=
{
!
selectedEggId
}
onClick
=
{
()
=>
setReinstallStep
(
2
)
}
>
{
t
(
"
reinstallContinue
"
)
}
</
Button
>
"
reinstallDialogTitle
"
,
{
name
:
server
?.
name
??
""
}
)
}
</
DialogTitle
>
outline
"
size
=
"
sm
"
onClick
=
{
()
=>
planEggs
.
length
>
0
?
setReinstallStep
(
1
)
:
closeReinstall
()
}
>
}
}
reinstallMutation
.
mutate
(
{
serverId
:
id
,
eggId
:
selectedEggId
??
undefined
}
)
}
t
(
"
reinstalling
"
)
:
t
(
"
reinstall
"
)
}
planEggs
.
length
>
0
&&
reinstallStep
===
1
?
(
"
selectEggTitle
"
)
}
</
SheetTitle
>
"
selectEggDescription
"
)
}
</
SheetDescription
>
outline
"
size
=
"
sm
"
onClick
=
{
closeReinstall
}
>
{
t
(
"
cancel
"
)
}
</
Button
>
sm
"
disabled
=
{
!
selectedEggId
}
onClick
=
{
()
=>
setReinstallStep
(
2
)
}
>
{
t
(
"
reinstallContinue
"
)
}
</
Button
>
"
reinstallDialogTitle
"
,
{
name
:
server
?.
name
??
""
}
)
}
</
SheetTitle
>
outline
"
size
=
"
sm
"
onClick
=
{
()
=>
planEggs
.
length
>
0
?
setReinstallStep
(
1
)
:
closeReinstall
()
}
>
}
}
reinstallMutation
.
mutate
(
{
serverId
:
id
,
eggId
:
selectedEggId
??
undefined
}
)
}
t
(
"
reinstalling
"
)
:
t
(
"
reinstall
"
)
}
"
>
"
>
t
(
"
generalTitle
"
)
}
description
=
{
t
(
"
generalDescription
"
)
}
>
t
(
"
serverNameLabel
"
)
}
description
=
{
t
(
"
serverNameDescription
"
)
}
>
"
>
??
""
}
t
(
"
dockerImageLabel
"
)
}
description
=
{
t
(
"
dockerImageDescription
"
)
}
>
1
?
(
"
>
(
(
{
tag
,
alias
}
)
=>
(
{
value
:
tag
,
label
:
alias
||
tag
}
))
}
}
"
>
"
}
t
(
"
sftpTitle
"
)
}
description
=
{
t
(
"
sftpDescription
"
)
}
>
"
>
"
sftpHost
"
)
,
value
:
sftp
.
host
,
copy
:
sftp
.
host
}
,
"
sftpPort
"
)
,
value
:
String
(
sftp
.
port
)
,
copy
:
null
}
,
"
sftpUsername
"
)
,
value
:
sftp
.
username
,
copy
:
sftp
.
username
}
,
label
,
value
,
copy
:
copyVal
},
i
)
=>
(
}
className
=
{
`
px-4 py-3
${
i
<
2
?
"
border-b border-border sm:border-b-0 sm:border-r
"
:
""
}
`
}
>
"
>
{
label
}
</
p
>
"
>
"
>
{
value
}
</
span
>
copy
(
copyVal
)
}
h-3.5 w-3.5
"
/>
"
>
"
>
{
t
(
"
sftpConnectHint
"
)
}
</
p
>
window
.
open
(
`
sftp://
${
sftp
.
username
}
@
${
sftp
.
host
}
:
${
sftp
.
port
}
`
,
"
_self
"
)
}
}
t
(
"
startupTitle
"
)
}
description
=
{
t
(
"
startupDescription
"
)
}
>
"
>
"
>
{
t
(
"
startupCommandLabel
"
)
}
</
p
>
"
>
"
-
"
}
sv
)
=>
{
isBoolVariable
(
sv
.
variable
.
rules
)
;
sv
.
variableValue
??
sv
.
variable
.
defaultValue
??
"
0
"
;
.
envVariable
}
.
name
}
.
description
??
undefined
}
"
>
"
1
"
}
=>
saveVariable
(
sv
.
variable
.
envVariable
,
v
?
"
1
"
:
"
0
"
)
}
||
===
sv
.
variable
.
envVariable
)
userEditable
&&
(
"
>
{
t
(
"
readOnly
"
)
}
</
span
>
.
envVariable
}
.
name
}
.
description
??
undefined
}
"
>
?
(
=>
saveVariable
(
sv
.
variable
.
envVariable
,
v
)
}
.
defaultValue
??
undefined
}
userEditable
&&
(
"
>
{
t
(
"
readOnly
"
)
}
</
span
>
(
t
(
"
subscriptionTitle
"
)
}
description
=
{
t
(
"
subscriptionDescription
"
)
}
>
(
()
=>
{
subscriptionQuery
.
data
;
sub
.
currentPeriodEnd
?
new
Date
(
sub
.
currentPeriodEnd
)
:
null
;
periodEnd
?
(
periodEnd
.
getTime
()
-
Date
.
now
())
<
7
*
24
*
60
*
60
*
1000
:
false
;
(
cents
:
number
)
=>
(
undefined
,
{
style
:
"
currency
"
,
currency
:
sub
.
currency
,
minimumFractionDigits
:
2
}
)
.
format
(
cents
/
100
)
;
"
>
"
>
size-3.5 shrink-0 text-amber-500
"
/>
"
>
{
t
(
"
subscriptionExtendExpiringSoon
"
)
}
</
span
>
(
(
price
)
=>
{
extendPriceId
===
price
.
id
;
}
setExtendPriceId
(
price
.
id
)
}
:
"
border-border hover:border-foreground/30
"
,
"
>
{
tBilling
(
`
durations.
${
price
.
duration
}
`
)
}
</
span
>
"
>
"
>
{
formatCents
(
price
.
priceCents
)
}
</
span
>
(
:
"
border-border
"
,
className
=
"
h-1.5 w-1.5 rounded-full bg-background
"
/>
}
outline
"
size
=
"
sm
"
onClick
=
{
()
=>
setExtendOpen
(
false
)
}
>
{
t
(
"
cancel
"
)
}
</
Button
>
||
extendMutation
.
isPending
}
extendPriceId
&&
extendMutation
.
mutate
(
{
subscriptionId
:
sub
.
subscriptionId
,
priceId
:
extendPriceId
}
)
}
t
(
"
subscriptionExtending
"
)
:
t
(
"
subscriptionExtendConfirm
"
)
}
t
(
"
subscriptionExpiresLabel
"
)
}
>
"
>
className
=
"
size-3.5 text-amber-500
"
/>
}
(
"
text-sm font-medium
"
,
expiringSoon
?
"
text-amber-600
"
:
"
text-foreground
"
)
}
>
.
toLocaleDateString
(
undefined
,
{
year
:
"
numeric
"
,
month
:
"
short
"
,
day
:
"
numeric
"
}
)
:
"
-
"
}
t
(
"
subscriptionExtendLabel
"
)
}
description
=
{
t
(
"
subscriptionExtendDescription
"
)
}
>
setExtendOpen
(
true
)
}
}
extendOpen
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
setExtendOpen
(
false
)
;
}
}
>
bottom
"
showCloseButton
=
{
false
}
className
=
"
rounded-t-2xl
"
>
"
subscriptionExtendDialogTitle
"
)
}
</
SheetTitle
>
"
subscriptionExtendDialogDescription
"
)
}
</
SheetDescription
>
extendOpen
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
setExtendOpen
(
false
)
;
}
}
>
false
}
className
=
"
max-w-md
"
>
"
subscriptionExtendDialogTitle
"
)
}
</
DialogTitle
>
"
subscriptionExtendDialogDescription
"
)
}
</
DialogDescription
>
t
(
"
dangerZoneTitle
"
)
}
description
=
{
t
(
"
dangerZoneDescription
"
)
}
>
reinstallLabel
"
)
}
reinstallDescription
"
)
}
setReinstallConfirm
(
true
)
}
}
reinstallConfirm
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
closeReinstall
()
;
}
}
>
bottom
"
showCloseButton
=
{
false
}
className
=
"
rounded-t-2xl
"
>
reinstallConfirm
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
closeReinstall
()
;
}
}
>
false
}
className
=
"
max-w-md
"
>
"
>
"
>
Server
}
label
=
{
t
(
"
statServerId
"
)
}
>
"
>
{
server
?.
uuid
??
"
-
"
}
</
span
>
Globe
}
label
=
{
t
(
"
statNode
"
)
}
>
"
>
{
node
?.
name
??
"
-
"
}
</
span
>
Server
}
label
=
{
t
(
"
statEgg
"
)
}
>
"
>
{
(
server
?.
egg
as
{
name
?
:
string
}
|
undefined
)
?.
name
??
"
-
"
}
</
span
>
Terminal
}
label
=
{
t
(
"
statSftpHost
"
)
}
>
"
>
"
>
{
sftp
.
host
}
:
{
sftp
.
port
}
</
span
>
copy
(
`
${
sftp
.
host
}
:
${
sftp
.
port
}
`
)
}
h-3.5 w-3.5
"
/>