├── .env.example ├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE.md ├── NOTICE.txt ├── README.md ├── bigint.d.ts ├── biome.json ├── components.json ├── drizzle.config.ts ├── drizzle ├── 0000_auth.sql ├── 0001_init.sql ├── 0002_page_handle_hostname.sql ├── 0003_rename_probe.sql └── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ └── _journal.json ├── esbuild.config.ts ├── global.d.ts ├── messages └── zh.json ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── public ├── icon.svg └── site.webmanifest ├── renovate.json ├── reset.d.ts ├── src ├── app │ ├── [handle] │ │ ├── data.ts │ │ ├── layout.tsx │ │ ├── page.client.tsx │ │ └── page.tsx │ ├── _lib │ │ ├── actions.ts │ │ ├── queries.ts │ │ ├── seeds.ts │ │ ├── utils.ts │ │ └── validations.ts │ ├── api │ │ ├── [[...route]] │ │ │ └── route.ts │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ ├── auth │ │ ├── layout.tsx │ │ └── page.tsx │ ├── dash │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── page │ │ │ ├── [pageId] │ │ │ │ └── page.tsx │ │ │ ├── page.client.tsx │ │ │ └── page.tsx │ │ ├── setting │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── personal │ │ │ │ ├── account │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── site │ │ │ │ ├── general │ │ │ │ └── page.tsx │ │ │ │ ├── notification │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ └── terminal │ │ │ ├── page.client.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── robots.ts │ ├── sitemap.ts │ └── vm │ │ └── [vmId] │ │ ├── layout.tsx │ │ └── page.tsx ├── components │ ├── data-table │ │ ├── data-table-advanced-toolbar.tsx │ │ ├── data-table-column-header.tsx │ │ ├── data-table-faceted-filter.tsx │ │ ├── data-table-filter-list.tsx │ │ ├── data-table-pagination.tsx │ │ ├── data-table-skeleton.tsx │ │ ├── data-table-sort-list.tsx │ │ ├── data-table-toolbar.tsx │ │ ├── data-table-view-options.tsx │ │ └── data-table.tsx │ ├── date-range-picker.tsx │ ├── derive-ui │ │ ├── animate-count.tsx │ │ └── animated-circular-progress-bar.tsx │ ├── icons.tsx │ ├── icons │ │ ├── dmit.tsx │ │ └── logo.tsx │ ├── kbd.tsx │ ├── layouts │ │ ├── link-bar.tsx │ │ ├── mode-toggle.tsx │ │ ├── profile.client.tsx │ │ ├── profile.tsx │ │ └── site-header.tsx │ ├── loading.tsx │ ├── markdown.tsx │ ├── merchant │ │ └── create-merchant-dialog.tsx │ ├── monitor │ │ ├── LICENSE-APACHE │ │ ├── network-chart-loading.tsx │ │ ├── network-chart.tsx │ │ ├── server-card-inline.tsx │ │ ├── server-card.tsx │ │ ├── server-detail-chart.tsx │ │ ├── server-detail-loading.tsx │ │ ├── server-detail.tsx │ │ ├── server-flag.tsx │ │ ├── server-ip-info.tsx │ │ ├── server-list-card.tsx │ │ ├── server-usage-bar.tsx │ │ └── vm-data-context.tsx │ ├── overview-cards.tsx │ ├── page │ │ ├── add-vm-dialog.tsx │ │ ├── create-page-dialog.tsx │ │ └── modify-vm-dialog.tsx │ ├── providers.tsx │ ├── setting │ │ ├── items.tsx │ │ ├── setting-list.tsx │ │ └── setting-sidebar.tsx │ ├── shell.tsx │ ├── tailwind-indicator.tsx │ ├── terminal.tsx │ ├── ui │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── faceted-filter.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── portal.tsx │ │ ├── progress.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── sortable.tsx │ │ ├── stepper.tsx │ │ ├── switch.tsx │ │ ├── tab-switch.tsx │ │ ├── table.tsx │ │ ├── textarea.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ └── vm │ │ ├── create-vm-dialog.tsx │ │ ├── create-vm-sheet.tsx │ │ ├── delete-vms-dialog.tsx │ │ ├── feature-flags-provider.tsx │ │ ├── feature-flags.tsx │ │ ├── update-vm-sheet.tsx │ │ ├── vms-table-columns.tsx │ │ ├── vms-table-floating-bar.tsx │ │ ├── vms-table-toolbar-actions.tsx │ │ └── vms-table.tsx ├── config │ ├── data-table.ts │ └── site.ts ├── db │ ├── bigint-jsonb.ts │ ├── index.ts │ ├── migrate.ts │ ├── redis.ts │ ├── schema.ts │ ├── schema │ │ ├── auth.ts │ │ ├── config.ts │ │ ├── merchant.ts │ │ ├── metrics.ts │ │ ├── page.ts │ │ ├── ssh-key.ts │ │ └── vm.ts │ └── utils.ts ├── env.js ├── hooks │ ├── use-callback-ref.ts │ ├── use-data-table.ts │ ├── use-debounce.ts │ ├── use-debounced-callback.ts │ ├── use-media-query.ts │ ├── use-mobile.tsx │ ├── use-monitor.ts │ └── use-query-string.ts ├── i18n │ ├── pick.ts │ └── request.ts ├── lib │ ├── api-client.ts │ ├── auth-client.ts │ ├── auth.ts │ ├── composition.ts │ ├── constants.ts │ ├── data-table.ts │ ├── export.ts │ ├── filter-columns.ts │ ├── fonts.ts │ ├── handle-error.ts │ ├── id.ts │ ├── logo-class.tsx │ ├── parsers.ts │ ├── rsc-client.ts │ ├── session.ts │ ├── unstable-cache.ts │ └── utils.ts ├── middleware.ts ├── queues │ ├── analysis.ts │ ├── index.ts │ ├── notification.ts │ └── utils.ts ├── server.ts ├── server │ ├── error.ts │ ├── factory.ts │ ├── hc.ts │ ├── index.ts │ ├── middleware │ │ └── auth.ts │ ├── routes │ │ ├── merchant.ts │ │ ├── page.ts │ │ └── vm.ts │ └── wss │ │ ├── manager │ │ ├── monitor-manager.ts │ │ ├── socket.ts │ │ └── vm-manager.ts │ │ ├── monitor.ts │ │ ├── probe.ts │ │ ├── terminal.ts │ │ ├── types.ts │ │ └── utils.ts ├── styles │ └── globals.css └── types │ ├── index.ts │ └── metrics.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.mjs" 10 | # should be updated accordingly. 11 | 12 | # Database 13 | DATABASE_URL="postgres://YOUR_POSTGRESQL_URL" 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: AprilNEA # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: AprilNEA # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: AprilNEA # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | 44 | dist/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindCSS.includeLanguages": { 3 | "plaintext": "html" 4 | }, 5 | "tailwindCSS.experimental.classRegex": [ 6 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 7 | ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 8 | ["tw=\"([^\"]*)\""] 9 | ], 10 | "json.schemas": [ 11 | { 12 | "fileMatch": ["/package.json"], 13 | "url": "https://json.schemastore.org/package", 14 | "schema": true 15 | } 16 | ], 17 | "i18n-ally":{ 18 | "localesPaths":[ 19 | "messages", 20 | "src/i18n" 21 | ], 22 | "keystyle": "nested", 23 | // "annotations": false, 24 | "sourceLanguage": "zh", 25 | "displayLanguage": "zh" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.9-slim AS base 2 | ENV PNPM_HOME="/pnpm" 3 | ENV PATH="$PNPM_HOME:$PATH" 4 | RUN corepack enable 5 | 6 | FROM base AS build 7 | COPY . /app 8 | WORKDIR /app 9 | 10 | # Install dependencies 11 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 12 | 13 | # Deploy only the dokploy app 14 | ENV NODE_ENV=production 15 | RUN pnpm build 16 | 17 | FROM base AS vmboard 18 | WORKDIR /app 19 | 20 | # Set production 21 | ENV NODE_ENV=production 22 | 23 | RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* 24 | 25 | # Copy only the necessary files 26 | COPY --from=build /app/.next ./.next 27 | COPY --from=build /app/dist ./dist 28 | COPY --from=build /app/src/env.js ./src/env.js 29 | COPY --from=build /app/next.config.mjs ./next.config.mjs 30 | COPY --from=build /app/public ./public 31 | COPY --from=build /app/package.json ./package.json 32 | COPY --from=build /app/drizzle ./drizzle 33 | COPY --from=build /app/components.json ./components.json 34 | COPY --from=build /app/node_modules ./node_modules 35 | 36 | EXPOSE 3000 37 | CMD [ "pnpm", "start" ] -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | This project contains code from the following third-party libraries: 2 | 3 | ================================================================================ 4 | 5 | nezha-dash 6 | Copyright (c) 2025 hamster1963 7 | 8 | Licensed under the Apache License, Version 2.0 (the "License"); 9 | you may not use this file except in compliance with the License. 10 | You may obtain a copy of the License at 11 | 12 | http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | Unless required by applicable law or agreed to in writing, software 15 | distributed under the License is distributed on an "AS IS" BASIS, 16 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | See the License for the specific language governing permissions and 18 | limitations under the License. 19 | 20 | Source code: https://github.com/hamster1963/nezha-dash 21 | 22 | ================================================================================ 23 | 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | onedrive-vercel-index 3 |

VMBoard

4 |

开始 · What's new? · 赞助

5 |

又一个强大且轻量的服务器监控平台

