mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 17:50:13 +00:00
feat: server transfer rotation
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ export default tseslint.config(
|
|||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": "off",
|
||||||
indent: ["error", 4],
|
indent: ["error", 4],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1933
-20
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -10,6 +10,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -65,6 +66,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.37.0",
|
"@eslint/js": "^9.37.0",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^24.7.0",
|
"@types/node": "^24.7.0",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
@@ -74,11 +77,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.0",
|
"eslint-plugin-react-hooks": "^7.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.23",
|
"eslint-plugin-react-refresh": "^0.4.23",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "8.4.24",
|
"postcss": "8.4.24",
|
||||||
"swagger-typescript-api": "^13.2.15",
|
"swagger-typescript-api": "^13.2.15",
|
||||||
"tailwindcss": "3.4.19",
|
"tailwindcss": "3.4.19",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.0",
|
"typescript-eslint": "^8.46.0",
|
||||||
"vite": "^7.1.9"
|
"vite": "^7.1.9",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ModelBatchMoveServerForm,
|
ModelBatchMoveServerForm,
|
||||||
|
ModelBatchMoveServerResult,
|
||||||
ModelServer,
|
ModelServer,
|
||||||
ModelServerConfigForm,
|
ModelServerConfigForm,
|
||||||
ModelServerForm,
|
ModelServerForm,
|
||||||
@@ -16,8 +17,16 @@ export const deleteServer = async (id: number[]): Promise<void> => {
|
|||||||
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-delete/server", id)
|
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-delete/server", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const batchMoveServer = async (data: ModelBatchMoveServerForm): Promise<void> => {
|
// batchMoveServer kicks off one ServerTransfer per id and returns a per-id
|
||||||
return fetcher<void>(FetcherMethod.POST, "/api/v1/batch-move/server", data)
|
// result. The dashboard previously returned void from this endpoint; the new
|
||||||
|
// response shape carries the transfer ID for callers that want to subscribe
|
||||||
|
// to /ws/transfer for state changes, plus structured non-pending statuses
|
||||||
|
// (permission_denied, already_transferring, server_not_found, same_owner)
|
||||||
|
// that the UI can render without parsing prose error messages.
|
||||||
|
export const batchMoveServer = async (
|
||||||
|
data: ModelBatchMoveServerForm,
|
||||||
|
): Promise<ModelBatchMoveServerResult[]> => {
|
||||||
|
return fetcher<ModelBatchMoveServerResult[]>(FetcherMethod.POST, "/api/v1/batch-move/server", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const forceUpdateServer = async (id: number[]): Promise<ModelServerTaskResponse> => {
|
export const forceUpdateServer = async (id: number[]): Promise<ModelServerTaskResponse> => {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ModelServerTransfer } from "@/types"
|
||||||
|
|
||||||
|
import { FetcherMethod, fetcher } from "./api"
|
||||||
|
|
||||||
|
export const getServerTransfers = async (): Promise<ModelServerTransfer[]> => {
|
||||||
|
return fetcher<ModelServerTransfer[]>(FetcherMethod.GET, "/api/v1/transfer", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cancelServerTransfer = async (id: number): Promise<ModelServerTransfer> => {
|
||||||
|
return fetcher<ModelServerTransfer>(FetcherMethod.POST, `/api/v1/transfer/${id}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const retryServerTransfer = async (id: number): Promise<ModelServerTransfer> => {
|
||||||
|
return fetcher<ModelServerTransfer>(FetcherMethod.POST, `/api/v1/transfer/${id}/retry`)
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ import { ModelAlertRule } from "@/types"
|
|||||||
import { triggerModes } from "@/types"
|
import { triggerModes } from "@/types"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm, useWatch } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { KeyedMutator } from "swr"
|
import { KeyedMutator } from "swr"
|
||||||
@@ -50,13 +50,15 @@ interface AlertRuleCardProps {
|
|||||||
mutate: KeyedMutator<ModelAlertRule[]>
|
mutate: KeyedMutator<ModelAlertRule[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cycleUnitSchema = z.enum(["hour", "day", "week", "month", "year"])
|
||||||
|
|
||||||
const ruleSchema = z.object({
|
const ruleSchema = z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
min: z.number().optional(),
|
min: z.number().optional(),
|
||||||
max: z.number().optional(),
|
max: z.number().optional(),
|
||||||
cycle_start: z.string().optional(),
|
cycle_start: z.string().optional(),
|
||||||
cycle_interval: z.number().optional(),
|
cycle_interval: z.number().optional(),
|
||||||
cycle_unit: z.enum(["hour", "day", "week", "month", "year"]).optional(),
|
cycle_unit: cycleUnitSchema.optional(),
|
||||||
duration: z.number().optional(),
|
duration: z.number().optional(),
|
||||||
cover: z.number().int().min(0),
|
cover: z.number().int().min(0),
|
||||||
ignore: z.record(z.string(), z.boolean()).optional(),
|
ignore: z.record(z.string(), z.boolean()).optional(),
|
||||||
@@ -71,7 +73,7 @@ const alertRuleFormSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,28 +94,30 @@ const alertRuleFormSchema = z.object({
|
|||||||
export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) => {
|
export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
type AlertRuleFormData = z.infer<typeof alertRuleFormSchema>
|
type AlertRuleEntry = z.output<typeof ruleSchema>
|
||||||
|
type AlertRuleFormInput = z.input<typeof alertRuleFormSchema>
|
||||||
|
type AlertRuleFormData = z.output<typeof alertRuleFormSchema>
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<AlertRuleFormInput, unknown, AlertRuleFormData>({
|
||||||
resolver: zodResolver(alertRuleFormSchema) as any,
|
resolver: zodResolver(alertRuleFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
...data,
|
...data,
|
||||||
rules_raw: JSON.stringify(data.rules),
|
rules_raw: JSON.stringify(data.rules),
|
||||||
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
|
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
|
||||||
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
|
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: "",
|
name: "",
|
||||||
rules_raw: "",
|
rules_raw: "",
|
||||||
rules: [],
|
rules: [],
|
||||||
fail_trigger_tasks: [],
|
fail_trigger_tasks: [],
|
||||||
fail_trigger_tasks_raw: "",
|
fail_trigger_tasks_raw: "",
|
||||||
recover_trigger_tasks: [],
|
recover_trigger_tasks: [],
|
||||||
recover_trigger_tasks_raw: "",
|
recover_trigger_tasks_raw: "",
|
||||||
notification_group_id: 0,
|
notification_group_id: 0,
|
||||||
trigger_mode: 0,
|
trigger_mode: 0,
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -124,14 +128,16 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
// 结构化规则编辑状态:从已有数据或 rules_raw 初始化
|
// 结构化规则编辑状态:从已有数据或 rules_raw 初始化
|
||||||
const initialRules = (() => {
|
const initialRules = (() => {
|
||||||
try {
|
try {
|
||||||
if (data?.rules) return data.rules as any[]
|
if (data?.rules) return data.rules
|
||||||
const raw = form.getValues("rules_raw")
|
const raw = form.getValues("rules_raw")
|
||||||
return raw ? JSON.parse(raw) : []
|
if (!raw) return []
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
return z.array(ruleSchema).parse(parsed)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
const [rulesUI, setRulesUI] = useState<any[]>(initialRules)
|
const [rulesUI, setRulesUI] = useState<AlertRuleEntry[]>(initialRules)
|
||||||
|
|
||||||
// 同步到 rules_raw(提交仍走 JSON 字符串)
|
// 同步到 rules_raw(提交仍走 JSON 字符串)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -140,17 +146,22 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [rulesUI])
|
}, [form, rulesUI])
|
||||||
|
|
||||||
|
const rulesRaw = useWatch({ control: form.control, name: "rules_raw" })
|
||||||
|
|
||||||
const onSubmit = async (values: AlertRuleFormData) => {
|
const onSubmit = async (values: AlertRuleFormData) => {
|
||||||
values.rules = JSON.parse(values.rules_raw)
|
values.rules = z.array(ruleSchema).parse(JSON.parse(values.rules_raw))
|
||||||
values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number)
|
values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number)
|
||||||
values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number)
|
values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number)
|
||||||
const { rules_raw, ...requiredFields } = values
|
const requiredFields = { ...values }
|
||||||
|
delete (requiredFields as Record<string, unknown>).rules_raw
|
||||||
try {
|
try {
|
||||||
data?.id
|
if (data?.id) {
|
||||||
? await updateAlertRule(data.id, requiredFields)
|
await updateAlertRule(data.id, requiredFields)
|
||||||
: await createAlertRule(requiredFields)
|
} else {
|
||||||
|
await createAlertRule(requiredFields)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -185,7 +196,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit as any)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-2 my-2"
|
className="space-y-2 my-2"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -358,9 +369,9 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
...next[idx],
|
...next[idx],
|
||||||
min: e.target.value
|
min: e.target.value
|
||||||
? Number(
|
? Number(
|
||||||
e.target
|
e.target
|
||||||
.value,
|
.value,
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
setRulesUI(next)
|
setRulesUI(next)
|
||||||
@@ -381,9 +392,9 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
...next[idx],
|
...next[idx],
|
||||||
max: e.target.value
|
max: e.target.value
|
||||||
? Number(
|
? Number(
|
||||||
e.target
|
e.target
|
||||||
.value,
|
.value,
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
setRulesUI(next)
|
setRulesUI(next)
|
||||||
@@ -445,8 +456,8 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
try {
|
try {
|
||||||
return r.ignore
|
return r.ignore
|
||||||
? JSON.stringify(
|
? JSON.stringify(
|
||||||
r.ignore,
|
r.ignore,
|
||||||
)
|
)
|
||||||
: ""
|
: ""
|
||||||
} catch {
|
} catch {
|
||||||
return ""
|
return ""
|
||||||
@@ -457,8 +468,8 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
try {
|
try {
|
||||||
const obj = e.target.value
|
const obj = e.target.value
|
||||||
? JSON.parse(
|
? JSON.parse(
|
||||||
e.target.value,
|
e.target.value,
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
next[idx] = {
|
next[idx] = {
|
||||||
...next[idx],
|
...next[idx],
|
||||||
@@ -512,9 +523,9 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
cycle_interval: e.target
|
cycle_interval: e.target
|
||||||
.value
|
.value
|
||||||
? Number(
|
? Number(
|
||||||
e.target
|
e.target
|
||||||
.value,
|
.value,
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
setRulesUI(next)
|
setRulesUI(next)
|
||||||
@@ -531,7 +542,10 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
const next = [...rulesUI]
|
const next = [...rulesUI]
|
||||||
next[idx] = {
|
next[idx] = {
|
||||||
...next[idx],
|
...next[idx],
|
||||||
cycle_unit: val,
|
cycle_unit:
|
||||||
|
cycleUnitSchema.parse(
|
||||||
|
val,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
setRulesUI(next)
|
setRulesUI(next)
|
||||||
}}
|
}}
|
||||||
@@ -603,7 +617,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="resize-y"
|
className="resize-y"
|
||||||
value={form.watch("rules_raw")}
|
value={rulesRaw}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// 同步到结构化编辑器
|
// 同步到结构化编辑器
|
||||||
form.setValue("rules_raw", e.target.value, {
|
form.setValue("rules_raw", e.target.value, {
|
||||||
@@ -630,7 +644,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
|
|||||||
placeholder={t("Search")}
|
placeholder={t("Search")}
|
||||||
options={ngroupList}
|
options={ngroupList}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value.toString()}
|
defaultValue={String(field.value ?? "")}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
|
|||||||
const [toUserId, setToUserId] = useState<number | undefined>(undefined)
|
const [toUserId, setToUserId] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
|
let results
|
||||||
try {
|
try {
|
||||||
await batchMoveServer({
|
results = await batchMoveServer({
|
||||||
ids: serverIds,
|
ids: serverIds,
|
||||||
to_user: toUserId!,
|
to_user: toUserId!,
|
||||||
})
|
})
|
||||||
@@ -45,7 +46,30 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast(t("Done"))
|
|
||||||
|
// The backend now responds per-server; render a structured summary
|
||||||
|
// instead of "Done" so the operator can see which ids hit a snag
|
||||||
|
// (DisableCommandExecute / already_transferring / etc). Pending ids
|
||||||
|
// remain async — watching /ws/transfer or the Transfers page will
|
||||||
|
// surface their terminal state.
|
||||||
|
const pending = results.filter((r) => r.status === "pending").length
|
||||||
|
const denied = results.filter((r) => r.status === "permission_denied").length
|
||||||
|
const dup = results.filter((r) => r.status === "already_transferring").length
|
||||||
|
const missing = results.filter((r) => r.status === "server_not_found").length
|
||||||
|
const same = results.filter((r) => r.status === "same_owner").length
|
||||||
|
const tooOld = results.filter((r) => r.status === "agent_too_old").length
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
if (pending) parts.push(t("Transfer.PendingCount", { count: pending }))
|
||||||
|
if (denied) parts.push(t("Transfer.PermissionDeniedCount", { count: denied }))
|
||||||
|
if (dup) parts.push(t("Transfer.AlreadyTransferringCount", { count: dup }))
|
||||||
|
if (missing) parts.push(t("Transfer.ServerNotFoundCount", { count: missing }))
|
||||||
|
if (same) parts.push(t("Transfer.SameOwnerCount", { count: same }))
|
||||||
|
if (tooOld) parts.push(t("Transfer.AgentTooOldCount", { count: tooOld }))
|
||||||
|
|
||||||
|
toast(t("Transfer.BatchSubmitted"), {
|
||||||
|
description: parts.join(" · ") || t("Done"),
|
||||||
|
})
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-24
@@ -63,33 +63,34 @@ const cronFormSchema = z.object({
|
|||||||
notification_group_id: z.coerce.number().int(),
|
notification_group_id: z.coerce.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type CronFormData = z.infer<typeof cronFormSchema>
|
type CronFormInput = z.input<typeof cronFormSchema>
|
||||||
|
type CronFormData = z.output<typeof cronFormSchema>
|
||||||
|
|
||||||
export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const form = useForm<CronFormData>({
|
const form = useForm<CronFormInput, unknown, CronFormData>({
|
||||||
resolver: zodResolver(cronFormSchema as any),
|
resolver: zodResolver(cronFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
task_type: data.task_type ?? 0,
|
task_type: data.task_type ?? 0,
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
scheduler: data.scheduler ?? "",
|
scheduler: data.scheduler ?? "",
|
||||||
command: (data as any).command ?? "",
|
command: data.command ?? "",
|
||||||
servers: data.servers ?? [],
|
servers: data.servers ?? [],
|
||||||
cover: data.cover ?? 0,
|
cover: data.cover ?? 0,
|
||||||
push_successful: (data as any).push_successful ?? false,
|
push_successful: data.push_successful ?? false,
|
||||||
notification_group_id: data.notification_group_id ?? 0,
|
notification_group_id: data.notification_group_id ?? 0,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
task_type: 0,
|
task_type: 0,
|
||||||
name: "",
|
name: "",
|
||||||
scheduler: "",
|
scheduler: "",
|
||||||
command: "",
|
command: "",
|
||||||
servers: [],
|
servers: [],
|
||||||
cover: 0,
|
cover: 0,
|
||||||
push_successful: false,
|
push_successful: false,
|
||||||
notification_group_id: 0,
|
notification_group_id: 0,
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -99,7 +100,11 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
|||||||
|
|
||||||
const onSubmit = async (values: CronFormData) => {
|
const onSubmit = async (values: CronFormData) => {
|
||||||
try {
|
try {
|
||||||
data?.id ? await updateCron(data.id, values) : await createCron(values)
|
if (data?.id) {
|
||||||
|
await updateCron(data.id, values)
|
||||||
|
} else {
|
||||||
|
await createCron(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -267,7 +272,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
|||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
options={ngroupList}
|
options={ngroupList}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value.toString()}
|
defaultValue={String(field.value ?? "")}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -282,7 +287,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
|
|||||||
+58
-45
@@ -67,45 +67,46 @@ const ddnsFormSchema = z.object({
|
|||||||
webhook_headers: asOptionalField(z.string()),
|
webhook_headers: asOptionalField(z.string()),
|
||||||
})
|
})
|
||||||
|
|
||||||
type DDNSFormData = z.infer<typeof ddnsFormSchema>
|
type DDNSFormInput = z.input<typeof ddnsFormSchema>
|
||||||
|
type DDNSFormData = z.output<typeof ddnsFormSchema>
|
||||||
|
|
||||||
export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) => {
|
export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const form = useForm<DDNSFormData>({
|
const form = useForm<DDNSFormInput, unknown, DDNSFormData>({
|
||||||
resolver: zodResolver(ddnsFormSchema as any),
|
resolver: zodResolver(ddnsFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
max_retries: data.max_retries ?? 3,
|
max_retries: data.max_retries ?? 3,
|
||||||
enable_ipv4: (data as any).enable_ipv4 ?? false,
|
enable_ipv4: data.enable_ipv4 ?? false,
|
||||||
enable_ipv6: (data as any).enable_ipv6 ?? false,
|
enable_ipv6: data.enable_ipv6 ?? false,
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
provider: data.provider ?? "dummy",
|
provider: data.provider ?? "dummy",
|
||||||
domains: data.domains ?? [],
|
domains: data.domains ?? [],
|
||||||
domains_raw: conv.arrToStr(data.domains ?? []),
|
domains_raw: conv.arrToStr(data.domains ?? []),
|
||||||
access_id: (data as any).access_id ?? "",
|
access_id: data.access_id ?? "",
|
||||||
access_secret: (data as any).access_secret ?? "",
|
access_secret: data.access_secret ?? "",
|
||||||
webhook_url: (data as any).webhook_url ?? "",
|
webhook_url: data.webhook_url ?? "",
|
||||||
webhook_method: (data as any).webhook_method,
|
webhook_method: data.webhook_method,
|
||||||
webhook_request_type: (data as any).webhook_request_type,
|
webhook_request_type: data.webhook_request_type,
|
||||||
webhook_request_body: (data as any).webhook_request_body ?? "",
|
webhook_request_body: data.webhook_request_body ?? "",
|
||||||
webhook_headers: (data as any).webhook_headers ?? "",
|
webhook_headers: data.webhook_headers ?? "",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
enable_ipv4: false,
|
enable_ipv4: false,
|
||||||
enable_ipv6: false,
|
enable_ipv6: false,
|
||||||
name: "",
|
name: "",
|
||||||
provider: "dummy",
|
provider: "dummy",
|
||||||
domains: [],
|
domains: [],
|
||||||
domains_raw: "",
|
domains_raw: "",
|
||||||
access_id: "",
|
access_id: "",
|
||||||
access_secret: "",
|
access_secret: "",
|
||||||
webhook_url: "",
|
webhook_url: "",
|
||||||
webhook_method: undefined,
|
webhook_method: undefined,
|
||||||
webhook_request_type: undefined,
|
webhook_request_type: undefined,
|
||||||
webhook_request_body: "",
|
webhook_request_body: "",
|
||||||
webhook_headers: "",
|
webhook_headers: "",
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -116,7 +117,11 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
const onSubmit = async (values: DDNSFormData) => {
|
const onSubmit = async (values: DDNSFormData) => {
|
||||||
try {
|
try {
|
||||||
values.domains = conv.strToArr(values.domains_raw)
|
values.domains = conv.strToArr(values.domains_raw)
|
||||||
data?.id ? await updateDDNSProfile(data.id, values) : await createDDNSProfile(values)
|
if (data?.id) {
|
||||||
|
await updateDDNSProfile(data.id, values)
|
||||||
|
} else {
|
||||||
|
await createDDNSProfile(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -227,15 +232,23 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="max_retries"
|
name="max_retries"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const { value, ...fieldProps } = field
|
||||||
<FormLabel>{t("MaximumRetryAttempts")}</FormLabel>
|
return (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input type="number" placeholder="3" {...field} />
|
<FormLabel>{t("MaximumRetryAttempts")}</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input
|
||||||
</FormItem>
|
type="number"
|
||||||
)}
|
placeholder="3"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -351,7 +364,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
@@ -371,7 +384,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
|
|||||||
+83
-72
@@ -37,7 +37,7 @@ import {
|
|||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { Row, flexRender } from "@tanstack/react-table"
|
import { Row, flexRender } from "@tanstack/react-table"
|
||||||
import { File, Folder } from "lucide-react"
|
import { File, Folder } from "lucide-react"
|
||||||
import { HTMLAttributes, JSX, useEffect, useRef, useState } from "react"
|
import { HTMLAttributes, JSX, useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -59,6 +59,10 @@ interface FMProps {
|
|||||||
wsUrl: string
|
wsUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VirtualizedTableRowProps = HTMLAttributes<HTMLTableRowElement> & {
|
||||||
|
"data-index"?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
|
const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
for (let i = 0; i < a.length; i++) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
@@ -67,16 +71,12 @@ const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl, ...props }) => {
|
export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl, ...props }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const fmRef = useRef<HTMLDivElement>(null)
|
const fmRef = useRef<HTMLDivElement>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const tRef = useRef(t)
|
||||||
useEffect(() => {
|
tRef.current = t
|
||||||
return () => {
|
|
||||||
wsRef.current?.close()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [dOpen, setdOpen] = useState(false)
|
const [dOpen, setdOpen] = useState(false)
|
||||||
const [uOpen, setuOpen] = useState(false)
|
const [uOpen, setuOpen] = useState(false)
|
||||||
@@ -120,9 +120,8 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
]
|
]
|
||||||
|
|
||||||
const tableRowComponent = (rows: Row<FMEntry>[]) =>
|
const tableRowComponent = (rows: Row<FMEntry>[]) =>
|
||||||
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
|
function getTableRow(props: VirtualizedTableRowProps) {
|
||||||
// @ts-expect-error data-index is a valid attribute
|
const index = Number(props["data-index"])
|
||||||
const index = props["data-index"]
|
|
||||||
const row = rows[index]
|
const row = rows[index]
|
||||||
|
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
@@ -160,6 +159,65 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
|
||||||
|
switch (event.data.type) {
|
||||||
|
case FMWorkerOpcode.Error: {
|
||||||
|
console.error("Error from worker", event.data.error)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case FMWorkerOpcode.Progress: {
|
||||||
|
handleReady.current = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case FMWorkerOpcode.Result: {
|
||||||
|
handleReady.current = false
|
||||||
|
|
||||||
|
if (event.data.blob && event.data.fileName) {
|
||||||
|
const url = URL.createObjectURL(event.data.blob)
|
||||||
|
const anchor = document.createElement("a")
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = event.data.fileName
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstChunk.current = true
|
||||||
|
if (dOpen) setdOpen(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
worker.postMessage({
|
||||||
|
operation: 3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||||
|
}
|
||||||
|
}, [dOpen])
|
||||||
|
|
||||||
|
const [currentPath, setPath] = useState("")
|
||||||
|
const currentPathRef = useRef(currentPath)
|
||||||
|
currentPathRef.current = currentPath
|
||||||
|
|
||||||
|
const listFile = useCallback(() => {
|
||||||
|
const prefix = new Int8Array([FMOpcode.List])
|
||||||
|
const pathMsg = new TextEncoder().encode(currentPathRef.current)
|
||||||
|
|
||||||
|
const msg = new Int8Array(prefix.length + pathMsg.length)
|
||||||
|
msg.set(prefix)
|
||||||
|
msg.set(pathMsg, prefix.length)
|
||||||
|
|
||||||
|
wsRef.current?.send(msg)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The WebSocket initialization must not depend on listFile or currentPath,
|
||||||
|
// otherwise navigating directories triggers a disconnect and reconnect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = new URL(wsUrl, window.location.origin)
|
const url = new URL(wsUrl, window.location.origin)
|
||||||
url.protocol = url.protocol.replace("http", "ws")
|
url.protocol = url.protocol.replace("http", "ws")
|
||||||
@@ -174,8 +232,8 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
}
|
}
|
||||||
ws.onerror = (e) => {
|
ws.onerror = (e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast("Websocket" + " " + t("Error"), {
|
toast("Websocket" + " " + tRef.current("Error"), {
|
||||||
description: t("Results.UnExpectedError"),
|
description: tRef.current("Results.UnExpectedError"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ws.onmessage = async (e: MessageEvent<ArrayBufferLike>) => {
|
ws.onmessage = async (e: MessageEvent<ArrayBufferLike>) => {
|
||||||
@@ -186,7 +244,6 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
const errMsg = new TextDecoder("utf-8").decode(errBytes)
|
const errMsg = new TextDecoder("utf-8").decode(errBytes)
|
||||||
throw new Error(errMsg)
|
throw new Error(errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstChunk.current) {
|
if (firstChunk.current) {
|
||||||
if (arraysEqual(identifier, FMIdentifier.file)) {
|
if (arraysEqual(identifier, FMIdentifier.file)) {
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
@@ -204,7 +261,7 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
setuOpen(false)
|
setuOpen(false)
|
||||||
listFile()
|
listFile()
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t("Results.UnknownIdentifier"))
|
throw new Error(tRef.current("Results.UnknownIdentifier"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await waitForHandleReady()
|
await waitForHandleReady()
|
||||||
@@ -216,74 +273,27 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing received data:", error)
|
console.error("Error processing received data:", error)
|
||||||
toast("FM" + " " + t("Error"), {
|
toast("FM" + " " + tRef.current("Error"), {
|
||||||
description: t("Results.UnExpectedError"),
|
description: tRef.current("Results.UnExpectedError"),
|
||||||
})
|
})
|
||||||
setdOpen(false)
|
setdOpen(false)
|
||||||
setuOpen(false)
|
setuOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [wsUrl])
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
|
ws.close()
|
||||||
switch (event.data.type) {
|
if (wsRef.current === ws) {
|
||||||
case FMWorkerOpcode.Error: {
|
wsRef.current = null
|
||||||
console.error("Error from worker", event.data.error)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case FMWorkerOpcode.Progress: {
|
|
||||||
handleReady.current = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case FMWorkerOpcode.Result: {
|
|
||||||
handleReady.current = false
|
|
||||||
|
|
||||||
if (event.data.blob && event.data.fileName) {
|
|
||||||
const url = URL.createObjectURL(event.data.blob)
|
|
||||||
const anchor = document.createElement("a")
|
|
||||||
anchor.href = url
|
|
||||||
anchor.download = event.data.fileName
|
|
||||||
anchor.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
firstChunk.current = true
|
|
||||||
if (dOpen) setdOpen(false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [listFile, wsUrl])
|
||||||
|
|
||||||
const handleBeforeUnload = () => {
|
|
||||||
worker.postMessage({
|
|
||||||
operation: 3,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
|
||||||
}
|
|
||||||
}, [worker, dOpen])
|
|
||||||
|
|
||||||
const [currentPath, setPath] = useState("")
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
listFile()
|
listFile()
|
||||||
}
|
}
|
||||||
}, [wsRef.current, currentPath])
|
}, [currentPath, listFile])
|
||||||
|
|
||||||
const listFile = () => {
|
|
||||||
const prefix = new Int8Array([FMOpcode.List])
|
|
||||||
const pathMsg = new TextEncoder().encode(currentPath)
|
|
||||||
|
|
||||||
const msg = new Int8Array(prefix.length + pathMsg.length)
|
|
||||||
msg.set(prefix)
|
|
||||||
msg.set(pathMsg, prefix.length)
|
|
||||||
|
|
||||||
wsRef.current?.send(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadFile = (basename: string) => {
|
const downloadFile = (basename: string) => {
|
||||||
currentBasename.current = basename
|
currentBasename.current = basename
|
||||||
@@ -331,9 +341,10 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(formatPath(currentPath))
|
await copyToClipboard(formatPath(currentPath))
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const description = error instanceof Error ? error.message : t("Results.UnExpectedError")
|
||||||
toast("FM" + " " + t("Error"), {
|
toast("FM" + " " + t("Error"), {
|
||||||
description: error.message,
|
description,
|
||||||
})
|
})
|
||||||
console.error("copy error: ", error)
|
console.error("copy error: ", error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const pages = [
|
|||||||
{ href: "/dashboard/ddns", label: i18next.t("DDNS") },
|
{ href: "/dashboard/ddns", label: i18next.t("DDNS") },
|
||||||
{ href: "/dashboard/nat", label: i18next.t("NATT") },
|
{ href: "/dashboard/nat", label: i18next.t("NATT") },
|
||||||
{ href: "/dashboard/server-group", label: i18next.t("Group") },
|
{ href: "/dashboard/server-group", label: i18next.t("Group") },
|
||||||
|
{ href: "/dashboard/transfer", label: i18next.t("Transfer.Title") },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
@@ -248,6 +249,15 @@ export default function Header() {
|
|||||||
<Link to="/dashboard/server-group">{t("Group")}</Link>
|
<Link to="/dashboard/server-group">{t("Group")}</Link>
|
||||||
</NzNavigationMenuLink>
|
</NzNavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NzNavigationMenuLink
|
||||||
|
asChild
|
||||||
|
active={location.pathname === "/dashboard/transfer"}
|
||||||
|
className={navigationMenuTriggerStyle()}
|
||||||
|
>
|
||||||
|
<Link to="/dashboard/transfer">{t("Transfer.Title")}</Link>
|
||||||
|
</NzNavigationMenuLink>
|
||||||
|
</NavigationMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,8 +395,7 @@ const useInterval = (callback: () => void, delay?: number | null) => {
|
|||||||
function Overview() {
|
function Overview() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const profile = useMainStore((store) => store.profile)
|
const profile = useMainStore((store) => store.profile)
|
||||||
const timeOption = DateTime.TIME_SIMPLE
|
const timeOption = { ...DateTime.TIME_SIMPLE, hour12: true }
|
||||||
timeOption.hour12 = true
|
|
||||||
const [timeString, setTimeString] = useState(
|
const [timeString, setTimeString] = useState(
|
||||||
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
|
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -146,23 +146,23 @@ const generateCommand = (
|
|||||||
const env = envParts.join(" ")
|
const env = envParts.join(" ")
|
||||||
|
|
||||||
const envWinParts = [
|
const envWinParts = [
|
||||||
`$env:NZ_SERVER=\"${install_host}\";`,
|
`$env:NZ_SERVER="${install_host}";`,
|
||||||
`$env:NZ_TLS=\"${tls || false}\";`,
|
`$env:NZ_TLS="${tls || false}";`,
|
||||||
`$env:NZ_CLIENT_SECRET=\"${agent_secret}\";`,
|
`$env:NZ_CLIENT_SECRET="${agent_secret}";`,
|
||||||
]
|
]
|
||||||
if (uuid) envWinParts.push(`$env:NZ_UUID=\"${uuid}\";`)
|
if (uuid) envWinParts.push(`$env:NZ_UUID="${uuid}";`)
|
||||||
const env_win = envWinParts.join("")
|
const env_win = envWinParts.join("")
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case OSTypes.Linux:
|
case OSTypes.Linux:
|
||||||
case OSTypes.macOS: {
|
case OSTypes.macOS: {
|
||||||
return `curl -L https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.sh -o agent.sh && chmod +x agent.sh && env ${env} ./agent.sh`
|
return `curl -L https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.sh -o agent.sh && chmod +x agent.sh && env ${env} ./agent.sh`
|
||||||
}
|
}
|
||||||
case OSTypes.Windows: {
|
case OSTypes.Windows: {
|
||||||
return `${env_win} [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Ssl3 -bor [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12;set-ExecutionPolicy RemoteSigned;Invoke-WebRequest https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.ps1 -OutFile C:\install.ps1;powershell.exe C:\install.ps1`
|
return `${env_win} [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Ssl3 -bor [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12;set-ExecutionPolicy RemoteSigned;Invoke-WebRequest https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.ps1 -OutFile C:\\install.ps1;powershell.exe C:\\install.ps1`
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unknown OS: ${type}`)
|
throw new Error(`Unknown OS: ${type}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-26
@@ -46,27 +46,28 @@ const natFormSchema = z.object({
|
|||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type NatFormData = z.infer<typeof natFormSchema>
|
type NatFormInput = z.input<typeof natFormSchema>
|
||||||
|
type NatFormData = z.output<typeof natFormSchema>
|
||||||
|
|
||||||
export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const form = useForm<NatFormData>({
|
const form = useForm<NatFormInput, unknown, NatFormData>({
|
||||||
resolver: zodResolver(natFormSchema as any),
|
resolver: zodResolver(natFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
enabled: (data as any).enabled ?? false,
|
enabled: data.enabled ?? false,
|
||||||
server_id: data.server_id ?? 0,
|
server_id: data.server_id ?? 0,
|
||||||
host: data.host ?? "",
|
host: data.host ?? "",
|
||||||
domain: data.domain ?? "",
|
domain: data.domain ?? "",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: "",
|
name: "",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
server_id: 0,
|
server_id: 0,
|
||||||
host: "",
|
host: "",
|
||||||
domain: "",
|
domain: "",
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -76,7 +77,11 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
|||||||
|
|
||||||
const onSubmit = async (values: NatFormData) => {
|
const onSubmit = async (values: NatFormData) => {
|
||||||
try {
|
try {
|
||||||
data?.id ? await updateNAT(data.id, values) : await createNAT(values)
|
if (data?.id) {
|
||||||
|
await updateNAT(data.id, values)
|
||||||
|
} else {
|
||||||
|
await createNAT(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -119,15 +124,23 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="server_id"
|
name="server_id"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const { value, ...fieldProps } = field
|
||||||
<FormLabel>{t("Server")} ID</FormLabel>
|
return (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input type="number" placeholder="1" {...field} />
|
<FormLabel>{t("Server")} ID</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input
|
||||||
</FormItem>
|
type="number"
|
||||||
)}
|
placeholder="1"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -169,7 +182,7 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">{t("Enable")}</Label>
|
<Label className="text-sm">{t("Enable")}</Label>
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ export const NotificationGroupCard: React.FC<NotificationGroupCardProps> = ({ da
|
|||||||
resolver: zodResolver(notificationGroupFormSchema),
|
resolver: zodResolver(notificationGroupFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
name: data.group.name,
|
name: data.group.name,
|
||||||
notifications: data.notifications,
|
notifications: data.notifications,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: "",
|
name: "",
|
||||||
notifications: [],
|
notifications: [],
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -64,9 +64,11 @@ export const NotificationGroupCard: React.FC<NotificationGroupCardProps> = ({ da
|
|||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof notificationGroupFormSchema>) => {
|
const onSubmit = async (values: z.infer<typeof notificationGroupFormSchema>) => {
|
||||||
try {
|
try {
|
||||||
data?.group.id
|
if (data?.group.id) {
|
||||||
? await updateNotificationGroup(data.group.id, values)
|
await updateNotificationGroup(data.group.id, values)
|
||||||
: await createNotificationGroup(values)
|
} else {
|
||||||
|
await createNotificationGroup(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
|
|||||||
+36
-29
@@ -62,33 +62,36 @@ const notificationFormSchema = z.object({
|
|||||||
|
|
||||||
export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
type notificationFormData = z.infer<typeof notificationFormSchema>
|
type NotificationFormInput = z.input<typeof notificationFormSchema>
|
||||||
|
type NotificationFormData = z.output<typeof notificationFormSchema>
|
||||||
|
type NotificationDefaults = ModelNotification & Partial<Pick<NotificationFormData, "skip_check">>
|
||||||
|
const notificationDefaults: NotificationDefaults | undefined = data
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<NotificationFormInput, unknown, NotificationFormData>({
|
||||||
resolver: zodResolver(notificationFormSchema) as any,
|
resolver: zodResolver(notificationFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
url: data.url ?? "",
|
url: data.url ?? "",
|
||||||
request_method: data.request_method ?? 1,
|
request_method: data.request_method ?? 1,
|
||||||
request_type: data.request_type ?? 1,
|
request_type: data.request_type ?? 1,
|
||||||
request_header: data.request_header ?? "",
|
request_header: data.request_header ?? "",
|
||||||
request_body: data.request_body ?? "",
|
request_body: data.request_body ?? "",
|
||||||
verify_tls: (data as any).verify_tls ?? false,
|
verify_tls: data.verify_tls ?? false,
|
||||||
skip_check: (data as any).skip_check ?? false,
|
skip_check: notificationDefaults?.skip_check ?? false,
|
||||||
format_metric_units: (data as any).format_metric_units ?? false,
|
format_metric_units: data.format_metric_units ?? false,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: "",
|
name: "",
|
||||||
url: "",
|
url: "",
|
||||||
request_method: 1,
|
request_method: 1,
|
||||||
request_type: 1,
|
request_type: 1,
|
||||||
request_header: "",
|
request_header: "",
|
||||||
request_body: "",
|
request_body: "",
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
skip_check: false,
|
skip_check: false,
|
||||||
format_metric_units: false,
|
format_metric_units: false,
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -96,9 +99,13 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const onSubmit = async (values: notificationFormData) => {
|
const onSubmit = async (values: NotificationFormData) => {
|
||||||
try {
|
try {
|
||||||
data?.id ? await updateNotification(data.id, values) : await createNotification(values)
|
if (data?.id) {
|
||||||
|
await updateNotification(data.id, values)
|
||||||
|
} else {
|
||||||
|
await createNotification(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
@@ -127,7 +134,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit as any)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-2 my-2"
|
className="space-y-2 my-2"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -254,7 +261,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
@@ -274,7 +281,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
@@ -294,7 +301,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value === true}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">
|
<Label className="text-sm">
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const agentConfigSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ const agentConfigSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -129,7 +129,7 @@ export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (open) fetchData()
|
if (open) fetchData()
|
||||||
}, [open])
|
}, [open, sid, t])
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(agentConfigSchema) as any,
|
resolver: zodResolver(agentConfigSchema) as any,
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ export const ServerGroupCard: React.FC<ServerGroupCardProps> = ({ data, mutate }
|
|||||||
resolver: zodResolver(serverGroupFormSchema),
|
resolver: zodResolver(serverGroupFormSchema),
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
name: data.group.name,
|
name: data.group.name,
|
||||||
servers: data.servers,
|
servers: data.servers,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: "",
|
name: "",
|
||||||
servers: [],
|
servers: [],
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -64,9 +64,11 @@ export const ServerGroupCard: React.FC<ServerGroupCardProps> = ({ data, mutate }
|
|||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof serverGroupFormSchema>) => {
|
const onSubmit = async (values: z.infer<typeof serverGroupFormSchema>) => {
|
||||||
try {
|
try {
|
||||||
data?.group.id
|
if (data?.group.id) {
|
||||||
? await updateServerGroup(data.group.id, values)
|
await updateServerGroup(data.group.id, values)
|
||||||
: await createServerGroup(values)
|
} else {
|
||||||
|
await createServerGroup(values)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
|
|||||||
+12
-11
@@ -51,7 +51,7 @@ import { ModelServer } from "@/types"
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { HelpCircle } from "lucide-react"
|
import { HelpCircle } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm, useWatch } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { KeyedMutator } from "swr"
|
import { KeyedMutator } from "swr"
|
||||||
@@ -94,7 +94,7 @@ const serverFormSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -120,6 +120,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const enableDDNS = useWatch({ control: form.control, name: "enable_ddns" })
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -278,7 +279,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{form.watch("enable_ddns") ? (
|
{enableDDNS ? (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -467,7 +468,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
?.startDate
|
?.startDate
|
||||||
? new Date(
|
? new Date(
|
||||||
publicNoteObj.billingDataMod!.startDate!,
|
publicNoteObj.billingDataMod!.startDate!,
|
||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: "YYYY-MM-DD"}
|
: "YYYY-MM-DD"}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -492,7 +493,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
?.startDate
|
?.startDate
|
||||||
? new Date(
|
? new Date(
|
||||||
publicNoteObj.billingDataMod!.startDate!,
|
publicNoteObj.billingDataMod!.startDate!,
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onSelect={(d) => {
|
onSelect={(d) => {
|
||||||
@@ -559,17 +560,17 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
{publicNoteObj.billingDataMod
|
{publicNoteObj.billingDataMod
|
||||||
?.endDate
|
?.endDate
|
||||||
? publicNoteObj
|
? publicNoteObj
|
||||||
.billingDataMod
|
.billingDataMod
|
||||||
?.endDate ===
|
?.endDate ===
|
||||||
"0000-00-00T23:59:59+08:00"
|
"0000-00-00T23:59:59+08:00"
|
||||||
? t(
|
? t(
|
||||||
"PublicNote.NoExpiry",
|
"PublicNote.NoExpiry",
|
||||||
)
|
)
|
||||||
: new Date(
|
: new Date(
|
||||||
publicNoteObj
|
publicNoteObj
|
||||||
.billingDataMod
|
.billingDataMod
|
||||||
?.endDate as string,
|
?.endDate as string,
|
||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: "YYYY-MM-DD"}
|
: "YYYY-MM-DD"}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -600,7 +601,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
|
|||||||
publicNoteObj
|
publicNoteObj
|
||||||
.billingDataMod
|
.billingDataMod
|
||||||
?.endDate as string,
|
?.endDate as string,
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onSelect={(d) => {
|
onSelect={(d) => {
|
||||||
|
|||||||
+28
-25
@@ -80,28 +80,28 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
|
|||||||
resolver: zodResolver(serviceFormSchema) as any,
|
resolver: zodResolver(serviceFormSchema) as any,
|
||||||
defaultValues: data
|
defaultValues: data
|
||||||
? {
|
? {
|
||||||
...data,
|
...data,
|
||||||
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
|
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
|
||||||
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
|
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
|
||||||
skip_servers_raw: conv.recordToStrArr(data.skip_servers ? data.skip_servers : {}),
|
skip_servers_raw: conv.recordToStrArr(data.skip_servers ? data.skip_servers : {}),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
type: 1,
|
type: 1,
|
||||||
cover: 0,
|
cover: 0,
|
||||||
display_index: 0,
|
display_index: 0,
|
||||||
name: "",
|
name: "",
|
||||||
target: "",
|
target: "",
|
||||||
max_latency: 0.0,
|
max_latency: 0.0,
|
||||||
min_latency: 0.0,
|
min_latency: 0.0,
|
||||||
duration: 30,
|
duration: 30,
|
||||||
notification_group_id: 0,
|
notification_group_id: 0,
|
||||||
fail_trigger_tasks: [],
|
fail_trigger_tasks: [],
|
||||||
fail_trigger_tasks_raw: "",
|
fail_trigger_tasks_raw: "",
|
||||||
recover_trigger_tasks: [],
|
recover_trigger_tasks: [],
|
||||||
recover_trigger_tasks_raw: "",
|
recover_trigger_tasks_raw: "",
|
||||||
skip_servers: {},
|
skip_servers: {},
|
||||||
skip_servers_raw: [],
|
skip_servers_raw: [],
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
@@ -113,11 +113,14 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
|
|||||||
values.skip_servers = conv.arrToRecord(values.skip_servers_raw)
|
values.skip_servers = conv.arrToRecord(values.skip_servers_raw)
|
||||||
values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number)
|
values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number)
|
||||||
values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number)
|
values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number)
|
||||||
const { skip_servers_raw, ...requiredFields } = values
|
const requiredFields = { ...values }
|
||||||
|
delete (requiredFields as Record<string, unknown>).skip_servers_raw
|
||||||
try {
|
try {
|
||||||
data?.id
|
if (data?.id) {
|
||||||
? await updateService(data.id, requiredFields)
|
await updateService(data.id, requiredFields)
|
||||||
: await createService(requiredFields)
|
} else {
|
||||||
|
await createService(requiredFields)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast(t("Error"), {
|
toast(t("Error"), {
|
||||||
|
|||||||
+56
-47
@@ -14,7 +14,15 @@ import { FitAddon } from "@xterm/addon-fit"
|
|||||||
import { Terminal } from "@xterm/xterm"
|
import { Terminal } from "@xterm/xterm"
|
||||||
import "@xterm/xterm/css/xterm.css"
|
import "@xterm/xterm/css/xterm.css"
|
||||||
import { Terminal as TerminalIcon } from "lucide-react"
|
import { Terminal as TerminalIcon } from "lucide-react"
|
||||||
import { JSX, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"
|
import {
|
||||||
|
JSX,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -28,7 +36,7 @@ interface XtermProps {
|
|||||||
setClose: React.Dispatch<React.SetStateAction<boolean>>
|
setClose: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
|
export const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
|
||||||
({ wsUrl, setClose, ...props }, ref) => {
|
({ wsUrl, setClose, ...props }, ref) => {
|
||||||
const terminalIdRef = useRef<HTMLDivElement>(null)
|
const terminalIdRef = useRef<HTMLDivElement>(null)
|
||||||
const terminalRef = useRef<Terminal | null>(null)
|
const terminalRef = useRef<Terminal | null>(null)
|
||||||
@@ -43,42 +51,10 @@ const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElem
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
wsRef.current?.close()
|
|
||||||
terminalRef.current?.dispose()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
terminalRef.current = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontSize: 16,
|
|
||||||
})
|
|
||||||
const url = new URL(wsUrl, window.location.origin)
|
|
||||||
url.protocol = url.protocol.replace("http", "ws")
|
|
||||||
const ws = new WebSocket(url)
|
|
||||||
wsRef.current = ws
|
|
||||||
ws.binaryType = "arraybuffer"
|
|
||||||
ws.onopen = () => {
|
|
||||||
onResize()
|
|
||||||
}
|
|
||||||
ws.onclose = () => {
|
|
||||||
terminalRef.current?.dispose()
|
|
||||||
setClose(true)
|
|
||||||
}
|
|
||||||
ws.onerror = (e) => {
|
|
||||||
console.error(e)
|
|
||||||
toast("Websocket error", {
|
|
||||||
description: "View console for details.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [wsUrl])
|
|
||||||
|
|
||||||
const fitAddon = useRef(new FitAddon()).current
|
const fitAddon = useRef(new FitAddon()).current
|
||||||
const sendResize = useRef(false)
|
const sendResize = useRef(false)
|
||||||
|
|
||||||
const doResize = () => {
|
const doResize = useCallback(() => {
|
||||||
if (!terminalIdRef.current) return
|
if (!terminalIdRef.current) return
|
||||||
|
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
@@ -100,9 +76,9 @@ const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElem
|
|||||||
|
|
||||||
wsRef.current?.send(msg)
|
wsRef.current?.send(msg)
|
||||||
}
|
}
|
||||||
}
|
}, [fitAddon])
|
||||||
|
|
||||||
const onResize = async () => {
|
const onResize = useCallback(async () => {
|
||||||
if (sendResize.current) return
|
if (sendResize.current) return
|
||||||
|
|
||||||
sendResize.current = true
|
sendResize.current = true
|
||||||
@@ -114,22 +90,55 @@ const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElem
|
|||||||
} finally {
|
} finally {
|
||||||
sendResize.current = false
|
sendResize.current = false
|
||||||
}
|
}
|
||||||
}
|
}, [doResize])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wsRef.current || !terminalIdRef.current || !terminalRef.current) return
|
const container = terminalIdRef.current
|
||||||
const attachAddon = new AttachAddon(wsRef.current)
|
if (!container) return
|
||||||
terminalRef.current.loadAddon(attachAddon)
|
|
||||||
terminalRef.current.loadAddon(fitAddon)
|
const terminal = new Terminal({
|
||||||
terminalRef.current.open(terminalIdRef.current)
|
cursorBlink: true,
|
||||||
|
fontSize: 16,
|
||||||
|
})
|
||||||
|
const url = new URL(wsUrl, window.location.origin)
|
||||||
|
url.protocol = url.protocol.replace("http", "ws")
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
ws.binaryType = "arraybuffer"
|
||||||
|
|
||||||
|
terminalRef.current = terminal
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
const attachAddon = new AttachAddon(ws)
|
||||||
|
terminal.loadAddon(attachAddon)
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
terminal.open(container)
|
||||||
window.addEventListener("resize", onResize)
|
window.addEventListener("resize", onResize)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
onResize()
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
terminal.dispose()
|
||||||
|
setClose(true)
|
||||||
|
}
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error(e)
|
||||||
|
toast("Websocket error", {
|
||||||
|
description: "View console for details.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", onResize)
|
window.removeEventListener("resize", onResize)
|
||||||
if (wsRef.current) {
|
ws.onopen = null
|
||||||
wsRef.current.close()
|
ws.onclose = null
|
||||||
}
|
ws.onerror = null
|
||||||
|
ws.close()
|
||||||
|
terminal.dispose()
|
||||||
|
if (wsRef.current === ws) wsRef.current = null
|
||||||
|
if (terminalRef.current === terminal) terminalRef.current = null
|
||||||
}
|
}
|
||||||
}, [wsRef.current, terminalRef.current, terminalIdRef.current])
|
}, [fitAddon, onResize, setClose, wsUrl])
|
||||||
|
|
||||||
return <div ref={terminalIdRef} {...props} />
|
return <div ref={terminalIdRef} {...props} />
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ const FormField = <
|
|||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
>({
|
>({
|
||||||
...props
|
...props
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
return (
|
return (
|
||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
<Controller {...props} />
|
<Controller {...props} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { InputHTMLAttributes, forwardRef } from "react"
|
import { InputHTMLAttributes, forwardRef } from "react"
|
||||||
|
|
||||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
export type InputProps = InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -53,60 +53,60 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((props,
|
|||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (props.icon) {
|
switch (props.icon) {
|
||||||
case "clipboard": {
|
case "clipboard": {
|
||||||
return <Clipboard />
|
return <Clipboard />
|
||||||
}
|
}
|
||||||
case "check": {
|
case "check": {
|
||||||
return <Check />
|
return <Check />
|
||||||
}
|
}
|
||||||
case "edit": {
|
case "edit": {
|
||||||
return <Edit2 />
|
return <Edit2 />
|
||||||
}
|
}
|
||||||
case "trash": {
|
case "trash": {
|
||||||
return <Trash2 />
|
return <Trash2 />
|
||||||
}
|
}
|
||||||
case "plus": {
|
case "plus": {
|
||||||
return <Plus />
|
return <Plus />
|
||||||
}
|
}
|
||||||
case "terminal": {
|
case "terminal": {
|
||||||
return <Terminal />
|
return <Terminal />
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
return <CircleArrowUp />
|
return <CircleArrowUp />
|
||||||
}
|
}
|
||||||
case "folder-closed": {
|
case "folder-closed": {
|
||||||
return <FolderClosed />
|
return <FolderClosed />
|
||||||
}
|
}
|
||||||
case "play": {
|
case "play": {
|
||||||
return <Play />
|
return <Play />
|
||||||
}
|
}
|
||||||
case "download": {
|
case "download": {
|
||||||
return <Download />
|
return <Download />
|
||||||
}
|
}
|
||||||
case "upload": {
|
case "upload": {
|
||||||
return <Upload />
|
return <Upload />
|
||||||
}
|
}
|
||||||
case "menu": {
|
case "menu": {
|
||||||
return <Menu />
|
return <Menu />
|
||||||
}
|
}
|
||||||
case "ban": {
|
case "ban": {
|
||||||
return <BanIcon />
|
return <BanIcon />
|
||||||
}
|
}
|
||||||
case "expand": {
|
case "expand": {
|
||||||
return <Expand />
|
return <Expand />
|
||||||
}
|
}
|
||||||
case "cog": {
|
case "cog": {
|
||||||
return <CogIcon />
|
return <CogIcon />
|
||||||
}
|
}
|
||||||
case "minus": {
|
case "minus": {
|
||||||
return <Minus />
|
return <Minus />
|
||||||
}
|
}
|
||||||
case "user-pen": {
|
case "user-pen": {
|
||||||
return <UserPen />
|
return <UserPen />
|
||||||
}
|
}
|
||||||
case "more": {
|
case "more": {
|
||||||
return <MoreHorizontal />
|
return <MoreHorizontal />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
|||||||
animation = 0,
|
animation = 0,
|
||||||
maxCount = 3,
|
maxCount = 3,
|
||||||
modalPopover = false,
|
modalPopover = false,
|
||||||
asChild = false,
|
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -158,9 +158,9 @@ export function DataTable<TData, TValue>({
|
|||||||
{...{
|
{...{
|
||||||
style: header.column.getCanSort()
|
style: header.column.getCanSort()
|
||||||
? {
|
? {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
onClick:
|
onClick:
|
||||||
header.column.getToggleSortingHandler(),
|
header.column.getToggleSortingHandler(),
|
||||||
|
|||||||
+10
-10
@@ -1,6 +1,6 @@
|
|||||||
import { getProfile, login as loginRequest } from "@/api/user"
|
import { getProfile, login as loginRequest } from "@/api/user"
|
||||||
import { AuthContextProps } from "@/types"
|
import { AuthContextProps } from "@/types"
|
||||||
import { createContext, useContext, useEffect, useMemo } from "react"
|
import { createContext, useCallback, useContext, useEffect, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -25,15 +25,15 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
user.role = user.role || 0
|
user.role = user.role || 0
|
||||||
setProfile(user)
|
setProfile(user)
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
setProfile(undefined)
|
setProfile(undefined)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [])
|
}, [setProfile])
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const login = async (username: string, password: string) => {
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
await loginRequest(username, password)
|
await loginRequest(username, password)
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
@@ -48,9 +48,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
toast(msg || t("NetworkError"))
|
toast(msg || t("NetworkError"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, [navigate, setProfile, t])
|
||||||
|
|
||||||
const loginOauth2 = async () => {
|
const loginOauth2 = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
user.role = user.role || 0
|
user.role = user.role || 0
|
||||||
@@ -61,9 +61,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
window.history.replaceState({}, document.title, window.location.pathname)
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
}
|
}
|
||||||
}
|
}, [navigate, setProfile])
|
||||||
|
|
||||||
const logout = () => {
|
const logout = useCallback(() => {
|
||||||
document.cookie.split(";").forEach(function (c) {
|
document.cookie.split(";").forEach(function (c) {
|
||||||
document.cookie = c
|
document.cookie = c
|
||||||
.replace(/^ +/, "")
|
.replace(/^ +/, "")
|
||||||
@@ -71,7 +71,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
})
|
})
|
||||||
setProfile(undefined)
|
setProfile(undefined)
|
||||||
navigate("/dashboard/login", { replace: true })
|
navigate("/dashboard/login", { replace: true })
|
||||||
}
|
}, [navigate, setProfile])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -80,7 +80,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
loginOauth2,
|
loginOauth2,
|
||||||
logout,
|
logout,
|
||||||
}),
|
}),
|
||||||
[profile],
|
[profile, login, loginOauth2, logout],
|
||||||
)
|
)
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-14
@@ -1,19 +1,16 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useCallback, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
export function useMediaQuery(query: string) {
|
export function useMediaQuery(query: string) {
|
||||||
const [value, setValue] = useState(false)
|
const subscribe = useCallback(
|
||||||
|
(callback: () => void) => {
|
||||||
|
const result = matchMedia(query)
|
||||||
|
result.addEventListener("change", callback)
|
||||||
|
return () => result.removeEventListener("change", callback)
|
||||||
|
},
|
||||||
|
[query],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
const getSnapshot = useCallback(() => matchMedia(query).matches, [query])
|
||||||
function onChange(event: MediaQueryListEvent) {
|
|
||||||
setValue(event.matches)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = matchMedia(query)
|
return useSyncExternalStore(subscribe, getSnapshot, () => false)
|
||||||
result.addEventListener("change", onChange)
|
|
||||||
setValue(result.matches)
|
|
||||||
|
|
||||||
return () => result.removeEventListener("change", onChange)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({
|
|||||||
setNotifier(undefined)
|
setNotifier(undefined)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [location.pathname])
|
}, [location.pathname, setNotifier, setNotifierGroup, withNotifier, withNotifierGroup])
|
||||||
|
|
||||||
const value: NotificationContextProps = useMemo(
|
const value: NotificationContextProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const ServerProvider: React.FC<ServerProviderProps> = ({
|
|||||||
setServer(undefined)
|
setServer(undefined)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [location.pathname])
|
}, [location.pathname, setServer, setServerGroup, withServer, withServerGroup])
|
||||||
|
|
||||||
const value: ServerContextProps = useMemo(
|
const value: ServerContextProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
+11
-10
@@ -5,18 +5,19 @@ import { useEffect, useState } from "react"
|
|||||||
export default function useTerminal(serverId?: number) {
|
export default function useTerminal(serverId?: number) {
|
||||||
const [terminal, setTerminal] = useState<ModelCreateTerminalResponse | null>(null)
|
const [terminal, setTerminal] = useState<ModelCreateTerminalResponse | null>(null)
|
||||||
|
|
||||||
async function fetchTerminal() {
|
|
||||||
try {
|
|
||||||
const response = await createTerminal(serverId!)
|
|
||||||
setTerminal(response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch terminal:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverId) return
|
if (!serverId) return
|
||||||
fetchTerminal()
|
let cancelled = false
|
||||||
|
createTerminal(serverId)
|
||||||
|
.then((response) => {
|
||||||
|
if (!cancelled) setTerminal(response)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to fetch terminal:", error)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [serverId])
|
}, [serverId])
|
||||||
|
|
||||||
return terminal
|
return terminal
|
||||||
|
|||||||
+37
-37
@@ -15,53 +15,53 @@ onmessage = async function (event) {
|
|||||||
const { operation, arrayBuffer, fileName } = event.data
|
const { operation, arrayBuffer, fileName } = event.data
|
||||||
|
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
case Operation.WriteHeader: {
|
case Operation.WriteHeader: {
|
||||||
const dataView = new DataView(arrayBuffer)
|
const dataView = new DataView(arrayBuffer)
|
||||||
expectedLength = Number(dataView.getBigUint64(4, false))
|
expectedLength = Number(dataView.getBigUint64(4, false))
|
||||||
receivedLength = 0
|
receivedLength = 0
|
||||||
|
|
||||||
// Create a new temporary file
|
// Create a new temporary file
|
||||||
root = await navigator.storage.getDirectory()
|
root = await navigator.storage.getDirectory()
|
||||||
draftHandle = await root.getFileHandle(fileName, { create: true })
|
draftHandle = await root.getFileHandle(fileName, { create: true })
|
||||||
accessHandle = await draftHandle.createSyncAccessHandle()
|
accessHandle = await draftHandle.createSyncAccessHandle()
|
||||||
|
|
||||||
// Inform that file handle is created
|
// Inform that file handle is created
|
||||||
const dataChunk = arrayBuffer.slice(12)
|
const dataChunk = arrayBuffer.slice(12)
|
||||||
receivedLength += dataChunk.byteLength
|
receivedLength += dataChunk.byteLength
|
||||||
accessHandle.write(dataChunk, { at: 0 })
|
accessHandle.write(dataChunk, { at: 0 })
|
||||||
const progress = "got handle"
|
const progress = "got handle"
|
||||||
postMessage({ type: 1, progress: progress })
|
postMessage({ type: 1, progress: progress })
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
case Operation.WriteChunks: {
|
||||||
|
if (!accessHandle) {
|
||||||
|
throw new Error("accessHandle is undefined")
|
||||||
}
|
}
|
||||||
case Operation.WriteChunks: {
|
|
||||||
if (!accessHandle) {
|
|
||||||
throw new Error("accessHandle is undefined")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessHandle.write(arrayBuffer, { at: receivedLength })
|
accessHandle.write(arrayBuffer, { at: receivedLength })
|
||||||
receivedLength += arrayBuffer.byteLength
|
receivedLength += arrayBuffer.byteLength
|
||||||
|
|
||||||
if (receivedLength === expectedLength) {
|
if (receivedLength === expectedLength) {
|
||||||
accessHandle.flush()
|
accessHandle.flush()
|
||||||
accessHandle.close()
|
accessHandle.close()
|
||||||
|
|
||||||
const fileBlob = await draftHandle.getFile()
|
const fileBlob = await draftHandle.getFile()
|
||||||
const blob = new Blob([fileBlob], { type: "application/octet-stream" })
|
const blob = new Blob([fileBlob], { type: "application/octet-stream" })
|
||||||
|
|
||||||
postMessage({ type: 2, blob: blob, fileName: fileName })
|
postMessage({ type: 2, blob: blob, fileName: fileName })
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
case Operation.DeleteFiles: {
|
break
|
||||||
for await (const [name, handle] of root.entries()) {
|
}
|
||||||
if (handle.kind === "file") {
|
case Operation.DeleteFiles: {
|
||||||
await root.removeEntry(name)
|
for await (const [name, handle] of root.entries()) {
|
||||||
} else if (handle.kind === "directory") {
|
if (handle.kind === "file") {
|
||||||
await root.removeEntry(name, { recursive: true })
|
await root.removeEntry(name)
|
||||||
}
|
} else if (handle.kind === "directory") {
|
||||||
|
await root.removeEntry(name, { recursive: true })
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) postMessage({ type: 0, error: error.message })
|
if (error instanceof Error) postMessage({ type: 0, error: error.message })
|
||||||
|
|||||||
@@ -136,6 +136,9 @@
|
|||||||
"EditServerGroup": "Edit Server Group",
|
"EditServerGroup": "Edit Server Group",
|
||||||
"CreateServerGroup": "Create Server Group",
|
"CreateServerGroup": "Create Server Group",
|
||||||
"User": "User",
|
"User": "User",
|
||||||
|
"Owner": "Owner",
|
||||||
|
"GlobalAgent": "Global Agent",
|
||||||
|
"UnknownUser": "Unknown user (#{{id}})",
|
||||||
"WAF": "Web Application Firewall",
|
"WAF": "Web Application Firewall",
|
||||||
"SiteName": "Site Name",
|
"SiteName": "Site Name",
|
||||||
"DashboardOriginalHost": "Agent connecting address [domain name/IP:port]",
|
"DashboardOriginalHost": "Agent connecting address [domain name/IP:port]",
|
||||||
@@ -255,5 +258,35 @@
|
|||||||
"ClipboardWriteFailed": "Clipboard write failed",
|
"ClipboardWriteFailed": "Clipboard write failed",
|
||||||
"PastedFromClipboard": "Pasted from clipboard",
|
"PastedFromClipboard": "Pasted from clipboard",
|
||||||
"ClipboardReadFailed": "Clipboard read failed",
|
"ClipboardReadFailed": "Clipboard read failed",
|
||||||
"FormatMetricUnits": "Format Metric Units"
|
"FormatMetricUnits": "Format Metric Units",
|
||||||
|
"Status": "Status",
|
||||||
|
"ServerID": "Server ID",
|
||||||
|
"CreatedAt": "Created At",
|
||||||
|
"BatchMoveServer": "Batch Move Server Owner",
|
||||||
|
"Servers": "Servers",
|
||||||
|
"ToUser": "To User",
|
||||||
|
"Move": "Move",
|
||||||
|
"Transfer": {
|
||||||
|
"Title": "Server Transfers",
|
||||||
|
"PageHint": "Tracks ownership transfers initiated from the batch-move action. Pending entries can be cancelled; terminal entries can be retried.",
|
||||||
|
"From": "From",
|
||||||
|
"To": "To",
|
||||||
|
"Initiator": "Initiator",
|
||||||
|
"LastError": "Last Error",
|
||||||
|
"Retry": "Retry",
|
||||||
|
"CancelRequested": "Cancellation requested",
|
||||||
|
"RetryRequested": "Retry requested",
|
||||||
|
"BatchSubmitted": "Batch transfer submitted",
|
||||||
|
"PendingCount": "{{count}} pending",
|
||||||
|
"PermissionDeniedCount": "{{count}} permission denied",
|
||||||
|
"AlreadyTransferringCount": "{{count}} already transferring",
|
||||||
|
"ServerNotFoundCount": "{{count}} not found",
|
||||||
|
"SameOwnerCount": "{{count}} already owned by target",
|
||||||
|
"AgentTooOldCount": "{{count}} agent too old (upgrade required)",
|
||||||
|
"StatusPending": "Pending",
|
||||||
|
"StatusVerified": "Verified",
|
||||||
|
"StatusFailed": "Failed",
|
||||||
|
"StatusTimeout": "Timeout",
|
||||||
|
"StatusCancelled": "Cancelled"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,9 @@
|
|||||||
"EditNotifierGroup": "编辑通知分组",
|
"EditNotifierGroup": "编辑通知分组",
|
||||||
"CreateNotifierGroup": "创建通知分组",
|
"CreateNotifierGroup": "创建通知分组",
|
||||||
"User": "用户",
|
"User": "用户",
|
||||||
|
"Owner": "所属用户",
|
||||||
|
"GlobalAgent": "全局 Agent",
|
||||||
|
"UnknownUser": "未知用户 (#{{id}})",
|
||||||
"WAF": "Web应用防火墙",
|
"WAF": "Web应用防火墙",
|
||||||
"SiteName": "站点名称",
|
"SiteName": "站点名称",
|
||||||
"Language": "语言",
|
"Language": "语言",
|
||||||
@@ -255,5 +258,35 @@
|
|||||||
"ClipboardWriteFailed": "无法写入剪贴板",
|
"ClipboardWriteFailed": "无法写入剪贴板",
|
||||||
"PastedFromClipboard": "已从剪贴板粘贴",
|
"PastedFromClipboard": "已从剪贴板粘贴",
|
||||||
"ClipboardReadFailed": "无法读取剪贴板",
|
"ClipboardReadFailed": "无法读取剪贴板",
|
||||||
"FormatMetricUnits": "格式化数据单位"
|
"FormatMetricUnits": "格式化数据单位",
|
||||||
|
"Status": "状态",
|
||||||
|
"ServerID": "服务器 ID",
|
||||||
|
"CreatedAt": "创建时间",
|
||||||
|
"BatchMoveServer": "批量转移服务器所有者",
|
||||||
|
"Servers": "服务器",
|
||||||
|
"ToUser": "目标用户",
|
||||||
|
"Move": "转移",
|
||||||
|
"Transfer": {
|
||||||
|
"Title": "服务器转移",
|
||||||
|
"PageHint": "跟踪由批量转移操作发起的所有权转移。待处理条目可以取消;已终止条目可以重试。",
|
||||||
|
"From": "源用户",
|
||||||
|
"To": "目标用户",
|
||||||
|
"Initiator": "发起者",
|
||||||
|
"LastError": "最近错误",
|
||||||
|
"Retry": "重试",
|
||||||
|
"CancelRequested": "已请求取消",
|
||||||
|
"RetryRequested": "已请求重试",
|
||||||
|
"BatchSubmitted": "批量转移已提交",
|
||||||
|
"PendingCount": "{{count}} 个待处理",
|
||||||
|
"PermissionDeniedCount": "{{count}} 个权限不足",
|
||||||
|
"AlreadyTransferringCount": "{{count}} 个正在转移中",
|
||||||
|
"ServerNotFoundCount": "{{count}} 个未找到",
|
||||||
|
"SameOwnerCount": "{{count}} 个已属于目标用户",
|
||||||
|
"AgentTooOldCount": "{{count}} 个 agent 版本过低(需升级)",
|
||||||
|
"StatusPending": "待处理",
|
||||||
|
"StatusVerified": "已验证",
|
||||||
|
"StatusFailed": "已失败",
|
||||||
|
"StatusTimeout": "已超时",
|
||||||
|
"StatusCancelled": "已取消"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,9 @@
|
|||||||
"EditNotifierGroup": "編輯通知分組",
|
"EditNotifierGroup": "編輯通知分組",
|
||||||
"CreateNotifierGroup": "建立通知分組",
|
"CreateNotifierGroup": "建立通知分組",
|
||||||
"User": "使用者",
|
"User": "使用者",
|
||||||
|
"Owner": "所屬使用者",
|
||||||
|
"GlobalAgent": "全域 Agent",
|
||||||
|
"UnknownUser": "未知使用者 (#{{id}})",
|
||||||
"WAF": "Web應用防火牆",
|
"WAF": "Web應用防火牆",
|
||||||
"SiteName": "網站名稱",
|
"SiteName": "網站名稱",
|
||||||
"Language": "語言",
|
"Language": "語言",
|
||||||
@@ -186,5 +189,35 @@
|
|||||||
"Option": "選項",
|
"Option": "選項",
|
||||||
"Value": "值",
|
"Value": "值",
|
||||||
"Preview": "預覽",
|
"Preview": "預覽",
|
||||||
"FormatMetricUnits": "格式化資料單位"
|
"FormatMetricUnits": "格式化資料單位",
|
||||||
|
"Status": "狀態",
|
||||||
|
"ServerID": "伺服器 ID",
|
||||||
|
"CreatedAt": "建立時間",
|
||||||
|
"BatchMoveServer": "批次轉移伺服器擁有者",
|
||||||
|
"Servers": "伺服器",
|
||||||
|
"ToUser": "目標使用者",
|
||||||
|
"Move": "轉移",
|
||||||
|
"Transfer": {
|
||||||
|
"Title": "伺服器轉移",
|
||||||
|
"PageHint": "追蹤由批次轉移操作發起的擁有權轉移。待處理項目可以取消;已終止項目可以重試。",
|
||||||
|
"From": "來源使用者",
|
||||||
|
"To": "目標使用者",
|
||||||
|
"Initiator": "發起者",
|
||||||
|
"LastError": "最近錯誤",
|
||||||
|
"Retry": "重試",
|
||||||
|
"CancelRequested": "已請求取消",
|
||||||
|
"RetryRequested": "已請求重試",
|
||||||
|
"BatchSubmitted": "批次轉移已提交",
|
||||||
|
"PendingCount": "{{count}} 個待處理",
|
||||||
|
"PermissionDeniedCount": "{{count}} 個權限不足",
|
||||||
|
"AlreadyTransferringCount": "{{count}} 個正在轉移中",
|
||||||
|
"ServerNotFoundCount": "{{count}} 個未找到",
|
||||||
|
"SameOwnerCount": "{{count}} 個已屬於目標使用者",
|
||||||
|
"AgentTooOldCount": "{{count}} 個 agent 版本過舊(需升級)",
|
||||||
|
"StatusPending": "待處理",
|
||||||
|
"StatusVerified": "已驗證",
|
||||||
|
"StatusFailed": "已失敗",
|
||||||
|
"StatusTimeout": "已逾時",
|
||||||
|
"StatusCancelled": "已取消"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import NotificationPage from "./routes/notification"
|
|||||||
import OnlineUserPage from "./routes/online-user"
|
import OnlineUserPage from "./routes/online-user"
|
||||||
import ProfilePage from "./routes/profile"
|
import ProfilePage from "./routes/profile"
|
||||||
import SettingsPage from "./routes/settings"
|
import SettingsPage from "./routes/settings"
|
||||||
|
import TransferPage from "./routes/transfer"
|
||||||
import UserPage from "./routes/user"
|
import UserPage from "./routes/user"
|
||||||
import WAFPage from "./routes/waf"
|
import WAFPage from "./routes/waf"
|
||||||
|
|
||||||
@@ -146,6 +147,10 @@ const router = createBrowserRouter([
|
|||||||
path: "/dashboard/settings/online-user",
|
path: "/dashboard/settings/online-user",
|
||||||
element: <OnlineUserPage />,
|
element: <OnlineUserPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard/transfer",
|
||||||
|
element: <TransferPage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -165,9 +165,9 @@ export default function AlertRulePage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+12
-12
@@ -110,15 +110,15 @@ export default function CronPage() {
|
|||||||
<div className="max-w-48 whitespace-normal break-words">
|
<div className="max-w-48 whitespace-normal break-words">
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (s.cover) {
|
switch (s.cover) {
|
||||||
case 0: {
|
case 0: {
|
||||||
return <span>{t("IgnoreAll")}</span>
|
return <span>{t("IgnoreAll")}</span>
|
||||||
}
|
}
|
||||||
case 1: {
|
case 1: {
|
||||||
return <span>{t("CoverAll")}</span>
|
return <span>{t("CoverAll")}</span>
|
||||||
}
|
}
|
||||||
case 2: {
|
case 2: {
|
||||||
return <span>{t("OnAlert")}</span>
|
return <span>{t("OnAlert")}</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -233,9 +233,9 @@ export default function CronPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+3
-3
@@ -170,9 +170,9 @@ export default function DDNSPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function Login() {
|
|||||||
if (oauth2) {
|
if (oauth2) {
|
||||||
loginOauth2()
|
loginOauth2()
|
||||||
}
|
}
|
||||||
}, [window.location.search])
|
}, [loginOauth2])
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -56,7 +56,7 @@ function Login() {
|
|||||||
async function loginWith(provider: string) {
|
async function loginWith(provider: string) {
|
||||||
try {
|
try {
|
||||||
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.LOGIN)
|
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.LOGIN)
|
||||||
window.location.href = redirectUrl.redirect!
|
window.location.assign(redirectUrl.redirect!)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message)
|
toast.error(error.message)
|
||||||
}
|
}
|
||||||
@@ -108,14 +108,14 @@ function Login() {
|
|||||||
</form>
|
</form>
|
||||||
{settingData?.config?.oauth2_providers &&
|
{settingData?.config?.oauth2_providers &&
|
||||||
settingData?.config?.oauth2_providers.length > 0 && (
|
settingData?.config?.oauth2_providers.length > 0 && (
|
||||||
<section className="flex items-center my-3 w-full">
|
<section className="flex items-center my-3 w-full">
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
<div className="flex justify-center text-xs text-muted-foreground w-full max-w-[100px]">
|
<div className="flex justify-center text-xs text-muted-foreground w-full max-w-[100px]">
|
||||||
OAuth2
|
OAuth2
|
||||||
</div>
|
</div>
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
{settingData?.config?.oauth2_providers?.map((p: string) => (
|
{settingData?.config?.oauth2_providers?.map((p: string) => (
|
||||||
|
|||||||
+3
-3
@@ -153,9 +153,9 @@ export default function NATPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ export default function NotificationGroupPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ export default function NotificationPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -264,9 +264,9 @@ export default function OnlineUserPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export default function ProfilePage() {
|
|||||||
})
|
})
|
||||||
window.history.replaceState({}, document.title, window.location.pathname)
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
}
|
}
|
||||||
}, [window.location.search])
|
}, [setProfile])
|
||||||
|
|
||||||
const bindO2 = async (provider: string) => {
|
const bindO2 = async (provider: string) => {
|
||||||
try {
|
try {
|
||||||
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.BIND)
|
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.BIND)
|
||||||
window.location.href = redirectUrl.redirect!
|
window.location.assign(redirectUrl.redirect!)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message)
|
toast.error(error.message)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -22,6 +22,12 @@ export default function Root() {
|
|||||||
}
|
}
|
||||||
}, [settingData?.config?.custom_code_dashboard])
|
}, [settingData?.config?.custom_code_dashboard])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingData?.config?.language && !localStorage.getItem("language")) {
|
||||||
|
i18n.changeLanguage(settingData?.config?.language)
|
||||||
|
}
|
||||||
|
}, [settingData?.config?.language])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -30,10 +36,6 @@ export default function Root() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingData?.config?.language && !localStorage.getItem("language")) {
|
|
||||||
i18n.changeLanguage(settingData?.config?.language)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||||
<section className="text-sm mx-auto h-full flex flex-col justify-between">
|
<section className="text-sm mx-auto h-full flex flex-col justify-between">
|
||||||
|
|||||||
@@ -145,9 +145,9 @@ export default function ServerGroupPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+35
-3
@@ -99,6 +99,38 @@ export default function ServerPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "owner",
|
||||||
|
header: t("Owner"),
|
||||||
|
// Backend Server.MarshalJSON always emits owner.id; username is
|
||||||
|
// omitted for uid=0 (legacy global agent secret) and for users
|
||||||
|
// that no longer exist. Render uid=0 as "Global Agent" and a
|
||||||
|
// missing username as "Unknown user (#id)" so deleted-user rows
|
||||||
|
// stay debuggable instead of silently appearing ownerless.
|
||||||
|
accessorFn: (row) => {
|
||||||
|
if (!row.owner) return ""
|
||||||
|
if (row.owner.id === 0) return t("GlobalAgent")
|
||||||
|
return row.owner.username || t("UnknownUser", { id: row.owner.id })
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const owner = row.original.owner
|
||||||
|
if (!owner) {
|
||||||
|
return <span className="text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
if (owner.id === 0) {
|
||||||
|
return <span>{t("GlobalAgent")}</span>
|
||||||
|
}
|
||||||
|
const label = owner.username || t("UnknownUser", { id: owner.id })
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="max-w-32 whitespace-normal break-words"
|
||||||
|
title={`uid=${owner.id}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "ip",
|
id: "ip",
|
||||||
header: "IP",
|
header: "IP",
|
||||||
@@ -260,9 +292,9 @@ export default function ServerPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -90,12 +90,12 @@ export default function ServicePage() {
|
|||||||
<div className="max-w-48 whitespace-normal break-words">
|
<div className="max-w-48 whitespace-normal break-words">
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (s.cover) {
|
switch (s.cover) {
|
||||||
case 0: {
|
case 0: {
|
||||||
return <span>{t("CoverAll")}</span>
|
return <span>{t("CoverAll")}</span>
|
||||||
}
|
}
|
||||||
case 1: {
|
case 1: {
|
||||||
return <span>{t("IgnoreAll")}</span>
|
return <span>{t("IgnoreAll")}</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,9 +199,9 @@ export default function ServicePage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -76,19 +76,19 @@ export default function SettingsPage() {
|
|||||||
resolver: zodResolver(settingFormSchema) as any,
|
resolver: zodResolver(settingFormSchema) as any,
|
||||||
defaultValues: config
|
defaultValues: config
|
||||||
? {
|
? {
|
||||||
...config.config,
|
...config.config,
|
||||||
user_template:
|
user_template:
|
||||||
config.config?.user_template ||
|
config.config?.user_template ||
|
||||||
Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] ||
|
Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] ||
|
||||||
"user-dist",
|
"user-dist",
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
ip_change_notification_group_id: 0,
|
ip_change_notification_group_id: 0,
|
||||||
cover: 1,
|
cover: 1,
|
||||||
site_name: "",
|
site_name: "",
|
||||||
language: "",
|
language: "",
|
||||||
user_template: "user-dist",
|
user_template: "user-dist",
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: false,
|
keepDefaultValues: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { swrFetcher } from "@/api/api"
|
||||||
|
import { cancelServerTransfer, retryServerTransfer } from "@/api/transfer"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import {
|
||||||
|
ModelServerTransfer,
|
||||||
|
ModelServerTransferStatus,
|
||||||
|
ModelServerTransferStatusCancelled,
|
||||||
|
ModelServerTransferStatusFailed,
|
||||||
|
ModelServerTransferStatusPending,
|
||||||
|
ModelServerTransferStatusTimeout,
|
||||||
|
ModelServerTransferStatusVerified,
|
||||||
|
} from "@/types"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import useSWR from "swr"
|
||||||
|
|
||||||
|
// Map status enum to a label key + a Badge variant. Centralised here so the
|
||||||
|
// table cell and any future drawer agree. Verified is "secondary" because
|
||||||
|
// the row is no longer actionable; Failed/Timeout use "destructive" to draw
|
||||||
|
// the operator's attention. Cancelled is "outline" — soft signal, expected.
|
||||||
|
const statusMeta: Record<
|
||||||
|
ModelServerTransferStatus,
|
||||||
|
{ key: string; variant: "default" | "secondary" | "destructive" | "outline" }
|
||||||
|
> = {
|
||||||
|
[ModelServerTransferStatusPending]: { key: "Transfer.StatusPending", variant: "default" },
|
||||||
|
[ModelServerTransferStatusVerified]: { key: "Transfer.StatusVerified", variant: "secondary" },
|
||||||
|
[ModelServerTransferStatusFailed]: { key: "Transfer.StatusFailed", variant: "destructive" },
|
||||||
|
[ModelServerTransferStatusTimeout]: { key: "Transfer.StatusTimeout", variant: "destructive" },
|
||||||
|
[ModelServerTransferStatusCancelled]: { key: "Transfer.StatusCancelled", variant: "outline" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransferPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { profile } = useAuth()
|
||||||
|
const isAdmin = profile?.role === 0
|
||||||
|
const callerID = profile?.id
|
||||||
|
const { data, mutate, error } = useSWR<ModelServerTransfer[]>(
|
||||||
|
"/api/v1/transfer",
|
||||||
|
swrFetcher,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast(t("Error"), {
|
||||||
|
description: t("Results.UnExpectedError", { error: error.message }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [error, t])
|
||||||
|
|
||||||
|
// Live updates: subscribe to /ws/transfer and patch the SWR cache on each
|
||||||
|
// pushed event. We don't refetch on every event because the WS payload IS
|
||||||
|
// the latest row — refetching would just cost a round trip.
|
||||||
|
//
|
||||||
|
// Reconnect contract: the original implementation opened the socket once
|
||||||
|
// and never reconnected, so a dashboard restart, deploy, or transient
|
||||||
|
// network blip silently froze this view — rows stayed Pending forever
|
||||||
|
// even after the agent had reconnected and the backend had transitioned
|
||||||
|
// them, and operators only discovered it on a hard refresh. Reconnect
|
||||||
|
// with capped exponential backoff (1s → 30s) and trigger a SWR revalidate
|
||||||
|
// on each open so any transitions that broadcast while we were offline
|
||||||
|
// are reconciled.
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let backoffMs = 1000
|
||||||
|
const maxBackoffMs = 30000
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
const ws = new WebSocket(`${proto}//${window.location.host}/api/v1/ws/transfer`)
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
backoffMs = 1000
|
||||||
|
// Backfill anything the broker fanned out while we were
|
||||||
|
// reconnecting — the broker drops events for full subscribers
|
||||||
|
// and there is no replay on the wire.
|
||||||
|
mutate()
|
||||||
|
}
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const t: ModelServerTransfer = JSON.parse(ev.data)
|
||||||
|
mutate((prev) => {
|
||||||
|
const list = prev ?? []
|
||||||
|
const idx = list.findIndex((x) => x.id === t.id)
|
||||||
|
if (idx === -1) return [t, ...list]
|
||||||
|
const next = list.slice()
|
||||||
|
next[idx] = t
|
||||||
|
return next
|
||||||
|
}, false)
|
||||||
|
} catch (e) {
|
||||||
|
// Malformed payload — log and keep the socket open; a refetch
|
||||||
|
// on next mount will re-sync. Closing the WS would be louder
|
||||||
|
// than the actual problem.
|
||||||
|
console.error("transfer ws parse failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (reconnectTimer !== null) return
|
||||||
|
const delay = backoffMs
|
||||||
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null
|
||||||
|
connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
ws.onclose = scheduleReconnect
|
||||||
|
ws.onerror = () => {
|
||||||
|
// onerror fires before onclose and may not produce a close
|
||||||
|
// event in every browser when the handshake is rejected.
|
||||||
|
// Force-close so onclose's reconnect path runs deterministically.
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (reconnectTimer !== null) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
const ws = wsRef.current
|
||||||
|
wsRef.current = null
|
||||||
|
if (ws) {
|
||||||
|
// Detach handlers so the close we trigger here doesn't bounce
|
||||||
|
// into a reconnect attempt against an unmounted component.
|
||||||
|
ws.onopen = null
|
||||||
|
ws.onmessage = null
|
||||||
|
ws.onclose = null
|
||||||
|
ws.onerror = null
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mutate])
|
||||||
|
|
||||||
|
const onCancel = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
try {
|
||||||
|
await cancelServerTransfer(id)
|
||||||
|
toast(t("Transfer.CancelRequested"))
|
||||||
|
mutate()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
toast(t("Error"), { description: msg })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutate, t],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onRetry = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
try {
|
||||||
|
await retryServerTransfer(id)
|
||||||
|
toast(t("Transfer.RetryRequested"))
|
||||||
|
mutate()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
toast(t("Error"), { description: msg })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutate, t],
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = useMemo(() => data ?? [], [data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3">
|
||||||
|
<h1 className="mt-6 text-2xl font-semibold">{t("Transfer.Title")}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
|
||||||
|
{t("Transfer.PageHint")}
|
||||||
|
</p>
|
||||||
|
<Table className="mt-6">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>{t("ServerID")}</TableHead>
|
||||||
|
<TableHead>{t("Transfer.From")}</TableHead>
|
||||||
|
<TableHead>{t("Transfer.To")}</TableHead>
|
||||||
|
<TableHead>{t("Transfer.Initiator")}</TableHead>
|
||||||
|
<TableHead>{t("Status")}</TableHead>
|
||||||
|
<TableHead>{t("Transfer.LastError")}</TableHead>
|
||||||
|
<TableHead>{t("CreatedAt")}</TableHead>
|
||||||
|
<TableHead>{t("Actions")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="h-24 text-center">
|
||||||
|
{t("NoResults")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => {
|
||||||
|
const meta = statusMeta[row.status]
|
||||||
|
const isPending = row.status === ModelServerTransferStatusPending
|
||||||
|
const isTerminalRevert =
|
||||||
|
row.status === ModelServerTransferStatusFailed ||
|
||||||
|
row.status === ModelServerTransferStatusTimeout ||
|
||||||
|
row.status === ModelServerTransferStatusCancelled
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell>{row.id}</TableCell>
|
||||||
|
<TableCell>{row.server_id}</TableCell>
|
||||||
|
<TableCell>{row.from_user_id}</TableCell>
|
||||||
|
<TableCell>{row.to_user_id}</TableCell>
|
||||||
|
<TableCell>{row.initiator_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={meta.variant}>{t(meta.key)}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate" title={row.last_error}>
|
||||||
|
{row.last_error || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(row.created_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="space-x-2">
|
||||||
|
{isPending &&
|
||||||
|
(isAdmin || row.from_user_id === callerID) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onCancel(row.id)}
|
||||||
|
>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isTerminalRevert && isAdmin && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onRetry(row.id)}
|
||||||
|
>
|
||||||
|
{t("Transfer.Retry")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-3
@@ -139,9 +139,9 @@ export default function UserPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+3
-3
@@ -283,9 +283,9 @@ export default function WAFPage() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||||
|
|
||||||
|
const toastCalls: Array<{ title: string; description: string }> = []
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: (title: string, opts?: { description?: string }) => {
|
||||||
|
toastCalls.push({ title, description: opts?.description ?? "" })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, vars?: Record<string, unknown>) => {
|
||||||
|
if (vars && typeof vars.count === "number") {
|
||||||
|
return `${key}=${vars.count}`
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
initReactI18next: { type: "3rdParty", init: () => undefined },
|
||||||
|
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const batchMoveServer = vi.fn()
|
||||||
|
vi.mock("@/api/server", () => ({
|
||||||
|
batchMoveServer: (...args: unknown[]) => batchMoveServer(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
toastCalls.length = 0
|
||||||
|
batchMoveServer.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openDialogAndSubmit(serverIds: number[], toUser: number) {
|
||||||
|
render(<BatchMoveServerIcon serverIds={serverIds} />)
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button"))
|
||||||
|
})
|
||||||
|
const userInput = await screen.findByPlaceholderText("User ID")
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(userInput, { target: { value: String(toUser) } })
|
||||||
|
})
|
||||||
|
const submit = screen.getByRole("button", { name: "Move" })
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit)
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(toastCalls.length).toBeGreaterThan(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("BatchMoveServer toast surfaces agent_too_old count so operator sees the failure", async () => {
|
||||||
|
batchMoveServer.mockResolvedValueOnce([
|
||||||
|
{ server_id: 1, status: "agent_too_old", error: "agent build older than v1.18.0" },
|
||||||
|
{ server_id: 2, status: "pending", transfer_id: 99 },
|
||||||
|
])
|
||||||
|
|
||||||
|
await openDialogAndSubmit([1, 2], 300)
|
||||||
|
|
||||||
|
expect(batchMoveServer).toHaveBeenCalledOnce()
|
||||||
|
const summary = toastCalls[toastCalls.length - 1]
|
||||||
|
expect(summary, "submission must surface a toast").toBeDefined()
|
||||||
|
expect(summary.description, "toast must include the agent_too_old count").toContain(
|
||||||
|
"Transfer.AgentTooOldCount=1",
|
||||||
|
)
|
||||||
|
expect(summary.description).toContain("Transfer.PendingCount=1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("BatchMoveServer toast does not show Done fallback when every server is agent_too_old", async () => {
|
||||||
|
batchMoveServer.mockResolvedValueOnce([
|
||||||
|
{ server_id: 1, status: "agent_too_old", error: "older than v1.18.0" },
|
||||||
|
{ server_id: 2, status: "agent_too_old", error: "older than v1.18.0" },
|
||||||
|
])
|
||||||
|
|
||||||
|
await openDialogAndSubmit([1, 2], 300)
|
||||||
|
|
||||||
|
const summary = toastCalls[toastCalls.length - 1]
|
||||||
|
expect(summary).toBeDefined()
|
||||||
|
expect(
|
||||||
|
summary.description,
|
||||||
|
"all-failed batch must NOT collapse to a generic Done label — operator would think it succeeded",
|
||||||
|
).toContain("Transfer.AgentTooOldCount=2")
|
||||||
|
expect(summary.description).not.toBe("Done")
|
||||||
|
})
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { act, render, waitFor } from "@testing-library/react"
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||||
|
|
||||||
|
type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean }
|
||||||
|
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: string
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
type IconButtonProps = ButtonProps & { icon?: string }
|
||||||
|
type SentWebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView
|
||||||
|
|
||||||
|
vi.mock("../components/ui/button", () => ({
|
||||||
|
Button: (props: ButtonProps) => {
|
||||||
|
const { asChild, size, variant, ...buttonProps } = props
|
||||||
|
void asChild
|
||||||
|
void size
|
||||||
|
void variant
|
||||||
|
return <button {...buttonProps} />
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock("../components/ui/input", () => ({
|
||||||
|
Input: (props: React.ComponentPropsWithoutRef<"input">) => <input {...props} />,
|
||||||
|
}))
|
||||||
|
vi.mock("../components/ui/table", () => ({
|
||||||
|
Table: (props: React.ComponentPropsWithoutRef<"table">) => <table {...props} />,
|
||||||
|
TableHeader: (props: React.ComponentPropsWithoutRef<"thead">) => <thead {...props} />,
|
||||||
|
TableBody: (props: React.ComponentPropsWithoutRef<"tbody">) => <tbody {...props} />,
|
||||||
|
TableRow: (props: React.ComponentPropsWithoutRef<"tr">) => <tr {...props} />,
|
||||||
|
TableCell: (props: React.ComponentPropsWithoutRef<"td">) => <td {...props} />,
|
||||||
|
TableHead: (props: React.ComponentPropsWithoutRef<"th">) => <th {...props} />,
|
||||||
|
}))
|
||||||
|
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||||
|
DropdownMenu: (props: DivProps) => <div {...props} />,
|
||||||
|
DropdownMenuTrigger: (props: DivProps) => <div {...props} />,
|
||||||
|
DropdownMenuContent: (props: DivProps) => <div {...props} />,
|
||||||
|
DropdownMenuItem: (props: DivProps) => <div {...props} />,
|
||||||
|
}))
|
||||||
|
vi.mock("../components/ui/alert-dialog", () => ({
|
||||||
|
AlertDialog: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogTrigger: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogContent: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogHeader: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogFooter: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogTitle: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogDescription: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogCancel: (props: DivProps) => <div {...props} />,
|
||||||
|
AlertDialogAction: (props: DivProps) => <div {...props} />,
|
||||||
|
}))
|
||||||
|
vi.mock("../components/ui/drawer", () => ({
|
||||||
|
Drawer: (props: DivProps) => <div {...props} />,
|
||||||
|
DrawerContent: (props: DivProps) => <div {...props} />,
|
||||||
|
DrawerHeader: (props: DivProps) => <div {...props} />,
|
||||||
|
DrawerTitle: (props: DivProps) => <div {...props} />,
|
||||||
|
DrawerTrigger: (props: DivProps) => <div {...props} />,
|
||||||
|
}))
|
||||||
|
vi.mock("../components/xui/overlayless-sheet", () => ({
|
||||||
|
Sheet: (props: DivProps) => <div {...props} />,
|
||||||
|
SheetContent: (props: DivProps) => <div {...props} />,
|
||||||
|
SheetDescription: (props: DivProps) => <div {...props} />,
|
||||||
|
SheetHeader: (props: DivProps) => <div {...props} />,
|
||||||
|
SheetTitle: (props: DivProps) => <div {...props} />,
|
||||||
|
SheetTrigger: (props: DivProps) => <div {...props} />,
|
||||||
|
}))
|
||||||
|
vi.mock("../components/xui/filepath", () => ({
|
||||||
|
Filepath: () => <div data-testid="filepath" />,
|
||||||
|
}))
|
||||||
|
vi.mock("../components/xui/icon-button", () => ({
|
||||||
|
IconButton: (props: IconButtonProps) => {
|
||||||
|
const { asChild, icon, size, variant, ...buttonProps } = props
|
||||||
|
void asChild
|
||||||
|
void icon
|
||||||
|
void size
|
||||||
|
void variant
|
||||||
|
return <button {...buttonProps} />
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock("../components/xui/virtulized-data-table", () => ({
|
||||||
|
DataTable: () => <div data-testid="data-table" />,
|
||||||
|
}))
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
File: () => <div data-testid="file-icon" />,
|
||||||
|
Folder: () => <div data-testid="folder-icon" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const translate = (key: string) => key
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: translate }),
|
||||||
|
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({ toast: vi.fn() }))
|
||||||
|
vi.mock("@/lib/utils", () => ({
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
fm: {
|
||||||
|
parseFMList: async (buf: ArrayBufferLike) => {
|
||||||
|
const view = new DataView(buf)
|
||||||
|
const pathLength = view.getUint32(4, false)
|
||||||
|
const pathBytes = new Uint8Array(buf, 8, pathLength)
|
||||||
|
|
||||||
|
return { path: new TextDecoder().decode(pathBytes), fmList: [] }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fmWorker: new Worker(""),
|
||||||
|
formatPath: (path: string) => path,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { FMComponent } from "../components/fm"
|
||||||
|
|
||||||
|
const webSockets: MockWebSocket[] = []
|
||||||
|
|
||||||
|
class MockWebSocket extends EventTarget implements WebSocket {
|
||||||
|
static readonly CONNECTING = 0
|
||||||
|
static readonly OPEN = 1
|
||||||
|
static readonly CLOSING = 2
|
||||||
|
static readonly CLOSED = 3
|
||||||
|
|
||||||
|
readonly CONNECTING = MockWebSocket.CONNECTING
|
||||||
|
readonly OPEN = MockWebSocket.OPEN
|
||||||
|
readonly CLOSING = MockWebSocket.CLOSING
|
||||||
|
readonly CLOSED = MockWebSocket.CLOSED
|
||||||
|
readonly bufferedAmount = 0
|
||||||
|
readonly extensions = ""
|
||||||
|
readonly protocol = ""
|
||||||
|
readonly url: string
|
||||||
|
|
||||||
|
binaryType: BinaryType = "arraybuffer"
|
||||||
|
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null
|
||||||
|
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null
|
||||||
|
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null
|
||||||
|
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null
|
||||||
|
readyState = MockWebSocket.CONNECTING
|
||||||
|
sent: SentWebSocketData[] = []
|
||||||
|
|
||||||
|
constructor(url: string | URL) {
|
||||||
|
super()
|
||||||
|
this.url = url.toString()
|
||||||
|
webSockets.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener<K extends keyof WebSocketEventMap>(
|
||||||
|
type: K,
|
||||||
|
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
|
||||||
|
options?: boolean | AddEventListenerOptions,
|
||||||
|
): void
|
||||||
|
addEventListener(
|
||||||
|
type: string,
|
||||||
|
listener: EventListenerOrEventListenerObject | null,
|
||||||
|
options?: boolean | AddEventListenerOptions,
|
||||||
|
): void {
|
||||||
|
super.addEventListener(type, listener, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener<K extends keyof WebSocketEventMap>(
|
||||||
|
type: K,
|
||||||
|
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
|
||||||
|
options?: boolean | EventListenerOptions,
|
||||||
|
): void
|
||||||
|
removeEventListener(
|
||||||
|
type: string,
|
||||||
|
listener: EventListenerOrEventListenerObject | null,
|
||||||
|
options?: boolean | EventListenerOptions,
|
||||||
|
): void {
|
||||||
|
super.removeEventListener(type, listener, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.readyState = MockWebSocket.CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
this.readyState = MockWebSocket.OPEN
|
||||||
|
this.onopen?.call(this, new Event("open"))
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: SentWebSocketData): void {
|
||||||
|
this.sent.push(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
webSockets.length = 0
|
||||||
|
globalThis.WebSocket = MockWebSocket
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const encodeFileNameMessage = (path: string) => {
|
||||||
|
const identifier = new Uint8Array([0x4e, 0x5a, 0x46, 0x4e])
|
||||||
|
const pathBytes = new TextEncoder().encode(path)
|
||||||
|
const payload = new Uint8Array(identifier.length + 4 + pathBytes.length)
|
||||||
|
payload.set(identifier, 0)
|
||||||
|
new DataView(payload.buffer).setUint32(identifier.length, pathBytes.length, false)
|
||||||
|
payload.set(pathBytes, identifier.length + 4)
|
||||||
|
return payload.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodeCompleteMessage = () => new Uint8Array([0x4e, 0x5a, 0x55, 0x50]).buffer
|
||||||
|
|
||||||
|
const decodeListPath = (data: SentWebSocketData) => {
|
||||||
|
if (!ArrayBuffer.isView(data)) return null
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||||
|
if (bytes[0] !== 0) return null
|
||||||
|
|
||||||
|
return new TextDecoder().decode(bytes.slice(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("FM websocket lifecycle reuses the socket on path changes", async () => {
|
||||||
|
const { unmount } = render(<FMComponent wsUrl="/ws/file/test" />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(webSockets).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const socket = webSockets[0]
|
||||||
|
act(() => {
|
||||||
|
socket.open()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await socket.onmessage?.call(
|
||||||
|
socket,
|
||||||
|
new MessageEvent("message", { data: encodeFileNameMessage("/new/path") }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(webSockets).toHaveLength(1)
|
||||||
|
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.sent.length = 0
|
||||||
|
await act(async () => {
|
||||||
|
await socket.onmessage?.call(socket, new MessageEvent("message", { data: encodeCompleteMessage() }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(webSockets).toHaveLength(1)
|
||||||
|
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
class TestWorker implements Worker {
|
||||||
|
onmessage: ((this: Worker, ev: MessageEvent) => unknown) | null = null
|
||||||
|
onmessageerror: ((this: Worker, ev: MessageEvent) => unknown) | null = null
|
||||||
|
onerror: ((this: AbstractWorker, ev: ErrorEvent) => unknown) | null = null
|
||||||
|
|
||||||
|
addEventListener(): void {}
|
||||||
|
removeEventListener(): void {}
|
||||||
|
dispatchEvent(): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
postMessage(): void {}
|
||||||
|
terminate(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestResizeObserver implements ResizeObserver {
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestIntersectionObserver implements IntersectionObserver {
|
||||||
|
readonly root: Element | Document | null = null
|
||||||
|
readonly rootMargin = ""
|
||||||
|
readonly thresholds: ReadonlyArray<number> = []
|
||||||
|
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
takeRecords(): IntersectionObserverEntry[] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.Worker = TestWorker
|
||||||
|
globalThis.ResizeObserver = TestResizeObserver
|
||||||
|
globalThis.IntersectionObserver = TestIntersectionObserver
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { render } from "@testing-library/react"
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({ toast: () => undefined }))
|
||||||
|
|
||||||
|
vi.mock("@/lib/utils", () => ({
|
||||||
|
sleep: () => Promise.resolve(),
|
||||||
|
cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const attachAddonInstances: { ws: WebSocket }[] = []
|
||||||
|
vi.mock("@xterm/addon-attach", () => ({
|
||||||
|
AttachAddon: class {
|
||||||
|
ws: WebSocket
|
||||||
|
constructor(ws: WebSocket) {
|
||||||
|
this.ws = ws
|
||||||
|
attachAddonInstances.push(this)
|
||||||
|
}
|
||||||
|
activate() {}
|
||||||
|
dispose() {}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
|
FitAddon: class {
|
||||||
|
activate() {}
|
||||||
|
dispose() {}
|
||||||
|
fit() {}
|
||||||
|
proposeDimensions() {
|
||||||
|
return { rows: 24, cols: 80 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", () => ({
|
||||||
|
Terminal: class {
|
||||||
|
loadAddon() {}
|
||||||
|
open() {}
|
||||||
|
dispose() {}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static instances: FakeWebSocket[] = []
|
||||||
|
url: string
|
||||||
|
binaryType = "arraybuffer"
|
||||||
|
onopen: ((ev: Event) => unknown) | null = null
|
||||||
|
onclose: ((ev: Event) => unknown) | null = null
|
||||||
|
onerror: ((ev: Event) => unknown) | null = null
|
||||||
|
onmessage: ((ev: MessageEvent) => unknown) | null = null
|
||||||
|
readyState = 0
|
||||||
|
closeCalls = 0
|
||||||
|
|
||||||
|
constructor(url: string | URL) {
|
||||||
|
this.url = url.toString()
|
||||||
|
FakeWebSocket.instances.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closeCalls += 1
|
||||||
|
this.readyState = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
send() {}
|
||||||
|
addEventListener() {}
|
||||||
|
removeEventListener() {}
|
||||||
|
dispatchEvent() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeWebSocket.instances = []
|
||||||
|
attachAddonInstances.length = 0
|
||||||
|
;(globalThis as { WebSocket: typeof WebSocket }).WebSocket =
|
||||||
|
FakeWebSocket as unknown as typeof WebSocket
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("XtermComponent closes the previous WebSocket and re-attaches xterm when wsUrl changes", async () => {
|
||||||
|
const { XtermComponent } = await import("../components/terminal")
|
||||||
|
const noop = () => undefined
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<XtermComponent wsUrl="/api/v1/ws/terminal/session-1" setClose={noop} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||||
|
const firstSocket = FakeWebSocket.instances[0]
|
||||||
|
expect(attachAddonInstances).toHaveLength(1)
|
||||||
|
expect(attachAddonInstances[0].ws).toBe(firstSocket as unknown as WebSocket)
|
||||||
|
|
||||||
|
rerender(<XtermComponent wsUrl="/api/v1/ws/terminal/session-2" setClose={noop} />)
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||||
|
const secondSocket = FakeWebSocket.instances[1]
|
||||||
|
expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(attachAddonInstances).toHaveLength(2)
|
||||||
|
expect(attachAddonInstances[1].ws).toBe(secondSocket as unknown as WebSocket)
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { act, render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||||
|
|
||||||
|
const toastCalls: Array<{ title: string; description: string }> = []
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: (title: string, opts?: { description?: string }) => {
|
||||||
|
toastCalls.push({ title, description: opts?.description ?? "" })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
initReactI18next: { type: "3rdParty", init: () => undefined },
|
||||||
|
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const cancelServerTransfer = vi.fn()
|
||||||
|
const retryServerTransfer = vi.fn()
|
||||||
|
vi.mock("@/api/transfer", () => ({
|
||||||
|
cancelServerTransfer: (...args: unknown[]) => cancelServerTransfer(...args),
|
||||||
|
retryServerTransfer: (...args: unknown[]) => retryServerTransfer(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const swrFetcher = vi.fn()
|
||||||
|
vi.mock("@/api/api", () => ({
|
||||||
|
swrFetcher: (...args: unknown[]) => swrFetcher(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("swr", () => ({
|
||||||
|
default: (_key: string, _fetcher: unknown) => ({
|
||||||
|
data: mockedRows,
|
||||||
|
mutate: vi.fn(),
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let mockProfile: { id: number; role: number } | undefined
|
||||||
|
vi.mock("@/hooks/useAuth", () => ({
|
||||||
|
useAuth: () => ({ profile: mockProfile }),
|
||||||
|
}))
|
||||||
|
vi.mock("@/hooks/useMainStore", () => ({
|
||||||
|
useMainStore: (selector?: (s: { profile?: { id: number; role: number } }) => unknown) => {
|
||||||
|
const store = { profile: mockProfile }
|
||||||
|
return selector ? selector(store) : store
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import type { ModelServerTransfer } from "@/types"
|
||||||
|
|
||||||
|
let mockedRows: ModelServerTransfer[] = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
toastCalls.length = 0
|
||||||
|
cancelServerTransfer.mockReset()
|
||||||
|
retryServerTransfer.mockReset()
|
||||||
|
mockedRows = []
|
||||||
|
mockProfile = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<ModelServerTransfer>): ModelServerTransfer {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
server_id: 10,
|
||||||
|
from_user_id: 100,
|
||||||
|
to_user_id: 200,
|
||||||
|
initiator_id: 100,
|
||||||
|
status: 0,
|
||||||
|
last_error: "",
|
||||||
|
acked_at: "",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
} as ModelServerTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage() {
|
||||||
|
const { default: TransferPage } = await import("@/routes/transfer")
|
||||||
|
await act(async () => {
|
||||||
|
render(<TransferPage />)
|
||||||
|
})
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Transfer.Title")).toBeTruthy()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("non-admin member who is the FromUserID sees Cancel for pending rows but NOT Retry for terminal rows", async () => {
|
||||||
|
mockProfile = { id: 100, role: 1 }
|
||||||
|
mockedRows = [
|
||||||
|
makeRow({ id: 1, status: 0, from_user_id: 100 }),
|
||||||
|
makeRow({ id: 2, status: 2, from_user_id: 100 }),
|
||||||
|
makeRow({ id: 3, status: 4, from_user_id: 100 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBeGreaterThan(0)
|
||||||
|
expect(
|
||||||
|
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
|
||||||
|
"Retry is admin-only on the backend; rendering it for members produces guaranteed permission_denied on click",
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-admin member who is only the ToUserID or InitiatorID sees neither Cancel nor Retry", async () => {
|
||||||
|
mockProfile = { id: 200, role: 1 }
|
||||||
|
mockedRows = [
|
||||||
|
makeRow({ id: 1, status: 0, from_user_id: 100, to_user_id: 200 }),
|
||||||
|
makeRow({ id: 2, status: 3, from_user_id: 100, to_user_id: 200 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryAllByRole("button", { name: "Cancel" }),
|
||||||
|
"backend cancelServerTransfer rejects non-admins that are not the FromUserID; UI must not pretend it works",
|
||||||
|
).toHaveLength(0)
|
||||||
|
expect(
|
||||||
|
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
|
||||||
|
"backend retryServerTransfer is admin-only; non-admin must not see the button",
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("admin sees both Cancel for pending and Retry for terminal", async () => {
|
||||||
|
mockProfile = { id: 1, role: 0 }
|
||||||
|
mockedRows = [
|
||||||
|
makeRow({ id: 1, status: 0 }),
|
||||||
|
makeRow({ id: 2, status: 2 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBe(1)
|
||||||
|
expect(screen.queryAllByRole("button", { name: "Transfer.Retry" }).length).toBe(1)
|
||||||
|
})
|
||||||
+52
-1
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/*
|
/*
|
||||||
* ---------------------------------------------------------------
|
* ---------------------------------------------------------------
|
||||||
@@ -193,6 +192,46 @@ export interface ModelBatchMoveServerForm {
|
|||||||
to_user: number
|
to_user: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModelServerTransferStatus mirrors model.ServerTransferStatus in nezha. The
|
||||||
|
// values are stable iota numbers; do NOT renumber without coordinating with
|
||||||
|
// the backend or every existing transfer row will be misread.
|
||||||
|
export const ModelServerTransferStatusPending = 0
|
||||||
|
export const ModelServerTransferStatusVerified = 1
|
||||||
|
export const ModelServerTransferStatusFailed = 2
|
||||||
|
export const ModelServerTransferStatusTimeout = 3
|
||||||
|
export const ModelServerTransferStatusCancelled = 4
|
||||||
|
export type ModelServerTransferStatus = 0 | 1 | 2 | 3 | 4
|
||||||
|
|
||||||
|
// ModelBatchMoveServerResultStatus enumerates the per-server outcomes of
|
||||||
|
// /batch-move/server. Mirrors model.BatchMoveServerResultStatus.
|
||||||
|
export type ModelBatchMoveServerResultStatus =
|
||||||
|
| "pending"
|
||||||
|
| "permission_denied"
|
||||||
|
| "already_transferring"
|
||||||
|
| "server_not_found"
|
||||||
|
| "same_owner"
|
||||||
|
| "agent_too_old"
|
||||||
|
|
||||||
|
export interface ModelBatchMoveServerResult {
|
||||||
|
server_id: number
|
||||||
|
status: ModelBatchMoveServerResultStatus
|
||||||
|
transfer_id?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelServerTransfer {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
server_id: number
|
||||||
|
from_user_id: number
|
||||||
|
to_user_id: number
|
||||||
|
initiator_id: number
|
||||||
|
status: ModelServerTransferStatus
|
||||||
|
last_error?: string
|
||||||
|
acked_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelCreateFMResponse {
|
export interface ModelCreateFMResponse {
|
||||||
session_id: string
|
session_id: string
|
||||||
}
|
}
|
||||||
@@ -500,6 +539,14 @@ export interface ModelSensorTemperature {
|
|||||||
temperature?: number
|
temperature?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModelServerOwner mirrors model.ServerOwnerInfo from the backend. Server.id
|
||||||
|
// is always present; username is omitted for the legacy uid=0 pseudo-owner
|
||||||
|
// and for users that no longer exist in the User table.
|
||||||
|
export interface ModelServerOwner {
|
||||||
|
id: number
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelServer {
|
export interface ModelServer {
|
||||||
created_at: string
|
created_at: string
|
||||||
/** DDNS配置 */
|
/** DDNS配置 */
|
||||||
@@ -518,6 +565,10 @@ export interface ModelServer {
|
|||||||
/** 管理员可见备注 */
|
/** 管理员可见备注 */
|
||||||
note: string
|
note: string
|
||||||
override_ddns_domains?: Record<string, string[]>
|
override_ddns_domains?: Record<string, string[]>
|
||||||
|
/** Owner identity from model.Server.MarshalJSON. Always present for
|
||||||
|
* current backends; optional here for compatibility with older
|
||||||
|
* dashboards that pre-date the owner field. */
|
||||||
|
owner?: ModelServerOwner
|
||||||
/** 公开备注 */
|
/** 公开备注 */
|
||||||
public_note: string
|
public_note: string
|
||||||
state: ModelHostState
|
state: ModelHostState
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ export const AgentConfigSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -33,7 +33,7 @@ export const AgentConfigSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(val)
|
JSON.parse(val)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig, mergeConfig } from "vitest/config"
|
||||||
|
|
||||||
|
import viteConfig from "./vite.config.ts"
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user