feat: 增加跨平台打包脚本
@@ -0,0 +1,36 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
ENV
|
||||||
|
env
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache
|
||||||
|
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
frontend/.vite
|
||||||
|
|
||||||
|
cache
|
||||||
|
logs
|
||||||
|
dist
|
||||||
|
uploads
|
||||||
|
CapacityReportData
|
||||||
|
auth.ini
|
||||||
|
Configure.json
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
build/temp
|
||||||
|
build/pyinstaller
|
||||||
|
target
|
||||||
|
src-tauri/target
|
||||||
|
src-tauri/binaries
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -36,7 +36,12 @@ cache/
|
|||||||
logs/
|
logs/
|
||||||
uploads/
|
uploads/
|
||||||
build/temp/
|
build/temp/
|
||||||
|
build/pyinstaller/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
|
src-tauri/gen/
|
||||||
CapacityReportData/
|
CapacityReportData/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
# Local configuration and secrets
|
# Local configuration and secrets
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -2,13 +2,24 @@
|
|||||||
配置管理模块
|
配置管理模块
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
def _resolve_base_dir() -> Path:
|
||||||
|
env_base_dir = os.environ.get("CAPAREPORT_BASE_DIR")
|
||||||
|
if env_base_dir:
|
||||||
|
return Path(env_base_dir).expanduser().resolve()
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys.executable).resolve().parent
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = _resolve_base_dir()
|
||||||
CACHE_DIR = BASE_DIR / "cache"
|
CACHE_DIR = BASE_DIR / "cache"
|
||||||
CONFIG_FILE = BASE_DIR / "Configure.json"
|
CONFIG_FILE = BASE_DIR / "Configure.json"
|
||||||
SQL_SCRIPT = BASE_DIR / "ReportScript.sql"
|
SQL_SCRIPT = BASE_DIR / "ReportScript.sql"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
@@ -117,8 +118,20 @@ def _safe_file(root: Path, path: str) -> Path | None:
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="CapacityReport server")
|
||||||
|
parser.add_argument("--host", default=APP_HOST, help="Host to bind")
|
||||||
|
parser.add_argument("--port", default=APP_PORT, type=int, help="Port to bind")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(host: str = APP_HOST, port: int = APP_PORT) -> None:
|
||||||
print(f"CapacityReport v{APP_VERSION}")
|
print(f"CapacityReport v{APP_VERSION}")
|
||||||
print(f"配置更新时间: {state.current_config().update}")
|
print(f"Config update: {state.current_config().update}")
|
||||||
print(f"前端地址: http://localhost:{APP_PORT}")
|
print(f"Frontend: http://localhost:{port}")
|
||||||
uvicorn.run(app, host=APP_HOST, port=APP_PORT, reload=False)
|
uvicorn.run(app, host=host, port=port, reload=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parse_args()
|
||||||
|
run_server(args.host, args.port)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# CapacityReport Dockerfile
|
# CapacityReport Dockerfile
|
||||||
# 内网离线部署镜像,前端在镜像构建阶段编译后交给 FastAPI 托管。
|
# The frontend is built in a Node stage and served by the FastAPI runtime.
|
||||||
|
|
||||||
FROM node:22-slim AS frontend-build
|
FROM node:22-slim AS frontend-build
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ RUN npm run build
|
|||||||
FROM python:3.13.11-slim
|
FROM python:3.13.11-slim
|
||||||
|
|
||||||
LABEL maintainer="CapacityReport"
|
LABEL maintainer="CapacityReport"
|
||||||
LABEL description="容量报表处理系统"
|
LABEL description="CapacityReport processing service"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -30,7 +30,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
RUN mkdir -p /var/log/supervisor /var/run /app/cache
|
RUN mkdir -p /var/log/supervisor /var/run /app/cache
|
||||||
|
|
||||||
COPY . .
|
COPY app ./app
|
||||||
|
COPY ReportScript.sql start.sh supervisord.conf ./
|
||||||
COPY --from=frontend-build /frontend/dist /app/frontend/dist
|
COPY --from=frontend-build /frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
RUN chmod +x /app/start.sh 2>/dev/null || true
|
RUN chmod +x /app/start.sh 2>/dev/null || true
|
||||||
|
|||||||
@@ -1,58 +1,26 @@
|
|||||||
# 构建脚本
|
# Build Notes
|
||||||
|
|
||||||
## 使用方法
|
Use the root scripts for release builds:
|
||||||
|
|
||||||
在项目根目录运行:
|
```powershell
|
||||||
|
scripts\build.bat server
|
||||||
```bash
|
scripts\build.bat docker
|
||||||
python build/build.py
|
scripts\build.bat desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
选择构建类型:
|
On Linux or macOS:
|
||||||
- `1` - 完整部署包(MySQL + 应用)
|
|
||||||
- `2` - 更新包(仅应用)
|
|
||||||
|
|
||||||
## 输出
|
|
||||||
|
|
||||||
构建完成后在 `dist/` 目录生成:
|
|
||||||
- `capacity-report-full.tar.gz` - 完整部署包
|
|
||||||
- `capacity-report-update.tar.gz` - 更新包
|
|
||||||
|
|
||||||
## 部署包内容
|
|
||||||
|
|
||||||
**完整部署包**:
|
|
||||||
- `images/capacity-images.tar` - Docker 镜像
|
|
||||||
- `docker-compose.yml` - 编排文件
|
|
||||||
- `deploy.sh` - 部署脚本
|
|
||||||
- `Configure.json` - 配置文件(已更新为 Docker 环境)
|
|
||||||
- `ReportScript.sql` - SQL 脚本
|
|
||||||
- `mysql/` - MySQL 配置
|
|
||||||
|
|
||||||
**更新包**:
|
|
||||||
- `images/capacity-app-update.tar` - 应用镜像
|
|
||||||
- `docker-compose.yml` - 编排文件
|
|
||||||
- `update.sh` - 更新脚本
|
|
||||||
|
|
||||||
## 部署
|
|
||||||
|
|
||||||
**完整部署**:
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf capacity-report-full.tar.gz
|
sh scripts/build.sh server
|
||||||
cd capacity-report-full
|
sh scripts/build.sh docker
|
||||||
sh deploy.sh
|
sh scripts/build.sh desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
**更新应用**:
|
## Targets
|
||||||
```bash
|
|
||||||
tar -xzf capacity-report-update.tar.gz
|
|
||||||
cd capacity-report-update
|
|
||||||
sh update.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置
|
- `server`: builds a portable server package for the current OS.
|
||||||
|
- `docker`: builds the Docker image and validates the compose file.
|
||||||
|
- `desktop`: builds a Tauri desktop package with the Python server sidecar.
|
||||||
|
- `all`: runs all targets.
|
||||||
|
|
||||||
所有配置在 `build/build.py` 文件头部:
|
Native server and desktop packages must be built on the target OS. Build Windows packages on Windows, Linux packages on Linux, and macOS packages on macOS. Docker images can be built from the Windows development host.
|
||||||
- 默认镜像版本
|
|
||||||
- 版本要求范围
|
|
||||||
- 数据库账号密码
|
|
||||||
- 端口映射:前端 `19081 -> 9081`
|
|
||||||
|
|||||||
@@ -1,990 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
CapacityReport 统一构建脚本
|
|
||||||
支持完整部署包和更新包的构建
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import re
|
|
||||||
import tarfile
|
|
||||||
import traceback
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 配置常量 - 可根据需要修改
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# 默认镜像版本
|
|
||||||
DEFAULT_MYSQL_VERSION = "8.0.44"
|
|
||||||
DEFAULT_PYTHON_VERSION = "3.13.11-slim"
|
|
||||||
|
|
||||||
# 版本要求
|
|
||||||
MYSQL_MIN_VERSION = (8, 0, 0) # MySQL >= 8.0
|
|
||||||
MYSQL_MAX_VERSION = (9, 0, 0) # MySQL < 9.0
|
|
||||||
PYTHON_MIN_VERSION = (3, 10, 0) # Python >= 3.10
|
|
||||||
PYTHON_MAX_VERSION = (3, 14, 0) # Python < 3.14
|
|
||||||
|
|
||||||
# 数据库配置
|
|
||||||
MYSQL_ROOT_USER = "root"
|
|
||||||
MYSQL_ROOT_PASSWORD = "gmcc123"
|
|
||||||
MYSQL_DATABASE = "CapacityReport"
|
|
||||||
MYSQL_HOST = "capacity-mysql"
|
|
||||||
MYSQL_PORT = 3306
|
|
||||||
|
|
||||||
# 应用端口映射
|
|
||||||
APP_PORT_HOST = 19081
|
|
||||||
MYSQL_PORT_HOST = 13306
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 脚本目录配置
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.absolute()
|
|
||||||
PROJECT_ROOT = SCRIPT_DIR.parent
|
|
||||||
DIST_DIR = PROJECT_ROOT / "dist"
|
|
||||||
BUILD_DIR = PROJECT_ROOT / "build"
|
|
||||||
TEMP_BUILD_DIR = BUILD_DIR / "temp"
|
|
||||||
|
|
||||||
if platform.system() == 'Windows':
|
|
||||||
try:
|
|
||||||
import ctypes
|
|
||||||
kernel32 = ctypes.windll.kernel32
|
|
||||||
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
class Colors:
|
|
||||||
GREEN = '\033[92m'
|
|
||||||
YELLOW = '\033[93m'
|
|
||||||
RED = '\033[91m'
|
|
||||||
BLUE = '\033[94m'
|
|
||||||
RESET = '\033[0m'
|
|
||||||
|
|
||||||
def print_step(msg):
|
|
||||||
print(f"\n{Colors.GREEN}{msg}{Colors.RESET}")
|
|
||||||
|
|
||||||
def print_info(msg):
|
|
||||||
print(f" {msg}")
|
|
||||||
|
|
||||||
def print_warning(msg):
|
|
||||||
print(f"{Colors.YELLOW}警告: {msg}{Colors.RESET}")
|
|
||||||
|
|
||||||
def print_error(msg):
|
|
||||||
print(f"{Colors.RED}错误: {msg}{Colors.RESET}")
|
|
||||||
|
|
||||||
def print_choice(msg):
|
|
||||||
print(f"{Colors.BLUE}{msg}{Colors.RESET}")
|
|
||||||
|
|
||||||
def run_cmd(cmd, check=True, capture_output=False):
|
|
||||||
"""执行命令"""
|
|
||||||
if isinstance(cmd, str):
|
|
||||||
cmd = cmd.split()
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
check=check,
|
|
||||||
capture_output=capture_output,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
if check:
|
|
||||||
print_error(f"命令执行失败: {' '.join(cmd)}")
|
|
||||||
sys.exit(1)
|
|
||||||
return None
|
|
||||||
except FileNotFoundError:
|
|
||||||
print_error(f"命令未找到: {cmd[0]}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def check_command(cmd):
|
|
||||||
"""检查命令是否存在"""
|
|
||||||
try:
|
|
||||||
if cmd == "docker":
|
|
||||||
subprocess.run([cmd, "--version"], capture_output=True, check=True)
|
|
||||||
elif cmd == "docker-compose":
|
|
||||||
subprocess.run([cmd, "version"], capture_output=True, check=True)
|
|
||||||
return True
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def parse_version(version_str):
|
|
||||||
"""解析版本字符串为元组,例如 '8.0.44' -> (8, 0, 44)"""
|
|
||||||
parts = version_str.split('.')
|
|
||||||
try:
|
|
||||||
# 处理带后缀的版本,如 '3.13.11-slim' -> (3, 13, 11)
|
|
||||||
base_version = parts[0] if len(parts) > 0 else '0'
|
|
||||||
major = int(base_version)
|
|
||||||
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
||||||
patch_str = parts[2] if len(parts) > 2 else '0'
|
|
||||||
# 移除后缀,如 '11-slim' -> '11'
|
|
||||||
patch = int(patch_str.split('-')[0])
|
|
||||||
return (major, minor, patch)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def check_version_in_range(version_tuple, min_version, max_version):
|
|
||||||
"""检查版本是否在指定范围内 [min, max)"""
|
|
||||||
if version_tuple is None:
|
|
||||||
return False
|
|
||||||
return min_version <= version_tuple < max_version
|
|
||||||
|
|
||||||
def get_python_tag_from_dockerfile():
|
|
||||||
"""从 Dockerfile 中提取 Python 镜像标签"""
|
|
||||||
# 从 build 目录读取 Dockerfile
|
|
||||||
dockerfile_path = BUILD_DIR / "Dockerfile"
|
|
||||||
if not dockerfile_path.exists():
|
|
||||||
print_error(f"Dockerfile 不存在: {dockerfile_path}")
|
|
||||||
print_error("请确保 build/Dockerfile 文件存在")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
with open(dockerfile_path, 'r', encoding='utf-8') as f:
|
|
||||||
for line in f:
|
|
||||||
match = re.match(r'^FROM\s+python:(.+?)(?:\s|$)', line, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
tag = match.group(1).strip()
|
|
||||||
return tag
|
|
||||||
|
|
||||||
print_error("无法从 Dockerfile 中找到 Python 镜像标签")
|
|
||||||
print_error("请确保 Dockerfile 中包含 'FROM python:xxx' 行")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def check_and_get_python_image():
|
|
||||||
"""检查并获取符合要求的 Python 镜像"""
|
|
||||||
dockerfile_tag = get_python_tag_from_dockerfile()
|
|
||||||
dockerfile_version = parse_version(dockerfile_tag)
|
|
||||||
|
|
||||||
# 检查 Dockerfile 中的版本是否符合要求
|
|
||||||
if dockerfile_version and check_version_in_range(
|
|
||||||
dockerfile_version, PYTHON_MIN_VERSION, PYTHON_MAX_VERSION
|
|
||||||
):
|
|
||||||
print_info(f"Dockerfile 中的 Python 版本符合要求: {dockerfile_tag}")
|
|
||||||
return f"python:{dockerfile_tag}"
|
|
||||||
else:
|
|
||||||
print_warning(f"Dockerfile 中的 Python 版本 {dockerfile_tag} 不符合要求")
|
|
||||||
print_warning(f"要求: Python >= {'.'.join(map(str, PYTHON_MIN_VERSION))} < {'.'.join(map(str, PYTHON_MAX_VERSION))}")
|
|
||||||
print_info(f"将使用默认版本: {DEFAULT_PYTHON_VERSION}")
|
|
||||||
print_info(f"如果镜像不存在,将自动拉取: python:{DEFAULT_PYTHON_VERSION}")
|
|
||||||
return f"python:{DEFAULT_PYTHON_VERSION}"
|
|
||||||
|
|
||||||
def check_and_get_mysql_image():
|
|
||||||
"""检查并获取符合要求的 MySQL 镜像"""
|
|
||||||
# 检查默认版本是否符合要求
|
|
||||||
default_version = parse_version(DEFAULT_MYSQL_VERSION)
|
|
||||||
if default_version and check_version_in_range(
|
|
||||||
default_version, MYSQL_MIN_VERSION, MYSQL_MAX_VERSION
|
|
||||||
):
|
|
||||||
print_info(f"默认 MySQL 版本符合要求: {DEFAULT_MYSQL_VERSION}")
|
|
||||||
return f"mysql:{DEFAULT_MYSQL_VERSION}"
|
|
||||||
else:
|
|
||||||
print_error(f"默认 MySQL 版本 {DEFAULT_MYSQL_VERSION} 不符合要求")
|
|
||||||
print_error(f"要求: MySQL >= {'.'.join(map(str, MYSQL_MIN_VERSION))} < {'.'.join(map(str, MYSQL_MAX_VERSION))}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def image_exists(image_name):
|
|
||||||
"""检查镜像是否已存在"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
return image_name in result.stdout
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def format_size(size_bytes):
|
|
||||||
"""格式化文件大小"""
|
|
||||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
|
||||||
if size_bytes < 1024.0:
|
|
||||||
return f"{size_bytes:.1f} {unit}"
|
|
||||||
size_bytes /= 1024.0
|
|
||||||
return f"{size_bytes:.1f} TB"
|
|
||||||
|
|
||||||
def write_sh_script(file_path, content):
|
|
||||||
"""写入 shell 脚本,确保使用 LF 换行和 UTF-8 编码"""
|
|
||||||
# 确保内容使用 LF 换行
|
|
||||||
content = content.replace('\r\n', '\n').replace('\r', '\n')
|
|
||||||
# 写入文件(二进制模式确保换行符不被转换)
|
|
||||||
with open(file_path, 'wb') as f:
|
|
||||||
f.write(content.encode('utf-8'))
|
|
||||||
# 设置执行权限(在 Linux 上)
|
|
||||||
if platform.system() != 'Windows':
|
|
||||||
os.chmod(file_path, 0o755)
|
|
||||||
|
|
||||||
def get_deploy_sh():
|
|
||||||
"""生成 deploy.sh 脚本内容"""
|
|
||||||
mysql_version = DEFAULT_MYSQL_VERSION
|
|
||||||
return f'''#!/bin/bash
|
|
||||||
# CapacityReport Deployment Script
|
|
||||||
# Run this script on offline machine to import images and start services
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# Set permissions automatically
|
|
||||||
chmod -R 0777 "$SCRIPT_DIR" 2>/dev/null || true
|
|
||||||
find "$SCRIPT_DIR" -type f \\( -name "*.sh" -o -name "*.py" \\) -exec chmod +x {{}} \\; 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "CapacityReport Deployment"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check Docker
|
|
||||||
if ! command -v docker &> /dev/null; then
|
|
||||||
echo "Error: Docker not found, please install Docker"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Docker Compose
|
|
||||||
DOCKER_COMPOSE_CMD=""
|
|
||||||
if docker compose version &> /dev/null; then
|
|
||||||
DOCKER_COMPOSE_CMD="docker compose"
|
|
||||||
elif command -v docker-compose &> /dev/null; then
|
|
||||||
DOCKER_COMPOSE_CMD="docker-compose"
|
|
||||||
else
|
|
||||||
echo "Error: Docker Compose not found, please install Docker Compose"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check image file
|
|
||||||
if [ ! -f "images/capacity-images.tar" ]; then
|
|
||||||
echo "Error: Image file not found: images/capacity-images.tar"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Step 1: Checking ports..."
|
|
||||||
check_port() {{
|
|
||||||
local port=${{1}}
|
|
||||||
local name=${{2}}
|
|
||||||
local in_use=false
|
|
||||||
|
|
||||||
if command -v lsof &> /dev/null; then
|
|
||||||
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then
|
|
||||||
in_use=true
|
|
||||||
fi
|
|
||||||
elif command -v netstat &> /dev/null; then
|
|
||||||
if netstat -tuln 2>/dev/null | grep -q ":$port "; then
|
|
||||||
in_use=true
|
|
||||||
fi
|
|
||||||
elif command -v ss &> /dev/null; then
|
|
||||||
if ss -tuln 2>/dev/null | grep -q ":$port "; then
|
|
||||||
in_use=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$in_use" = true ]; then
|
|
||||||
echo "Warning: Port $port ($name) is in use"
|
|
||||||
read -p "Continue? (y/n) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
{{}}
|
|
||||||
|
|
||||||
check_port {APP_PORT_HOST} "Frontend"
|
|
||||||
check_port {MYSQL_PORT_HOST} "MySQL"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 2: Loading images..."
|
|
||||||
docker load -i images/capacity-images.tar >/dev/null 2>&1
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 3: Verifying images..."
|
|
||||||
if docker images | grep -q "capacity-report-app.*latest"; then
|
|
||||||
echo " App image: OK"
|
|
||||||
else
|
|
||||||
echo " App image: FAILED"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if docker images | grep -q "capacity-mysql.*{mysql_version}"; then
|
|
||||||
echo " MySQL image: OK"
|
|
||||||
else
|
|
||||||
echo " MySQL image: FAILED"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 4: Creating directories..."
|
|
||||||
mkdir -p cache logs
|
|
||||||
chmod 777 cache logs 2>/dev/null || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 5: Checking config..."
|
|
||||||
if [ ! -f "Configure.json" ]; then
|
|
||||||
echo " Creating default Configure.json..."
|
|
||||||
cat > Configure.json << 'EOF'
|
|
||||||
{{
|
|
||||||
"MySQL_DBInfo": {{
|
|
||||||
"host": "{MYSQL_HOST}",
|
|
||||||
"port": {MYSQL_PORT},
|
|
||||||
"user": "{MYSQL_ROOT_USER}",
|
|
||||||
"passwd": "{MYSQL_ROOT_PASSWORD}",
|
|
||||||
"dbname": "{MYSQL_DATABASE}"
|
|
||||||
}},
|
|
||||||
"ExtractField": []
|
|
||||||
}}
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
if command -v python3 &> /dev/null || command -v python &> /dev/null; then
|
|
||||||
(command -v python3 >/dev/null && python3 || python) << 'PYEOF'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open('Configure.json', 'r', encoding='utf-8') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
db_info = config.get('MySQL_DBInfo', {{}})
|
|
||||||
needs_update = False
|
|
||||||
|
|
||||||
if db_info.get('host') != '{MYSQL_HOST}':
|
|
||||||
db_info['host'] = '{MYSQL_HOST}'
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
if db_info.get('port') != {MYSQL_PORT}:
|
|
||||||
db_info['port'] = {MYSQL_PORT}
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
if db_info.get('user') != '{MYSQL_ROOT_USER}':
|
|
||||||
db_info['user'] = '{MYSQL_ROOT_USER}'
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
if db_info.get('passwd') != '{MYSQL_ROOT_PASSWORD}':
|
|
||||||
db_info['passwd'] = '{MYSQL_ROOT_PASSWORD}'
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
if db_info.get('dbname') != '{MYSQL_DATABASE}':
|
|
||||||
db_info['dbname'] = '{MYSQL_DATABASE}'
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
if needs_update:
|
|
||||||
with open('Configure.json', 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
|
||||||
print(" Updated Configure.json")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
PYEOF
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "ReportScript.sql" ]; then
|
|
||||||
touch ReportScript.sql
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 6: Starting services..."
|
|
||||||
$DOCKER_COMPOSE_CMD down 2>/dev/null || true
|
|
||||||
$DOCKER_COMPOSE_CMD up -d >/dev/null 2>&1
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 7: Waiting for services..."
|
|
||||||
for i in {{1..30}}; do
|
|
||||||
if docker exec capacity-mysql mysqladmin ping -h localhost -u {MYSQL_ROOT_USER} -p{MYSQL_ROOT_PASSWORD} --silent 2>/dev/null; then
|
|
||||||
echo " MySQL: Ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ $i -eq 30 ]; then
|
|
||||||
echo " MySQL: Timeout"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if command -v curl &> /dev/null; then
|
|
||||||
for i in {{1..30}}; do
|
|
||||||
if curl -f http://localhost:{APP_PORT_HOST}/health >/dev/null 2>&1; then
|
|
||||||
echo " App: Ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ $i -eq 30 ]; then
|
|
||||||
echo " App: Timeout"
|
|
||||||
echo " Check logs: $DOCKER_COMPOSE_CMD logs capacity-app"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
else
|
|
||||||
for i in {{1..30}}; do
|
|
||||||
if docker ps --filter "name=capacity-report-app" --filter "status=running" --format "{{.Names}}" | grep -q "capacity-report-app"; then
|
|
||||||
if docker inspect capacity-report-app --format='{{.State.Health.Status}}' 2>/dev/null | grep -q "healthy"; then
|
|
||||||
echo " App: Ready"
|
|
||||||
break
|
|
||||||
elif [ $i -gt 10 ]; then
|
|
||||||
echo " App: Running"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ $i -eq 30 ]; then
|
|
||||||
echo " App: Timeout"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Deployment completed!"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Frontend: http://localhost:{APP_PORT_HOST}"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " Logs: $DOCKER_COMPOSE_CMD logs -f"
|
|
||||||
echo " Stop: $DOCKER_COMPOSE_CMD down"
|
|
||||||
echo " Restart: $DOCKER_COMPOSE_CMD restart"
|
|
||||||
echo " Status: $DOCKER_COMPOSE_CMD ps"
|
|
||||||
echo ""
|
|
||||||
echo "Database:"
|
|
||||||
echo " Host: localhost"
|
|
||||||
echo " Port: {MYSQL_PORT_HOST}"
|
|
||||||
echo " User: {MYSQL_ROOT_USER}"
|
|
||||||
echo " Password: {MYSQL_ROOT_PASSWORD}"
|
|
||||||
echo " Database: {MYSQL_DATABASE}"
|
|
||||||
echo ""
|
|
||||||
'''
|
|
||||||
|
|
||||||
def get_update_sh():
|
|
||||||
"""生成 update.sh 脚本内容"""
|
|
||||||
return f'''#!/bin/bash
|
|
||||||
# CapacityReport Update Script
|
|
||||||
# Update application container without affecting database
|
|
||||||
# Run this script when you need to update the application
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# Set permissions automatically
|
|
||||||
chmod -R 0777 "$SCRIPT_DIR" 2>/dev/null || true
|
|
||||||
find "$SCRIPT_DIR" -type f \\( -name "*.sh" -o -name "*.py" \\) -exec chmod +x {{}} \\; 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "CapacityReport Application Update"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check Docker
|
|
||||||
if ! command -v docker &> /dev/null; then
|
|
||||||
echo "Error: Docker not found, please install Docker"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Docker Compose
|
|
||||||
DOCKER_COMPOSE_CMD=""
|
|
||||||
if docker compose version &> /dev/null; then
|
|
||||||
DOCKER_COMPOSE_CMD="docker compose"
|
|
||||||
elif command -v docker-compose &> /dev/null; then
|
|
||||||
DOCKER_COMPOSE_CMD="docker-compose"
|
|
||||||
else
|
|
||||||
echo "Error: Docker Compose not found, please install Docker Compose"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if services are running
|
|
||||||
if ! docker ps --format "{{.Names}}" | grep -q "capacity-report-app"; then
|
|
||||||
echo "Error: Application container is not running"
|
|
||||||
echo "Please use deploy.sh for initial deployment"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Step 1: Checking current status..."
|
|
||||||
if docker ps --format "{{.Names}}" | grep -q "capacity-mysql"; then
|
|
||||||
echo " MySQL: Running"
|
|
||||||
else
|
|
||||||
echo " Warning: MySQL container is not running"
|
|
||||||
read -p "Continue? (y/n) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 2: Loading new application image..."
|
|
||||||
if [ -f "images/capacity-app-update.tar" ]; then
|
|
||||||
echo " Loading images from capacity-app-update.tar..."
|
|
||||||
docker load -i images/capacity-app-update.tar >/dev/null 2>&1
|
|
||||||
|
|
||||||
if docker images | grep -q "capacity-report-app.*latest"; then
|
|
||||||
echo " App image: Loaded"
|
|
||||||
else
|
|
||||||
echo " App image: Not found in tar file"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " Error: Image file not found: images/capacity-app-update.tar"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 3: Stopping application container..."
|
|
||||||
$DOCKER_COMPOSE_CMD stop capacity-app 2>/dev/null || true
|
|
||||||
$DOCKER_COMPOSE_CMD rm -f capacity-app 2>/dev/null || true
|
|
||||||
echo " Application stopped"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 4: Updating configuration files..."
|
|
||||||
if [ -f "Configure.json" ]; then
|
|
||||||
if command -v python3 &> /dev/null || command -v python &> /dev/null; then
|
|
||||||
(command -v python3 >/dev/null && python3 || python) << 'PYEOF'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open('Configure.json', 'r', encoding='utf-8') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
db_info = config.get('MySQL_DBInfo', {{}})
|
|
||||||
needs_update = False
|
|
||||||
|
|
||||||
if db_info.get('host') != '{MYSQL_HOST}':
|
|
||||||
db_info['host'] = '{MYSQL_HOST}'
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
if db_info.get('port') != {MYSQL_PORT}:
|
|
||||||
db_info['port'] = {MYSQL_PORT}
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
if needs_update:
|
|
||||||
with open('Configure.json', 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
|
||||||
print(" Updated Configure.json")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
PYEOF
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " Warning: Configure.json not found, creating default..."
|
|
||||||
cat > Configure.json << 'EOF'
|
|
||||||
{{
|
|
||||||
"MySQL_DBInfo": {{
|
|
||||||
"host": "{MYSQL_HOST}",
|
|
||||||
"port": {MYSQL_PORT},
|
|
||||||
"user": "{MYSQL_ROOT_USER}",
|
|
||||||
"passwd": "{MYSQL_ROOT_PASSWORD}",
|
|
||||||
"dbname": "{MYSQL_DATABASE}"
|
|
||||||
}},
|
|
||||||
"ExtractField": []
|
|
||||||
}}
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "ReportScript.sql" ]; then
|
|
||||||
touch ReportScript.sql
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p cache logs
|
|
||||||
chmod 777 cache logs 2>/dev/null || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 5: Starting application container..."
|
|
||||||
$DOCKER_COMPOSE_CMD up -d capacity-app >/dev/null 2>&1
|
|
||||||
echo " Application started"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Step 6: Waiting for application to be ready..."
|
|
||||||
if command -v curl &> /dev/null; then
|
|
||||||
for i in {{1..30}}; do
|
|
||||||
if curl -f http://localhost:{APP_PORT_HOST}/health >/dev/null 2>&1; then
|
|
||||||
echo " App: Ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ $i -eq 30 ]; then
|
|
||||||
echo " App: Timeout (may still be starting)"
|
|
||||||
echo " Check logs: $DOCKER_COMPOSE_CMD logs capacity-app"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
else
|
|
||||||
for i in {{1..30}}; do
|
|
||||||
if docker ps --filter "name=capacity-report-app" --filter "status=running" --format "{{.Names}}" | grep -q "capacity-report-app"; then
|
|
||||||
if docker inspect capacity-report-app --format='{{.State.Health.Status}}' 2>/dev/null | grep -q "healthy"; then
|
|
||||||
echo " App: Ready"
|
|
||||||
break
|
|
||||||
elif [ $i -gt 10 ]; then
|
|
||||||
echo " App: Running"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ $i -eq 30 ]; then
|
|
||||||
echo " App: Timeout"
|
|
||||||
echo " Check logs: $DOCKER_COMPOSE_CMD logs capacity-app"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Update completed!"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Frontend: http://localhost:{APP_PORT_HOST}"
|
|
||||||
echo ""
|
|
||||||
echo "Database status:"
|
|
||||||
if docker ps --format "{{.Names}}" | grep -q "capacity-mysql"; then
|
|
||||||
echo " MySQL: Running (unchanged)"
|
|
||||||
else
|
|
||||||
echo " MySQL: Not running"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "Useful commands:"
|
|
||||||
echo " View logs: $DOCKER_COMPOSE_CMD logs -f capacity-app"
|
|
||||||
echo " Restart app: $DOCKER_COMPOSE_CMD restart capacity-app"
|
|
||||||
echo " Status: $DOCKER_COMPOSE_CMD ps"
|
|
||||||
echo ""
|
|
||||||
'''
|
|
||||||
|
|
||||||
def build_full_package():
|
|
||||||
"""构建完整部署包"""
|
|
||||||
print_step("构建完整部署包...")
|
|
||||||
|
|
||||||
# 清理临时目录
|
|
||||||
if TEMP_BUILD_DIR.exists():
|
|
||||||
shutil.rmtree(TEMP_BUILD_DIR)
|
|
||||||
TEMP_BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 检查 Docker
|
|
||||||
if not check_command("docker"):
|
|
||||||
print_error("未检测到 Docker,请先安装 Docker Desktop")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 检查 Docker Compose
|
|
||||||
try:
|
|
||||||
subprocess.run(["docker", "compose", "version"], capture_output=True, check=True)
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
if not check_command("docker-compose"):
|
|
||||||
print_error("未检测到 Docker Compose")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 检查并获取符合要求的镜像
|
|
||||||
print_step("检查镜像版本...")
|
|
||||||
python_image = check_and_get_python_image()
|
|
||||||
mysql_image = check_and_get_mysql_image()
|
|
||||||
|
|
||||||
# 拉取基础镜像(如果不存在则自动拉取)
|
|
||||||
print_step("拉取基础镜像...")
|
|
||||||
if not image_exists(python_image):
|
|
||||||
print_info(f"Python 镜像不存在,正在拉取: {python_image}")
|
|
||||||
run_cmd(["docker", "pull", python_image])
|
|
||||||
print_info("Python 镜像拉取完成")
|
|
||||||
else:
|
|
||||||
print_info(f"Python 镜像已存在: {python_image}")
|
|
||||||
|
|
||||||
if not image_exists(mysql_image):
|
|
||||||
print_info(f"MySQL 镜像不存在,正在拉取: {mysql_image}")
|
|
||||||
run_cmd(["docker", "pull", mysql_image])
|
|
||||||
print_info("MySQL 镜像拉取完成")
|
|
||||||
else:
|
|
||||||
print_info(f"MySQL 镜像已存在: {mysql_image}")
|
|
||||||
|
|
||||||
# 构建应用镜像
|
|
||||||
print_step("构建应用镜像...")
|
|
||||||
# 使用 build 目录的 Dockerfile
|
|
||||||
dockerfile_path = BUILD_DIR / "Dockerfile"
|
|
||||||
if not dockerfile_path.exists():
|
|
||||||
print_error(f"Dockerfile 不存在: {dockerfile_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
print_info(f"使用 build 目录的 Dockerfile: {dockerfile_path}")
|
|
||||||
|
|
||||||
# 构建时使用项目根目录作为上下文(因为需要复制应用代码)
|
|
||||||
os.chdir(PROJECT_ROOT)
|
|
||||||
# 但使用 build 目录的 Dockerfile
|
|
||||||
dockerfile_arg = str(dockerfile_path)
|
|
||||||
# .dockerignore 会自动使用构建上下文根目录的(即 PROJECT_ROOT/.dockerignore)
|
|
||||||
# 从 build 目录复制 .dockerignore 到根目录(临时,构建时使用)
|
|
||||||
dockerignore_build = BUILD_DIR / ".dockerignore"
|
|
||||||
dockerignore_root = PROJECT_ROOT / ".dockerignore"
|
|
||||||
if dockerignore_build.exists():
|
|
||||||
shutil.copy2(dockerignore_build, dockerignore_root)
|
|
||||||
print_info("已使用 build 目录的 .dockerignore")
|
|
||||||
run_cmd(["docker", "build", "-t", "capacity-report-app:latest", "-f", dockerfile_arg, "."])
|
|
||||||
# 清理临时复制的 .dockerignore(如果根目录原本没有)
|
|
||||||
if dockerignore_build.exists() and dockerignore_root.exists():
|
|
||||||
try:
|
|
||||||
# 如果内容相同,说明是从 build 目录复制的,可以删除
|
|
||||||
if dockerignore_build.read_text() == dockerignore_root.read_text():
|
|
||||||
dockerignore_root.unlink()
|
|
||||||
print_info("已清理临时 .dockerignore")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 标记 MySQL 镜像(提取版本号)
|
|
||||||
mysql_tag = mysql_image.split(':')[1]
|
|
||||||
mysql_tagged = f"capacity-mysql:{mysql_tag}"
|
|
||||||
run_cmd(["docker", "tag", mysql_image, mysql_tagged])
|
|
||||||
|
|
||||||
# 导出镜像
|
|
||||||
print_step("导出镜像...")
|
|
||||||
images_dir = TEMP_BUILD_DIR / "images"
|
|
||||||
images_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
tar_file = images_dir / "capacity-images.tar"
|
|
||||||
# 提取 MySQL 标签
|
|
||||||
mysql_tag = mysql_image.split(':')[1]
|
|
||||||
mysql_tagged = f"capacity-mysql:{mysql_tag}"
|
|
||||||
|
|
||||||
run_cmd([
|
|
||||||
"docker", "save",
|
|
||||||
"capacity-report-app:latest",
|
|
||||||
mysql_tagged,
|
|
||||||
"-o", str(tar_file)
|
|
||||||
])
|
|
||||||
print_info(f"镜像导出完成: {format_size(tar_file.stat().st_size)}")
|
|
||||||
|
|
||||||
# 复制配置文件
|
|
||||||
print_step("复制配置文件...")
|
|
||||||
# 优先从 build 目录读取,如果不存在则从根目录读取
|
|
||||||
def get_source_path(filename, subdir=None):
|
|
||||||
"""获取源文件路径,优先 build 目录,其次根目录"""
|
|
||||||
if subdir:
|
|
||||||
build_path = BUILD_DIR / subdir / filename
|
|
||||||
root_path = PROJECT_ROOT / subdir / filename
|
|
||||||
else:
|
|
||||||
build_path = BUILD_DIR / filename
|
|
||||||
root_path = PROJECT_ROOT / filename
|
|
||||||
return build_path if build_path.exists() else root_path
|
|
||||||
|
|
||||||
# Configure.json 和 ReportScript.sql 从项目根目录读取(应用配置文件)
|
|
||||||
files_to_copy = [
|
|
||||||
(PROJECT_ROOT / "Configure.json", TEMP_BUILD_DIR / "Configure.json"),
|
|
||||||
(PROJECT_ROOT / "ReportScript.sql", TEMP_BUILD_DIR / "ReportScript.sql"),
|
|
||||||
(get_source_path("docker-compose.yml"), TEMP_BUILD_DIR / "docker-compose.yml"),
|
|
||||||
(get_source_path("custom.cnf", "mysql/conf.d"), TEMP_BUILD_DIR / "mysql" / "conf.d" / "custom.cnf"),
|
|
||||||
(get_source_path("01-init-db.sql", "mysql/init"), TEMP_BUILD_DIR / "mysql" / "init" / "01-init-db.sql"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for src, dst in files_to_copy:
|
|
||||||
if src.exists():
|
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copy2(src, dst)
|
|
||||||
else:
|
|
||||||
print_warning(f"{src.name} 不存在: {src}")
|
|
||||||
|
|
||||||
# 更新 Configure.json
|
|
||||||
config_file = TEMP_BUILD_DIR / "Configure.json"
|
|
||||||
if config_file.exists():
|
|
||||||
with open(config_file, 'r', encoding='utf-8') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
config['MySQL_DBInfo'] = {
|
|
||||||
'host': MYSQL_HOST,
|
|
||||||
'port': MYSQL_PORT,
|
|
||||||
'user': MYSQL_ROOT_USER,
|
|
||||||
'passwd': MYSQL_ROOT_PASSWORD,
|
|
||||||
'dbname': MYSQL_DATABASE
|
|
||||||
}
|
|
||||||
with open(config_file, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
# 生成 deploy.sh
|
|
||||||
print_step("生成部署脚本...")
|
|
||||||
write_sh_script(TEMP_BUILD_DIR / "deploy.sh", get_deploy_sh())
|
|
||||||
|
|
||||||
# 打包
|
|
||||||
print_step("打包部署包...")
|
|
||||||
tar_output = DIST_DIR / "capacity-report-full.tar.gz"
|
|
||||||
DIST_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
with tarfile.open(tar_output, 'w:gz') as tar:
|
|
||||||
tar.add(TEMP_BUILD_DIR, arcname='.')
|
|
||||||
|
|
||||||
print_info(f"部署包已生成: {tar_output}")
|
|
||||||
print_info(f"文件大小: {format_size(tar_output.stat().st_size)}")
|
|
||||||
|
|
||||||
# 清理临时文件和缓存
|
|
||||||
print_step("清理临时文件...")
|
|
||||||
if TEMP_BUILD_DIR.exists():
|
|
||||||
shutil.rmtree(TEMP_BUILD_DIR, ignore_errors=True)
|
|
||||||
print_info("已清理临时构建目录")
|
|
||||||
|
|
||||||
return tar_output
|
|
||||||
|
|
||||||
def build_update_package():
|
|
||||||
"""构建更新包"""
|
|
||||||
print_step("构建更新包...")
|
|
||||||
|
|
||||||
# 清理临时目录
|
|
||||||
if TEMP_BUILD_DIR.exists():
|
|
||||||
shutil.rmtree(TEMP_BUILD_DIR)
|
|
||||||
TEMP_BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 检查 Docker
|
|
||||||
if not check_command("docker"):
|
|
||||||
print_error("未检测到 Docker")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 检查并获取符合要求的镜像
|
|
||||||
print_step("检查镜像版本...")
|
|
||||||
python_image = check_and_get_python_image()
|
|
||||||
|
|
||||||
# 拉取基础镜像(如果不存在则自动拉取)
|
|
||||||
print_step("拉取基础镜像...")
|
|
||||||
if not image_exists(python_image):
|
|
||||||
print_info(f"Python 镜像不存在,正在拉取: {python_image}")
|
|
||||||
run_cmd(["docker", "pull", python_image])
|
|
||||||
print_info("Python 镜像拉取完成")
|
|
||||||
else:
|
|
||||||
print_info(f"Python 镜像已存在: {python_image}")
|
|
||||||
|
|
||||||
# 构建应用镜像
|
|
||||||
print_step("构建应用镜像...")
|
|
||||||
# 使用 build 目录的 Dockerfile
|
|
||||||
dockerfile_path = BUILD_DIR / "Dockerfile"
|
|
||||||
if not dockerfile_path.exists():
|
|
||||||
print_error(f"Dockerfile 不存在: {dockerfile_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
print_info(f"使用 build 目录的 Dockerfile: {dockerfile_path}")
|
|
||||||
|
|
||||||
# 构建时使用项目根目录作为上下文(因为需要复制应用代码)
|
|
||||||
os.chdir(PROJECT_ROOT)
|
|
||||||
# 但使用 build 目录的 Dockerfile
|
|
||||||
dockerfile_arg = str(dockerfile_path)
|
|
||||||
# .dockerignore 会自动使用构建上下文根目录的(即 PROJECT_ROOT/.dockerignore)
|
|
||||||
# 从 build 目录复制 .dockerignore 到根目录(临时,构建时使用)
|
|
||||||
dockerignore_build = BUILD_DIR / ".dockerignore"
|
|
||||||
dockerignore_root = PROJECT_ROOT / ".dockerignore"
|
|
||||||
if dockerignore_build.exists():
|
|
||||||
shutil.copy2(dockerignore_build, dockerignore_root)
|
|
||||||
print_info("已使用 build 目录的 .dockerignore")
|
|
||||||
run_cmd(["docker", "build", "-t", "capacity-report-app:latest", "-f", dockerfile_arg, "."])
|
|
||||||
# 清理临时复制的 .dockerignore(如果根目录原本没有)
|
|
||||||
if dockerignore_build.exists() and dockerignore_root.exists():
|
|
||||||
try:
|
|
||||||
# 如果内容相同,说明是从 build 目录复制的,可以删除
|
|
||||||
if dockerignore_build.read_text() == dockerignore_root.read_text():
|
|
||||||
dockerignore_root.unlink()
|
|
||||||
print_info("已清理临时 .dockerignore")
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 导出镜像
|
|
||||||
print_step("导出镜像...")
|
|
||||||
images_dir = TEMP_BUILD_DIR / "images"
|
|
||||||
images_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
tar_file = images_dir / "capacity-app-update.tar"
|
|
||||||
run_cmd([
|
|
||||||
"docker", "save",
|
|
||||||
"capacity-report-app:latest",
|
|
||||||
"-o", str(tar_file)
|
|
||||||
])
|
|
||||||
print_info(f"镜像导出完成: {format_size(tar_file.stat().st_size)}")
|
|
||||||
|
|
||||||
# 复制配置文件
|
|
||||||
print_step("复制配置文件...")
|
|
||||||
# 从 build 目录读取配置文件
|
|
||||||
def get_source_path(filename, subdir=None):
|
|
||||||
"""获取源文件路径,从 build 目录读取"""
|
|
||||||
if subdir:
|
|
||||||
return BUILD_DIR / subdir / filename
|
|
||||||
else:
|
|
||||||
return BUILD_DIR / filename
|
|
||||||
|
|
||||||
files_to_copy = [
|
|
||||||
(get_source_path("docker-compose.yml"), TEMP_BUILD_DIR / "docker-compose.yml"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for src, dst in files_to_copy:
|
|
||||||
if src.exists():
|
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copy2(src, dst)
|
|
||||||
|
|
||||||
# 生成 update.sh
|
|
||||||
print_step("生成更新脚本...")
|
|
||||||
write_sh_script(TEMP_BUILD_DIR / "update.sh", get_update_sh())
|
|
||||||
|
|
||||||
# 打包
|
|
||||||
print_step("打包更新包...")
|
|
||||||
tar_output = DIST_DIR / "capacity-report-update.tar.gz"
|
|
||||||
DIST_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
with tarfile.open(tar_output, 'w:gz') as tar:
|
|
||||||
tar.add(TEMP_BUILD_DIR, arcname='.')
|
|
||||||
|
|
||||||
print_info(f"更新包已生成: {tar_output}")
|
|
||||||
print_info(f"文件大小: {format_size(tar_output.stat().st_size)}")
|
|
||||||
|
|
||||||
# 清理临时文件和缓存
|
|
||||||
print_step("清理临时文件...")
|
|
||||||
if TEMP_BUILD_DIR.exists():
|
|
||||||
shutil.rmtree(TEMP_BUILD_DIR, ignore_errors=True)
|
|
||||||
print_info("已清理临时构建目录")
|
|
||||||
|
|
||||||
return tar_output
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 60)
|
|
||||||
print("CapacityReport 统一构建脚本")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
print_choice("请选择构建类型:")
|
|
||||||
print(" 1. 完整部署包 (包含 MySQL + 应用)")
|
|
||||||
print(" 2. 更新包 (仅应用)")
|
|
||||||
print()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
choice = input("请输入选项 (1/2): ").strip()
|
|
||||||
if choice in ['1', '2']:
|
|
||||||
break
|
|
||||||
print_error("无效选项,请输入 1 或 2")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
if choice == '1':
|
|
||||||
tar_file = build_full_package()
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("构建完成!")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
print(f"部署包位置: {tar_file}")
|
|
||||||
print()
|
|
||||||
print("使用方法:")
|
|
||||||
print("1. 将 tar.gz 文件传输到目标 Linux 机器")
|
|
||||||
print("2. 解压: tar -xzf capacity-report-full.tar.gz")
|
|
||||||
print("3. 进入解压目录,执行: sh deploy.sh")
|
|
||||||
print()
|
|
||||||
else:
|
|
||||||
tar_file = build_update_package()
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("构建完成!")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
print(f"更新包位置: {tar_file}")
|
|
||||||
print()
|
|
||||||
print("使用方法:")
|
|
||||||
print("1. 将 tar.gz 文件传输到目标 Linux 机器")
|
|
||||||
print("2. 解压: tar -xzf capacity-report-update.tar.gz")
|
|
||||||
print("3. 进入解压目录,执行: sh update.sh")
|
|
||||||
print()
|
|
||||||
print("注意: 更新包仅用于更新已部署的应用,不包含 MySQL")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\n构建已取消")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print_error(f"构建失败: {e}")
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
onefile = os.environ.get("CAPAREPORT_ONEFILE") == "1"
|
||||||
|
|
||||||
|
hiddenimports = [
|
||||||
|
"uvicorn.logging",
|
||||||
|
"uvicorn.loops.auto",
|
||||||
|
"uvicorn.loops.asyncio",
|
||||||
|
"uvicorn.protocols.http.auto",
|
||||||
|
"uvicorn.protocols.http.h11_impl",
|
||||||
|
"uvicorn.protocols.websockets.auto",
|
||||||
|
"uvicorn.protocols.websockets.websockets_impl",
|
||||||
|
"uvicorn.lifespan.on",
|
||||||
|
"pymysql",
|
||||||
|
"paramiko",
|
||||||
|
"openpyxl",
|
||||||
|
"chardet",
|
||||||
|
]
|
||||||
|
|
||||||
|
excludes = [
|
||||||
|
"pytest",
|
||||||
|
"pandas.tests",
|
||||||
|
"numpy.tests",
|
||||||
|
"openpyxl.tests",
|
||||||
|
"matplotlib",
|
||||||
|
"IPython",
|
||||||
|
"notebook",
|
||||||
|
"tkinter",
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
[str(project_root / "app" / "main.py")],
|
||||||
|
pathex=[str(project_root)],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=excludes,
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
if onefile:
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
exclude_binaries=False,
|
||||||
|
name="capareport-server",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name="capareport-server",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name="capareport-server",
|
||||||
|
)
|
||||||
@@ -1,15 +1,4 @@
|
|||||||
# CapacityReport Docker 编排文件
|
|
||||||
# 用于内网离线部署
|
|
||||||
#
|
|
||||||
# 使用方法:
|
|
||||||
# sh deploy.sh # 一键导入镜像并启动服务
|
|
||||||
# docker-compose up -d # 启动所有服务
|
|
||||||
# docker-compose down # 停止所有服务
|
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# 容量报表应用
|
|
||||||
capacity-app:
|
capacity-app:
|
||||||
image: capacity-report-app:latest
|
image: capacity-report-app:latest
|
||||||
container_name: capacity-report-app
|
container_name: capacity-report-app
|
||||||
@@ -17,12 +6,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "19081:9081"
|
- "19081:9081"
|
||||||
volumes:
|
volumes:
|
||||||
# 持久化数据
|
- ../cache:/app/cache
|
||||||
- ./cache:/app/cache
|
- ../Configure.json:/app/Configure.json
|
||||||
- ./Configure.json:/app/Configure.json
|
- ../ReportScript.sql:/app/ReportScript.sql
|
||||||
- ./ReportScript.sql:/app/ReportScript.sql
|
- ../logs:/var/log/supervisor
|
||||||
# 日志目录
|
|
||||||
- ./logs:/var/log/supervisor
|
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
@@ -33,19 +20,15 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- capacity-network
|
- capacity-network
|
||||||
|
|
||||||
# MySQL 数据库
|
|
||||||
capacity-mysql:
|
capacity-mysql:
|
||||||
image: capacity-mysql:8.0.44
|
image: mysql:8.0.44
|
||||||
container_name: capacity-mysql
|
container_name: capacity-mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "13306:3306"
|
- "13306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
# 数据持久化
|
|
||||||
- capacity-mysql-data:/var/lib/mysql
|
- capacity-mysql-data:/var/lib/mysql
|
||||||
# 自定义配置(启用 LOAD DATA INFILE)
|
|
||||||
- ./mysql/conf.d:/etc/mysql/conf.d:ro
|
- ./mysql/conf.d:/etc/mysql/conf.d:ro
|
||||||
# 初始化脚本(仅在首次启动时执行)
|
|
||||||
- ./mysql/init:/docker-entrypoint-initdb.d:ro
|
- ./mysql/init:/docker-entrypoint-initdb.d:ro
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
|||||||
@@ -1,33 +1,27 @@
|
|||||||
# MySQL 自定义配置 - CapacityReport
|
# MySQL settings for CapacityReport
|
||||||
# 启用 LOAD DATA LOCAL INFILE 支持
|
|
||||||
|
|
||||||
[mysqld]
|
[mysqld]
|
||||||
# 字符集
|
# Charset
|
||||||
character-set-server = utf8mb4
|
character-set-server = utf8mb4
|
||||||
collation-server = utf8mb4_unicode_ci
|
collation-server = utf8mb4_unicode_ci
|
||||||
|
|
||||||
# 启用 LOAD DATA LOCAL INFILE
|
# LOAD DATA LOCAL INFILE
|
||||||
local_infile = ON
|
local_infile = ON
|
||||||
secure_file_priv = ""
|
secure_file_priv = ""
|
||||||
|
|
||||||
# 性能优化
|
# Performance
|
||||||
max_allowed_packet = 256M
|
max_allowed_packet = 256M
|
||||||
innodb_buffer_pool_size = 512M
|
innodb_buffer_pool_size = 512M
|
||||||
innodb_log_file_size = 128M
|
innodb_log_file_size = 128M
|
||||||
innodb_flush_log_at_trx_commit = 2
|
innodb_flush_log_at_trx_commit = 2
|
||||||
sync_binlog = 0
|
sync_binlog = 0
|
||||||
|
|
||||||
# 连接设置
|
# Connections
|
||||||
max_connections = 200
|
max_connections = 200
|
||||||
wait_timeout = 28800
|
wait_timeout = 28800
|
||||||
interactive_timeout = 28800
|
interactive_timeout = 28800
|
||||||
|
|
||||||
# 日志(可选,生产环境建议注释掉)
|
|
||||||
# general_log = 1
|
|
||||||
# general_log_file = /var/lib/mysql/general.log
|
|
||||||
|
|
||||||
[mysql]
|
[mysql]
|
||||||
# 客户端设置
|
|
||||||
local_infile = ON
|
local_infile = ON
|
||||||
default-character-set = utf8mb4
|
default-character-set = utf8mb4
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
-- MySQL 初始化脚本 - 确保数据库以正确的字符集创建
|
-- MySQL initialization for CapacityReport.
|
||||||
-- 此脚本仅在首次初始化时执行(数据目录为空时)
|
-- This file runs only when the MySQL data directory is empty.
|
||||||
|
|
||||||
-- 如果数据库不存在,则创建(使用 utf8mb4 字符集)
|
|
||||||
CREATE DATABASE IF NOT EXISTS CapacityReport
|
CREATE DATABASE IF NOT EXISTS CapacityReport
|
||||||
CHARACTER SET utf8mb4
|
CHARACTER SET utf8mb4
|
||||||
COLLATE utf8mb4_unicode_ci;
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 切换到数据库
|
|
||||||
USE CapacityReport;
|
USE CapacityReport;
|
||||||
|
|
||||||
-- 输出确认信息
|
|
||||||
SELECT 'CapacityReport database initialized with utf8mb4 charset' AS status;
|
SELECT 'CapacityReport database initialized with utf8mb4 charset' AS status;
|
||||||
|
|||||||
@@ -378,3 +378,16 @@
|
|||||||
- 当前项目按每周整包替换使用,不维护旧版 API 兼容层;但核心处理流程、配置文件和 `ReportScript.sql` 仍沿用现有语义。
|
- 当前项目按每周整包替换使用,不维护旧版 API 兼容层;但核心处理流程、配置文件和 `ReportScript.sql` 仍沿用现有语义。
|
||||||
- `ReportScript.sql` 是业务处理链路的一部分,重构接口或前端时不要改写 SQL 语义。
|
- `ReportScript.sql` 是业务处理链路的一部分,重构接口或前端时不要改写 SQL 语义。
|
||||||
- `auth.ini`、`cache/`、`dist/`、`frontend/dist/`、`frontend/node_modules/` 均为本地运行或构建产物,不进入版本库。
|
- `auth.ini`、`cache/`、`dist/`、`frontend/dist/`、`frontend/node_modules/` 均为本地运行或构建产物,不进入版本库。
|
||||||
|
|
||||||
|
## 2026-05-20: Cross-platform packaging
|
||||||
|
|
||||||
|
- Added one-command build entry points: `scripts/build.bat`, `scripts/build.ps1`, and `scripts/build.sh`. Targets are `server`, `desktop`, `docker`, and `all`; script output stays ASCII to avoid console encoding issues on Windows.
|
||||||
|
- Server Portable uses PyInstaller one-dir mode and packages the backend executable with `frontend/dist`, `Configure.json`, `ReportScript.sql`, `cache/`, `logs/`, and launch scripts. The default server port remains `9081`.
|
||||||
|
- Desktop packaging uses Tauri 2 + Vue + Python sidecar. The build sets `VITE_API_BASE=http://127.0.0.1:19082`, builds a PyInstaller one-file `capareport-server` sidecar, and the desktop app starts it on `127.0.0.1:19082`.
|
||||||
|
- `app/config.py` supports `CAPAREPORT_BASE_DIR`; frozen PyInstaller server builds default `BASE_DIR` to the executable directory. The Tauri sidecar sets `CAPAREPORT_BASE_DIR` to the app data directory so runtime files are not written into the install directory.
|
||||||
|
- Tauri startup creates app-data `cache/` and `logs/`, then copies bundled `Configure.json` and `ReportScript.sql` on first run. Resource lookup supports both normal Tauri resources and the `_up_` directory generated for bundled `../` resources.
|
||||||
|
- Windows desktop shutdown uses `taskkill /F /T /PID` before killing the shell child, which avoids PyInstaller one-file sidecar process leftovers.
|
||||||
|
- Docker build now copies only required app files and frontend build output. `.dockerignore` excludes local dependencies, caches, build output, and local config; `build/docker-compose.yml` mounts paths relative to the `build/` directory.
|
||||||
|
- `app/main.py` accepts `--host` and `--port` and exposes `run_server()` so portable launchers, Docker, and the Tauri sidecar share the same backend entry point.
|
||||||
|
- Windows verification completed: `scripts\build.bat server -NoArchive`, `scripts\build.bat server`, `scripts\build.bat docker`, and `scripts\build.bat desktop`. Server portable `/health`, Docker container `/health`, and desktop sidecar `/health` all returned HTTP 200; desktop first run also copied config and SQL into app data.
|
||||||
|
- Cleanup verification completed: `.venv\Scripts\python.exe -m compileall app`, `npm run build`, PowerShell AST parse for `scripts/build.ps1`, Docker-hosted `sh -n scripts/build.sh`, and `cargo check --manifest-path src-tauri\Cargo.toml` with a temporary sidecar placeholder all passed. Linux/macOS native server and desktop packages still need native OS verification.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ApiError } from '../types';
|
import type { ApiError } from '../types';
|
||||||
|
|
||||||
const TOKEN_KEY = 'capacity_report_token';
|
const TOKEN_KEY = 'capacity_report_token';
|
||||||
|
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||||
let onUnauthorized: (() => void) | null = null;
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
|
||||||
export function getToken(): string {
|
export function getToken(): string {
|
||||||
@@ -40,7 +41,7 @@ export async function request<T>(url: string, init: RequestInit = {}): Promise<T
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, { ...init, headers });
|
const response = await fetch(apiUrl(url), { ...init, headers });
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken();
|
clearToken();
|
||||||
onUnauthorized?.();
|
onUnauthorized?.();
|
||||||
@@ -62,7 +63,7 @@ export function upload<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', url);
|
xhr.open('POST', apiUrl(url));
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -103,7 +104,7 @@ export async function download(url: string, body: unknown, filename: string): Pr
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(apiUrl(url), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
@@ -123,7 +124,7 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, { headers });
|
const response = await fetch(apiUrl(url), { headers });
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken();
|
clearToken();
|
||||||
onUnauthorized?.();
|
onUnauthorized?.();
|
||||||
@@ -177,3 +178,10 @@ function parseFilename(disposition: string | null): string {
|
|||||||
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
|
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||||||
return plainMatch?.[1] || '';
|
return plainMatch?.[1] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiUrl(url: string): string {
|
||||||
|
if (!API_BASE || /^https?:\/\//i.test(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set SCRIPT_DIR=%~dp0
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%build.ps1" %*
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
param(
|
||||||
|
[ValidateSet("server", "desktop", "docker", "all")]
|
||||||
|
[string]$Target = "all",
|
||||||
|
|
||||||
|
[ValidateSet("current", "windows", "linux", "macos")]
|
||||||
|
[string]$Platform = "current",
|
||||||
|
|
||||||
|
[switch]$SkipDockerBuild,
|
||||||
|
[switch]$SkipDesktopBuild,
|
||||||
|
[switch]$NoArchive,
|
||||||
|
[switch]$Clean,
|
||||||
|
[Alias("h", "?")]
|
||||||
|
[switch]$Help
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$Root = Resolve-Path (Join-Path $ScriptDir "..")
|
||||||
|
$DistDir = Join-Path $Root "dist"
|
||||||
|
$PackageDir = Join-Path $DistDir "packages"
|
||||||
|
$PyInstallerDir = Join-Path $Root "build\pyinstaller"
|
||||||
|
$FrontendDir = Join-Path $Root "frontend"
|
||||||
|
$ServerName = "capareport-server"
|
||||||
|
$AppVersion = "2.0.2"
|
||||||
|
$DesktopApiBase = "http://127.0.0.1:19082"
|
||||||
|
|
||||||
|
function Write-Step($Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Info($Message) {
|
||||||
|
Write-Host " $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Usage {
|
||||||
|
Write-Host "Usage: scripts\build.bat [server|desktop|docker|all] [options]"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Options:"
|
||||||
|
Write-Host " -Platform current|windows|linux|macos"
|
||||||
|
Write-Host " -NoArchive"
|
||||||
|
Write-Host " -Clean"
|
||||||
|
Write-Host " -SkipDockerBuild"
|
||||||
|
Write-Host " -SkipDesktopBuild"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Checked($Command, $WorkingDirectory = $Root) {
|
||||||
|
Write-Info $Command
|
||||||
|
Push-Location $WorkingDirectory
|
||||||
|
try {
|
||||||
|
cmd /c $Command
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Command failed with exit code $LASTEXITCODE`: $Command"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CurrentPlatform {
|
||||||
|
if ($Platform -ne "current") {
|
||||||
|
return $Platform
|
||||||
|
}
|
||||||
|
if ($env:OS -eq "Windows_NT") {
|
||||||
|
return "windows"
|
||||||
|
}
|
||||||
|
$uname = (uname -s 2>$null)
|
||||||
|
if ($uname -eq "Darwin") {
|
||||||
|
return "macos"
|
||||||
|
}
|
||||||
|
return "linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExecutableName($PlatformName) {
|
||||||
|
if ($PlatformName -eq "windows") {
|
||||||
|
return "$ServerName.exe"
|
||||||
|
}
|
||||||
|
return $ServerName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RustHostTriple {
|
||||||
|
$output = rustc -vV
|
||||||
|
foreach ($line in $output) {
|
||||||
|
if ($line -like "host:*") {
|
||||||
|
return $line.Substring(5).Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw "Unable to detect Rust host triple"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-TauriCli {
|
||||||
|
cmd /c "cargo tauri --version >nul 2>nul"
|
||||||
|
return $LASTEXITCODE -eq 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-PythonEnvironment {
|
||||||
|
Write-Step "Preparing Python environment"
|
||||||
|
$venvPython = Join-Path $Root ".venv\Scripts\python.exe"
|
||||||
|
if (!(Test-Path $venvPython)) {
|
||||||
|
Invoke-Checked "uv venv" $Root
|
||||||
|
}
|
||||||
|
Invoke-Checked "uv pip install -r requirements.txt" $Root
|
||||||
|
Invoke-Checked "uv pip install pyinstaller" $Root
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-Frontend($ApiBase) {
|
||||||
|
Write-Step "Building frontend"
|
||||||
|
if (!(Test-Path (Join-Path $FrontendDir "node_modules"))) {
|
||||||
|
Invoke-Checked "npm ci" $FrontendDir
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousApiBase = $env:VITE_API_BASE
|
||||||
|
if ($ApiBase) {
|
||||||
|
$env:VITE_API_BASE = $ApiBase
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Remove-Item Env:\VITE_API_BASE -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-Checked "npm run build" $FrontendDir
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $previousApiBase) {
|
||||||
|
$env:VITE_API_BASE = $previousApiBase
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Remove-Item Env:\VITE_API_BASE -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-ServerBinary([switch]$OneFile) {
|
||||||
|
Ensure-PythonEnvironment
|
||||||
|
Write-Step "Building server binary"
|
||||||
|
Remove-Item -LiteralPath $PyInstallerDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
$python = Join-Path $Root ".venv\Scripts\python.exe"
|
||||||
|
$previousOneFile = $env:CAPAREPORT_ONEFILE
|
||||||
|
if ($OneFile) {
|
||||||
|
$env:CAPAREPORT_ONEFILE = "1"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Remove-Item Env:\CAPAREPORT_ONEFILE -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-Checked "`"$python`" -m PyInstaller --clean --noconfirm --workpath build\pyinstaller\work --distpath build\pyinstaller\dist build\capareport-server.spec" $Root
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $previousOneFile) {
|
||||||
|
$env:CAPAREPORT_ONEFILE = $previousOneFile
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Remove-Item Env:\CAPAREPORT_ONEFILE -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($OneFile) {
|
||||||
|
$binaryDir = Join-Path $PyInstallerDir "dist"
|
||||||
|
$binaryPath = Join-Path $binaryDir (Get-ExecutableName (Get-CurrentPlatform))
|
||||||
|
if (!(Test-Path $binaryPath)) {
|
||||||
|
throw "PyInstaller output not found: $binaryPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$binaryDir = Join-Path $PyInstallerDir "dist\$ServerName"
|
||||||
|
if (!(Test-Path $binaryDir)) {
|
||||||
|
throw "PyInstaller output not found: $binaryDir"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $binaryDir
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-TextFile($Path, $Content, [switch]$Lf) {
|
||||||
|
$parent = Split-Path -Parent $Path
|
||||||
|
New-Item -ItemType Directory -Force -Path $parent | Out-Null
|
||||||
|
if ($Lf) {
|
||||||
|
[System.IO.File]::WriteAllText($Path, $Content.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
[System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Copy-RuntimeFiles($OutputDir) {
|
||||||
|
Copy-Item -Path (Join-Path $Root "Configure.json") -Destination (Join-Path $OutputDir "Configure.json") -Force
|
||||||
|
Copy-Item -Path (Join-Path $Root "ReportScript.sql") -Destination (Join-Path $OutputDir "ReportScript.sql") -Force
|
||||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "cache") | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "logs") | Out-Null
|
||||||
|
|
||||||
|
$frontendDist = Join-Path $FrontendDir "dist"
|
||||||
|
if (!(Test-Path $frontendDist)) {
|
||||||
|
throw "Frontend dist not found: $frontendDist"
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir "frontend") | Out-Null
|
||||||
|
Copy-Item -Path $frontendDist -Destination (Join-Path $OutputDir "frontend") -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-ServerLaunchers($OutputDir, $PlatformName) {
|
||||||
|
$exe = Get-ExecutableName $PlatformName
|
||||||
|
$bat = @"
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if not exist cache mkdir cache
|
||||||
|
if not exist logs mkdir logs
|
||||||
|
echo Starting CapacityReport server...
|
||||||
|
echo URL: http://localhost:9081
|
||||||
|
"%~dp0$exe" --host 0.0.0.0 --port 9081
|
||||||
|
pause
|
||||||
|
"@
|
||||||
|
Write-TextFile (Join-Path $OutputDir "run.bat") $bat
|
||||||
|
|
||||||
|
$sh = @'
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
mkdir -p cache logs
|
||||||
|
chmod +x "./__SERVER_EXE__" 2>/dev/null || true
|
||||||
|
echo "Starting CapacityReport server..."
|
||||||
|
echo "URL: http://localhost:9081"
|
||||||
|
exec "./__SERVER_EXE__" --host 0.0.0.0 --port 9081
|
||||||
|
'@
|
||||||
|
$sh = $sh.Replace("__SERVER_EXE__", $exe)
|
||||||
|
Write-TextFile (Join-Path $OutputDir "start.sh") $sh -Lf
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-ServerPortable {
|
||||||
|
$platformName = Get-CurrentPlatform
|
||||||
|
if ($platformName -ne "windows" -and $env:OS -eq "Windows_NT") {
|
||||||
|
Write-Host "Native $platformName portable packages must be built on $platformName." -ForegroundColor Yellow
|
||||||
|
Write-Host "The Windows build will continue for the current host." -ForegroundColor Yellow
|
||||||
|
$platformName = "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
Build-Frontend $null
|
||||||
|
$binaryDir = Build-ServerBinary
|
||||||
|
|
||||||
|
$outputDir = Join-Path $PackageDir "CapacityReport-Server-$platformName-x64"
|
||||||
|
Remove-Item -LiteralPath $outputDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
|
||||||
|
|
||||||
|
Copy-Item -Path (Join-Path $binaryDir "*") -Destination $outputDir -Recurse -Force
|
||||||
|
Copy-RuntimeFiles $outputDir
|
||||||
|
Write-ServerLaunchers $outputDir $platformName
|
||||||
|
|
||||||
|
if (!$NoArchive) {
|
||||||
|
$archive = "$outputDir.zip"
|
||||||
|
Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue
|
||||||
|
Compress-Archive -Path (Join-Path $outputDir "*") -DestinationPath $archive -Force
|
||||||
|
Write-Info "Archive: $archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info "Server package: $outputDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-DockerImage {
|
||||||
|
if ($SkipDockerBuild) {
|
||||||
|
Write-Host "Docker build skipped."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Building Docker image"
|
||||||
|
Invoke-Checked "docker build --progress=plain -f build\Dockerfile -t capacity-report-app:latest ." $Root
|
||||||
|
Invoke-Checked "docker compose -f build\docker-compose.yml config" $Root
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-DesktopPackage {
|
||||||
|
if ($SkipDesktopBuild) {
|
||||||
|
Write-Host "Desktop build skipped."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(Get-Command rustc -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Rust is required for the Tauri desktop build"
|
||||||
|
}
|
||||||
|
|
||||||
|
Build-Frontend $DesktopApiBase
|
||||||
|
$binaryDir = Build-ServerBinary -OneFile
|
||||||
|
$platformName = Get-CurrentPlatform
|
||||||
|
$exe = Get-ExecutableName $platformName
|
||||||
|
$triple = Get-RustHostTriple
|
||||||
|
$sidecarDir = Join-Path $Root "src-tauri\binaries"
|
||||||
|
New-Item -ItemType Directory -Force -Path $sidecarDir | Out-Null
|
||||||
|
$sidecarName = if ($platformName -eq "windows") { "$ServerName-$triple.exe" } else { "$ServerName-$triple" }
|
||||||
|
Copy-Item -Path (Join-Path $binaryDir $exe) -Destination (Join-Path $sidecarDir $sidecarName) -Force
|
||||||
|
|
||||||
|
if (!(Test-TauriCli)) {
|
||||||
|
Write-Step "Installing Tauri CLI"
|
||||||
|
Invoke-Checked "cargo install tauri-cli --locked" $Root
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Building Tauri desktop package"
|
||||||
|
Invoke-Checked "cargo tauri build" (Join-Path $Root "src-tauri")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Help) {
|
||||||
|
Show-Usage
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Clean) {
|
||||||
|
Write-Step "Cleaning build output"
|
||||||
|
Remove-Item -LiteralPath $DistDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $PyInstallerDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath (Join-Path $Root "src-tauri\binaries") -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $PackageDir | Out-Null
|
||||||
|
|
||||||
|
switch ($Target) {
|
||||||
|
"server" { Build-ServerPortable }
|
||||||
|
"docker" { Build-DockerImage }
|
||||||
|
"desktop" { Build-DesktopPackage }
|
||||||
|
"all" {
|
||||||
|
Build-ServerPortable
|
||||||
|
Build-DockerImage
|
||||||
|
Build-DesktopPackage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Build finished"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TARGET="${1:-server}"
|
||||||
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
DESKTOP_API_BASE="http://127.0.0.1:19082"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '\n==> %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
printf ' %s\n' "$*"
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
platform_name() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) printf 'macos' ;;
|
||||||
|
Linux) printf 'linux' ;;
|
||||||
|
*) printf 'windows' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_python() {
|
||||||
|
log "Preparing Python environment"
|
||||||
|
if [ ! -x ".venv/bin/python" ]; then
|
||||||
|
run uv venv
|
||||||
|
fi
|
||||||
|
run uv pip install -r requirements.txt
|
||||||
|
run uv pip install pyinstaller
|
||||||
|
}
|
||||||
|
|
||||||
|
build_frontend() {
|
||||||
|
log "Building frontend"
|
||||||
|
if [ ! -d "frontend/node_modules" ]; then
|
||||||
|
(cd frontend && run npm ci)
|
||||||
|
fi
|
||||||
|
if [ "${1:-}" = "desktop" ]; then
|
||||||
|
(cd frontend && VITE_API_BASE="$DESKTOP_API_BASE" run npm run build)
|
||||||
|
else
|
||||||
|
(cd frontend && run npm run build)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_server_binary() {
|
||||||
|
mode="${1:-onedir}"
|
||||||
|
ensure_python
|
||||||
|
log "Building server binary"
|
||||||
|
rm -rf build/pyinstaller
|
||||||
|
if [ "$mode" = "onefile" ]; then
|
||||||
|
CAPAREPORT_ONEFILE=1
|
||||||
|
export CAPAREPORT_ONEFILE
|
||||||
|
run .venv/bin/python -m PyInstaller --clean --noconfirm \
|
||||||
|
--workpath build/pyinstaller/work \
|
||||||
|
--distpath build/pyinstaller/dist \
|
||||||
|
build/capareport-server.spec
|
||||||
|
unset CAPAREPORT_ONEFILE
|
||||||
|
else
|
||||||
|
run .venv/bin/python -m PyInstaller --clean --noconfirm \
|
||||||
|
--workpath build/pyinstaller/work \
|
||||||
|
--distpath build/pyinstaller/dist \
|
||||||
|
build/capareport-server.spec
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_launchers() {
|
||||||
|
out_dir="$1"
|
||||||
|
exe_name="$2"
|
||||||
|
cat > "$out_dir/start.sh" <<EOF
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
SCRIPT_DIR="\$(CDPATH= cd -- "\$(dirname -- "\$0")" && pwd)"
|
||||||
|
cd "\$SCRIPT_DIR"
|
||||||
|
mkdir -p cache logs
|
||||||
|
chmod +x "./$exe_name" 2>/dev/null || true
|
||||||
|
echo "Starting CapacityReport server..."
|
||||||
|
echo "URL: http://localhost:9081"
|
||||||
|
exec "./$exe_name" --host 0.0.0.0 --port 9081
|
||||||
|
EOF
|
||||||
|
chmod +x "$out_dir/start.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_server() {
|
||||||
|
platform="$(platform_name)"
|
||||||
|
build_frontend server
|
||||||
|
build_server_binary onedir
|
||||||
|
|
||||||
|
out_dir="dist/packages/CapacityReport-Server-$platform-x64"
|
||||||
|
rm -rf "$out_dir"
|
||||||
|
mkdir -p "$out_dir/cache" "$out_dir/logs" "$out_dir/frontend"
|
||||||
|
cp -R build/pyinstaller/dist/capareport-server/. "$out_dir/"
|
||||||
|
cp Configure.json ReportScript.sql "$out_dir/"
|
||||||
|
cp -R frontend/dist "$out_dir/frontend/"
|
||||||
|
write_launchers "$out_dir" "capareport-server"
|
||||||
|
(cd "$out_dir/.." && tar -czf "$(basename "$out_dir").tar.gz" "$(basename "$out_dir")")
|
||||||
|
log "Server package: $out_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_docker() {
|
||||||
|
log "Building Docker image"
|
||||||
|
run docker build --progress=plain -f build/Dockerfile -t capacity-report-app:latest .
|
||||||
|
run docker compose -f build/docker-compose.yml config
|
||||||
|
}
|
||||||
|
|
||||||
|
build_desktop() {
|
||||||
|
build_frontend desktop
|
||||||
|
build_server_binary onefile
|
||||||
|
triple="$(rustc -vV | awk '/^host:/ {print $2}')"
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
cp build/pyinstaller/dist/capareport-server "src-tauri/binaries/capareport-server-$triple"
|
||||||
|
if ! cargo tauri --version >/dev/null 2>&1; then
|
||||||
|
run cargo install tauri-cli --locked
|
||||||
|
fi
|
||||||
|
(cd src-tauri && run cargo tauri build)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$TARGET" in
|
||||||
|
server) build_server ;;
|
||||||
|
docker) build_docker ;;
|
||||||
|
desktop) build_desktop ;;
|
||||||
|
all) build_server; build_docker; build_desktop ;;
|
||||||
|
*) echo "Usage: scripts/build.sh [server|docker|desktop|all]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
log "Build finished"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "capacity-report-desktop"
|
||||||
|
version = "2.0.2"
|
||||||
|
description = "CapacityReport desktop shell"
|
||||||
|
authors = ["NIXEVOL"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default desktop permissions",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use tauri::{Manager, Runtime};
|
||||||
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
|
||||||
|
struct ServerProcess {
|
||||||
|
child: CommandChild,
|
||||||
|
pid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerState(Mutex<Option<ServerProcess>>);
|
||||||
|
|
||||||
|
fn copy_resource<R: Runtime>(
|
||||||
|
app: &tauri::App<R>,
|
||||||
|
name: &str,
|
||||||
|
data_dir: &Path,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let target = data_dir.join(name);
|
||||||
|
if target.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for source in resource_candidates(app, name)? {
|
||||||
|
if source.exists() {
|
||||||
|
fs::copy(source, target)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Bundled resource not found: {name}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_candidates<R: Runtime>(
|
||||||
|
app: &tauri::App<R>,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||||
|
let resource_path = app.path().resolve(name, tauri::path::BaseDirectory::Resource)?;
|
||||||
|
let escaped_parent_path = app
|
||||||
|
.path()
|
||||||
|
.resolve(format!("_up_/{name}"), tauri::path::BaseDirectory::Resource)?;
|
||||||
|
let exe_dir = std::env::current_exe()?
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.ok_or("Unable to resolve executable directory")?;
|
||||||
|
|
||||||
|
Ok(vec![
|
||||||
|
resource_path,
|
||||||
|
escaped_parent_path,
|
||||||
|
exe_dir.join(name),
|
||||||
|
exe_dir.join("_up_").join(name),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_server<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn Error>> {
|
||||||
|
let data_dir = app.path().app_data_dir()?;
|
||||||
|
fs::create_dir_all(&data_dir)?;
|
||||||
|
fs::create_dir_all(data_dir.join("cache"))?;
|
||||||
|
fs::create_dir_all(data_dir.join("logs"))?;
|
||||||
|
copy_resource(app, "Configure.json", &data_dir)?;
|
||||||
|
copy_resource(app, "ReportScript.sql", &data_dir)?;
|
||||||
|
|
||||||
|
let (mut rx, child) = app
|
||||||
|
.shell()
|
||||||
|
.sidecar("capareport-server")?
|
||||||
|
.env("CAPAREPORT_BASE_DIR", data_dir.to_string_lossy().to_string())
|
||||||
|
.args(["--host", "127.0.0.1", "--port", "19082"])
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
while rx.recv().await.is_some() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
let pid = child.pid();
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
*state.0.lock().expect("server state lock poisoned") = Some(ServerProcess { child, pid });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_server<R: Runtime>(app: &tauri::AppHandle<R>) {
|
||||||
|
let state = app.state::<ServerState>();
|
||||||
|
let child = state
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.expect("server state lock poisoned")
|
||||||
|
.take();
|
||||||
|
if let Some(process) = child {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/F", "/T", "/PID", &process.pid.to_string()])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
let _ = process.child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.manage(ServerState(Mutex::new(None)))
|
||||||
|
.setup(|app| {
|
||||||
|
start_server(app)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
|
stop_server(window.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "CapacityReport",
|
||||||
|
"version": "2.0.2",
|
||||||
|
"identifier": "com.nixevol.capacityreport",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../frontend/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "CapacityReport",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 820,
|
||||||
|
"minWidth": 1100,
|
||||||
|
"minHeight": 720
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/capareport-server"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
"../Configure.json",
|
||||||
|
"../ReportScript.sql"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||