6 | 7 | VMBoard Version 8 | Documentation 9 | GitHub Discussions 10 | hits 11 |
12 | 13 | ## 致谢 14 | 15 | - [vmapi](https://github.com/AprilNEA/vmapi) 16 | - [shadcn-table](https://github.com/sadmann7/shadcn-table) 17 | - [nezha-dash](https://github.com/hamster1963/nezha-dash) 18 | 19 | ## 开源 20 | ![Alt](https://repobeats.axiom.co/api/embed/dc5524c6e91cb38261e30d733649b0734688ced9.svg "Repobeats analytics image") 21 | 22 | ## License 23 | 24 | ``` 25 | Copyright (C) 2024-2025 AprilNEA LLC 26 | ``` 27 | 28 | This project is primarily licensed under the [Functional Source License 1.1](./LICENSE.md), 29 | with a transition to Apache License 2.0 after two years. 30 | 31 | However, this project includes some code licensed under the Apache License 2.0: 32 | - [hamster1963/nezha-dash](https://github.com/hamster1963/nezha-dash) - Copyright 2025 [hamster1963](https://github.com/hamster1963) 33 | 34 | The full text of the Apache License 2.0 can be found in the [LICENSE-APACHE](./src/components/monitor/LICENSE-APACHE) file. -------------------------------------------------------------------------------- /bigint.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface BigInt { 3 | toJSON(): String; 4 | } 5 | } 6 | 7 | BigInt.prototype.toJSON = function () { 8 | return String(this); 9 | }; 10 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "enabled": true, 8 | "indentWidth": 2, 9 | "indentStyle": "space", 10 | "ignore": [ 11 | "**/node_modules", 12 | "**/dist", 13 | "**/build", 14 | "**/public", 15 | "**/.turbo", 16 | "**/.next" 17 | ] 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "a11y": { 24 | "noSvgWithoutTitle": "off", 25 | "useButtonType": "off", 26 | "useAltText": "off", 27 | "useKeyWithClickEvents": "off", 28 | "useSemanticElements": "off", 29 | "noLabelWithoutControl": "off" 30 | }, 31 | "correctness": { 32 | "noUnusedVariables": "warn", 33 | "useExhaustiveDependencies": "warn" 34 | }, 35 | "complexity": { 36 | "noBannedTypes": "off" 37 | }, 38 | "style": { 39 | "useImportType": "warn" 40 | }, 41 | "security": { 42 | "noDangerouslySetInnerHtml": "off" 43 | }, 44 | "suspicious": { 45 | "noAssignInExpressions": "off", 46 | "noArrayIndexKey": "off" 47 | }, 48 | "nursery": { 49 | "useSortedClasses": { 50 | "level": "warn", 51 | "options": { 52 | "attributes": ["classList"], 53 | "functions": ["clsx", "cva", "tw"] 54 | } 55 | } 56 | } 57 | }, 58 | "ignore": [ 59 | "**/node_modules", 60 | "**/dist", 61 | "**/build", 62 | "**/public", 63 | "**/.turbo", 64 | "**/.next" 65 | ] 66 | }, 67 | "overrides": [ 68 | { 69 | "include": ["**/*.test.ts"] 70 | } 71 | ], 72 | "vcs": { 73 | "enabled": true, 74 | "clientKind": "git", 75 | "useIgnoreFile": true 76 | }, 77 | "files": { 78 | "ignore": [ 79 | "**/node_modules", 80 | "**/dist", 81 | "**/build", 82 | "**/public", 83 | "**/.turbo", 84 | "**/.next" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "iconLibrary": "lucide", 5 | "rsc": true, 6 | "tsx": true, 7 | "tailwind": { 8 | "config": "tailwind.config.ts", 9 | "css": "src/styles/globals.css", 10 | "baseColor": "zinc", 11 | "cssVariables": true 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.js"; 2 | import type { Config } from "drizzle-kit"; 3 | 4 | export default { 5 | schema: "./src/db/schema.ts", 6 | dialect: "postgresql", 7 | out: "./drizzle", 8 | dbCredentials: { 9 | url: env.DATABASE_URL, 10 | }, 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /drizzle/0002_page_handle_hostname.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "hostname" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "user_id" varchar(255) NOT NULL, 4 | "hostname" varchar(255) NOT NULL, 5 | "verified_at" timestamp with time zone, 6 | "created_at" timestamp with time zone DEFAULT now() NOT NULL 7 | ); 8 | --> statement-breakpoint 9 | CREATE TABLE "page_bind" ( 10 | "id" serial PRIMARY KEY NOT NULL, 11 | "page_id" integer NOT NULL, 12 | "handle" varchar(255), 13 | "hostname_id" integer, 14 | "created_at" timestamp with time zone DEFAULT now() NOT NULL 15 | ); 16 | --> statement-breakpoint 17 | DROP INDEX "page_handle_idx";--> statement-breakpoint 18 | DROP INDEX "page_hostname_idx";--> statement-breakpoint 19 | ALTER TABLE "page_bind" ADD CONSTRAINT "page_bind_page_id_page_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."page"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint 20 | ALTER TABLE "page_bind" ADD CONSTRAINT "page_bind_hostname_id_hostname_id_fk" FOREIGN KEY ("hostname_id") REFERENCES "public"."hostname"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint 21 | CREATE UNIQUE INDEX "page_bind_handle_hostname_idx" ON "page_bind" USING btree (lower("handle"),"hostname_id");--> statement-breakpoint 22 | ALTER TABLE "page" DROP COLUMN "handle";--> statement-breakpoint 23 | ALTER TABLE "page" DROP COLUMN "hostname"; -------------------------------------------------------------------------------- /drizzle/0003_rename_probe.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "vm" RENAME COLUMN "token" TO "probe_token";--> statement-breakpoint 2 | ALTER TABLE "vm" RENAME COLUMN "monitor_info" TO "probe_info";--> statement-breakpoint 3 | ALTER TABLE "vm" ADD COLUMN "probe_config" jsonb;--> statement-breakpoint 4 | ALTER TABLE "vm" ADD CONSTRAINT "vm_probe_token_unique" UNIQUE("probe_token"); -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1740286963407, 9 | "tag": "0000_auth", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1742387310282, 16 | "tag": "0001_init", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "7", 22 | "when": 1742551334206, 23 | "tag": "0002_page_handle_hostname", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "7", 29 | "when": 1742895215175, 30 | "tag": "0003_rename_probe", 31 | "breakpoints": true 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /esbuild.config.ts: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | 3 | try { 4 | esbuild 5 | .build({ 6 | entryPoints: { 7 | server: "src/server.ts", 8 | }, 9 | bundle: true, 10 | platform: "node", 11 | format: "esm", 12 | target: "node18", 13 | outExtension: { ".js": ".mjs" }, 14 | minify: true, 15 | sourcemap: true, 16 | outdir: "dist", 17 | tsconfig: "tsconfig.json", 18 | packages: "external", 19 | }) 20 | .catch(() => { 21 | return process.exit(1); 22 | }); 23 | } catch (error) { 24 | console.log(error); 25 | } 26 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import type messages from "./messages/zh.json"; 2 | 3 | export type Messages = typeof messages; 4 | 5 | declare module "next-intl" { 6 | interface AppConfig { 7 | Messages: Messages; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | import { env } from './src/env.js'; 6 | import createNextIntlPlugin from 'next-intl/plugin'; 7 | const withNextIntl = createNextIntlPlugin(); 8 | 9 | /** @type {import("next").NextConfig} */ 10 | const nextConfig = { 11 | experimental: { 12 | authInterrupts: true 13 | }, 14 | async rewrites() { 15 | if (!env.CLOUD_HOST) return []; 16 | return { 17 | beforeFiles: [ 18 | { 19 | source: '/:path((?!api|dash|auth|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', 20 | destination: `${env.DOCS_BASE}/:path*`, 21 | has: [ 22 | { 23 | type: "host", 24 | value: env.CLOUD_HOST, 25 | }, 26 | ], 27 | }, 28 | ], 29 | } 30 | }, 31 | serverExternalPackages: ["vmapi"], 32 | // Already doing linting and typechecking as separate tasks in CI 33 | eslint: { ignoreDuringBuilds: true }, 34 | typescript: { ignoreBuildErrors: true }, 35 | }; 36 | 37 | export default withNextIntl(nextConfig); 38 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "VMBoard", 3 | "short_name": "VMBoard", 4 | "icons": [ 5 | { 6 | "src": "/icon.svg", 7 | "sizes": "32x32", 8 | "type": "image/svg+xml" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "automerge": true 5 | } 6 | -------------------------------------------------------------------------------- /reset.d.ts: -------------------------------------------------------------------------------- 1 | import "@total-typescript/ts-reset"; 2 | -------------------------------------------------------------------------------- /src/app/[handle]/data.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { headers } from "next/headers"; 3 | 4 | export const getHostname = async () => { 5 | const host = (await headers()).get("host"); 6 | if (!host) { 7 | return null; 8 | } 9 | const hostname = new URL(`http://${host}`).hostname; 10 | if (hostname === env.HOSTNAME) { 11 | return null; 12 | } 13 | return hostname; 14 | }; 15 | -------------------------------------------------------------------------------- /src/app/[handle]/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function MonitorLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return <>{children}; 7 | } -------------------------------------------------------------------------------- /src/app/[handle]/page.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import useMonitor from "@/hooks/use-monitor"; 4 | import { 5 | MetricsDataProvider, 6 | useMetricsData, 7 | } from "@/components/monitor/vm-data-context"; 8 | import type { Page } from "@/db/schema/page"; 9 | import { ServerList } from "@/components/monitor/server-list-card"; 10 | import type { VM } from "@/db/schema/vm"; 11 | import type { Client } from "@/server/hc"; 12 | import type { InferResponseType } from "hono"; 13 | import ServerOverview from "@/components/overview-cards"; 14 | import { Separator } from "@/components/ui/separator"; 15 | 16 | export type MonitorVM = Pick & { 17 | os?: string; 18 | osVersion?: string; 19 | platform?: string; 20 | platformVersion?: string; 21 | }; 22 | 23 | type MonitorPageProps = { 24 | page: InferResponseType; 25 | }; 26 | 27 | const MonitorPage = ({ page }: MonitorPageProps) => { 28 | const { metrics, addMetrics } = useMetricsData(); 29 | 30 | const { isConnected, montoringVmIds } = useMonitor({ 31 | vmIds: page.vms.map((vm) => vm.id), 32 | onVMMetrics: (vmId, metrics) => { 33 | addMetrics(vmId, metrics); 34 | }, 35 | onEvent: (event) => { 36 | console.log(event); 37 | }, 38 | }); 39 | 40 | return ( 41 |
42 | 43 | 44 | 45 |
46 | ); 47 | }; 48 | 49 | export default function MonitorPageWrapper(props: MonitorPageProps) { 50 | return ( 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/app/[handle]/page.tsx: -------------------------------------------------------------------------------- 1 | import MonitorPageWrapper from "./page.client"; 2 | import rscClient from "@/lib/rsc-client"; 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 4 | import { getHostname } from "./data"; 5 | 6 | type Params = Promise<{ handle: string }>; 7 | 8 | const NotFound = () => { 9 | return ( 10 | 11 | 12 | Page not found 13 | 14 | 15 |

The page you are looking for does not exist.

16 |
17 |
18 | ); 19 | }; 20 | 21 | export default async function Page({ params }: { params: Params }) { 22 | const hostname = (await getHostname()) ?? undefined; 23 | const handle = (await params).handle; 24 | 25 | const res = await rscClient.page.bind.$get({ 26 | query: { 27 | handle, 28 | hostname: hostname, 29 | }, 30 | }); 31 | 32 | if (res.status === 404) { 33 | return ; 34 | } 35 | 36 | if (!res.ok) { 37 | throw new Error(res.statusText); 38 | } 39 | 40 | const page = await res.json(); 41 | 42 | return ; 43 | } 44 | -------------------------------------------------------------------------------- /src/app/_lib/seeds.ts: -------------------------------------------------------------------------------- 1 | import db from "@/db/index"; 2 | // import { type Task, tasks } from "@/db/schema"; 3 | 4 | // import { generateRandomTask } from "./utils"; 5 | 6 | export async function seedTasks(input: { count: number }) { 7 | // const count = input.count ?? 100; 8 | 9 | // try { 10 | // const allTasks: Task[] = []; 11 | 12 | // // for (let i = 0; i < count; i++) { 13 | // // allTasks.push(generateRandomTask()); 14 | // // } 15 | 16 | // await db.delete(tasks); 17 | 18 | // console.log("📝 Inserting tasks", allTasks.length); 19 | 20 | // await db.insert(tasks).values(allTasks).onConflictDoNothing(); 21 | // } catch (err) { 22 | // console.error(err); 23 | // } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/_lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Merchant, VM } from "@/db/schema"; 2 | import { 3 | ArrowDownIcon, 4 | ArrowRightIcon, 5 | ArrowUpIcon, 6 | CheckCircle2, 7 | CircleHelp, 8 | CircleIcon, 9 | CircleX, 10 | Timer, 11 | } from "lucide-react"; 12 | import { customAlphabet } from "nanoid"; 13 | 14 | import { generateId } from "@/lib/id"; 15 | import { DMITLogoWithText2022, HKG, LAX, TYO } from "@/components/icons/dmit"; 16 | 17 | // export function generateRandomTask(): Task { 18 | // return { 19 | // id: generateId("task"), 20 | // code: `TASK-${customAlphabet("0123456789", 4)()}`, 21 | // title: faker.hacker 22 | // .phrase() 23 | // .replace(/^./, (letter) => letter.toUpperCase()), 24 | // status: faker.helpers.shuffle(tasks.status.enumValues)[0] ?? "todo", 25 | // label: faker.helpers.shuffle(tasks.label.enumValues)[0] ?? "bug", 26 | // priority: faker.helpers.shuffle(tasks.priority.enumValues)[0] ?? "low", 27 | // archived: faker.datatype.boolean({ probability: 0.2 }), 28 | // createdAt: new Date(), 29 | // updatedAt: new Date(), 30 | // }; 31 | // } 32 | 33 | /** 34 | * Returns the appropriate status icon based on the provided status. 35 | * @param status - The status of the task. 36 | * @returns A React component representing the status icon. 37 | */ 38 | export function getStatusIcon(status: VM["status"]) { 39 | const statusIcons = { 40 | canceled: CircleX, 41 | running: CheckCircle2, 42 | expired: Timer, 43 | stopped: CircleHelp, 44 | error: CircleX, 45 | }; 46 | 47 | return statusIcons[status] || CircleIcon; 48 | } 49 | 50 | export function getMerchantIcon(merchant: Merchant["merchant"]) { 51 | const merchantIcons = { 52 | dmit: DMITLogoWithText2022, 53 | }; 54 | 55 | return merchantIcons[merchant as keyof typeof merchantIcons] || CircleIcon; 56 | } 57 | export function getDMITLocationIcon(location: "HKG" | "LAX" | "TYO") { 58 | const dmitLocationIcons = { 59 | HKG: HKG, 60 | LAX: LAX, 61 | TYO: TYO, 62 | }; 63 | 64 | return dmitLocationIcons[location] || CircleIcon; 65 | } 66 | 67 | // /** 68 | // * Returns the appropriate priority icon based on the provided priority. 69 | // * @param priority - The priority of the task. 70 | // * @returns A React component representing the priority icon. 71 | // */ 72 | // export function getPriorityIcon(priority: Task["priority"]) { 73 | // const priorityIcons = { 74 | // high: ArrowUpIcon, 75 | // low: ArrowDownIcon, 76 | // medium: ArrowRightIcon, 77 | // }; 78 | 79 | // return priorityIcons[priority] || CircleIcon; 80 | // } 81 | -------------------------------------------------------------------------------- /src/app/_lib/validations.ts: -------------------------------------------------------------------------------- 1 | import { type VM, vm as vmTable } from "@/db/schema/vm"; 2 | import { 3 | createSearchParamsCache, 4 | parseAsArrayOf, 5 | parseAsInteger, 6 | parseAsString, 7 | parseAsStringEnum, 8 | } from "nuqs/server"; 9 | import * as z from "zod"; 10 | 11 | import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; 12 | 13 | export const searchParamsCache = createSearchParamsCache({ 14 | flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( 15 | [], 16 | ), 17 | page: parseAsInteger.withDefault(1), 18 | perPage: parseAsInteger.withDefault(10), 19 | sort: getSortingStateParser().withDefault([ 20 | { id: "createdAt", desc: true }, 21 | ]), 22 | title: parseAsString.withDefault(""), 23 | status: parseAsArrayOf(z.enum(vmTable.status.enumValues)).withDefault([]), 24 | // priority: parseAsArrayOf(z.enum(tasks.priority.enumValues)).withDefault([]), 25 | from: parseAsString.withDefault(""), 26 | to: parseAsString.withDefault(""), 27 | // advanced filter 28 | filters: getFiltersStateParser().withDefault([]), 29 | joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), 30 | }); 31 | 32 | export const createVMSchema = z.object({ 33 | nickname: z.string(), 34 | // label: z.enum(tasks.label.enumValues), 35 | ipAddress: z.string().optional(), 36 | status: z.enum(vmTable.status.enumValues), 37 | merchantId: z.number().optional(), 38 | metadata: z.any().optional(), 39 | // priority: z.enum(tasks.priority.enumValues), 40 | }); 41 | 42 | export const updateVMSchema = z.object({ 43 | nickname: z.string().optional(), 44 | 45 | // label: z.enum(tasks.label.enumValues).optional(), 46 | status: z.enum(vmTable.status.enumValues).optional(), 47 | // priority: z.enum(tasks.priority.enumValues).optional(), 48 | }); 49 | 50 | export type GetVMSchema = Awaited>; 51 | export type CreateVMSchema = z.infer; 52 | export type UpdateVMSchema = z.infer; 53 | -------------------------------------------------------------------------------- /src/app/api/[[...route]]/route.ts: -------------------------------------------------------------------------------- 1 | import api from "@/server/index"; 2 | import { Hono } from "hono"; 3 | import { handle } from "hono/vercel"; 4 | 5 | const app = new Hono(); 6 | app.route("/api", api); 7 | const handler = handle(app); 8 | 9 | export { 10 | handler as DELETE, 11 | handler as GET, 12 | handler as PATCH, 13 | handler as POST, 14 | handler as PUT, 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { GET, POST } = toNextJsHandler(auth.handler); -------------------------------------------------------------------------------- /src/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { headers } from "next/headers"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export default async function AuthLayout({ 6 | children, 7 | }: { children: React.ReactNode }) { 8 | const session = await auth.api.getSession({ 9 | headers: await headers(), 10 | }); 11 | if (session) { 12 | redirect("/dash"); 13 | } 14 | return ( 15 |
16 | {children} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/dash/layout.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { getMessages } from "next-intl/server"; 3 | import { headers } from "next/headers"; 4 | import { redirect } from "next/navigation"; 5 | import { NextIntlClientProvider } from "next-intl"; 6 | 7 | export default async function DashLayout({ 8 | children, 9 | }: { children: React.ReactNode }) { 10 | const session = await auth.api.getSession({ 11 | headers: await headers(), 12 | }); 13 | if (!session) { 14 | redirect("/auth"); 15 | } 16 | const messages = await getMessages(); 17 | 18 | return ( 19 | 20 |
{children}
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/dash/page.tsx: -------------------------------------------------------------------------------- 1 | import type { SearchParams } from "@/types"; 2 | import * as React from "react"; 3 | 4 | import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 5 | import { DateRangePicker } from "@/components/date-range-picker"; 6 | import { Shell } from "@/components/shell"; 7 | import { Skeleton } from "@/components/ui/skeleton"; 8 | import { getValidFilters } from "@/lib/data-table"; 9 | import { CreateVMSheet } from "@/components/vm/create-vm-sheet"; 10 | import { FeatureFlagsProvider } from "../../components/vm/feature-flags-provider"; 11 | import { VMsTable } from "../../components/vm/vms-table"; 12 | import { getMerchants, getVMs, getVMStatusCounts } from "../_lib/queries"; 13 | import { searchParamsCache } from "../_lib/validations"; 14 | import ServerOverview from "@/components/overview-cards"; 15 | import { useState } from "react"; 16 | import { CreateVMDialog } from "@/components/vm/create-vm-dialog"; 17 | 18 | interface IndexPageProps { 19 | searchParams: Promise; 20 | } 21 | 22 | export default async function IndexPage(props: IndexPageProps) { 23 | const searchParams = await props.searchParams; 24 | const search = searchParamsCache.parse(searchParams); 25 | 26 | const validFilters = getValidFilters(search.filters); 27 | 28 | const promises = Promise.all([ 29 | getVMs({ 30 | ...search, 31 | filters: validFilters, 32 | }), 33 | getMerchants(), 34 | getVMStatusCounts(), 35 | ]); 36 | 37 | const [vms, merchants, statusCounts] = await promises; 38 | 39 | return ( 40 | 41 | 42 | 43 | {/* */} 44 | {/* }> 45 | 51 | */} 52 | 61 | } 62 | > 63 | {vms ? :
promises is null
} 64 |
65 | {/*
*/} 66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/app/dash/page/page.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import useSWR from "swr"; 4 | import apiClient, { fetchWrapper } from "@/lib/api-client"; 5 | import { Skeleton } from "@/components/ui/skeleton"; 6 | import { CreatePageDialog } from "@/components/page/create-page-dialog"; 7 | import { Button } from "@/components/ui/button"; 8 | import { Link } from "next-view-transitions"; 9 | import { useTranslations } from "next-intl"; 10 | 11 | export function PageManagementData() { 12 | const t = useTranslations("Private.Page.Management"); 13 | const tD = useTranslations("Private.Page.Detail"); 14 | const tAction = useTranslations("Private.Action"); 15 | 16 | const { data, isLoading } = useSWR( 17 | ["/api/page", {}], 18 | fetchWrapper(apiClient.page.$get), 19 | ); 20 | if (!data) { 21 | if (isLoading) { 22 | return ; 23 | } 24 | return
Error
; 25 | } 26 | return ( 27 |
28 | {data?.length === 0 ? ( 29 |
30 |

{t("emptyPage")}

31 | 32 | 33 | 34 |
35 | ) : ( 36 |
37 | {data?.map((page) => ( 38 |
39 |
40 | 41 |

{page.title}

42 | 43 |
44 | 47 | 50 |
51 |
52 |

53 | {tD("accessLink")}: /{page.handle} 54 |

55 |
56 |

57 | {t("vmCount", { count: page.vmCount })} 58 |

59 |

60 | {t("createdAt", { date: page.createdAt })} 61 |

62 |
63 |
64 | ))} 65 | 66 | 67 | 68 |
69 | )} 70 |
71 | ); 72 | } 73 | 74 | function PageManagementSkeleton() { 75 | return ( 76 |
77 |
78 | 79 |
80 | 81 | 82 |
83 |
84 |
85 | {Array.from({ length: 3 }).map((_, i) => ( 86 |
87 | 88 | 89 |
90 | ))} 91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/app/dash/page/page.tsx: -------------------------------------------------------------------------------- 1 | import { Shell } from "@/components/shell"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | import { PageManagementData } from "./page.client"; 10 | import type { Metadata } from "next"; 11 | import { getTranslations } from "next-intl/server"; 12 | 13 | export async function generateMetadata(): Promise { 14 | const t = await getTranslations("Private.Page.Management"); 15 | 16 | return { 17 | title: t("title"), 18 | description: t("description"), 19 | }; 20 | } 21 | 22 | export default async function Page() { 23 | const t = await getTranslations("Private.Page.Management"); 24 | 25 | return ( 26 | 27 |
28 |
29 |

{t("title")}

30 |

31 | {t("description")} 32 |

33 |
34 |
35 |
36 | 37 | 38 | {t("subTitle")} 39 | 40 | {t("description2")} 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/dash/setting/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SettingSidebar } from "@/components/setting/setting-sidebar"; 4 | import { SidebarProvider } from "@/components/ui/sidebar"; 5 | import { 6 | Card, 7 | CardContent, 8 | CardDescription, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { useTranslations } from "next-intl"; 13 | import { Separator } from "@/components/ui/separator"; 14 | import { usePathname } from "next/navigation"; 15 | import { useMemo } from "react"; 16 | import { capitalize } from "@/lib/utils"; 17 | 18 | export default function SettingLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode; 22 | }) { 23 | const t = useTranslations("Private.Setting"); 24 | const pathname = usePathname(); 25 | const { title, description } = useMemo(() => { 26 | if (pathname === "/dash/setting") { 27 | return { 28 | title: t("title"), 29 | description: t("description"), 30 | }; 31 | } 32 | if (!pathname.startsWith("/dash/setting")) { 33 | return { 34 | title: "Unknown", 35 | description: "Unknown", 36 | }; 37 | } 38 | // Get the last two parts of the path 39 | const parts = pathname.split("/"); 40 | const lastPart = capitalize(parts[parts.length - 1] ?? ""); 41 | 42 | const secondLastPart = capitalize(parts[parts.length - 2] ?? ""); 43 | if (secondLastPart === "Setting") { 44 | return { 45 | title: t(`${lastPart}.title`), 46 | description: t(`${lastPart}.description`), 47 | }; 48 | } 49 | return { 50 | title: t(`${secondLastPart}.${lastPart}.title`), 51 | description: t(`${secondLastPart}.${lastPart}.description`), 52 | }; 53 | }, [t, pathname]); 54 | 55 | return ( 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | {title} 64 | {description} 65 | 66 | 67 | {children} 68 | 69 | 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/app/dash/setting/page.tsx: -------------------------------------------------------------------------------- 1 | import { SettingList } from "@/components/setting/setting-list"; 2 | 3 | export default function SettingPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/dash/setting/personal/account/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTranslations } from "next-intl"; 4 | import { useForm } from "react-hook-form"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | Form, 9 | FormField, 10 | FormLabel, 11 | FormItem, 12 | FormControl, 13 | FormMessage, 14 | } from "@/components/ui/form"; 15 | import type { User } from "better-auth"; 16 | import { authClient } from "@/lib/auth-client"; 17 | 18 | type FormUser = Pick; 19 | 20 | export default function SettingAccountPage() { 21 | const { data: session, isPending } = authClient.useSession(); 22 | 23 | if (isPending || !session) { 24 | return
Loading...
; 25 | } 26 | 27 | return ; 28 | } 29 | const AccountForm: React.FC<{ user: User }> = ({ user }) => { 30 | const t = useTranslations("Private.Setting.Personal.Account"); 31 | const tAction = useTranslations("Private.Action"); 32 | const form = useForm({ 33 | defaultValues: { 34 | name: user.name, 35 | email: user.email, 36 | }, 37 | }); 38 | 39 | const onSubmit = async (data: FormUser) => { 40 | await authClient.updateUser({ 41 | name: data.name, 42 | }); 43 | }; 44 | 45 | return ( 46 |
47 | 48 | ( 52 | 53 | {t("name")} 54 | 55 | 56 | 57 | 58 | 59 | )} 60 | rules={{ required: "昵称不能为空" }} 61 | /> 62 | 63 | ( 67 | 68 | {t("email")} 69 | 70 | 71 | 72 | 73 | 74 | )} 75 | /> 76 | 77 | {form.formState.errors.root && ( 78 |

79 | {form.formState.errors.root.message} 80 |

81 | )} 82 | 83 | 90 | 91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /src/app/dash/setting/personal/page.tsx: -------------------------------------------------------------------------------- 1 | import { SettingList } from "@/components/setting/setting-list"; 2 | 3 | export default function SettingPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/dash/setting/site/general/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTranslations } from "next-intl"; 4 | import { useForm } from "react-hook-form"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | Select, 9 | SelectContent, 10 | SelectGroup, 11 | SelectItem, 12 | SelectLabel, 13 | SelectTrigger, 14 | SelectValue, 15 | } from "@/components/ui/select" 16 | import { 17 | Form, 18 | FormField, 19 | FormLabel, 20 | FormItem, 21 | FormControl, 22 | FormMessage, 23 | } from "@/components/ui/form"; 24 | import type { User } from "better-auth"; 25 | import { authClient } from "@/lib/auth-client"; 26 | import { z } from "zod"; 27 | 28 | export const SiteSettingSchema = z.object({ 29 | basic: z.object({ 30 | name: z.string(), 31 | description: z.string(), 32 | }), 33 | preferences: z.object({ 34 | theme: z.string(), 35 | color: z.string(), 36 | font: z.string(), 37 | fontSize: z.string(), 38 | }), 39 | monitor: z.object({ 40 | ip_info: z.boolean(), 41 | flag: z.boolean(), 42 | network_transfer: z.boolean(), 43 | svg_flag: z.boolean(), 44 | top_server_name: z.boolean(), 45 | }), 46 | }); 47 | 48 | type SiteSetting = z.infer; 49 | 50 | export default function SiteSettingPage() { 51 | const t = useTranslations("Private.Setting.Site.General.Detail"); 52 | const form = useForm(); 53 | const onSubmit = async (data: SiteSetting) => { 54 | console.log(data); 55 | }; 56 | 57 | return ( 58 |
59 | 60 | ( 64 | 65 | {t("name")} 66 | 67 | 68 | 69 | 70 | 71 | )} 72 | /> 73 | ( 77 | 78 | {t("description")} 79 | 80 | 81 | 82 | 83 | 84 | )} 85 | /> 86 | 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/app/dash/setting/site/notification/page.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmvision/vmboard/b837cc2b755389f7cbfebd859789c28ab676930f/src/app/dash/setting/site/notification/page.tsx -------------------------------------------------------------------------------- /src/app/dash/setting/site/page.tsx: -------------------------------------------------------------------------------- 1 | import { SettingList } from "@/components/setting/setting-list"; 2 | 3 | export default function SettingPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/dash/terminal/page.tsx: -------------------------------------------------------------------------------- 1 | import rscClient from "@/lib/rsc-client"; 2 | import dynamic from "next/dynamic"; 3 | import { notFound } from "next/navigation"; 4 | import TerminalSetupPage from "./page.client"; 5 | import { getVM, getSSHKey } from "@/app/_lib/queries"; 6 | 7 | type SearchParams = Promise<{ vmId: string }>; 8 | export const generateMetadata = async ({ 9 | searchParams, 10 | }: { 11 | searchParams: SearchParams; 12 | }) => { 13 | const vmId = (await searchParams).vmId; 14 | const vm = await getVM(Number(vmId)); 15 | // const res = await rscClient.vm[":id"] 16 | // .$get({ 17 | // param: { 18 | // id: vmId, 19 | // }, 20 | // }) 21 | // .then((res) => res.json()); 22 | return { 23 | title: vm?.nickname, 24 | }; 25 | }; 26 | 27 | const Terminal = dynamic(() => import("@/components/terminal")); 28 | 29 | export default async function VMTerminalPage({ 30 | searchParams, 31 | }: { 32 | searchParams: SearchParams; 33 | }) { 34 | const vmId = (await searchParams).vmId; 35 | if (!vmId) { 36 | notFound(); 37 | } 38 | const vm = await getVM(Number(vmId)); 39 | if (!vm) { 40 | notFound(); 41 | } 42 | if (!vm.sshInfo) { 43 | const sshKeys = await getSSHKey(); 44 | return ; 45 | } 46 | return ; 47 | } 48 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SiteHeader } from "@/components/layouts/site-header"; 2 | import { ThemeProvider } from "@/components/providers"; 3 | import { TailwindIndicator } from "@/components/tailwind-indicator"; 4 | import { siteConfig } from "@/config/site"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | import "@/styles/globals.css"; 8 | 9 | import type { Metadata, Viewport } from "next"; 10 | 11 | import { Toaster } from "@/components/ui/toaster"; 12 | import NextTopLoader from "nextjs-toploader"; 13 | import { fontMono, fontSans } from "@/lib/fonts"; 14 | import { getLocale, getMessages } from "next-intl/server"; 15 | import { NextIntlClientProvider } from "next-intl"; 16 | import { pickPublic } from "@/i18n/pick"; 17 | import type { Messages } from "global"; 18 | 19 | export const metadata: Metadata = { 20 | metadataBase: new URL(siteConfig.url), 21 | title: { 22 | default: siteConfig.name, 23 | template: `%s - ${siteConfig.name}`, 24 | }, 25 | description: siteConfig.description, 26 | keywords: ["board", "panel", "vps"], 27 | authors: [ 28 | { 29 | name: "AprilNEA", 30 | url: "https://sku.moe", 31 | }, 32 | ], 33 | creator: "AprilNEA", 34 | openGraph: { 35 | type: "website", 36 | locale: "zh_CN", 37 | url: siteConfig.url, 38 | title: siteConfig.name, 39 | description: siteConfig.description, 40 | siteName: siteConfig.name, 41 | }, 42 | twitter: { 43 | card: "summary_large_image", 44 | title: siteConfig.name, 45 | description: siteConfig.description, 46 | images: [`${siteConfig.url}/og.jpg`], 47 | creator: "@AprilNEA", 48 | }, 49 | icons: { 50 | icon: "/icon.svg", 51 | }, 52 | manifest: `${siteConfig.url}/site.webmanifest`, 53 | }; 54 | 55 | export const viewport: Viewport = { 56 | colorScheme: "dark light", 57 | themeColor: [ 58 | { media: "(prefers-color-scheme: light)", color: "white" }, 59 | { media: "(prefers-color-scheme: dark)", color: "black" }, 60 | ], 61 | }; 62 | 63 | export default async function RootLayout({ 64 | children, 65 | }: React.PropsWithChildren) { 66 | const locale = await getLocale(); 67 | // Providing all messages to the client 68 | // side is the easiest way to get started 69 | const messages = (await getMessages()) as Messages; 70 | 71 | return ( 72 | 73 | 74 | 81 | 82 | 83 | 89 |
90 | 91 | {children} 92 |
93 | 94 |
95 | 96 |
97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import rscClient from "@/lib/rsc-client"; 2 | import { redirect } from "next/navigation"; 3 | import { getHostname } from "./[handle]/data"; 4 | import MonitorPageWrapper from "./[handle]/page.client"; 5 | 6 | export default async function Home() { 7 | const hostname = await getHostname() ?? undefined; 8 | const res = await rscClient.page.bind.$get({ 9 | query: { 10 | hostname, 11 | }, 12 | }); 13 | 14 | if (res.status === 404) { 15 | redirect("/dash"); 16 | } 17 | 18 | if (!res.ok) { 19 | throw new Error(res.statusText); 20 | } 21 | 22 | const page = await res.json(); 23 | 24 | return ; 25 | } 26 | -------------------------------------------------------------------------------- /src/app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from "next"; 2 | 3 | import { siteConfig } from "@/config/site"; 4 | 5 | export default function robots(): MetadataRoute.Robots { 6 | return { 7 | rules: { 8 | userAgent: "*", 9 | allow: "/", 10 | }, 11 | sitemap: `${siteConfig.url}/sitemap.xml`, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from "next"; 2 | 3 | import { siteConfig } from "@/config/site"; 4 | 5 | export default function sitemap(): MetadataRoute.Sitemap { 6 | const routes = [""].map((route) => ({ 7 | url: `${siteConfig.url}${route}`, 8 | lastModified: new Date().toISOString(), 9 | })); 10 | 11 | return [...routes]; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/vm/[vmId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { MetricsDataProvider } from "@/components/monitor/vm-data-context"; 2 | import { getVM } from "@/app/_lib/queries"; 3 | 4 | export const generateMetadata = async ({ 5 | params, 6 | }: { 7 | params: Promise<{ vmId: string }>; 8 | }) => { 9 | const vmId = Number((await params).vmId); 10 | const vm = await getVM(vmId); 11 | return { 12 | title: vm?.nickname, 13 | }; 14 | }; 15 | 16 | export default function ServerLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return <>{children}; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/data-table/data-table-advanced-toolbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { DataTableAdvancedFilterField } from "@/types"; 4 | import type { Table } from "@tanstack/react-table"; 5 | import type * as React from "react"; 6 | 7 | import { DataTableFilterList } from "@/components/data-table/data-table-filter-list"; 8 | import { DataTableSortList } from "@/components/data-table/data-table-sort-list"; 9 | import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"; 10 | import { cn } from "@/lib/utils"; 11 | 12 | interface DataTableAdvancedToolbarProps 13 | extends React.HTMLAttributes { 14 | /** 15 | * The table instance returned from useDataTable hook with pagination, sorting, filtering, etc. 16 | * @type Table 17 | */ 18 | table: Table; 19 | 20 | /** 21 | * An array of filter field configurations for the data table. 22 | * @type DataTableAdvancedFilterField[] 23 | * @example 24 | * const filterFields = [ 25 | * { 26 | * id: 'name', 27 | * label: 'Name', 28 | * type: 'text', 29 | * placeholder: 'Filter by name...' 30 | * }, 31 | * { 32 | * id: 'status', 33 | * label: 'Status', 34 | * type: 'select', 35 | * options: [ 36 | * { label: 'Active', value: 'active', count: 10 }, 37 | * { label: 'Inactive', value: 'inactive', count: 5 } 38 | * ] 39 | * } 40 | * ] 41 | */ 42 | filterFields: DataTableAdvancedFilterField[]; 43 | 44 | /** 45 | * Debounce time (ms) for filter updates to enhance performance during rapid input. 46 | * @default 300 47 | */ 48 | debounceMs?: number; 49 | 50 | /** 51 | * Shallow mode keeps query states client-side, avoiding server calls. 52 | * Setting to `false` triggers a network request with the updated querystring. 53 | * @default true 54 | */ 55 | shallow?: boolean; 56 | } 57 | 58 | export function DataTableAdvancedToolbar({ 59 | table, 60 | filterFields = [], 61 | debounceMs = 300, 62 | shallow = true, 63 | children, 64 | className, 65 | ...props 66 | }: DataTableAdvancedToolbarProps) { 67 | return ( 68 |
75 |
76 | 82 | 87 |
88 |
89 | {children} 90 | 91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/data-table/data-table-view-options.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Table } from "@tanstack/react-table"; 4 | import { Check, ChevronsUpDown, Settings2 } from "lucide-react"; 5 | import * as React from "react"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Command, 10 | CommandEmpty, 11 | CommandGroup, 12 | CommandInput, 13 | CommandItem, 14 | CommandList, 15 | } from "@/components/ui/command"; 16 | import { 17 | Popover, 18 | PopoverContent, 19 | PopoverTrigger, 20 | } from "@/components/ui/popover"; 21 | import { cn, toSentenceCase } from "@/lib/utils"; 22 | 23 | interface DataTableViewOptionsProps { 24 | table: Table; 25 | } 26 | 27 | export function DataTableViewOptions({ 28 | table, 29 | }: DataTableViewOptionsProps) { 30 | const triggerRef = React.useRef(null); 31 | 32 | return ( 33 | 34 | 35 | 47 | 48 | triggerRef.current?.focus()} 52 | > 53 | 54 | 55 | 56 | 未找到列。 57 | 58 | {table 59 | .getAllColumns() 60 | .filter( 61 | (column) => 62 | (typeof column.accessorFn !== "undefined" || 63 | typeof column.id !== "undefined") && 64 | column.getCanHide(), 65 | ) 66 | .map((column) => { 67 | return ( 68 | 71 | column.toggleVisibility(!column.getIsVisible()) 72 | } 73 | > 74 | 75 | {toSentenceCase(column.id)} 76 | 77 | 83 | 84 | ); 85 | })} 86 | 87 | 88 | 89 | 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/components/derive-ui/animate-count.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export default function AnimateCount({ 7 | count, 8 | className, 9 | minDigits = 1, 10 | ...props 11 | }: { 12 | count: number; 13 | className?: string; 14 | minDigits?: number; 15 | }) { 16 | const [previousCount, setPreviousCount] = useState(count); 17 | 18 | useEffect(() => { 19 | if (count !== previousCount) { 20 | setTimeout(() => { 21 | setPreviousCount(count); 22 | }, 300); 23 | } 24 | }, [count, previousCount, setPreviousCount]); 25 | 26 | const currentDigits = count.toString().split(""); 27 | const previousDigits = ( 28 | previousCount !== undefined 29 | ? previousCount.toString() 30 | : count - 1 >= 0 31 | ? (count - 1).toString() 32 | : "0" 33 | ).split(""); 34 | 35 | // Ensure both numbers meet the minimum length requirement and maintain the same length for animation 36 | const maxLength = Math.max( 37 | previousDigits.length, 38 | currentDigits.length, 39 | minDigits, 40 | ); 41 | while (previousDigits.length < maxLength) { 42 | previousDigits.unshift("0"); 43 | } 44 | while (currentDigits.length < maxLength) { 45 | currentDigits.unshift("0"); 46 | } 47 | 48 | return ( 49 |
57 | {currentDigits.map((digit, index) => { 58 | const hasChanged = digit !== previousDigits[index]; 59 | return ( 60 |
69 |
77 | {previousDigits[index]} 78 |
79 |
86 | {digit} 87 |
88 |
89 | ); 90 | })} 91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | export type IconProps = React.HTMLAttributes; 2 | 3 | export const Icons = { 4 | GitHub: (props: IconProps) => ( 5 | 6 | 10 | 11 | ), 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/icons/logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { SVGProps } from "react"; 3 | const Logo = (props: SVGProps) => ( 4 | 5 | 6 | 7 | 13 | 19 | 25 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 47 | 52 | 53 | ); 54 | export default Logo; 55 | -------------------------------------------------------------------------------- /src/components/kbd.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const kbdVariants = cva( 7 | "select-none rounded border px-1.5 py-px font-mono font-normal text-[0.7rem] shadow-sm disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-accent text-accent-foreground", 12 | outline: "bg-background text-foreground", 13 | }, 14 | }, 15 | defaultVariants: { 16 | variant: "default", 17 | }, 18 | }, 19 | ); 20 | 21 | export interface KbdProps 22 | extends React.ComponentPropsWithoutRef<"kbd">, 23 | VariantProps { 24 | /** 25 | * The title of the `abbr` element inside the `kbd` element. 26 | * @default undefined 27 | * @type string | undefined 28 | * @example title="Command" 29 | */ 30 | abbrTitle?: string; 31 | } 32 | 33 | const Kbd = React.forwardRef( 34 | ({ abbrTitle, children, className, variant, ...props }, ref) => { 35 | return ( 36 | 41 | {abbrTitle ? ( 42 | 43 | {children} 44 | 45 | ) : ( 46 | children 47 | )} 48 | 49 | ); 50 | }, 51 | ); 52 | Kbd.displayName = "Kbd"; 53 | 54 | export { Kbd }; 55 | -------------------------------------------------------------------------------- /src/components/layouts/link-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Link } from "next-view-transitions"; 5 | import { usePathname } from "next/navigation"; 6 | import { useTranslations } from "next-intl"; 7 | 8 | const links = [ 9 | { 10 | href: "/dash", 11 | label: "vm", 12 | }, 13 | { 14 | href: "/dash/page", 15 | label: "page", 16 | }, 17 | { 18 | href: "/dash/rule", 19 | label: "rule", 20 | }, 21 | ]; 22 | 23 | export function LinkBar() { 24 | const t = useTranslations("Public.Link"); 25 | const pathname = usePathname(); 26 | return links.map((link) => ( 27 | 35 | {t(link.label)} 36 | 37 | )); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/layouts/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LaptopIcon, MoonIcon, SunIcon } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | 14 | export function ModeToggle() { 15 | const { setTheme } = useTheme(); 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | 29 | Light 30 | 31 | setTheme("dark")}> 32 | 33 | Dark 34 | 35 | setTheme("system")}> 36 | 37 | System 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/layouts/profile.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { authClient } from "@/lib/auth-client"; 4 | import { DropdownMenuItem } from "../ui/dropdown-menu"; 5 | import type { Session } from "better-auth"; 6 | import { useCallback } from "react"; 7 | import { useTransitionRouter } from "next-view-transitions"; 8 | import { toast } from "sonner"; 9 | import { useTranslations } from "next-intl"; 10 | import { LogOutIcon } from "lucide-react"; 11 | 12 | export const ProfileLogout: React.FC<{ session: Session }> = ({ session }) => { 13 | const t = useTranslations("Public.Auth"); 14 | const router = useTransitionRouter(); 15 | const logout = useCallback(async () => { 16 | await authClient.revokeSession( 17 | { 18 | token: session.token, 19 | }, 20 | { 21 | onSuccess: () => { 22 | router.refresh(); 23 | toast.success(t("logoutSuccess")); 24 | }, 25 | onError: () => { 26 | toast.error(t("logoutFailed")); 27 | }, 28 | }, 29 | ); 30 | }, [t, router, session]); 31 | 32 | return ( 33 | 34 | 35 | {t("logout")} 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/layouts/profile.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuTrigger, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuSeparator, 7 | } from "@/components/ui/dropdown-menu"; 8 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 9 | import { auth } from "@/lib/auth"; 10 | import { headers } from "next/headers"; 11 | import { ProfileLogout } from "./profile.client"; 12 | import { Link } from "next-view-transitions"; 13 | import { Button } from "../ui/button"; 14 | import { getTranslations } from "next-intl/server"; 15 | import { BoltIcon, CircleUserRoundIcon } from "lucide-react"; 16 | 17 | export default async function Profile() { 18 | const t = await getTranslations("Public.Auth"); 19 | const tP = await getTranslations("Private.Profile"); 20 | 21 | const session = await auth.api.getSession({ 22 | headers: await headers(), 23 | }); 24 | if (!session) { 25 | return ( 26 | 27 | 30 | 31 | ); 32 | } 33 | const { user } = session; 34 | 35 | return ( 36 | 37 | 38 | 39 | {user.image && } 40 | {user?.email.slice(0, 2)} 41 | {tP("menu")} 42 | 43 | 44 | 45 | 46 | 47 | 48 | {tP("account")} 49 | 50 | 51 | 52 | 53 | 54 | {tP("setting")} 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/layouts/site-header.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "next-view-transitions"; 2 | import { Icons } from "@/components/icons"; 3 | import { ModeToggle } from "@/components/layouts/mode-toggle"; 4 | import { Button } from "@/components/ui/button"; 5 | import { siteConfig } from "@/config/site"; 6 | import Logo from "../icons/logo"; 7 | import Profile from "./profile"; 8 | import { LinkBar } from "./link-bar"; 9 | import { BookOpenTextIcon } from "lucide-react"; 10 | 11 | export async function SiteHeader() { 12 | return ( 13 |
14 |
15 | 16 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | const bars = Array(8).fill(0); 2 | 3 | export const Loader = ({ visible }: { visible: boolean }) => { 4 | return ( 5 |
6 |
7 | {bars.map((_, i) => ( 8 |
9 | ))} 10 |
11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from "react-markdown"; 2 | 3 | export function Markdown({ content }: { content: string }) { 4 | return {content}; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/merchant/create-merchant-dialog.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmvision/vmboard/b837cc2b755389f7cbfebd859789c28ab676930f/src/components/merchant/create-merchant-dialog.tsx -------------------------------------------------------------------------------- /src/components/monitor/network-chart-loading.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is based on code from the nezha-dash project, 3 | * originally licensed under the Apache License 2.0. 4 | * The original license can be found in the LICENSE-APACHE file. 5 | * 6 | * Modifications made by AprilNEA 7 | * Derived from: https://github.com/hamster1963/nezha-dash/raw/ac15be6e71ba9804681b1fe760fa245f94912372/components/loading/NetworkChartLoading.tsx 8 | * Licensed under the GNU General Public License v3.0 (GPLv3). 9 | */ 10 | import { Loader } from "@/components/loading"; 11 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 12 | 13 | export default function NetworkChartLoading() { 14 | return ( 15 | 16 | 17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 | 25 |
26 | 27 | 28 |
29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/monitor/server-detail-loading.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is based on code from the nezha-dash project, 3 | * originally licensed under the Apache License 2.0. 4 | * The original license can be found in the LICENSE-APACHE file. 5 | * 6 | * Modifications made by AprilNEA 7 | * Derived from: https://github.com/hamster1963/nezha-dash/raw/ac15be6e71ba9804681b1fe760fa245f94912372/components/loading/ServerDetailLoading.tsx 8 | * Licensed under the GNU General Public License v3.0 (GPLv3). 9 | */ 10 | // import { BackIcon } from "@/components/Icon" 11 | import { Skeleton } from "@/components/ui/skeleton" 12 | import { useRouter } from "next/navigation" 13 | 14 | export function ServerDetailChartLoading() { 15 | return ( 16 |
17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | ) 27 | } 28 | 29 | export function ServerDetailLoading() { 30 | const router = useRouter() 31 | 32 | return ( 33 | <> 34 |
{ 36 | router.push("/") 37 | }} 38 | className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight" 39 | > 40 | {/* */} 41 | 42 |
43 | 44 | 45 | ) 46 | } -------------------------------------------------------------------------------- /src/components/monitor/server-flag.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is based on code from the nezha-dash project, 3 | * originally licensed under the Apache License 2.0. 4 | * The original license can be found in the LICENSE-APACHE file. 5 | * 6 | * Modifications made by AprilNEA 7 | * Derived from: https://raw.githubusercontent.com/hamster1963/nezha-dash/ac15be6e71ba9804681b1fe760fa245f94912372/components/ServerFlag.tsx 8 | * Licensed under the GNU General Public License v3.0 (GPLv3). 9 | */ 10 | 11 | import { env } from "@/env"; 12 | import { cn } from "@/lib/utils"; 13 | import getUnicodeFlagIcon from "country-flag-icons/unicode"; 14 | import { useEffect, useState } from "react"; 15 | 16 | export default function ServerFlag({ 17 | country_code, 18 | className, 19 | }: { 20 | country_code: string; 21 | className?: string; 22 | }) { 23 | const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(true); 24 | 25 | useEffect(() => { 26 | if (env.NEXT_PUBLIC_FORCE_USE_SVG_FLAG) { 27 | // If the environment variable requires that SVG be used directly, there is no need to check for Emoji support 28 | setSupportsEmojiFlags(false); 29 | return; 30 | } 31 | 32 | const checkEmojiSupport = () => { 33 | const canvas = document.createElement("canvas"); 34 | const ctx = canvas.getContext("2d"); 35 | const emojiFlag = "🇺🇸"; // Use the American flag as a test 36 | if (!ctx) return; 37 | ctx.fillStyle = "#000"; 38 | ctx.textBaseline = "top"; 39 | ctx.font = "32px Arial"; 40 | ctx.fillText(emojiFlag, 0, 0); 41 | 42 | const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0; 43 | setSupportsEmojiFlags(support); 44 | }; 45 | 46 | checkEmojiSupport(); 47 | }, []); 48 | 49 | if (!country_code) return null; 50 | 51 | return ( 52 | 53 | {env.NEXT_PUBLIC_FORCE_USE_SVG_FLAG || !supportsEmojiFlags ? ( 54 | 55 | ) : ( 56 | getUnicodeFlagIcon(country_code) 57 | )} 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/monitor/server-usage-bar.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is based on code from the nezha-dash project, 3 | * originally licensed under the Apache License 2.0. 4 | * The original license can be found in the LICENSE-APACHE file. 5 | * 6 | * Modifications made by AprilNEA 7 | * Derived from: https://raw.githubusercontent.com/hamster1963/nezha-dash/ac15be6e71ba9804681b1fe760fa245f94912372/components/ServerUsageBar.tsx 8 | * Licensed under the GNU General Public License v3.0 (GPLv3). 9 | */ 10 | import { Progress } from "@/components/ui/progress"; 11 | 12 | type ServerUsageBarProps = { 13 | value: number | string; 14 | }; 15 | 16 | export default function ServerUsageBar({ value }: ServerUsageBarProps) { 17 | const valueNumber = Number(value); 18 | return ( 19 | 90 25 | ? "bg-red-500" 26 | : valueNumber > 70 27 | ? "bg-orange-400" 28 | : "bg-green-500" 29 | } 30 | className={"h-[3px] rounded-sm"} 31 | /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | ThemeProvider as NextThemesProvider, 5 | type ThemeProviderProps, 6 | } from "next-themes"; 7 | import { NuqsAdapter } from "nuqs/adapters/next/app"; 8 | 9 | import { TooltipProvider } from "@/components/ui/tooltip"; 10 | 11 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 12 | return ( 13 | 14 | 15 | {children} 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/setting/items.tsx: -------------------------------------------------------------------------------- 1 | import { Calendar, Home, Inbox, Search, Settings } from "lucide-react"; 2 | 3 | // Menu items. 4 | export const userItems = [ 5 | { 6 | id: "account", 7 | icon: Home, 8 | }, 9 | // { 10 | // title: "Inbox", 11 | // url: "#", 12 | // icon: Inbox, 13 | // }, 14 | // { 15 | // title: "Calendar", 16 | // url: "#", 17 | // icon: Calendar, 18 | // }, 19 | // { 20 | // title: "Search", 21 | // url: "#", 22 | // icon: Search, 23 | // }, 24 | // { 25 | // title: "Settings", 26 | // url: "#", 27 | // icon: Settings, 28 | // }, 29 | ]; 30 | 31 | // Menu items. 32 | export const siteItems = [ 33 | { 34 | id: "general", 35 | icon: Home, 36 | }, 37 | { 38 | id: "api", 39 | icon: Home, 40 | }, 41 | { 42 | id: "notification", 43 | icon: Home, 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /src/components/setting/setting-list.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardDescription, 4 | CardHeader, 5 | CardTitle, 6 | } from "@/components/ui/card"; 7 | import { Link } from "next-view-transitions"; 8 | import { useTranslations } from "next-intl"; 9 | import { capitalize } from "@/lib/utils"; 10 | import { siteItems, userItems } from "./items"; 11 | import * as motion from "motion/react-client"; 12 | 13 | export const SettingList: React.FC<{ region?: "personal" | "site" }> = ({ 14 | region, 15 | }) => { 16 | const t = useTranslations("Private.Setting"); 17 | 18 | return ( 19 |
20 | {(!region || region === "personal") && ( 21 | 22 | 23 |

24 | {t("Personal.title")} 25 |

26 | 27 |
28 | {userItems.map((item, index) => ( 29 | 37 | 38 | 39 | 40 | 41 | 42 | {t(`Personal.${capitalize(item.id)}.title`)} 43 | 44 | 45 | {t(`Personal.${capitalize(item.id)}.description`)} 46 | 47 | 48 | 49 | 50 | 51 | ))} 52 |
53 |
54 | )} 55 | 56 | {(!region || region === "site") && ( 57 | 58 | 59 |

{t("Site.title")}

60 | 61 |
62 | {siteItems.map((item, index) => ( 63 | 71 | 72 | 73 | 74 | 75 | 76 | {t(`Site.${capitalize(item.id)}.title`)} 77 | 78 | 79 | {t(`Site.${capitalize(item.id)}.description`)} 80 | 81 | 82 | 83 | 84 | 85 | ))} 86 |
87 |
88 | )} 89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/setting/setting-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | 4 | import { 5 | Sidebar, 6 | SidebarContent, 7 | SidebarGroup, 8 | SidebarGroupContent, 9 | SidebarGroupLabel, 10 | SidebarMenu, 11 | SidebarMenuButton, 12 | SidebarMenuItem, 13 | } from "@/components/ui/sidebar"; 14 | import { Link } from "next-view-transitions"; 15 | import { useTranslations } from "next-intl"; 16 | import { usePathname } from "next/navigation"; 17 | import { capitalize } from "@/lib/utils"; 18 | 19 | import { siteItems, userItems } from "./items"; 20 | 21 | 22 | export function SettingSidebar() { 23 | const t = useTranslations("Private.Setting"); 24 | const pathname = usePathname(); 25 | 26 | return ( 27 | 28 | 29 | 30 | {t("Personal.title")} 31 | 32 | 33 | {userItems.map((item) => { 34 | const path = `/dash/setting/personal/${item.id}`; 35 | return ( 36 | 37 | 38 | 39 | 40 | {t(`Personal.${capitalize(item.id)}.title`)} 41 | 42 | 43 | 44 | ); 45 | })} 46 | 47 | 48 | 49 | 50 | {t("Site.title")} 51 | 52 | 53 | {siteItems.map((item) => { 54 | const path = `/dash/setting/site/${item.id}`; 55 | return ( 56 | 57 | 58 | 59 | 60 | {t(`Site.${capitalize(item.id)}.title`)} 61 | 62 | 63 | 64 | ); 65 | })} 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/shell.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from "class-variance-authority"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const shellVariants = cva("grid items-center gap-8 pt-6 pb-8 md:py-8", { 7 | variants: { 8 | variant: { 9 | default: "container", 10 | sidebar: "", 11 | centered: "container flex h-dvh max-w-2xl flex-col justify-center py-16", 12 | markdown: "container max-w-3xl py-8 md:py-10 lg:py-10", 13 | }, 14 | }, 15 | defaultVariants: { 16 | variant: "default", 17 | }, 18 | }); 19 | 20 | interface ShellProps 21 | extends React.HTMLAttributes, 22 | VariantProps { 23 | as?: React.ElementType; 24 | } 25 | 26 | function Shell({ 27 | className, 28 | as: Comp = "section", 29 | variant, 30 | ...props 31 | }: ShellProps) { 32 | return ( 33 | 34 | ); 35 | } 36 | 37 | export { Shell, shellVariants }; 38 | -------------------------------------------------------------------------------- /src/components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.js"; 2 | 3 | export function TailwindIndicator() { 4 | if (env.NODE_ENV === "production") return null; 5 | 6 | return ( 7 |
8 |
xs
9 |
10 | sm 11 |
12 |
md
13 |
lg
14 |
xl
15 |
2xl
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/terminal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FitAddon } from "@xterm/addon-fit"; 4 | import { Terminal as XTermTerminal } from "@xterm/xterm"; 5 | import React, { useEffect, useRef } from "react"; 6 | import "@xterm/xterm/css/xterm.css"; 7 | import { cn } from "@/lib/utils"; 8 | import { AttachAddon } from "@xterm/addon-attach"; 9 | import { WebglAddon } from "@xterm/addon-webgl"; 10 | 11 | interface TerminalProps { 12 | vmId: string; 13 | className?: string; 14 | } 15 | 16 | const Terminal: React.FC = ({ className, vmId }) => { 17 | const termRef = useRef(null); 18 | const terminalRef = useRef(null); 19 | const [activeWay, setActiveWay] = React.useState("bash"); 20 | 21 | useEffect(() => { 22 | if (terminalRef.current) { 23 | terminalRef.current.dispose(); 24 | } 25 | 26 | if (!termRef.current) return; 27 | 28 | const term = new XTermTerminal({ 29 | cursorBlink: true, 30 | cols: 80, 31 | rows: 30, 32 | lineHeight: 1.4, 33 | convertEol: true, 34 | theme: { 35 | cursor: "transparent", 36 | background: "rgba(0, 0, 0, 0)", 37 | }, 38 | }); 39 | terminalRef.current = term; 40 | const addonFit = new FitAddon(); 41 | 42 | // biome-ignore lint/style/noNonNullAssertion: exist 43 | term.open(termRef.current!); 44 | term.loadAddon(addonFit); 45 | 46 | const { cols, rows } = term; 47 | const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; 48 | const wsUrl = `${protocol}//${window.location.host}/wss/terminal?vmId=${vmId}&activeWay=${activeWay}&cols=${cols}&rows=${rows}`; 49 | const ws = new WebSocket(wsUrl); 50 | 51 | ws.onopen = () => { 52 | const addonAttach = new AttachAddon(ws); 53 | term.loadAddon(addonAttach); 54 | 55 | // 尝试加载 WebGL 插件以提高性能 56 | try { 57 | const webgl = new WebglAddon(); 58 | term.loadAddon(webgl); 59 | } catch (e) { 60 | console.warn("WebGL addon could not be loaded", e); 61 | } 62 | }; 63 | 64 | // observe and handle window size change 65 | const handleResize = () => { 66 | addonFit.fit(); 67 | const { cols, rows } = term; 68 | if (ws.readyState === WebSocket.OPEN) { 69 | ws.send( 70 | JSON.stringify({ 71 | type: "resize", 72 | cols, 73 | rows, 74 | }), 75 | ); 76 | } 77 | }; 78 | window.addEventListener("resize", handleResize); 79 | const resizeObserver = new ResizeObserver(() => { 80 | handleResize(); 81 | }); 82 | resizeObserver.observe(termRef.current); 83 | 84 | return () => { 85 | // window.removeEventListener("resize", handleResize); 86 | // resizeObserver.disconnect(); 87 | if (ws.readyState === WebSocket.OPEN) { 88 | ws.close(); 89 | } 90 | // term.dispose(); 91 | }; 92 | }, [vmId, activeWay]); 93 | 94 | return ( 95 |
101 |
102 |
103 | ); 104 | }; 105 | export default Terminal; 106 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from "class-variance-authority"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 font-semibold text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot, Slottable } from "@radix-ui/react-slot"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | import { Loader2 } from "lucide-react"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "inline text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "size-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | isLoading?: boolean; 42 | children?: React.ReactNode; 43 | } 44 | 45 | const Button = React.forwardRef( 46 | ( 47 | { 48 | className, 49 | variant, 50 | size, 51 | children, 52 | isLoading = false, 53 | asChild = false, 54 | ...props 55 | }, 56 | ref, 57 | ) => { 58 | const Comp = asChild ? Slot : "button"; 59 | return ( 60 | <> 61 | 70 | {isLoading && } 71 | {children} 72 | 73 | 74 | ); 75 | }, 76 | ); 77 | Button.displayName = "Button"; 78 | 79 | export { Button, buttonVariants }; 80 | -------------------------------------------------------------------------------- /src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChevronLeft, ChevronRight } from "lucide-react"; 4 | import type * as React from "react"; 5 | import { DayPicker } from "react-day-picker"; 6 | 7 | import { buttonVariants } from "@/components/ui/button"; 8 | import { cn } from "@/lib/utils"; 9 | 10 | export type CalendarProps = React.ComponentProps; 11 | 12 | function Calendar({ 13 | className, 14 | classNames, 15 | showOutsideDays = true, 16 | ...props 17 | }: CalendarProps) { 18 | return ( 19 | .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" 43 | : "[&:has([aria-selected])]:rounded-md", 44 | ), 45 | day: cn( 46 | buttonVariants({ variant: "ghost" }), 47 | "h-8 w-8 p-0 font-normal aria-selected:opacity-100", 48 | ), 49 | day_range_start: "day-range-start", 50 | day_range_end: "day-range-end", 51 | day_selected: 52 | "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", 53 | day_today: "bg-accent text-accent-foreground", 54 | day_outside: 55 | "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", 56 | day_disabled: "text-muted-foreground opacity-50", 57 | day_range_middle: 58 | "aria-selected:bg-accent aria-selected:text-accent-foreground", 59 | day_hidden: "invisible", 60 | ...classNames, 61 | }} 62 | components={{ 63 | IconLeft: ({ className, ...props }) => ( 64 | 65 | ), 66 | IconRight: ({ className, ...props }) => ( 67 | 68 | ), 69 | }} 70 | {...props} 71 | /> 72 | ); 73 | } 74 | Calendar.displayName = "Calendar"; 75 | 76 | export { Calendar }; 77 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 4 | import { Check } from "lucide-react"; 5 | import * as React from "react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )); 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /src/components/ui/faceted-filter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Check } from "lucide-react"; 4 | import * as React from "react"; 5 | 6 | import { 7 | Command, 8 | CommandEmpty, 9 | CommandGroup, 10 | CommandInput, 11 | CommandItem, 12 | CommandList, 13 | CommandSeparator, 14 | CommandShortcut, 15 | } from "@/components/ui/command"; 16 | import { 17 | Popover, 18 | PopoverContent, 19 | PopoverTrigger, 20 | } from "@/components/ui/popover"; 21 | import { cn } from "@/lib/utils"; 22 | 23 | const FacetedFilter = Popover; 24 | 25 | const FacetedFilterTrigger = React.forwardRef< 26 | React.ComponentRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, children, ...props }, ref) => ( 29 | 30 | {children} 31 | 32 | )); 33 | FacetedFilterTrigger.displayName = "FacetedFilterTrigger"; 34 | 35 | const FacetedFilterContent = React.forwardRef< 36 | React.ComponentRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, children, ...props }, ref) => ( 39 | 45 | {children} 46 | 47 | )); 48 | FacetedFilterContent.displayName = "FacetedFilterContent"; 49 | 50 | const FacetedFilterInput = CommandInput; 51 | 52 | const FacetedFilterList = CommandList; 53 | 54 | const FacetedFilterEmpty = CommandEmpty; 55 | 56 | const FacetedFilterGroup = CommandGroup; 57 | 58 | interface FacetedFilterItemProps 59 | extends React.ComponentPropsWithoutRef { 60 | selected: boolean; 61 | } 62 | 63 | const FacetedFilterItem = React.forwardRef< 64 | React.ComponentRef, 65 | FacetedFilterItemProps 66 | >(({ className, children, selected, ...props }, ref) => { 67 | return ( 68 | 75 | 83 | 84 | 85 | {children} 86 | 87 | ); 88 | }); 89 | FacetedFilterItem.displayName = "FacetedFilterItem"; 90 | 91 | const FacetedFilterSeparator = CommandSeparator; 92 | 93 | const FacetedFilterShortcut = CommandShortcut; 94 | 95 | export { 96 | FacetedFilter, 97 | FacetedFilterTrigger, 98 | FacetedFilterContent, 99 | FacetedFilterInput, 100 | FacetedFilterList, 101 | FacetedFilterEmpty, 102 | FacetedFilterGroup, 103 | FacetedFilterItem, 104 | FacetedFilterSeparator, 105 | FacetedFilterShortcut, 106 | }; 107 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends Omit, "value"> { 7 | value?: string | number | readonly string[] | null; 8 | } 9 | 10 | const Input = React.forwardRef( 11 | ({ className, type, value, ...props }, ref) => { 12 | return ( 13 | 23 | ); 24 | }, 25 | ); 26 | Input.displayName = "Input"; 27 | 28 | export { Input }; 29 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as LabelPrimitive from "@radix-ui/react-label"; 4 | import { type VariantProps, cva } from "class-variance-authority"; 5 | import * as React from "react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor; 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )); 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 34 | -------------------------------------------------------------------------------- /src/components/ui/portal.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import * as React from "react"; 3 | import * as ReactDOM from "react-dom"; 4 | 5 | interface PortalProps extends React.ComponentPropsWithoutRef { 6 | /** 7 | * The container to mount the portal into. 8 | * @default document.body 9 | */ 10 | container?: HTMLElement | DocumentFragment | null; 11 | } 12 | 13 | const Portal = React.forwardRef( 14 | (props, forwardedRef) => { 15 | const { container: containerProp, ...portalProps } = props; 16 | const [mounted, setMounted] = React.useState(false); 17 | 18 | React.useLayoutEffect(() => { 19 | setMounted(true); 20 | }, []); 21 | 22 | const container = 23 | containerProp ?? (mounted ? globalThis.document?.body : null); 24 | 25 | if (!container) return null; 26 | 27 | return ReactDOM.createPortal( 28 | , 29 | container, 30 | ); 31 | }, 32 | ); 33 | 34 | Portal.displayName = "Portal"; 35 | 36 | export { Portal }; 37 | 38 | export type { PortalProps }; 39 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef & { 11 | indicatorClassName?: string; 12 | } 13 | >(({ className, indicatorClassName, value, ...props }, ref) => ( 14 | 22 | 29 | 30 | )); 31 | Progress.displayName = ProgressPrimitive.Root.displayName; 32 | 33 | export { Progress }; 34 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ); 13 | } 14 | 15 | export { Skeleton }; 16 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /src/components/ui/tab-switch.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is based on code from the nezha-dash project, 3 | * originally licensed under the Apache License 2.0. 4 | * The original license can be found in the LICENSE-APACHE file. 5 | * 6 | * Modifications made by AprilNEA 7 | * Derived from: https://github.com/hamster1963/nezha-dash/raw/ac15be6e71ba9804681b1fe760fa245f94912372/components/TabSwitch.tsx 8 | * Licensed under the GNU General Public License v3.0 (GPLv3). 9 | */ 10 | "use client" 11 | 12 | import { cn } from "@/lib/utils" 13 | import { useLocale, useTranslations } from "next-intl" 14 | import { useEffect, useRef, useState } from "react" 15 | 16 | export default function TabSwitch({ 17 | tabs, 18 | currentTab, 19 | setCurrentTab, 20 | }: { 21 | tabs: string[] 22 | currentTab: string 23 | setCurrentTab: (tab: string) => void 24 | }) { 25 | const t = useTranslations("TabSwitch") 26 | const [indicator, setIndicator] = useState<{ x: number; w: number }>({ 27 | x: 0, 28 | w: 0, 29 | }) 30 | const tabRefs = useRef<(HTMLDivElement | null)[]>([]) 31 | const locale = useLocale() 32 | 33 | useEffect(() => { 34 | const currentTabElement = tabRefs.current[tabs.indexOf(currentTab)] 35 | if (currentTabElement) { 36 | const parentPadding = 1 37 | setIndicator({ 38 | x: 39 | tabs.indexOf(currentTab) !== 0 40 | ? currentTabElement.offsetLeft - parentPadding 41 | : currentTabElement.offsetLeft, 42 | w: currentTabElement.offsetWidth, 43 | }) 44 | } 45 | }, [currentTab, tabs, locale]) 46 | 47 | return ( 48 |
49 |
50 | {indicator.w > 0 && ( 51 |
60 | )} 61 | {tabs.map((tab: string, index) => ( 62 |
{ 65 | tabRefs.current[index] = el 66 | }} 67 | onClick={() => setCurrentTab(tab)} 68 | className={cn( 69 | "relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]", 70 | "text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50", 71 | { 72 | "text-stone-950 dark:text-stone-50": currentTab === tab, 73 | }, 74 | )} 75 | > 76 |
77 |

{t(tab)}

78 |
79 |
80 | ))} 81 |
82 |
83 | ) 84 | } -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )); 17 | Table.displayName = "Table"; 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )); 25 | TableHeader.displayName = "TableHeader"; 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )); 37 | TableBody.displayName = "TableBody"; 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className, 48 | )} 49 | {...props} 50 | /> 51 | )); 52 | TableFooter.displayName = "TableFooter"; 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )); 67 | TableRow.displayName = "TableRow"; 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className, 78 | )} 79 | {...props} 80 | /> 81 | )); 82 | TableHead.displayName = "TableHead"; 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className, 93 | )} 94 | {...props} 95 | /> 96 | )); 97 | TableCell.displayName = "TableCell"; 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )); 109 | TableCaption.displayName = "TableCaption"; 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | }; 121 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |