🎉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>*/}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|