🎉Initial commit
43
.dockerignore
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,5 @@
|
|||
.idea
|
||||
.next
|
||||
node_modules
|
||||
|
||||
.prisma/migrations/
|
21
LICENSE
Normal 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
|
@ -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
|
@ -0,0 +1 @@
|
|||
module.exports = { presets: ["@babel/preset-env"] };
|
17
components.json
Normal 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
|
@ -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"]
|
47
docker/docker-compose.test.yml
Normal 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
|
35
docker/docker-compose.umami.yml
Normal 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
|
@ -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
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
prisma migrate deploy
|
||||
|
||||
node server.js
|
2296
docker/redis.conf
Normal file
27
jest.config.mjs
Normal 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
|
@ -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
127
package.json
Normal 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
|
@ -0,0 +1,8 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
6
prettier.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
const config = {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
export default config;
|
394
prisma/migrations/20240315073121_init/migration.sql
Normal 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;
|
3
prisma/migrations/migration_lock.toml
Normal 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
|
@ -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])
|
||||
}
|
BIN
public/3d-casual-life-screwdriver-and-wrench-as-settings.webm
Normal file
BIN
public/3d-fluency-face-screaming-in-fear.png
Normal file
After Width: | Height: | Size: 6.1 MiB |
BIN
public/3d-fluency-hugging-face.png
Normal file
After Width: | Height: | Size: 6.7 MiB |
BIN
public/casual-life-3d-boy-with-magnifier-in-hand.png
Normal file
After Width: | Height: | Size: 4.0 MiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
public/isometric-server-transferring-data.gif
Normal file
After Width: | Height: | Size: 12 MiB |
BIN
public/lllustration.mp4
Normal file
BIN
public/loading.gif
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
public/logo-3d.png
Normal file
After Width: | Height: | Size: 349 KiB |
BIN
public/logo-flat-black.png
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
public/logo-flat.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/logo-grey.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
public/people-wave.png
Normal file
After Width: | Height: | Size: 168 KiB |
BIN
public/techny-rocket.gif
Normal file
After Width: | Height: | Size: 4.4 MiB |
14
public/user-profile.svg
Normal file
After Width: | Height: | Size: 1.4 MiB |
42
src/app/(manage)/admin/config/[classify]/page.tsx
Normal 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} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
161
src/app/(manage)/admin/config/_components/config-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
src/app/(manage)/admin/config/_components/config-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
256
src/app/(manage)/admin/config/_components/cron-input.tsx
Normal 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>
|
||||
// );
|
||||
// }
|
110
src/app/(manage)/admin/config/_components/payment-info.tsx
Normal 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>
|
||||
);
|
||||
}
|
307
src/app/(manage)/admin/config/_components/payment-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
204
src/app/(manage)/admin/config/_components/withdrawal-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
55
src/app/(manage)/admin/config/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
src/app/(manage)/admin/config/page.tsx
Normal 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} />;
|
||||
}
|
5
src/app/(manage)/admin/config/payment/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import PaymentTable from "~/app/(manage)/admin/config/_components/payment-table";
|
||||
|
||||
export default function PaymentPage() {
|
||||
return <PaymentTable />;
|
||||
}
|
30
src/app/(manage)/admin/config/recharge-code/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
5
src/app/(manage)/admin/config/withdraw/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import WithdrawalTable from "~/app/(manage)/admin/config/_components/withdrawal-table";
|
||||
|
||||
export default function WithdrawalPage() {
|
||||
return <WithdrawalTable />;
|
||||
}
|
61
src/app/(manage)/admin/log/_components/log-delete.tsx
Normal 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>
|
||||
);
|
||||
}
|
128
src/app/(manage)/admin/log/_components/log-glance.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
103
src/app/(manage)/admin/log/_components/log-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
105
src/app/(manage)/admin/log/_components/log.tsx
Normal 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>
|
||||
);
|
||||
}
|
99
src/app/(manage)/admin/log/_components/logs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
14
src/app/(manage)/admin/log/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
71
src/app/(manage)/admin/log/store/log-store.ts
Normal 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 });
|
||||
},
|
||||
}));
|
|
@ -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>
|
||||
);
|
||||
}
|
47
src/app/(manage)/admin/users/_components/user-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
164
src/app/(manage)/admin/users/_components/user-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
src/app/(manage)/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
src/app/(manage)/agent/[agentId]/config/base/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
27
src/app/(manage)/agent/[agentId]/config/other/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
17
src/app/(manage)/agent/[agentId]/forward/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
13
src/app/(manage)/agent/[agentId]/install/page.tsx
Normal 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} />;
|
||||
}
|
45
src/app/(manage)/agent/[agentId]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
15
src/app/(manage)/agent/[agentId]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
17
src/app/(manage)/agent/[agentId]/log/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
183
src/app/(manage)/agent/[agentId]/status/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
143
src/app/(manage)/agent/_components/agent-command.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
src/app/(manage)/agent/_components/agent-config.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
60
src/app/(manage)/agent/_components/agent-delete.tsx
Normal 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>
|
||||
);
|
||||
}
|
130
src/app/(manage)/agent/_components/agent-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
116
src/app/(manage)/agent/_components/agent-install.tsx
Normal 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>
|
||||
);
|
||||
}
|
99
src/app/(manage)/agent/_components/agent-list-aside.tsx
Normal 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>
|
||||
);
|
||||
}
|
56
src/app/(manage)/agent/_components/agent-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
124
src/app/(manage)/agent/_components/agent-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
28
src/app/(manage)/agent/_components/agent-price.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
88
src/app/(manage)/agent/_components/bandwidth-usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
src/app/(manage)/agent/_components/cpu-usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
49
src/app/(manage)/agent/_components/mem-usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
85
src/app/(manage)/agent/_components/traffic-usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
42
src/app/(manage)/agent/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
135
src/app/(manage)/dashboard/_components/system-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
64
src/app/(manage)/dashboard/_components/traffic-usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
187
src/app/(manage)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
64
src/app/(manage)/forward/_components/forward-delete.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
89
src/app/(manage)/forward/_components/forward-new-gost.tsx
Normal 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>*/}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|