🎉Initial commit

This commit is contained in:
jarvis2f 2024-03-15 15:47:32 +08:00
commit ee56d0bf16
274 changed files with 44706 additions and 0 deletions

43
.dockerignore Normal file
View File

@ -0,0 +1,43 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
.env
.env.prod
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
docker/Dockerfile
.dockerignore
npm-debug.log
README.md
.git
.run

54
.env.example Normal file
View File

@ -0,0 +1,54 @@
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
# postgresql://USER:PASSWORD@HOST:PORT/DATABASE
DATABASE_URL="postgresql://postgres:123456@localhost:5432/vortex"
# If you use docker-compose, you should use the following configuration
# POSTGRES_USER="postgres"
# POSTGRES_PASSWORD="123456"
# POSTGRES_DB="vortex"
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
# Next Auth Providers
# Next Auth GitHub Provider
# GITHUB_CLIENT_ID=""
# GITHUB_CLIENT_SECRET=""
# Next Auth Google Provider
# GOOGLE_CLIENT_ID=""
# GOOGLE_CLIENT_SECRET=""
# Next Auth Email Provider
# smtp://username:password@smtp.example.com:587
EMAIL_SERVER=""
# noreply@example.com
EMAIL_FROM=""
# Redis
REDIS_URL="redis://localhost:6379"
# REDIS_USERNAME=
REDIS_PASSWORD="123456"
REDIS_DB="0"
# This should accessible by the agent node
# AGENT_REDIS_URL=
# Server
SERVER_URL="http://localhost:3000"
AGENT_SHELL_URL="http://localhost:3000"
# Payment
## DePay https://depay.com
DEPAY_INTEGRATION_ID=""
DEPAY_PUBLIC_KEY=""
# Umami https://umami.is/docs
#NEXT_PUBLIC_UMAMI_URL
# script.js
#NEXT_PUBLIC_UMAMI
# website id
#NEXT_PUBLIC_UMAMI_ID

12
.env.test Normal file
View File

@ -0,0 +1,12 @@
SERVER_URL="http://localhost:3000"
AGENT_SHELL_URL=http://vortex-agent-file:8080/vortex.sh
AGENT_REDIS_URL=redis://vortex-redis:6379
NEXTAUTH_SECRET="secret"
NEXTAUTH_URL="http://localhost:3000"
DATABASE_URL=postgresql://postgres:postgres@vortex-postgres:5432/vortex
EMAIL_SERVER="smtp-mail.outlook.com"
EMAIL_FROM="Vortex <"
REDIS_URL=redis://vortex-redis:6379
REDIS_DB=0

51
.eslintrc.cjs Normal file
View File

@ -0,0 +1,51 @@
/** @type {import("eslint").Linter.Config} */
const config = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
plugins: ["@typescript-eslint"],
extends: [
"plugin:@next/next/recommended",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
],
ignorePatterns: [
"node_modules/",
".next/",
"out/",
"public/",
"**/*spec.ts",
"test",
"**/pino-prisma.mjs",
],
rules: {
// These opinionated rules are enabled in stylistic-type-checked above.
// Feel free to reconfigure them to your own preference.
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: { attributes: false },
},
],
},
};
module.exports = config;

90
.github/workflows/docker-publish.yml vendored Normal file
View File

@ -0,0 +1,90 @@
name: Docker
on:
push:
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
with:
cosign-release: "v2.1.1"
- name: Docker Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: jarvis2f/vortex
tags: |
type=raw,value=latest
type=ref,event=tag
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: ./docker/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
- name: Dockerhub Readme
uses: ms-jpq/sync-dockerhub-readme@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: jarvis2f/vortex
readme: "./README.md"

37
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Test
on:
workflow_dispatch:
pull_request:
push:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
env:
SKIP_ENV_VALIDATION: 1
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Run npm install
run: npm ci
- name: Run linter
run: npm run lint
- name: Run formatter
run: npm run format
- name: Run tsc
run: npm run check
- name: Run unit tests & coverage
run: npm run test:cov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: jarvis2f/vortex

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# 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
.env
.env*.local
.env.prod
# vercel
.vercel
# typescript
*.tsbuildinfo
.idea
.run

6
.husky/pre-commit Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
#npm run test
npm run format:fix
npm run check:code

5
.prettierignore Normal file
View File

@ -0,0 +1,5 @@
.idea
.next
node_modules
.prisma/migrations/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 jarvis2f
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

77
README.md Normal file
View File

@ -0,0 +1,77 @@
<h1 align="center">
Vortex
</h1>
<p align="center">
<img src="https://raw.githubusercontent.com/jarvis2f/vortex/main/public/logo-3d.png" alt="Vortex Logo" width="200" />
</p>
Vortex is a simple and fast web application. It is built with Next.js, Tailwind CSS, and Prisma.
<p align="center">
<img src="https://github.com/jarvis2f/vortex/actions/workflows/docker-publish.yml/badge.svg" alt="Vortex Docker" />
<img src="https://img.shields.io/github/package-json/v/jarvis2f/vortex" alt="Vortex Version" />
<img src="https://codecov.io/gh/jarvis2f/vortex/graph/badge.svg?token=62ZZ6VYJUG" alt="Vortex codecov" />
<img src="https://img.shields.io/github/license/jarvis2f/vortex" alt="Vortex License" />
</p>
# Installation
## Docker Compose
1. Copy the [.env.example](.env.example) file to `.env` and fill in the environment variables.
2. Copy the [docker-compose.yml](docker%2Fdocker-compose.yml) file to the root of your project.
3. Copy the [redis.conf](docker%2Fredis.conf) file to the redis folder, and modify the password.
```bash
docker-compose up
```
### Optional Steps for umami
1. Copy the [docker-compose.umami.yml](docker%2Fdocker-compose.umami.yml) file to the root of your project.
```bash
docker-compose up -f docker-compose.umami.yml
```
## Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fjarvis2f%2Fvortex)
# Development
## Prerequisites
- Node.js >= v20.8.1
- Yarn
- PostgreSQL
- Redis
## Getting Started
1. Install the dependencies.
```bash
npm install
```
2. Copy the [.env.example](.env.example) file to `.env` and fill in the environment variables.
3. Start the development server.
```bash
npm run dev
```
# License
Vortex is open source software [licensed as MIT](LICENSE).
# Acknowledgments
- [T3 Stack](https://create.t3.gg/)
- [Next.js](https://nextjs.org/)
- [NextAuth.js](https://next-auth.js.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Prisma](https://www.prisma.io/)
- [Umami](https://umami.is/)

1
babel.config.cjs Normal file
View File

@ -0,0 +1 @@
module.exports = { presets: ["@babel/preset-env"] };

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/lib",
"utils": "~/lib/utils"
}
}

43
docker/Dockerfile Normal file
View File

@ -0,0 +1,43 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY ./prisma ./
COPY ./package.json package-lock.json* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS builder
ARG DATABASE_URL
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV SKIP_ENV_VALIDATION 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then SKIP_ENV_VALIDATION=1 yarn build; \
elif [ -f package-lock.json ]; then SKIP_ENV_VALIDATION=1 npm run build; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && SKIP_ENV_VALIDATION=1 pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm install -g prisma
COPY --from=builder /app/docker/docker-start.sh ./start.sh
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["server.js"]
CMD ["sh", "start.sh"]

View File

@ -0,0 +1,47 @@
version: "3.3"
services:
vortex:
build:
context: ../
dockerfile: docker/Dockerfile
env_file:
- .env.test
depends_on:
- vortex-postgres
- vortex-redis
vortex-postgres:
image: postgres:16.1-alpine3.19
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=vortex
volumes:
- ./db:/var/lib/postgresql/data
vortex-redis:
image: redis:7.2.4-alpine
command: [redis-server]
volumes:
- ./redis/data:/data
vortex-agent-file:
build:
context: .
dockerfile: git@github.com:jarvis2f/vortex-agent.git#main
ssh: ["default"]
environment:
- VORTEX_FILE_PORT=8080
vortex-agent-alice:
image: alpine:3.18
command: [echo, "Hello from Alice"]
depends_on:
- vortex-agent-file
vortex-agent-bob:
image: alpine:3.18
command: [echo, "Hello from Bob"]
depends_on:
- vortex-agent-file

View File

@ -0,0 +1,35 @@
version: "3"
services:
umami:
container_name: umami
image: ghcr.io/umami-software/umami:postgresql-latest
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://umami:umami@umami-postgres:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: APP_SECRET
# ALLOWED_FRAME_URLS: Your vortex website URL
depends_on:
- umami-postgres
restart: always
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
interval: 5s
timeout: 5s
retries: 5
umami-postgres:
container_name: umami-postgres
image: postgres:15-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: umami
volumes:
- ./postgres/data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5

42
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
version: "3.3"
services:
vortex:
container_name: vortex
image: jarvis2f/vortex:latest
env_file:
- .env
restart: always
volumes:
- /etc/localtime:/etc/localtime:ro
ports:
- "18000:3000"
depends_on:
- vortex-postgres
- vortex-redis
vortex-postgres:
container_name: vortex-postgres
image: postgres:16.1-alpine3.19
env_file:
- .env
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
restart: always
volumes:
- /etc/localtime:/etc/localtime:ro
- ./db:/var/lib/postgresql/data
vortex-redis:
container_name: vortex-redis
image: redis:7.2.4-alpine
restart: always
command: [redis-server, /etc/redis/redis.conf]
volumes:
- /etc/localtime:/etc/localtime:ro
- ./redis/data:/data
- ./redis/redis.conf:/etc/redis/redis.conf
ports:
- "18044:6379"

5
docker/docker-start.sh Normal file
View File

@ -0,0 +1,5 @@
#!/bin/bash
prisma migrate deploy
node server.js

2296
docker/redis.conf Normal file

File diff suppressed because it is too large Load Diff

27
jest.config.mjs Normal file
View File

@ -0,0 +1,27 @@
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: "./",
});
const customJestConfig = {
preset: "ts-jest",
clearMocks: true,
rootDir: ".",
testEnvironment: "node",
transform: {
"^.+\\.(ts)$": "ts-jest",
"^.+\\.(js|jsx)$": "babel-jest",
},
collectCoverageFrom: ["<rootDir>/src/server/core/**/*.(t|j)s"],
coverageDirectory: "./coverage",
testRegex: ".*\\.spec\\.ts$",
moduleNameMapper: {
"^~/(.*)$": "<rootDir>/src/$1",
},
moduleFileExtensions: ["ts", "tsx", "js", "json", "node"],
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
};
export default createJestConfig(customJestConfig);

33
next.config.js Normal file
View File

@ -0,0 +1,33 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
await import("./src/env.js");
/** @type {import("next").NextConfig} */
const config = {
experimental: {
instrumentationHook: true,
serverComponentsExternalPackages: ["pino"],
forceSwcTransforms: true,
},
images: {
remotePatterns: [
{
hostname: "raw.githubusercontent.com",
},
],
},
output: "standalone",
reactStrictMode: false,
webpack: (config, options) => {
config.externals.push({ "thread-stream": "commonjs thread-stream" });
config.module = {
...config.module,
exprContextCritical: false,
};
return config;
},
};
export default config;

18134
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

127
package.json Normal file
View File

@ -0,0 +1,127 @@
{
"name": "vortex",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:migrate": "prisma migrate dev --create-only",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"check": "tsc --noEmit",
"check:code": "npm run format && npm run lint && npm run check",
"dev": "next dev",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"postinstall": "prisma generate",
"preversion": "npm run check:code && npm run test",
"lint": "next lint",
"start": "next start",
"prepare": "husky install"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.2.4",
"@codemirror/lint": "^6.4.2",
"@depay/js-verify-signature": "^3.1.8",
"@depay/widgets": "^12.8.1",
"@hookform/resolvers": "^3.3.4",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.7.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.11.2",
"@trpc/client": "^10.43.6",
"@trpc/next": "^10.43.6",
"@trpc/react-query": "^10.43.6",
"@trpc/server": "^10.43.6",
"@uiw/react-codemirror": "^4.21.21",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
"cron": "^3.1.6",
"cronstrue": "^2.47.0",
"dayjs": "^1.11.10",
"eslint-linter-browserify": "^8.56.0",
"flag-icons": "^7.1.0",
"highlight.js": "^11.9.0",
"ioredis": "^5.3.2",
"lucide-react": "^0.316.0",
"next": "^14.1.0",
"next-auth": "^4.24.5",
"next-themes": "^0.2.1",
"nodemailer": "^6.9.9",
"pino": "^8.17.2",
"pino-abstract-transport": "^1.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.49.2",
"react-markdown": "^9.0.1",
"react-number-format": "^5.3.3",
"react-resizable-panels": "^1.0.7",
"reactflow": "^11.10.1",
"recharts": "^2.10.3",
"server-only": "^0.0.1",
"sharp": "0.32.6",
"superjson": "^2.2.1",
"systeminformation": "^5.22.0",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",
"zustand": "^4.4.7"
},
"devDependencies": {
"@babel/preset-env": "^7.23.8",
"@next/eslint-plugin-next": "^14.0.3",
"@types/eslint": "^8.44.7",
"@types/jest": "^29.5.11",
"@types/node": "^18.17.0",
"@types/react": "^18.2.37",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.15",
"@types/umami": "^0.1.5",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"autoprefixer": "^10.4.14",
"babel-jest": "^29.7.0",
"eslint": "^8.54.0",
"husky": "^8.0.3",
"jest": "^29.7.0",
"jest-mock-extended": "^3.0.5",
"lint-staged": "^15.2.0",
"pino-pretty": "^10.3.1",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.7",
"prisma": "^5.7.1",
"tailwindcss": "^3.3.5",
"ts-jest": "^29.1.1",
"typescript": "^5.1.6"
},
"ct3aMetadata": {
"initVersion": "7.25.0"
},
"packageManager": "npm@10.1.0"
}

8
postcss.config.cjs Normal file
View File

@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

6
prettier.config.js Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;

View File

