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
+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"
+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> => {
+1 -4
View File
@@ -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"
+8 -2
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)
@@ -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,
}) })
+3 -5
View File
@@ -64,7 +64,8 @@ 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>({
@@ -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 -2
View File
@@ -467,7 +467,9 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{publicNoteObj.billingDataMod {publicNoteObj.billingDataMod
?.startDate ?.startDate
? new Date( ? new Date(
publicNoteObj.billingDataMod!.startDate!, publicNoteObj
.billingDataMod!
.startDate!,
).toLocaleDateString() ).toLocaleDateString()
: "YYYY-MM-DD"} : "YYYY-MM-DD"}
</Button> </Button>
@@ -492,7 +494,9 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
.billingDataMod .billingDataMod
?.startDate ?.startDate
? new Date( ? new Date(
publicNoteObj.billingDataMod!.startDate!, publicNoteObj
.billingDataMod!
.startDate!,
) )
: undefined : undefined
} }
+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
} }
+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) {
+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>>
} }
+5 -2
View File
@@ -53,7 +53,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate() const navigate = useNavigate()
const login = useCallback(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()
@@ -73,7 +74,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
// mount probe; clear loading so ProtectedRoute stops blanking. // mount probe; clear loading so ProtectedRoute stops blanking.
setLoading(false) setLoading(false)
} }
}, [navigate, setProfile, t]) },
[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;
} }
+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>
) )
+7 -5
View File
@@ -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>
+9 -8
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(
JSON.stringify({ success, error: success ? "" : "boom", data: payload }),
{
status: ok ? 200 : 500, status: ok ? 200 : 500,
headers: { "Content-Type": "application/json" }, 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()