Stabilize CI runtime for updated deps and unblock Dependabot auto-format flow (#185)

* chore: apply ci and dependabot remediation changes

* chore: auto-fix linting and formatting issues

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2026-08-01 10:58:33 +08:00
committed by GitHub
parent 95b51f0209
commit 04fbddb979
59 changed files with 627 additions and 516 deletions
+18 -18
View File
@@ -1,21 +1,21 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
groups: groups:
npm-dependencies: npm-dependencies:
patterns: patterns:
- "*" - "*"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
groups: groups:
github-actions: github-actions:
patterns: patterns:
- "*" - "*"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: 20 node-version: 22.19.0
cache: npm cache: npm
cache-dependency-path: package-lock.json cache-dependency-path: package-lock.json
+1
View File
@@ -1 +1,2 @@
src/main.tsx src/main.tsx
.github/workflows/*
+3 -1
View File
@@ -3460,7 +3460,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.1", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
+5 -5
View File
@@ -24,9 +24,9 @@ export default defineConfig({
webServer: process.env.E2E_SKIP_WEBSERVER webServer: process.env.E2E_SKIP_WEBSERVER
? undefined ? undefined
: { : {
command: "npm run dev -- --host 127.0.0.1", command: "npm run dev -- --host 127.0.0.1",
url: baseURL + "/dashboard/login", url: baseURL + "/dashboard/login",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,
}, },
}) })
+94 -23
View File
@@ -47,20 +47,56 @@ export const deleteApiToken = async (id: number): Promise<void> => {
} }
export const SCOPE_OPTIONS = [ export const SCOPE_OPTIONS = [
{ value: "nezha:inventory:read", label: "Inventory: read", desc: "List servers & groups (server.list)" }, {
{ value: "nezha:inventory:delete", label: "Inventory: delete", desc: "Delete servers & server groups" }, value: "nezha:inventory:read",
label: "Inventory: read",
desc: "List servers & groups (server.list)",
},
{
value: "nezha:inventory:delete",
label: "Inventory: delete",
desc: "Delete servers & server groups",
},
{ value: "nezha:inventory:*", label: "Inventory: all", desc: "List + delete servers & groups" }, { value: "nezha:inventory:*", label: "Inventory: all", desc: "List + delete servers & groups" },
{ value: "nezha:server:read", label: "Server: read", desc: "Inspect a server, read files & metrics (needs server id)" }, {
value: "nezha:server:read",
label: "Server: read",
desc: "Inspect a server, read files & metrics (needs server id)",
},
{ value: "nezha:server:write", label: "Server: write", desc: "Edit a server, push files" }, { value: "nezha:server:write", label: "Server: write", desc: "Edit a server, push files" },
{ value: "nezha:server:delete", label: "Server: delete", desc: "Delete files on a server" }, { value: "nezha:server:delete", label: "Server: delete", desc: "Delete files on a server" },
{ value: "nezha:server:exec", label: "Server: exec", desc: "Run shell commands on servers" }, { value: "nezha:server:exec", label: "Server: exec", desc: "Run shell commands on servers" },
{ value: "nezha:server:*", label: "Server: all", desc: "Every server operation (read+write+delete+exec)" }, {
{ value: "nezha:service:read", label: "Service monitor: read", desc: "List service monitors & history" }, value: "nezha:server:*",
{ value: "nezha:service:write", label: "Service monitor: write", desc: "Create / edit service monitors" }, label: "Server: all",
{ value: "nezha:service:delete", label: "Service monitor: delete", desc: "Delete service monitors" }, desc: "Every server operation (read+write+delete+exec)",
{ value: "nezha:service:*", label: "Service monitor: all", desc: "Every service monitor permission" }, },
{
value: "nezha:service:read",
label: "Service monitor: read",
desc: "List service monitors & history",
},
{
value: "nezha:service:write",
label: "Service monitor: write",
desc: "Create / edit service monitors",
},
{
value: "nezha:service:delete",
label: "Service monitor: delete",
desc: "Delete service monitors",
},
{
value: "nezha:service:*",
label: "Service monitor: all",
desc: "Every service monitor permission",
},
{ value: "nezha:alertrule:read", label: "Alert rule: read", desc: "List alert rules" }, { value: "nezha:alertrule:read", label: "Alert rule: read", desc: "List alert rules" },
{ value: "nezha:alertrule:write", label: "Alert rule: write", desc: "Create / edit alert rules" }, {
value: "nezha:alertrule:write",
label: "Alert rule: write",
desc: "Create / edit alert rules",
},
{ value: "nezha:alertrule:delete", label: "Alert rule: delete", desc: "Delete alert rules" }, { value: "nezha:alertrule:delete", label: "Alert rule: delete", desc: "Delete alert rules" },
{ value: "nezha:alertrule:*", label: "Alert rule: all", desc: "Every alert-rule permission" }, { value: "nezha:alertrule:*", label: "Alert rule: all", desc: "Every alert-rule permission" },
{ value: "nezha:cron:read", label: "Cron: read", desc: "List scheduled tasks" }, { value: "nezha:cron:read", label: "Cron: read", desc: "List scheduled tasks" },
@@ -69,13 +105,41 @@ export const SCOPE_OPTIONS = [
{ value: "nezha:cron:exec", label: "Cron: trigger", desc: "Manually trigger scheduled tasks" }, { value: "nezha:cron:exec", label: "Cron: trigger", desc: "Manually trigger scheduled tasks" },
{ value: "nezha:cron:*", label: "Cron: all", desc: "Every cron permission" }, { value: "nezha:cron:*", label: "Cron: all", desc: "Every cron permission" },
{ value: "nezha:notification:read", label: "Notification: read", desc: "List notifications" }, { value: "nezha:notification:read", label: "Notification: read", desc: "List notifications" },
{ value: "nezha:notification:write", label: "Notification: write", desc: "Create / edit notifications" }, {
{ value: "nezha:notification:delete", label: "Notification: delete", desc: "Delete notifications" }, value: "nezha:notification:write",
{ value: "nezha:notification:*", label: "Notification: all", desc: "Every notification permission" }, label: "Notification: write",
{ value: "nezha:notification-group:read", label: "Notification group: read", desc: "List notification groups" }, desc: "Create / edit notifications",
{ value: "nezha:notification-group:write", label: "Notification group: write", desc: "Create / edit groups" }, },
{ value: "nezha:notification-group:delete", label: "Notification group: delete", desc: "Delete groups" }, {
{ value: "nezha:notification-group:*", label: "Notification group: all", desc: "Every notification-group permission" }, value: "nezha:notification:delete",
label: "Notification: delete",
desc: "Delete notifications",
},
{
value: "nezha:notification:*",
label: "Notification: all",
desc: "Every notification permission",
},
{
value: "nezha:notification-group:read",
label: "Notification group: read",
desc: "List notification groups",
},
{
value: "nezha:notification-group:write",
label: "Notification group: write",
desc: "Create / edit groups",
},
{
value: "nezha:notification-group:delete",
label: "Notification group: delete",
desc: "Delete groups",
},
{
value: "nezha:notification-group:*",
label: "Notification group: all",
desc: "Every notification-group permission",
},
{ value: "nezha:ddns:read", label: "DDNS: read", desc: "List DDNS profiles" }, { value: "nezha:ddns:read", label: "DDNS: read", desc: "List DDNS profiles" },
{ value: "nezha:ddns:write", label: "DDNS: write", desc: "Create / edit DDNS profiles" }, { value: "nezha:ddns:write", label: "DDNS: write", desc: "Create / edit DDNS profiles" },
{ value: "nezha:ddns:delete", label: "DDNS: delete", desc: "Delete DDNS profiles" }, { value: "nezha:ddns:delete", label: "DDNS: delete", desc: "Delete DDNS profiles" },
@@ -86,21 +150,27 @@ export const SCOPE_OPTIONS = [
{ value: "nezha:nat:*", label: "NAT: all", desc: "Every NAT permission" }, { value: "nezha:nat:*", label: "NAT: all", desc: "Every NAT permission" },
{ value: "nezha:transfer:read", label: "Transfer: read", desc: "Read server transfer state" }, { value: "nezha:transfer:read", label: "Transfer: read", desc: "Read server transfer state" },
{ value: "nezha:transfer:write", label: "Transfer: write", desc: "Cancel / retry transfers" }, { value: "nezha:transfer:write", label: "Transfer: write", desc: "Cancel / retry transfers" },
{ value: "nezha:transfer:delete", label: "Transfer: delete", desc: "Delete server transfer records" }, {
value: "nezha:transfer:delete",
label: "Transfer: delete",
desc: "Delete server transfer records",
},
{ value: "nezha:transfer:*", label: "Transfer: all", desc: "Every transfer permission" }, { value: "nezha:transfer:*", label: "Transfer: all", desc: "Every transfer permission" },
{ value: "nezha:admin:*", label: "Admin: all (admin only)", desc: "User / WAF / Setting / Online-user management" }, {
value: "nezha:admin:*",
label: "Admin: all (admin only)",
desc: "User / WAF / Setting / Online-user management",
},
{ value: "nezha:*", label: "Everything (admin only)", desc: "Full access to all resources" }, { value: "nezha:*", label: "Everything (admin only)", desc: "Full access to all resources" },
] as const ] as const
export type Scope = (typeof SCOPE_OPTIONS)[number]["value"] export type Scope = (typeof SCOPE_OPTIONS)[number]["value"]
export type ParseServerIDsResult = export type ParseServerIDsResult =
| { ok: true; value: number[] | undefined } { ok: true; value: number[] | undefined } | { ok: false; error: string }
| { ok: false; error: string }
export type ParseExpiresInDaysResult = export type ParseExpiresInDaysResult =
| { ok: true; value: number | undefined } { ok: true; value: number | undefined } | { ok: false; error: string }
| { ok: false; error: string }
// Validates the "expires in days" field before it is sent to the backend. // Validates the "expires in days" field before it is sent to the backend.
// The backend model field is `ExpiresInDays int`, so a fractional value would // The backend model field is `ExpiresInDays int`, so a fractional value would
@@ -130,7 +200,8 @@ export function parseServerIDsInput(raw: string): ParseServerIDsResult {
// Server IDs are uint64 on the backend; reject values that lose // Server IDs are uint64 on the backend; reject values that lose
// precision as a JS number, otherwise the PAT could bind to a // precision as a JS number, otherwise the PAT could bind to a
// different server than the operator typed. // different server than the operator typed.
if (!Number.isSafeInteger(n) || n <= 0) return { ok: false, error: `invalid server id: ${p}` } if (!Number.isSafeInteger(n) || n <= 0)
return { ok: false, error: `invalid server id: ${p}` }
if (seen.has(n)) continue if (seen.has(n)) continue
seen.add(n) seen.add(n)
out.push(n) out.push(n)
+5 -1
View File
@@ -26,7 +26,11 @@ export const deleteServer = async (id: number[]): Promise<void> => {
export const batchMoveServer = async ( export const batchMoveServer = async (
data: ModelBatchMoveServerForm, data: ModelBatchMoveServerForm,
): Promise<ModelBatchMoveServerResult[]> => { ): Promise<ModelBatchMoveServerResult[]> => {
return fetcher<ModelBatchMoveServerResult[]>(FetcherMethod.POST, "/api/v1/batch-move/server", data) 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> => {
+29 -32
View File
@@ -102,22 +102,22 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
resolver: zodResolver(alertRuleFormSchema), 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,
}, },
@@ -195,10 +195,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
<DialogDescription /> <DialogDescription />
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 my-2"
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -369,9 +366,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)
@@ -392,9 +389,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)
@@ -456,8 +453,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 ""
@@ -468,8 +465,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],
@@ -523,9 +520,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)
+18 -18
View File
@@ -72,25 +72,25 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
resolver: zodResolver(cronFormSchema), 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.command ?? "", command: data.command ?? "",
servers: data.servers ?? [], servers: data.servers ?? [],
cover: data.cover ?? 0, cover: data.cover ?? 0,
push_successful: data.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,
}, },
+30 -30
View File
@@ -76,37 +76,37 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
resolver: zodResolver(ddnsFormSchema), resolver: zodResolver(ddnsFormSchema),
defaultValues: data defaultValues: data
? { ? {
max_retries: data.max_retries ?? 3, max_retries: data.max_retries ?? 3,
enable_ipv4: data.enable_ipv4 ?? false, enable_ipv4: data.enable_ipv4 ?? false,
enable_ipv6: data.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.access_id ?? "", access_id: data.access_id ?? "",
access_secret: data.access_secret ?? "", access_secret: data.access_secret ?? "",
webhook_url: data.webhook_url ?? "", webhook_url: data.webhook_url ?? "",
webhook_method: data.webhook_method, webhook_method: data.webhook_method,
webhook_request_type: data.webhook_request_type, webhook_request_type: data.webhook_request_type,
webhook_request_body: data.webhook_request_body ?? "", webhook_request_body: data.webhook_request_body ?? "",
webhook_headers: data.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,
}, },
+30 -24
View File
@@ -71,7 +71,10 @@ const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
return true return true
} }
export 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)
@@ -162,30 +165,30 @@ export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({
useEffect(() => { useEffect(() => {
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => { worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
switch (event.data.type) { switch (event.data.type) {
case FMWorkerOpcode.Error: { case FMWorkerOpcode.Error: {
console.error("Error from worker", event.data.error) console.error("Error from worker", event.data.error)
break 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)
} }
case FMWorkerOpcode.Progress: {
handleReady.current = true
break
}
case FMWorkerOpcode.Result: {
handleReady.current = false
firstChunk.current = true if (event.data.blob && event.data.fileName) {
if (dOpen) setdOpen(false) const url = URL.createObjectURL(event.data.blob)
break 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
}
} }
} }
@@ -342,7 +345,10 @@ export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({
try { try {
await copyToClipboard(formatPath(currentPath)) await copyToClipboard(formatPath(currentPath))
} catch (error) { } catch (error) {
const description = error instanceof Error ? error.message : t("Results.UnExpectedError") const description =
error instanceof Error
? error.message
: t("Results.UnExpectedError")
toast("FM" + " " + t("Error"), { toast("FM" + " " + t("Error"), {
description, description,
}) })
+10 -10
View File
@@ -154,15 +154,15 @@ const generateCommand = (
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}`)
} }
} }
} }
+12 -12
View File
@@ -55,19 +55,19 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
resolver: zodResolver(natFormSchema), resolver: zodResolver(natFormSchema),
defaultValues: data defaultValues: data
? { ? {
name: data.name ?? "", name: data.name ?? "",
enabled: data.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,
}, },
+6 -6
View File
@@ -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,
}, },
+23 -25
View File
@@ -64,34 +64,35 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
const { t } = useTranslation() const { t } = useTranslation()
type NotificationFormInput = z.input<typeof notificationFormSchema> type NotificationFormInput = z.input<typeof notificationFormSchema>
type NotificationFormData = z.output<typeof notificationFormSchema> type NotificationFormData = z.output<typeof notificationFormSchema>
type NotificationDefaults = ModelNotification & Partial<Pick<NotificationFormData, "skip_check">> type NotificationDefaults = ModelNotification &
Partial<Pick<NotificationFormData, "skip_check">>
const notificationDefaults: NotificationDefaults | undefined = data const notificationDefaults: NotificationDefaults | undefined = data
const form = useForm<NotificationFormInput, unknown, NotificationFormData>({ const form = useForm<NotificationFormInput, unknown, NotificationFormData>({
resolver: zodResolver(notificationFormSchema), 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.verify_tls ?? false, verify_tls: data.verify_tls ?? false,
skip_check: notificationDefaults?.skip_check ?? false, skip_check: notificationDefaults?.skip_check ?? false,
format_metric_units: data.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,
}, },
@@ -133,10 +134,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
<DialogDescription /> <DialogDescription />
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 my-2">
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 my-2"
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
+6 -6
View File
@@ -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,
}, },
+14 -10
View File
@@ -467,8 +467,10 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{publicNoteObj.billingDataMod {publicNoteObj.billingDataMod
?.startDate ?.startDate
? new Date( ? new Date(
publicNoteObj.billingDataMod!.startDate!, publicNoteObj
).toLocaleDateString() .billingDataMod!
.startDate!,
).toLocaleDateString()
: "YYYY-MM-DD"} : "YYYY-MM-DD"}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -492,8 +494,10 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
.billingDataMod .billingDataMod
?.startDate ?.startDate
? new Date( ? new Date(
publicNoteObj.billingDataMod!.startDate!, publicNoteObj
) .billingDataMod!
.startDate!,
)
: undefined : undefined
} }
onSelect={(d) => { onSelect={(d) => {
@@ -560,17 +564,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>
@@ -601,7 +605,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) => {
+21 -21
View File
@@ -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,
}, },
+1 -2
View File
@@ -23,8 +23,7 @@ const badgeVariants = cva(
) )
export interface BadgeProps export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>, extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} /> return <div className={cn(badgeVariants({ variant }), className)} {...props} />
+1 -2
View File
@@ -31,8 +31,7 @@ const buttonVariants = cva(
) )
export interface ButtonProps export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
} }
+2 -2
View File
@@ -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} />
+5 -4
View File
@@ -1,9 +1,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
type SVGProps = React.ComponentPropsWithoutRef<"svg"> type SVGProps = React.ComponentPropsWithoutRef<"svg">
type IconMarkupState = type IconMarkupState = { status: "ready"; icon: ParsedIconMarkup } | { status: "missing" }
| { status: "ready"; icon: ParsedIconMarkup }
| { status: "missing" }
type ParsedIconMarkup = { type ParsedIconMarkup = {
title?: string title?: string
@@ -26,7 +24,10 @@ const iconLoadersBySlug = Object.fromEntries(
const iconMarkupCache = new Map<string, IconMarkupState>() const iconMarkupCache = new Map<string, IconMarkupState>()
function toProviderSlug(provider: string) { function toProviderSlug(provider: string) {
return provider.trim().replace(/[^a-z0-9]+/gi, "").toLowerCase() return provider
.trim()
.replace(/[^a-z0-9]+/gi, "")
.toLowerCase()
} }
function loadProviderIconMarkup(provider: string) { function loadProviderIconMarkup(provider: string) {
+54 -54
View File
@@ -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>
+2 -1
View File
@@ -67,7 +67,8 @@ const multiSelectVariants = cva(
* Props for MultiSelect component * Props for MultiSelect component
*/ */
interface MultiSelectProps interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> { VariantProps<typeof multiSelectVariants> {
/** /**
* An array of option objects to be displayed in the multi-select component. * An array of option objects to be displayed in the multi-select component.
+2 -1
View File
@@ -37,7 +37,8 @@ const sheetVariants = cva(
) )
interface SheetContentProps interface SheetContentProps
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends
ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { VariantProps<typeof sheetVariants> {
setOpen: Dispatch<SetStateAction<boolean>> setOpen: Dispatch<SetStateAction<boolean>>
} }
+3 -3
View File
@@ -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(),
+23 -20
View File
@@ -53,27 +53,30 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate() const navigate = useNavigate()
const login = useCallback(async (username: string, password: string) => { const login = useCallback(
try { async (username: string, password: string) => {
await loginRequest(username, password) try {
const user = await getProfile() await loginRequest(username, password)
authEpoch.current++ const user = await getProfile()
user.role = normalizeRole(user.role) authEpoch.current++
setProfile(user) user.role = normalizeRole(user.role)
navigate("/dashboard") setProfile(user)
} catch (error: any) { navigate("/dashboard")
const msg = error?.message } catch (error: any) {
if (msg === "ApiErrorUnauthorized" || msg === "Unauthorized") { const msg = error?.message
toast(t("InvalidUsernameOrPassword")) if (msg === "ApiErrorUnauthorized" || msg === "Unauthorized") {
} else { toast(t("InvalidUsernameOrPassword"))
toast(msg || t("NetworkError")) } else {
toast(msg || t("NetworkError"))
}
} finally {
// An explicit login resolves auth regardless of the still-pending
// mount probe; clear loading so ProtectedRoute stops blanking.
setLoading(false)
} }
} finally { },
// An explicit login resolves auth regardless of the still-pending [navigate, setProfile, t],
// mount probe; clear loading so ProtectedRoute stops blanking. )
setLoading(false)
}
}, [navigate, setProfile, t])
const loginOauth2 = useCallback(async () => { const loginOauth2 = useCallback(async () => {
try { try {
+1 -1
View File
@@ -117,5 +117,5 @@ body,
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply rounded-full border-[1px] border-solid border-transparent bg-border bg-clip-padding; @apply bg-border rounded-full border-[1px] border-solid border-transparent bg-clip-padding;
} }
+42 -42
View File
@@ -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: {
accessHandle.write(arrayBuffer, { at: receivedLength }) if (!accessHandle) {
receivedLength += arrayBuffer.byteLength throw new Error("accessHandle is undefined")
if (receivedLength === expectedLength) {
accessHandle.flush()
accessHandle.close()
const fileBlob = await draftHandle.getFile()
const blob = new Blob([fileBlob], { type: "application/octet-stream" })
postMessage({ type: 2, blob: blob, fileName: fileName })
}
break
}
case Operation.DeleteFiles: {
for await (const [name, handle] of root.entries()) {
if (handle.kind === "file") {
await root.removeEntry(name)
} else if (handle.kind === "directory") {
await root.removeEntry(name, { recursive: true })
} }
accessHandle.write(arrayBuffer, { at: receivedLength })
receivedLength += arrayBuffer.byteLength
if (receivedLength === expectedLength) {
accessHandle.flush()
accessHandle.close()
const fileBlob = await draftHandle.getFile()
const blob = new Blob([fileBlob], { type: "application/octet-stream" })
postMessage({ type: 2, blob: blob, fileName: fileName })
}
break
}
case Operation.DeleteFiles: {
for await (const [name, handle] of root.entries()) {
if (handle.kind === "file") {
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 })
+3 -3
View File
@@ -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>
) )
})} })}
+18 -6
View File
@@ -98,7 +98,10 @@ export default function ApiTokensPage() {
</TableRow> </TableRow>
) : !data || data.length === 0 ? ( ) : !data || data.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground"> <TableCell
colSpan={6}
className="h-24 text-center text-muted-foreground"
>
{t("NoResults")} {t("NoResults")}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -119,10 +122,14 @@ export default function ApiTokensPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
{tok.server_ids?.length ? tok.server_ids.join(", ") : t("ApiTokenAllServers")} {tok.server_ids?.length
? tok.server_ids.join(", ")
: t("ApiTokenAllServers")}
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
{tok.expires_at ? new Date(tok.expires_at).toLocaleString() : t("ApiTokenNever")} {tok.expires_at
? new Date(tok.expires_at).toLocaleString()
: t("ApiTokenNever")}
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
{tok.last_used_at {tok.last_used_at
@@ -239,7 +246,8 @@ function CreateApiTokenDialog({
<Label>{t("Scopes")}</Label> <Label>{t("Scopes")}</Label>
<div className="max-h-72 space-y-2 overflow-y-auto pr-2"> <div className="max-h-72 space-y-2 overflow-y-auto pr-2">
{SCOPE_OPTIONS.map((s) => { {SCOPE_OPTIONS.map((s) => {
const adminOnly = s.value === "nezha:*" || s.value === "nezha:admin:*" const adminOnly =
s.value === "nezha:*" || s.value === "nezha:admin:*"
const disabled = adminOnly && !isAdmin const disabled = adminOnly && !isAdmin
return ( return (
<label <label
@@ -248,12 +256,16 @@ function CreateApiTokenDialog({
> >
<Checkbox <Checkbox
checked={scopes.includes(s.value)} checked={scopes.includes(s.value)}
onCheckedChange={() => !disabled && toggleScope(s.value)} onCheckedChange={() =>
!disabled && toggleScope(s.value)
}
disabled={disabled} disabled={disabled}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-mono text-xs">{s.value}</span> <span className="font-mono text-xs">{s.value}</span>
<span className="text-xs text-muted-foreground">{s.desc}</span> <span className="text-xs text-muted-foreground">
{s.desc}
</span>
</div> </div>
</label> </label>
) )
+12 -12
View File
@@ -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
View File
@@ -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>
) )
})} })}
+7 -7
View File
@@ -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
View File
@@ -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>
) )
})} })}
+3 -3
View File
@@ -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>
) )
})} })}
+3 -3
View File
@@ -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>
) )
})} })}
+3 -3
View File
@@ -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>
) )
})} })}
+3 -3
View File
@@ -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>
) )
})} })}
+3 -3
View File
@@ -292,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>
) )
})} })}
+9 -9
View File
@@ -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>
) )
})} })}
+16 -14
View File
@@ -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,
}, },
@@ -224,9 +224,13 @@ export default function SettingsPage() {
</div> </div>
</SelectItem> </SelectItem>
<div className="px-8 py-1"> <div className="px-8 py-1">
{safeExternalHref(template.repository) ? ( {safeExternalHref(
template.repository,
) ? (
<a <a
href={safeExternalHref(template.repository)} href={safeExternalHref(
template.repository,
)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 hover:underline" className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
@@ -552,9 +556,7 @@ export default function SettingsPage() {
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
/> />
<Label className="text-sm"> <Label className="text-sm">{t("EnableMCP")}</Label>
{t("EnableMCP")}
</Label>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
+3 -8
View File
@@ -1,7 +1,7 @@
import { swrFetcher } from "@/api/api" import { swrFetcher } from "@/api/api"
import { cancelServerTransfer, retryServerTransfer } from "@/api/transfer" import { cancelServerTransfer, retryServerTransfer } from "@/api/transfer"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { import {
Table, Table,
TableBody, TableBody,
@@ -45,10 +45,7 @@ export default function TransferPage() {
const { profile } = useAuth() const { profile } = useAuth()
const isAdmin = profile?.role === 0 const isAdmin = profile?.role === 0
const callerID = profile?.id const callerID = profile?.id
const { data, mutate, error } = useSWR<ModelServerTransfer[]>( const { data, mutate, error } = useSWR<ModelServerTransfer[]>("/api/v1/transfer", swrFetcher)
"/api/v1/transfer",
swrFetcher,
)
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -182,9 +179,7 @@ export default function TransferPage() {
return ( return (
<div className="px-3"> <div className="px-3">
<h1 className="mt-6 text-2xl font-semibold">{t("Transfer.Title")}</h1> <h1 className="mt-6 text-2xl font-semibold">{t("Transfer.Title")}</h1>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl"> <p className="text-sm text-muted-foreground mt-1 max-w-2xl">{t("Transfer.PageHint")}</p>
{t("Transfer.PageHint")}
</p>
<Table className="mt-6"> <Table className="mt-6">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
+3 -3
View File
@@ -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
View File
@@ -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>
) )
})} })}
+11 -10
View File
@@ -6,10 +6,13 @@ const realFetch = globalThis.fetch
function mockFetch(payload: unknown, ok = true, success = true) { function mockFetch(payload: unknown, ok = true, success = true) {
globalThis.fetch = vi.fn(async () => { globalThis.fetch = vi.fn(async () => {
return new Response(JSON.stringify({ success, error: success ? "" : "boom", data: payload }), { return new Response(
status: ok ? 200 : 500, JSON.stringify({ success, error: success ? "" : "boom", data: payload }),
headers: { "Content-Type": "application/json" }, {
}) status: ok ? 200 : 500,
headers: { "Content-Type": "application/json" },
},
)
}) as unknown as typeof fetch }) as unknown as typeof fetch
} }
@@ -94,9 +97,9 @@ test("createApiToken POSTs /api/v1/api-tokens and serializes scopes / server_ids
test("createApiToken surfaces server error via thrown Error", async () => { test("createApiToken surfaces server error via thrown Error", async () => {
mockFetch(null, true, false) mockFetch(null, true, false)
await expect( await expect(createApiToken({ name: "x", scopes: ["nezha:server:read"] })).rejects.toThrow(
createApiToken({ name: "x", scopes: ["nezha:server:read"] }), "boom",
).rejects.toThrow("boom") )
}) })
test("listApiTokens normalizes null scopes to an empty array so the table cannot crash", async () => { test("listApiTokens normalizes null scopes to an empty array so the table cannot crash", async () => {
@@ -107,9 +110,7 @@ test("listApiTokens normalizes null scopes to an empty array so the table cannot
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
data: [ data: [{ id: 1, name: "legacy", scopes: null, created_at: "2025-01-01T00:00:00Z" }],
{ id: 1, name: "legacy", scopes: null, created_at: "2025-01-01T00:00:00Z" },
],
}), }),
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
) )
+1 -2
View File
@@ -9,8 +9,7 @@ const setProfileSpy = vi.fn((p: any) => {
vi.mock("./useMainStore", () => ({})) vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({ vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) => useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
selector({ profile: profileStore, setProfile: setProfileSpy }),
})) }))
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
+1 -2
View File
@@ -9,8 +9,7 @@ const setProfileSpy = vi.fn((p: any) => {
vi.mock("./useMainStore", () => ({})) vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({ vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) => useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
selector({ profile: profileStore, setProfile: setProfileSpy }),
})) }))
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
+1 -2
View File
@@ -1,3 +1,4 @@
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest" import { afterEach, beforeEach, expect, test, vi } from "vitest"
@@ -26,8 +27,6 @@ vi.mock("@/api/server", () => ({
batchMoveServer: (...args: unknown[]) => batchMoveServer(...args), batchMoveServer: (...args: unknown[]) => batchMoveServer(...args),
})) }))
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
beforeEach(() => { beforeEach(() => {
toastCalls.length = 0 toastCalls.length = 0
batchMoveServer.mockReset() batchMoveServer.mockReset()
+3 -1
View File
@@ -83,7 +83,9 @@ test("auto refresh-token uses POST (backend route is POST)", async () => {
// Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not // Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not
// blow up on response.json() of an empty body and must resolve successfully. // blow up on response.json() of an empty body and must resolve successfully.
test("DELETE tolerates an empty 204 response body", async () => { test("DELETE tolerates an empty 204 response body", async () => {
globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch globalThis.fetch = vi.fn(
async () => new Response(null, { status: 204 }),
) as unknown as typeof fetch
await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined() await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
}) })
+6 -3
View File
@@ -1,6 +1,8 @@
import { act, render, waitFor } from "@testing-library/react" import { act, render, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest" import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FMComponent } from "../components/fm"
type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean } type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean }
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & { type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
asChild?: boolean asChild?: boolean
@@ -106,8 +108,6 @@ vi.mock("@/lib/utils", () => ({
formatPath: (path: string) => path, formatPath: (path: string) => path,
})) }))
import { FMComponent } from "../components/fm"
const webSockets: MockWebSocket[] = [] const webSockets: MockWebSocket[] = []
class MockWebSocket extends EventTarget implements WebSocket { class MockWebSocket extends EventTarget implements WebSocket {
@@ -235,7 +235,10 @@ test("FM websocket lifecycle reuses the socket on path changes", async () => {
socket.sent.length = 0 socket.sent.length = 0
await act(async () => { await act(async () => {
await socket.onmessage?.call(socket, new MessageEvent("message", { data: encodeCompleteMessage() })) await socket.onmessage?.call(
socket,
new MessageEvent("message", { data: encodeCompleteMessage() }),
)
}) })
expect(webSockets).toHaveLength(1) expect(webSockets).toHaveLength(1)
+5 -18
View File
@@ -32,15 +32,11 @@ function renderAtPath(path: string, child: React.ReactNode) {
<Routes> <Routes>
<Route <Route
path="/dashboard/login" path="/dashboard/login"
element={ element={<ProtectedRoute>{child}</ProtectedRoute>}
<ProtectedRoute>{child}</ProtectedRoute>
}
/> />
<Route <Route
path="/dashboard/*" path="/dashboard/*"
element={ element={<ProtectedRoute>{child}</ProtectedRoute>}
<ProtectedRoute>{child}</ProtectedRoute>
}
/> />
</Routes> </Routes>
</MemoryRouter>, </MemoryRouter>,
@@ -50,18 +46,12 @@ function renderAtPath(path: string, child: React.ReactNode) {
} }
test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => { test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => {
await renderAtPath( await renderAtPath("/dashboard", <div data-testid="protected-child">protected</div>)
"/dashboard",
<div data-testid="protected-child">protected</div>,
)
expect(document.querySelector("[data-testid='protected-child']")).toBeNull() expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
}) })
test("ProtectedRoute renders children on the login page even while auth is loading", async () => { test("ProtectedRoute renders children on the login page even while auth is loading", async () => {
await renderAtPath( await renderAtPath("/dashboard/login", <div data-testid="login-child">login</div>)
"/dashboard/login",
<div data-testid="login-child">login</div>,
)
expect(document.querySelector("[data-testid='login-child']")).not.toBeNull() expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
}) })
@@ -100,9 +90,6 @@ test("ProtectedRoute redirects unauthenticated users without mounting protected
test("ProtectedRoute renders children once an authenticated profile resolves", async () => { test("ProtectedRoute renders children once an authenticated profile resolves", async () => {
mockLoading = false mockLoading = false
mockProfile = { id: 1, role: 0 } mockProfile = { id: 1, role: 0 }
await renderAtPath( await renderAtPath("/dashboard", <div data-testid="protected-child">protected</div>)
"/dashboard",
<div data-testid="protected-child">protected</div>,
)
expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull() expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull()
}) })
+3 -1
View File
@@ -47,7 +47,9 @@ vi.mock("@/hooks/useNotfication", () => ({
})) }))
const updateSettings = vi.fn() const updateSettings = vi.fn()
vi.mock("@/api/settings", () => ({ updateSettings: (...args: unknown[]) => updateSettings(...args) })) vi.mock("@/api/settings", () => ({
updateSettings: (...args: unknown[]) => updateSettings(...args),
}))
beforeEach(() => { beforeEach(() => {
toastCalls.length = 0 toastCalls.length = 0
+2 -6
View File
@@ -1,3 +1,4 @@
import type { ModelServerTransfer } from "@/types"
import { act, render, screen, waitFor } from "@testing-library/react" import { act, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest" import { afterEach, beforeEach, expect, test, vi } from "vitest"
@@ -47,8 +48,6 @@ vi.mock("@/hooks/useMainStore", () => ({
}, },
})) }))
import type { ModelServerTransfer } from "@/types"
let mockedRows: ModelServerTransfer[] = [] let mockedRows: ModelServerTransfer[] = []
beforeEach(() => { beforeEach(() => {
@@ -127,10 +126,7 @@ test("non-admin member who is only the ToUserID or InitiatorID sees neither Canc
test("admin sees both Cancel for pending and Retry for terminal", async () => { test("admin sees both Cancel for pending and Retry for terminal", async () => {
mockProfile = { id: 1, role: 0 } mockProfile = { id: 1, role: 0 }
mockedRows = [ mockedRows = [makeRow({ id: 1, status: 0 }), makeRow({ id: 2, status: 2 })]
makeRow({ id: 1, status: 0 }),
makeRow({ id: 2, status: 2 }),
]
await renderPage() await renderPage()
+4 -1
View File
@@ -85,7 +85,10 @@ test("an API token can authenticate /mcp", async ({ page }) => {
headers: await csrfHeaders(page), headers: await csrfHeaders(page),
data: { ...baseSettings, enable_mcp: true }, data: { ...baseSettings, enable_mcp: true },
}) })
expect(enableResp.ok(), "PATCH /api/v1/setting must succeed to enable MCP for the test").toBeTruthy() expect(
enableResp.ok(),
"PATCH /api/v1/setting must succeed to enable MCP for the test",
).toBeTruthy()
try { try {
const apiResp = await page.request.post("/api/v1/api-tokens", { const apiResp = await page.request.post("/api/v1/api-tokens", {
+12 -2
View File
@@ -1,6 +1,13 @@
import { expect } from "@playwright/test" import { expect } from "@playwright/test"
import { csrfRequest, defaultAdmin, expectAuthenticated, expectUnauthenticated, loginAs, test } from "./fixtures" import {
csrfRequest,
defaultAdmin,
expectAuthenticated,
expectUnauthenticated,
loginAs,
test,
} from "./fixtures"
test("login persists session via cookie and getProfile succeeds", async ({ page }) => { test("login persists session via cookie and getProfile succeeds", async ({ page }) => {
await loginAs(page, defaultAdmin) await loginAs(page, defaultAdmin)
@@ -51,7 +58,10 @@ test("password change rotates TokenVersion and revokes existing session", async
reject_password: false, reject_password: false,
}, },
}) })
expect(restoreResp.ok(), "password restore must succeed so other suites can still log in").toBeTruthy() expect(
restoreResp.ok(),
"password restore must succeed so other suites can still log in",
).toBeTruthy()
} }
} }
}) })
+3 -2
View File
@@ -1,4 +1,4 @@
import { Page, Request, expect, test as base } from "@playwright/test" import { Page, Request, test as base, expect } from "@playwright/test"
export type LoginContext = { export type LoginContext = {
username: string username: string
@@ -39,7 +39,8 @@ export async function csrfHeaders(page: Page): Promise<Record<string, string>> {
await expect await expect
.poll( .poll(
async () => { async () => {
value = (await page.context().cookies()).find((c) => c.name === "nz-csrf")?.value ?? "" value =
(await page.context().cookies()).find((c) => c.name === "nz-csrf")?.value ?? ""
return value return value
}, },
{ timeout: 10_000 }, { timeout: 10_000 },
+4 -1
View File
@@ -17,6 +17,9 @@ test("file manager creation only accepts POST", async ({ adminPage: page }) => {
}) })
expect(postResp.status()).toBe(200) expect(postResp.status()).toBe(200)
const body = await postResp.json() const body = await postResp.json()
expect(body.success, "without a connected agent server the POST surfaces a Service error, but the route is reachable").not.toBe(true) expect(
body.success,
"without a connected agent server the POST surfaces a Service error, but the route is reachable",
).not.toBe(true)
expect(body.error).toBeTruthy() expect(body.error).toBeTruthy()
}) })
+14 -4
View File
@@ -2,7 +2,10 @@ import { expect } from "@playwright/test"
import { csrfHeaders, test } from "./fixtures" import { csrfHeaders, test } from "./fixtures"
test("server-group hides guest-empty groups from anonymous callers", async ({ adminPage: page, browser }) => { test("server-group hides guest-empty groups from anonymous callers", async ({
adminPage: page,
browser,
}) => {
const tag = Date.now().toString(36) const tag = Date.now().toString(36)
const visibleName = `e2e-visible-${tag}` const visibleName = `e2e-visible-${tag}`
const hiddenName = `e2e-hidden-${tag}` const hiddenName = `e2e-hidden-${tag}`
@@ -12,7 +15,9 @@ test("server-group hides guest-empty groups from anonymous callers", async ({ ad
// scenario boils down to "no group is guest-visible", which the assertion below still covers. // scenario boils down to "no group is guest-visible", which the assertion below still covers.
const serversResp = await page.request.get("/api/v1/server") const serversResp = await page.request.get("/api/v1/server")
expect(serversResp.ok()).toBeTruthy() expect(serversResp.ok()).toBeTruthy()
const serversBody = (await serversResp.json()) as { data: Array<{ id: number; hide_for_guest?: boolean }> } const serversBody = (await serversResp.json()) as {
data: Array<{ id: number; hide_for_guest?: boolean }>
}
const publicServer = serversBody.data?.find((s) => !s.hide_for_guest) const publicServer = serversBody.data?.find((s) => !s.hide_for_guest)
const createdGroupIDs: number[] = [] const createdGroupIDs: number[] = []
@@ -40,9 +45,14 @@ test("server-group hides guest-empty groups from anonymous callers", async ({ ad
data: Array<{ group: { name: string }; servers: number[] }> data: Array<{ group: { name: string }; servers: number[] }>
} }
const names = (guestBody.data || []).map((it) => it.group.name) const names = (guestBody.data || []).map((it) => it.group.name)
expect(names, "guest must NOT see groups with zero visible servers").not.toContain(hiddenName) expect(names, "guest must NOT see groups with zero visible servers").not.toContain(
hiddenName,
)
if (publicServer) { if (publicServer) {
expect(names, "guest still sees groups that contain a guest-visible server").toContain(visibleName) expect(
names,
"guest still sees groups that contain a guest-visible server",
).toContain(visibleName)
} }
} finally { } finally {
await guestCtx.close() await guestCtx.close()