feat: add Monaco editor, API error handling, and CI/CD
- Monaco Editor: script editor with syntax highlighting - API: error handling with ApiError class and token management - CI/CD: Gitea Actions workflow for build, Docker, and deploy - Docker: multi-stage build with nginx - nginx.conf: SPA fallback and API proxy
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: git.viaeon.com
|
||||
IMAGE_NAME: admin/taskpool-react
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: web/dist
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: web/dist
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
|
||||
deploy:
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
password: ${{ secrets.SSH_PASSWORD }}
|
||||
script: |
|
||||
cd /opt/taskpool
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker image prune -f
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
# Copy package files
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY web/ ./
|
||||
|
||||
# Build
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
+17
-21
@@ -1,25 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
baihu:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
frontend:
|
||||
image: git.viaeon.com/admin/taskpool-react:latest
|
||||
ports:
|
||||
- "8052:8052"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./configs:/app/configs
|
||||
- ./envs:/app/envs
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: git.viaeon.com/admin/taskpool:latest
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# 以下环境变量可覆盖配置文件(可选)
|
||||
# - BH_SERVER_PORT=8052
|
||||
# - BH_SERVER_HOST=0.0.0.0
|
||||
# - BH_DB_TYPE=mysql
|
||||
# - BH_DB_HOST=localhost
|
||||
# - BH_DB_PORT=3306
|
||||
# - BH_DB_USER=root
|
||||
# - BH_DB_PASSWORD=password
|
||||
# - BH_DB_NAME=baihu
|
||||
# - BH_DB_TABLE_PREFIX=baihu_
|
||||
# - BH_SECRET=your_secret_key
|
||||
- DB_TYPE=sqlite
|
||||
- DB_PATH=/data/taskpool.db
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -9,11 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"@tanstack/react-table": "^8.21.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.2.7",
|
||||
|
||||
Generated
+57
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.80.0
|
||||
version: 5.101.2(react@19.2.7)
|
||||
@@ -85,6 +88,16 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.2':
|
||||
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
'@monaco-editor/react@4.7.0':
|
||||
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
|
||||
peerDependencies:
|
||||
monaco-editor: '>= 0.25.0 < 1'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
@@ -276,6 +289,9 @@ packages:
|
||||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@vitejs/plugin-react@6.0.3':
|
||||
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -381,6 +397,9 @@ packages:
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
electron-to-chromium@1.5.393:
|
||||
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
|
||||
|
||||
@@ -523,6 +542,14 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
marked@14.0.0:
|
||||
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||
|
||||
motion-dom@11.18.1:
|
||||
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
|
||||
|
||||
@@ -619,6 +646,9 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
tailwindcss@4.3.3:
|
||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||
|
||||
@@ -735,6 +765,17 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
|
||||
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@monaco-editor/loader': 1.7.0
|
||||
monaco-editor: 0.55.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.1
|
||||
@@ -878,6 +919,9 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
@@ -963,6 +1007,10 @@ snapshots:
|
||||
'@babel/runtime': 7.29.7
|
||||
csstype: 3.2.3
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
electron-to-chromium@1.5.393: {}
|
||||
|
||||
escalade@3.2.0: {}
|
||||
@@ -1054,6 +1102,13 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
marked@14.0.0: {}
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
marked: 14.0.0
|
||||
|
||||
motion-dom@11.18.1:
|
||||
dependencies:
|
||||
motion-utils: 11.18.1
|
||||
@@ -1160,6 +1215,8 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
tailwindcss@4.3.3: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
+84
-29
@@ -1,21 +1,40 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from '@/api/endpoints'
|
||||
import * as api from './index'
|
||||
import type * as types from './types'
|
||||
|
||||
// Auth
|
||||
// Re-export types
|
||||
export type * from './types'
|
||||
|
||||
// Auth hooks
|
||||
export function useUser() {
|
||||
return useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: api.getCurrentUser,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export function useLogin() {
|
||||
return useMutation({
|
||||
mutationFn: ({ username, password }: { username: string; password: string }) =>
|
||||
api.login(username, password),
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
return useMutation({
|
||||
mutationFn: api.logout,
|
||||
})
|
||||
}
|
||||
|
||||
// Dashboard hooks
|
||||
export function useMonitorStats() {
|
||||
return useQuery({
|
||||
queryKey: ['monitor'],
|
||||
queryFn: api.getMonitor,
|
||||
queryFn: api.getMonitorStats,
|
||||
refetchInterval: 5000,
|
||||
retry: 2,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,8 +46,8 @@ export function useTaskStats() {
|
||||
})
|
||||
}
|
||||
|
||||
// Tasks
|
||||
export function useTasks(params?: { status?: string; tag?: string }) {
|
||||
// Task hooks
|
||||
export function useTasks(params?: Record<string, string>) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', params],
|
||||
queryFn: () => api.getTasks(params),
|
||||
@@ -47,15 +66,17 @@ export function useCreateTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.createTask,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['tasks'] })
|
||||
qc.invalidateQueries({ queryKey: ['taskStats'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Parameters<typeof api.updateTask>[1] }) =>
|
||||
api.updateTask(id, data),
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateTask(id, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
})
|
||||
}
|
||||
@@ -64,7 +85,10 @@ export function useDeleteTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.deleteTask,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['tasks'] })
|
||||
qc.invalidateQueries({ queryKey: ['taskStats'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,7 +100,14 @@ export function useExecuteTask() {
|
||||
})
|
||||
}
|
||||
|
||||
// Scripts
|
||||
export function useTags() {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: api.getTags,
|
||||
})
|
||||
}
|
||||
|
||||
// Script hooks
|
||||
export function useScripts() {
|
||||
return useQuery({
|
||||
queryKey: ['scripts'],
|
||||
@@ -100,6 +131,14 @@ export function useCreateScript() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateScript() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateScript(id, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['scripts'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteScript() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
@@ -108,15 +147,39 @@ export function useDeleteScript() {
|
||||
})
|
||||
}
|
||||
|
||||
// Logs
|
||||
export function useLogs(params?: { task_id?: number; status?: string; page?: number }) {
|
||||
// Env hooks
|
||||
export function useEnvs() {
|
||||
return useQuery({
|
||||
queryKey: ['envs'],
|
||||
queryFn: api.getEnvs,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateEnv() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.createEnv,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteEnv() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.deleteEnv,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||
})
|
||||
}
|
||||
|
||||
// Log hooks
|
||||
export function useLogs(params?: Record<string, string>) {
|
||||
return useQuery({
|
||||
queryKey: ['logs', params],
|
||||
queryFn: () => api.getLogs(params),
|
||||
})
|
||||
}
|
||||
|
||||
// Interconnect
|
||||
// Node hooks
|
||||
export function useNodes() {
|
||||
return useQuery({
|
||||
queryKey: ['nodes'],
|
||||
@@ -141,26 +204,18 @@ export function useDeleteNode() {
|
||||
})
|
||||
}
|
||||
|
||||
// Env
|
||||
export function useEnvs() {
|
||||
// Settings hooks
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['envs'],
|
||||
queryFn: api.getEnvs,
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateEnv() {
|
||||
export function useUpdateSettings() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.createEnv,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteEnv() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.deleteEnv,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||
mutationFn: api.updateSettings,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
})
|
||||
}
|
||||
+159
-18
@@ -2,32 +2,173 @@ const BASE_URL = (window as any).__BASE_URL__ || ''
|
||||
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
|
||||
const API_BASE_URL = BASE_URL + API_VERSION
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: number
|
||||
data?: any
|
||||
|
||||
constructor(message: string, code: number, data?: any) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Handle non-JSON responses
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType?.includes('application/json')) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
throw new ApiError('Unauthorized', 401)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`HTTP ${response.status}`, response.status)
|
||||
}
|
||||
return {} as T
|
||||
}
|
||||
|
||||
const result: ApiResponse<T> = await response.json()
|
||||
|
||||
// Business error
|
||||
if (result.code !== 0 && result.code !== 200) {
|
||||
throw new ApiError(result.message || 'Request failed', result.code, result.data)
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error
|
||||
}
|
||||
// Network error
|
||||
throw new ApiError(
|
||||
error instanceof Error ? error.message : 'Network error',
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const login = async (username: string, password: string) => {
|
||||
const data = await request<{ token: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
localStorage.setItem('token', data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
const json: ApiResponse<T> = await res.json()
|
||||
|
||||
if (json.code === 401) {
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error(json.msg || 'Unauthorized')
|
||||
export const logout = async () => {
|
||||
try {
|
||||
await request('/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(json.msg || 'Request failed')
|
||||
}
|
||||
export const getCurrentUser = () => request<{ id: number; username: string; role: string }>('/auth/me')
|
||||
|
||||
return json.data
|
||||
}
|
||||
// Dashboard
|
||||
export const getMonitorStats = () => request<any>('/monitor')
|
||||
export const getTaskStats = () => request<{ total: number; running: number; scheduled: number }>('/taskstats')
|
||||
export const getSentence = () => request<{ content: string; author: string }>('/sentence')
|
||||
|
||||
// Tasks
|
||||
export const getTasks = (params?: Record<string, string>) => {
|
||||
const query = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||
return request<any[]>(`/tasks${query}`)
|
||||
}
|
||||
|
||||
export const getTask = (id: number) => request<any>(`/tasks/${id}`)
|
||||
|
||||
export const createTask = (data: any) => request<any>('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
export const updateTask = (id: number, data: any) => request<any>(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
export const deleteTask = (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' })
|
||||
|
||||
export const executeTask = (id: number) => request<void>(`/execute/task/${id}`, { method: 'POST' })
|
||||
|
||||
export const getTags = () => request<string[]>('/tasks/tags')
|
||||
|
||||
// Scripts
|
||||
export const getScripts = () => request<any[]>('/scripts')
|
||||
|
||||
export const getScript = (id: number) => request<any>(`/scripts/${id}`)
|
||||
|
||||
export const createScript = (data: any) => request<any>('/scripts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
export const updateScript = (id: number, data: any) => request<any>(`/scripts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
export const deleteScript = (id: number) => request<void>(`/scripts/${id}`, { method: 'DELETE' })
|
||||
|
||||
// Environment Variables
|
||||
export const getEnvs = () => request<any[]>('/env')
|
||||
|
||||
export const createEnv = (data: any) => request<any>('/env', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
export const deleteEnv = (id: number) => request<void>(`/env/${id}`, { method: 'DELETE' })
|
||||
|
||||
// Logs
|
||||
export const getLogs = (params?: Record<string, string>) => {
|
||||
const query = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||
return request<any[]>(`/logs${query}`)
|
||||
}
|
||||
|
||||
// Interconnect Nodes
|
||||
export const getNodes = () => request<any[]>('/interconnect/nodes')
|
||||
|
||||
export const createNode = (data: any) => request<any>('/interconnect/nodes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
export const deleteNode = (id: string) => request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
|
||||
|
||||
// Settings
|
||||
export const getSettings = () => request<any>('/settings')
|
||||
export const updateSettings = (data: any) => request<any>('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
export { request }
|
||||
+208
-128
@@ -1,166 +1,246 @@
|
||||
import { useScripts, useDeleteScript } from '@/api/hooks'
|
||||
import { Plus, Trash2, FileCode, RefreshCw } from 'lucide-react'
|
||||
import type { Script } from '@/api/types'
|
||||
import { useState } from 'react'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import { useScripts, useCreateScript, useDeleteScript } from '@/api/hooks'
|
||||
import { Plus, Trash2, Save, FileCode, Play, FolderOpen } from 'lucide-react'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ id: 'python', name: 'Python', extension: '.py', monaco: 'python' },
|
||||
{ id: 'javascript', name: 'JavaScript', extension: '.js', monaco: 'javascript' },
|
||||
{ id: 'typescript', name: 'TypeScript', extension: '.ts', monaco: 'typescript' },
|
||||
{ id: 'bash', name: 'Bash', extension: '.sh', monaco: 'shell' },
|
||||
{ id: 'go', name: 'Go', extension: '.go', monaco: 'go' },
|
||||
]
|
||||
|
||||
export default function Scripts() {
|
||||
const { data: scripts, isLoading, refetch } = useScripts()
|
||||
const createScript = useCreateScript()
|
||||
const deleteScript = useDeleteScript()
|
||||
|
||||
const [selectedScript, setSelectedScript] = useState<any>(null)
|
||||
const [code, setCode] = useState('')
|
||||
const [language, setLanguage] = useState('python')
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const handleNewScript = () => {
|
||||
setSelectedScript(null)
|
||||
setCode('# New script\nprint("Hello World")')
|
||||
setName('untitled')
|
||||
setLanguage('python')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
await createScript.mutateAsync({
|
||||
name,
|
||||
filename: name + LANGUAGES.find(l => l.id === language)?.extension,
|
||||
content: code,
|
||||
language,
|
||||
})
|
||||
refetch()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('Delete this script?')) {
|
||||
await deleteScript.mutateAsync(id)
|
||||
if (selectedScript?.id === id) {
|
||||
setSelectedScript(null)
|
||||
setCode('')
|
||||
}
|
||||
refetch()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectScript = (script: any) => {
|
||||
setSelectedScript(script)
|
||||
setCode(script.content || '')
|
||||
setName(script.name)
|
||||
setLanguage(script.language?.toLowerCase() || 'python')
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Scripts</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 140px)', gap: 16 }}>
|
||||
{/* Sidebar */}
|
||||
<div style={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: 12,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontWeight: 500, fontSize: 13 }}>Scripts</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
onClick={handleNewScript}
|
||||
title="New script"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={1.5} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 12px',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
borderRadius: 4,
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={1.5} />
|
||||
Add Script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
||||
{scripts?.map(script => (
|
||||
<ScriptCard key={script.id} script={script} onDelete={() => handleDelete(script.id)} />
|
||||
))}
|
||||
{(!scripts || scripts.length === 0) && (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: 48,
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
No scripts found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScriptCard({ script, onDelete }: { script: Script; onDelete: () => void }) {
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString()
|
||||
}
|
||||
|
||||
const getLanguageColor = (lang: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
python: '#3776ab',
|
||||
javascript: '#f7df1e',
|
||||
typescript: '#3178c6',
|
||||
go: '#00add8',
|
||||
bash: '#4eaa25',
|
||||
shell: '#4eaa25',
|
||||
}
|
||||
return colors[lang?.toLowerCase()] || '#6b7280'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
transition: 'border-color 0.15s',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 6,
|
||||
background: `${getLanguageColor(script.language)}15`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<FileCode size={20} strokeWidth={1.5} style={{ color: getLanguageColor(script.language) }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 2 }}>{script.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{script.filename}</div>
|
||||
</div>
|
||||
{/* List */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 8 }}>
|
||||
{scripts?.map(script => (
|
||||
<div
|
||||
key={script.id}
|
||||
onClick={() => handleSelectScript(script)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: selectedScript?.id === script.id ? 'var(--bg-tertiary)' : 'transparent',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<FileCode size={14} strokeWidth={1.5} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{script.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(!scripts || scripts.length === 0) && (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
No scripts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
background: `${getLanguageColor(script.language)}15`,
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: getLanguageColor(script.language),
|
||||
fontWeight: 500,
|
||||
{/* Editor */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}>
|
||||
{script.language || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Script name"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 13,
|
||||
width: 200,
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{LANGUAGES.map(lang => (
|
||||
<option key={lang.id} value={lang.id}>{lang.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ flex: 1 }} />
|
||||
{selectedScript && (
|
||||
<button
|
||||
onClick={() => handleDelete(selectedScript.id)}
|
||||
title="Delete"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '6px 10px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} strokeWidth={1.5} />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '6px 12px',
|
||||
background: 'var(--bg-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Save size={12} strokeWidth={1.5} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Updated: {formatDate(script.updated_at)}
|
||||
{/* Monaco Editor */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language={LANGUAGES.find(l => l.id === language)?.monaco || 'python'}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value || '')}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: 'JetBrains Mono, Menlo, monospace',
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 12 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user