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 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { Toaster as Sonner } from "sonner";
5 |
6 | type ToasterProps = React.ComponentProps;
7 |
8 | function Toaster({ ...props }: ToasterProps) {
9 | const { theme = "system" } = useTheme();
10 |
11 | return (
12 |
29 | );
30 | }
31 |
32 | export { Toaster };
33 |
--------------------------------------------------------------------------------
/src/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
4 | import type { VariantProps } from "class-variance-authority";
5 | import * as React from "react";
6 |
7 | import { toggleVariants } from "@/components/ui/toggle";
8 | import { cn } from "@/lib/utils";
9 |
10 | const ToggleGroupContext = React.createContext<
11 | VariantProps
12 | >({
13 | size: "default",
14 | variant: "default",
15 | });
16 |
17 | const ToggleGroup = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef &
20 | VariantProps
21 | >(({ className, variant, size, children, ...props }, ref) => (
22 |
27 |
28 | {children}
29 |
30 |
31 | ));
32 |
33 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
34 |
35 | const ToggleGroupItem = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef &
38 | VariantProps
39 | >(({ className, children, variant, size, ...props }, ref) => {
40 | const context = React.useContext(ToggleGroupContext);
41 |
42 | return (
43 |
54 | {children}
55 |
56 | );
57 | });
58 |
59 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
60 |
61 | export { ToggleGroup, ToggleGroupItem };
62 |
--------------------------------------------------------------------------------
/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as TogglePrimitive from "@radix-ui/react-toggle";
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 toggleVariants = cva(
10 | "inline-flex items-center justify-center rounded-md font-medium text-sm transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
11 | {
12 | variants: {
13 | variant: {
14 | default: "bg-transparent",
15 | outline:
16 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
17 | },
18 | size: {
19 | default: "h-9 px-3",
20 | sm: "h-8 px-2",
21 | lg: "h-10 px-3",
22 | },
23 | },
24 | defaultVariants: {
25 | variant: "default",
26 | size: "default",
27 | },
28 | },
29 | );
30 |
31 | const Toggle = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef &
34 | VariantProps
35 | >(({ className, variant, size, ...props }, ref) => (
36 |
41 | ));
42 |
43 | Toggle.displayName = TogglePrimitive.Root.displayName;
44 |
45 | export { Toggle, toggleVariants };
46 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
4 | import * as React from "react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider;
9 |
10 | const Tooltip = TooltipPrimitive.Root;
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger;
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ));
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
31 |
--------------------------------------------------------------------------------
/src/components/vm/feature-flags.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQueryState } from "nuqs";
4 | import * as React from "react";
5 |
6 | import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
7 | import {
8 | Tooltip,
9 | TooltipContent,
10 | TooltipTrigger,
11 | } from "@/components/ui/tooltip";
12 | import { type DataTableConfig, dataTableConfig } from "@/config/data-table";
13 |
14 | type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"];
15 |
16 | interface VMsTableContextProps {
17 | featureFlags: FeatureFlagValue[];
18 | setFeatureFlags: (value: FeatureFlagValue[]) => void;
19 | }
20 |
21 | const VMsTableContext = React.createContext({
22 | featureFlags: [],
23 | setFeatureFlags: () => {},
24 | });
25 |
26 | export function useTasksTable() {
27 | const context = React.useContext(VMsTableContext);
28 | if (!context) {
29 | throw new Error("useTasksTable must be used within a TasksTableProvider");
30 | }
31 | return context;
32 | }
33 |
34 | export function TasksTableProvider({ children }: React.PropsWithChildren) {
35 | const [featureFlags, setFeatureFlags] = useQueryState(
36 | "featureFlags",
37 | {
38 | defaultValue: [],
39 | parse: (value) => value.split(",") as FeatureFlagValue[],
40 | serialize: (value) => value.join(","),
41 | eq: (a, b) =>
42 | a.length === b.length && a.every((value, index) => value === b[index]),
43 | clearOnDefault: true,
44 | },
45 | );
46 |
47 | return (
48 | void setFeatureFlags(value),
52 | }}
53 | >
54 |
55 |
setFeatureFlags(value)}
61 | className="w-fit"
62 | >
63 | {dataTableConfig.featureFlags.map((flag) => (
64 |
65 |
70 |
71 |
75 | {flag.label}
76 |
77 |
78 |
84 | {flag.tooltipTitle}
85 |
86 | {flag.tooltipDescription}
87 |
88 |
89 |
90 | ))}
91 |
92 |
93 | {children}
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/vm/vms-table-toolbar-actions.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { VM } from "@/db/schema/vm";
4 | import type { Table } from "@tanstack/react-table";
5 | import { Download } from "lucide-react";
6 |
7 | import { Button } from "@/components/ui/button";
8 | // import { exportTableToCSV } from "@/lib/export";
9 |
10 | import { DeleteVMsDialog } from "./delete-vms-dialog";
11 | import { CreateVMSheet } from "./create-vm-sheet";
12 |
13 | interface VMsTableToolbarActionsProps {
14 | table: Table;
15 | }
16 |
17 | export function VMsTableToolbarActions({ table }: VMsTableToolbarActionsProps) {
18 | return (
19 |
20 | {table.getFilteredSelectedRowModel().rows.length > 0 ? (
21 | row.original)}
25 | onSuccess={() => table.toggleAllRowsSelected(false)}
26 | />
27 | ) : null}
28 | {/*
32 | exportTableToCSV(table, {
33 | filename: "tasks",
34 | excludeColumns: ["select", "actions"],
35 | })
36 | }
37 | className="gap-2"
38 | >
39 |
40 | 导出
41 | */}
42 | {/**
43 | * Other actions can be added here.
44 | * For example, import, view, etc.
45 | */}
46 |
47 |
48 |
49 | 导入
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/config/data-table.ts:
--------------------------------------------------------------------------------
1 | import { Pickaxe, SquareSquare } from "lucide-react";
2 |
3 | export type DataTableConfig = typeof dataTableConfig;
4 |
5 | export const dataTableConfig = {
6 | featureFlags: [
7 | {
8 | label: "Advanced table",
9 | value: "advancedTable" as const,
10 | icon: Pickaxe,
11 | tooltipTitle: "Toggle advanced table",
12 | tooltipDescription: "A filter and sort builder to filter and sort rows.",
13 | },
14 | {
15 | label: "Floating bar",
16 | value: "floatingBar" as const,
17 | icon: SquareSquare,
18 | tooltipTitle: "Toggle floating bar",
19 | tooltipDescription: "A floating bar that sticks to the top of the table.",
20 | },
21 | ],
22 | textOperators: [
23 | { label: "Contains", value: "iLike" as const },
24 | { label: "Does not contain", value: "notILike" as const },
25 | { label: "Is", value: "eq" as const },
26 | { label: "Is not", value: "ne" as const },
27 | { label: "Is empty", value: "isEmpty" as const },
28 | { label: "Is not empty", value: "isNotEmpty" as const },
29 | ],
30 | numericOperators: [
31 | { label: "Is", value: "eq" as const },
32 | { label: "Is not", value: "ne" as const },
33 | { label: "Is less than", value: "lt" as const },
34 | { label: "Is less than or equal to", value: "lte" as const },
35 | { label: "Is greater than", value: "gt" as const },
36 | { label: "Is greater than or equal to", value: "gte" as const },
37 | { label: "Is empty", value: "isEmpty" as const },
38 | { label: "Is not empty", value: "isNotEmpty" as const },
39 | ],
40 | dateOperators: [
41 | { label: "Is", value: "eq" as const },
42 | { label: "Is not", value: "ne" as const },
43 | { label: "Is before", value: "lt" as const },
44 | { label: "Is after", value: "gt" as const },
45 | { label: "Is on or before", value: "lte" as const },
46 | { label: "Is on or after", value: "gte" as const },
47 | { label: "Is between", value: "isBetween" as const },
48 | { label: "Is relative to today", value: "isRelativeToToday" as const },
49 | { label: "Is empty", value: "isEmpty" as const },
50 | { label: "Is not empty", value: "isNotEmpty" as const },
51 | ],
52 | selectOperators: [
53 | { label: "Is", value: "eq" as const },
54 | { label: "Is not", value: "ne" as const },
55 | { label: "Is empty", value: "isEmpty" as const },
56 | { label: "Is not empty", value: "isNotEmpty" as const },
57 | ],
58 | booleanOperators: [
59 | { label: "Is", value: "eq" as const },
60 | { label: "Is not", value: "ne" as const },
61 | ],
62 | joinOperators: [
63 | { label: "And", value: "and" as const },
64 | { label: "Or", value: "or" as const },
65 | ],
66 | sortOrders: [
67 | { label: "Asc", value: "asc" as const },
68 | { label: "Desc", value: "desc" as const },
69 | ],
70 | columnTypes: [
71 | "text",
72 | "number",
73 | "date",
74 | "boolean",
75 | "select",
76 | "multi-select",
77 | ] as const,
78 | globalOperators: [
79 | "iLike",
80 | "notILike",
81 | "eq",
82 | "ne",
83 | "isEmpty",
84 | "isNotEmpty",
85 | "lt",
86 | "lte",
87 | "gt",
88 | "gte",
89 | "isBetween",
90 | "isRelativeToToday",
91 | "and",
92 | "or",
93 | ] as const,
94 | };
95 |
--------------------------------------------------------------------------------
/src/config/site.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env";
2 |
3 | export type SiteConfig = typeof siteConfig;
4 |
5 | export const siteConfig = {
6 | name: "VMBoard",
7 | description:
8 | "VMBoard is management aggregation panel for major VPS Hosting providers.",
9 | url:
10 | env.NODE_ENV === "development"
11 | ? "http://localhost:3000"
12 | : "https://vmboard.app",
13 | links: {
14 | github: "https://github.com/AprilNEA/vmboard",
15 | docs: "https://vmboard.io",
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/db/bigint-jsonb.ts:
--------------------------------------------------------------------------------
1 | import { customType } from "drizzle-orm/pg-core";
2 | import JSONbig from "json-bigint";
3 |
4 | const JSONBigInt = JSONbig({
5 | storeAsString: true,
6 | protoAction: "preserve",
7 | constructorAction: "preserve",
8 | });
9 |
10 | const bigintJsonb = (name: string) =>
11 | customType<{ data: TData; driverData: string }>({
12 | dataType() {
13 | return "jsonb";
14 | },
15 | toDriver(value: TData): string {
16 | return JSONBigInt.stringify(value);
17 | },
18 | fromDriver(value: string): TData {
19 | return JSONBigInt.parse(JSON.stringify(value)) as TData;
20 | },
21 | })(name);
22 |
23 | export default bigintJsonb;
24 |
--------------------------------------------------------------------------------
/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env.js";
2 | import { drizzle } from "drizzle-orm/node-postgres";
3 | import pg from "pg";
4 |
5 | import * as schema from "./schema";
6 |
7 | const getDbPool = () => {
8 | return new pg.Pool({
9 | connectionString: env.DATABASE_URL,
10 | });
11 | };
12 |
13 | const globalForDB = globalThis as unknown as {
14 | dbPool: ReturnType | undefined;
15 | };
16 |
17 | const dbPool = globalForDB?.dbPool ?? getDbPool();
18 |
19 | const db = drizzle(dbPool, { schema });
20 |
21 | export type DataBase = ReturnType>;
22 |
23 | if (process.env.NODE_ENV !== "production") {
24 | globalForDB.dbPool = dbPool;
25 | }
26 |
27 | export default db;
28 |
--------------------------------------------------------------------------------
/src/db/migrate.ts:
--------------------------------------------------------------------------------
1 | import { migrate } from "drizzle-orm/postgres-js/migrator";
2 |
3 | import db from ".";
4 |
5 | export async function runMigrate() {
6 | console.log("⏳ Running migrations...");
7 |
8 | const start = Date.now();
9 |
10 | await migrate(db, { migrationsFolder: "drizzle" });
11 |
12 | const end = Date.now();
13 |
14 | console.log(`✅ Migrations completed in ${end - start}ms`);
15 | }
16 |
17 | if (process.env.RUN_MIGRATE === "1") {
18 | runMigrate()
19 | .then(() => {
20 | console.log("✅ Migration completed, exiting...");
21 | process.exit(0);
22 | })
23 | .catch((err) => {
24 | console.error("❌ Migration failed");
25 | console.error(err);
26 | process.exit(1);
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/src/db/redis.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "ioredis";
2 |
3 | const globalForRedis = globalThis as unknown as {
4 | redis: Redis | undefined;
5 | };
6 | const url =
7 | process.env.REDIS_URL || process.env.REDIS_URI || "redis://localhost:6379";
8 | if (!url) {
9 | if (process.env.IS_BUILDING !== "true") {
10 | throw new Error("REDIS_URL is not set");
11 | }
12 | }
13 | const parsedURL = new URL(url);
14 |
15 | export const redisConfig = {
16 | host: parsedURL.hostname || "localhost",
17 | port: Number(parsedURL.port || 6379),
18 | database: (parsedURL.pathname || "/0").slice(1) || "0",
19 | password: parsedURL.password
20 | ? decodeURIComponent(parsedURL.password)
21 | : undefined,
22 | connectTimeout: 10000,
23 | };
24 |
25 | const getRedis = (reuse = true): Redis => {
26 | if (process.env.IS_BUILDING === "true") {
27 | console.log("While building, redis will return undefined");
28 | return undefined as unknown as Redis;
29 | }
30 | return reuse ? (globalForRedis.redis ?? new Redis(url)) : new Redis(url);
31 | };
32 | const redis = globalForRedis.redis ?? getRedis();
33 |
34 | export default redis;
35 |
36 | if (process.env.NODE_ENV !== "production") {
37 | globalForRedis.redis = redis;
38 | }
39 |
40 | export const REDIS_PREFIX = {
41 | CONFIG: "config",
42 | } as const;
43 |
44 | // Used for trpc subscribing to channels
45 | export const redisForSub = getRedis(false);
46 |
--------------------------------------------------------------------------------
/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | export * from "./schema/auth";
2 | export * from "./schema/merchant";
3 | export * from "./schema/vm";
4 | export * from "./schema/ssh-key";
5 | export * from "./schema/metrics";
6 | export * from "./schema/page";
7 |
--------------------------------------------------------------------------------
/src/db/schema/auth.ts:
--------------------------------------------------------------------------------
1 | import {
2 | pgTable,
3 | text,
4 | integer,
5 | timestamp,
6 | boolean,
7 | } from "drizzle-orm/pg-core";
8 |
9 | export const user = pgTable("user", {
10 | id: text("id").primaryKey(),
11 | name: text("name").notNull(),
12 | email: text("email").notNull().unique(),
13 | emailVerified: boolean("email_verified").notNull(),
14 | image: text("image"),
15 | createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
16 | updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
17 | username: text("username").unique(),
18 | role: text("role"),
19 | banned: boolean("banned"),
20 | banReason: text("ban_reason"),
21 | banExpires: timestamp("ban_expires", { withTimezone: true }),
22 | });
23 |
24 | export const session = pgTable("session", {
25 | id: text("id").primaryKey(),
26 | expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
27 | token: text("token").notNull().unique(),
28 | createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
29 | updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
30 | ipAddress: text("ip_address"),
31 | userAgent: text("user_agent"),
32 | userId: text("user_id")
33 | .notNull()
34 | .references(() => user.id),
35 | impersonatedBy: text("impersonated_by"),
36 | activeOrganizationId: text("active_organization_id"),
37 | });
38 |
39 | export const account = pgTable("account", {
40 | id: text("id").primaryKey(),
41 | accountId: text("account_id").notNull(),
42 | providerId: text("provider_id").notNull(),
43 | userId: text("user_id")
44 | .notNull()
45 | .references(() => user.id),
46 | accessToken: text("access_token"),
47 | refreshToken: text("refresh_token"),
48 | idToken: text("id_token"),
49 | accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
50 | refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
51 | scope: text("scope"),
52 | password: text("password"),
53 | createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
54 | updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
55 | });
56 |
57 | export const verification = pgTable("verification", {
58 | id: text("id").primaryKey(),
59 | identifier: text("identifier").notNull(),
60 | value: text("value").notNull(),
61 | expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
62 | createdAt: timestamp("created_at", { withTimezone: true }),
63 | updatedAt: timestamp("updated_at", { withTimezone: true }),
64 | });
65 |
66 | export const passkey = pgTable("passkey", {
67 | id: text("id").primaryKey(),
68 | name: text("name"),
69 | publicKey: text("public_key").notNull(),
70 | userId: text("user_id")
71 | .notNull()
72 | .references(() => user.id),
73 | webauthnUserID: text("webauthn_user_id").notNull(),
74 | counter: integer("counter").notNull(),
75 | deviceType: text("device_type").notNull(),
76 | backedUp: boolean("backed_up").notNull(),
77 | transports: text("transports"),
78 | createdAt: timestamp("created_at", { withTimezone: true }),
79 | });
80 |
--------------------------------------------------------------------------------
/src/db/schema/config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | pgTable,
3 | boolean,
4 | varchar,
5 | jsonb,
6 | timestamp,
7 | } from "drizzle-orm/pg-core";
8 | import { user } from "./auth";
9 |
10 | export const config = pgTable("config", {
11 | key: varchar("key", { length: 255 }).primaryKey(),
12 | value: jsonb("value").notNull(),
13 | isGlobal: boolean("is_global").default(false),
14 |
15 | userId: varchar("user_id", { length: 255 })
16 | .references(() => user.id, {
17 | onDelete: "cascade",
18 | onUpdate: "cascade",
19 | })
20 | .notNull(),
21 |
22 | createdAt: timestamp("created_at", { withTimezone: true })
23 | .defaultNow()
24 | .notNull(),
25 | updatedAt: timestamp("updated_at", { withTimezone: true })
26 | .defaultNow()
27 | .notNull(),
28 | });
29 |
--------------------------------------------------------------------------------
/src/db/schema/merchant.ts:
--------------------------------------------------------------------------------
1 | import {
2 | jsonb,
3 | pgEnum,
4 | pgTable,
5 | serial,
6 | text,
7 | timestamp,
8 | varchar,
9 | } from "drizzle-orm/pg-core";
10 | import { user } from "./auth";
11 |
12 | export const merchant = pgTable("merchant", {
13 | id: serial("id").primaryKey(),
14 | nickname: varchar("nickname").notNull(),
15 |
16 | userId: text("user_id")
17 | .notNull()
18 | .references(() => user.id, {
19 | onDelete: "cascade",
20 | onUpdate: "cascade",
21 | }),
22 | merchant: varchar("merchant", {
23 | length: 30,
24 | }).notNull(),
25 |
26 | username: varchar("username").notNull(),
27 | password: varchar("password").notNull(),
28 | cookieJar: jsonb("cookie_jar"),
29 | comment: text("comment"),
30 |
31 | createdAt: timestamp("created_at", { withTimezone: true })
32 | .notNull()
33 | .defaultNow(),
34 | updatedAt: timestamp("updated_at", { withTimezone: true })
35 | .notNull()
36 | .defaultNow()
37 | .$onUpdate(() => new Date()),
38 | });
39 |
40 | export type Merchant = typeof merchant.$inferSelect;
41 | export type NewMerchant = typeof merchant.$inferInsert;
42 |
--------------------------------------------------------------------------------
/src/db/schema/metrics.ts:
--------------------------------------------------------------------------------
1 | import { decimal, integer, timestamp } from "drizzle-orm/pg-core";
2 | import { pgTable } from "../utils";
3 | import { vm } from "./vm";
4 |
5 | export const metrics = pgTable("metrics", {
6 | time: timestamp("time", { mode: "string", withTimezone: true })
7 | .defaultNow()
8 | .notNull(),
9 | vmId: integer("vm_id")
10 | .references(() => vm.id, {
11 | onDelete: "cascade",
12 | onUpdate: "cascade",
13 | })
14 | .notNull(),
15 | uptime: integer("uptime").notNull(),
16 |
17 | cpuUsage: decimal("cpu_usage").notNull(),
18 | processCount: integer("process_count").notNull(),
19 |
20 | // Windows may not have load average
21 | load1: decimal("load1"),
22 | load5: decimal("load5"),
23 | load15: decimal("load15"),
24 |
25 | memoryUsed: decimal("memory_used").notNull(),
26 | memoryTotal: decimal("memory_total").notNull(),
27 | swapUsed: decimal("swap_used").notNull(),
28 | swapTotal: decimal("swap_total").notNull(),
29 |
30 | diskUsed: decimal("disk_used").notNull(),
31 | diskTotal: decimal("disk_total").notNull(),
32 | diskRead: decimal("disk_read").notNull(),
33 | diskWrite: decimal("disk_write").notNull(),
34 |
35 | networkIn: decimal("network_in").notNull(),
36 | networkOut: decimal("network_out").notNull(),
37 |
38 | tcpConnections: integer("tcp_connections").notNull(),
39 | udpConnections: integer("udp_connections").notNull(),
40 | });
41 |
42 | export type Metrics = typeof metrics.$inferSelect;
43 | export type NewMetrics = typeof metrics.$inferInsert;
44 |
--------------------------------------------------------------------------------
/src/db/schema/ssh-key.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
2 | import { user } from "./auth";
3 |
4 | export const sshKeysTable = pgTable("ssh-key", {
5 | id: serial("id").primaryKey(),
6 | userId: text("user_id")
7 | .notNull()
8 | .references(() => user.id, {
9 | onDelete: "cascade",
10 | onUpdate: "cascade",
11 | }),
12 |
13 | name: text("name").notNull(),
14 | description: text("description"),
15 |
16 | privateKey: text("privateKey").notNull().default(""),
17 | publicKey: text("publicKey").notNull(),
18 |
19 | createdAt: timestamp("created_at", { withTimezone: true })
20 | .notNull()
21 | .defaultNow(),
22 | updatedAt: timestamp("updated_at", { withTimezone: true })
23 | .notNull()
24 | .defaultNow()
25 | .$onUpdate(() => new Date()),
26 | });
27 |
28 | export type SSHKey = typeof sshKeysTable.$inferSelect;
29 | export type NewSSHKey = typeof sshKeysTable.$inferInsert;
30 |
--------------------------------------------------------------------------------
/src/db/schema/vm.ts:
--------------------------------------------------------------------------------
1 | import {
2 | inet,
3 | integer,
4 | jsonb,
5 | pgTable,
6 | serial,
7 | text,
8 | timestamp,
9 | uuid,
10 | varchar,
11 | } from "drizzle-orm/pg-core";
12 | import { user } from "./auth";
13 | import { merchant, type Merchant } from "./merchant";
14 | import type { VMMetadata } from "vmapi";
15 | import type { ConnectConfig } from "ssh2";
16 | import bigintJsonb from "../bigint-jsonb";
17 | import { relations } from "drizzle-orm";
18 | import { pageVM } from "./page";
19 | import { metrics } from "./metrics";
20 | import { z } from "zod";
21 |
22 | export interface SSHInfo extends ConnectConfig {
23 | sshKeyId?: number;
24 | }
25 |
26 | export interface MonitorVMInfo {
27 | os: string;
28 | osVersion: string;
29 | arch: string;
30 | platform: string;
31 | platformVersion: string;
32 | kernel: string;
33 | hostname: string;
34 | cpu: string[];
35 | memory: string;
36 | disk: string;
37 | uptime: string;
38 | version: string;
39 | }
40 |
41 | export const monitorConfig = z.object({
42 | metrics_interval: z.number().min(1).max(60),
43 | });
44 |
45 | export type MonitorConfig = z.infer;
46 |
47 | export const vm = pgTable("vm", {
48 | id: serial("id").primaryKey(),
49 | userId: text("user_id")
50 | .notNull()
51 | .references(() => user.id, {
52 | onDelete: "cascade",
53 | onUpdate: "cascade",
54 | }),
55 | merchantId: integer("merchant_id").references(() => merchant.id, {
56 | onDelete: "cascade",
57 | onUpdate: "cascade",
58 | }),
59 |
60 | nickname: varchar("nickname", { length: 128 }).notNull(),
61 | status: varchar("status", {
62 | length: 30,
63 | enum: ["running", "stopped", "expired", "error"],
64 | }).notNull(),
65 | ipAddress: inet("ip_address"),
66 | metadata: jsonb("metadata").$type(),
67 |
68 | sshInfo: jsonb("ssh_info").$type(),
69 | /* Secret token for probe */
70 | probeToken: uuid("probe_token").defaultRandom().unique().notNull(),
71 | /* Config for probe */
72 | probeConfig: jsonb("probe_config").$type(),
73 | /* Information harvest from probe */
74 | probeInfo: bigintJsonb("probe_info").$type(),
75 |
76 | createdAt: timestamp("created_at", { withTimezone: true })
77 | .notNull()
78 | .defaultNow(),
79 | updatedAt: timestamp("updated_at", { withTimezone: true })
80 | .notNull()
81 | .defaultNow()
82 | .$onUpdate(() => new Date()),
83 | });
84 |
85 | export const vmRelations = relations(vm, ({ one, many }) => ({
86 | user: one(user, {
87 | fields: [vm.userId],
88 | references: [user.id],
89 | }),
90 | merchant: one(merchant, {
91 | fields: [vm.merchantId],
92 | references: [merchant.id],
93 | }),
94 | pages: many(pageVM),
95 | metrics: many(metrics),
96 | }));
97 |
98 | export type VM = typeof vm.$inferSelect;
99 | export type NewVM = typeof vm.$inferInsert;
100 | export type VMWithMerchant = VM & {
101 | merchant: Merchant;
102 | };
103 |
--------------------------------------------------------------------------------
/src/db/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @see https://gist.github.com/rphlmr/0d1722a794ed5a16da0fdf6652902b15
3 | */
4 |
5 | import { type AnyColumn, not, sql } from "drizzle-orm";
6 | import { pgTableCreator } from "drizzle-orm/pg-core";
7 |
8 | // import { databasePrefix } from "@/lib/constants";
9 |
10 | /**
11 | * This lets us use the multi-project schema feature of Drizzle ORM. So the same
12 | * database instance can be used for multiple projects.
13 | *
14 | * @see https://orm.drizzle.team/docs/goodies#multi-project-schema
15 | */
16 | export const pgTable = pgTableCreator((name) => `${name}`);
17 |
18 | export function takeFirstOrNull(data: TData[]) {
19 | return data[0] ?? null;
20 | }
21 |
22 | export function takeFirstOrThrow(data: TData[]) {
23 | const first = takeFirstOrNull(data);
24 |
25 | if (!first) {
26 | throw new Error("Item not found");
27 | }
28 |
29 | return first;
30 | }
31 |
32 | export function isEmpty(column: TColumn) {
33 | return sql`
34 | case
35 | when ${column} is null then true
36 | when ${column} = '' then true
37 | when ${column}::text = '[]' then true
38 | when ${column}::text = '{}' then true
39 | else false
40 | end
41 | `;
42 | }
43 |
44 | export function isNotEmpty(column: TColumn) {
45 | return not(isEmpty(column));
46 | }
47 |
--------------------------------------------------------------------------------
/src/env.js:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs";
2 | import { z } from "zod";
3 |
4 | export const env = createEnv({
5 | /**
6 | * Specify your server-side environment variables schema here. This way you can ensure the app
7 | * isn't built with invalid env vars.
8 | */
9 | server: {
10 | NODE_ENV: z
11 | .enum(["development", "test", "production"])
12 | .default("development"),
13 | HOSTNAME: z.string().default("0.0.0.0"),
14 | PORT: z.coerce.number().default(3000),
15 | BASE_URL: z.string().url().optional(),
16 | INTERNAL_URL: z.string().url().default("http://localhost:3000"),
17 | // Database
18 | DATABASE_URL: z.string().url(),
19 |
20 | // Auth
21 | ENABLE_EMAIL_VERIFICATION: z.boolean().default(false),
22 | RESEND_API_KEY: z.string().optional(),
23 | GITHUB_CLIENT_ID: z.string().optional(),
24 | GITHUB_CLIENT_SECRET: z.string().optional(),
25 | GOOGLE_CLIENT_ID: z.string().optional(),
26 | GOOGLE_CLIENT_SECRET: z.string().optional(),
27 | CF_TURNSTILE_SECRET_KEY: z.string().optional(),
28 |
29 | // Cloud
30 | DOCS_BASE: z.string().optional(),
31 | CLOUD_HOST: z.string().optional(),
32 | },
33 |
34 | /**
35 | * Specify your client-side environment variables schema here. This way you can ensure the app
36 | * isn't built with invalid env vars. To expose them to the client, prefix them with
37 | * `NEXT_PUBLIC_`.
38 | */
39 | client: {
40 | // Auth
41 | NEXT_PUBLIC_ALLOW_OAUTH: z.string().transform((val) => val?.split(",")).default(""),
42 | NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY: z.string().optional(),
43 |
44 | },
45 |
46 | /**
47 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
48 | * middlewares) or client-side so we need to destruct manually.
49 | */
50 | runtimeEnv: {
51 | NODE_ENV: process.env.NODE_ENV,
52 | HOSTNAME: process.env.HOSTNAME,
53 | PORT: process.env.PORT,
54 | BASE_URL: process.env.BASE_URL,
55 | INTERNAL_URL: process.env.INTERNAL_URL,
56 | DATABASE_URL: process.env.DATABASE_URL,
57 |
58 | ENABLE_EMAIL_VERIFICATION: process.env.ENABLE_EMAIL_VERIFICATION,
59 | RESEND_API_KEY: process.env.RESEND_API_KEY,
60 | NEXT_PUBLIC_ALLOW_OAUTH: process.env.NEXT_PUBLIC_ALLOW_OAUTH,
61 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
62 | GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
63 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
64 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
65 | NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY:
66 | process.env.NEXT_PUBLIC_CF_TURNSTILE_SITE_KEY,
67 | CF_TURNSTILE_SECRET_KEY: process.env.CF_TURNSTILE_SECRET_KEY,
68 |
69 | DOCS_BASE: process.env.DOCS_BASE,
70 | CLOUD_HOST: process.env.CLOUD_HOST,
71 | },
72 | /**
73 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
74 | * useful for Docker builds.
75 | */
76 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
77 | /**
78 | * Makes it so that empty strings are treated as undefined.
79 | * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error.
80 | */
81 | emptyStringAsUndefined: true,
82 | });
83 |
--------------------------------------------------------------------------------
/src/hooks/use-callback-ref.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | /**
4 | * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
5 | */
6 |
7 | /**
8 | * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
9 | * prop or avoid re-executing effects when passed as a dependency
10 | */
11 | function useCallbackRef unknown>(
12 | callback: T | undefined,
13 | ): T {
14 | const callbackRef = React.useRef(callback);
15 |
16 | React.useEffect(() => {
17 | callbackRef.current = callback;
18 | });
19 |
20 | // https://github.com/facebook/react/issues/19240
21 | return React.useMemo(
22 | () => ((...args) => callbackRef.current?.(...args)) as T,
23 | [],
24 | );
25 | }
26 |
27 | export { useCallbackRef };
28 |
--------------------------------------------------------------------------------
/src/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useDebounce(value: T, delay?: number): T {
4 | const [debouncedValue, setDebouncedValue] = React.useState(value);
5 |
6 | React.useEffect(() => {
7 | const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500);
8 |
9 | return () => {
10 | clearTimeout(timer);
11 | };
12 | }, [value, delay]);
13 |
14 | return debouncedValue;
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/use-debounced-callback.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @see https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-debounced-callback/use-debounced-callback.ts
3 | */
4 |
5 | import * as React from "react";
6 |
7 | import { useCallbackRef } from "@/hooks/use-callback-ref";
8 |
9 | export function useDebouncedCallback unknown>(
10 | callback: T,
11 | delay: number,
12 | ) {
13 | const handleCallback = useCallbackRef(callback);
14 | const debounceTimerRef = React.useRef(0);
15 | React.useEffect(
16 | () => () => window.clearTimeout(debounceTimerRef.current),
17 | [],
18 | );
19 |
20 | const setValue = React.useCallback(
21 | (...args: Parameters) => {
22 | window.clearTimeout(debounceTimerRef.current);
23 | debounceTimerRef.current = window.setTimeout(
24 | () => handleCallback(...args),
25 | delay,
26 | );
27 | },
28 | [handleCallback, delay],
29 | );
30 |
31 | return setValue;
32 | }
33 |
--------------------------------------------------------------------------------
/src/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useMediaQuery(query: string) {
4 | const [value, setValue] = React.useState(false);
5 |
6 | React.useEffect(() => {
7 | function onChange(event: MediaQueryListEvent) {
8 | setValue(event.matches);
9 | }
10 |
11 | const result = matchMedia(query);
12 | result.addEventListener("change", onChange);
13 | setValue(result.matches);
14 |
15 | return () => result.removeEventListener("change", onChange);
16 | }, [query]);
17 |
18 | return value;
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/use-query-string.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | type QueryParams = Record;
4 |
5 | export function useQueryString(searchParams: URLSearchParams) {
6 | const createQueryString = React.useCallback(
7 | (params: QueryParams) => {
8 | const newSearchParams = new URLSearchParams(searchParams?.toString());
9 |
10 | for (const [key, value] of Object.entries(params)) {
11 | if (value === null) {
12 | newSearchParams.delete(key);
13 | } else {
14 | newSearchParams.set(key, String(value));
15 | }
16 | }
17 |
18 | return newSearchParams.toString();
19 | },
20 | [searchParams],
21 | );
22 |
23 | return { createQueryString };
24 | }
25 |
--------------------------------------------------------------------------------
/src/i18n/pick.ts:
--------------------------------------------------------------------------------
1 | import type { Messages } from "global";
2 |
3 | /**
4 | * Create a high-performance object property selector function
5 | * @template T Source object type
6 | * @template K type of key to select (must be a key of T)
7 | * @param {K[]} keys Array of keys to select from object
8 | * @returns {(obj: T) => Pick} Returns a function that takes the source object and returns a new object containing only the specified keys
9 | */
10 | function createFastPicker(
11 | keys: K[],
12 | ): (obj: T) => Pick {
13 | // Pre-compile a specialized picker function
14 | const fnBody = `
15 | return {
16 | ${keys.map((key) => `"${String(key)}": obj["${String(key)}"]`).join(",\n ")}
17 | };
18 | `;
19 |
20 | // Creating a new function using the Function constructor - note that TypeScript requires type assertions.
21 | return new Function("obj", fnBody) as (obj: T) => Pick;
22 | }
23 |
24 | export const pickPublic = createFastPicker(["Public"]);
25 |
--------------------------------------------------------------------------------
/src/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { getRequestConfig } from "next-intl/server";
2 |
3 | export default getRequestConfig(async () => {
4 | // Provide a static locale, fetch a user setting,
5 | // read from `cookies()`, `headers()`, etc.
6 | const locale = "zh";
7 |
8 | return {
9 | locale,
10 | messages: (await import(`../../messages/${locale}.json`)).default,
11 | };
12 | });
13 |
--------------------------------------------------------------------------------
/src/lib/api-client.ts:
--------------------------------------------------------------------------------
1 | import { hcWithType } from "@/server/hc";
2 | import type { InferRequestType, InferResponseType } from "hono/client";
3 |
4 | const apiClient = hcWithType("/api", {
5 | init: {
6 | credentials: "include",
7 | },
8 | });
9 |
10 | export function fetchWrapper(fn: F) {
11 | return async ([_, arg]: [string, InferRequestType]): Promise<
12 | InferResponseType
13 | > => {
14 | const res = await fn(arg);
15 | if (!res.ok) {
16 | throw new Error(await res.text());
17 | }
18 | return (await res.json()) as InferResponseType;
19 | };
20 | }
21 |
22 | export function mutationWrapper(fn: F) {
23 | return async (
24 | _url: string,
25 | { arg }: { arg: InferRequestType },
26 | ): Promise> => {
27 | const res = await fn(arg);
28 | if (!res.ok) {
29 | throw new Error(await res.text());
30 | }
31 | return await res.json();
32 | };
33 | }
34 |
35 | export default apiClient;
36 |
--------------------------------------------------------------------------------
/src/lib/auth-client.ts:
--------------------------------------------------------------------------------
1 | import { createAuthClient } from "better-auth/react" // make sure to import from better-auth/react
2 | import {
3 | usernameClient,
4 | passkeyClient,
5 | adminClient
6 | } from "better-auth/client/plugins";
7 | export const authClient = createAuthClient({
8 | plugins:[passkeyClient(), usernameClient(),adminClient()]
9 | })
--------------------------------------------------------------------------------
/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import db from "@/db";
2 | import { betterAuth } from "better-auth";
3 | import { drizzleAdapter } from "better-auth/adapters/drizzle";
4 | import { twoFactor, admin, username, captcha } from "better-auth/plugins";
5 | import { passkey } from "better-auth/plugins/passkey";
6 | import { env } from "@/env";
7 | import { expo } from "@better-auth/expo";
8 |
9 | const trustedOrigins = [];
10 | if (env.BASE_URL) {
11 | trustedOrigins.push(env.BASE_URL);
12 | }
13 | export const auth = betterAuth({
14 | trustedOrigins,
15 | database: drizzleAdapter(db, {
16 | provider: "pg",
17 | }),
18 | plugins: [
19 | username(),
20 | twoFactor(),
21 | passkey(),
22 | admin(),
23 | expo(),
24 | env.CF_TURNSTILE_SECRET_KEY &&
25 | captcha({
26 | provider: "cloudflare-turnstile",
27 | secretKey: env.CF_TURNSTILE_SECRET_KEY,
28 | }),
29 | ].filter(Boolean),
30 | emailAndPassword: {
31 | enabled: true,
32 | requireEmailVerification: env.ENABLE_EMAIL_VERIFICATION,
33 | },
34 | account: {
35 | accountLinking: {
36 | enabled: true,
37 | trustedProviders: ["google", "github"],
38 | allowDifferentEmails: true,
39 | },
40 | },
41 | socialProviders: {
42 | github: {
43 | enabled: !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET),
44 | clientId: env.GITHUB_CLIENT_ID as string,
45 | clientSecret: env.GITHUB_CLIENT_SECRET as string,
46 | },
47 | google: {
48 | enabled: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
49 | clientId: env.GOOGLE_CLIENT_ID as string,
50 | clientSecret: env.GOOGLE_CLIENT_SECRET as string,
51 | },
52 | },
53 | });
54 |
55 | export type Session = typeof auth.$Infer.Session;
56 |
--------------------------------------------------------------------------------
/src/lib/composition.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | /**
4 | * Composes two event handlers into a single handler by calling both in order.
5 | * The custom handler runs if `checkForDefaultPrevented` is false or if the original handler doesn't call `event.preventDefault()`.
6 | *
7 | * @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
8 | */
9 | function composeEventHandlers(
10 | originalEventHandler?: (event: E) => void,
11 | ourEventHandler?: (event: E) => void,
12 | { checkForDefaultPrevented = true } = {},
13 | ) {
14 | return function handleEvent(event: E) {
15 | originalEventHandler?.(event);
16 |
17 | if (
18 | checkForDefaultPrevented === false ||
19 | !(event as unknown as Event).defaultPrevented
20 | ) {
21 | return ourEventHandler?.(event);
22 | }
23 | };
24 | }
25 |
26 | /**
27 | * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx
28 | */
29 |
30 | type PossibleRef = React.Ref | undefined;
31 |
32 | /**
33 | * Set a given ref to a given value.
34 | * This utility takes care of different types of refs: callback refs and RefObject(s).
35 | */
36 | function setRef(ref: PossibleRef, value: T) {
37 | if (typeof ref === "function") {
38 | ref(value);
39 | } else if (ref !== null && ref !== undefined) {
40 | (ref as React.MutableRefObject).current = value;
41 | }
42 | }
43 |
44 | /**
45 | * A utility to compose multiple refs together.
46 | * Accepts callback refs and RefObject(s).
47 | */
48 | function composeRefs(...refs: PossibleRef[]) {
49 | return (node: T) => {
50 | for (const ref of refs) {
51 | setRef(ref, node);
52 | }
53 | };
54 | }
55 |
56 | /**
57 | * A custom hook that composes multiple refs.
58 | * Accepts callback refs and RefObject(s).
59 | */
60 | function useComposedRefs(...refs: PossibleRef[]) {
61 | // eslint-disable-next-line react-hooks/exhaustive-deps
62 | return React.useCallback(composeRefs(...refs), refs);
63 | }
64 |
65 | export { composeEventHandlers, composeRefs, useComposedRefs };
66 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const unknownError =
2 | "An unknown error occurred. Please try again later.";
3 |
--------------------------------------------------------------------------------
/src/lib/export.ts:
--------------------------------------------------------------------------------
1 | import type { Table } from "@tanstack/react-table";
2 |
3 | export function exportTableToCSV(
4 | /**
5 | * The table to export.
6 | * @type Table
7 | */
8 | table: Table,
9 | opts: {
10 | /**
11 | * The filename for the CSV file.
12 | * @default "table"
13 | * @example "tasks"
14 | */
15 | filename?: string;
16 | /**
17 | * The columns to exclude from the CSV file.
18 | * @default []
19 | * @example ["select", "actions"]
20 | */
21 | excludeColumns?: (keyof TData | "select" | "actions")[];
22 |
23 | /**
24 | * Whether to export only the selected rows.
25 | * @default false
26 | */
27 | onlySelected?: boolean;
28 | } = {},
29 | ): void {
30 | const {
31 | filename = "table",
32 | excludeColumns = [],
33 | onlySelected = false,
34 | } = opts;
35 |
36 | // Retrieve headers (column names)
37 | const headers = table
38 | .getAllLeafColumns()
39 | .map((column) => column.id)
40 | .filter((id) => !excludeColumns.includes(id));
41 |
42 | // Build CSV content
43 | const csvContent = [
44 | headers.join(","),
45 | ...(onlySelected
46 | ? table.getFilteredSelectedRowModel().rows
47 | : table.getRowModel().rows
48 | ).map((row) =>
49 | headers
50 | .map((header) => {
51 | const cellValue = row.getValue(header);
52 | // Handle values that might contain commas or newlines
53 | return typeof cellValue === "string"
54 | ? `"${cellValue.replace(/"/g, '""')}"`
55 | : cellValue;
56 | })
57 | .join(","),
58 | ),
59 | ].join("\n");
60 |
61 | // Create a Blob with CSV content
62 | const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
63 |
64 | // Create a link and trigger the download
65 | const url = URL.createObjectURL(blob);
66 | const link = document.createElement("a");
67 | link.setAttribute("href", url);
68 | link.setAttribute("download", `${filename}.csv`);
69 | link.style.visibility = "hidden";
70 | document.body.appendChild(link);
71 | link.click();
72 | document.body.removeChild(link);
73 | }
74 |
--------------------------------------------------------------------------------
/src/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import { GeistMono } from "geist/font/mono";
2 | import { GeistSans } from "geist/font/sans";
3 |
4 | export const fontSans = GeistSans;
5 | export const fontMono = GeistMono;
6 |
--------------------------------------------------------------------------------
/src/lib/handle-error.ts:
--------------------------------------------------------------------------------
1 | import { isRedirectError } from "next/dist/client/components/redirect-error";
2 | import { z } from "zod";
3 |
4 | export function getErrorMessage(err: unknown) {
5 | const unknownError = "Something went wrong, please try again later.";
6 |
7 | if (err instanceof z.ZodError) {
8 | const errors = err.issues.map((issue) => {
9 | return issue.message;
10 | });
11 | return errors.join("\n");
12 | }
13 |
14 | if (err instanceof Error) {
15 | return err.message;
16 | }
17 |
18 | if (isRedirectError(err)) {
19 | throw err;
20 | }
21 |
22 | return unknownError;
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/id.ts:
--------------------------------------------------------------------------------
1 | import { customAlphabet } from "nanoid";
2 |
3 | const prefixes = {
4 | task: "tsk",
5 | };
6 |
7 | interface GenerateIdOptions {
8 | /**
9 | * The length of the generated ID.
10 | * @default 12
11 | * @example 12 => "abc123def456"
12 | * */
13 | length?: number;
14 | /**
15 | * The separator to use between the prefix and the generated ID.
16 | * @default "_"
17 | * @example "_" => "str_abc123"
18 | * */
19 | separator?: string;
20 | }
21 |
22 | /**
23 | * Generates a unique ID with optional prefix and configuration.
24 | * @param prefixOrOptions The prefix string or options object
25 | * @param inputOptions The options for generating the ID
26 | */
27 | export function generateId(
28 | prefixOrOptions?: keyof typeof prefixes | GenerateIdOptions,
29 | inputOptions: GenerateIdOptions = {},
30 | ) {
31 | const finalOptions =
32 | typeof prefixOrOptions === "object" ? prefixOrOptions : inputOptions;
33 |
34 | const prefix =
35 | typeof prefixOrOptions === "object" ? undefined : prefixOrOptions;
36 |
37 | const { length = 12, separator = "_" } = finalOptions;
38 | const id = customAlphabet(
39 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
40 | length,
41 | )();
42 |
43 | return prefix ? `${prefixes[prefix]}${separator}${id}` : id;
44 | }
45 |
--------------------------------------------------------------------------------
/src/lib/parsers.ts:
--------------------------------------------------------------------------------
1 | import type { ExtendedSortingState, Filter } from "@/types";
2 | import type { Row } from "@tanstack/react-table";
3 | import { createParser } from "nuqs/server";
4 | import { z } from "zod";
5 |
6 | import { dataTableConfig } from "@/config/data-table";
7 |
8 | export const sortingItemSchema = z.object({
9 | id: z.string(),
10 | desc: z.boolean(),
11 | });
12 |
13 | /**
14 | * Creates a parser for TanStack Table sorting state.
15 | * @param originalRow The original row data to validate sorting keys against.
16 | * @returns A parser for TanStack Table sorting state.
17 | */
18 | export const getSortingStateParser = (
19 | originalRow?: Row["original"],
20 | ) => {
21 | const validKeys = originalRow ? new Set(Object.keys(originalRow)) : null;
22 |
23 | return createParser>({
24 | parse: (value) => {
25 | try {
26 | const parsed = JSON.parse(value);
27 | const result = z.array(sortingItemSchema).safeParse(parsed);
28 |
29 | if (!result.success) return null;
30 |
31 | if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
32 | return null;
33 | }
34 |
35 | return result.data as ExtendedSortingState;
36 | } catch {
37 | return null;
38 | }
39 | },
40 | serialize: (value) => JSON.stringify(value),
41 | eq: (a, b) =>
42 | a.length === b.length &&
43 | a.every(
44 | (item, index) =>
45 | item.id === b[index]?.id && item.desc === b[index]?.desc,
46 | ),
47 | });
48 | };
49 |
50 | export const filterSchema = z.object({
51 | id: z.string(),
52 | value: z.union([z.string(), z.array(z.string())]),
53 | type: z.enum(dataTableConfig.columnTypes),
54 | operator: z.enum(dataTableConfig.globalOperators),
55 | rowId: z.string(),
56 | });
57 |
58 | /**
59 | * Create a parser for data table filters.
60 | * @param originalRow The original row data to create the parser for.
61 | * @returns A parser for data table filters state.
62 | */
63 | export const getFiltersStateParser = (originalRow?: Row["original"]) => {
64 | const validKeys = originalRow ? new Set(Object.keys(originalRow)) : null;
65 |
66 | return createParser[]>({
67 | parse: (value) => {
68 | try {
69 | const parsed = JSON.parse(value);
70 | const result = z.array(filterSchema).safeParse(parsed);
71 |
72 | if (!result.success) return null;
73 |
74 | if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
75 | return null;
76 | }
77 |
78 | return result.data as Filter[];
79 | } catch {
80 | return null;
81 | }
82 | },
83 | serialize: (value) => JSON.stringify(value),
84 | eq: (a, b) =>
85 | a.length === b.length &&
86 | a.every(
87 | (filter, index) =>
88 | filter.id === b[index]?.id &&
89 | filter.value === b[index]?.value &&
90 | filter.type === b[index]?.type &&
91 | filter.operator === b[index]?.operator,
92 | ),
93 | });
94 | };
95 |
--------------------------------------------------------------------------------
/src/lib/rsc-client.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { hcWithType } from "@/server/hc";
3 | import { headers } from "next/headers";
4 | import { env } from "@/env";
5 |
6 | const rscClient = hcWithType(
7 | new URL("/api", `http://${env.HOSTNAME}:${env.PORT}`).toString(),
8 | {
9 | init: {
10 | credentials: "include",
11 | },
12 | headers: async () => {
13 | const h = await headers();
14 | return {
15 | Cookie: h.get("cookie") ?? "",
16 | };
17 | },
18 | },
19 | );
20 |
21 | export default rscClient;
22 |
--------------------------------------------------------------------------------
/src/lib/session.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/lib/auth";
2 |
3 | import { headers } from "next/headers";
4 | import { unauthorized } from "next/navigation";
5 |
6 | export async function getSession() {
7 | const session = await auth.api.getSession({
8 | headers: await headers(),
9 | });
10 | return session;
11 | }
12 |
13 | export async function getSessionOrThrow() {
14 | const session = await auth.api.getSession({
15 | headers: await headers(),
16 | });
17 |
18 | if (!session) {
19 | unauthorized();
20 | }
21 |
22 | return session;
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/unstable-cache.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @see https://github.com/ethanniser/NextMaster/blob/main/src/lib/unstable-cache.ts
3 | */
4 |
5 | import { unstable_cache as next_unstable_cache } from "next/cache";
6 | import { cache } from "react";
7 |
8 | // next_unstable_cache doesn't handle deduplication, so we wrap it in React's cache
9 | export const unstable_cache = (
10 | cb: (...args: Inputs) => Promise,
11 | keyParts: string[],
12 | options?: {
13 | /**
14 | * The revalidation interval in seconds.
15 | */
16 | revalidate?: number | false;
17 | tags?: string[];
18 | },
19 | ) => cache(next_unstable_cache(cb, keyParts, options));
20 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest, NextResponse } from "next/server";
2 | import { getSessionCookie } from "better-auth/cookies";
3 | import { env } from "./env";
4 |
5 | export async function middleware(request: NextRequest) {
6 | const host = request.headers.get("host") ?? request.nextUrl.host;
7 | const { pathname } = request.nextUrl;
8 |
9 | if (pathname === "/") {
10 | // Check if it's a subdomain by counting dots in host
11 | const isSubdomain = host.split(".").length > 2;
12 | if (isSubdomain) {
13 | const subdomain = host.split(".")[0];
14 | return NextResponse.rewrite(
15 | new URL(`/${subdomain}`, `http://${env.HOSTNAME}:${env.PORT}`),
16 | );
17 | }
18 | }
19 |
20 | // if (pathname.startsWith("/vm")) {
21 | // return NextResponse.rewrite(
22 | // new URL("/vm", `http://${env.HOSTNAME}:${env.PORT}`),
23 | // );
24 | // }
25 |
26 | if (pathname.startsWith("/dash")) {
27 | const sessionCookie = getSessionCookie(request);
28 | if (!sessionCookie) {
29 | return NextResponse.redirect(new URL("/auth", request.url));
30 | }
31 | return NextResponse.next();
32 | }
33 |
34 | return NextResponse.next();
35 | }
36 |
37 | export const config = {
38 | matcher: ["/", "/dash", "/dash/:path*"],
39 | };
40 |
--------------------------------------------------------------------------------
/src/queues/analysis.ts:
--------------------------------------------------------------------------------
1 | import type { Job } from "bullmq";
2 | import { QueueEnum, createQueue, createWorker } from "./utils";
3 |
4 | const queue = createQueue(QueueEnum.ANALYSIS);
5 |
6 | const worker = createWorker(QueueEnum.ANALYSIS, async (job: Job) => {});
7 |
8 | export { worker, queue };
9 |
--------------------------------------------------------------------------------
/src/queues/index.ts:
--------------------------------------------------------------------------------
1 | import { queue as analysisQueue } from "./analysis";
2 | import { queue as notificationQueue } from "./notification";
3 |
4 | export default async () => {
5 | const { worker: analysisWorker } = await import("./analysis");
6 | const { worker: notificationWorker } = await import("./notification");
7 |
8 | Promise.all([analysisWorker.run(), notificationWorker.run()]).then((n) => {
9 | console.log(`[BullMQ] Queue Workers(${n.length}) started`);
10 | });
11 | };
12 |
13 | const getQueues = () => [analysisQueue, notificationQueue];
14 |
15 | export { getQueues };
16 |
--------------------------------------------------------------------------------
/src/queues/notification.ts:
--------------------------------------------------------------------------------
1 | import type { Job } from "bullmq";
2 | import { QueueEnum, createQueue, createWorker } from "./utils";
3 |
4 | const queue = createQueue(QueueEnum.NOTIFICATION);
5 | const worker = createWorker(QueueEnum.NOTIFICATION, async (job: Job) => {});
6 |
7 | export { queue, worker };
8 |
--------------------------------------------------------------------------------
/src/queues/utils.ts:
--------------------------------------------------------------------------------
1 | import { redisConfig } from "@/db/redis";
2 | import { type Processor, Queue, Worker } from "bullmq";
3 |
4 | export enum QueueEnum {
5 | ANALYSIS = "traffic",
6 | NOTIFICATION = "notification",
7 | }
8 |
9 | export const createQueue = (name: string) => {
10 | const queue = new Queue(name, { connection: redisConfig });
11 | queue.on("error", (error) => {
12 | if ((error as { code?: string })?.code === "ECONNREFUSED") {
13 | console.error(
14 | "Make sure you have installed Redis and it is running.",
15 | error,
16 | );
17 | }
18 | });
19 |
20 | queue.on("waiting", (job) => {
21 | console.log(`Job ${job.id} added`);
22 | });
23 |
24 | return queue;
25 | };
26 |
27 | export const createWorker = <
28 | // biome-ignore lint/suspicious/noExplicitAny: same as bullmq
29 | DataType = any,
30 | // biome-ignore lint/suspicious/noExplicitAny: same as bullmq
31 | ResultType = any,
32 | NameType extends string = string,
33 | >(
34 | name: string,
35 | processor: Processor,
36 | ) => {
37 | const worker = new Worker(name, processor, {
38 | autorun: false,
39 | connection: redisConfig,
40 | });
41 |
42 | worker.on("completed", async (job) => {
43 | console.log(`Job completed for ${job.id}`);
44 | });
45 |
46 | worker.on("failed", async (job, err) => {
47 | console.error(`${job?.id} has failed with ${err.message}`);
48 | });
49 |
50 | worker.on("stalled", (str) => {
51 | console.log(`Job stalled: ${str}`);
52 | });
53 |
54 | return worker;
55 | };
56 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import "zod-openapi/extend";
3 |
4 | import { env } from "./env.js";
5 | import http from "node:http";
6 |
7 | import next from "next";
8 | import packageJson from "../package.json";
9 |
10 | const app = next({ dev: env.NODE_ENV !== "production" });
11 | const handle = app.getRequestHandler();
12 |
13 | /**
14 | * Prepare the app and start the server
15 | */
16 | void app.prepare().then(async () => {
17 | try {
18 | if (env.NODE_ENV === "production") {
19 | import("./db/migrate").then(({ runMigrate }) => runMigrate());
20 | }
21 |
22 | const server = http.createServer((req, res) => {
23 | handle(req, res);
24 | });
25 |
26 | (await import("./server/wss/probe.js")).setupProbeWebSocketServer(server);
27 | console.log("[WSS] Master Setup Complete");
28 | (await import("./server/wss/monitor.js")).setupMonitorWebSocketServer(
29 | server,
30 | );
31 | console.log("[WSS] Monitor Setup Complete");
32 | (await import("./server/wss/terminal.js")).setupTerminalWebSocketServer(
33 | server,
34 | );
35 | console.log("[WSS] Terminal Setup Complete");
36 |
37 | server.on("listening", () => {
38 | console.log(
39 | `[VMBoard] v${packageJson.version} running on http://${env.HOSTNAME}:${env.PORT}`,
40 | );
41 | });
42 |
43 | server.listen(env.PORT, env.HOSTNAME);
44 |
45 | // if (env.ENABLE_ALERT_QUEUE) {
46 | // if (!env.REDIS_URL) {
47 | // throw new Error("REDIS_URL is not set which is required for alert queue");
48 | // }
49 | // await (await import("./queues")).default();
50 | // }
51 | } catch (e) {
52 | console.error("[VMBoard] failed to start with error:", e);
53 | }
54 | });
55 |
--------------------------------------------------------------------------------
/src/server/error.ts:
--------------------------------------------------------------------------------
1 | import type { Context } from "hono";
2 | import type { ContentfulStatusCode } from "hono/utils/http-status";
3 |
4 | export default class BizError extends Error {
5 | name = "BizError";
6 |
7 | public readonly code: string;
8 | public readonly statusCode: number;
9 | public readonly message: string;
10 |
11 | constructor(code: (typeof BizCodeEnum)[keyof typeof BizCodeEnum]) {
12 | const [statusCode, message] = BizErrorEnum[code];
13 | super(message);
14 | this.code = code;
15 | this.statusCode = statusCode;
16 | this.message = message;
17 | }
18 | }
19 |
20 | export const errorHandler = (err: Error, c: Context) => {
21 | if (err instanceof BizError) {
22 | return c.json(
23 | {
24 | code: err.code,
25 | message: err.message,
26 | },
27 | err.statusCode as ContentfulStatusCode,
28 | );
29 | }
30 | console.error(err);
31 | return c.json(
32 | {
33 | message: "Internal Server Error",
34 | },
35 | 500,
36 | );
37 | };
38 |
39 | export const BizCodeEnum = {
40 | AuthFailed: "AUTH_FAILED",
41 | // VM
42 | VMNotFound: "VM_NOT_FOUND",
43 |
44 | // Page
45 | PageNotFound: "PAGE_NOT_FOUND",
46 | HandleAlreadyExists: "HANDLE_ALREADY_EXISTS",
47 | HostnameNotFound: "HOSTNAME_NOT_FOUND",
48 | HostnameAlreadyExists: "HOSTNAME_ALREADY_EXISTS",
49 | } as const;
50 |
51 | export const BizErrorEnum: Record<
52 | (typeof BizCodeEnum)[keyof typeof BizCodeEnum],
53 | [statusCode: number, message: string]
54 | > = {
55 | [BizCodeEnum.AuthFailed]: [401, "Authentication failed"],
56 |
57 | // VM
58 | [BizCodeEnum.VMNotFound]: [404, "VM not found"],
59 |
60 | // Page
61 | [BizCodeEnum.PageNotFound]: [404, "Page not found"],
62 | [BizCodeEnum.HandleAlreadyExists]: [400, "Handle already exists"],
63 | [BizCodeEnum.HostnameNotFound]: [404, "Hostname not found"],
64 | [BizCodeEnum.HostnameAlreadyExists]: [400, "Hostname already exists"],
65 | } as const;
66 |
--------------------------------------------------------------------------------
/src/server/factory.ts:
--------------------------------------------------------------------------------
1 | import { createFactory } from "hono/factory";
2 | import { authMiddleware } from "./middleware/auth";
3 | import type { auth } from "@/lib/auth";
4 | import db, { type DataBase } from "@/db";
5 |
6 | export type Env = {
7 | Variables: {
8 | db: DataBase;
9 | user: typeof auth.$Infer.Session.user;
10 | session: typeof auth.$Infer.Session.session;
11 | };
12 | };
13 |
14 | /**
15 | * Auth and DB middleware
16 | */
17 | const appFactory = createFactory({
18 | initApp: (app) => {
19 | app.use(authMiddleware);
20 | app.use(async (c, next) => {
21 | c.set("db", db);
22 | await next();
23 | });
24 | },
25 | });
26 |
27 | export default appFactory;
28 |
--------------------------------------------------------------------------------
/src/server/hc.ts:
--------------------------------------------------------------------------------
1 | import type { routes } from "./index";
2 | import { hc } from "hono/client";
3 |
4 | // assign the client to a variable to calculate the type when compiling
5 | const client = hc("");
6 | export type Client = typeof client;
7 |
8 | export const hcWithType = (...args: Parameters): Client =>
9 | hc(...args);
10 |
--------------------------------------------------------------------------------
/src/server/index.ts:
--------------------------------------------------------------------------------
1 | import { Hono } from "hono";
2 | import { cors } from "hono/cors";
3 | import { openAPISpecs } from "hono-openapi";
4 | import { apiReference } from "@scalar/hono-api-reference";
5 |
6 | import merchantRouter from "./routes/merchant";
7 | import vmRouter from "./routes/vm";
8 | import pageRouter from "./routes/page";
9 | import { errorHandler } from "./error";
10 |
11 | const app = new Hono();
12 |
13 | // Error Handler
14 | app.onError(errorHandler);
15 |
16 | // CORS
17 | app.use(
18 | "*",
19 | cors({
20 | origin: ["*"],
21 | allowMethods: ["POST", "GET", "PUT", "DELETE", "OPTIONS"],
22 | }),
23 | );
24 |
25 | // Business Routes
26 | export const routes = app
27 | .route("/vm", vmRouter)
28 | .route("/merchant", merchantRouter)
29 | .route("/page", pageRouter);
30 |
31 | // API Documentation
32 | app.get(
33 | "/docs",
34 | apiReference({
35 | theme: "saturn",
36 | spec: { url: "/api/openapi" },
37 | }),
38 | );
39 |
40 | app.get(
41 | "/openapi",
42 | openAPISpecs(app, {
43 | documentation: {
44 | info: {
45 | title: "VMBoard API",
46 | version: "1.0.0",
47 | },
48 | servers: [
49 | { url: "http://localhost:3000/api", description: "Local Server" },
50 | ],
51 | // @ts-ignore
52 | // "x-tagGroups": [
53 | // {
54 | // name: "Asset",
55 | // tags: ["asset"],
56 | // },
57 | // ],
58 | },
59 | }),
60 | );
61 |
62 | app.use(async (c, next) => {
63 | await next();
64 | console.log(`${c.res.status} [${c.req.method}] ${c.req.url}`);
65 | });
66 |
67 | export default app;
68 |
69 | export type AppType = typeof routes;
70 |
--------------------------------------------------------------------------------
/src/server/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import type { Env } from "../factory";
2 | import { auth } from "@/lib/auth";
3 | import { createMiddleware } from "hono/factory";
4 |
5 | export const authMiddleware = createMiddleware(async (c, next) => {
6 | const session = await auth.api.getSession({ headers: c.req.raw.headers });
7 |
8 | // if (!session) {
9 | // return c.json({ error: "Unauthorized" }, 401);
10 | // }
11 |
12 | c.set("user", session?.user);
13 | c.set("session", session?.session);
14 |
15 | return next();
16 | });
17 |
18 | export const roleGuard = (role: ("user" | "admin")[]) =>
19 | createMiddleware(async (c, next) => {
20 | const user = c.get("user");
21 |
22 | if (!role.includes(user.role ?? "user")) {
23 | return c.json({ error: "Forbidden" }, 403);
24 | }
25 |
26 | return next();
27 | });
28 |
--------------------------------------------------------------------------------
/src/server/routes/merchant.ts:
--------------------------------------------------------------------------------
1 | import { and, desc, eq } from "drizzle-orm";
2 | import appFactory from "../factory";
3 | import { merchant as merchantTable } from "@/db/schema/merchant";
4 | import vmAPI from "vmapi";
5 | import { zValidator } from "@hono/zod-validator";
6 | import { z } from "zod";
7 | import { headers } from "next/headers";
8 |
9 | const app = appFactory
10 | .createApp()
11 | .get("/list", async (c) => {
12 | const db = c.get("db");
13 | const user = c.get("user");
14 |
15 | const merchants = await db.query.merchant.findMany({
16 | where: eq(merchantTable.userId, user.id),
17 | columns: {
18 | id: true,
19 | nickname: true,
20 | username: true,
21 | },
22 | orderBy: [desc(merchantTable.createdAt)],
23 | });
24 |
25 | return c.json(merchants);
26 | })
27 | .get(
28 | "/:id/list-vms",
29 | zValidator(
30 | "param",
31 | z.object({
32 | id: z.coerce.number(),
33 | }),
34 | ),
35 | async (c) => {
36 | const input = c.req.valid("param");
37 |
38 | const db = c.get("db");
39 | const user = c.get("user");
40 |
41 | const merchant = await db.query.merchant.findFirst({
42 | where: and(
43 | eq(merchantTable.id, input.id),
44 | // eq(merchantTable.userId, user.id),
45 | ),
46 | });
47 |
48 | if (!merchant) {
49 | throw new Error("Merchant not found");
50 | }
51 |
52 | const vmapi = vmAPI(merchant.merchant, {
53 | account: {
54 | username: merchant.username,
55 | password: merchant.password,
56 | },
57 | headers: {},
58 | cookieJar: merchant.cookieJar,
59 | });
60 | if (!vmapi) {
61 | throw new Error("Merchant not support now");
62 | }
63 | // if (!merchant.cookieJar) {
64 | // await vmapi.login(merchant.username, merchant.password);
65 | // const cookieJar = await vmapi.exportCookie();
66 | // await db
67 | // .update(merchantTable)
68 | // .set({
69 | // cookieJar,
70 | // })
71 | // .where(eq(merchantTable.id, merchant.id));
72 | // } else {
73 | // await vmapi.importCookie(merchant.cookieJar);
74 | // }
75 |
76 | const vms = await vmapi.getVMList();
77 | console.log(vms);
78 | return c.json(vms);
79 | },
80 | );
81 |
82 | export default app;
83 |
--------------------------------------------------------------------------------
/src/server/wss/manager/monitor-manager.ts:
--------------------------------------------------------------------------------
1 | import { pack } from "msgpackr";
2 | import { socketManager } from "./socket";
3 | import type {
4 | VMIdentifier,
5 | SocketIdentifier,
6 | MonitorWebSocket,
7 | } from "../types";
8 |
9 | declare global {
10 | /* vmId <- socketId[] */
11 | var monitorMap: Map | undefined;
12 | }
13 |
14 | class MonitorManager {
15 | private static instance: MonitorManager;
16 | private monitorMap = new Map();
17 |
18 | private constructor() {
19 | if (!global.monitorMap) {
20 | global.monitorMap = new Map();
21 | }
22 | this.monitorMap = global.monitorMap;
23 | }
24 |
25 | static getInstance(): MonitorManager {
26 | if (!MonitorManager.instance) {
27 | MonitorManager.instance = new MonitorManager();
28 | }
29 | return MonitorManager.instance;
30 | }
31 |
32 | listen(socketId: SocketIdentifier, vmIds: VMIdentifier[]) {
33 | const socket = socketManager.getSocket(socketId);
34 | if (!socket) {
35 | return;
36 | }
37 | for (const vmId of vmIds) {
38 | this.monitorMap.set(
39 | vmId,
40 | (this.monitorMap.get(vmId) ?? []).concat(socketId),
41 | );
42 | }
43 | socket.listenVmIds = socket.listenVmIds.concat(vmIds);
44 | }
45 |
46 | unlisten(socketId: SocketIdentifier, vmIds: "all" | VMIdentifier[]) {
47 | const socket = socketManager.getSocket(socketId);
48 | if (vmIds === "all") {
49 | this.monitorMap.forEach((_, vmId) => {
50 | this.monitorMap.set(
51 | vmId,
52 | (this.monitorMap.get(vmId) ?? []).filter((id) => id !== socketId),
53 | );
54 | });
55 | if (socket) {
56 | socket.listenVmIds = [];
57 | }
58 | return;
59 | }
60 | for (const vmId of vmIds) {
61 | this.monitorMap.set(
62 | vmId,
63 | (this.monitorMap.get(vmId) ?? []).filter((id) => id !== socketId),
64 | );
65 | }
66 | if (socket) {
67 | socket.listenVmIds = socket.listenVmIds.filter((id) => !vmIds.includes(id));
68 | }
69 | }
70 |
71 | broadcast(vmId: number, message: unknown) {
72 | const vmListeners = this.monitorMap.get(vmId) ?? [];
73 | for (const listener of vmListeners) {
74 | if (!socketManager.sendMessage(listener, pack(message))) {
75 | this.unlisten(listener, [vmId]);
76 | }
77 | }
78 | }
79 | }
80 |
81 | export const monitorManager = MonitorManager.getInstance();
82 |
--------------------------------------------------------------------------------
/src/server/wss/manager/socket.ts:
--------------------------------------------------------------------------------
1 | import { WebSocket } from "ws";
2 | import type { SocketIdentifier } from "../types";
3 |
4 | declare global {
5 | var socketMap: Map | undefined;
6 | }
7 |
8 | class SocketManager {
9 | private static instance: SocketManager;
10 | private socketMap: Map = new Map();
11 |
12 | private constructor() {
13 | if (!global.socketMap) {
14 | global.socketMap = new Map();
15 | }
16 | this.socketMap = global.socketMap;
17 | }
18 |
19 | static getInstance(): SocketManager {
20 | if (!SocketManager.instance) {
21 | SocketManager.instance = new SocketManager();
22 | }
23 | return SocketManager.instance;
24 | }
25 |
26 | addSocket(id: SocketIdentifier, socket: WebSocket) {
27 | this.socketMap.set(id, socket);
28 | return id;
29 | }
30 |
31 | removeSocket(id: SocketIdentifier) {
32 | this.socketMap.delete(id);
33 | }
34 |
35 | getSocket(id: SocketIdentifier) {
36 | return this.socketMap.get(id) as T | undefined;
37 | }
38 |
39 | sendMessage(id: SocketIdentifier, message: unknown) {
40 | const socket = this.getSocket(id);
41 | if (
42 | !socket ||
43 | socket.readyState === WebSocket.CLOSED ||
44 | socket.readyState === WebSocket.CLOSING
45 | ) {
46 | return false;
47 | }
48 | if (socket.readyState === WebSocket.CONNECTING) {
49 | socket.once("open", () => {
50 | socket.send(message as Buffer);
51 | });
52 | return true;
53 | }
54 | if (socket.readyState === WebSocket.OPEN) {
55 | socket.send(message as Buffer);
56 | return true;
57 | }
58 | return false;
59 | }
60 | }
61 |
62 | export const socketManager = SocketManager.getInstance();
63 |
--------------------------------------------------------------------------------
/src/server/wss/manager/vm-manager.ts:
--------------------------------------------------------------------------------
1 | import { WebSocket } from "ws";
2 | import type { SocketIdentifier, VMIdentifier } from "../types";
3 | import { socketManager } from "./socket";
4 | import { pack } from "msgpackr";
5 |
6 | declare global {
7 | /* vmId <- SocketId */
8 | var vmMap: Map | undefined;
9 | }
10 |
11 | class VmManager {
12 | private static instance: VmManager;
13 | private vmMap = new Map();
14 |
15 | private constructor() {
16 | if (!global.vmMap) {
17 | global.vmMap = new Map();
18 | }
19 | this.vmMap = global.vmMap;
20 | }
21 |
22 | static getInstance(): VmManager {
23 | if (!VmManager.instance) {
24 | VmManager.instance = new VmManager();
25 | }
26 | return VmManager.instance;
27 | }
28 |
29 | /**
30 | * Check if the socket is open for the given VM ID
31 | * @param vmId - The ID of the VM to check
32 | * @returns true if the socket is open, false otherwise
33 | */
34 | checkSocket(vmId: VMIdentifier) {
35 | const socketId = this.vmMap.get(vmId);
36 | if (!socketId) {
37 | return false;
38 | }
39 | const socket = socketManager.getSocket(socketId);
40 | if (!socket) {
41 | this.removeSocket(vmId);
42 | return false;
43 | }
44 | return socket.readyState === WebSocket.OPEN;
45 | }
46 |
47 | addSocket(vmId: VMIdentifier, socketId: SocketIdentifier) {
48 | this.vmMap.set(vmId, socketId);
49 | }
50 |
51 | removeSocket(vmId: VMIdentifier) {
52 | this.vmMap.delete(vmId);
53 | }
54 |
55 | broadcast(vmId: VMIdentifier, message: unknown) {
56 | const socketId = this.vmMap.get(vmId);
57 | if (!socketId) {
58 | return;
59 | }
60 | socketManager.sendMessage(socketId, pack(message));
61 | }
62 | }
63 |
64 | export const vmManager = VmManager.getInstance();
65 |
--------------------------------------------------------------------------------
/src/server/wss/types.ts:
--------------------------------------------------------------------------------
1 | import type { MonitorVMInfo, VM } from "@/db/schema/vm";
2 | import type { Session } from "@/lib/auth";
3 | import type { UUIDTypes } from "uuid";
4 | import type { WebSocket as _WebSocket } from "ws";
5 | export type SocketIdentifier = UUIDTypes;
6 | export type VMIdentifier = number;
7 |
8 | export type WebSocket = _WebSocket & {
9 | socketId: SocketIdentifier;
10 | };
11 | export interface VMWebSocket extends WebSocket {
12 | vm: VM;
13 | }
14 | export interface MonitorWebSocket extends WebSocket {
15 | session: Session | null;
16 | listenVmIds: VMIdentifier[];
17 | }
18 | export interface StartMonitorEvent {
19 | type: "startMonitor";
20 | data:
21 | | {
22 | pageId: number;
23 | }
24 | | {
25 | vmIds: number[];
26 | };
27 | }
28 |
29 | export interface GetMonitorMetricsEvent {
30 | type: "getMonitorMetrics";
31 | data: {
32 | vmIds: number[];
33 | };
34 | }
35 |
36 | /* User monitor side event */
37 | export type MonitorClientEvent = StartMonitorEvent | GetMonitorMetricsEvent;
38 |
39 | export interface VMReportEvent {
40 | type: "metrics";
41 | data: {
42 | uptime: number;
43 | system: SystemInfo;
44 | network: NetworkInfo;
45 | disk: DiskInfo;
46 | };
47 | }
48 |
49 | interface SystemInfo {
50 | cpuUsage: number;
51 | memoryUsed: number;
52 | memoryTotal: number;
53 | swapUsed: number;
54 | swapTotal: number;
55 | processCount: number;
56 | loadAvg: {
57 | one: number;
58 | five: number;
59 | fifteen: number;
60 | };
61 | }
62 |
63 | interface DiskInfo {
64 | spaceUsed: number;
65 | spaceTotal: number;
66 | read: number;
67 | write: number;
68 | }
69 |
70 | interface NetworkInfo {
71 | downloadTraffic: number;
72 | uploadTraffic: number;
73 | tcpCount: number;
74 | udpCount: number;
75 | }
76 |
77 | export interface VMInfoEvent {
78 | type: "vm_info";
79 | data: MonitorVMInfo;
80 | }
81 |
82 | export type ProbeServerEvent = VMReportEvent | VMInfoEvent;
83 |
--------------------------------------------------------------------------------
/src/server/wss/utils.ts:
--------------------------------------------------------------------------------
1 | import type { WebSocket } from "ws";
2 | import { unpack } from "msgpackr";
3 |
4 | export const parseMsgPack = (data: WebSocket.RawData): unknown => {
5 | let binaryData: Uint8Array;
6 |
7 | if (data instanceof Buffer) {
8 | binaryData = data;
9 | } else if (Array.isArray(data)) {
10 | binaryData = Buffer.concat(data);
11 | } else if (data instanceof ArrayBuffer) {
12 | binaryData = new Uint8Array(data);
13 | } else {
14 | throw new Error("Unsupported data type received");
15 | }
16 |
17 | return unpack(binaryData);
18 | };
19 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --muted: 240 4.8% 95.9%;
11 | --muted-foreground: 240 3.8% 46.1%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 240 10% 3.9%;
18 |
19 | --border: 240 5.9% 90%;
20 | --input: 240 5.9% 90%;
21 |
22 | --primary: 240 5.9% 10%;
23 | --primary-foreground: 0 0% 98%;
24 |
25 | --secondary: 240 4.8% 95.9%;
26 | --secondary-foreground: 240 5.9% 10%;
27 |
28 | --accent: 240 4.8% 95.9%;
29 | --accent-foreground: 240 5.9% 10%;
30 |
31 | --destructive: 0 84.2% 60.2%;
32 | --destructive-foreground: 0 0% 98%;
33 |
34 | --ring: 240 5% 64.9%;
35 |
36 | --radius: 0.5rem;
37 |
38 | --chart-1: 220 70% 50%;
39 | --chart-2: 340 75% 55%;
40 | --chart-3: 30 80% 55%;
41 | --chart-4: 280 65% 60%;
42 | --chart-5: 160 60% 45%;
43 | --chart-6: 180 50% 50%;
44 | --chart-7: 216 50% 50%;
45 | --chart-8: 252 50% 50%;
46 | --chart-9: 288 50% 50%;
47 | --chart-10: 324 50% 50%;
48 |
49 | --timing: cubic-bezier(0.4, 0, 0.2, 1);
50 |
51 | --sidebar-background: 0 0% 98%;
52 |
53 | --sidebar-foreground: 240 5.3% 26.1%;
54 |
55 | --sidebar-primary: 240 5.9% 10%;
56 |
57 | --sidebar-primary-foreground: 0 0% 98%;
58 |
59 | --sidebar-accent: 240 4.8% 95.9%;
60 |
61 | --sidebar-accent-foreground: 240 5.9% 10%;
62 |
63 | --sidebar-border: 220 13% 91%;
64 |
65 | --sidebar-ring: 217.2 91.2% 59.8%;
66 | }
67 |
68 | .dark {
69 | --background: 240 10% 3.9%;
70 | --foreground: 0 0% 98%;
71 |
72 | --muted: 240 3.7% 15.9%;
73 | --muted-foreground: 240 5% 64.9%;
74 |
75 | --popover: 240 10% 3.9%;
76 | --popover-foreground: 0 0% 98%;
77 |
78 | --card: 240 10% 3.9%;
79 | --card-foreground: 0 0% 98%;
80 |
81 | --border: 240 3.7% 15.9%;
82 | --input: 240 3.7% 15.9%;
83 |
84 | --primary: 0 0% 98%;
85 | --primary-foreground: 240 5.9% 10%;
86 |
87 | --secondary: 240 3.7% 15.9%;
88 | --secondary-foreground: 0 0% 98%;
89 |
90 | --accent: 240 3.7% 15.9%;
91 | --accent-foreground: 0 0% 98%;
92 |
93 | --destructive: 0 62.8% 30.6%;
94 | --destructive-foreground: 0 85.7% 97.3%;
95 |
96 | --ring: 240 3.7% 15.9%;
97 |
98 | --sidebar-background: 240 5.9% 10%;
99 |
100 | --sidebar-foreground: 240 4.8% 95.9%;
101 |
102 | --sidebar-primary: 224.3 76.3% 48%;
103 |
104 | --sidebar-primary-foreground: 0 0% 100%;
105 |
106 | --sidebar-accent: 240 3.7% 15.9%;
107 |
108 | --sidebar-accent-foreground: 240 4.8% 95.9%;
109 |
110 | --sidebar-border: 240 3.7% 15.9%;
111 |
112 | --sidebar-ring: 217.2 91.2% 59.8%;
113 | }
114 | }
115 |
116 | @layer base {
117 | * {
118 | @apply border-border;
119 | }
120 | body {
121 | @apply bg-background text-foreground;
122 | }
123 | }
124 |
125 | @media (max-width: 640px) {
126 | .container {
127 | @apply px-4;
128 | }
129 | }
130 |
131 | @layer base {
132 | * {
133 | @apply border-border outline-ring/50;
134 | }
135 | body {
136 | @apply bg-background text-foreground;
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import type { ColumnSort, Row } from "@tanstack/react-table";
2 | import type { SQL } from "drizzle-orm";
3 | import type { z } from "zod";
4 |
5 | import type { DataTableConfig } from "@/config/data-table";
6 | import type { filterSchema } from "@/lib/parsers";
7 |
8 | export type Prettify = {
9 | [K in keyof T]: T[K];
10 | } & {};
11 |
12 | export type StringKeyOf = Extract;
13 |
14 | export interface SearchParams {
15 | [key: string]: string | string[] | undefined;
16 | }
17 |
18 | export interface Option {
19 | label: string;
20 | value: string;
21 | icon?: React.ComponentType<{ className?: string }>;
22 | count?: number;
23 | }
24 |
25 | export interface ExtendedColumnSort extends Omit {
26 | id: StringKeyOf;
27 | }
28 |
29 | export type ExtendedSortingState = ExtendedColumnSort[];
30 |
31 | export type ColumnType = DataTableConfig["columnTypes"][number];
32 |
33 | export type FilterOperator = DataTableConfig["globalOperators"][number];
34 |
35 | export type JoinOperator = DataTableConfig["joinOperators"][number]["value"];
36 |
37 | export interface DataTableFilterField {
38 | id: StringKeyOf;
39 | label: string;
40 | placeholder?: string;
41 | options?: Option[];
42 | }
43 |
44 | export interface DataTableAdvancedFilterField
45 | extends DataTableFilterField {
46 | type: ColumnType;
47 | }
48 |
49 | export type Filter = Prettify<
50 | Omit, "id"> & {
51 | id: StringKeyOf;
52 | }
53 | >;
54 |
55 | export interface DataTableRowAction {
56 | row: Row;
57 | type: "update" | "delete";
58 | }
59 |
60 | export interface QueryBuilderOpts {
61 | where?: SQL;
62 | orderBy?: SQL;
63 | distinct?: boolean;
64 | nullish?: boolean;
65 | }
66 |
--------------------------------------------------------------------------------
/src/types/metrics.ts:
--------------------------------------------------------------------------------
1 | import type { Metrics } from "@/db/schema/metrics";
2 | import type { MonitorVMInfo, VM } from "@/db/schema/vm";
3 |
4 | export type ServerWithLiveMetrics = Pick & {
5 | vmInfo: MonitorVMInfo;
6 | metrics: Metrics;
7 | };
8 |
9 | export type ServerWithTimeSeriesMetrics = ServerWithLiveMetrics & {
10 | metrics: Metrics[];
11 | };
12 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import { fontFamily } from "tailwindcss/defaultTheme";
3 |
4 | export default {
5 | darkMode: ["class"],
6 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
7 | theme: {
8 | container: {
9 | center: true,
10 | padding: '2rem',
11 | screens: {
12 | '2xl': '1400px'
13 | }
14 | },
15 | extend: {
16 | colors: {
17 | border: 'hsl(var(--border))',
18 | input: 'hsl(var(--input))',
19 | ring: 'hsl(var(--ring))',
20 | background: 'hsl(var(--background))',
21 | foreground: 'hsl(var(--foreground))',
22 | primary: {
23 | DEFAULT: 'hsl(var(--primary))',
24 | foreground: 'hsl(var(--primary-foreground))'
25 | },
26 | secondary: {
27 | DEFAULT: 'hsl(var(--secondary))',
28 | foreground: 'hsl(var(--secondary-foreground))'
29 | },
30 | destructive: {
31 | DEFAULT: 'hsl(var(--destructive))',
32 | foreground: 'hsl(var(--destructive-foreground))'
33 | },
34 | muted: {
35 | DEFAULT: 'hsl(var(--muted))',
36 | foreground: 'hsl(var(--muted-foreground))'
37 | },
38 | accent: {
39 | DEFAULT: 'hsl(var(--accent))',
40 | foreground: 'hsl(var(--accent-foreground))'
41 | },
42 | popover: {
43 | DEFAULT: 'hsl(var(--popover))',
44 | foreground: 'hsl(var(--popover-foreground))'
45 | },
46 | card: {
47 | DEFAULT: 'hsl(var(--card))',
48 | foreground: 'hsl(var(--card-foreground))'
49 | },
50 | sidebar: {
51 | DEFAULT: 'hsl(var(--sidebar-background))',
52 | foreground: 'hsl(var(--sidebar-foreground))',
53 | primary: 'hsl(var(--sidebar-primary))',
54 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
55 | accent: 'hsl(var(--sidebar-accent))',
56 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
57 | border: 'hsl(var(--sidebar-border))',
58 | ring: 'hsl(var(--sidebar-ring))'
59 | }
60 | },
61 | borderRadius: {
62 | lg: 'var(--radius)',
63 | md: 'calc(var(--radius) - 2px)',
64 | sm: 'calc(var(--radius) - 4px)'
65 | },
66 | fontFamily: {
67 | sans: [
68 | 'var(--font-geist-sans)',
69 | ...fontFamily.sans
70 | ],
71 | mono: [
72 | 'var(--font-geist-mono)',
73 | ...fontFamily.mono
74 | ]
75 | },
76 | keyframes: {
77 | 'accordion-down': {
78 | from: {
79 | height: '0'
80 | },
81 | to: {
82 | height: 'var(--radix-accordion-content-height)'
83 | }
84 | },
85 | 'accordion-up': {
86 | from: {
87 | height: 'var(--radix-accordion-content-height)'
88 | },
89 | to: {
90 | height: '0'
91 | }
92 | }
93 | },
94 | animation: {
95 | 'accordion-down': 'accordion-down 0.2s ease-out',
96 | 'accordion-up': 'accordion-up 0.2s ease-out'
97 | }
98 | }
99 | },
100 | // eslint-disable-next-line @typescript-eslint/no-require-imports
101 | plugins: [require("tailwindcss-animate")],
102 | } satisfies Config;
103 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Base Options: */
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 |
12 | /* Strictness */
13 | "strict": true,
14 | "noUncheckedIndexedAccess": true,
15 | "checkJs": true,
16 |
17 | /* Bundled projects */
18 | "lib": ["dom", "dom.iterable", "ES2022"],
19 | "noEmit": true,
20 | "module": "ESNext",
21 | "moduleResolution": "Bundler",
22 | "jsx": "preserve",
23 | "plugins": [{ "name": "next" }],
24 | "incremental": true,
25 | "allowSyntheticDefaultImports": true,
26 |
27 | /* Path Aliases */
28 | "baseUrl": ".",
29 | "paths": {
30 | "@/*": ["./src/*"]
31 | }
32 | },
33 | "include": [
34 | "next-env.d.ts",
35 | "**/*.ts",
36 | "**/*.tsx",
37 | "**/*.cjs",
38 | "**/*.js",
39 | ".next/types/**/*.ts"
40 | ],
41 | "exclude": ["node_modules"]
42 | }
43 |
--------------------------------------------------------------------------------