@ -0,0 +1,394 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER', 'AGENT_PROVIDER');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'BANNED');
-- CreateEnum
CREATE TYPE "BalanceType" AS ENUM ('CONSUMPTION', 'INCOME');
-- CreateEnum
CREATE TYPE "BalanceLogType" AS ENUM ('DEFAULT', 'ADMIN_UPDATE', 'RECHARGE', 'RECHARGE_CODE', 'TRAFFIC_CONSUMPTION', 'TRAFFIC_INCOME', 'WITHDRAWAL');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('CREATED', 'SUCCEEDED', 'FAILED');
-- CreateEnum
CREATE TYPE "WithdrawalStatus" AS ENUM ('CREATED', 'WITHDRAWN');
-- CreateEnum
CREATE TYPE "AgentStatus" AS ENUM ('UNKNOWN', 'ONLINE', 'OFFLINE');
-- CreateEnum
CREATE TYPE "AgentTaskStatus" AS ENUM ('CREATED', 'SUCCEEDED', 'FAILED');
-- CreateEnum
CREATE TYPE "ForwardMethod" AS ENUM ('IPTABLES', 'GOST');
-- CreateEnum
CREATE TYPE "ForwardStatus" AS ENUM ('CREATED', 'CREATED_FAILED', 'RUNNING', 'STOPPED');
-- CreateEnum
CREATE TYPE "ForwardTargetType" AS ENUM ('AGENT', 'EXTERNAL');
-- CreateEnum
CREATE TYPE "TicketStatus" AS ENUM ('CREATED', 'REPLIED', 'CLOSED');
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"password" TEXT,
"passwordSalt" TEXT,
"image" TEXT,
"roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[],
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Wallet" (
"id" TEXT NOT NULL,
"balance" DECIMAL(65,30) NOT NULL DEFAULT 0,
"incomeBalance" DECIMAL(65,30) NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BalanceRechargeCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"usedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BalanceRechargeCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BalanceLog" (
"id" SERIAL NOT NULL,
"userId" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"afterBalance" DECIMAL(65,30) NOT NULL,
"balanceType" "BalanceType" NOT NULL,
"type" "BalanceLogType" NOT NULL DEFAULT 'DEFAULT',
"extra" TEXT,
"relatedInfo" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BalanceLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"targetAmount" DECIMAL(65,30) NOT NULL,
"status" "PaymentStatus" NOT NULL,
"paymentInfo" JSONB,
"callback" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Withdrawal" (
"id" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"address" TEXT NOT NULL,
"status" "WithdrawalStatus" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Withdrawal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Log" (
"id" SERIAL NOT NULL,
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"level" INTEGER NOT NULL,
"message" JSONB NOT NULL,
CONSTRAINT "Log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Config" (
"id" TEXT NOT NULL,
"relationId" TEXT NOT NULL DEFAULT '0',
"key" TEXT NOT NULL,
"value" TEXT,
CONSTRAINT "Config_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Agent" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"connectConfig" JSONB NOT NULL,
"info" JSONB NOT NULL,
"status" "AgentStatus" NOT NULL DEFAULT 'UNKNOWN',
"isShared" BOOLEAN NOT NULL DEFAULT false,
"lastReport" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdById" TEXT NOT NULL,
CONSTRAINT "Agent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentStat" (
"id" SERIAL NOT NULL,
"agentId" TEXT NOT NULL,
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"stat" JSONB NOT NULL,
CONSTRAINT "AgentStat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentTask" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"task" JSONB NOT NULL,
"result" JSONB,
"status" "AgentTaskStatus" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AgentTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Forward" (
"id" TEXT NOT NULL,
"method" "ForwardMethod" NOT NULL,
"status" "ForwardStatus" NOT NULL DEFAULT 'CREATED',
"options" JSONB,
"agentPort" INTEGER NOT NULL,
"targetPort" INTEGER NOT NULL,
"target" TEXT NOT NULL,
"targetType" "ForwardTargetType" NOT NULL DEFAULT 'EXTERNAL',
"usedTraffic" INTEGER NOT NULL DEFAULT 0,
"download" INTEGER NOT NULL DEFAULT 0,
"upload" INTEGER NOT NULL DEFAULT 0,
"remark" TEXT,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"agentId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
CONSTRAINT "Forward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForwardTraffic" (
"id" SERIAL NOT NULL,
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"download" INTEGER NOT NULL,
"upload" INTEGER NOT NULL,
"forwardId" TEXT NOT NULL,
CONSTRAINT "ForwardTraffic_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Network" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"flow" JSONB NOT NULL,
"createdById" TEXT NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Network_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NetworkEdge" (
"id" TEXT NOT NULL,
"networkId" TEXT NOT NULL,
"sourceAgentId" TEXT NOT NULL,
"sourceForwardId" TEXT,
"targetAgentId" TEXT,
"nextEdgeId" TEXT,
"deleted" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "NetworkEdge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" "TicketStatus" NOT NULL DEFAULT 'CREATED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" TEXT NOT NULL,
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TicketReply" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ticketId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
CONSTRAINT "TicketReply_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Wallet_userId_key" ON "Wallet"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "BalanceRechargeCode_code_key" ON "BalanceRechargeCode"("code");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Config_relationId_key_key" ON "Config"("relationId", "key");
-- CreateIndex
CREATE UNIQUE INDEX "NetworkEdge_sourceForwardId_key" ON "NetworkEdge"("sourceForwardId");
-- CreateIndex
CREATE UNIQUE INDEX "NetworkEdge_nextEdgeId_key" ON "NetworkEdge"("nextEdgeId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BalanceRechargeCode" ADD CONSTRAINT "BalanceRechargeCode_usedById_fkey" FOREIGN KEY ("usedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BalanceLog" ADD CONSTRAINT "BalanceLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Withdrawal" ADD CONSTRAINT "Withdrawal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentStat" ADD CONSTRAINT "AgentStat_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentTask" ADD CONSTRAINT "AgentTask_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Forward" ADD CONSTRAINT "Forward_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Forward" ADD CONSTRAINT "Forward_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForwardTraffic" ADD CONSTRAINT "ForwardTraffic_forwardId_fkey" FOREIGN KEY ("forwardId") REFERENCES "Forward"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Network" ADD CONSTRAINT "Network_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_networkId_fkey" FOREIGN KEY ("networkId") REFERENCES "Network"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_sourceAgentId_fkey" FOREIGN KEY ("sourceAgentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_sourceForwardId_fkey" FOREIGN KEY ("sourceForwardId") REFERENCES "Forward"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NetworkEdge" ADD CONSTRAINT "NetworkEdge_nextEdgeId_fkey" FOREIGN KEY ("nextEdgeId") REFERENCES "NetworkEdge"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketReply" ADD CONSTRAINT "TicketReply_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketReply" ADD CONSTRAINT "TicketReply_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

363
prisma/schema.prisma Normal file
View File

@ -0,0 +1,363 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? // @db.Text
access_token String? // @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? // @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum Role {
ADMIN
USER
AGENT_PROVIDER
}
enum UserStatus {
ACTIVE
BANNED
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
password String?
passwordSalt String?
image String?
roles Role[] @default([USER])
status UserStatus @default(ACTIVE)
createdAt DateTime @default(now())
accounts Account[]
sessions Session[]
agents Agent[]
forwards Forward[]
tickets Ticket[]
networks Network[]
wallets Wallet?
balanceLogs BalanceLog[]
balanceRechargeCodes BalanceRechargeCode[]
payments Payment[]
withdrawals Withdrawal[]
ticketReplies TicketReply[]
}
model Wallet {
id String @id @default(cuid())
balance Decimal @default(0)
incomeBalance Decimal @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String @unique
user User @relation(fields: [userId], references: [id])
}
model BalanceRechargeCode {
id String @id @default(cuid())
code String @unique
amount Decimal
used Boolean @default(false)
usedById String?
user User? @relation(fields: [usedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum BalanceType {
CONSUMPTION
INCOME
}
enum BalanceLogType {
DEFAULT
ADMIN_UPDATE
RECHARGE
RECHARGE_CODE
TRAFFIC_CONSUMPTION
TRAFFIC_INCOME
WITHDRAWAL
}
model BalanceLog {
id Int @id @default(autoincrement())
userId String
amount Decimal
afterBalance Decimal
balanceType BalanceType
type BalanceLogType @default(DEFAULT)
extra String?
relatedInfo Json?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
enum PaymentStatus {
CREATED
SUCCEEDED
FAILED
}
model Payment {
id String @id @default(cuid())
amount Decimal
targetAmount Decimal
status PaymentStatus
paymentInfo Json?
callback Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
}
enum WithdrawalStatus {
CREATED
WITHDRAWN
}
model Withdrawal {
id String @id @default(cuid())
amount Decimal
address String
status WithdrawalStatus
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Log {
id Int @id @default(autoincrement())
time DateTime @default(now())
level Int
message Json
}
model Config {
id String @id @default(cuid())
relationId String @default("0")
key String
value String?
@@unique([relationId, key])
}
enum AgentStatus {
UNKNOWN
ONLINE
OFFLINE
}
model Agent {
id String @id @default(cuid())
name String
description String?
connectConfig Json
info Json
status AgentStatus @default(UNKNOWN)
isShared Boolean @default(false)
lastReport DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted Boolean @default(false)
createdById String
createdBy User @relation(fields: [createdById], references: [id])
stats AgentStat[]
forwards Forward[]
tasks AgentTask[]
networkEdges NetworkEdge[]
}
model AgentStat {
id Int @id @default(autoincrement())
agentId String
time DateTime @default(now())
stat Json
agent Agent @relation(fields: [agentId], references: [id])
}
enum AgentTaskStatus {
CREATED
SUCCEEDED
FAILED
}
model AgentTask {
id String @id @default(cuid())
agentId String
type String
task Json
result Json?
status AgentTaskStatus
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent @relation(fields: [agentId], references: [id])
}
enum ForwardMethod {
IPTABLES
GOST
}
enum ForwardStatus {
CREATED // 已创建
CREATED_FAILED // 创建失败
RUNNING // 运行中
STOPPED // 已停止
}
enum ForwardTargetType {
AGENT
EXTERNAL
}
model Forward {
id String @id @default(cuid())
method ForwardMethod
status ForwardStatus @default(CREATED)
options Json?
agentPort Int
targetPort Int
target String
targetType ForwardTargetType @default(EXTERNAL)
usedTraffic Int @default(0)
download Int @default(0)
upload Int @default(0)
remark String?
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agentId String
agent Agent @relation(fields: [agentId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
traffic ForwardTraffic[]
networkEdge NetworkEdge?
}
model ForwardTraffic {
id Int @id @default(autoincrement())
time DateTime @default(now())
download Int
upload Int
forwardId String
forward Forward @relation(fields: [forwardId], references: [id])
}
model Network {
id String @id @default(cuid())
name String
flow Json
createdById String
createdBy User @relation(fields: [createdById], references: [id])
edges NetworkEdge[]
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NetworkEdge {
id String @id @default(cuid())
networkId String
sourceAgentId String
sourceForwardId String? @unique
targetAgentId String?
nextEdgeId String? @unique
deleted Boolean @default(false)
network Network @relation(fields: [networkId], references: [id])
sourceAgent Agent @relation(fields: [sourceAgentId], references: [id])
sourceForward Forward? @relation(fields: [sourceForwardId], references: [id])
nextEdge NetworkEdge? @relation("NextEdge", fields: [nextEdgeId], references: [id])
lastEdge NetworkEdge? @relation("NextEdge")
}
enum TicketStatus {
CREATED
REPLIED
CLOSED
}
model Ticket {
id String @id @default(cuid())
title String
content String
status TicketStatus @default(CREATED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User @relation(fields: [createdById], references: [id])
createdById String
ticketReplies TicketReply[]
}
model TicketReply {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

BIN
public/lllustration.mp4 Normal file

Binary file not shown.

BIN
public/loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/logo-3d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

BIN
public/logo-flat-black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
public/logo-flat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/logo-grey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/people-wave.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
public/techny-rocket.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

14
public/user-profile.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,42 @@
import { api } from "~/trpc/server";
import ConfigList from "~/app/(manage)/admin/config/_components/config-list";
import { GLOBAL_CONFIG_SCHEMA_MAP } from "~/lib/constants/config";
import { type CONFIG_KEY } from "~/lib/types";
type FilterKeys<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
const classifyConfigKeys: Record<string, CONFIG_KEY[]> = {
appearance: [
"ENABLE_REGISTER",
"ANNOUNCEMENT",
"RECHARGE_MIN_AMOUNT",
"WITHDRAW_MIN_AMOUNT",
],
log: ["LOG_RETENTION_PERIOD", "LOG_RETENTION_LEVEL"],
agent: [
"SERVER_AGENT_STAT_JOB_CRON",
"SERVER_AGENT_STATUS_JOB_CRON",
"SERVER_AGENT_LOG_JOB_CRON",
"SERVER_AGENT_TRAFFIC_JOB_CRON",
"SERVER_AGENT_STAT_INTERVAL",
"TRAFFIC_PRICE",
],
};
export default async function ConfigClassifyPage({
params: { classify },
}: {
params: { classify: string };
}) {
const configs = await api.system.getAllConfig.query();
const configKeys = classifyConfigKeys[classify];
const schemaMap = Object.fromEntries(
Object.entries(GLOBAL_CONFIG_SCHEMA_MAP).filter(([key]) =>
configKeys!.includes(key as CONFIG_KEY),
),
) as FilterKeys<typeof GLOBAL_CONFIG_SCHEMA_MAP, CONFIG_KEY>;
return <ConfigList configs={configs} schemaMap={schemaMap} />;
}

View File

@ -0,0 +1,70 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/lib/ui/dialog";
import CodeInput from "~/app/_components/code-input";
import { useState } from "react";
import { Tabs, TabsList, TabsTrigger } from "~/lib/ui/tabs";
import Markdown from "react-markdown";
interface CodeInputDialogProps {
title: string;
language?: "javascript" | "json" | "markdown";
value: string;
onChange: (value: string) => void;
}
export default function CodeInputDialog({
title,
language = "json",
value,
onChange,
}: CodeInputDialogProps) {
const [error, setError] = useState<Error | null>(null);
const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit");
return (
<Dialog>
<DialogTrigger asChild>
<textarea
className="w-full rounded border p-2 text-sm"
value={String(value ?? "")}
onChange={(e) => onChange(e.currentTarget.value)}
/>
</DialogTrigger>
<DialogContent className="flex max-h-[66%] min-h-[40%] w-2/3 max-w-none flex-col">
<DialogHeader className="flex-row items-center space-x-3">
<DialogTitle>{title}</DialogTitle>
{language === "markdown" && (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as typeof activeTab)}
>
<TabsList>
<TabsTrigger value="edit"></TabsTrigger>
<TabsTrigger value="preview"></TabsTrigger>
</TabsList>
</Tabs>
)}
</DialogHeader>
{activeTab === "preview" && language === "markdown" && (
<Markdown className="markdown">{value}</Markdown>
)}
{activeTab === "edit" && (
<CodeInput
className="h-full flex-1 overflow-x-scroll"
value={value}
height={"100%"}
onChange={onChange}
language={language}
onError={setError}
/>
)}
{error && <p className="mt-2 text-sm text-red-500">{error.message}</p>}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,161 @@
"use client";
import { Suspense, useRef, useState } from "react";
import { api } from "~/trpc/react";
import {
type CONFIG_KEY,
type ConfigSchema,
type CustomComponentRef,
} from "~/lib/types";
import { Input } from "~/lib/ui/input";
import { Button } from "~/lib/ui/button";
import type { Config } from "@prisma/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/lib/ui/select";
import { Switch } from "~/lib/ui/switch";
import { CONFIG_DEFAULT_VALUE_MAP } from "~/lib/constants/config";
import CodeInputDialog from "~/app/(manage)/admin/config/_components/code-input-dialog";
import { isTrue } from "~/lib/utils";
import { useTrack } from "~/lib/hooks/use-track";
export default function ConfigField({
config,
schema,
}: {
config: Omit<Config, "id">;
schema: ConfigSchema;
}) {
const [value, setValue] = useState(
config.value
? config.value
: CONFIG_DEFAULT_VALUE_MAP[config.key as CONFIG_KEY],
);
const fieldRef = useRef<CustomComponentRef>(null);
const setConfig = api.system.setConfig.useMutation();
const { track } = useTrack();
async function handleConfigChange() {
if (schema.component === "custom" && fieldRef.current) {
const result = await fieldRef.current.beforeSubmit();
if (!result) return;
}
track("config-change-button", {
key: config.key,
value: String(value),
relationId: config.relationId,
});
await setConfig.mutateAsync({
key: config.key as CONFIG_KEY,
value: String(value),
relationId: config.relationId,
});
}
function renderSwitch() {
return (
<Switch checked={isTrue(value)} onCheckedChange={(e) => setValue(e)} />
);
}
function getComponent() {
switch (schema.component) {
case "input":
return renderInput();
case "textarea":
return renderTextarea();
case "select":
return renderSelect();
case "switch":
return renderSwitch();
case "custom":
if (schema.customComponent) {
return (
<Suspense fallback={<div>Loading...</div>}>
<schema.customComponent
innerRef={fieldRef}
value={value ? String(value) : ""}
onChange={(value) => setValue(value)}
/>
</Suspense>
);
}
return (
<div className="text-red-500">
Custom component not configured for {schema.title}
</div>
);
default:
return (
<div className="text-red-500">
Unknown component {schema.component}
</div>
);
}
}
function renderInput() {
return (
<Input
type="text"
value={String(value ?? "")}
onChange={(e) => setValue(e.currentTarget.value)}
/>
);
}
function renderTextarea() {
return schema.type === "json" || schema.type === "markdown" ? (
<CodeInputDialog
title={schema.title}
value={String(value ?? "")}
onChange={setValue}
language={schema.type}
/>
) : (
<textarea
className="w-full rounded border p-2 text-sm"
value={String(value ?? "")}
onChange={(e) => setValue(e.currentTarget.value)}
/>
);
}
function renderSelect() {
return (
<Select value={String(value)} onValueChange={setValue}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{schema.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<div className="border-error/40 rounded border p-4 hover:bg-accent">
<h3 className="mb-2 text-lg">{schema.title}</h3>
<p className="mb-2 text-sm text-gray-500">{schema.description}</p>
<div className="flex items-end justify-between gap-3">
{getComponent()}
<Button
onClick={() => handleConfigChange()}
disabled={config.value === value}
loading={setConfig.isLoading}
success={setConfig.isSuccess}
>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { type Config } from "@prisma/client";
import ConfigField from "~/app/(manage)/admin/config/_components/config-field";
import { type CONFIG_KEY, type ConfigSchema } from "~/lib/types";
export default function ConfigList({
configs,
schemaMap,
relationId = "0",
}: {
configs: Config[];
schemaMap: Partial<Record<CONFIG_KEY, ConfigSchema>>;
relationId?: string;
}) {
return (
<div className="flex flex-col items-center justify-center">
<div className="flex w-full flex-col gap-3 md:w-2/3">
{Object.keys(schemaMap).map((key, index) => {
const config = configs.find((config) => config.key === key);
return (
<ConfigField
key={index}
config={
config ?? {
key: key as CONFIG_KEY,
value: null,
relationId: relationId,
}
}
schema={schemaMap[key as CONFIG_KEY]!}
/>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,256 @@
// import React, { useEffect, useRef, useState } from "react";
// import "./crontab-input.css";
// import { CronTime } from "cron";
// import cronstrue from 'cronstrue';
// import {formatDate} from "~/lib/utils";
// import {CronParser} from "cronstrue/dist/cronParser";
//
// const commonValueHint = [
// ["*", "任何值"],
// [",", "取值分隔符"],
// ["-", "范围内的值"],
// ["/", "步长"],
// ];
//
// const valueHints = [
// [...commonValueHint, ["0-59", "可取的值"]],
// [...commonValueHint, ["0-23", "可取的值"]],
// [...commonValueHint, ["1-31", "可取的值"]],
// [...commonValueHint, ["1-12", "可取的值"], ["JAN-DEC", "可取的值"]],
// [...commonValueHint, ["0-6", "可取的值"], ["SUN-SAT", "可取的值"]],
// ];
// valueHints[-1] = [...commonValueHint];
//
// export default function CrontabInput({
// value,
// onChange,
// }: {
// value: string;
// onChange: (value: string) => void;
// }) {
// const [parsed, setParsed] = useState({});
// const [highlightedExplanation, setHighlightedExplanation] = useState("");
// const [isValid, setIsValid] = useState(true);
// const [selectedPartIndex, setSelectedPartIndex] = useState(-1);
// const [nextSchedules, setNextSchedules] = useState<string[]>([]);
// const [nextExpanded, setNextExpanded] = useState(false);
//
// const inputRef = useRef<HTMLInputElement>(null);
// const lastCaretPosition = useRef(-1);
//
// useEffect(() => {
// calculateNext();
// calculateExplanation();
// }, [value]);
//
// const clearCaretPosition = () => {
// lastCaretPosition.current = -1;
// setSelectedPartIndex(-1);
// setHighlightedExplanation(highlightParsed(-1));
// };
//
// const calculateNext = () => {
// const nextSchedules = [];
// try {
// const cronInstance = new CronTime(value);
// let timePointer = +new Date();
// for (let i = 0; i < 5; i++) {
// const next = cronInstance.getNextDateFrom(new Date(timePointer));
// nextSchedules.push(formatDate(next.toJSDate()));
// timePointer = +next + 1000;
// }
// } catch (e) {}
//
// setNextSchedules(nextSchedules);
// };
//
// const highlightParsed = (selectedPartIndex: number) => {
// let toHighlight = [];
// let highlighted = "";
//
// for (let i = 0; i < 5; i++) {
// if (parsed.segments[i]?.text) {
// toHighlight.push({ ...parsed.segments[i] });
// } else {
// toHighlight.push(null);
// }
// }
//
// if (selectedPartIndex >= 0) {
// if (toHighlight[selectedPartIndex]) {
// toHighlight[selectedPartIndex].active = true;
// }
// }
//
// if (
// toHighlight[0] &&
// toHighlight[1] &&
// toHighlight[0].text &&
// toHighlight[0].text === toHighlight[1].text &&
// toHighlight[0].start === toHighlight[1].start
// ) {
// if (toHighlight[1].active) {
// toHighlight[0] = null;
// } else {
// toHighlight[1] = null;
// }
// }
//
// toHighlight = toHighlight.filter((_) => _);
//
// toHighlight.sort((a, b) => {
// return a.start - b.start;
// });
//
// let pointer = 0;
// toHighlight.forEach((item) => {
// if (pointer > item.start) {
// return;
// }
// highlighted += parsed.description.substring(pointer, item.start);
// pointer = item.start;
// highlighted += `<span${
// item.active ? ' class="active"' : ""
// }>${parsed.description.substring(
// pointer,
// pointer + item.text.length,
// )}</span>`;
// pointer += item.text.length;
// });
//
// highlighted += parsed.description.substring(pointer);
//
// return highlighted;
// };
//
// const calculateExplanation = () => {
// let isValid = true;
// let parsed;
// let highlightedExplanation = "";
// try {
// parsed = new CronParser(value).parse();
// } catch (e : unknown) {
// highlightedExplanation = String(e);
// isValid = false;
// }
//
// setParsed(parsed);
// setHighlightedExplanation(highlightedExplanation);
// setIsValid(isValid);
//
// if (isValid) {
// setHighlightedExplanation(highlightParsed(-1));
// }
// };
//
// const onCaretPositionChange = () => {
// if (!inputRef.current) {
// return;
// }
// let caretPosition = inputRef.current.selectionStart;
// const selected = value.substring(
// inputRef.current.selectionStart ?? 0,
// inputRef.current.selectionEnd ?? 0,
// );
// if (selected.indexOf(" ") >= 0) {
// caretPosition = -1;
// }
// if (lastCaretPosition.current === caretPosition) {
// return;
// }
// lastCaretPosition.current = caretPosition ?? -1;
// if (caretPosition === -1) {
// setHighlightedExplanation(highlightParsed(-1));
// setSelectedPartIndex(-1);
// return;
// }
// const textBeforeCaret = value.substring(0, caretPosition ?? 0);
// const selectedPartIndex = textBeforeCaret.split(" ").length - 1;
// setSelectedPartIndex(selectedPartIndex);
// setHighlightedExplanation(highlightParsed(selectedPartIndex));
// };
//
// return (
// <div className="crontab-input">
// <div
// className="explanation"
// dangerouslySetInnerHTML={{
// __html: isValid ? `“${highlightedExplanation}”` : " ",
// }}
// />
//
// <div className="next">
// {!!nextSchedules.length && (
// <span>
// 下次: {nextSchedules[0]}{" "}
// {nextExpanded ? (
// <a onClick={() => setNextExpanded(false)}>(隐藏)</a>
// ) : (
// <a onClick={() => setNextExpanded(true)}>
// (更多)
// </a>
// )}
// {nextExpanded && (
// <div className="next-items">
// {nextSchedules.slice(1).map((item, index) => (
// <div className="next-item" key={index}>
// 之后: {item}
// </div>
// ))}
// </div>
// )}
// </span>
// )}
// </div>
//
// <input
// type="text"
// className={`cron-input ${!isValid ? "error" : ""}`}
// value={value}
// ref={inputRef}
// onMouseUp={() => onCaretPositionChange()}
// onKeyUp={() => onCaretPositionChange()}
// onBlur={() => clearCaretPosition()}
// onChange={(e) => {
// const parts = e.target.value.split(" ").filter((_) => _);
// if (parts.length !== 5) {
// onChange(e.target.value);
// setParsed({});
// setIsValid(false);
// return;
// }
//
// onChange(e.target.value);
// }}
// />
//
// <div className="parts">
// {[
// "分",
// "时",
// "日",
// "月",
// "周",
// ].map((unit, index) => (
// <div
// key={index}
// className={`part ${selectedPartIndex === index ? "selected" : ""}`}
// >
// {unit}
// </div>
// ))}
// </div>
//
// {valueHints[selectedPartIndex] && (
// <div className="allowed-values">
// {valueHints[selectedPartIndex]?.map((value, index) => (
// <div className="value" key={index}>
// <div className="key">{value[0]}</div>
// <div className="value">{value[1]}</div>
// </div>
// ))}
// </div>
// )}
// </div>
// );
// }

View File

@ -0,0 +1,110 @@
import Blockchains from "@depay/web3-blockchains";
import * as React from "react";
import { Label } from "~/lib/ui/label";
import Link from "next/link";
import ID from "~/app/_components/id";
import Image from "next/image";
import { Badge } from "~/lib/ui/badge";
import { formatDate } from "~/lib/utils";
export default function PaymentInfo({ paymentInfo }: { paymentInfo: any }) {
const blockchain = Blockchains.findByName(paymentInfo.blockchain as string)!;
const token = blockchain.tokens.find(
(token) => token.address === paymentInfo.token,
)!;
const Item = ({
children,
label,
value,
}: {
children?: React.ReactNode;
label: string;
value?: string;
}) => {
return (
<div className="flex items-center space-x-4">
<Label className="w-[8rem] text-right">{label} :</Label>
{children ? children : <span>{value}</span>}
</div>
);
};
return (
<div className="flex flex-col space-y-4">
<Item label="Transaction">
<Link
className="underline"
href={
blockchain.explorerUrlFor({
transaction: paymentInfo.transaction,
}) ?? "#"
}
target="_blank"
>
<ID id={paymentInfo.transaction} />
</Link>
</Item>
<Item label="Blockchain">
<Image
src={blockchain.logo}
alt={blockchain.name}
width={24}
height={24}
style={{ backgroundColor: blockchain.logoBackgroundColor }}
/>
<span>{blockchain.fullName}</span>
</Item>
<Item label="Amount" value={paymentInfo.amount} />
<Item label="Sender">
<Link
className="underline"
href={
blockchain.explorerUrlFor({
address: paymentInfo.sender,
}) ?? "#"
}
target="_blank"
>
<ID id={paymentInfo.sender} />
</Link>
</Item>
<Item label="Receiver">
<Link
className="underline"
href={
blockchain.explorerUrlFor({
address: paymentInfo.receiver,
}) ?? "#"
}
target="_blank"
>
<ID id={paymentInfo.receiver} />
</Link>
</Item>
<Item label="Token">
<Image src={token.logo} alt={token.name} width={24} height={24} />
<span>{token.symbol}</span>
</Item>
<Item label="Status">
<Badge className="rounded-md px-2 py-1 text-white">
{paymentInfo.status}
</Badge>
</Item>
<Item label="Commitment" value={paymentInfo.commitment} />
<Item
label="Created At"
value={formatDate(new Date(paymentInfo.created_at as string))}
/>
<Item label="After Block" value={paymentInfo.after_block} />
<Item label="Confirmations" value={paymentInfo.confirmations} />
<Item
label="Confirmed At"
value={
paymentInfo.confirmed_at &&
formatDate(new Date(paymentInfo.confirmed_at as string))
}
/>
</div>
);
}

View File

@ -0,0 +1,307 @@
"use client";
import * as React from "react";
import { type ChangeEvent, useMemo, useState } from "react";
import {
type ColumnDef,
type ColumnFiltersState,
getCoreRowModel,
type PaginationState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { api } from "~/trpc/react";
import { Input } from "~/lib/ui/input";
import Table from "~/app/_components/table";
import { type PaymentGetAllOutput } from "~/lib/types/trpc";
import { Button } from "~/lib/ui/button";
import { MoreHorizontalIcon, XIcon } from "lucide-react";
import { DataTableViewOptions } from "~/app/_components/table-view-options";
import ID from "~/app/_components/id";
import UserColumn from "~/app/_components/user-column";
import { MoneyInput } from "~/lib/ui/money-input";
import { TableFacetedFilter } from "~/app/_components/table-faceted-filter";
import { PaymentStatusOptions } from "~/lib/constants";
import { type $Enums } from ".prisma/client";
import { cn, formatDate } from "~/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/lib/ui/dropdown-menu";
import { Dialog, DialogContent, DialogTrigger } from "~/lib/ui/dialog";
import { Badge } from "~/lib/ui/badge";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/lib/ui/accordion";
import PaymentInfo from "~/app/(manage)/admin/config/_components/payment-info";
type PaymentStatus = $Enums.PaymentStatus;
interface PaymentTableProps {
page?: number;
size?: number;
keyword?: string;
filters?: string;
}
export default function PaymentTable({
page,
size,
keyword: initialKeyword,
filters,
}: PaymentTableProps) {
const [keyword, setKeyword] = useState(initialKeyword ?? "");
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: page ?? 0,
pageSize: size ?? 10,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
filters ? (JSON.parse(filters) as ColumnFiltersState) : [],
);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const payments = api.payment.getAll.useQuery({
page: pageIndex,
size: pageSize,
keyword: keyword,
status: columnFilters.find((filter) => filter.id === "status")
?.value as PaymentStatus[],
});
const columns: ColumnDef<PaymentGetAllOutput>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
return <ID id={row.original.id} createdAt={row.original.createdAt} />;
},
},
{
accessorKey: "targetAmount",
header: "充值金额",
cell: ({ row }) => {
return (
<MoneyInput
className="text-sm"
displayType="text"
value={String(row.original.targetAmount)}
/>
);
},
},
{
accessorKey: "amount",
header: "到账金额",
cell: ({ row }) => {
return (
<MoneyInput
className="text-sm"
displayType="text"
value={String(row.original.amount)}
/>
);
},
},
{
accessorKey: "status",
header: "状态",
cell: ({ row }) => {
return <PaymentStatusBadge status={row.original.status} />;
},
},
{
accessorKey: "user",
header: "用户",
cell: ({ row }) => <UserColumn user={row.original.user} />,
},
{
id: "actions",
cell: ({ row }) => {
if (!row.original.paymentInfo && !row.original.callback) {
return null;
}
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<MoreHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<PaymentInfoDialog paymentInfo={row.original.paymentInfo} />
<PaymentCallbackDialog callback={row.original.callback} />
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
const table = useReactTable({
columns,
data: payments.data?.payments ?? [],
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: Math.ceil((payments.data?.total ?? 0) / 10),
state: {
pagination,
columnFilters,
columnVisibility,
},
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
});
const isFiltered = useMemo(
() => keyword !== "" || columnFilters.length > 0,
[keyword, columnFilters],
);
return (
<div>
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="ID/用户ID/信息"
value={keyword ?? ""}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setKeyword(event.target.value)
}
className="h-8 w-[150px] lg:w-[250px]"
/>
<TableFacetedFilter
column={table.getColumn("status")}
title="状态"
options={PaymentStatusOptions}
/>
{isFiltered && (
<Button
variant="ghost"
onClick={() => {
setKeyword("");
table.resetColumnFilters();
}}
className="h-8 px-2 lg:px-3"
>
<XIcon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<div className="flex space-x-2">
<DataTableViewOptions table={table} />
</div>
</div>
<Table table={table} isLoading={payments.isLoading} />
</div>
);
}
function PaymentStatusBadge({ status }: { status: PaymentStatus }) {
return (
<span
className={cn(
"text-primary-background rounded bg-primary-foreground px-2 py-1 text-xs",
status === "SUCCEEDED" && "bg-green-500/80 text-white",
status === "FAILED" && "bg-red-500/80 text-white",
)}
>
{status}
</span>
);
}
function PaymentInfoDialog({ paymentInfo }: { paymentInfo: any }) {
if (!paymentInfo) {
return null;
}
return (
<Dialog modal={true}>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Payment Info
</DropdownMenuItem>
</DialogTrigger>
<DialogContent
className="h-full w-full max-w-none overflow-y-auto md:h-auto md:w-2/3"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<PaymentInfo paymentInfo={paymentInfo} />
</DialogContent>
</Dialog>
);
}
function PaymentCallbackDialog({ callback }: { callback: any }) {
if (!callback) {
return null;
}
return (
<Dialog modal={true}>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Events
</DropdownMenuItem>
</DialogTrigger>
<DialogContent
className="h-full w-full max-w-none overflow-y-auto md:h-auto md:w-2/3"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<Accordion type="single" collapsible className="w-full">
{callback.map((event: any, index: number) => {
return (
<AccordionItem value={`callback_${index}`}>
<AccordionTrigger className="flex items-center">
<div className="w-[100px] text-left">
<Badge className="rounded-md px-2 py-1 text-white">
{event.status}
</Badge>
</div>
<span className="text-muted-foreground">
{formatDate(new Date(event.created_at as string))}
</span>
</AccordionTrigger>
<AccordionContent>
<PaymentInfo paymentInfo={event} />
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,326 @@
"use client";
import { type ChangeEvent, useMemo, useState } from "react";
import {
type ColumnDef,
getCoreRowModel,
type PaginationState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { api } from "~/trpc/react";
import { Input } from "~/lib/ui/input";
import Table from "~/app/_components/table";
import { type RechargeCodeGetAllOutput } from "~/lib/types/trpc";
import { Button } from "~/lib/ui/button";
import { TicketCheckIcon, TicketIcon, Trash2Icon, XIcon } from "lucide-react";
import { DataTableViewOptions } from "~/app/_components/table-view-options";
import ID from "~/app/_components/id";
import UserColumn from "~/app/_components/user-column";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/lib/ui/alert-dialog";
import { Switch } from "~/lib/ui/switch";
import { Label } from "~/lib/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/lib/ui/dialog";
import { Slider } from "~/lib/ui/slider";
import { MoneyInput } from "~/lib/ui/money-input";
import { useTrack } from "~/lib/hooks/use-track";
interface RechargeCodeTableProps {
page?: number;
size?: number;
keyword?: string;
used?: boolean;
}
export default function RechargeCodeTable({
page,
size,
keyword: initialKeyword,
used: initialUsed,
}: RechargeCodeTableProps) {
const [keyword, setKeyword] = useState(initialKeyword ?? "");
const [used, setUsed] = useState(initialUsed ?? false);
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: page ?? 0,
pageSize: size ?? 10,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const rechargeCodes = api.rechargeCode.getAll.useQuery({
page: pageIndex,
size: pageSize,
keyword: keyword,
used: used,
});
const columns: ColumnDef<RechargeCodeGetAllOutput>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
return <ID id={row.original.id} />;
},
},
{
accessorKey: "code",
header: "充值码",
cell: ({ row }) => {
return <ID id={row.original.code} />;
},
},
{
accessorKey: "amount",
header: "金额",
cell: ({ row }) => {
return (
<MoneyInput
className="text-sm"
displayType="text"
value={row.original.amount}
/>
);
},
},
{
accessorKey: "used",
header: "使用状态",
cell: ({ row }) => {
return row.original.used ? (
<TicketCheckIcon className="h-5 w-5 text-green-600" />
) : (
<TicketIcon className="h-5 w-5 text-muted-foreground" />
);
},
},
{
accessorKey: "user",
header: "使用用户",
cell: ({ row }) =>
row.original.user && <UserColumn user={row.original.user} />,
},
{
id: "actions",
cell: ({ row }) => {
return (
<div className="flex gap-1">
{!row.original.used && <RechargeCodeDelete id={row.original.id} />}
</div>
);
},
},
];
const table = useReactTable({
columns,
data: rechargeCodes.data?.rechargeCodes ?? [],
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: Math.ceil((rechargeCodes.data?.total ?? 0) / 10),
state: {
pagination,
columnVisibility,
},
onPaginationChange: setPagination,
onColumnVisibilityChange: setColumnVisibility,
});
const isFiltered = useMemo(() => keyword !== "" || used, [keyword, used]);
return (
<div>
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Code/用户ID/金额"
value={keyword ?? ""}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setKeyword(event.target.value)
}
className="h-8 w-[150px] lg:w-[250px]"
/>
<div className="flex items-center space-x-2">
<Switch
id="used"
checked={used}
onCheckedChange={(checked) => setUsed(checked)}
/>
<Label className="ml-2" htmlFor="used">
使
</Label>
</div>
{isFiltered && (
<Button
variant="ghost"
onClick={() => {
setKeyword("");
setUsed(false);
table.resetColumnFilters();
}}
className="h-8 px-2 lg:px-3"
>
<XIcon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<div className="flex space-x-2">
<RechargeCodeNew />
<DataTableViewOptions table={table} />
</div>
</div>
<Table table={table} isLoading={rechargeCodes.isLoading} />
</div>
);
}
function RechargeCodeDelete({ id }: { id: string }) {
const deleteMutation = api.rechargeCode.delete.useMutation();
const utils = api.useUtils();
const { track } = useTrack();
const handleDelete = () => {
track("recharge-code-delete-button", {
codeId: id,
});
void deleteMutation.mutateAsync({ id: id }).then(() => {
void utils.rechargeCode.getAll.refetch();
});
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
loading={deleteMutation.isLoading}
success={deleteMutation.isSuccess}
>
<Trash2Icon className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function RechargeCodeNew() {
const [amount, setAmount] = useState("0");
const [num, setNum] = useState(1);
const [needExport, setNeedExport] = useState(false);
const { track } = useTrack();
const createRechargeCodeMutation = api.rechargeCode.create.useMutation({
onSuccess: (data) => {
if (needExport) {
const blob = new Blob([JSON.stringify(data)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "recharge-codes.json";
a.click();
URL.revokeObjectURL(url);
}
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button className="ml-auto hidden h-8 lg:flex"></Button>
</DialogTrigger>
<DialogContent className="min-w-20">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="mb-3">
<Label></Label>
<div className="mt-3">
<MoneyInput
value={amount}
onValueChange={(value) => {
setAmount(value.value);
}}
/>
</div>
</div>
<div className="mb-3">
<Label></Label>
<div className="mt-3">
<Slider
className="mb-3"
value={[num]}
max={100}
step={1}
min={1}
onValueChange={(value) => setNum(value[0]!)}
/>
<span className="mt-3 text-xs text-muted-foreground">
{num} {parseFloat(amount) * num}
</span>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-3">
<Label htmlFor="needExport"></Label>
<Switch
id="needExport"
onCheckedChange={setNeedExport}
checked={needExport}
/>
</div>
<Button
onClick={() => {
track("create-recharge-code-button", {
amount: amount,
num: num,
export: needExport,
});
void createRechargeCodeMutation.mutateAsync({
amount: parseFloat(amount),
num: num,
});
}}
disabled={!amount || amount === "0"}
loading={createRechargeCodeMutation.isLoading}
success={createRechargeCodeMutation.isSuccess}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,110 @@
import { type z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/lib/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/lib/ui/select";
import { BYTE_UNITS } from "~/lib/utils";
import React, { useEffect, useImperativeHandle } from "react";
import { type CustomComponentProps } from "~/lib/types";
import { trafficPriceSchema } from "~/lib/types/zod-schema";
import { MoneyInput } from "~/lib/ui/money-input";
export default function TrafficPriceConfig({
value,
onChange,
innerRef,
}: CustomComponentProps) {
const form = useForm<z.infer<typeof trafficPriceSchema>>({
resolver: zodResolver(trafficPriceSchema),
defaultValues: value ? JSON.parse(value) : undefined,
});
const watch = form.watch;
useEffect(() => {
const { unsubscribe } = watch((value) => {
onChange(JSON.stringify(value));
});
return () => unsubscribe();
}, [watch]);
useImperativeHandle(innerRef, () => {
return {
async beforeSubmit() {
return await form.trigger();
},
};
});
return (
<Form {...form}>
<form className="w-2/3 space-y-3 overflow-y-auto p-1">
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<MoneyInput
value={field.value}
onValueChange={(value) => {
field.onChange(value.value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="unit"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.keys(BYTE_UNITS as object)
.filter((key) => key !== "Bytes" && key !== "Kilobytes")
.map((key) => (
<SelectItem value={key} key={key}>
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}
export function TrafficPriceSkeleton() {
return (
<div className="w-2/3 space-y-3 overflow-y-auto p-1">
<div className="h-8 animate-pulse rounded bg-gray-200" />
<div className="h-8 animate-pulse rounded bg-gray-200" />
</div>
);
}

View File

@ -0,0 +1,204 @@
"use client";
import * as React from "react";
import { type ChangeEvent, useMemo, useState } from "react";
import {
type ColumnDef,
type ColumnFiltersState,
getCoreRowModel,
type PaginationState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { api } from "~/trpc/react";
import { Input } from "~/lib/ui/input";
import Table from "~/app/_components/table";
import { type WithdrawalGetAllOutput } from "~/lib/types/trpc";
import { Button } from "~/lib/ui/button";
import { CheckSquareIcon, XIcon } from "lucide-react";
import { DataTableViewOptions } from "~/app/_components/table-view-options";
import ID from "~/app/_components/id";
import UserColumn from "~/app/_components/user-column";
import { MoneyInput } from "~/lib/ui/money-input";
import { TableFacetedFilter } from "~/app/_components/table-faceted-filter";
import { WithdrawalStatusOptions } from "~/lib/constants";
import { Badge } from "~/lib/ui/badge";
import { toast } from "~/lib/ui/use-toast";
import { WithdrawalStatus } from ".prisma/client";
interface WithdrawalTableProps {
page?: number;
size?: number;
keyword?: string;
filters?: string;
}
export default function WithdrawalTable({
page,
size,
keyword: initialKeyword,
filters,
}: WithdrawalTableProps) {
const [keyword, setKeyword] = useState(initialKeyword ?? "");
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: page ?? 0,
pageSize: size ?? 10,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
filters ? (JSON.parse(filters) as ColumnFiltersState) : [],
);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const withdrawals = api.withdrawal.getAll.useQuery({
page: pageIndex,
size: pageSize,
keyword: keyword,
status: columnFilters.find((filter) => filter.id === "status")
?.value as WithdrawalStatus[],
});
const updateStatusMutation = api.withdrawal.updateStatus.useMutation({
onSuccess: () => {
toast({
title: "操作成功",
description: "提现状态已更新, 已自动更新用户钱包收益余额",
});
void withdrawals.refetch();
},
});
const columns: ColumnDef<WithdrawalGetAllOutput>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
return <ID id={row.original.id} createdAt={row.original.createdAt} />;
},
},
{
accessorKey: "amount",
header: "提现金额",
cell: ({ row }) => {
return (
<MoneyInput
className="text-sm"
displayType="text"
value={String(row.original.amount)}
/>
);
},
},
{
accessorKey: "address",
header: "地址",
cell: ({ row }) => {
return <ID id={row.original.address} />;
},
},
{
accessorKey: "status",
header: "状态",
cell: ({ row }) => {
return (
<Badge className="rounded-md px-2 py-1 text-white">
{row.original.status}
</Badge>
);
},
},
{
accessorKey: "user",
header: "用户",
cell: ({ row }) => <UserColumn user={row.original.user} />,
},
{
id: "actions",
cell: ({ row }) => {
if (row.original.status === WithdrawalStatus.WITHDRAWN) {
return null;
}
return (
<Button
variant="ghost"
size="sm"
onClick={() => {
void updateStatusMutation.mutateAsync({
id: row.original.id,
status: WithdrawalStatus.WITHDRAWN,
});
}}
>
<CheckSquareIcon className="h-4 w-4" />
</Button>
);
},
},
];
const table = useReactTable({
columns,
data: withdrawals.data?.withdrawals ?? [],
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: Math.ceil((withdrawals.data?.total ?? 0) / 10),
state: {
pagination,
columnFilters,
columnVisibility,
},
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
});
const isFiltered = useMemo(
() => keyword !== "" || columnFilters.length > 0,
[keyword, columnFilters],
);
return (
<div>
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="ID/用户ID"
value={keyword ?? ""}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setKeyword(event.target.value)
}
className="h-8 w-[150px] lg:w-[250px]"
/>
<TableFacetedFilter
column={table.getColumn("status")}
title="状态"
options={WithdrawalStatusOptions}
/>
{isFiltered && (
<Button
variant="ghost"
onClick={() => {
setKeyword("");
table.resetColumnFilters();
}}
className="h-8 px-2 lg:px-3"
>
<XIcon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<div className="flex space-x-2">
<DataTableViewOptions table={table} />
</div>
</div>
<Table table={table} isLoading={withdrawals.isLoading} />
</div>
);
}

View File

@ -0,0 +1,55 @@
import { Separator } from "~/lib/ui/separator";
import { type ReactNode } from "react";
import { SidebarNav } from "~/app/_components/sidebar-nav";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "系统配置 - vortex",
};
export default function ConfigLayout({ children }: { children: ReactNode }) {
const sidebarNavItems = [
{
title: "通用",
href: `/admin/config/appearance`,
},
{
title: "节点相关配置",
href: `/admin/config/agent`,
},
{
title: "日志配置",
href: `/admin/config/log`,
},
{
title: "支付记录",
href: `/admin/config/payment`,
},
{
title: "充值码",
href: `/admin/config/recharge-code`,
},
{
title: "提现记录",
href: `/admin/config/withdraw`,
},
];
return (
<div className="space-y-6 p-10 pb-16">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">
</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1">{children}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { api } from "~/trpc/server";
import ConfigList from "~/app/(manage)/admin/config/_components/config-list";
import { GLOBAL_CONFIG_SCHEMA_MAP } from "~/lib/constants/config";
export default async function Config() {
const configs = await api.system.getAllConfig.query();
return <ConfigList configs={configs} schemaMap={GLOBAL_CONFIG_SCHEMA_MAP} />;
}

View File

@ -0,0 +1,5 @@
import PaymentTable from "~/app/(manage)/admin/config/_components/payment-table";
export default function PaymentPage() {
return <PaymentTable />;
}

View File

@ -0,0 +1,30 @@
import RechargeCodeTable from "~/app/(manage)/admin/config/_components/recharge-code-table";
import { Separator } from "~/lib/ui/separator";
import Link from "next/link";
import { MoveRightIcon } from "lucide-react";
import { getServerAuthSession } from "~/server/auth";
export default async function RechargeCodeConfigPage() {
const session = await getServerAuthSession();
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium"></h3>
<div className="flex items-center text-sm text-muted-foreground">
<Link
href={`/user/${session?.user.id}/balance`}
className="flex items-center gap-2 rounded border bg-accent px-2"
>
<MoveRightIcon className="h-4 w-4" />
</Link>
使
</div>
</div>
<Separator />
<RechargeCodeTable />
</div>
);
}

View File

@ -0,0 +1,5 @@
import WithdrawalTable from "~/app/(manage)/admin/config/_components/withdrawal-table";
export default function WithdrawalPage() {
return <WithdrawalTable />;
}

View File

@ -0,0 +1,61 @@
"use client";
import { api } from "~/trpc/react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/lib/ui/alert-dialog";
import { Button } from "~/lib/ui/button";
import { Trash2Icon } from "lucide-react";
import React from "react";
import { toast } from "~/lib/ui/use-toast";
import { useLogStore } from "~/app/(manage)/admin/log/store/log-store";
import { useTrack } from "~/lib/hooks/use-track";
export default function LogDelete() {
const utils = api.useUtils();
const { params } = useLogStore();
const deleteLogs = api.log.deleteLogs.useMutation({
onSuccess: () => {
toast({
title: "Deleted successfully",
variant: "default",
});
void utils.log.getLogs.refetch();
},
});
const { track } = useTrack();
function handleDelete() {
track("log-delete-button", {});
void deleteLogs.mutateAsync();
}
if (params.agentId && params.agentId !== "") {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" className="h-8">
<Trash2Icon className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,128 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/lib/ui/tooltip";
import { BarChartHorizontalIcon } from "lucide-react";
import React from "react";
import { cn } from "~/lib/utils";
import { useLogStore } from "~/app/(manage)/admin/log/store/log-store";
import { api } from "~/trpc/react";
export default function LogGlance() {
const { params, setParams, convertParams } = useLogStore();
const getLogGlance = api.log.getLogGlance.useQuery({
...convertParams(),
});
const renderTimeAxis = () => {
let lastTime: Date;
const length = getLogGlance.data?.timeAxis.length ?? 0;
return getLogGlance.data?.timeAxis.map((time: Date, i: number) => {
const timeString =
time.toDateString() === lastTime?.toDateString()
? `${time.getHours()}:${time.getMinutes()}`
: `${time.getMonth() + 1} / ${time.getDate()}`;
lastTime = time;
const top = i === 0 ? 0 : i === length - 1 ? 90 : (i / (length - 1)) * 90;
return (
<span
key={i}
className={cn(
"absolute right-[3px] whitespace-nowrap after:mr-[-4px] after:text-slate-100 after:content-['-'] after:dark:text-slate-600",
)}
style={{ top: `${top}%` }}
>
{timeString}
</span>
);
});
};
const renderTimeAxisData = () => {
const timeAxisData = getLogGlance.data?.timeAxisData;
if (!timeAxisData || timeAxisData.length === 0) return null;
const max = timeAxisData.reduce((prev, curr) =>
prev.count > curr.count ? prev : curr,
).count;
const min = timeAxisData.reduce((prev, curr) =>
prev.count < curr.count ? prev : curr,
).count;
return timeAxisData.map((data, i) => {
const width = ((data.count - min) / (max - min)) * 100;
return (
<TooltipProvider delayDuration={100} key={i}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="group relative h-[11px] hover:cursor-pointer"
onClick={() => {
setParams({
...params,
startDate: data.start.toLocaleString(),
endDate: data.end.toLocaleString(),
});
}}
>
<div className="h-[6px]"></div>
<div className="h-[7px]">
<div
className={cn(
"h-full min-w-[6px] rounded-[2px] bg-slate-200 transition-[width,background-color] duration-75 group-hover:bg-sky-500 dark:bg-slate-600",
)}
style={{ width: `${width}%` }}
></div>
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={10}
className="flex h-[98px] w-[222px] flex-col p-0"
>
<div className="flex flex-col border-b border-slate-200 py-2 dark:border-slate-600">
<div className="flex flex-grow-[2] flex-col justify-evenly px-2">
<div className="mb-[8px] flex flex-row items-center">
<div className="ml-[3px] mr-[12px] h-[9px] w-[10px] rounded-full border border-slate-400 after:absolute after:top-[25px] after:ml-[3px] after:h-[14px] after:rounded-sm after:border-r after:border-slate-400 dark:border-slate-400 after:dark:border-slate-400"></div>
<div className="inline-flex w-full flex-row justify-between">
<div className="text-slate-500 dark:text-slate-200">
{data.start.toLocaleString()}
</div>
</div>
</div>
<div className="flex flex-row items-center">
<div className="ml-[3px] mr-[12px] h-[9px] w-[10px] rounded-full bg-slate-600 dark:bg-slate-400"></div>
<div className="inline-flex w-full flex-row justify-between">
<div className="text-slate-500 dark:text-slate-200">
{data.end.toLocaleString()}
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-grow items-center bg-slate-50 px-2 dark:bg-slate-700">
<BarChartHorizontalIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 text-slate-300 dark:text-slate-200" />
<span className="font-medium text-slate-300 dark:text-slate-200">
{data.count} logs
</span>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
};
return (
<div className="hidden h-full min-w-[140px] flex-row border-l border-slate-100 md:flex dark:border-slate-700">
<div className="relative min-w-[60px] select-none border-r border-slate-100 pr-[4px] text-right text-sm text-primary/20 dark:border-slate-700">
{renderTimeAxis()}
</div>
<div className="min-w-[79px] select-none pl-[6px]">
{renderTimeAxisData()}
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
import { useLogStore } from "~/app/(manage)/admin/log/store/log-store";
import { Popover, PopoverContent, PopoverTrigger } from "~/lib/ui/popover";
import { Input } from "~/lib/ui/input";
import React, { useEffect } from "react";
import { Switch } from "~/lib/ui/switch";
import { Label } from "~/lib/ui/label";
import { Textarea } from "~/lib/ui/textarea";
import { Button } from "~/lib/ui/button";
export default function LogSearchKeyword() {
const { params, setParams } = useLogStore();
const [open, setOpen] = React.useState(false);
const [jql, setJql] = React.useState(params.jql);
const [keyword, setKeyword] = React.useState(params.keyword);
useEffect(() => {
setJql(params.jql);
setKeyword(params.keyword);
}, [params.jql, params.keyword]);
return (
<Popover modal={true} open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Input
className="h-8 w-[250px] lg:w-[350px]"
placeholder="Search for logs"
value={params.keyword}
/>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center gap-2">
<Switch id="jql" checked={jql} onCheckedChange={setJql} />
<Label htmlFor="jql"></Label>
</div>
{jql && (
<div className="text-xs text-foreground/50">
<p>path op value</p>
<p>path格式.a.b.c a.b.c字段</p>
<p>[]a.b[0].c a.b数组下标为0的c字段</p>
<p>op支持{"=, !=, like, <, <=, >, >="}</p>
</div>
)}
<Textarea
placeholder="默认模糊查询msg字段"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="h-16"
/>
<div className="flex justify-end">
<Button
onClick={() => {
setParams({
...params,
jql,
keyword,
});
setOpen(false);
}}
>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { Input } from "~/lib/ui/input";
import { Button } from "~/lib/ui/button";
import { ChevronDown, ClockIcon, XIcon } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "~/lib/ui/popover";
import { Label } from "~/lib/ui/label";
import React from "react";
import { LEVELS } from "~/lib/constants/log-level";
import { useLogStore } from "~/app/(manage)/admin/log/store/log-store";
import LogDelete from "~/app/(manage)/admin/log/_components/log-delete";
import { FacetedFilter } from "~/app/_components/faceted-filter";
import LogSearchKeyword from "~/app/(manage)/admin/log/_components/log-search-keyword";
export default function LogToolbar() {
const { params, setParams, resetParams, isFiltering } = useLogStore();
const [hasNewLog, setHasNewLog] = React.useState(false);
// TODO: 增加日志展示字段的配置
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<FacetedFilter
title="Level"
options={LEVELS}
value={new Set(params.levels)}
onChange={(v) =>
setParams({
...params,
levels: Array.from(v ?? []),
})
}
/>
<Popover modal>
<PopoverTrigger asChild>
<Button variant="outline" className="flex h-8 space-x-1 px-2">
{params.startDate || params.endDate ? (
<span className="whitespace-nowrap text-foreground/50">
{params.startDate} - {params.endDate}
</span>
) : (
<ClockIcon className="h-4 w-4 rotate-0 scale-100 text-foreground/50" />
)}
<ChevronDown className="h-4 w-4 rotate-0 scale-100 text-foreground/50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-2">
<div className="grid grid-cols-3 items-center gap-4">
<Label htmlFor="width">Start Date</Label>
<Input
value={params.startDate}
onChange={(e) =>
setParams({
...params,
startDate: e.target.value,
})
}
className="col-span-2 h-8"
/>
</div>
<div className="grid grid-cols-3 items-center gap-4">
<Label htmlFor="width">End Date</Label>
<Input
value={params.endDate}
onChange={(e) =>
setParams({
...params,
endDate: e.target.value,
})
}
className="col-span-2 h-8"
/>
</div>
</div>
</PopoverContent>
</Popover>
<LogSearchKeyword />
{isFiltering() && (
<Button
variant="ghost"
onClick={() => resetParams()}
className="h-8 px-2 lg:px-3"
>
<XIcon className="ml-2 h-4 w-4" />
</Button>
)}
{hasNewLog && (
<Button
variant="ghost"
onClick={() => {
setHasNewLog(false);
resetParams();
}}
className="h-8 px-2 lg:px-3"
>
</Button>
)}
</div>
<LogDelete />
</div>
);
}

View File

@ -0,0 +1,105 @@
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/lib/ui/accordion";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/lib/ui/tooltip";
import React from "react";
import { cn, copyToClipboard } from "~/lib/utils";
import { type LogsOutput } from "~/lib/types/trpc";
import { getLevel } from "~/lib/constants/log-level";
import { type LogMessage } from "~/lib/types";
import { toast } from "~/lib/ui/use-toast";
export default function Log({
log,
showId,
}: {
log: LogsOutput;
showId?: boolean;
}) {
if (!log) return null;
const level = getLevel(String(log.level));
const message = log.message as unknown as LogMessage;
const msg = message.msg;
let formatMessage = "";
let moduleName;
try {
formatMessage = JSON.stringify(message, null, 2);
moduleName = message?.module;
} catch (e) {
// ignore
}
return (
<AccordionItem value={log.id + ""} key={log.id} className="border-none">
<AccordionTrigger className="flex px-6 py-0 hover:bg-foreground/10 hover:no-underline">
<div className="flex space-x-2">
<div className="flex items-center justify-center space-x-2">
{showId && <div className="w-10 text-left text-sm">{log.id}</div>}
<div className={cn("w-10 py-1 text-left text-sm", level?.color)}>
{level?.label}
</div>
<div className="w-[10.7rem] text-left text-sm text-foreground/50">{`${log.time.toLocaleString()} ${log.time.getMilliseconds()}`}</div>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex w-20 justify-between text-sm text-foreground/75 hover:bg-foreground/15">
<span>[</span>
<span className="overflow-hidden whitespace-nowrap">
{moduleName}
</span>
<span>]</span>
</div>
</TooltipTrigger>
<TooltipContent align="center">
<p>{moduleName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center justify-center space-x-2">
<div
className={cn("text-start text-sm text-foreground", level?.color)}
>
{msg}
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<pre
className="cursor-pointer whitespace-pre-wrap px-6 hover:bg-foreground/10"
onClick={() => {
if (window.isSecureContext && navigator.clipboard) {
void navigator.clipboard
.writeText(formatMessage)
.then(() => {
toast({
description: "已复制到剪贴板",
});
});
} else {
copyToClipboard(formatMessage);
}
}}
>
{formatMessage}
</pre>
</TooltipTrigger>
<TooltipContent align="start">
<p>Copy</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</AccordionContent>
</AccordionItem>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import React, { useEffect } from "react";
import { ScrollArea } from "~/lib/ui/scroll-area";
import { Accordion } from "~/lib/ui/accordion";
import { api } from "~/trpc/react";
import { useLogStore } from "~/app/(manage)/admin/log/store/log-store";
import LogGlance from "~/app/(manage)/admin/log/_components/log-glance";
import Log from "~/app/(manage)/admin/log/_components/log";
import LogToolbar from "~/app/(manage)/admin/log/_components/log-toolbar";
import type { LogsOutput } from "~/lib/types/trpc";
import SearchEmptyState from "~/app/_components/search-empty-state";
export function Logs({ agentId }: { agentId?: string }) {
const { convertParams, setParams, params } = useLogStore();
useEffect(() => {
if (agentId) {
setParams({
...params,
agentId,
});
}
}, [agentId]);
const getLogs = api.log.getLogs.useInfiniteQuery(
{
...convertParams(),
agentId: agentId,
},
{
getNextPageParam: (lastPage) => {
return lastPage.nextCursor;
},
},
);
const handleScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
const scrollable = e.currentTarget;
const bottomReached =
scrollable.scrollHeight - scrollable.scrollTop ===
scrollable.clientHeight;
if (bottomReached && getLogs.hasNextPage && !getLogs.isFetching) {
void getLogs.fetchNextPage();
}
};
const LogPulse = () => {
return (
<div className="flex flex-col space-y-2">
<div className="flex h-5 animate-pulse space-x-2 px-6 py-0 ">
<div className="w-10 bg-slate-200 p-1"></div>
<div className="w-10 bg-slate-200 p-1"></div>
<div className="w-[11rem] bg-slate-200"></div>
<div className="w-20 bg-slate-200"></div>
<div className="flex-1 bg-slate-200"></div>
</div>
<div className="flex h-5 animate-pulse space-x-2 px-6 py-0 ">
<div className="w-10 bg-slate-200 p-1"></div>
<div className="w-10 bg-slate-200 p-1"></div>
<div className="w-[11rem] bg-slate-200"></div>
<div className="w-20 bg-slate-200"></div>
<div className="flex-1 bg-slate-200"></div>
</div>
<div className="flex h-5 animate-pulse space-x-2 px-6 py-0 ">
<div className="w-10 bg-slate-200 p-1"></div>
<div className="w-10 bg-slate-200 p-1"></div>
<div className="w-[11rem] bg-slate-200"></div>
<div className="w-20 bg-slate-200"></div>
</div>
</div>
);
};
return (
<>
<LogToolbar />
<div className="mt-3 flex h-[calc(100vh_-_8rem)] w-full rounded-md border p-3">
<ScrollArea className="flex-1" onViewScroll={handleScroll}>
<div className="flex flex-col">
{getLogs.data?.pages.length === 1 &&
getLogs.data.pages[0]!.logs.length === 0 ? (
<SearchEmptyState />
) : (
<Accordion type="single" collapsible>
{getLogs.data?.pages.map((page) => {
return page.logs.map((log: LogsOutput) => {
return <Log log={log} key={log.id} showId={true} />;
});
})}
</Accordion>
)}
{getLogs.isLoading && <LogPulse />}
</div>
</ScrollArea>
<LogGlance />
</div>
</>
);
}

View File

@ -0,0 +1,14 @@
import { Logs } from "~/app/(manage)/admin/log/_components/logs";
export const metadata = {
title: "日志 - vortex",
};
export default function LogPage() {
return (
<div className="p-4">
<h1 className="mb-4 text-3xl"></h1>
<Logs />
</div>
);
}

View File

@ -0,0 +1,71 @@
import { create } from "zustand";
import { type LogsInput } from "~/lib/types/trpc";
interface LogParams {
limit?: number;
levels: string[];
agentId?: string;
jql?: boolean;
keyword: string;
startDate: string;
endDate: string;
}
interface LogStore {
params: LogParams;
liveMode: boolean;
}
interface LogStoreAction {
setParams: (params: LogParams) => void;
resetParams: () => void;
isFiltering: () => boolean;
convertParams: () => LogsInput;
setLiveMode: (liveMode: boolean) => void;
}
const defaultParams: LogParams = {
limit: 30,
levels: [],
agentId: "",
keyword: "",
startDate: "",
endDate: "",
};
export const useLogStore = create<LogStore & LogStoreAction>()((set, get) => ({
params: defaultParams,
liveMode: false,
setParams: (params: LogParams) => {
set({ params });
},
resetParams: () => {
set({
params: defaultParams,
});
},
isFiltering: () => {
const { levels, keyword, startDate, endDate } = get().params;
return (
levels.length > 0 || keyword !== "" || startDate !== "" || endDate !== ""
);
},
convertParams: () => {
const { limit, levels, agentId, jql, keyword, startDate, endDate } =
get().params;
const numLevels = levels.map((level) => parseInt(level));
const params: LogsInput = {
limit,
agentId: agentId ?? undefined,
levels: numLevels,
jql: jql ?? undefined,
keyword: keyword ?? undefined,
startDate: startDate === "" ? undefined : new Date(startDate),
endDate: endDate === "" ? undefined : new Date(endDate),
};
return params;
},
setLiveMode: (liveMode: boolean) => {
set({ liveMode });
},
}));

View File

@ -0,0 +1,80 @@
import { useState } from "react";
import { Role } from "@prisma/client";
import { api } from "~/trpc/react";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "~/lib/ui/command";
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
import {
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "~/lib/ui/dropdown-menu";
export default function UserRoleSettings({
id,
roles: originRoles,
}: {
id: string;
roles: Role[];
}) {
const utils = api.useUtils();
const [roles, setRoles] = useState(originRoles ?? []);
const updateRolesMutation = api.user.updateRoles.useMutation({
onSuccess: () => {
void utils.user.getAll.refetch();
},
});
function handleUpdateRoles() {
void updateRolesMutation.mutateAsync({
id,
roles,
});
}
return (
<DropdownMenuSub onOpenChange={(open) => !open && handleUpdateRoles()}>
<DropdownMenuSubTrigger
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<Command>
<CommandList>
<CommandGroup heading={"修改之后需重新登录"}>
{Object.values(Role as object).map((role: Role) => (
<CommandItem
key={role}
value={role}
onSelect={(value) => {
const r = Role[value.toUpperCase() as keyof typeof Role];
if (roles.includes(r)) {
setRoles(roles.filter((role) => role !== r));
} else {
setRoles([...roles, r]);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
roles.includes(role) ? "opacity-100" : "opacity-0",
)}
/>
{role}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}

View File

@ -0,0 +1,47 @@
import { UserStatus } from "@prisma/client";
import { api } from "~/trpc/react";
import { Switch } from "~/lib/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/lib/ui/tooltip";
import React from "react";
export default function UserStatusSwitch({
id,
status,
}: {
id: string;
status: UserStatus;
}) {
const utils = api.useUtils();
const updateMutation = api.user.updateStatus.useMutation({
onSuccess: () => {
void utils.user.getAll.refetch();
},
});
const handleStatusChange = (status: UserStatus) => {
void updateMutation.mutateAsync({
id,
status,
});
};
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex">
<Switch
checked={status === UserStatus.ACTIVE}
onCheckedChange={(checked) => {
handleStatusChange(
checked ? UserStatus.ACTIVE : UserStatus.BANNED,
);
}}
/>
</div>
</TooltipTrigger>
<TooltipContent align="center">
{status === UserStatus.ACTIVE ? "封禁此用户" : "激活此用户"}
</TooltipContent>
</Tooltip>
);
}

View File

@ -0,0 +1,164 @@
"use client";
import {
type ColumnDef,
getCoreRowModel,
type PaginationState,
useReactTable,
} from "@tanstack/react-table";
import { Input } from "~/lib/ui/input";
import { type ChangeEvent, useMemo, useState } from "react";
import Table from "~/app/_components/table";
import { api } from "~/trpc/react";
import { type UserGetAllOutput } from "~/lib/types/trpc";
import ID from "~/app/_components/id";
import UserColumn from "~/app/_components/user-column";
import UserStatusSwitch from "~/app/(manage)/admin/users/_components/user-status";
import { TooltipProvider } from "~/lib/ui/tooltip";
import { Badge } from "~/lib/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/lib/ui/dropdown-menu";
import { Button } from "~/lib/ui/button";
import { MoreHorizontalIcon, XIcon } from "lucide-react";
import UserRoleSettings from "~/app/(manage)/admin/users/_components/user-role-setting";
import { DataTableViewOptions } from "~/app/_components/table-view-options";
export default function UserTable() {
const [keyword, setKeyword] = useState("");
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const isFiltered = useMemo(() => keyword !== "", [keyword]);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const users = api.user.getAll.useQuery({
page: pageIndex,
size: pageSize,
keyword: keyword,
});
const columns: ColumnDef<UserGetAllOutput>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
return (
<ID id={row.getValue("id")} createdAt={row.original.createdAt} />
);
},
},
{
accessorKey: "info",
header: "信息",
cell: ({ row }) => {
return <UserColumn user={row.original} />;
},
},
{
id: "rules",
header: "角色",
cell: ({ row }) => {
return (
<div className="max-w-20 space-y-1">
{row.original.roles.map((role) => (
<Badge key={role}>{role}</Badge>
))}
</div>
);
},
},
{
id: "status",
header: "状态",
cell: ({ row }) => {
return (
<UserStatusSwitch id={row.original.id} status={row.original.status} />
);
},
},
{
id: "actions",
header: "操作",
cell: ({ row }) => {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<MoreHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<UserRoleSettings
id={row.original.id}
roles={row.original.roles}
/>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
const table = useReactTable({
columns,
data: users.data?.users ?? [],
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: Math.ceil((users.data?.total ?? 0) / 10),
state: {
pagination,
},
onPaginationChange: setPagination,
});
return (
<div>
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="过滤名称 | 邮箱"
value={keyword ?? ""}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setKeyword(event.target.value)
}
className="h-8 w-[150px] lg:w-[250px]"
/>
{isFiltered && (
<Button
variant="ghost"
onClick={() => {
setKeyword("");
table.resetColumnFilters();
}}
className="h-8 px-2 lg:px-3"
>
<XIcon className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<div className="flex space-x-2">
<DataTableViewOptions table={table} />
</div>
</div>
<TooltipProvider delayDuration={100}>
<Table table={table} isLoading={users.isLoading} />
</TooltipProvider>
</div>
);
}

View File

@ -0,0 +1,14 @@
import UserTable from "~/app/(manage)/admin/users/_components/user-table";
export const metadata = {
title: "用户 - vortex",
};
export default async function UserListPage() {
return (
<div className="p-4">
<h1 className="mb-4 text-3xl"></h1>
<UserTable />
</div>
);
}

View File

@ -0,0 +1,18 @@
import { AgentForm } from "~/app/(manage)/agent/_components/agent-form";
import { api } from "~/trpc/server";
export const metadata = {
title: "服务器 - 基础信息 - vortex",
};
export default async function AgentConfigBasePage({
params: { agentId },
}: {
params: { agentId: string };
}) {
const agent = await api.agent.getOne.query({ id: agentId });
return (
<div className="ml-10 w-1/3">
<AgentForm agent={agent} />
</div>
);
}

View File

@ -0,0 +1,27 @@
import ConfigList from "~/app/(manage)/admin/config/_components/config-list";
import { AGENT_CONFIG_SCHEMA_MAP } from "~/lib/constants/config";
import { api } from "~/trpc/server";
export const metadata = {
title: "服务器 - 其它设置 - vortex",
};
export default async function AgentConfig({
params: { agentId },
}: {
params: { agentId: string };
}) {
const configs = await api.system.getAllConfig.query({
relationId: agentId,
});
return (
<div className="p-3">
<ConfigList
schemaMap={AGENT_CONFIG_SCHEMA_MAP}
configs={configs ?? []}
relationId={agentId}
/>
</div>
);
}

View File

@ -0,0 +1,17 @@
import ForwardTable from "~/app/(manage)/forward/_components/forward-table";
export const metadata = {
title: "服务器 - 转发 - vortex",
};
export default function AgentForwardPage({
params: { agentId },
}: {
params: { agentId: string };
}) {
return (
<div className="p-3">
<ForwardTable agentId={agentId} />
</div>
);
}

View File

@ -0,0 +1,13 @@
import AgentInstall from "~/app/(manage)/agent/_components/agent-install";
export const metadata = {
title: "服务器 - 安装 - vortex",
};
export default function AgentInstallPage({
params: { agentId },
}: {
params: { agentId: string };
}) {
return <AgentInstall agentId={agentId} />;
}

View File

@ -0,0 +1,45 @@
import { api } from "~/trpc/server";
import AgentResizableLayout from "~/app/(manage)/agent/_components/agent-resizable-layout";
import { type ReactNode } from "react";
import AgentMenu from "~/app/(manage)/agent/_components/agent-menu";
export const metadata = {
title: "服务器 - vortex",
};
export default async function AgentLayout({
children,
params: { agentId },
}: {
children: ReactNode;
params: { agentId: string };
}) {
const agents = await api.agent.getAll.query(undefined);
let agent;
if (agentId !== undefined) {
agent = Object.values(agents)
.flat()
.find((agent) => agent.id === agentId);
} else {
if (agents.ONLINE.length > 0) {
agent = agents.ONLINE[0];
} else if (agents.OFFLINE.length > 0) {
agent = agents.OFFLINE[0];
} else if (agents.UNKNOWN.length > 0) {
agent = agents.UNKNOWN[0];
}
}
if (agentId === undefined && agent !== undefined) {
agentId = agent.id;
}
return (
<AgentResizableLayout agents={agents} agentId={agentId}>
<div className="flex min-h-screen w-full flex-1 flex-col">
{agent && <AgentMenu agent={agent} />}
{children}
</div>
</AgentResizableLayout>
);
}

View File

@ -0,0 +1,15 @@
export default function Loading() {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center">
<video width="360" height="360" autoPlay loop muted>
<source
src="/3d-casual-life-screwdriver-and-wrench-as-settings.webm"
type="video/webm"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import { Logs } from "~/app/(manage)/admin/log/_components/logs";
export const metadata = {
title: "服务器 - 日志 - vortex",
};
export default function AgentLogPage({
params: { agentId },
}: {
params: { agentId: string };
}) {
return (
<div className="p-3">
<Logs agentId={agentId} />
</div>
);
}

View File

@ -0,0 +1,183 @@
import { getPlatformIcon } from "~/lib/icons";
import CpuUsage, {
type CpuStat,
} from "~/app/(manage)/agent/_components/cpu-usage";
import MemUsage, {
type MemStat,
} from "~/app/(manage)/agent/_components/mem-usage";
import BandwidthUsage, {
type BandwidthStat,
} from "~/app/(manage)/agent/_components/bandwidth-usage";
import TrafficUsage, {
type TrafficStat,
} from "~/app/(manage)/agent/_components/traffic-usage";
import { convertBytes, convertBytesToBestUnit, formatDate } from "~/lib/utils";
import "/node_modules/flag-icons/css/flag-icons.min.css";
import ID from "~/app/_components/id";
import { MoveDownIcon, MoveUpIcon } from "lucide-react";
import { api } from "~/trpc/server";
import AgentPrice from "~/app/(manage)/agent/_components/agent-price";
export const metadata = {
title: "服务器 - 状态 - vortex",
};
export default async function AgentStat({
params: { agentId },
}: {
params: { agentId: string };
}) {
const agent = await api.agent.stats.query({ id: agentId });
if (!agent) {
return null;
}
const cpuStats: CpuStat[] = [];
const memStats: MemStat[] = [];
const bandwidthStats: BandwidthStat[] = [];
const [totalDownload, totalDownloadUnit] = convertBytesToBestUnit(
agent.info.network?.totalDownload ?? 0,
);
const [totalUpload, totalUploadUnit] = convertBytesToBestUnit(
agent.info.network?.totalUpload ?? 0,
);
const trafficStat: TrafficStat = {
download: totalDownload,
downloadUnit: totalDownloadUnit,
upload: totalUpload,
uploadUnit: totalUploadUnit,
};
const memTotal = convertBytes(
agent.info.memory?.total ?? 0,
"Bytes",
"Gigabytes",
);
const [latestDownload, latestDownloadUnit] = convertBytesToBestUnit(
agent.stats[agent.stats.length - 1]?.network.downloadSpeed ?? 0,
);
const [latestUpload, latestUploadUnit] = convertBytesToBestUnit(
agent.stats[agent.stats.length - 1]?.network.uploadSpeed ?? 0,
);
for (const stat of agent.stats) {
const time = formatDate(stat.time);
cpuStats.push({
date: time,
percent: Number(stat.cpu.percent.toFixed(2)),
});
memStats.push({
date: time,
percent: Math.round((stat.memory.used / agent.info.memory?.total) * 100),
used: convertBytes(stat.memory.used, "Bytes", "Gigabytes"),
});
bandwidthStats.push({
date: time,
download: stat.network.downloadSpeed,
upload: -stat.network.uploadSpeed,
});
}
return (
<div className="mt-0 flex flex-grow flex-col">
<div className="flex h-2/5 border-b">
<div className="flex w-1/4 flex-col border-r p-4">
<p className="text-lg"></p>
<p>{agent.name}</p>
<p className="line-clamp-4 py-2 text-xs text-muted-foreground">
{agent.description}
</p>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-1 flex flex-col space-y-1 text-muted-foreground">
<span className="text-sm">ID</span>
<span className="text-sm"></span>
<span className="text-sm">CPU</span>
<span className="text-sm"></span>
<span className="text-sm">IP地址</span>
<span className="text-sm"></span>
<span className="text-sm"></span>
<span className="text-sm"></span>
</div>
<div className="col-span-2 flex flex-col space-y-1">
<ID id={agent.id} />
<span className="flex items-center gap-1 text-sm">
{getPlatformIcon(agent.info.platform)}{" "}
{agent.info.platform ?? "未知"}
</span>
<span className="line-clamp-1 text-sm">
{agent.info.cpu?.model} {agent.info.cpu?.cores ?? 0} Core
</span>
<span className="text-sm">{memTotal} GB</span>
<span className="text-sm">
{agent.info.ip?.country && (
<span
className={`fi mr-1 fi-${agent.info.ip?.country.toLocaleLowerCase()}`}
></span>
)}
{agent.info.ip?.ipv4 ?? 0}
</span>
<span className="w-fit bg-accent px-1 text-sm">
{agent.info.version ?? "UNKNOWN"}
</span>
<span className="text-sm">
{agent.lastReport && formatDate(agent.lastReport)}
</span>
<AgentPrice agentId={agentId} />
</div>
</div>
</div>
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col border-b px-4 pt-4">
<div className="flex justify-between">
<p className="text-lg">CPU</p>
<div className="text-sm text-gray-500">
{cpuStats[cpuStats.length - 1]?.percent}%
</div>
</div>
<div className="mt-4 flex-grow">
<CpuUsage data={cpuStats} />
</div>
</div>
<div className="flex flex-1 flex-col px-4 pt-4">
<div className="flex justify-between">
<p className="text-lg"></p>
<div className="text-sm text-gray-500">
{memStats[memStats.length - 1]?.used ?? 0} GB / {memTotal} GB
</div>
</div>
<div className="mt-4 flex-grow">
<MemUsage data={memStats} />
</div>
</div>
</div>
</div>
<div className="grid flex-grow grid-cols-3">
<div className="col-span-2 flex flex-col border-r px-4 pt-4">
<div className="flex justify-between">
<p className="text-lg"></p>
<div className="flex items-center gap-3 text-sm text-gray-500">
<span className="flex items-center">
<MoveDownIcon className="mr-1 inline-block h-4 w-4" />
{latestDownload} {latestDownloadUnit}
</span>
<span className="flex items-center">
<MoveUpIcon className="mr-1 inline-block h-4 w-4" />
{latestUpload} {latestUploadUnit}
</span>
</div>
</div>
<div className="flex-grow">
<BandwidthUsage data={bandwidthStats} />
</div>
</div>
<div className="col-span-1 flex flex-col px-4 pt-4">
<div className="flex justify-between">
<p className="text-lg"></p>
</div>
<div className="mt-10 flex-grow">
<TrafficUsage stat={trafficStat} />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
"use client";
import {
AlertDialog,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/lib/ui/alert-dialog";
import { Button } from "~/lib/ui/button";
import { TerminalSquareIcon, XIcon } from "lucide-react";
import { Textarea } from "~/lib/ui/textarea";
import { useState } from "react";
import { api } from "~/trpc/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/lib/ui/select";
import { type AgentTaskType } from "~/lib/types/agent";
import { AgentTaskTypeOptions } from "~/lib/constants";
import { isBase64 } from "~/lib/utils";
import { useTrack } from "~/lib/hooks/use-track";
export default function AgentCommand({
currentAgentId,
}: {
currentAgentId: string;
}) {
const [command, setCommand] = useState("");
const [type, setType] = useState<AgentTaskType>("shell");
const [open, setOpen] = useState(false);
const executeCommandMutation = api.agent.executeCommand.useMutation();
const { track } = useTrack();
function executeCommand() {
track("agent-execute-command-button", {
agentId: currentAgentId,
command: command,
type: type,
});
void executeCommandMutation.mutateAsync({
id: currentAgentId,
command: command,
type: type,
});
}
const RenderCommandResult = () => {
const error = executeCommandMutation.error;
if (executeCommandMutation.isError) {
return (
<div className="mt-2">
<p className="text-sm text-muted-foreground">
<span></span>
</p>
<code className="whitespace-pre text-sm text-muted-foreground">
<span>{error?.message}</span>
</code>
</div>
);
}
if (!executeCommandMutation.data) {
return null;
}
const extra = executeCommandMutation.data.extra;
const result = extra ? (isBase64(extra) ? atob(extra) : String(extra)) : "";
return (
<div className="mt-2">
<p className="text-sm text-muted-foreground">
<span>{executeCommandMutation.data.success ? "成功" : "失败"}</span>
</p>
<pre className="max-h-[20rem] overflow-scroll whitespace-pre text-sm text-muted-foreground">
{result}
</pre>
</div>
);
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="ghost">
<TerminalSquareIcon className="h-5 w-5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex justify-between">
<span></span>
<Button
variant="ghost"
className="ml-2"
onClick={() => setOpen(false)}
>
<XIcon className="h-4 w-4" />
</Button>
</AlertDialogTitle>
</AlertDialogHeader>
<div className="overflow-hidden">
<Select
onValueChange={(value) => setType(value as AgentTaskType)}
value={type}
>
<SelectTrigger className="mb-3 w-[8rem]">
<SelectValue placeholder="类型" />
</SelectTrigger>
<SelectContent>
{AgentTaskTypeOptions?.map((option, index) => (
<SelectItem value={option.value} key={index}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{type === "shell" && (
<Textarea
className="w-full"
rows={5}
placeholder="请输入命令"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
)}
<RenderCommandResult />
</div>
<AlertDialogFooter>
<Button
onClick={executeCommand}
disabled={type === "shell" && command === ""}
loading={executeCommandMutation.isLoading}
success={executeCommandMutation.isSuccess}
>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,20 @@
import ConfigList from "~/app/(manage)/admin/config/_components/config-list";
import { AGENT_CONFIG_SCHEMA_MAP } from "~/lib/constants/config";
import { api } from "~/trpc/server";
export default async function AgentConfig({ id }: { id: string }) {
const configs = await api.system.getAllConfig.query({
relationId: id,
});
return (
<>
{/*TODO: 服务器基本信息设置 */}
<ConfigList
schemaMap={AGENT_CONFIG_SCHEMA_MAP}
configs={configs ?? []}
relationId={id}
/>
</>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { Button } from "~/lib/ui/button";
import { Trash2Icon } from "lucide-react";
import { api } from "~/trpc/react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/lib/ui/alert-dialog";
import { useTrack } from "~/lib/hooks/use-track";
export default function AgentDelete({
currentAgentId,
}: {
currentAgentId: string;
}) {
const deleteMutation = api.agent.delete.useMutation();
const { track } = useTrack();
const handleDelete = () => {
track("agent-delete-button", {
agentId: currentAgentId,
});
void deleteMutation.mutateAsync({ id: currentAgentId }).then(() => {
window.location.replace("/agent");
});
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
loading={deleteMutation.isLoading}
success={deleteMutation.isSuccess}
>
<Trash2Icon className="h-5 w-5 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,130 @@
"use client";
import { Button } from "~/lib/ui/button";
import { Input } from "~/lib/ui/input";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/lib/ui/form";
import { api } from "~/trpc/react";
import { type Agent } from ".prisma/client";
import { Switch } from "~/lib/ui/switch";
import { useRouter } from "next/navigation";
const agentFormSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
isShared: z.boolean().optional(),
});
export function AgentForm({ agent }: { agent?: Agent }) {
const edit = !!agent;
const router = useRouter();
const form = useForm<z.infer<typeof agentFormSchema>>({
resolver: zodResolver(agentFormSchema),
defaultValues: {
name: agent?.name ?? "",
description: agent?.description ?? "",
isShared: agent?.isShared ?? false,
},
});
const createAgent = api.agent.create.useMutation({
onSuccess: () => {
router.refresh();
},
});
const updateAgent = api.agent.update.useMutation({
onSuccess: () => {
router.refresh();
},
});
function handleSubmit(values: z.infer<typeof agentFormSchema>) {
if (edit) {
void updateAgent.mutateAsync({
id: agent?.id,
...values,
});
} else {
void createAgent.mutateAsync({ ...values });
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<div className="grid space-y-4 py-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormDescription>
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormDescription></FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{edit && (
<FormField
control={form.control}
name="isShared"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormDescription></FormDescription>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end">
<Button
type="submit"
success={edit ? updateAgent.isSuccess : createAgent.isSuccess}
loading={edit ? updateAgent.isLoading : createAgent.isLoading}
>
</Button>
</div>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,116 @@
"use client";
import { Button } from "~/lib/ui/button";
import { CopyIcon, Terminal } from "lucide-react";
import { cn, copyToClipboard } from "~/lib/utils";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Switch } from "~/lib/ui/switch";
import { Label } from "~/lib/ui/label";
import { useTrack } from "~/lib/hooks/use-track";
export default function AgentInstall({ agentId }: { agentId: string }) {
const [alpha, setAlpha] = useState(false);
const {
data: installInfo,
isLoading,
refetch,
} = api.agent.getInstallInfo.useQuery({
id: agentId,
alpha,
});
const refreshKeyMutation = api.agent.refreshKey.useMutation({
onSuccess: () => {
void refetch();
},
});
const { track } = useTrack();
function handleRefreshKey() {
track("agent-refresh-key-button", {
agentId: agentId,
});
void refreshKeyMutation.mutate({ id: agentId });
}
return (
<div className="flex w-full flex-col justify-center p-4">
<h1 className="mb-4 text-2xl font-bold"></h1>
<div className="text-md mb-4">
<li className="ml-8 list-disc"></li>
<li className="ml-8 list-disc"></li>
<li className="ml-8 list-disc">
<code className="mx-1 rounded border px-2 text-accent-foreground">
agent started successfully
</code>
</li>
<li className="ml-8 list-disc">
<Button
variant="secondary"
size="sm"
className="ml-3"
onClick={() => handleRefreshKey()}
>
Key
</Button>
</li>
</div>
{process.env.NODE_ENV !== "production" && (
<div className="mb-4 flex items-center gap-1">
<Switch checked={alpha} onCheckedChange={setAlpha} id="alpha" />
<Label htmlFor="alpha">Alpha版本</Label>
</div>
)}
<Command
command={installInfo?.installShell ?? ""}
isLoading={isLoading}
/>
<h1 className="mb-4 mt-8 text-2xl font-bold"></h1>
<div className="text-md mb-4">
<li className="ml-8 list-disc"></li>
<li className="ml-8 list-disc text-red-500">
</li>
</div>
<Command command={installInfo?.uninstallShell ?? ""} isLoading={false} />
</div>
);
}
function Command({
command,
isLoading,
}: {
command: string;
isLoading: boolean;
}) {
function handleCopy() {
copyToClipboard(command);
}
return (
<div className="flex w-full items-center rounded border bg-gray-200 p-4 shadow-sm dark:bg-gray-500">
<div className="flex w-full overflow-x-hidden rounded border bg-white py-2 dark:bg-accent">
<div className="ml-3 flex w-6 select-none items-center text-right">
<Terminal className="h-5 w-5" />
</div>
<span
className={cn(
"ml-3 w-[95%] min-w-[95%] overflow-x-scroll text-sm",
isLoading && "animate-pulse bg-slate-200",
)}
>
{command}
</span>
</div>
<Button
className="ml-4 flex h-8 w-8 min-w-8 p-0"
onClick={() => handleCopy()}
>
<CopyIcon className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import { ScrollArea } from "~/lib/ui/scroll-area";
import AgentList from "~/app/(manage)/agent/_components/agent-list";
import { AgentForm } from "~/app/(manage)/agent/_components/agent-form";
import { Button } from "~/lib/ui/button";
import { PlusIcon } from "lucide-react";
import { type AgentGetAllOutput } from "~/lib/types/trpc";
import { api } from "~/trpc/react";
import { cn } from "~/lib/utils";
import { hasPermission } from "~/lib/constants/permission";
import { useSession } from "next-auth/react";
import { CSSProperties, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/lib/ui/dialog";
import { Input } from "~/lib/ui/input";
export default function AgentListAside({
agentId,
agents,
className,
style,
}: {
agentId: string;
agents: AgentGetAllOutput;
className?: string;
style?: CSSProperties;
}) {
const [keyword, setKeyword] = useState("");
agents =
api.agent.getAll.useQuery(undefined, {
refetchInterval: 3000,
}).data ?? agents;
agents = useMemo(() => {
if (!keyword) return agents;
return {
ONLINE: agents.ONLINE.filter((agent) => agent.name.includes(keyword)),
OFFLINE: agents.OFFLINE.filter((agent) => agent.name.includes(keyword)),
UNKNOWN: agents.UNKNOWN.filter((agent) => agent.name.includes(keyword)),
};
}, [agents, keyword]);
const { data: session } = useSession();
return (
<div className="fixed h-full" style={style}>
<Input
className="mx-auto mt-4 w-[90%]"
placeholder="搜索服务器"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<ScrollArea className={cn("h-full w-full px-4 pb-12", className)}>
<AgentList
title="在线服务器"
agents={agents.ONLINE}
agentId={agentId}
/>
<AgentList
title="掉线服务器"
agents={agents.OFFLINE}
agentId={agentId}
/>
<AgentList
title="未知服务器"
agents={agents.UNKNOWN}
agentId={agentId}
/>
</ScrollArea>
{hasPermission(session!, "page:button:addAgent") && (
<div className="absolute bottom-0 left-1/2 w-[90%] -translate-x-1/2 transform pb-2">
<Dialog>
<DialogTrigger asChild>
<Button className="w-full">
<PlusIcon className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<AgentForm />
</DialogContent>
</Dialog>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,56 @@
import { type Agent } from ".prisma/client";
import { cn, convertBytes } from "~/lib/utils";
import Link from "next/link";
import { type AgentInfo } from "~/lib/types/agent";
interface AgentListProps {
title: string;
agents: Agent[];
agentId: string;
}
export default function AgentList({ title, agents, agentId }: AgentListProps) {
const Hardware = ({ agent }: { agent: Agent }) => {
const cpu = (agent.info as unknown as AgentInfo).cpu;
const memory = (agent.info as unknown as AgentInfo).memory;
return (
<div className="flex justify-between">
<p className="line-clamp-2 max-h-10 text-xs text-muted-foreground">
{agent.description}
</p>
<p className="h-7 min-w-[85px] overflow-hidden rounded border p-1 text-xs">
{cpu?.cores ?? 0} core,{" "}
{convertBytes(memory?.total ?? 0, "Bytes", "Gigabytes")} GB
</p>
</div>
);
};
return (
<div className="mb-3.5">
<div className="py-4">
<span className="mr-3 text-lg">{title}</span>
<span className="text-xl text-gray-500">{agents.length}</span>
</div>
{agents.map((agent, index) => (
<Link
href={`/agent/${agent.id}/${
agent.status === "UNKNOWN" ? "install" : "status"
}`}
passHref
key={index}
>
<div
className={cn(
"min-h-[5.5rem] cursor-pointer border-b p-4 hover:bg-accent",
agent.id === agentId && "bg-muted",
)}
>
<p>{agent.name}</p>
<Hardware agent={agent} />
</div>
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,124 @@
"use client";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "~/lib/ui/navigation-menu";
import Link from "next/link";
import { usePathname } from "next/navigation";
import * as React from "react";
import { cn } from "~/lib/utils";
import { useSession } from "next-auth/react";
import AgentCommand from "~/app/(manage)/agent/_components/agent-command";
import AgentDelete from "~/app/(manage)/agent/_components/agent-delete";
import { type Agent } from ".prisma/client";
import { Role } from "@prisma/client";
export default function AgentMenu({ agent }: { agent: Agent }) {
const value = usePathname();
const { data: session } = useSession();
if (!session) {
return null;
}
const isAgentProvider = session.user.roles.includes("AGENT_PROVIDER");
const agentId = agent.id;
const isUnknown = agent?.status === "UNKNOWN";
const isOnline = agent?.status === "ONLINE";
return (
<div className="flex w-full justify-between border-b bg-background px-3 py-1">
<NavigationMenu defaultValue={value}>
<NavigationMenuList>
{!isUnknown && (
<>
<NavigationMenuItem>
<Link
href={`/agent/${agentId}/status`}
className={navigationMenuTriggerStyle()}
>
</Link>
</NavigationMenuItem>
{isAgentProvider && (
<>
<NavigationMenuItem>
<Link
href={`/agent/${agentId}/forward`}
className={navigationMenuTriggerStyle()}
>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger></NavigationMenuTrigger>
<NavigationMenuContent>
<div className="w-[300px] space-y-2 p-3">
<Link
href={`/agent/${agentId}/config/base`}
className={cn(
navigationMenuTriggerStyle(),
"flex space-x-2",
)}
>
<div className="font-medium leading-none">
</div>
<p className="line-clamp-2 text-xs leading-snug text-muted-foreground">
</p>
</Link>
<Link
href={`/agent/${agentId}/config/other`}
className={cn(
navigationMenuTriggerStyle(),
"flex space-x-2",
)}
>
<div className="font-medium leading-none">
</div>
<p className="line-clamp-2 text-xs leading-snug text-muted-foreground">
</p>
</Link>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<Link
href={`/agent/${agentId}/log`}
className={navigationMenuTriggerStyle()}
>
</Link>
</NavigationMenuItem>
</>
)}
</>
)}
{isAgentProvider && (
<NavigationMenuItem>
<Link
href={`/agent/${agentId}/install`}
className={navigationMenuTriggerStyle()}
>
</Link>
</NavigationMenuItem>
)}
</NavigationMenuList>
</NavigationMenu>
{isAgentProvider && (
<div className="flex flex-row">
{isOnline && session.user.roles.includes(Role.ADMIN) && (
<AgentCommand currentAgentId={agentId} />
)}
<AgentDelete currentAgentId={agentId} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,28 @@
import { api } from "~/trpc/server";
import { MoneyInput } from "~/lib/ui/money-input";
import { type ByteUnit, ByteUnitsShort } from "~/lib/utils";
export default async function AgentPrice({ agentId }: { agentId: string }) {
const agentPrice = await api.system.getConfig.query({
relationId: agentId,
key: "TRAFFIC_PRICE",
});
const globalPrice = await api.system.getConfig.query({
key: "TRAFFIC_PRICE",
});
const priceConfig = agentPrice.TRAFFIC_PRICE ?? globalPrice.TRAFFIC_PRICE;
if (!priceConfig) {
return null;
}
return (
<div className="flex w-full items-end gap-1">
<MoneyInput value={priceConfig.price} displayType="text" />
<span className="text-lg">/</span>
<span className="text-lg">
{ByteUnitsShort[priceConfig.unit as ByteUnit]}
</span>
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/lib/ui/resizable";
import AgentListAside from "~/app/(manage)/agent/_components/agent-list-aside";
import { type AgentGetAllOutput } from "~/lib/types/trpc";
import { type ReactNode, useRef } from "react";
import { useResizeObserver } from "~/lib/hooks/use-resize-observer";
interface AgentLayoutProps {
agents: AgentGetAllOutput;
agentId: string;
children: ReactNode;
}
export default function AgentResizableLayout({
agents,
agentId,
children,
}: AgentLayoutProps) {
const ref = useRef<HTMLDivElement>(null);
const { width = 0 } = useResizeObserver({
ref,
});
return (
<ResizablePanelGroup
direction="horizontal"
className="flex h-full flex-row"
>
<ResizablePanel
defaultSize={23}
minSize={15}
maxSize={30}
className="h-screen"
>
<div className="w-full" ref={ref}>
<AgentListAside
agents={agents}
agentId={agentId}
style={{ width: width === 0 ? "22%" : `${width}px` }}
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={77}>{children}</ResizablePanel>
</ResizablePanelGroup>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import {
Area,
AreaChart,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
} from "recharts";
import { type TooltipProps } from "recharts/types/component/Tooltip";
import { convertBytesToBestUnit } from "~/lib/utils";
export interface BandwidthStat {
date: string;
download: number;
upload: number;
}
export default function BandwidthUsage({ data }: { data: BandwidthStat[] }) {
const legendFormatter = (value: string) => {
return value === "download" ? "下载" : "上传";
};
const tooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
if (active && payload?.length) {
const [download, downloadUnit] = convertBytesToBestUnit(
payload[0]?.value ?? 0,
);
const [upload, uploadUnit] = convertBytesToBestUnit(
-(payload[1]?.value ?? 0),
);
return (
<div className="rounded-md bg-white p-4 shadow-md dark:bg-accent">
<p className="mb-2">{`${label}`}</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col space-y-2">
<span className="text-sm"></span>
<span className="text-sm"></span>
</div>
<div className="flex flex-col space-y-2">
<span className="text-sm">{`${download} ${downloadUnit}`}</span>
<span className="text-sm">{`${upload} ${uploadUnit}`}</span>
</div>
</div>
</div>
);
}
return null;
};
return (
<ResponsiveContainer width="100%" height="80%">
<AreaChart data={data} height={200}>
<defs>
<linearGradient id="colorDownload" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#c084fc" stopOpacity={0.9} />
<stop offset="95%" stopColor="#c084fc" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorUpload" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#5eead4" stopOpacity={0} />
<stop offset="95%" stopColor="#5eead4" stopOpacity={1} />
</linearGradient>
</defs>
<XAxis dataKey="date" display="none" />
<Tooltip<number, string> content={tooltip} />
<Legend verticalAlign="top" height={36} formatter={legendFormatter} />
<Area
type="monotone"
dataKey="download"
stroke="#c084fc"
strokeWidth={2}
fill="url(#colorDownload)"
/>
<Area
type="monotone"
dataKey="upload"
stroke="#5eead4"
strokeWidth={2}
fill="url(#colorUpload)"
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,48 @@
"use client";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import type { TooltipProps } from "recharts/types/component/Tooltip";
export interface CpuStat {
date: string;
percent: number;
}
export default function CpuUsage({ data }: { data: CpuStat[] }) {
const tooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
if (active && payload?.length) {
return (
<div className="rounded-md bg-white p-4 shadow-md dark:bg-accent">
<p>{`${label} ${payload[0]!.value}%`}</p>
</div>
);
}
return null;
};
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} height={100}>
<defs>
<linearGradient id="cpuUsageGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" display="none" />
<Tooltip<number, string> content={tooltip} />
<Area
type="linear"
dataKey="percent"
stroke="#22d3ee"
fillOpacity={1}
fill="url(#cpuUsageGradient)"
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,49 @@
"use client";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import type { TooltipProps } from "recharts/types/component/Tooltip";
export interface MemStat {
date: string;
percent: number;
used: number;
}
export default function MemUsage({ data }: { data: MemStat[] }) {
const tooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
if (active && payload?.length) {
return (
<div className="rounded-md bg-white p-4 shadow-md dark:bg-accent">
<p className="mb-2">{`${label} ${payload[0]!.value}% `}</p>
</div>
);
}
return null;
};
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} height={100}>
<defs>
<linearGradient id="memUsageGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#fde68a" stopOpacity={0.3} />
<stop offset="95%" stopColor="#fde68a" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" display="none" />
<Tooltip<number, string> content={tooltip} />
<Area
type="linear"
dataKey="percent"
stroke="#86efac"
fillOpacity={1}
fill="url(#memUsageGradient)"
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,85 @@
"use client";
import {
Bar,
BarChart,
LabelList,
ResponsiveContainer,
Tooltip,
XAxis,
} from "recharts";
import type { TooltipProps } from "recharts/types/component/Tooltip";
export interface TrafficStat {
download: number;
downloadUnit: string;
upload: number;
uploadUnit: string;
}
export default function TrafficUsage({ stat }: { stat: TrafficStat }) {
const data = [
{
type: "下载",
traffic: stat.download,
},
{
type: "上传",
traffic: stat.upload,
},
];
const tooltip = ({
active,
payload,
label,
}: TooltipProps<number, string>) => {
if (active && payload?.length) {
const unit = label == "下载" ? stat.downloadUnit : stat.uploadUnit;
return (
<div className="rounded-md bg-white p-4 shadow-md dark:bg-accent">
<p className="mb-2">{`${label} ${payload[0]!.value} ${unit}`}</p>
</div>
);
}
return null;
};
return (
<ResponsiveContainer width="100%" height="80%">
<BarChart data={data} barCategoryGap="30%" height={300}>
<XAxis
dataKey="type"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<defs>
<linearGradient id="trafficUsageGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.6} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<Tooltip<number, string> content={tooltip} />
<Bar
dataKey="traffic"
fill="url(#trafficUsageGradient)"
className="bg-gradient-to-t from-indigo-500"
>
<LabelList
dataKey="traffic"
position="insideTop"
className="fill-white"
formatter={(v: number, i: number) => {
return `${v} ${
data[i]?.type == "下载" ? stat.downloadUnit : stat.uploadUnit
}`;
}}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,42 @@
import AgentResizableLayout from "~/app/(manage)/agent/_components/agent-resizable-layout";
import { api } from "~/trpc/server";
import { redirect, RedirectType } from "next/navigation";
import { AgentStatus } from "@prisma/client";
export default async function AgentPage() {
const agents = await api.agent.getAll.query(undefined);
if (Object.values(agents).flat().length > 0) {
let agent;
if (agents.ONLINE.length > 0) {
agent = agents.ONLINE[0];
} else if (agents.OFFLINE.length > 0) {
agent = agents.OFFLINE[0];
} else if (agents.UNKNOWN.length > 0) {
agent = agents.UNKNOWN[0];
}
if (agent) {
redirect(
`/agent/${agent.id}/${
agent.status === AgentStatus.UNKNOWN ? "install" : "status"
}`,
RedirectType.replace,
);
}
}
return (
<AgentResizableLayout agents={agents} agentId={""}>
<div className="h-full w-full flex-1">
<div className="flex h-full flex-1 flex-col items-center justify-center">
<video autoPlay muted loop className="w-[60%]">
<source src="/lllustration.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
<h2 className="mt-4 text-xl text-gray-500">
No agents found. Please install the agent on a device to get
started.
</h2>
</div>
</div>
</AgentResizableLayout>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import { api } from "~/trpc/react";
import { Label } from "~/lib/ui/label";
import { Progress } from "~/lib/ui/progress";
import { convertBytesToBestUnit } from "~/lib/utils";
import { Line, LineChart, ResponsiveContainer, Tooltip } from "recharts";
import { useEffect, useState } from "react";
import { MoveDownIcon, MoveUpIcon } from "lucide-react";
import type { TooltipProps } from "recharts/types/component/Tooltip";
export default function SystemStatus() {
const [networks, setNetworks] = useState<
{
upload: number;
download: number;
}[]
>(() => {
return new Array(10).fill({ upload: 0, download: 0 });
});
const { data } = api.system.getSystemStatus.useQuery(undefined, {
refetchInterval: 5000,
});
useEffect(() => {
if (data) {
setNetworks((networks) => {
networks.push({
upload: data.network.upload ?? 0,
download: data.network.download ?? 0,
});
if (networks.length > 10) {
networks.shift();
}
return networks;
});
}
}, [data]);
const [upload, uploadUnit] = convertBytesToBestUnit(
data?.network.upload ?? 0,
);
const [download, downloadUnit] = convertBytesToBestUnit(
data?.network.download ?? 0,
);
const tooltip = ({ active, payload }: TooltipProps<number, string>) => {
if (active && payload?.length) {
const [download, downloadUnit] = convertBytesToBestUnit(
payload[0]?.value ?? 0,
);
const [upload, uploadUnit] = convertBytesToBestUnit(
payload[1]?.value ?? 0,
);
return (
<div className="rounded-md bg-white p-4 shadow-md dark:bg-accent">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col space-y-2">
<span className="text-sm"></span>
<span className="text-sm"></span>
</div>
<div className="flex flex-col space-y-2">
<span className="text-sm">{`${download} ${downloadUnit}`}</span>
<span className="text-sm">{`${Number(
upload,
)} ${uploadUnit}`}</span>
</div>
</div>
</div>
);
}
return null;
};
return (
<div className="flex h-full w-full flex-col gap-3 lg:flex-row lg:p-4">
<div className="space-y-4 lg:w-[200px]">
<div className="flex flex-col space-y-2">
<Label className="block">CPU</Label>
<div className="flex items-center space-x-2">
<Progress value={data?.load} />
<span className="text-sm">{data?.load.toFixed(2)}%</span>
</div>
</div>
<div className="flex flex-col space-y-2">
<Label className="block"></Label>
<div className="flex items-center space-x-2">
<Progress value={data?.mem} />
<span className="text-sm">{data?.mem.toFixed(2)}%</span>
</div>
</div>
</div>
<div className="flex flex-1 flex-col items-center gap-1">
<div className="relative flex w-full items-center justify-center gap-1">
<Label className="absolute left-0"></Label>
<div className="before:content flex items-center before:h-2 before:w-2 before:bg-[#fef08a]">
<MoveDownIcon className="h-3 w-3" />
<span className="text-sm">
{download} {downloadUnit}
</span>
</div>
<div className="before:content ml-3 flex items-center before:h-2 before:w-2 before:bg-[#38bdf8]">
<MoveUpIcon className="h-3 w-3" />
<span className="text-sm">
{upload} {uploadUnit}
</span>
</div>
</div>
<ResponsiveContainer width="80%" height="80%">
<LineChart
width={300}
height={100}
data={networks}
key={`rc_${networks[0]?.upload}_${networks[0]?.download}`}
>
<Tooltip<number, string> content={tooltip} />
<Line
dataKey="download"
stroke="#fef08a"
strokeWidth={2}
dot={false}
/>
<Line
dataKey="upload"
stroke="#38bdf8"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
"use client";
import { Line, LineChart, ResponsiveContainer, Tooltip } from "recharts";
import type { TooltipProps } from "recharts/types/component/Tooltip";
import { convertBytesToBestUnit } from "~/lib/utils";
import { api } from "~/trpc/react";
import dayjs from "dayjs";
import { useMemo } from "react";
export default function UserTrafficUsage() {
const startDate = dayjs().subtract(7, "day").startOf("day").toDate();
const endDate = dayjs().endOf("day").toDate();
const { data } = api.forward.trafficUsage.useQuery({
startDate: startDate,
endDate: endDate,
dimensions: "user",
});
const trafficUsage = useMemo(() => {
if (!data) return [];
return data.map((item) => {
return {
date: item.date,
traffic: item.download + item.upload,
};
});
}, [data]);
const tooltip = ({ active, payload }: TooltipProps<number, string>) => {
if (active && payload?.length) {
const [traffic, trafficUnit] = convertBytesToBestUnit(
payload[0]?.value ?? 0,
);
return (
<div className="rounded-md bg-white p-4 shadow-md dark:bg-accent">
<p className="mb-2">{`${payload[0]?.payload.date}`}</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col space-y-2">
<span className="text-sm">使</span>
</div>
<div className="flex flex-col space-y-2">
<span className="text-sm">{`${traffic} ${trafficUnit}`}</span>
</div>
</div>
</div>
);
}
return null;
};
return (
<ResponsiveContainer width="80%" height="70%">
<LineChart width={300} height={100} data={trafficUsage}>
<Tooltip<number, string> content={tooltip} />
<Line
type="monotone"
dataKey="traffic"
stroke="#8884d8"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@ -0,0 +1,187 @@
import Image from "next/image";
import { getServerSession } from "next-auth";
import { authOptions } from "~/server/auth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/lib/ui/card";
import Link from "next/link";
import UserTrafficUsage from "~/app/(manage)/dashboard/_components/traffic-usage";
import SystemStatus from "~/app/(manage)/dashboard/_components/system-status";
import { api } from "~/trpc/server";
import { MoneyInput } from "~/lib/ui/money-input";
import { MoreHorizontalIcon } from "lucide-react";
import Markdown from "react-markdown";
import { Dialog, DialogContent, DialogTrigger } from "~/lib/ui/dialog";
import { type RouterOutputs } from "~/trpc/shared";
export const metadata = {
title: "Dashboard - vortex",
};
export default async function Dashboard() {
const session = await getServerSession(authOptions);
if (!session) {
return null;
}
const wallet = await api.user.getWallet.query({ id: session.user.id });
const yesterdayBalanceChange = await api.user.getYesterdayBalanceChange.query(
{ id: session.user.id },
);
const { ANNOUNCEMENT: announcement } = await api.system.getConfig.query({
key: "ANNOUNCEMENT",
});
return (
<div className="flex h-full flex-col p-4 lg:h-screen">
<div className="mb-6 flex items-end">
<h1 className="mr-2 text-2xl text-muted-foreground">Welcome,</h1>
<Link href={`/user/${session.user.id}`}>
<h1 className="text-2xl hover:underline">
{session.user?.name ?? session.user?.email}
</h1>
</Link>
<Image
src="/3d-fluency-hugging-face.png"
alt="3D Fluency Hugging Face"
width={70}
height={70}
/>
</div>
<div className="flex-grow grid-cols-3 lg:grid lg:space-x-4">
<div className="col-span-2 flex flex-col">
<Card className="h-[300px] lg:h-3/5">
<CardHeader>
<CardTitle>使</CardTitle>
<CardDescription>7使</CardDescription>
</CardHeader>
<CardContent className="relative h-full w-full">
<UserTrafficUsage />
<Image
className="absolute -right-[4rem] top-0 w-[200px] lg:w-[300px]"
src="/techny-rocket.gif"
alt="Techny Rocket"
width={300}
height={150}
/>
</CardContent>
</Card>
<Card className="mt-4 flex-grow">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="flex">
<Image
className="hidden lg:block"
src="/isometric-server-transferring-data.gif"
alt="Isometric Server Transferring Data"
width={170}
height={170}
/>
<SystemStatus />
</CardContent>
</Card>
</div>
<div className="col-span-1 mt-4 flex flex-col lg:mt-0">
<Card className="grid grid-rows-2 lg:h-2/5">
<UserBalance
wallet={wallet}
yesterdayBalanceChange={yesterdayBalanceChange}
userId={session.user.id}
/>
</Card>
<Card className="mt-4 flex-grow overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
{announcement && (
<AnnouncementDialog announcement={announcement} />
)}
</CardHeader>
<CardContent className="max-h-[300px] w-full p-4">
{announcement ? (
<Markdown className="markdown overflow-hidden">
{announcement}
</Markdown>
) : (
<p></p>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function UserBalance({
userId,
wallet,
yesterdayBalanceChange,
}: {
userId: string;
wallet: RouterOutputs["user"]["getWallet"];
yesterdayBalanceChange: RouterOutputs["user"]["getYesterdayBalanceChange"];
}) {
return (
<>
<div className="flex flex-col border-b px-6 py-3">
<div className="flex justify-between">
<h3 className="text-2xl font-semibold leading-none tracking-tight">
</h3>
<Link href={`/user/${userId}/balance`}>
<MoreHorizontalIcon className="h-6 w-6 cursor-pointer hover:bg-muted" />
</Link>
</div>
<div className="flex h-full items-center space-x-6">
<MoneyInput
displayType="text"
value={wallet.balance.toNumber() ?? 0.0}
className="bg-gradient-to-r from-blue-500 to-green-500 bg-clip-text text-5xl text-transparent"
/>
<div className="flex items-center space-x-2">
<span className="text-muted-foreground"></span>
<span className="text-2xl">
{yesterdayBalanceChange?.CONSUMPTION?.toNumber() ?? 0}
</span>
</div>
</div>
</div>
<div className="flex flex-col px-6 py-3">
<h3 className="text-2xl font-semibold leading-none tracking-tight">
</h3>
<div className="flex h-full items-center space-x-6">
<MoneyInput
displayType="text"
value={wallet.incomeBalance.toNumber() ?? 0.0}
className="bg-gradient-to-r from-blue-500 to-pink-500 bg-clip-text text-5xl text-transparent"
/>
<div className=" flex items-center space-x-2">
<span className="text-muted-foreground"></span>
<span className="text-2xl">
{yesterdayBalanceChange?.INCOME?.toNumber() ?? 0}
</span>
</div>
</div>
</div>
</>
);
}
function AnnouncementDialog({ announcement }: { announcement: string }) {
return (
<Dialog>
<DialogTrigger asChild={true}>
<MoreHorizontalIcon className="h-6 w-6 cursor-pointer hover:bg-muted" />
</DialogTrigger>
<DialogContent className="h-full w-full md:h-auto md:max-w-[60%]">
<Markdown className="markdown mt-3">{announcement}</Markdown>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,64 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/lib/ui/alert-dialog";
import { type ReactNode } from "react";
import { api } from "~/trpc/react";
import { useTrack } from "~/lib/hooks/use-track";
export default function ForwardDelete({
trigger,
forwardId,
}: {
trigger: ReactNode;
forwardId: string;
}) {
const utils = api.useUtils();
const deleteMutation = api.forward.delete.useMutation({
onSuccess: (data) => {
if (data.result.success) {
void utils.forward.getAll.refetch();
}
},
});
const { track } = useTrack();
function handleDelete() {
track("forward-delete-button", {
forwardId: forwardId,
});
void deleteMutation
.mutateAsync({
id: forwardId,
})
.then(() => {
window.location.reload();
});
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,66 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/lib/ui/alert-dialog";
import { type ReactNode, useState } from "react";
import { api } from "~/trpc/react";
import { Textarea } from "~/lib/ui/textarea";
import { useTrack } from "~/lib/hooks/use-track";
export default function ForwardModifyRemark({
trigger,
forwardId,
remark: originRemark,
}: {
trigger: ReactNode;
forwardId: string;
remark: string | null;
}) {
const [remark, setRemark] = useState(originRemark ?? "");
const updateRemarkMutation = api.forward.updateRemark.useMutation();
const { track } = useTrack();
function handleUpdateRemark() {
track("forward-update-remark-button", {
forwardId: forwardId,
remark: remark,
});
void updateRemarkMutation
.mutateAsync({
id: forwardId,
remark: remark,
})
.then(() => {
window.location.reload();
});
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<Textarea
placeholder="在这里输入你的备注"
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
/>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleUpdateRemark}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,45 @@
import { z } from "zod";
import { ForwardMethod } from ".prisma/client";
import { type UseFormReturn } from "react-hook-form";
export const forwardFormSchema = z.object({
agentId: z.string().min(1, {
message: "请选择中转服务器",
}),
method: z.nativeEnum(ForwardMethod),
options: z.any().optional(),
agentPort: z
.preprocess(
(a) => (a ? parseInt(z.string().parse(a), 10) : undefined),
z
.number()
.positive()
.min(1, {
message: "监听端口必须大于 0",
})
.max(65535, {
message: "监听端口必须小于 65536",
}),
)
.optional(),
targetPort: z.preprocess(
(a) => (a ? parseInt(z.string().parse(a), 10) : undefined),
z
.number()
.positive()
.min(1, {
message: "目标端口必须大于 0",
})
.max(65535, {
message: "目标端口必须小于 65536",
}),
),
target: z.string().min(1, {
message: "转发目标不能为空",
}),
remark: z.string().optional(),
});
export type ForwardFormValues = z.infer<typeof forwardFormSchema>;
export type ForwardForm = UseFormReturn<ForwardFormValues>;

View File

@ -0,0 +1,89 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/lib/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/lib/ui/select";
import { type ForwardForm } from "~/app/(manage)/forward/_components/forward-new-form-schema";
import { GostChannelOptions, GostProtocolOptions } from "~/lib/constants";
import { WithDescSelector } from "~/app/_components/with-desc-selector";
export default function ForwardNewGost({ form }: { form: ForwardForm }) {
return (
<>
<FormField
control={form.control}
name="options"
render={({ field }) => (
<>
<FormItem>
<FormLabel></FormLabel>
<FormDescription></FormDescription>
<Select
onValueChange={(v) =>
field.onChange({
...field.value,
protocol: v,
})
}
defaultValue={field.value?.protocol}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{GostProtocolOptions.map((protocol, index) => (
<SelectItem value={protocol.value} key={index}>
{protocol.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormDescription></FormDescription>
<WithDescSelector
options={GostChannelOptions}
value={field.value?.channel}
onChange={(v) =>
field.onChange({
...field.value,
channel: v,
})
}
/>
<FormMessage />
</FormItem>
{/*<FormItem>*/}
{/* <FormLabel>多路复用</FormLabel>*/}
{/* <FormDescription>*/}
{/* 选择后开启*/}
{/* </FormDescription>*/}
{/* <Switch*/}
{/* checked={field.value?.mux === "true"}*/}
{/* onCheckedChange={(e) => field.onChange({*/}
{/* ...field.value,*/}
{/* mux: e ? "true" : "false",*/}
{/* })}*/}
{/* />*/}
{/* <FormMessage />*/}
{/*</FormItem>*/}
</>
)}
/>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More