Compare commits

..
194 changed files with 817 additions and 13473 deletions
+150
View File
@@ -261,3 +261,153 @@ jobs:
- name: Image digest - name: Image digest
run: | run: |
echo ${{ steps.docker_build_310.outputs.digest }} echo ${{ steps.docker_build_310.outputs.digest }}
build-debian:
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta-debian
uses: docker/metadata-action@v5
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: |
latest=false
suffix=-debian,onlatest=true
tags: |
type=schedule,pattern=nightly
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Debian
id: docker_build_debian
uses: docker/build-push-action@v6
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v7,linux/arm64
context: .
file: ./docker/debian.Dockerfile
push: true
tags: ${{ steps.meta-debian.outputs.tags }}
labels: ${{ steps.meta-debian.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
- name: Image digest
run: |
echo ${{ steps.docker_build_debian.outputs.digest }}
build-debian-310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Debian python3.10
id: docker_build_debian_310
uses: docker/build-push-action@v6
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v7,linux/arm64
context: .
file: ./docker/debian-310.Dockerfile
push: true
tags: whyour/qinglong:debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
- name: Image digest
run: |
echo ${{ steps.docker_build_debian_310.outputs.digest }}
-324
View File
@@ -1,324 +0,0 @@
# Scenario Mode - Architecture Diagram
## System Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ Qinglong Application │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Navigation Menu │ │ API Layer │ │
│ │ ┌──────────────┐ │ │ /api/scenarios/ │ │
│ │ │ 定时任务 │ │ │ - GET (list) │ │
│ │ │ 订阅管理 │ │ │ - POST (create) │ │
│ │ │ 场景管理 ⭐ │◄──┼─────────┤ - PUT (update) │ │
│ │ │ 环境变量 │ │ │ - DELETE (delete) │ │
│ │ │ ... │ │ │ - PUT /enable │ │
│ │ └──────────────┘ │ │ - PUT /disable │ │
│ └─────────────────────┘ │ - GET /:id │ │
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Scenario Management Page │ │
│ │ /scenario │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ Toolbar: │ │ │
│ │ │ [新建场景] [启用] [禁用] [删除] [搜索] │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ Table: │ │ │
│ │ │ ┌─────┬────────┬──────┬──────┬────────────┐ │ │ │
│ │ │ │名称 │描述 │状态 │节点数│操作 │ │ │ │
│ │ │ ├─────┼────────┼──────┼──────┼────────────┤ │ │ │
│ │ │ │场景1│... │启用 │5节点 │[编辑工作流]│ │ │ │
│ │ │ │场景2│... │禁用 │3节点 │[编辑工作流]│ │ │ │
│ │ │ └─────┴────────┴──────┴──────┴────────────┘ │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ Click "编辑工作流" │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Workflow Editor Modal (Full Screen) │ │
│ │ ┌────────────────────────────────────────────────────────┐ │ │
│ │ │ Canvas Area (Left) │ Edit Panel (Right 400px) │ │ │
│ │ ├─────────────────────────────┼──────────────────────────┤ │ │
│ │ │ Toolbar: │ Node Configuration │ │ │
│ │ │ [+HTTP] [+Script] │ ┌────────────────────┐ │ │ │
│ │ │ [+Condition] [+Delay] │ │ Label: [______] │ │ │ │
│ │ │ [+Loop] [Validate] │ │ Type: [HTTP▼] │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ Nodes Grid: │ │ URL: [______] │ │ │ │
│ │ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │ Method:[GET ▼] │ │ │ │
│ │ │ │Node 1│ │Node 2│ │Node 3│ │ │ Headers:[____] │ │ │ │
│ │ │ │HTTP │ │Script│ │Cond. │ │ │ Body: [____] │ │ │ │
│ │ │ └──────┘ └──────┘ └──────┘ │ │ │ │ │ │
│ │ │ ┌──────┐ ┌──────┐ │ │ [Save] [Delete] │ │ │ │
│ │ │ │Node 4│ │Node 5│ │ └────────────────────┘ │ │ │
│ │ │ │Delay │ │Loop │ │ │ │ │
│ │ │ └──────┘ └──────┘ │ │ │ │
│ │ └─────────────────────────────┴──────────────────────────┘ │ │
│ │ [Cancel] [Save Workflow] │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## Data Flow
```
┌─────────────┐
│ Browser │
└──────┬──────┘
│ 1. Navigate to /scenario
┌─────────────────┐
│ Scenario Page │
│ (React) │
└──────┬──────────┘
│ 2. GET /api/scenarios
┌─────────────────┐
│ Scenario API │
│ (Express) │
└──────┬──────────┘
│ 3. Query database
┌─────────────────┐
│ Scenario Model │
│ (Sequelize) │
└──────┬──────────┘
│ 4. Read from SQLite
┌─────────────────┐
│ Database.db │
│ Scenarios │
│ Table │
└─────────────────┘
```
## Workflow Editor Data Flow
```
User Action Flow:
┌──────────────────────────────────────────────────────────────┐
│ │
│ Click "编辑工作流" │
│ │ │
│ ▼ │
│ Open WorkflowEditorModal │
│ │ │
│ ├──► Load existing workflowGraph │
│ │ (if scenario has one) │
│ │ │
│ ▼ │
│ Display Canvas & Edit Panel │
│ │ │
│ ├──► Click [+HTTP] button │
│ │ └──► Create new HTTP node │
│ │ └──► Add to localGraph.nodes │
│ │ │
│ ├──► Click node card │
│ │ └──► Set selectedNodeId │
│ │ └──► Populate form in Edit Panel │
│ │ │
│ ├──► Edit form fields │
│ │ └──► Update node.config │
│ │ └──► Save to localGraph │
│ │ │
│ ├──► Click [Delete] button │
│ │ └──► Remove node from localGraph.nodes │
│ │ │
│ ▼ │
│ Click "保存工作流" │
│ │ │
│ ├──► Validate workflow │
│ │ └──► Check nodes.length > 0 │
│ │ │
│ ▼ │
│ Call onOk(localGraph) │
│ │ │
│ ▼ │
│ PUT /api/scenarios │
│ │ │
│ └──► Update scenario.workflowGraph │
│ └──► Save to database │
│ └──► Success message │
│ └──► Close modal │
│ └──► Refresh list │
│ │
└──────────────────────────────────────────────────────────────┘
```
## Node Type Configurations
```
┌─────────────────────────────────────────────────────────────┐
│ Node Types │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. HTTP Request Node │
│ ┌──────────────────────────────────────────┐ │
│ │ type: 'http' │ │
│ │ config: │ │
│ │ - url: string │ │
│ │ - method: GET|POST|PUT|DELETE │ │
│ │ - headers: Record<string, string> │ │
│ │ - body: string │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 2. Script Execution Node │
│ ┌──────────────────────────────────────────┐ │
│ │ type: 'script' │ │
│ │ config: │ │
│ │ - scriptPath: string │ │
│ │ - scriptContent: string │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 3. Condition Node │
│ ┌──────────────────────────────────────────┐ │
│ │ type: 'condition' │ │
│ │ config: │ │
│ │ - condition: string │ │
│ │ - trueNext: string │ │
│ │ - falseNext: string │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 4. Delay Node │
│ ┌──────────────────────────────────────────┐ │
│ │ type: 'delay' │ │
│ │ config: │ │
│ │ - delayMs: number │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 5. Loop Node │
│ ┌──────────────────────────────────────────┐ │
│ │ type: 'loop' │ │
│ │ config: │ │
│ │ - iterations: number │ │
│ │ - loopBody: string[] │ │
│ └──────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Database Schema
```
Table: Scenarios
┌──────────────┬──────────────┬──────────────┬───────────────┐
│ Column │ Type │ Nullable │ Default │
├──────────────┼──────────────┼──────────────┼───────────────┤
│ id │ INTEGER │ NO │ AUTO_INCREMENT│
│ name │ STRING │ NO │ - │
│ description │ TEXT │ YES │ NULL │
│ status │ INTEGER │ YES │ 0 │
│ workflowGraph│ JSON │ YES │ NULL │
│ createdAt │ DATETIME │ NO │ NOW() │
│ updatedAt │ DATETIME │ NO │ NOW() │
└──────────────┴──────────────┴──────────────┴───────────────┘
workflowGraph JSON structure:
{
"nodes": [
{
"id": "node_1234567890",
"type": "http",
"label": "HTTP请求 1",
"x": 100,
"y": 100,
"config": {
"url": "https://api.example.com",
"method": "GET",
"headers": {},
"body": ""
},
"next": "node_1234567891"
}
],
"startNode": "node_1234567890"
}
```
## Component Hierarchy
```
App
└── Layout
└── Scenario Page (/scenario)
├── Toolbar
│ ├── Button (新建场景)
│ ├── Button (启用)
│ ├── Button (禁用)
│ ├── Button (删除)
│ └── Search (搜索场景)
├── Table
│ └── Columns
│ ├── 场景名称
│ ├── 场景描述
│ ├── 状态
│ ├── 工作流
│ ├── 创建时间
│ └── 操作
│ └── Button (编辑工作流)
├── ScenarioModal
│ └── Form
│ ├── Input (名称)
│ └── TextArea (描述)
└── WorkflowEditorModal
├── Canvas (Left)
│ ├── Toolbar
│ │ ├── Button (+ HTTP)
│ │ ├── Button (+ Script)
│ │ ├── Button (+ Condition)
│ │ ├── Button (+ Delay)
│ │ ├── Button (+ Loop)
│ │ └── Button (Validate)
│ └── Nodes Grid
│ └── NodeCard (×N)
│ ├── Type Badge
│ └── Label
└── Edit Panel (Right)
└── Form (Dynamic)
├── Input (Label)
├── Select (Type)
├── [Node-specific fields]
└── Buttons
├── Save
└── Delete
```
## File Organization
```
qinglong/
├── back/
│ ├── api/
│ │ ├── index.ts (modified: +scenario route)
│ │ └── scenario.ts (new: API endpoints)
│ ├── data/
│ │ └── scenario.ts (new: Model definition)
│ └── services/
│ └── scenario.ts (new: Business logic)
├── src/
│ ├── layouts/
│ │ └── defaultProps.tsx (modified: +scenario nav)
│ ├── locales/
│ │ ├── zh-CN.json (modified: +53 keys)
│ │ └── en-US.json (modified: +53 keys)
│ └── pages/
│ └── scenario/
│ ├── index.tsx (new: Main page)
│ ├── index.less (new: Page styles)
│ ├── modal.tsx (new: Create/Edit modal)
│ ├── workflowEditorModal.tsx (new: Editor)
│ ├── workflowEditor.less (new: Editor styles)
│ └── type.ts (new: TypeScript types)
└── SCENARIO_MODE.md (new: Documentation)
```
-196
View File
@@ -1,196 +0,0 @@
# Flowgram.ai Integration
## Overview
The workflow editor now uses the official Flowgram.ai library (@flowgram.ai/free-layout-editor) instead of a custom implementation. This provides a professional, feature-rich workflow editing experience.
## Architecture
### Components
```
FlowgramEditor (Main Component)
FreeLayoutEditorProvider (Context)
EditorRenderer (Canvas)
```
### Node Registries
Following Flowgram's pattern, each node type is registered with:
- `type`: Node identifier
- `info`: Display information (icon, description)
- `meta`: Visual properties (size, etc.)
- `onAdd`: Factory function to create new node instances
### Plugins Enabled
1. **FreeSnapPlugin** - Snap-to-grid for precise placement
2. **FreeLinesPlugin** - Visual connection lines between nodes
3. **FreeNodePanelPlugin** - Node addition panel
4. **MinimapPlugin** - Overview map for large workflows
5. **PanelManagerPlugin** - Panel management
## Node Types
### 1. Start Node
- Type: `start`
- Size: 120x60
- Purpose: Workflow entry point
### 2. HTTP Request Node
- Type: `http`
- Size: 280x120
- Config: url, method, headers, body
### 3. Script Execution Node
- Type: `script`
- Size: 280x120
- Config: scriptPath, scriptContent
### 4. Condition Node
- Type: `condition`
- Size: 280x120
- Config: condition expression
### 5. Delay Node
- Type: `delay`
- Size: 280x100
- Config: delayMs (milliseconds)
### 6. Loop Node
- Type: `loop`
- Size: 280x100
- Config: iterations
### 7. End Node
- Type: `end`
- Size: 120x60
- Purpose: Workflow termination
## Data Format Conversion
### From WorkflowGraph to Flowgram
```typescript
{
nodes: workflowGraph.nodes.map(node => ({
id: node.id,
type: node.type,
data: { title: node.label, ...node.config },
position: { x: node.x || 0, y: node.y || 0 }
})),
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
}
```
### From Flowgram to WorkflowGraph
```typescript
{
nodes: flowgramData.nodes.map(node => ({
id: node.id,
type: node.type,
label: node.data.title,
x: node.position.x,
y: node.position.y,
config: { ...node.data }
})),
startNode: flowgramData.nodes[0]?.id
}
```
## Dependencies
### Core
- `@flowgram.ai/free-layout-editor@1.0.2` - Main editor
- `@flowgram.ai/runtime-interface@1.0.2` - Runtime types
### Plugins
- `@flowgram.ai/free-snap-plugin@1.0.2`
- `@flowgram.ai/free-lines-plugin@1.0.2`
- `@flowgram.ai/free-node-panel-plugin@1.0.2`
- `@flowgram.ai/minimap-plugin@1.0.2`
- `@flowgram.ai/free-container-plugin@1.0.2`
- `@flowgram.ai/free-group-plugin@1.0.2`
- `@flowgram.ai/panel-manager-plugin@1.0.2`
- `@flowgram.ai/free-stack-plugin@1.0.2`
### Utilities
- `nanoid@^3.0.0` - Unique ID generation
- `lodash-es@^4.17.21` - Utility functions
## Usage
### In Modal
```tsx
<WorkflowEditorModal
visible={isVisible}
workflowGraph={existingGraph}
onOk={(graph) => saveWorkflow(graph)}
onCancel={() => setIsVisible(false)}
/>
```
### Editor Props
```tsx
const editorProps = useEditorProps(initialData, nodeRegistries);
```
## Features
### Visual Editing
- Drag and drop nodes
- Visual connection lines
- Snap-to-grid alignment
- Pan and zoom canvas
- Minimap for navigation
### Node Management
- Add nodes via panel or toolbar
- Select and edit nodes
- Delete nodes
- Move and position freely
### Professional UX
- Smooth animations
- Responsive design
- Dark mode compatible
- Undo/redo support (via Flowgram)
- Keyboard shortcuts (via Flowgram)
## Future Enhancements
With Flowgram integration, we can easily add:
1. **Form Meta** - Detailed node configuration forms
2. **Runtime Plugin** - Execute workflows
3. **Variable Panel** - Manage workflow variables
4. **Context Menu** - Right-click actions
5. **Custom Services** - Validation, testing, etc.
6. **Shortcuts** - Custom keyboard shortcuts
7. **Container Nodes** - Group nodes together
8. **Group Nodes** - Visual grouping
## Benefits
### For Users
- Professional workflow editor
- Intuitive drag-and-drop interface
- Visual feedback
- Familiar editing patterns
### For Developers
- Maintained by Bytedance
- Active development
- Plugin ecosystem
- TypeScript support
- Comprehensive documentation
### For Product
- Future-proof architecture
- Extensible design
- Community support
- Regular updates
## References
- [Flowgram.ai Official Site](https://flowgram.ai/)
- [GitHub Repository](https://github.com/bytedance/flowgram.ai)
- [Free Layout Demo](https://flowgram.ai/examples/free-layout/free-feature-overview.html)
- [Best Practices](https://flowgram.ai/examples/free-layout/free-feature-overview.html#%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5)
-275
View File
@@ -1,275 +0,0 @@
# Implementation Summary - Scenario Mode
## Overview
Successfully implemented a complete visual workflow automation system inspired by Flowgram.ai, adding comprehensive scenario management with an intuitive workflow editor to Qinglong.
## Status: ✅ COMPLETE
All requirements from issue #1 have been successfully implemented and tested.
## Key Achievements
### 1. Backend Implementation ✅
Created a complete RESTful API for scenario management:
**Files Added:**
- `back/data/scenario.ts` - Sequelize model for scenarios
- `back/services/scenario.ts` - Business logic layer
- `back/api/scenario.ts` - REST API endpoints
**Files Modified:**
- `back/api/index.ts` - Added scenario route registration
**API Endpoints:**
- `GET /api/scenarios` - List scenarios with search/pagination
- `POST /api/scenarios` - Create new scenario
- `PUT /api/scenarios` - Update scenario
- `DELETE /api/scenarios` - Delete scenarios
- `PUT /api/scenarios/enable` - Enable scenarios
- `PUT /api/scenarios/disable` - Disable scenarios
- `GET /api/scenarios/:id` - Get single scenario
**Features:**
- SQLite database with JSON support for workflow graphs
- Joi validation for all inputs
- TypeDI dependency injection
- Sequelize ORM integration
### 2. Frontend Implementation ✅
Created a comprehensive scenario management interface:
**Files Added:**
- `src/pages/scenario/index.tsx` - Main scenario list page (349 lines)
- `src/pages/scenario/index.less` - Page styles (26 lines)
- `src/pages/scenario/modal.tsx` - Create/Edit modal (75 lines)
- `src/pages/scenario/workflowEditorModal.tsx` - Workflow editor (409 lines)
- `src/pages/scenario/workflowEditor.less` - Editor styles (148 lines)
- `src/pages/scenario/type.ts` - TypeScript definitions (51 lines)
**Files Modified:**
- `src/layouts/defaultProps.tsx` - Added scenario route to navigation
- `src/locales/zh-CN.json` - Added 53 Chinese translations
- `src/locales/en-US.json` - Added 53 English translations
**Features:**
- Full CRUD operations with search and pagination
- Batch operations (enable, disable, delete)
- Independent workflow editor modal (95vw × 85vh)
- Grid-based node canvas with visual selection
- Dynamic configuration panel (400px fixed width)
- 5 node types fully implemented
### 3. Workflow Editor Design ✅
**Layout (Flowgram.ai-inspired):**
```
┌─────────────────────────────────────────────────────┐
│ Workflow Editor Modal │
├──────────────────────────────┬──────────────────────┤
│ Canvas Area (flexible) │ Edit Panel (400px) │
│ │ │
│ [+HTTP] [+Script] [+Cond] │ Node Configuration │
│ │ │
│ ┌───────┐ ┌───────┐ │ Label: [_____] │
│ │Node 1 │ │Node 2 │ │ Type: [HTTP▼] │
│ │ HTTP │ │Script │ │ │
│ └───────┘ └───────┘ │ URL: [_____] │
│ │ Method:[GET ▼] │
│ ┌───────┐ ┌───────┐ │ │
│ │Node 3 │ │Node 4 │ │ [Save] [Delete] │
│ │ Delay │ │ Loop │ │ │
│ └───────┘ └───────┘ │ │
└──────────────────────────────┴──────────────────────┘
```
**Node Types:**
1. **HTTP Request** - REST API calls with headers/body
2. **Script Execution** - Run scripts by path or inline
3. **Condition** - Conditional branching logic
4. **Delay** - Time-based delays (milliseconds)
5. **Loop** - Iteration-based repetition
### 4. Internationalization ✅
**53 New Translation Keys Added:**
- Scenario management UI
- Workflow editor UI
- Node type names
- Validation messages
- Error messages
- Success messages
**Languages Supported:**
- Chinese (zh-CN) - 100% coverage
- English (en-US) - 100% coverage
**Examples:**
- 场景管理 / Scenario Management
- 编辑工作流 / Edit Workflow
- HTTP请求 / HTTP Request
- 工作流验证通过 / Workflow validation passed
### 5. Documentation ✅
**Files Added:**
- `SCENARIO_MODE.md` - Feature documentation (202 lines)
- Feature overview
- User workflow guide
- Technical details
- Database schema
- File list
- `ARCHITECTURE.md` - Architecture documentation (324 lines)
- System diagrams
- Data flow diagrams
- Component hierarchy
- Node type configurations
- File organization
## Technical Details
### Technology Stack
- **Backend**: Express + TypeScript + Sequelize + TypeDI + Joi
- **Frontend**: React 18 + UmiJS 4 + Ant Design 4 + TypeScript
- **Database**: SQLite with JSON support
- **i18n**: react-intl-universal
### Code Quality Metrics
- **TypeScript**: 100% typed, 0 compilation errors
- **Linting**: All code follows project ESLint/Prettier rules
- **i18n**: 100% coverage, 0 hardcoded strings
- **Build**: Frontend and backend both build successfully
- **Code Review**: All review comments addressed
### Performance
- **Bundle Size**: Minimal impact on overall bundle
- **Code Splitting**: Async loading for scenario page
- **Database**: JSON field for flexible workflow storage
- **UI**: Responsive design with mobile support
## Testing Results
### Build Tests ✅
```bash
# Backend build
npm run build:back
✅ Success (0 errors)
# Frontend build
npm run build:front
✅ Success (0 errors)
```
### Code Review ✅
- Round 1: 9 issues found (i18n hardcoded strings)
- Round 2: 2 issues found (translation patterns)
- Round 3: 0 issues ✅ PASSED
### Manual Testing ✅
- ✅ Navigation menu shows "场景管理"
- ✅ Scenario list page loads
- ✅ Create scenario modal works
- ✅ Edit scenario modal works
- ✅ Workflow editor opens full-screen
- ✅ Add node buttons create nodes
- ✅ Click node shows configuration
- ✅ Edit node configuration saves
- ✅ Delete node removes from canvas
- ✅ Save workflow updates scenario
- ✅ Search functionality works
- ✅ Batch operations work
- ✅ Dark mode compatible
- ✅ Responsive on mobile
## Deployment Readiness
### Checklist ✅
- [x] Backend API implemented
- [x] Frontend UI implemented
- [x] Database schema defined
- [x] Internationalization complete
- [x] Documentation written
- [x] Code review passed
- [x] Build tests passed
- [x] Manual testing completed
- [x] Dark mode compatible
- [x] Mobile responsive
- [x] No security vulnerabilities introduced
### Database Migration
The Scenario model will be automatically created by Sequelize on first run.
**Table: Scenarios**
```sql
CREATE TABLE Scenarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT,
status INTEGER DEFAULT 0,
workflowGraph JSON,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
```
## Future Enhancements (Optional)
While the current implementation is complete, the following enhancements could be considered in future iterations:
1. **Visual Workflow Connections**
- Draw lines between nodes to show flow
- Implement with libraries like react-flow or xyflow
2. **Drag & Drop Positioning**
- Allow manual node repositioning on canvas
- Save x/y coordinates in node data
3. **Workflow Execution Engine**
- Backend execution engine to run workflows
- Queue management for concurrent executions
4. **Real-time Monitoring**
- Live execution status updates
- Detailed execution logs per node
5. **Workflow Templates**
- Pre-built workflow templates for common tasks
- Template marketplace/library
6. **Import/Export**
- Export workflows as JSON
- Import workflows from files
7. **Advanced Validation**
- Detect circular dependencies
- Validate node connections
- Required field validation
8. **Version Control**
- Save workflow history
- Rollback to previous versions
- Compare versions
## Conclusion
**Status: PRODUCTION READY**
The Scenario Mode implementation is complete, tested, documented, and ready for production deployment. All requirements from the original issue have been met or exceeded.
### Summary Statistics
- **14 files** changed (11 added, 3 modified)
- **1,600+ lines** of code
- **53 translations** added (Chinese & English)
- **5 node types** implemented
- **7 API endpoints** created
- **0 compilation errors**
- **0 code review issues** remaining
The implementation follows all project conventions, includes comprehensive documentation, and provides a solid foundation for future workflow automation features.
---
**Author**: GitHub Copilot
**Date**: November 23, 2025
**Issue**: #1 - Add Scenario Mode
**PR**: copilot/add-scenario-mode-visual-workflow
-2
View File
@@ -41,8 +41,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image. The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
-2
View File
@@ -43,8 +43,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像 `latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
-172
View File
@@ -1,172 +0,0 @@
# Scenario Mode Implementation
## Overview
A complete visual workflow automation system inspired by Flowgram.ai, featuring a canvas-based workflow editor with intuitive node management.
## Features Implemented
### Backend API
- **Endpoints**: `/api/scenarios`
- `GET /` - List scenarios with search and pagination
- `POST /` - Create new scenario
- `PUT /` - Update scenario
- `DELETE /` - Delete scenarios
- `PUT /enable` - Enable scenarios
- `PUT /disable` - Disable scenarios
- `GET /:id` - Get scenario by ID
### Frontend Components
#### 1. Scenario Management Page (`/scenario`)
- **List View**: Table displaying all scenarios with:
- Scenario name, description, status
- Workflow node count
- Creation date
- Batch operations (enable, disable, delete)
- Search functionality
#### 2. Workflow Editor Modal
- **Full-screen modal** (95vw × 85vh)
- **Split Layout**:
- **Left Canvas** (flexible width, min 600px):
- Grid-based node cards
- Visual node selection with highlighting
- Toolbar with quick node addition buttons
- **Right Edit Panel** (fixed 400px):
- Dynamic configuration forms
- Node-specific fields
- Save and delete controls
#### 3. Node Types Supported
1. **HTTP Request**
- URL, method (GET/POST/PUT/DELETE)
- Headers (JSON format)
- Request body
2. **Script Execution**
- Script path
- Inline script content
3. **Condition**
- Conditional expression
- Branch handling
4. **Delay**
- Delay time in milliseconds
5. **Loop**
- Number of iterations
## User Workflow
```
1. Navigate to "场景管理" (Scenario Management) in sidebar
2. Click "新建场景" (New Scenario)
3. Enter scenario name and description
4. Click "编辑工作流" (Edit Workflow)
5. Add nodes by clicking toolbar buttons
6. Click node to configure in right panel
7. Configure node parameters
8. Click "保存工作流" (Save Workflow)
9. Enable scenario to activate
```
## Technical Architecture
### Data Model
```typescript
interface Scenario {
id?: number;
name: string;
description?: string;
status?: 0 | 1; // 0: disabled, 1: enabled
workflowGraph?: WorkflowGraph;
createdAt?: Date;
updatedAt?: Date;
}
interface WorkflowGraph {
nodes: WorkflowNode[];
startNode?: string;
}
interface WorkflowNode {
id: string;
type: 'http' | 'script' | 'condition' | 'delay' | 'loop';
label: string;
x?: number;
y?: number;
config: {...};
next?: string | string[];
}
```
### Layout Design
- **Flexbox-based responsive layout**
- **Desktop**: Side-by-side canvas and edit panel
- **Mobile**: Stacked layout (50% height each)
- **Theme Support**: Light and dark mode
### Internationalization
- Full Chinese (zh-CN) support
- Full English (en-US) support
- 50+ translated terms
## UI Screenshots
The workflow editor follows Flowgram.ai design principles:
- **Clean visual hierarchy**
- **Compact node cards** on canvas
- **Focused editing panel** for detailed configuration
- **Quick access toolbar** for node creation
- **Visual feedback** for selection and hover states
## Database Schema
SQLite table: `Scenarios`
- `id` (INTEGER, PRIMARY KEY)
- `name` (STRING, NOT NULL)
- `description` (TEXT)
- `status` (INTEGER, DEFAULT 0)
- `workflowGraph` (JSON)
- `createdAt` (DATETIME)
- `updatedAt` (DATETIME)
## Files Added
### Backend
- `back/data/scenario.ts` - Data model
- `back/services/scenario.ts` - Business logic
- `back/api/scenario.ts` - API routes
### Frontend
- `src/pages/scenario/index.tsx` - Main page
- `src/pages/scenario/index.less` - Page styles
- `src/pages/scenario/modal.tsx` - Create/Edit modal
- `src/pages/scenario/workflowEditorModal.tsx` - Workflow editor
- `src/pages/scenario/workflowEditor.less` - Editor styles
- `src/pages/scenario/type.ts` - TypeScript types
### Configuration
- `src/layouts/defaultProps.tsx` - Navigation menu (added scenario route)
- `src/locales/zh-CN.json` - Chinese translations
- `src/locales/en-US.json` - English translations
## Next Steps (Future Enhancements)
1. **Visual Connections**: Draw lines between nodes to show workflow flow
2. **Drag and Drop**: Allow repositioning nodes on canvas
3. **Node Execution**: Implement backend workflow execution engine
4. **Real-time Monitoring**: Show execution status and logs
5. **Templates**: Pre-built workflow templates
6. **Export/Import**: Share workflows as JSON
7. **Validation**: Advanced workflow validation rules
8. **History**: Version control for workflows
+7 -41
View File
@@ -1,12 +1,12 @@
import { Joi, celebrate } from 'celebrate'; import { Router, Request, Response, NextFunction } from 'express';
import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util';
import EnvService from '../services/env'; import EnvService from '../services/env';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import config from '../config';
import fs from 'fs';
import { safeJSONParse } from '../config/util';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -196,40 +196,6 @@ export default (app: Router) => {
}, },
); );
route.put(
'/pin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.pin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/unpin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.unPin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post( route.post(
'/upload', '/upload',
upload.single('env'), upload.single('env'),
-2
View File
@@ -11,7 +11,6 @@ import system from './system';
import subscription from './subscription'; import subscription from './subscription';
import update from './update'; import update from './update';
import health from './health'; import health from './health';
import scenario from './scenario';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -27,7 +26,6 @@ export default () => {
subscription(app); subscription(app);
update(app); update(app);
health(app); health(app);
scenario(app);
return app; return app;
}; };
-144
View File
@@ -1,144 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import ScenarioService from '../services/scenario';
import { celebrate, Joi } from 'celebrate';
const route = Router();
export default (app: Router) => {
app.use('/scenarios', route);
route.get(
'/',
celebrate({
query: Joi.object({
searchValue: Joi.string().optional().allow(''),
page: Joi.number().optional(),
size: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const { searchValue, page, size } = req.query as any;
const result = await scenarioService.list(
searchValue,
page ? parseInt(page) : undefined,
size ? parseInt(size) : undefined,
);
return res.send({ code: 200, data: result });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
celebrate({
body: Joi.object({
name: Joi.string().required(),
description: Joi.string().optional().allow(''),
workflowGraph: Joi.object().optional(),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const data = await scenarioService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
id: Joi.number().required(),
name: Joi.string().required(),
description: Joi.string().optional().allow(''),
workflowGraph: Joi.object().optional(),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const data = await scenarioService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
await scenarioService.remove(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/disable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
await scenarioService.disabled(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/enable',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
await scenarioService.enabled(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scenarioService = Container.get(ScenarioService);
const data = await scenarioService.getDb({ id: parseInt(req.params.id) });
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
};
-1
View File
@@ -129,7 +129,6 @@ export default (app: Router) => {
content: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
+3 -3
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { CronExpressionParser } from 'cron-parser'; import cron_parser from 'cron-parser';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -60,7 +60,7 @@ export default (app: Router) => {
try { try {
if ( if (
!req.body.schedule || !req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext() cron_parser.parseExpression(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body); const data = await subscriptionService.create(req.body);
@@ -193,7 +193,7 @@ export default (app: Router) => {
if ( if (
!req.body.schedule || !req.body.schedule ||
typeof req.body.schedule === 'object' || typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext() cron_parser.parseExpression(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body); const data = await subscriptionService.update(req.body);
+2 -23
View File
@@ -14,7 +14,6 @@ import {
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer'; import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -277,19 +276,17 @@ export default (app: Router) => {
res.setHeader('QL-Task-Log', `${logPath}`); res.setHeader('QL-Task-Log', `${logPath}`);
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end(); res.end();
}, },
onError: async (message: string) => { onError: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message); await fs.appendFile(absolutePath, message);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message); await fs.appendFile(absolutePath, message);
}, },
}, },
); );
@@ -426,24 +423,6 @@ export default (app: Router) => {
}, },
); );
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put( route.put(
'/config/dependence-clean', '/config/dependence-clean',
celebrate({ celebrate({
+2 -3
View File
@@ -8,7 +8,7 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import config from '../config'; import config from '../config';
import { isDemoEnv, getToken } from '../config/util'; import { isDemoEnv } from '../config/util';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -56,8 +56,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
const token = getToken(req); await userService.logout(req.platform);
await userService.logout(req.platform, token);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
+16 -80
View File
@@ -24,7 +24,6 @@ class Application {
private grpcServerService?: GrpcServerService; private grpcServerService?: GrpcServerService;
private isShuttingDown = false; private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>(); private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() { constructor() {
this.app = express(); this.app = express();
@@ -54,54 +53,21 @@ class Application {
} }
private startMasterProcess() { private startMasterProcess() {
// Fork gRPC worker first and wait for it to be ready this.forkWorker('http');
const grpcWorker = this.forkWorker('grpc'); this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error('✌️ Failed to wait for gRPC worker:', error);
process.exit(1);
});
cluster.on('exit', (worker, code, signal) => { cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id); const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) { if (metadata) {
if (!this.isShuttingDown) { if (!this.isShuttingDown) {
Logger.error( Logger.error(
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code `${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`, }). Restarting...`,
); );
// If gRPC worker died, restart it and wait for it to be ready const newWorker = this.forkWorker(metadata.serviceType);
if (metadata.serviceType === 'grpc') { Logger.info(
const newGrpcWorker = this.forkWorker('grpc'); `Restarted ${metadata.serviceType} worker (New PID: ${newWorker.process.pid})`,
this.waitForWorkerReady(newGrpcWorker, 30000) );
.then(() => {
Logger.info('✌️ gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('✌️ Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error('✌️ Failed to send reregister-crons message:', error);
}
}
})
.catch((error) => {
Logger.error('✌️ Failed to restart gRPC worker:', error);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`✌️ Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
} }
this.workerMetadataMap.delete(worker.id); this.workerMetadataMap.delete(worker.id);
@@ -111,25 +77,6 @@ class Application {
this.setupMasterShutdown(); this.setupMasterShutdown();
} }
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker { private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType }); const worker = cluster.fork({ SERVICE_TYPE: serviceType });
@@ -169,7 +116,7 @@ class Application {
if (worker) { if (worker) {
const exitPromise = new Promise<void>((resolve) => { const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => { worker.once('exit', () => {
Logger.info(`✌️ Worker ${worker.process.pid} exited`); Logger.info(`Worker ${worker.process.pid} exited`);
resolve(); resolve();
}); });
@@ -177,7 +124,7 @@ class Application {
worker.send('shutdown'); worker.send('shutdown');
} catch (error) { } catch (error) {
Logger.warn( Logger.warn(
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`, `Failed to send shutdown to worker ${worker.process.pid}:`,
error, error,
); );
} }
@@ -192,14 +139,14 @@ class Application {
Promise.all(workerPromises), Promise.all(workerPromises),
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
setTimeout(() => { setTimeout(() => {
Logger.warn('✌️ Worker shutdown timeout reached'); Logger.warn('Worker shutdown timeout reached');
resolve(); resolve();
}, 10000); }, 10000);
}), }),
]); ]);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error('✌️ Error during worker shutdown:', error); Logger.error('Error during worker shutdown:', error);
process.exit(1); process.exit(1);
} }
}; };
@@ -211,7 +158,7 @@ class Application {
private async startWorkerProcess() { private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE; const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) { if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType); Logger.error('Invalid SERVICE_TYPE:', serviceType);
process.exit(1); process.exit(1);
} }
@@ -226,7 +173,7 @@ class Application {
process.send?.('ready'); process.send?.('ready');
} catch (error) { } catch (error) {
Logger.error(`✌️ ${serviceType} worker failed:`, error); Logger.error(`${serviceType} worker failed:`, error);
process.exit(1); process.exit(1);
} }
} }
@@ -259,20 +206,9 @@ class Application {
} }
private setupWorkerShutdown(serviceType: string) { private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => { process.on('message', (msg) => {
if (msg === 'shutdown') { if (msg === 'shutdown') {
this.gracefulShutdown(serviceType); this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('✌️ Cron jobs re-registered successfully');
} catch (error) {
Logger.error('✌️ Failed to re-register cron jobs:', error);
}
} }
}); });
@@ -293,7 +229,7 @@ class Application {
} }
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error); Logger.error(`[${serviceType}] Error during shutdown:`, error);
process.exit(1); process.exit(1);
} }
} }
@@ -301,6 +237,6 @@ class Application {
const app = new Application(); const app = new Application();
app.start().catch((error) => { app.start().catch((error) => {
Logger.error('🙅‍♀️ Application failed to start:', error); Logger.error('Application failed to start:', error);
process.exit(1); process.exit(1);
}); });
-1
View File
@@ -176,5 +176,4 @@ export default {
sshdPath, sshdPath,
systemLogPath, systemLogPath,
dependenceCachePath, dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
}; };
-21
View File
@@ -417,27 +417,6 @@ export async function getPid(cmd: string) {
return pid ? Number(pid) : undefined; return pid ? Number(pid) : undefined;
} }
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion { interface IVersion {
version: string; version: string;
changeLogLink: string; changeLogLink: string;
+1 -7
View File
@@ -21,8 +21,6 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
task_before?: string; task_before?: string;
task_after?: string; task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
@@ -47,8 +45,6 @@ export class Crontab {
this.extra_schedules = options.extra_schedules; this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before; this.task_before = options.task_before;
this.task_after = options.task_after; this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
} }
} }
@@ -59,7 +55,7 @@ export enum CrontabStatus {
'disabled', 'disabled',
} }
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {} export interface CronInstance extends Model<Crontab, Crontab>, Crontab { }
export const CrontabModel = sequelize.define<CronInstance>('Crontab', { export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: { name: {
unique: 'compositeIndex', unique: 'compositeIndex',
@@ -88,6 +84,4 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON, extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING, task_before: DataTypes.STRING,
task_after: DataTypes.STRING, task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
}); });
+1 -4
View File
@@ -1,5 +1,5 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.'; import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env { export class Env {
value?: string; value?: string;
@@ -9,7 +9,6 @@ export class Env {
position?: number; position?: number;
name?: string; name?: string;
remarks?: string; remarks?: string;
isPinned?: 1 | 0;
constructor(options: Env) { constructor(options: Env) {
this.value = options.value; this.value = options.value;
@@ -22,7 +21,6 @@ export class Env {
this.position = options.position; this.position = options.position;
this.name = options.name; this.name = options.name;
this.remarks = options.remarks || ''; this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
} }
} }
@@ -44,5 +42,4 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER, position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' }, name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING, remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
}); });
-89
View File
@@ -1,89 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
interface WorkflowNode {
id: string;
type: 'http' | 'script' | 'condition' | 'delay' | 'loop';
label: string;
x?: number;
y?: number;
config: {
// HTTP Request node
url?: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: string;
// Script node
scriptId?: number;
scriptPath?: string;
scriptContent?: string;
// Condition node
condition?: string;
trueNext?: string;
falseNext?: string;
// Delay node
delayMs?: number;
// Loop node
iterations?: number;
loopBody?: string[];
};
next?: string | string[]; // ID(s) of next node(s)
}
interface WorkflowGraph {
nodes: WorkflowNode[];
startNode?: string;
}
export class Scenario {
name?: string;
description?: string;
id?: number;
status?: 0 | 1; // 0: disabled, 1: enabled
workflowGraph?: WorkflowGraph;
createdAt?: Date;
updatedAt?: Date;
constructor(options: Scenario) {
this.name = options.name;
this.description = options.description;
this.id = options.id;
this.status = options.status || 0;
this.workflowGraph = options.workflowGraph;
this.createdAt = options.createdAt;
this.updatedAt = options.updatedAt;
}
}
export interface ScenarioInstance
extends Model<Scenario, Scenario>,
Scenario {}
export const ScenarioModel = sequelize.define<ScenarioInstance>(
'Scenario',
{
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
workflowGraph: {
type: DataTypes.JSON,
allowNull: true,
},
},
{
timestamps: true,
},
);
+1 -15
View File
@@ -38,7 +38,6 @@ export interface SystemConfigInfo {
pythonMirror?: string; pythonMirror?: string;
linuxMirror?: string; linuxMirror?: string;
timezone?: string; timezone?: string;
globalSshKey?: string;
} }
export interface LoginLogInfo { export interface LoginLogInfo {
@@ -49,19 +48,6 @@ export interface LoginLogInfo {
status?: LoginStatus; status?: LoginStatus;
} }
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo { export interface AuthInfo {
username: string; username: string;
password: string; password: string;
@@ -72,7 +58,7 @@ export interface AuthInfo {
platform: string; platform: string;
isTwoFactorChecking: boolean; isTwoFactorChecking: boolean;
token: string; token: string;
tokens: Record<string, string | TokenInfo[]>; tokens: Record<string, string>;
twoFactorActivated: boolean; twoFactorActivated: boolean;
twoFactorSecret: string; twoFactorSecret: string;
avatar: string; avatar: string;
+37 -35
View File
@@ -6,7 +6,6 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system'; import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription'; import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView'; import { CrontabViewModel } from '../data/cronView';
import { ScenarioModel } from '../data/scenario';
import { sequelize } from '../data'; import { sequelize } from '../data';
export default async () => { export default async () => {
@@ -18,42 +17,45 @@ export default async () => {
await EnvModel.sync(); await EnvModel.sync();
await SubscriptionModel.sync(); await SubscriptionModel.sync();
await CrontabViewModel.sync(); await CrontabViewModel.sync();
await ScenarioModel.sync();
// 初始化新增字段 // 初始化新增字段
const migrations = [ try {
{ await sequelize.query(
table: 'CrontabViews', 'alter table CrontabViews add column filterRelation VARCHAR(255)',
column: 'filterRelation', );
type: 'VARCHAR(255)', } catch (error) {}
}, try {
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' }, await sequelize.query(
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' }, 'alter table Subscriptions add column proxy VARCHAR(255)',
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' }, );
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' }, } catch (error) {}
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' }, try {
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' }, await sequelize.query('alter table CrontabViews add column type NUMBER');
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' }, } catch (error) {}
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' }, try {
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' }, await sequelize.query(
{ 'alter table Subscriptions add column autoAddCron NUMBER',
table: 'Crontabs', );
column: 'allow_multiple_instances', } catch (error) {}
type: 'NUMBER', try {
}, await sequelize.query(
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' }, 'alter table Subscriptions add column autoDelCron NUMBER',
{ table: 'Scenarios', column: 'status', type: 'INTEGER DEFAULT 0' }, );
]; } catch (error) {}
try {
for (const migration of migrations) { await sequelize.query('alter table Crontabs add column sub_id NUMBER');
try { } catch (error) {}
await sequelize.query( try {
`alter table ${migration.table} add column ${migration.column} ${migration.type}`, await sequelize.query(
); 'alter table Crontabs add column extra_schedules JSON',
} catch (error) { );
// Column already exists or other error, continue } catch (error) {}
} try {
} await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {}
Logger.info('✌️ DB loaded'); Logger.info('✌️ DB loaded');
} catch (error) { } catch (error) {
+5 -17
View File
@@ -1,9 +1,8 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
import config from '../config/index'; import config from '../config/index';
import Logger from './logger'; import { fileExist, promiseExec, rmPath } from '../config/util';
async function linkToNodeModule(src: string, dst?: string) { async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src); const target = path.join(config.rootPath, 'node_modules', dst || src);
@@ -18,18 +17,8 @@ async function linkToNodeModule(src: string, dst?: string) {
} }
async function linkCommand() { async function linkCommand() {
const homeDir = os.homedir(); const commandPath = await promiseExec('which node');
let userBinDir = path.join(homeDir, 'bin'); const commandDir = path.dirname(commandPath);
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [ const linkShell = [
{ {
src: 'update.sh', src: 'update.sh',
@@ -53,7 +42,6 @@ async function linkCommandToDir(commandDir: string) {
await fs.unlink(tmpTarget); await fs.unlink(tmpTarget);
} }
} catch (error) { } } catch (error) { }
await fs.symlink(source, tmpTarget); await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target); await fs.rename(tmpTarget, target);
} }
@@ -70,6 +58,6 @@ export default async (src: string = 'deps') => {
}); });
watcher watcher
.on('add', () => linkToNodeModule(src)) .on('add', (path) => linkToNodeModule(src))
.on('change', () => linkToNodeModule(src)); .on('change', (path) => linkToNodeModule(src));
}; };
+5 -3
View File
@@ -9,7 +9,6 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate'; import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv'; import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store'; import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path'; import path from 'path';
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
@@ -78,8 +77,11 @@ export default ({ app }: { app: Application }) => {
} }
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) { if (authInfo && headerToken) {
return next(); const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
} }
const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
-7
View File
@@ -2,7 +2,6 @@ import { Container } from 'typedi';
import SystemService from '../services/system'; import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule'; import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
@@ -11,7 +10,6 @@ export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService); const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token // 生成内置token
let tokenCommand = `ts-node-transpile-only ${join( let tokenCommand = `ts-node-transpile-only ${join(
@@ -59,11 +57,6 @@ export default async () => {
} }
systemService.updateTimezone(data.info); systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
} }
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();
+12 -11
View File
@@ -4,7 +4,6 @@ import { Container } from 'typedi';
import SockService from '../services/sock'; import SockService from '../services/sock';
import { getPlatform } from '../config/util'; import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
export default async ({ server }: { server: Server }) => { export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} }); const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
@@ -18,19 +17,21 @@ export default async ({ server }: { server: Server }) => {
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (authInfo) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[platform] === headerToken) {
sockService.addClient(conn);
if (isValidToken(authInfo, headerToken, platform)) { conn.on('data', (message) => {
sockService.addClient(conn); conn.write(message);
});
conn.on('data', (message) => { conn.on('close', function () {
conn.write(message); sockService.removeClient(conn);
}); });
conn.on('close', function () { return;
sockService.removeClient(conn); }
});
return;
} }
conn.close('404'); conn.close('404');
-17
View File
@@ -97,18 +97,6 @@ message UpdateCronRequest {
message DeleteCronsRequest { repeated int32 ids = 1; } message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse { message CronsResponse {
int32 code = 1; int32 code = 1;
repeated CronItem data = 2; repeated CronItem data = 2;
@@ -266,9 +254,4 @@ service Api {
rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {} rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
} }
-471
View File
@@ -281,26 +281,6 @@ export interface DeleteCronsRequest {
ids: number[]; ids: number[];
} }
export interface GetCronsRequest {
searchValue?: string | undefined;
}
export interface GetCronByIdRequest {
id: number;
}
export interface EnableCronsRequest {
ids: number[];
}
export interface DisableCronsRequest {
ids: number[];
}
export interface RunCronsRequest {
ids: number[];
}
export interface CronsResponse { export interface CronsResponse {
code: number; code: number;
data: CronItem[]; data: CronItem[];
@@ -2227,332 +2207,6 @@ export const DeleteCronsRequest: MessageFns<DeleteCronsRequest> = {
}, },
}; };
function createBaseGetCronsRequest(): GetCronsRequest {
return { searchValue: undefined };
}
export const GetCronsRequest: MessageFns<GetCronsRequest> = {
encode(message: GetCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.searchValue !== undefined) {
writer.uint32(10).string(message.searchValue);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.searchValue = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronsRequest {
return { searchValue: isSet(object.searchValue) ? globalThis.String(object.searchValue) : undefined };
},
toJSON(message: GetCronsRequest): unknown {
const obj: any = {};
if (message.searchValue !== undefined) {
obj.searchValue = message.searchValue;
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronsRequest>, I>>(base?: I): GetCronsRequest {
return GetCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronsRequest>, I>>(object: I): GetCronsRequest {
const message = createBaseGetCronsRequest();
message.searchValue = object.searchValue ?? undefined;
return message;
},
};
function createBaseGetCronByIdRequest(): GetCronByIdRequest {
return { id: 0 };
}
export const GetCronByIdRequest: MessageFns<GetCronByIdRequest> = {
encode(message: GetCronByIdRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== 0) {
writer.uint32(8).int32(message.id);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronByIdRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronByIdRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.id = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronByIdRequest {
return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 };
},
toJSON(message: GetCronByIdRequest): unknown {
const obj: any = {};
if (message.id !== 0) {
obj.id = Math.round(message.id);
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(base?: I): GetCronByIdRequest {
return GetCronByIdRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(object: I): GetCronByIdRequest {
const message = createBaseGetCronByIdRequest();
message.id = object.id ?? 0;
return message;
},
};
function createBaseEnableCronsRequest(): EnableCronsRequest {
return { ids: [] };
}
export const EnableCronsRequest: MessageFns<EnableCronsRequest> = {
encode(message: EnableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): EnableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseEnableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): EnableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: EnableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(base?: I): EnableCronsRequest {
return EnableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(object: I): EnableCronsRequest {
const message = createBaseEnableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseDisableCronsRequest(): DisableCronsRequest {
return { ids: [] };
}
export const DisableCronsRequest: MessageFns<DisableCronsRequest> = {
encode(message: DisableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DisableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDisableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DisableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: DisableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(base?: I): DisableCronsRequest {
return DisableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(object: I): DisableCronsRequest {
const message = createBaseDisableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseRunCronsRequest(): RunCronsRequest {
return { ids: [] };
}
export const RunCronsRequest: MessageFns<RunCronsRequest> = {
encode(message: RunCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RunCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRunCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RunCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: RunCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<RunCronsRequest>, I>>(base?: I): RunCronsRequest {
return RunCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RunCronsRequest>, I>>(object: I): RunCronsRequest {
const message = createBaseRunCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseCronsResponse(): CronsResponse { function createBaseCronsResponse(): CronsResponse {
return { code: 0, data: [], message: undefined }; return { code: 0, data: [], message: undefined };
} }
@@ -4322,51 +3976,6 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value), responseDeserialize: (value: Buffer) => Response.decode(value),
}, },
getCrons: {
path: "/com.ql.api.Api/GetCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronsRequest) => Buffer.from(GetCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronsRequest.decode(value),
responseSerialize: (value: CronsResponse) => Buffer.from(CronsResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronsResponse.decode(value),
},
getCronById: {
path: "/com.ql.api.Api/GetCronById",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronByIdRequest) => Buffer.from(GetCronByIdRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronByIdRequest.decode(value),
responseSerialize: (value: CronResponse) => Buffer.from(CronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronResponse.decode(value),
},
enableCrons: {
path: "/com.ql.api.Api/EnableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: EnableCronsRequest) => Buffer.from(EnableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => EnableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
disableCrons: {
path: "/com.ql.api.Api/DisableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: DisableCronsRequest) => Buffer.from(DisableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => DisableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
runCrons: {
path: "/com.ql.api.Api/RunCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: RunCronsRequest) => Buffer.from(RunCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RunCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
} as const; } as const;
export interface ApiServer extends UntypedServiceImplementation { export interface ApiServer extends UntypedServiceImplementation {
@@ -4384,11 +3993,6 @@ export interface ApiServer extends UntypedServiceImplementation {
createCron: handleUnaryCall<CreateCronRequest, CronResponse>; createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>; updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>; deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>;
getCrons: handleUnaryCall<GetCronsRequest, CronsResponse>;
getCronById: handleUnaryCall<GetCronByIdRequest, CronResponse>;
enableCrons: handleUnaryCall<EnableCronsRequest, Response>;
disableCrons: handleUnaryCall<DisableCronsRequest, Response>;
runCrons: handleUnaryCall<RunCronsRequest, Response>;
} }
export interface ApiClient extends Client { export interface ApiClient extends Client {
@@ -4602,81 +4206,6 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void, callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall; ): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
} }
export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as { export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as {
-119
View File
@@ -30,12 +30,6 @@ import {
UpdateCronRequest, UpdateCronRequest,
DeleteCronsRequest, DeleteCronsRequest,
CronResponse, CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api'; } from '../protos/api';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
@@ -329,116 +323,3 @@ export const deleteCrons = async (
callback(e); callback(e);
} }
}; };
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+6 -3
View File
@@ -17,11 +17,14 @@ const check = async (
return callback(null, { status: 1 }); return callback(null, { status: 1 });
} }
const qinglongErrLog = await promiseExec( const panelErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`, `tail -n 300 ~/.pm2/logs/panel-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
); );
return callback( return callback(
new Error(`${qinglongErrLog || ''}\n${res}`.trim()), new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
); );
default: default:
+22 -69
View File
@@ -4,12 +4,11 @@ import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { CronExpressionParser } from 'cron-parser'; import cron_parser from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
fileExist, fileExist,
killTask, killTask,
killAllTasks,
getUniqPath, getUniqPath,
safeJSONParse, safeJSONParse,
isDemoEnv, isDemoEnv,
@@ -25,7 +24,6 @@ import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class CronService { export default class CronService {
@@ -51,29 +49,9 @@ export default class CronService {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule); return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
} }
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> { public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload); const tab = new Crontab(payload);
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab); const doc = await this.insert(tab);
if (isDemoEnv()) { if (isDemoEnv()) {
@@ -104,7 +82,6 @@ export default class CronService {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload }); const tab = new Crontab({ ...doc, ...payload });
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) { if (doc.isDisabled === 1 || isDemoEnv()) {
@@ -465,17 +442,12 @@ export default class CronService {
public async stop(ids: number[]) { public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
// Kill all running instances of this task if (doc.pid) {
try { try {
const command = this.makeCommand(doc); await killTask(doc.pid);
await killAllTasks(command); } catch (error) {
this.logger.info( this.logger.error(error);
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`, }
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
} }
} }
@@ -504,15 +476,13 @@ export default class CronService {
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
); );
let { id, command, log_name } = cron; let { id, command, log_path } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`); const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true }); if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
await fs.mkdir(logDirPath, { recursive: true });
}
const logPath = `${uniqPath}/${logTime}.log`; const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`); const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn( const cp = spawn(
@@ -528,7 +498,7 @@ export default class CronService {
{ where: { id } }, { where: { id } },
); );
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString()); await fs.appendFile(absolutePath, data.toString());
}); });
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.logger.info( this.logger.info(
@@ -536,7 +506,7 @@ export default class CronService {
command, command,
data.toString(), data.toString(),
); );
await logStreamManager.write(absolutePath, data.toString()); await fs.appendFile(absolutePath, data.toString());
}); });
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.logger.error( this.logger.error(
@@ -544,7 +514,7 @@ export default class CronService {
command, command,
err, err,
); );
await logStreamManager.write(absolutePath, JSON.stringify(err)); await fs.appendFile(absolutePath, JSON.stringify(err));
}); });
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
@@ -553,8 +523,6 @@ export default class CronService {
JSON.stringify(params), JSON.stringify(params),
code, code,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: undefined },
{ where: { id } }, { where: { id } },
@@ -596,9 +564,7 @@ export default class CronService {
if (!doc) { if (!doc) {
return ''; return '';
} }
if (doc.log_name === '/dev/null') {
return '日志设置为忽略';
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath)); const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) { if (logFileExist) {
@@ -641,11 +607,9 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`; command = `${TASK_PREFIX}${tab.command}`;
} }
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `; let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell tab.id
if (tab.log_name) { } `;
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) { if (tab.task_before) {
commandVariable += `task_before='${tab.task_before commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''") .replace(/'/g, "'\\''")
@@ -687,23 +651,12 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string); await writeFileWithLock(config.crontabFile, crontab_string);
try { execSync(`crontab ${config.crontabFile}`);
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
await CrontabModel.update({ saved: true }, { where: {} }); await CrontabModel.update({ saved: true }, { where: {} });
} }
public importCrontab() { public importCrontab() {
exec('crontab -l', (error, stdout) => { exec('crontab -l', (error, stdout, stderr) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
const lines = stdout.split('\n'); const lines = stdout.split('\n');
const namePrefix = new Date().getTime(); const namePrefix = new Date().getTime();
@@ -717,7 +670,7 @@ export default class CronService {
if ( if (
command && command &&
schedule && schedule &&
CronExpressionParser.parse(schedule).hasNext() cron_parser.parseExpression(schedule).hasNext()
) { ) {
const name = namePrefix + '_' + index; const name = namePrefix + '_' + index;
+4 -12
View File
@@ -1,8 +1,7 @@
import groupBy from 'lodash/groupBy'; import { Service, Inject } from 'typedi';
import { FindOptions, Op } from 'sequelize';
import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises';
import { import {
Env, Env,
EnvModel, EnvModel,
@@ -12,6 +11,8 @@ import {
minPosition, minPosition,
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
@Service() @Service()
@@ -146,7 +147,6 @@ export default class EnvService {
} }
try { try {
const result = await this.find(condition, [ const result = await this.find(condition, [
['isPinned', 'DESC'],
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
@@ -190,14 +190,6 @@ export default class EnvService {
await this.set_envs(); await this.set_envs();
} }
public async pin(ids: number[]) {
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async set_envs() { public async set_envs() {
const envs = await this.envs('', { const envs = await this.envs('', {
name: { [Op.not]: null }, name: { [Op.not]: null },
-81
View File
@@ -1,81 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { Scenario, ScenarioModel } from '../data/scenario';
import { FindOptions, Op } from 'sequelize';
@Service()
export default class ScenarioService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public async create(payload: Scenario): Promise<Scenario> {
const scenario = new Scenario(payload);
const doc = await this.insert(scenario);
return doc;
}
public async insert(payload: Scenario): Promise<Scenario> {
const result = await ScenarioModel.create(payload, { returning: true });
return result.get({ plain: true });
}
public async update(payload: Scenario): Promise<Scenario> {
const doc = await this.getDb({ id: payload.id });
const scenario = new Scenario({ ...doc, ...payload });
const newDoc = await this.updateDb(scenario);
return newDoc;
}
public async updateDb(payload: Scenario): Promise<Scenario> {
await ScenarioModel.update(payload, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[]) {
await ScenarioModel.destroy({ where: { id: ids } });
}
public async list(
searchText?: string,
page?: number,
size?: number,
): Promise<{ data: Scenario[]; total: number }> {
const where: any = {};
if (searchText) {
where[Op.or] = [
{ name: { [Op.like]: `%${searchText}%` } },
{ description: { [Op.like]: `%${searchText}%` } },
];
}
const count = await ScenarioModel.count({ where });
const data = await ScenarioModel.findAll({
where,
order: [['createdAt', 'DESC']],
limit: size,
offset: page && size ? (page - 1) * size : undefined,
});
return {
data: data.map((item) => item.get({ plain: true })),
total: count,
};
}
public async getDb(
query: FindOptions<Scenario>['where'],
): Promise<Scenario> {
const doc: any = await ScenarioModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`Scenario ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async disabled(ids: number[]) {
await ScenarioModel.update({ status: 0 }, { where: { id: ids } });
}
public async enabled(ids: number[]) {
await ScenarioModel.update({ status: 1 }, { where: { id: ids } });
}
}
-28
View File
@@ -131,32 +131,4 @@ export default class SshKeyService {
} }
} }
} }
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
} }
+7 -11
View File
@@ -31,7 +31,6 @@ import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron'; import { CrontabModel } from '../data/cron';
import CrontabService from './cron'; import CrontabService from './cron';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -137,7 +136,7 @@ export default class SubscriptionService {
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { if (doc.sub_before) {
await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`); await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`);
beforeStr = await promiseExec(doc.sub_before); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
@@ -145,7 +144,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (beforeStr) { if (beforeStr) {
await logStreamManager.write(absolutePath, `${beforeStr}\n`); await fs.appendFile(absolutePath, `${beforeStr}\n`);
} }
}, },
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -164,7 +163,7 @@ export default class SubscriptionService {
let afterStr = ''; let afterStr = '';
try { try {
if (sub.sub_after) { if (sub.sub_after) {
await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`); await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`);
afterStr = await promiseExec(sub.sub_after); afterStr = await promiseExec(sub.sub_after);
} }
} catch (error: any) { } catch (error: any) {
@@ -172,19 +171,16 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (afterStr) { if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`); await fs.appendFile(absolutePath, `${afterStr}\n`);
} }
await logStreamManager.write( await fs.appendFile(
absolutePath, absolutePath,
`\n## 执行结束... ${endTime.format( `\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
)} ${diff} ${LOG_END_SYMBOL}`, )} ${diff} ${LOG_END_SYMBOL}`,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update( await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined }, { status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } }, { where: { id: sub.id } },
@@ -199,12 +195,12 @@ export default class SubscriptionService {
onError: async (message: string) => { onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
await logStreamManager.write(absolutePath, `\n${message}`); await fs.appendFile(absolutePath, `\n${message}`);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
await logStreamManager.write(absolutePath, `\n${message}`); await fs.appendFile(absolutePath, `\n${message}`);
}, },
}; };
} }
-21
View File
@@ -530,27 +530,6 @@ export default class SystemService {
} }
} }
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' }; return { code: 400, message: '参数错误' };
+7 -139
View File
@@ -11,7 +11,6 @@ import {
SystemModelInfo, SystemModelInfo,
LoginStatus, LoginStatus,
AuthInfo, AuthInfo,
TokenInfo,
} from '../data/system'; } from '../data/system';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import NotificationService from './notify'; import NotificationService from './notify';
@@ -102,23 +101,12 @@ export default class UserService {
algorithm: 'HS384', algorithm: 'HS384',
}); });
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
token, token,
tokens: updatedTokens, tokens: {
...tokens,
[req.platform]: token,
},
lastlogon: timestamp, lastlogon: timestamp,
retries: 0, retries: 0,
lastip: ip, lastip: ip,
@@ -192,37 +180,11 @@ export default class UserService {
} }
} }
public async logout(platform: string, tokenValue: string): Promise<any> { public async logout(platform: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token, token: '',
tokens: updatedTokens, tokens: { ...authInfo.tokens, [platform]: '' },
}); });
} }
@@ -402,100 +364,6 @@ export default class UserService {
} }
} }
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) { public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info; const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
-46
View File
@@ -1,46 +0,0 @@
import { AuthInfo, TokenInfo } from '../data/system';
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
-110
View File
@@ -1,110 +0,0 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
*/
export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
}
}
/**
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
}
/**
* Get the number of open streams
*/
getOpenStreamCount(): number {
return this.streams.size;
}
}
// Export a singleton instance for shared use
export const logStreamManager = new LogStreamManager();
-35
View File
@@ -2,45 +2,10 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit'; import taskLimit from './pLimit';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { ICron } from '../protos/cron'; import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
export function runCron(cmd: string, cron: ICron): Promise<number | void> { export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => { return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => { return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info( Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({ `[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron, ...cron,
+2 -47
View File
@@ -1,8 +1,6 @@
import { Joi } from 'celebrate'; import { Joi } from 'celebrate';
import { CronExpressionParser } from 'cron-parser'; import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => { const validateSchedule = (value: string, helpers: any) => {
if ( if (
@@ -13,7 +11,7 @@ const validateSchedule = (value: string, helpers: any) => {
} }
try { try {
if (CronExpressionParser.parse(value).hasNext()) { if (cron_parser.parseExpression(value).hasNext()) {
return value; return value;
} }
} catch (e) { } catch (e) {
@@ -39,47 +37,4 @@ export const commonCronSchema = {
extra_schedules: Joi.array().optional().allow(null), extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null), task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null), task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
.optional()
.allow('')
.allow(null)
.custom((value, helpers) => {
if (!value) return value;
// Check if it's an absolute path
if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value;
}
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
return helpers.error('string.max');
}
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
}; };
+93
View File
@@ -0,0 +1,93 @@
FROM python:3.10-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs npm git ca-certificates \
&& npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \
&& pnpm install --prod \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
FROM python:3.10-slim-bookworm
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.10
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \
DEBIAN_FRONTEND=noninteractive
VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /usr/local/bin/. /usr/local/bin/
RUN set -x \
&& apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
bash \
coreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nodejs \
jq \
openssh-client \
procps \
netcat-openbsd \
unzip \
npm \
cron \
ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& rm -rf /root/.cache
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+93
View File
@@ -0,0 +1,93 @@
FROM python:3.11-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs npm git ca-certificates \
&& npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \
&& pnpm install --prod \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
FROM python:3.11-slim-bookworm
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.11
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \
DEBIAN_FRONTEND=noninteractive
VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /usr/local/bin/. /usr/local/bin/
RUN set -x \
&& apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
bash \
coreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nodejs \
jq \
openssh-client \
procps \
netcat-openbsd \
unzip \
npm \
cron \
ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& rm -rf /root/.cache
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+7 -26
View File
@@ -1,56 +1,37 @@
#!/bin/bash #!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell dir_shell=/ql/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/env.sh
export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
log_with_style() { log_with_style() {
local level="$1" local level="$1"
local message="$2" local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
} }
# Fix DNS resolution issues in Alpine Linux log_with_style "INFO" "🚀 1. 检测配置文件..."
# Alpine uses musl libc which has known DNS resolver issues with certain domains
# Adding ndots:0 prevents unnecessary search domain appending
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)"
fi
fi
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
. $dir_shell/env.sh
import_config "$@" import_config "$@"
fix_config fix_config
# Try to initialize PM2, but don't fail if it doesn't work pm2 l &>/dev/null
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..." log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2 reload_pm2
if [[ $AutoStartBot == true ]]; then if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..." log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 & nohup ql bot >$dir_log/bot.log 2>&1 &
fi fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >$dir_log/extra.log 2>&1 &
fi fi
log_with_style "SUCCESS" "🎉 容器启动成功!" log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null crond -f >/dev/null
+23 -38
View File
@@ -1,6 +1,5 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@8.3.1",
"scripts": { "scripts": {
"start": "concurrently -n w: npm:start:*", "start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts", "start:back": "nodemon ./back/app.ts",
@@ -51,24 +50,18 @@
} }
}, },
"overrides": { "overrides": {
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3"
"@codemirror/state": "^6",
"@codemirror/view": "^6",
"@codemirror/language": "^6"
} }
}, },
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.10.0",
"@grpc/grpc-js": "^1.14.0", "@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0", "@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1", "@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"celebrate": "^15.0.3", "celebrate": "^15.0.3",
"chokidar": "^4.0.1", "chokidar": "^4.0.1",
"compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^5.4.0", "cron-parser": "^4.9.0",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.6", "dotenv": "^16.4.6",
@@ -76,73 +69,69 @@
"express-jwt": "^8.4.1", "express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1", "express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3", "express-urlrewrite": "^2.0.3",
"helmet": "^8.1.0", "undici": "^7.9.0",
"hpagent": "^1.2.0", "hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.3", "http-proxy-middleware": "^3.0.3",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"ip2region": "2.3.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"multer": "1.4.5-lts.1", "multer": "1.4.5-lts.1",
"node-schedule": "^2.1.0", "node-schedule": "^2.1.0",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4", "p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2", "@bufbuild/protobuf": "^2.10.0",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"request-ip": "3.3.0",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"toad-scheduler": "^3.0.1", "toad-scheduler": "^3.0.1",
"typedi": "^0.10.0", "typedi": "^0.10.0",
"undici": "^7.9.0",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0",
"request-ip": "3.3.0",
"ip2region": "2.3.0",
"keyv": "^5.2.3",
"@keyv/sqlite": "^4.0.1",
"proper-lockfile": "^4.1.2",
"compression": "^1.7.4",
"helmet": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"moment": "2.30.1",
"@ant-design/icons": "^5.0.1", "@ant-design/icons": "^5.0.1",
"@ant-design/pro-layout": "6.38.22", "@ant-design/pro-layout": "6.38.22",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1", "@codemirror/view": "^6.34.1",
"@flowgram.ai/fixed-layout-editor": "1.0.2", "@codemirror/state": "^6.4.1",
"@flowgram.ai/form-materials": "1.0.2",
"@flowgram.ai/fixed-semi-materials": "1.0.2",
"@flowgram.ai/group-plugin": "1.0.2",
"@flowgram.ai/minimap-plugin": "1.0.2",
"@flowgram.ai/panel-manager-plugin": "1.0.2",
"@monaco-editor/react": "4.2.1", "@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2", "@react-hook/resize-observer": "^2.0.2",
"react-router-dom": "6.26.1",
"@types/body-parser": "^1.19.2", "@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/cross-spawn": "^6.0.2", "@types/cross-spawn": "^6.0.2",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4", "@types/express-jwt": "^6.0.4",
"@types/file-saver": "2.0.2", "@types/file-saver": "2.0.2",
"@types/helmet": "^4.0.0",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8", "@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185", "@types/lodash": "^4.14.185",
"@types/lodash-es": "^4.17.12",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2", "@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/qrcode.react": "^1.0.2", "@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.20", "@types/react": "^18.0.20",
"@types/react-copy-to-clipboard": "^5.0.4", "@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/request-ip": "0.0.41",
"@types/serve-handler": "^6.1.1", "@types/serve-handler": "^6.1.1",
"@types/sockjs": "^0.3.33", "@types/sockjs": "^0.3.33",
"@types/sockjs-client": "^1.5.1", "@types/sockjs-client": "^1.5.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@types/request-ip": "0.0.41",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@uiw/codemirror-extensions-langs": "^4.21.9", "@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9", "@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.4.4", "@umijs/max": "^4.4.4",
@@ -154,13 +143,10 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"compression-webpack-plugin": "9.2.0", "compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0", "concurrently": "^7.0.0",
"classnames": "^2.5.1", "react-hotkeys-hook": "^4.6.1",
"file-saver": "2.0.2", "file-saver": "2.0.2",
"lint-staged": "^13.0.3", "lint-staged": "^13.0.3",
"lodash-es": "^4.17.21",
"moment": "2.30.1",
"monaco-editor": "0.33.0", "monaco-editor": "0.33.0",
"nanoid": "^3.3.8",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"pretty-bytes": "6.1.1", "pretty-bytes": "6.1.1",
@@ -175,17 +161,16 @@
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-intl-universal": "^2.12.0", "react-intl-universal": "^2.12.0",
"react-router-dom": "6.26.1",
"react-split-pane": "^0.1.92", "react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0", "sockjs-client": "^1.6.0",
"styled-components": "^5.3.10",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"ts-proto": "^2.6.1", "ts-proto": "^2.6.1",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "5.2.2", "typescript": "5.2.2",
"vh-check": "^2.0.5", "vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0" "virtualizedtableforantd4": "1.3.0",
"@types/compression": "^1.7.2",
"@types/helmet": "^4.0.0"
} }
} }
+185 -3920
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -12,32 +12,4 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => { QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', x); console.log('systemNotify', x);
}); });
// 查询定时任务 (Query cron tasks)
QLAPI.getCrons({ searchValue: 'test' }).then((x) => {
console.log('getCrons', x);
});
// 通过ID查询定时任务 (Get cron by ID)
QLAPI.getCronById({ id: 1 }).then((x) => {
console.log('getCronById', x);
}).catch((err) => {
console.log('getCronById error', err);
});
// 启用定时任务 (Enable cron tasks)
QLAPI.enableCrons({ ids: [1, 2] }).then((x) => {
console.log('enableCrons', x);
});
// 禁用定时任务 (Disable cron tasks)
QLAPI.disableCrons({ ids: [1, 2] }).then((x) => {
console.log('disableCrons', x);
});
// 手动执行定时任务 (Run cron tasks manually)
QLAPI.runCrons({ ids: [1] }).then((x) => {
console.log('runCrons', x);
});
console.log('test desc'); console.log('test desc');
+8 -8
View File
@@ -41,7 +41,7 @@ add_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
@@ -71,7 +71,7 @@ update_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -98,7 +98,7 @@ update_cron_command_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -118,7 +118,7 @@ del_cron_api() {
local ids="$1" local ids="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \ -X 'DELETE' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -143,7 +143,7 @@ update_cron() {
local runningTime="${6:-0}" local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -165,7 +165,7 @@ notify_api() {
local content="$2" local content="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -185,7 +185,7 @@ find_cron_api() {
local params="$1" local params="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--compressed --compressed
@@ -204,7 +204,7 @@ update_auth_config() {
local tip="$2" local tip="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
+5 -5
View File
@@ -24,14 +24,14 @@ copy_dep() {
pm2_log() { pm2_log() {
echo -e "---> pm2日志" echo -e "---> pm2日志"
local panelOut="/root/.pm2/logs/qinglong-out.log" local panelOut="/root/.pm2/logs/panel-out.log"
local panelError="/root/.pm2/logs/qinglong-error.log" local panelError="/root/.pm2/logs/panel-error.log"
tail -n 300 "$panelOut" tail -n 300 "$panelOut"
tail -n 300 "$panelError" tail -n 300 "$panelError"
} }
check_ql() { check_ql() {
local api=$(curl -s --noproxy "*" "http://0.0.0.0:${ql_port}") local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700")
echo -e "\n=====> 检测面板\n\n$api\n" echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n" echo -e "=====> 面板服务启动正常\n"
@@ -42,10 +42,10 @@ check_pm2() {
pm2_log pm2_log
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:5700/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \ -H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H "Referer: http://0.0.0.0:${ql_port}/crontab" \ -H 'Referer: http://0.0.0.0:5700/crontab' \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed --compressed
) )
+1 -6
View File
@@ -5,7 +5,7 @@ const { join } = require('path');
class GrpcClient { class GrpcClient {
static #config = { static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'),
serverAddress: `0.0.0.0:${process.env.GRPC_PORT || '5500'}`, serverAddress: '0.0.0.0:5500',
protoOptions: { protoOptions: {
keepCase: true, keepCase: true,
longs: String, longs: String,
@@ -33,11 +33,6 @@ class GrpcClient {
'createCron', 'createCron',
'updateCron', 'updateCron',
'deleteCrons', 'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
]; ];
#client; #client;
+11 -32
View File
@@ -59,10 +59,15 @@ list_own_user=$dir_list_tmp/own_user.list
list_own_add=$dir_list_tmp/own_add.list list_own_add=$dir_list_tmp/own_add.list
list_own_drop=$dir_list_tmp/own_drop.list list_own_drop=$dir_list_tmp/own_drop.list
## 软连接及其原始文件对应关系
link_name=( link_name=(
task task
ql ql
) )
original_name=(
task.sh
update.sh
)
init_env() { init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null) local pnpm_global_path=$(pnpm root -g 2>/dev/null)
@@ -79,20 +84,15 @@ init_env() {
export PYTHONUNBUFFERED=1 export PYTHONUNBUFFERED=1
} }
load_ql_envs() {
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
ql_grpc_port=${QlGrpcPort:-"5500"}
current_branch=${QL_BRANCH:-""}
}
import_config() { import_config() {
[[ -f $file_config_user ]] && . $file_config_user [[ -f $file_config_user ]] && . $file_config_user
load_ql_envs ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
command_timeout_time=${CommandTimeoutTime:-""} command_timeout_time=${CommandTimeoutTime:-""}
file_extensions=${RepoFileExtensions:-"js py"} file_extensions=${RepoFileExtensions:-"js py"}
proxy_url=${ProxyUrl:-""} proxy_url=${ProxyUrl:-""}
current_branch=${QL_BRANCH:-""}
if [[ -n "${DefaultCronRule}" ]]; then if [[ -n "${DefaultCronRule}" ]]; then
default_cron="${DefaultCronRule}" default_cron="${DefaultCronRule}"
@@ -272,35 +272,14 @@ random_range() {
delete_pm2() { delete_pm2() {
cd $dir_root cd $dir_root
# Try to delete PM2 processes, but don't fail if PM2 is not available pm2 delete ecosystem.config.js
pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes
pkill -f "node.*static/build/app.js" 2>/dev/null || true
} }
reload_pm2() { reload_pm2() {
cd $dir_root cd $dir_root
restore_env_vars restore_env_vars
pm2 flush &>/dev/null
# Try to start PM2, but handle failures gracefully pm2 startOrGracefulReload ecosystem.config.js --update-env
if pm2 flush &>/dev/null && pm2 startOrGracefulReload ecosystem.config.js --update-env; then
return 0
else
local exit_code=$?
echo "警告: PM2 启动失败 (退出码: $exit_code),可能是由于硬件不兼容"
echo "正在尝试直接使用 Node.js 启动服务..."
# Kill any existing node processes for qinglong
pkill -f "node.*static/build/app.js" 2>/dev/null || true
# Start node directly in the background
nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 &
local node_pid=$!
echo "已使用 Node.js 直接启动服务 (PID: $node_pid)"
echo "注意: 使用此模式时,部分 PM2 管理功能将不可用"
return 0
fi
} }
diff_time() { diff_time() {
+10 -19
View File
@@ -46,22 +46,18 @@ handle_log_path() {
time=$(date "+$mtime_format") time=$(date "+$mtime_format")
log_time=$(format_log_time "$mtime_format" "$time") log_time=$(format_log_time "$mtime_format" "$time")
if [[ -z $log_name ]]; then log_dir_tmp="${file_param##*/}"
log_dir_tmp="${file_param##*/}" if [[ $file_param =~ "/" ]]; then
if [[ $file_param =~ "/" ]]; then if [[ $file_param == /* ]]; then
if [[ $file_param == /* ]]; then log_dir_tmp_path="${file_param:1}"
log_dir_tmp_path="${file_param:1}" else
else log_dir_tmp_path="${file_param}"
log_dir_tmp_path="${file_param}"
fi
fi fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
else
log_dir="$log_name"
fi fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
log_path="$log_dir/$log_time.log" log_path="$log_dir/$log_time.log"
if [[ ${real_log_path:=} ]]; then if [[ ${real_log_path:=} ]]; then
@@ -77,11 +73,6 @@ handle_log_path() {
if [[ "${real_time:=}" == "true" ]]; then if [[ "${real_time:=}" == "true" ]]; then
cmd="" cmd=""
fi fi
if [[ "${log_dir:=}" == "/dev/null" ]]; then
cmd=">> /dev/null"
log_path="/dev/null"
fi
} }
format_params() { format_params() {
-1
View File
@@ -3,7 +3,6 @@
dir_shell=$QL_DIR/shell dir_shell=$QL_DIR/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/api.sh . $dir_shell/api.sh
load_ql_envs
. $dir_shell/env.sh . $dir_shell/env.sh
send_mark=$dir_shell/send_mark send_mark=$dir_shell/send_mark
+5 -12
View File
@@ -7,18 +7,11 @@ export function rootContainer(container: any) {
'en': require('./locales/en-US.json'), 'en': require('./locales/en-US.json'),
'zh': require('./locales/zh-CN.json'), 'zh': require('./locales/zh-CN.json'),
}; };
let currentLocale: string; let currentLocale = intl.determineLocale({
try { urlLocaleKey: 'lang',
currentLocale = intl.determineLocale({ cookieLocaleKey: 'lang',
urlLocaleKey: 'lang', localStorageLocaleKey: 'lang',
cookieLocaleKey: 'lang', }).slice(0, 2);
localStorageLocaleKey: 'lang',
}).slice(0, 2);
} catch (e: unknown) {
// Handle decodeURIComponent errors from malformed cookies
console.warn('Failed to determine locale from cookies:', e);
currentLocale = '';
}
if (!currentLocale || !Object.keys(locales).includes(currentLocale)) { if (!currentLocale || !Object.keys(locales).includes(currentLocale)) {
currentLocale = 'zh'; currentLocale = 'zh';
+11 -16
View File
@@ -1,6 +1,6 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { Tooltip, Typography, message } from 'antd'; import { Tooltip, Typography } from 'antd';
import { CopyOutlined, CheckOutlined } from '@ant-design/icons'; import { CopyOutlined, CheckOutlined } from '@ant-design/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -10,21 +10,16 @@ const Copy = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyIdRef = useRef<number>(); const copyIdRef = useRef<number>();
const handleCopy = (text: string, result: boolean) => { const copyText = (e?: React.MouseEvent) => {
if (result) {
setCopied(true);
message.success(intl.get('复制成功'));
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
}
};
const handleClick = (e?: React.MouseEvent) => {
e?.preventDefault(); e?.preventDefault();
e?.stopPropagation(); e?.stopPropagation();
setCopied(true);
cleanCopyId();
copyIdRef.current = window.setTimeout(() => {
setCopied(false);
}, 3000);
}; };
const cleanCopyId = () => { const cleanCopyId = () => {
@@ -32,8 +27,8 @@ const Copy = ({ text }: { text: string }) => {
}; };
return ( return (
<Link onClick={handleClick} style={{ marginLeft: 4 }}> <Link onClick={copyText} style={{ marginLeft: 1 }}>
<CopyToClipboard text={text} onCopy={handleCopy}> <CopyToClipboard text={text}>
<Tooltip <Tooltip
key="copy" key="copy"
title={copied ? intl.get('复制成功') : intl.get('复制')} title={copied ? intl.get('复制成功') : intl.get('复制')}
+1 -7
View File
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { SettingOutlined, ApartmentOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import IconFont from '@/components/iconfont'; import IconFont from '@/components/iconfont';
import { BasicLayoutProps } from '@ant-design/pro-layout'; import { BasicLayoutProps } from '@ant-design/pro-layout';
@@ -36,12 +36,6 @@ export default {
icon: <IconFont type="ql-icon-subs" />, icon: <IconFont type="ql-icon-subs" />,
component: '@/pages/subscription/index', component: '@/pages/subscription/index',
}, },
{
path: '/scenario',
name: intl.get('场景管理'),
icon: <ApartmentOutlined />,
component: '@/pages/scenario/index',
},
{ {
path: '/env', path: '/env',
name: intl.get('环境变量'), name: intl.get('环境变量'),
-1
View File
@@ -39,7 +39,6 @@ body {
max-height: calc(80vh - 110px); max-height: calc(80vh - 110px);
max-height: calc(80vh - var(--vh-offset, 110px)); max-height: calc(80vh - var(--vh-offset, 110px));
overflow-y: auto; overflow-y: auto;
position: relative;
} }
.log-modal { .log-modal {
+4 -118
View File
@@ -104,7 +104,7 @@
"序号": "Number", "序号": "Number",
"备注": "Remarks", "备注": "Remarks",
"更新时间": "Update Time", "更新时间": "Update Time",
"创建时间": "Created Time", "创建时间": "Creation Time",
"确认删除依赖": "Confirm to delete the dependency", "确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall", "确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install", "确认取消安装": "Confirm to cancel install",
@@ -252,7 +252,7 @@
"登录日志": "Login Logs", "登录日志": "Login Logs",
"其他设置": "Other Settings", "其他设置": "Other Settings",
"关于": "About", "关于": "About",
"成功": "Successfully", "成功": "Success",
"失败": "Failure", "失败": "Failure",
"登录时间": "Login Time", "登录时间": "Login Time",
"登录地址": "Login Address", "登录地址": "Login Address",
@@ -521,119 +521,5 @@
"远程仓库缓存": "Remote repository cache", "远程仓库缓存": "Remote repository cache",
"SSH 文件缓存": "SSH file cache", "SSH 文件缓存": "SSH file cache",
"清除依赖缓存": "Clean dependency cache", "清除依赖缓存": "Clean dependency cache",
"清除成功": "Clean successful", "清除成功": "Clean successful"
"日志名称": "Log Name", }
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory",
"请输入自定义日志文件夹名称": "Please enter a custom log folder name",
"请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path",
"请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens",
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters",
"未启用": "Not enabled",
"默认为 CPU 个数": "Default is the number of CPUs",
"Minimum is 4": "Minimum is 4",
"实例模式": "Instance Mode",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously",
"请选择实例模式": "Please select instance mode",
"单实例": "Single Instance",
"多实例": "Multi-Instance",
"SSH密钥": "SSH Keys",
"别名": "Alias",
"编辑SSH密钥": "Edit SSH Key",
"创建SSH密钥": "Create SSH Key",
"更新SSH密钥成功": "SSH key updated successfully",
"创建SSH密钥成功": "SSH key created successfully",
"请输入SSH密钥别名": "Please enter SSH key alias",
"请输入SSH私钥": "Please enter SSH private key",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)",
"确认删除SSH密钥": "Confirm to delete SSH key",
"批量": "Batch",
"全局SSH私钥": "Global SSH Private Key",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content",
"场景模式": "Scenario Mode",
"场景管理": "Scenario Management",
"新建场景": "New Scenario",
"编辑场景": "Edit Scenario",
"场景名称": "Scenario Name",
"场景描述": "Scenario Description",
"工作流编辑": "Workflow Editor",
"编辑工作流": "Edit Workflow",
"请输入场景名称": "Please enter scenario name",
"请输入场景描述": "Please enter scenario description",
"确认删除场景": "Confirm to delete scenario",
"确认删除选中的场景吗": "Confirm to delete selected scenarios?",
"场景": "Scenario",
"工作流": "Workflow",
"节点类型": "Node Type",
"节点标签": "Node Label",
"节点配置": "Node Config",
"添加节点": "Add Node",
"HTTP请求": "HTTP Request",
"脚本执行": "Script Execution",
"条件判断": "Condition",
"延迟": "Delay",
"循环": "Loop",
"请求URL": "Request URL",
"请求头": "Request Headers",
"请求体": "Request Body",
"脚本ID": "Script ID",
"脚本路径": "Script Path",
"脚本内容": "Script Content",
"条件表达式": "Condition Expression",
"延迟时间": "Delay Time",
"迭代次数": "Iterations",
"选择节点类型": "Select Node Type",
"请输入节点标签": "Please enter node label",
"验证工作流": "Validate Workflow",
"保存工作流": "Save Workflow",
"请选择节点": "Please select a node",
"删除节点": "Delete Node",
"确认删除节点": "Confirm to delete node",
"工作流编辑器": "Workflow Editor",
"画布": "Canvas",
"编辑面板": "Edit Panel",
"工具栏": "Toolbar",
"启用场景": "Enable Scenario",
"禁用场景": "Disable Scenario",
"确认启用场景": "Confirm to enable scenario",
"确认禁用场景": "Confirm to disable scenario",
"工作流至少需要一个节点": "Workflow requires at least one node",
"工作流验证通过": "Workflow validation passed",
"请输入URL": "Please enter URL",
"请输入条件表达式": "Please enter condition expression",
"请输入延迟时间": "Please enter delay time",
"请输入迭代次数": "Please enter iterations",
"获取场景列表失败": "Failed to fetch scenario list",
"搜索场景": "Search scenarios",
"节点": "Nodes",
"确认删除节点吗": "Are you sure you want to delete this node?",
"开始": "Start",
"结束": "End",
"新建节点": "Add Node",
"视图": "View",
"放大": "Zoom In",
"缩小": "Zoom Out",
"适应画布": "Fit to Canvas",
"条件": "Condition",
"scenario_add_node": "Add Node",
"scenario_http_node": "HTTP Request",
"scenario_script_node": "Script Execution",
"scenario_condition_node": "Condition",
"scenario_delay_node": "Delay",
"scenario_loop_node": "Loop",
"scenario_fit_view": "Fit View",
"scenario_grid_view": "Grid View",
"scenario_zoom_in": "Zoom In",
"scenario_zoom_out": "Zoom Out",
"scenario_fit_canvas": "Fit Canvas",
"scenario_lock": "Lock",
"scenario_unlock": "Unlock",
"scenario_comments": "Comments",
"scenario_undo": "Undo",
"scenario_redo": "Redo",
"scenario_alerts": "Alerts",
"scenario_test_run": "Test Run"
}
+2 -116
View File
@@ -521,119 +521,5 @@
"远程仓库缓存": "远程仓库缓存", "远程仓库缓存": "远程仓库缓存",
"SSH 文件缓存": "SSH 文件缓存", "SSH 文件缓存": "SSH 文件缓存",
"清除依赖缓存": "清除依赖缓存", "清除依赖缓存": "清除依赖缓存",
"清除成功": "清除成功", "清除成功": "清除成功"
"日志名称": "日志名称", }
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内",
"请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称",
"请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径",
"请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符",
"日志名称不能超过100个字符": "日志名称不能超过100个字符",
"未启用": "未启用",
"默认为 CPU 个数": "默认为 CPU 个数",
"最小是 4": "最小是 4",
"实例模式": "实例模式",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例",
"请选择实例模式": "请选择实例模式",
"单实例": "单实例",
"多实例": "多实例",
"SSH密钥": "SSH密钥",
"别名": "别名",
"编辑SSH密钥": "编辑SSH密钥",
"创建SSH密钥": "创建SSH密钥",
"更新SSH密钥成功": "更新SSH密钥成功",
"创建SSH密钥成功": "创建SSH密钥成功",
"请输入SSH密钥别名": "请输入SSH密钥别名",
"请输入SSH私钥": "请输入SSH私钥",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "请输入SSH私钥内容(以 -----BEGIN 开头)",
"确认删除SSH密钥": "确认删除SSH密钥",
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容",
"场景模式": "场景模式",
"场景管理": "场景管理",
"新建场景": "新建场景",
"编辑场景": "编辑场景",
"场景名称": "场景名称",
"场景描述": "场景描述",
"工作流编辑": "工作流编辑",
"编辑工作流": "编辑工作流",
"请输入场景名称": "请输入场景名称",
"请输入场景描述": "请输入场景描述",
"确认删除场景": "确认删除场景",
"确认删除选中的场景吗": "确认删除选中的场景吗",
"场景": "场景",
"工作流": "工作流",
"节点类型": "节点类型",
"节点标签": "节点标签",
"节点配置": "节点配置",
"添加节点": "添加节点",
"HTTP请求": "HTTP请求",
"脚本执行": "脚本执行",
"条件判断": "条件判断",
"延迟": "延迟",
"循环": "循环",
"请求URL": "请求URL",
"请求头": "请求头",
"请求体": "请求体",
"脚本ID": "脚本ID",
"脚本路径": "脚本路径",
"脚本内容": "脚本内容",
"条件表达式": "条件表达式",
"延迟时间": "延迟时间",
"迭代次数": "迭代次数",
"选择节点类型": "选择节点类型",
"请输入节点标签": "请输入节点标签",
"验证工作流": "验证工作流",
"保存工作流": "保存工作流",
"请选择节点": "请选择节点",
"删除节点": "删除节点",
"确认删除节点": "确认删除节点",
"工作流编辑器": "工作流编辑器",
"画布": "画布",
"编辑面板": "编辑面板",
"工具栏": "工具栏",
"启用场景": "启用场景",
"禁用场景": "禁用场景",
"确认启用场景": "确认启用场景",
"确认禁用场景": "确认禁用场景",
"工作流至少需要一个节点": "工作流至少需要一个节点",
"工作流验证通过": "工作流验证通过",
"请输入URL": "请输入URL",
"请输入条件表达式": "请输入条件表达式",
"请输入延迟时间": "请输入延迟时间",
"请输入迭代次数": "请输入迭代次数",
"获取场景列表失败": "获取场景列表失败",
"搜索场景": "搜索场景",
"节点": "节点",
"确认删除节点吗": "确认删除节点吗?",
"开始": "开始",
"结束": "结束",
"新建节点": "新建节点",
"视图": "视图",
"放大": "放大",
"缩小": "缩小",
"适应画布": "适应画布",
"条件": "条件",
"scenario_add_node": "添加节点",
"scenario_http_node": "HTTP 请求",
"scenario_script_node": "脚本执行",
"scenario_condition_node": "条件判断",
"scenario_delay_node": "延迟",
"scenario_loop_node": "循环",
"scenario_fit_view": "适应视图",
"scenario_grid_view": "网格视图",
"scenario_zoom_in": "放大",
"scenario_zoom_out": "缩小",
"scenario_fit_canvas": "适应画布",
"scenario_lock": "锁定",
"scenario_unlock": "解锁",
"scenario_comments": "注释",
"scenario_undo": "撤销",
"scenario_redo": "重做",
"scenario_alerts": "提醒",
"scenario_test_run": "测试运行"
}
+17 -32
View File
@@ -66,7 +66,6 @@ const SHOW_TAB_COUNT = 10;
const Crontab = () => { const Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>(); const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [ const columns: ColumnProps<ICrontab>[] = [
{ {
title: intl.get('名称'), title: intl.get('名称'),
@@ -248,8 +247,8 @@ const Crontab = () => {
> >
{record.last_execution_time {record.last_execution_time
? dayjs(record.last_execution_time * 1000).format( ? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
) )
: '-'} : '-'}
</span> </span>
); );
@@ -273,12 +272,6 @@ const Crontab = () => {
title: intl.get('关联订阅'), title: intl.get('关联订阅'),
width: 185, width: 185,
render: (text, record: any) => record?.subscription?.name || '-', render: (text, record: any) => record?.subscription?.name || '-',
key: 'sub_id',
dataIndex: 'sub_id',
filters: allSubscriptions.map((sub) => ({
text: sub.name || sub.alias,
value: sub.id,
})),
}, },
{ {
title: intl.get('操作'), title: intl.get('操作'),
@@ -368,10 +361,11 @@ const Crontab = () => {
const getCrons = () => { const getCrons = () => {
setLoading(true); setLoading(true);
const { page, size, sorter, filters } = pageConf; const { page, size, sorter, filters } = pageConf;
let url = `${config.apiPrefix let url = `${
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( config.apiPrefix
filters, }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
)}`; filters,
)}`;
if (sorter && sorter.column && sorter.order) { if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({ url += `&sorter=${JSON.stringify({
field: sorter.column.key, field: sorter.column.key,
@@ -529,8 +523,9 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => { const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') title: `确认${
}`, record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -545,7 +540,8 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable' `${config.apiPrefix}crons/${
record.isDisabled === 1 ? 'enable' : 'disable'
}`, }`,
[record.id], [record.id],
) )
@@ -569,8 +565,9 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => { const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') title: `确认${
}`, record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: ( content: (
<> <>
{intl.get('确认')} {intl.get('确认')}
@@ -585,7 +582,8 @@ const Crontab = () => {
onOk() { onOk() {
request request
.put( .put(
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin' `${config.apiPrefix}crons/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`, }`,
[record.id], [record.id],
) )
@@ -801,20 +799,8 @@ const Crontab = () => {
} }
}, [viewConf, enabledCronViews]); }, [viewConf, enabledCronViews]);
const getAllSubscriptions = () => {
request
.get(`${config.apiPrefix}subscriptions`)
.then(({ code, data }) => {
if (code === 200) {
setAllSubscriptions(data || []);
}
})
.catch(() => {});
};
useEffect(() => { useEffect(() => {
getCronViews(); getCronViews();
getAllSubscriptions();
}, []); }, []);
const viewAction = (key: string) => { const viewAction = (key: string) => {
@@ -1028,7 +1014,6 @@ const Crontab = () => {
)} )}
<Table <Table
columns={columns} columns={columns}
sortDirections={['descend', 'ascend']}
pagination={{ pagination={{
current: pageConf.page, current: pageConf.page,
pageSize: pageConf.size, pageSize: pageConf.size,
+1 -1
View File
@@ -55,7 +55,7 @@ const CronLogModal = ({
const log = data as string; const log = data as string;
setValue(log || intl.get("暂无日志")); setValue(log || intl.get("暂无日志"));
const hasNext = Boolean( const hasNext = Boolean(
log && !logEnded(log) && !log.includes("日志不存在") && !log.includes("日志设置为忽略"), log && !logEnded(log) && !log.includes("日志不存在"),
); );
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) { if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
setTimeout(() => { setTimeout(() => {
+3 -47
View File
@@ -3,7 +3,7 @@ import config from '@/utils/config';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space, message } from 'antd'; import { Button, Form, Input, Modal, Select, Space, message } from 'antd';
import { CronExpressionParser } from 'cron-parser'; import cronParse from 'cron-parser';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { getScheduleType, scheduleTypeMap } from './const'; import { getScheduleType, scheduleTypeMap } from './const';
@@ -91,7 +91,7 @@ const CronModal = ({
{ required: true }, { required: true },
{ {
validator: (_, value) => { validator: (_, value) => {
if (!value || CronExpressionParser.parse(value).hasNext()) { if (!value || cronParse.parseExpression(value).hasNext()) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject(intl.get('Cron表达式格式有误')); return Promise.reject(intl.get('Cron表达式格式有误'));
@@ -180,51 +180,6 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}> <Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup /> <EditableTagGroup />
</Form.Item> </Form.Item>
<Form.Item
name="allow_multiple_instances"
label={intl.get('实例模式')}
tooltip={intl.get(
'单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例',
)}
>
<Select placeholder={intl.get('请选择实例模式')}>
<Select.Option value={0}>{intl.get('单实例')}</Select.Option>
<Select.Option value={1}>{intl.get('多实例')}</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="log_name"
label={intl.get('日志名称')}
tooltip={intl.get(
'自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内',
)}
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
if (value === '/dev/null') return Promise.resolve();
if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符'));
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'),
);
}
return Promise.resolve();
},
},
]}
>
<Input
placeholder={intl.get('请输入自定义日志文件夹名称或 /dev/null')}
maxLength={200}
/>
</Form.Item>
<Form.Item <Form.Item
name="task_before" name="task_before"
label={intl.get('执行前')} label={intl.get('执行前')}
@@ -357,3 +312,4 @@ const CronLabelModal = ({
}; };
export { CronLabelModal, CronModal as default }; export { CronLabelModal, CronModal as default };
-1
View File
@@ -37,7 +37,6 @@ export interface ICrontab {
nextRunTime: Date; nextRunTime: Date;
sub_id: number; sub_id: number;
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
allow_multiple_instances?: 1 | 0;
} }
export enum ScheduleType { export enum ScheduleType {
+36 -111
View File
@@ -1,42 +1,47 @@
import useTableScrollHeight from '@/hooks/useTableScrollHeight'; import intl from 'react-intl-universal';
import { SharedContext } from '@/layouts'; import React, {
import config from '@/utils/config'; useCallback,
import { request } from '@/utils/http'; useRef,
import { exportJson } from '@/utils/index'; useState,
useEffect,
useMemo,
} from 'react';
import {
Button,
message,
Modal,
Table,
Tag,
Space,
Typography,
Tooltip,
Input,
UploadProps,
Upload,
} from 'antd';
import { import {
CheckCircleOutlined,
DeleteOutlined,
EditOutlined, EditOutlined,
PushpinFilled, DeleteOutlined,
PushpinOutlined, SyncOutlined,
CheckCircleOutlined,
StopOutlined, StopOutlined,
UploadOutlined, UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout'; import { PageContainer } from '@ant-design/pro-layout';
import { useOutletContext } from '@umijs/max'; import { request } from '@/utils/http';
import { import EnvModal from './modal';
Button, import EditNameModal from './editNameModal';
Input,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
Upload,
UploadProps,
message,
} from 'antd';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import intl from 'react-intl-universal';
import { useVT } from 'virtualizedtableforantd4';
import Copy from '../../components/copy';
import EditNameModal from './editNameModal';
import './index.less'; import './index.less';
import EnvModal from './modal'; import { exportJson } from '@/utils/index';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import Copy from '../../components/copy';
import { useVT } from 'virtualizedtableforantd4';
import dayjs from 'dayjs';
const { Paragraph } = Typography; const { Paragraph } = Typography;
const { Search } = Input; const { Search } = Input;
@@ -54,15 +59,11 @@ enum StatusColor {
enum OperationName { enum OperationName {
'启用', '启用',
'禁用', '禁用',
'置顶',
'取消置顶',
} }
enum OperationPath { enum OperationPath {
'enable', 'enable',
'disable', 'disable',
'pin',
'unpin',
} }
const type = 'DragableBodyRow'; const type = 'DragableBodyRow';
@@ -180,7 +181,7 @@ const Env = () => {
{ {
title: intl.get('操作'), title: intl.get('操作'),
key: 'action', key: 'action',
width: 160, width: 120,
render: (text: string, record: any, index: number) => { render: (text: string, record: any, index: number) => {
const isPc = !isPhone; const isPc = !isPhone;
return ( return (
@@ -207,23 +208,6 @@ const Env = () => {
)} )}
</a> </a>
</Tooltip> </Tooltip>
<Tooltip
title={
isPc
? record.isPinned === 1
? intl.get('取消置顶')
: intl.get('置顶')
: ''
}
>
<a onClick={() => pinOrUnpinEnv(record, index)}>
{record.isPinned === 1 ? (
<PushpinFilled />
) : (
<PushpinOutlined />
)}
</a>
</Tooltip>
<Tooltip title={isPc ? intl.get('删除') : ''}> <Tooltip title={isPc ? intl.get('删除') : ''}>
<a onClick={() => deleteEnv(record, index)}> <a onClick={() => deleteEnv(record, index)}>
<DeleteOutlined /> <DeleteOutlined />
@@ -321,51 +305,6 @@ const Env = () => {
setIsModalVisible(true); setIsModalVisible(true);
}; };
const pinOrUnpinEnv = (record: any, index: number) => {
Modal.confirm({
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
{record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')}
Env{' '}
<Paragraph
style={{ wordBreak: 'break-all', display: 'inline' }}
ellipsis={{ rows: 6, expandable: true }}
type="warning"
copyable
>
{record.name}: {record.value}
</Paragraph>{' '}
{intl.get('吗')}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}envs/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`,
[record.id],
)
.then(({ code, data }) => {
if (code === 200) {
message.success(
`${
record.isPinned === 1
? intl.get('取消置顶')
: intl.get('置顶')
}${intl.get('成功')}`,
);
getEnvs();
}
});
},
});
};
const deleteEnv = (record: any, index: number) => { const deleteEnv = (record: any, index: number) => {
Modal.confirm({ Modal.confirm({
title: intl.get('确认删除'), title: intl.get('确认删除'),
@@ -650,20 +589,6 @@ const Env = () => {
> >
{intl.get('批量禁用')} {intl.get('批量禁用')}
</Button> </Button>
<Button
type="primary"
onClick={() => operateEnvs(2)}
style={{ marginLeft: 8, marginBottom: 5 }}
>
{intl.get('批量置顶')}
</Button>
<Button
type="primary"
onClick={() => operateEnvs(3)}
style={{ marginLeft: 8, marginRight: 8 }}
>
{intl.get('批量取消置顶')}
</Button>
<span style={{ marginLeft: 8 }}> <span style={{ marginLeft: 8 }}>
{intl.get('已选择')} {intl.get('已选择')}
<a>{selectedRowIds?.length}</a> <a>{selectedRowIds?.length}</a>
@@ -1 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" focusable="false" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M9.56066 2.43934C10.1464 3.02513 10.1464 3.97487 9.56066 4.56066L7.12132 7H14.75C18.8353 7 22 10.5796 22 14.5C22 18.4204 18.8353 22 14.75 22H11.5C10.6716 22 10 21.3284 10 20.5C10 19.6716 10.6716 19 11.5 19H14.75C17.016 19 19 16.9308 19 14.5C19 12.0692 17.016 10 14.75 10H7.12132L9.56066 12.4393C10.1464 13.0251 10.1464 13.9749 9.56066 14.5607C8.97487 15.1464 8.02513 15.1464 7.43934 14.5607L2.43934 9.56066C1.85355 8.97487 1.85355 8.02513 2.43934 7.43934L7.43934 2.43934C8.02513 1.85355 8.97487 1.85355 9.56066 2.43934Z" fill="#54A9FF"></path></svg>

Before

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44" height="45" viewBox="0 0 44 45" fill="none" class="injected-svg" data-src="https://lf3-static.bytednsdoc.com/obj/eden-cn/uvpahtvabh_lm_zhhwh/ljhwZthlaukjlkulzlp/activity_icons/exclusive-split-0518.svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4705 14.0152C15.299 12.8436 15.299 10.944 16.4705 9.77244L20.7131 5.5297C21.8846 4.3581 23.784 4.3581 24.9556 5.5297L29.1981 9.77244C30.3697 10.944 30.3697 12.8436 29.1981 14.0152L25.1206 18.0929H32.6674C36.5334 18.0929 39.6674 21.2269 39.6674 25.0929V33.154V33.3271V37.154C39.6674 38.2585 38.7719 39.154 37.6674 39.154H33.6674C32.5628 39.154 31.6674 38.2585 31.6674 37.154V33.3271V33.154V26.0929H23.5948H15.6674V33.1327L17.2685 33.1244C18.8397 33.1163 19.6322 35.0156 18.5212 36.1266L12.7374 41.9103C12.0506 42.5971 10.9371 42.5971 10.2503 41.9103L4.52588 36.1859C3.42107 35.0811 4.19797 33.1917 5.76038 33.1837L7.66737 33.1739V25.0929C7.66737 21.227 10.8014 18.0929 14.6674 18.0929H20.5481L16.4705 14.0152Z" fill="url(#paint0_linear_2752_183702-7)"/>
<defs>
<linearGradient id="paint0_linear_2752_183702-7" x1="38.52" y1="43.3915" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.1112 5.9556C31.0632 4.90759 29.2716 5.65513 29.2792 7.13722L29.2873 8.71503H12.1184C8.37321 8.71503 5.33711 11.7511 5.33711 15.4963H5.34238C5.33971 15.538 5.33839 15.58 5.33846 15.6224L5.34892 21.6473C5.35171 23.2499 7.22508 24.1194 8.4509 23.087L12.2732 19.8679C12.9121 19.3298 13.2806 18.5369 13.2801 17.7016L13.2795 16.715H29.3285L29.3351 17.9931C29.3427 19.4669 31.125 20.1998 32.1671 19.1576L37.5671 13.7576C38.215 13.1098 38.215 12.0594 37.5671 11.4115L32.1112 5.9556ZM13.279 15.8694L13.2788 15.6243C13.2788 15.5813 13.2773 15.5386 13.2745 15.4963H13.3371C13.3371 15.6265 13.3167 15.7518 13.279 15.8694ZM11.4759 37.9731C12.5239 39.0211 14.3156 38.2736 14.3079 36.7915L14.2998 35.2137H31.4687C35.2139 35.2137 38.25 32.1776 38.25 28.4324H38.2447C38.2474 28.3907 38.2487 28.3487 38.2487 28.3063L38.2382 22.2814C38.2354 20.6788 36.362 19.8093 35.1362 20.8417L31.314 24.0608C30.675 24.599 30.3065 25.3918 30.307 26.2272L30.3076 27.2137H14.2586L14.252 25.9356C14.2444 24.4618 12.4622 23.7289 11.42 24.7711L6.02002 30.1711C5.37215 30.819 5.37215 31.8694 6.02002 32.5172L11.4759 37.9731ZM30.3082 28.0593L30.3083 28.3044C30.3083 28.3474 30.3098 28.3901 30.3127 28.4324H30.25C30.25 28.3023 30.2704 28.1769 30.3082 28.0593Z" fill="url(#paint0_linear_775_1137)"/>
<defs>
<linearGradient id="paint0_linear_775_1137" x1="6.39609" y1="39.3063" x2="32.5905" y2="4.10488" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,11 +0,0 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="44" height="44">
<defs>
<linearGradient id="paint0_linear_memory" x1="1024" y1="1024" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
<path d="M960 160v96c0 88.4-200.6 160-448 160S64 344.4 64 256V160C64 71.6 264.6 0 512 0s448 71.6 448 160z m-109.6 269.4c41.6-14.8 79.8-33.8 109.6-57.2V576c0 88.4-200.6 160-448 160S64 664.4 64 576V372.2c29.8 23.6 68 42.4 109.6 57.2C263.4 461.4 383 480 512 480s248.6-18.6 338.4-50.6zM64 692.2c29.8 23.6 68 42.4 109.6 57.2C263.4 781.4 383 800 512 800s248.6-18.6 338.4-50.6c41.6-14.8 79.8-33.8 109.6-57.2V864c0 88.4-200.6 160-448 160S64 952.4 64 864v-171.8z"
fill="url(#paint0_linear_memory)"></path>
</svg>

Before

Width:  |  Height:  |  Size: 914 B

@@ -1,36 +0,0 @@
export function IconMouse(props: { width?: number; height?: number }) {
const { width, height } = props;
return (
<svg
width={width || 34}
height={height || 52}
viewBox="0 0 34 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M30.9998 16.6666V35.3333C30.9998 37.5748 30.9948 38.4695 30.9 39.1895C30.2108 44.4247 26.0912 48.5443 20.856 49.2335C20.1361 49.3283 19.2413 49.3333 16.9998 49.3333C14.7584 49.3333 13.8636 49.3283 13.1437 49.2335C7.90847 48.5443 3.78888 44.4247 3.09965 39.1895C3.00487 38.4695 2.99984 37.5748 2.99984 35.3333V16.6666C2.99984 14.4252 3.00487 13.5304 3.09965 12.8105C3.78888 7.57528 7.90847 3.45569 13.1437 2.76646C13.7232 2.69017 14.4159 2.67202 15.8332 2.66785V9.86573C14.4738 10.3462 13.4998 11.6426 13.4998 13.1666V17.8332C13.4998 19.3571 14.4738 20.6536 15.8332 21.1341V23.6666C15.8332 24.3109 16.3555 24.8333 16.9998 24.8333C17.6442 24.8333 18.1665 24.3109 18.1665 23.6666V21.1341C19.5259 20.6536 20.4998 19.3572 20.4998 17.8332V13.1666C20.4998 11.6426 19.5259 10.3462 18.1665 9.86571V2.66785C19.5837 2.67202 20.2765 2.69017 20.856 2.76646C26.0912 3.45569 30.2108 7.57528 30.9 12.8105C30.9948 13.5304 30.9998 14.4252 30.9998 16.6666ZM0.666504 16.6666C0.666504 14.4993 0.666504 13.4157 0.786276 12.5059C1.61335 6.22368 6.55687 1.28016 12.8391 0.453085C13.7489 0.333313 14.8325 0.333313 16.9998 0.333313C19.1671 0.333313 20.2508 0.333313 21.1605 0.453085C27.4428 1.28016 32.3863 6.22368 33.2134 12.5059C33.3332 13.4157 33.3332 14.4994 33.3332 16.6666V35.3333C33.3332 37.5006 33.3332 38.5843 33.2134 39.494C32.3863 45.7763 27.4428 50.7198 21.1605 51.5469C20.2508 51.6666 19.1671 51.6666 16.9998 51.6666C14.8325 51.6666 13.7489 51.6666 12.8391 51.5469C6.55687 50.7198 1.61335 45.7763 0.786276 39.494C0.666504 38.5843 0.666504 37.5006 0.666504 35.3333V16.6666ZM15.8332 13.1666C15.8332 13.0011 15.8676 12.8437 15.9297 12.7011C15.9886 12.566 16.0722 12.4443 16.1749 12.3416C16.386 12.1305 16.6777 11.9999 16.9998 11.9999C17.6435 11.9999 18.1654 12.5212 18.1665 13.1646L18.1665 13.1666V17.8332L18.1665 17.8353C18.1665 17.8364 18.1665 17.8376 18.1665 17.8387C18.1661 17.9132 18.1588 17.986 18.1452 18.0565C18.0853 18.3656 17.9033 18.6312 17.6515 18.8011C17.4655 18.9266 17.2412 18.9999 16.9998 18.9999C16.3555 18.9999 15.8332 18.4776 15.8332 17.8332V13.1666Z"
fill="currentColor"
fillOpacity="0.8"
/>
</svg>
);
}
export const IconMouseTool = () => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.5 8C4.5 4.13401 7.63401 1 11.5 1H12.5C16.366 1 19.5 4.13401 19.5 8V17C19.5 20.3137 16.8137 23 13.5 23H10.5C7.18629 23 4.5 20.3137 4.5 17V8ZM11.2517 3.00606C8.60561 3.13547 6.5 5.32184 6.5 8V17C6.5 19.2091 8.29086 21 10.5 21H13.5C15.7091 21 17.5 19.2091 17.5 17V8C17.5 5.32297 15.3962 3.13732 12.7517 3.00622V5.28013C13.2606 5.54331 13.6074 6.06549 13.6074 6.66669V8.75759C13.6074 9.35879 13.2606 9.88097 12.7517 10.1441V11.4091C12.7517 11.8233 12.4159 12.1591 12.0017 12.1591C11.5875 12.1591 11.2517 11.8233 11.2517 11.4091V10.1457C10.7411 9.88298 10.3931 9.35994 10.3931 8.75759V6.66669C10.3931 6.06433 10.7411 5.5413 11.2517 5.27862V3.00606ZM12.0017 6.14397C11.7059 6.14397 11.466 6.38381 11.466 6.67968V8.74462C11.466 9.03907 11.7036 9.27804 11.9975 9.28031L12.0002 9.28032C12.0456 9.28032 12.0896 9.27482 12.1316 9.26447C12.3401 9.21256 12.5002 9.0386 12.5318 8.82287C12.5345 8.80149 12.5359 8.7797 12.5359 8.75759V6.66669C12.5359 6.64463 12.5345 6.62288 12.5318 6.60154C12.4999 6.38354 12.3368 6.20817 12.1252 6.15826C12.0856 6.14891 12.0442 6.14397 12.0017 6.14397Z"
></path>
</svg>
);
@@ -1,51 +0,0 @@
export function IconPad(props: { width?: number; height?: number }) {
const { width, height } = props;
return (
<svg
width={width || 48}
height={height || 38}
viewBox="0 0 48 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="1.83317"
y="1.49998"
width="44.3333"
height="35"
rx="3.5"
stroke="currentColor"
strokeOpacity="0.8"
strokeWidth="2.33333"
/>
<path
d="M14.6665 30.6667H33.3332"
stroke="currentColor"
strokeOpacity="0.8"
strokeWidth="2.33333"
strokeLinecap="round"
/>
</svg>
);
}
export const IconPadTool = () => (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z"
></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z"
></path>
</svg>
);
@@ -1,6 +0,0 @@
<svg t="1724931640169" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1762"
width="20" height="20">
<path
d="M1024 800a96 96 0 0 1-96 96h-192a96 96 0 0 1-96-96v-64a96 96 0 0 1 96-96h62.656c-2.56-44.416-11.84-70.72-24.576-79.36-15.36-10.304-32.896-12.928-79.552-13.632l-17.28-0.32a436.544 436.544 0 0 1-24.32-1.152l-14.72-1.472a185.792 185.792 0 0 1-75.712-24.832c-19.968-12.032-36.608-33.92-50.944-65.92-14.272 32-30.912 53.888-50.88 65.92a185.792 185.792 0 0 1-75.648 24.832l-14.72 1.472c-7.936 0.64-14.72 0.96-24.32 1.152l-17.28 0.32c-46.72 0.64-64.256 3.328-79.616 13.696-12.736 8.576-22.016 34.88-24.512 79.296H288A96 96 0 0 1 384 736v64A96 96 0 0 1 288 896h-192A96 96 0 0 1 0 800v-64A96 96 0 0 1 96 640h64.448c3.2-65.664 19.52-109.888 52.864-132.352 24.96-16.896 47.04-22.208 89.28-23.936l47.36-1.152c3.84-0.128 7.168-0.256 10.496-0.512l4.992-0.32c25.984-1.984 45.312-7.04 62.144-17.28 12.8-7.68 27.392-34.752 41.152-80.32L416 384A96 96 0 0 1 320 288v-64A96 96 0 0 1 416 128h192A96 96 0 0 1 704 224v64A96 96 0 0 1 608 384l-53.504 0.128c13.696 45.568 28.352 72.64 41.088 80.32 16.832 10.24 36.16 15.296 62.208 17.28l4.992 0.32c3.264 0.256 6.592 0.384 10.432 0.512l47.36 1.152c42.24 1.728 64.32 7.04 89.344 23.936 33.28 22.4 49.6 66.688 52.8 132.352h65.28a96 96 0 0 1 96 96z m-704 0v-64a32 32 0 0 0-32-32h-192a32 32 0 0 0-32 32v64a32 32 0 0 0 32 32h192a32 32 0 0 0 32-32z m320-512v-64a32 32 0 0 0-32-32h-192a32 32 0 0 0-32 32v64a32 32 0 0 0 32 32h192a32 32 0 0 0 32-32z m320 512v-64a32 32 0 0 0-32-32h-192a32 32 0 0 0-32 32v64a32 32 0 0 0 32 32h192a32 32 0 0 0 32-32z"
fill="#666666" p-id="1763"></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,10 +0,0 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="44" height="44">
<defs>
<linearGradient id="paint0_linear_robot" x1="1024" y1="1024" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
<path d="M717.12 274H762c82.842 0 150 67.158 150 150v200c0 82.842-67.158 150-150 150H262c-82.842 0-150-67.158-150-150V424c0-82.842 67.158-150 150-150h44.88l-18.268-109.602c-4.086-24.514 12.476-47.7 36.99-51.786 24.514-4.086 47.7 12.476 51.786 36.99l20 120c0.246 1.472 0.416 2.94 0.516 4.398h228.192c0.1-1.46 0.27-2.926 0.516-4.398l20-120c4.086-24.514 27.272-41.076 51.786-36.99 24.514 4.086 41.076 27.272 36.99 51.786L717.12 274zM308 484v40c0 24.852 20.148 45 45 45S398 548.852 398 524v-40c0-24.852-20.148-45-45-45S308 459.148 308 484z m318 0v40c0 24.852 20.148 45 45 45S716 548.852 716 524v-40c0-24.852-20.148-45-45-45S626 459.148 626 484zM312 912c-24.852 0-45-20.148-45-45S287.148 822 312 822h400c24.852 0 45 20.148 45 45S736.852 912 712 912H312z"
fill="url(#paint0_linear_robot)" ></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

@@ -1,11 +0,0 @@
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="44" height="4">
<defs>
<linearGradient id="paint0_linear_tool" x1="1024" y1="1024" x2="8.09686" y2="4.6982" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"/>
<stop offset="0.997908" stop-color="#33A9FF"/>
</linearGradient>
</defs>
<path d="M1024 716.8v204.8a102.4 102.4 0 0 1-102.4 102.4h-204.8v-102.4a102.4 102.4 0 0 0-102.4-102.4 102.4 102.4 0 0 0-102.4 102.4v102.4H307.2a102.4 102.4 0 0 1-102.4-102.4v-204.8H102.4a102.4 102.4 0 0 1-102.4-102.4 102.4 102.4 0 0 1 102.4-102.4h102.4V307.2c0-56.32 46.08-102.4 102.4-102.4h204.8V102.4a102.4 102.4 0 0 1 102.4-102.4 102.4 102.4 0 0 1 102.4 102.4v102.4h204.8a102.4 102.4 0 0 1 102.4 102.4v204.8h-102.4a102.4 102.4 0 0 0-102.4 102.4 102.4 102.4 0 0 0 102.4 102.4h102.4z"
fill="url(#paint0_linear_tool)"></path>
</svg>

Before

Width:  |  Height:  |  Size: 939 B

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="45" viewBox="0 0 44 45" fill="none" >
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 10.3662C4 7.0525 6.68629 4.36621 10 4.36621H34C37.3137 4.36621 40 7.0525 40 10.3662V26.3662C40 29.6799 37.3137 32.3662 34 32.3662H10C6.68629 32.3662 4 29.6799 4 26.3662V10.3662ZM18.8578 15.7304L18.1723 18.4725C17.8941 19.5855 16.8941 20.3662 15.7469 20.3662H11.2415C10.413 20.3662 9.74145 19.6946 9.74145 18.8662C9.74145 18.0378 10.413 17.3662 11.2415 17.3662H15.3565L16.3224 13.5027C16.9107 11.1497 20.1682 10.9286 21.069 13.1805L24.5755 21.9468L25.8892 18.8814C26.2831 17.9622 27.187 17.3662 28.187 17.3662H32.7585C33.587 17.3662 34.2585 18.0378 34.2585 18.8662C34.2585 19.6946 33.587 20.3662 32.7585 20.3662H28.5168L26.8574 24.2381C25.98 26.2853 23.0655 26.2497 22.2383 24.1817L18.8578 15.7304ZM13 36.3662C11.8954 36.3662 11 37.2616 11 38.3662C11 39.4708 11.8954 40.3662 13 40.3662H31C32.1046 40.3662 33 39.4708 33 38.3662C33 37.2616 32.1046 36.3662 31 36.3662H13Z" fill="url(#paint0_linear_2752_183706-1)"></path>
<defs>
<linearGradient id="paint0_linear_2752_183706-1" x1="38.8417" y1="41.2869" x2="10.2834" y2="2.81176" gradientUnits="userSpaceOnUse">
<stop stop-color="#3370FF"></stop>
<stop offset="0.997908" stop-color="#33A9FF"></stop>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,65 +0,0 @@
import {
type FlowNodeEntity,
FlowNodeRenderData,
useClientContext,
} from '@flowgram.ai/fixed-layout-editor';
import { ToolNodeRegistry } from '../../nodes/agent/tool';
import { PlusOutlined } from '@ant-design/icons';
interface PropsType {
node: FlowNodeEntity;
}
export function AgentAdder(props: PropsType) {
const { node } = props;
const nodeData = node.firstChild?.getData<FlowNodeRenderData>(FlowNodeRenderData);
const ctx = useClientContext();
async function addPort() {
ctx.operation.addNode(ToolNodeRegistry.onAdd!(ctx, node), {
parent: node,
});
}
/**
* 1. Tools can always be added
* 2. LLM/Memory can only be added when there is no block
*/
const canAdd = node.flowNodeType === 'agentTools' || node.blocks.length === 0;
if (!canAdd) {
return null;
}
return (
<div
style={{
display: 'flex',
color: '#fff',
background: 'rgb(187, 191, 196)',
width: 20,
height: 20,
borderRadius: 10,
overflow: 'hidden',
}}
onMouseEnter={() => nodeData?.toggleMouseEnter()}
onMouseLeave={() => nodeData?.toggleMouseLeave()}
>
<div
style={{
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
onClick={() => addPort()}
>
<PlusOutlined />
</div>
</div>
);
}
@@ -1,41 +0,0 @@
import { FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
import { Typography } from 'antd';
interface PropsType {
node: FlowNodeEntity;
}
const Text = Typography.Text;
export function AgentLabel(props: PropsType) {
const { node } = props;
let label = 'Default';
switch (node.flowNodeType) {
case 'agentMemory':
label = 'Memory';
break;
case 'agentLLM':
label = 'LLM';
break;
case 'agentTools':
label = 'Tools';
}
return (
<Text
ellipsis={{ tooltip: true }}
style={{
maxWidth: 65,
fontSize: 12,
textAlign: 'center',
padding: '2px',
backgroundColor: 'var(--g-editor-background)',
color: '#8F959E',
}}
>
{label}
</Text>
);
}
@@ -1,69 +0,0 @@
import { useCallback } from 'react';
import { usePanelManager } from '@flowgram.ai/panel-manager-plugin';
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/fixed-layout-editor';
import { ConfigProvider } from 'antd';
import { NodeRenderContext } from '../../context';
import { BaseNodeStyle, ErrorIcon } from './styles';
import { nodeFormPanelFactory } from '../sidebar';
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
/**
* Provides methods related to node rendering
*
*/
const nodeRender = useNodeRender();
/**
* It can only be used when nodeEngine is enabled
* 使
*/
const form = nodeRender.form;
/**
* Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library
* Tooltip , ui
*/
const getPopupContainer = useCallback(() => node.renderData.node || document.body, []);
const panelManager = usePanelManager();
return (
<ConfigProvider getPopupContainer={getPopupContainer}>
{form?.state.invalid && <ErrorIcon />}
<BaseNodeStyle
/*
* onMouseEnter is added to a fixed layout node primarily to listen for hover highlighting of branch lines
* onMouseEnter 线 hover
**/
onMouseEnter={nodeRender.onMouseEnter}
onMouseLeave={nodeRender.onMouseLeave}
className={nodeRender.activated ? 'activated' : ''}
onClick={() => {
if (nodeRender.dragging) {
return;
}
panelManager.open(nodeFormPanelFactory.key, 'right', {
props: {
nodeId: nodeRender.node.id,
},
});
}}
style={{
/**
* Lets you precisely control the style of branch nodes
*
* isBlockIcon: 整个 condition
* isBlockOrderIcon: 分支的第一个节点
*/
...(nodeRender.isBlockOrderIcon || nodeRender.isBlockIcon ? {} : {}),
...nodeRender.node.getNodeRegistry().meta.style,
opacity: nodeRender.dragging ? 0.3 : 1,
outline: form?.state.invalid ? '1px solid red' : 'none',
}}
>
<NodeRenderContext.Provider value={nodeRender}>{form?.render()}</NodeRenderContext.Provider>
</BaseNodeStyle>
</ConfigProvider>
);
};
@@ -1,33 +0,0 @@
import { InfoCircleFilled } from '@ant-design/icons';
import styled from 'styled-components';
export const BaseNodeStyle = styled.div`
align-items: flex-start;
background-color: #fff;
border: 1px solid rgba(6, 7, 9, 0.15);
border-radius: 8px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
width: 360px;
cursor: default;
&.activated {
border: 1px solid #82a7fc;
}
`;
export const ErrorIcon = () => (
<InfoCircleFilled
style={{
position: 'absolute',
color: 'red',
left: -6,
top: -6,
zIndex: 1,
background: 'white',
borderRadius: 8,
}}
/>
);
@@ -1,58 +0,0 @@
import { type FlowNodeEntity, useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { CatchBlockNodeRegistry } from '../../nodes/catch-block';
import { CaseNodeRegistry } from '../../nodes/case';
import { Container } from './styles';
import { PlusOutlined } from '@ant-design/icons';
interface PropsType {
activated?: boolean;
node: FlowNodeEntity;
}
export default function BranchAdder(props: PropsType) {
const { activated, node } = props;
const nodeData = node.firstChild!.renderData;
const ctx = useClientContext();
const { operation, playground } = ctx;
const { isVertical } = node;
function addBranch() {
const block = operation.addBlock(
node,
node.flowNodeType === 'switch'
? CaseNodeRegistry.onAdd!(ctx, node)
: CatchBlockNodeRegistry.onAdd!(ctx, node),
{
index: 0,
}
);
setTimeout(() => {
playground.scrollToView({
bounds: block.bounds,
scrollToCenter: true,
});
}, 10);
}
if (playground.config.readonlyOrDisabled) return null;
return (
<Container
isVertical={isVertical}
activated={activated}
onMouseEnter={() => nodeData?.toggleMouseEnter()}
onMouseLeave={() => nodeData?.toggleMouseLeave()}
>
<div
onClick={() => {
addBranch();
}}
aria-hidden="true"
style={{ flexGrow: 1, textAlign: 'center' }}
>
<PlusOutlined />
</div>
</Container>
);
}
@@ -1,24 +0,0 @@
import styled from 'styled-components';
export const Container = styled.div<{ activated?: boolean; isVertical: boolean }>`
width: 28px;
height: 18px;
background: ${(props) => (props.activated ? '#82A7FC' : 'rgb(187, 191, 196)')};
display: flex;
border-radius: 9px;
justify-content: space-evenly;
align-items: center;
color: #fff;
font-size: 10px;
font-weight: bold;
transform: ${(props) => (props.isVertical ? '' : 'rotate(90deg)')};
div {
display: flex;
justify-content: center;
align-items: center;
svg {
width: 12px;
height: 12px;
}
}
`;
@@ -1,54 +0,0 @@
import type { FlowNodeEntity, FlowNodeJSON, Xor } from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeRegistries } from '../../nodes';
import { Icon } from '../../form-components/form-header/styles';
import { UIDragNodeContainer, UIDragCounts } from './styles';
export type PropsType = Xor<
{
dragStart: FlowNodeEntity;
},
{
dragJSON: FlowNodeJSON;
}
> & {
dragNodes: FlowNodeEntity[];
};
export function DragNode(props: PropsType): JSX.Element {
const { dragStart, dragNodes, dragJSON } = props;
const icon = FlowNodeRegistries.find(
(registry) => registry.type === dragStart?.flowNodeType || dragJSON?.type
)?.info?.icon;
const dragLength = (dragNodes || [])
.map((_node) =>
_node.allCollapsedChildren.length
? _node.allCollapsedChildren.filter((_n) => !_n.hidden).length
: 1
)
.reduce((acm, curr) => acm + curr, 0);
return (
<UIDragNodeContainer>
<Icon src={icon} />
{dragStart?.id || dragJSON?.id}
{dragLength > 1 && (
<>
<UIDragCounts>{dragLength}</UIDragCounts>
<UIDragNodeContainer
style={{
position: 'absolute',
top: 5,
right: -5,
left: 5,
bottom: -5,
opacity: 0.5,
}}
/>
</>
)}
</UIDragNodeContainer>
);
}
@@ -1,35 +0,0 @@
import styled from 'styled-components';
const primary = 'hsl(252 62% 54.9%)';
const primaryOpacity09 = 'hsl(252deg 62% 55% / 9%)';
export const UIDragNodeContainer = styled.div`
position: relative;
height: 32px;
border-radius: 5px;
display: flex;
align-items: center;
column-gap: 8px;
cursor: pointer;
font-size: 19px;
border: 1px solid ${primary};
padding: 0 15px;
&:hover: {
background-color: ${primaryOpacity09};
color: ${primary};
}
`;
export const UIDragCounts = styled.div`
position: absolute;
top: -8px;
right: -8px;
text-align: center;
line-height: 16px;
width: 16px;
height: 16px;
border-radius: 8px;
font-size: 12px;
color: #fff;
background-color: ${primary};
`;
@@ -1,3 +0,0 @@
export { DemoTools } from './tools';
export { DragNode } from './drag-node';
export { AgentAdder } from './agent-adder';
@@ -1,162 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { useClientContext } from '@flowgram.ai/fixed-layout-editor';
import { type FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
import { Popover, message, Typography } from 'antd';
import { NodeList } from '../node-list';
import { readData } from '../../shortcuts/utils';
import { generateNodeId } from './utils';
import { PasteIcon, Wrap } from './styles';
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
const generateNewIdForChildren = (n: FlowNodeEntity): FlowNodeEntity => {
if (n.blocks) {
return {
...n,
id: generateNodeId(n),
blocks: n.blocks.map((b) => generateNewIdForChildren(b)),
} as FlowNodeEntity;
} else {
return {
...n,
id: generateNodeId(n),
} as FlowNodeEntity;
}
};
export default function Adder(props: {
from: FlowNodeEntity;
to?: FlowNodeEntity;
hoverActivated: boolean;
}) {
const { from } = props;
const isVertical = from.isVertical;
const [visible, setVisible] = useState(false);
const { playground, operation, clipboard } = useClientContext();
const [pasteIconVisible, setPasteIconVisible] = useState(false);
const activated = useMemo(
() => props.hoverActivated && !playground.config.readonly,
[props.hoverActivated, playground.config.readonly]
);
const add = (addProps: any) => {
const blocks = addProps.blocks ? addProps.blocks : undefined;
const block = operation.addFromNode(from, {
...addProps,
blocks,
});
setTimeout(() => {
playground.scrollToView({
bounds: block.bounds,
scrollToCenter: true,
});
}, 10);
setVisible(false);
};
const handlePaste = useCallback(async (e: any) => {
try {
e.stopPropagation();
const nodes = await readData(clipboard);
if (!nodes) {
message.error({
content: 'The clipboard content has been updated, please copy the node again.',
});
return;
}
nodes.reverse().forEach((n: FlowNodeEntity) => {
const newNodeData = generateNewIdForChildren(n);
operation.addFromNode(from, newNodeData);
});
message.success({
content: 'Paste successfully!',
});
} catch (error) {
console.error(error);
message.error({
content: (
<Typography.Text>
Paste failed, please check if you have permission to read the clipboard,
</Typography.Text>
),
});
}
}, []);
if (playground.config.readonly) return null;
return (
<Popover
visible={visible}
onVisibleChange={setVisible}
content={<NodeList onSelect={add} from={from} />}
placement="right"
trigger="click"
align={{ offset: [30, 0] }}
overlayStyle={{
padding: 0,
}}
>
<Wrap
style={
props.hoverActivated
? {
width: 15,
height: 15,
}
: {}
}
onMouseDown={(e) => e.stopPropagation()}
>
{props.hoverActivated ? (
<PlusOutlined
onClick={() => {
setVisible(true);
}}
onMouseEnter={() => {
const data = clipboard.readText();
setPasteIconVisible(!!data);
}}
style={{
backgroundColor: '#fff',
color: '#3370ff',
borderRadius: 15,
}}
/>
) : (
''
)}
{activated && pasteIconVisible && (
<Popover placement="top" showArrow content="Paste">
<PasteIcon
onClick={handlePaste}
style={
isVertical
? {
right: -25,
top: 0,
}
: {
right: 0,
top: -20,
}
}
>
<CopyOutlined
style={{
backgroundColor: 'var(--semi-color-bg-0)',
borderRadius: 15,
}}
/>
</PasteIcon>
</Popover>
)}
</Wrap>
</Popover>
);
}
@@ -1,24 +0,0 @@
import styled from 'styled-components';
export const PasteIcon = styled.div`
position: absolute;
width: 15px;
height: 15px;
color: #3370ff;
display: flex;
justify-content: center;
align-items: center;
`;
export const Wrap = styled.div`
position: relative;
width: 6px;
height: 6px;
background-color: rgb(143, 149, 158);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
`;
@@ -1,4 +0,0 @@
import { nanoid } from 'nanoid';
import { FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
export const generateNodeId = (n: FlowNodeEntity) => `${n.type || n.flowNodeType}_${nanoid()}`;
@@ -1,69 +0,0 @@
import styled from 'styled-components';
import {
FlowNodeEntity,
FlowNodeRegistry,
useClientContext,
} from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeRegistries } from '../nodes';
const NodeWrap = styled.div`
width: 100%;
height: 32px;
border-radius: 5px;
display: flex;
align-items: center;
cursor: pointer;
font-size: 19px;
padding: 0 15px;
&:hover {
background-color: hsl(252deg 62% 55% / 9%);
color: hsl(252 62% 54.9%);
},
`;
const NodeLabel = styled.div`
font-size: 12px;
margin-left: 10px;
`;
function Node(props: { label: string; icon: JSX.Element; onClick: () => void; disabled: boolean }) {
return (
<NodeWrap
onClick={props.disabled ? undefined : props.onClick}
style={props.disabled ? { opacity: 0.3 } : {}}
>
<div style={{ fontSize: 14 }}>{props.icon}</div>
<NodeLabel>{props.label}</NodeLabel>
</NodeWrap>
);
}
const NodesWrap = styled.div`
max-height: 500px;
overflow: auto;
&::-webkit-scrollbar {
display: none;
}
`;
export function NodeList(props: { onSelect: (meta: any) => void; from: FlowNodeEntity }) {
const context = useClientContext();
const handleClick = (registry: FlowNodeRegistry) => {
const addProps = registry.onAdd(context, props.from);
props.onSelect?.(addProps);
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{FlowNodeRegistries.filter((registry) => !registry.meta?.addDisable).map((registry) => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context, props.from) ?? true)}
icon={<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info.icon} />}
label={registry.type as string}
onClick={() => handleClick(registry)}
/>
))}
</NodesWrap>
);
}
@@ -1,158 +0,0 @@
import { FunctionComponent, useMemo } from 'react';
import {
useStartDragNode,
FlowNodeRenderData,
FlowNodeBaseType,
FlowGroupService,
type FlowNodeEntity,
SelectorBoxPopoverProps,
} from '@flowgram.ai/fixed-layout-editor';
import { Button, Tooltip } from 'antd';
import { FlowCommandId } from '../../shortcuts/constants';
import { IconGroupOutlined } from '../../plugins/group-plugin/icons';
import { CopyOutlined, DeleteOutlined, DragOutlined, ExpandOutlined, ShrinkOutlined } from '@ant-design/icons';
const BUTTON_HEIGHT = 24;
export const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({
bounds,
children,
flowSelectConfig,
commandRegistry,
}) => {
const selectNodes = flowSelectConfig.selectedNodes;
const { startDrag } = useStartDragNode();
const draggable = selectNodes[0]?.getData(FlowNodeRenderData)?.draggable;
// Does the selected component have a group node? (High-cost computation must use memo)
const hasGroup: boolean = useMemo(() => {
if (!selectNodes || selectNodes.length === 0) {
return false;
}
const findGroupInNodes = (nodes: FlowNodeEntity[]): boolean =>
nodes.some((node) => {
if (node.flowNodeType === FlowNodeBaseType.GROUP) {
return true;
}
if (node.blocks && node.blocks.length) {
return findGroupInNodes(node.blocks);
}
return false;
});
return findGroupInNodes(selectNodes);
}, [selectNodes]);
const canGroup = !hasGroup && FlowGroupService.validate(selectNodes);
return (
<>
<div
style={{
position: 'absolute',
left: bounds.right,
top: bounds.top,
transform: 'translate(-100%, -100%)',
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<Button.Group
size="small"
style={{ display: 'flex', flexWrap: 'nowrap', height: BUTTON_HEIGHT }}
>
{draggable && (
<Tooltip title="Drag">
<Button
style={{ cursor: 'grab', height: BUTTON_HEIGHT }}
icon={<DragOutlined />}
type="primary"
onMouseDown={(e) => {
e.stopPropagation();
startDrag(e, {
dragStartEntity: selectNodes[0],
dragEntities: selectNodes,
});
}}
/>
</Tooltip>
)}
<Tooltip title={'Collapse'}>
<Button
icon={<ShrinkOutlined />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
onMouseDown={(e) => {
commandRegistry.executeCommand(FlowCommandId.COLLAPSE);
}}
/>
</Tooltip>
<Tooltip title={'Expand'}>
<Button
icon={<ExpandOutlined />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
onMouseDown={(e) => {
commandRegistry.executeCommand(FlowCommandId.EXPAND);
}}
/>
</Tooltip>
<Tooltip title={'Group'}>
<Button
icon={<IconGroupOutlined />}
type="primary"
style={{
display: canGroup ? 'inherit' : 'none',
height: BUTTON_HEIGHT,
}}
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.GROUP);
}}
/>
</Tooltip>
<Tooltip title={'Copy'}>
<Button
icon={<CopyOutlined />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.COPY);
}}
/>
</Tooltip>
<Tooltip title={'Delete'}>
<Button
type="primary"
icon={<DeleteOutlined />}
style={{ height: BUTTON_HEIGHT }}
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.DELETE);
}}
/>
</Tooltip>
</Button.Group>
</div>
<div
style={{ cursor: draggable ? 'grab' : 'auto' }}
onMouseDown={(e) => {
e.stopPropagation();
startDrag(e, {
dragStartEntity: selectNodes[0],
dragEntities: selectNodes,
});
}}
>
{children}
</div>
</>
);
};
@@ -1 +0,0 @@
export { nodeFormPanelFactory } from './sidebar-renderer';
@@ -1,24 +0,0 @@
import { useNodeRender, FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor';
import { NodeRenderContext } from '../../context';
export function SidebarNodeRenderer(props: { node: FlowNodeEntity }) {
const { node } = props;
const nodeRender = useNodeRender(node);
return (
<NodeRenderContext.Provider value={nodeRender}>
<div
style={{
background: 'rgb(251, 251, 251)',
height: '100%',
borderRadius: 8,
border: '1px solid rgba(82,100,154, 0.13)',
boxSizing: 'border-box',
}}
>
{nodeRender.form?.render()}
</div>
</NodeRenderContext.Provider>
);
}
@@ -1,90 +0,0 @@
import { useCallback, useEffect, startTransition } from 'react';
import { type PanelFactory, usePanelManager } from '@flowgram.ai/panel-manager-plugin';
import {
PlaygroundEntityContext,
useRefresh,
useClientContext,
} from '@flowgram.ai/fixed-layout-editor';
import { FlowNodeMeta } from '../../typings';
import { IsSidebarContext } from '../../context';
import { SidebarNodeRenderer } from './sidebar-node-renderer';
export interface NodeFormPanelProps {
nodeId: string;
}
export const SidebarRenderer: React.FC<NodeFormPanelProps> = ({ nodeId }) => {
const panelManager = usePanelManager();
const { selection, playground, document } = useClientContext();
const refresh = useRefresh();
const handleClose = useCallback(() => {
// Sidebar delayed closing
startTransition(() => {
panelManager.close(nodeFormPanelFactory.key);
});
}, []);
const node = nodeId ? document.getNode(nodeId) : undefined;
/**
* Listen readonly
*/
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => {
handleClose();
refresh();
});
return () => disposable.dispose();
}, [playground]);
/**
* Listen selection
*/
useEffect(() => {
const toDispose = selection.onSelectionChanged(() => {
/**
*
* If no node is selected, the sidebar is automatically closed
*/
if (selection.selection.length === 0) {
handleClose();
} else if (selection.selection.length === 1 && selection.selection[0] !== node) {
handleClose();
}
});
return () => toDispose.dispose();
}, [selection, handleClose, node]);
/**
* Close when node disposed
*/
useEffect(() => {
if (node) {
const toDispose = node.onDispose(() => {
panelManager.close(nodeFormPanelFactory.key);
});
return () => toDispose.dispose();
}
return () => {};
}, [node]);
if (!node || node.getNodeMeta<FlowNodeMeta>().sidebarDisabled === true) {
return null;
}
if (playground.config.readonly) {
return null;
}
return (
<IsSidebarContext.Provider value={true}>
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<SidebarNodeRenderer node={node} />
</PlaygroundEntityContext.Provider>
</IsSidebarContext.Provider>
);
};
export const nodeFormPanelFactory: PanelFactory<NodeFormPanelProps> = {
key: 'node-form-panel',
defaultSize: 400,
render: (props: NodeFormPanelProps) => <SidebarRenderer {...props} />,
};
@@ -1,11 +0,0 @@
import { ExpandOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
export const FitView = (props: { fitView: () => void }) => (
<Tooltip title="FitView">
<Button
icon={<ExpandOutlined />}
onClick={() => props.fitView()}
/>
</Tooltip>
);
@@ -1,58 +0,0 @@
import { useState, useEffect } from 'react';
import { usePlayground, usePlaygroundTools, useRefresh } from '@flowgram.ai/fixed-layout-editor';
import { Tooltip, Button } from 'antd';
import { ZoomSelect } from './zoom-select';
import { SwitchVertical } from './switch-vertical';
import { ToolContainer, ToolSection } from './styles';
import { Save } from './save';
import { Run } from './run';
import { Readonly } from './readonly';
import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
import { Interactive } from './interactive';
import { FitView } from './fit-view';
import { RedoOutlined, UndoOutlined } from '@ant-design/icons';
export const DemoTools = () => {
const tools = usePlaygroundTools();
const [minimapVisible, setMinimapVisible] = useState(false);
const playground = usePlayground();
const refresh = useRefresh();
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());
return () => disposable.dispose();
}, [playground]);
return (
<ToolContainer className="fixed-demo-tools">
<ToolSection>
<Interactive />
<SwitchVertical />
<ZoomSelect />
<FitView fitView={tools.fitView} />
<MinimapSwitch minimapVisible={minimapVisible} setMinimapVisible={setMinimapVisible} />
<Minimap visible={minimapVisible} />
<Readonly />
<Tooltip title="Undo">
<Button
icon={<UndoOutlined />}
disabled={!tools.canUndo || playground.config.readonly}
onClick={() => tools.undo()}
/>
</Tooltip>
<Tooltip title="Redo">
<Button
icon={<RedoOutlined />}
disabled={!tools.canRedo || playground.config.readonly}
onClick={() => tools.redo()}
/>
</Tooltip>
<Save disabled={playground.config.readonly} />
<Run />
</ToolSection>
</ToolContainer>
);
};
@@ -1,90 +0,0 @@
import { useEffect, useState } from 'react';
import { usePlaygroundTools, PlaygroundInteractiveType } from '@flowgram.ai/fixed-layout-editor';
import { Tooltip, Popover } from 'antd';
import { MousePadSelector } from './mouse-pad-selector';
export const CACHE_KEY = 'workflow_prefer_interactive_type';
export const IS_MAC_OS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);
export const getPreferInteractiveType = () => {
const data = localStorage.getItem(CACHE_KEY) as string;
if (data && [InteractiveType.Mouse, InteractiveType.Pad].includes(data as InteractiveType)) {
return data;
}
return IS_MAC_OS ? InteractiveType.Pad : InteractiveType.Mouse;
};
export const setPreferInteractiveType = (type: InteractiveType) => {
localStorage.setItem(CACHE_KEY, type);
};
export enum InteractiveType {
Mouse = 'MOUSE',
Pad = 'PAD',
}
export const Interactive = () => {
const tools = usePlaygroundTools();
const [visible, setVisible] = useState(false);
const [interactiveType, setInteractiveType] = useState<InteractiveType>(
() => getPreferInteractiveType() as InteractiveType
);
const [showInteractivePanel, setShowInteractivePanel] = useState(false);
const mousePadTooltip =
interactiveType === InteractiveType.Mouse ? 'Mouse-Friendly' : 'Touchpad-Friendly';
useEffect(() => {
// read from localStorage
const preferInteractiveType = getPreferInteractiveType();
tools.setInteractiveType(preferInteractiveType as PlaygroundInteractiveType);
}, []);
const handleClose = () => {
setVisible(false);
};
return (
<Popover trigger="custom" placement="top" >
<Tooltip
title={mousePadTooltip}
style={{ display: showInteractivePanel ? 'none' : 'block' }}
>
<div className="workflow-toolbar-interactive">
<MousePadSelector
value={interactiveType}
onChange={(value) => {
setInteractiveType(value);
setPreferInteractiveType(value);
tools.setInteractiveType(value);
}}
onPopupVisibleChange={setShowInteractivePanel}
containerStyle={{
border: 'none',
height: '32px',
width: '32px',
justifyContent: 'center',
alignItems: 'center',
gap: '2px',
padding: '4px',
borderRadius: 'var(--small, 6px)',
}}
iconStyle={{
margin: '0',
width: '16px',
height: '16px',
}}
arrowStyle={{
width: '12px',
height: '12px',
}}
/>
</div>
</Tooltip>
</Popover>
);
};

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