mirror of
https://github.com/TheFunny/ArisuAutoSweeper
synced 2025-12-16 19:55:12 +00:00
Implement FastAPI backend with REST API and basic frontend
Co-authored-by: TheFunny <26841179+TheFunny@users.noreply.github.com>
This commit is contained in:
parent
df4f3ef359
commit
4efae500d6
92
gui_fastapi.py
Normal file
92
gui_fastapi.py
Normal file
@ -0,0 +1,92 @@
|
||||
import threading
|
||||
from multiprocessing import Event, Process
|
||||
|
||||
from module.logger import logger
|
||||
from module.webui.setting import State
|
||||
|
||||
|
||||
def func(ev: threading.Event):
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
State.restart_event = ev
|
||||
|
||||
parser = argparse.ArgumentParser(description="Alas FastAPI web service")
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
help="Host to listen. Default to WebuiHost in deploy setting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--port",
|
||||
type=int,
|
||||
help="Port to listen. Default to WebuiPort in deploy setting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-k", "--key", type=str, help="Password of alas. No password by default"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--electron", action="store_true", help="Runs by electron client."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run",
|
||||
nargs="+",
|
||||
type=str,
|
||||
help="Run alas by config names on startup",
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
host = args.host or State.deploy_config.WebuiHost or "0.0.0.0"
|
||||
port = args.port or int(State.deploy_config.WebuiPort) or 23467
|
||||
State.electron = args.electron
|
||||
|
||||
logger.hr("Launcher config")
|
||||
logger.attr("Host", host)
|
||||
logger.attr("Port", port)
|
||||
logger.attr("Backend", "FastAPI")
|
||||
logger.attr("Electron", args.electron)
|
||||
logger.attr("Reload", ev is not None)
|
||||
|
||||
if State.electron:
|
||||
logger.info("Electron detected, remove log output to stdout")
|
||||
from module.logger.logger import console_hdlr
|
||||
logger.removeHandler(console_hdlr)
|
||||
|
||||
# Use the new FastAPI backend
|
||||
uvicorn.run(
|
||||
"module.webui.fastapi_backend.main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=False
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if State.deploy_config.EnableReload:
|
||||
should_exit = False
|
||||
while not should_exit:
|
||||
event = Event()
|
||||
process = Process(target=func, args=(event,))
|
||||
process.start()
|
||||
while not should_exit:
|
||||
try:
|
||||
b = event.wait(1)
|
||||
except KeyboardInterrupt:
|
||||
should_exit = True
|
||||
break
|
||||
if b:
|
||||
process.kill()
|
||||
break
|
||||
elif process.is_alive():
|
||||
continue
|
||||
else:
|
||||
should_exit = True
|
||||
else:
|
||||
func(None)
|
||||
130
module/webui/fastapi_backend/README.md
Normal file
130
module/webui/fastapi_backend/README.md
Normal file
@ -0,0 +1,130 @@
|
||||
# FastAPI Backend for ArisuAutoSweeper
|
||||
|
||||
This is the new FastAPI-based backend for the ArisuAutoSweeper WebUI, providing a modern REST API architecture while maintaining the same visual style as the original PyWebIO interface.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (FastAPI)
|
||||
- **main.py**: Main FastAPI application with route registration and lifecycle management
|
||||
- **routes/**: API endpoint modules
|
||||
- `config.py`: Configuration management endpoints
|
||||
- `process.py`: Process control endpoints (start/stop/restart)
|
||||
- `system.py`: System settings and update management
|
||||
- **websocket_handler.py**: WebSocket endpoints for real-time log streaming
|
||||
- **templates/**: Jinja2 HTML templates
|
||||
- **static/**: Static assets (CSS, JS)
|
||||
|
||||
### Frontend
|
||||
- Simple HTML/CSS/JS frontend that reuses existing CSS from `assets/gui/css/`
|
||||
- Bootstrap 5 for base styling
|
||||
- Native JavaScript for API interactions
|
||||
- WebSocket for real-time updates
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the FastAPI Backend
|
||||
|
||||
```bash
|
||||
# Use the new FastAPI backend
|
||||
python gui_fastapi.py
|
||||
|
||||
# Or with custom host/port
|
||||
python gui_fastapi.py --host 0.0.0.0 --port 23467
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Configuration Management
|
||||
- `GET /api/config/instances` - Get list of all instances
|
||||
- `GET /api/config/{instance_name}` - Get configuration for an instance
|
||||
- `POST /api/config/{instance_name}` - Update configuration
|
||||
- `POST /api/config/create` - Create new instance
|
||||
- `DELETE /api/config/{instance_name}` - Delete instance
|
||||
|
||||
#### Process Management
|
||||
- `GET /api/process/` - Get all processes status
|
||||
- `GET /api/process/{instance_name}/status` - Get process status
|
||||
- `POST /api/process/{instance_name}/start` - Start process
|
||||
- `POST /api/process/{instance_name}/stop` - Stop process
|
||||
- `POST /api/process/{instance_name}/restart` - Restart process
|
||||
|
||||
#### System Management
|
||||
- `GET /api/system/info` - Get system information
|
||||
- `POST /api/system/language` - Set language
|
||||
- `POST /api/system/theme` - Set theme
|
||||
- `GET /api/system/update/status` - Get update status
|
||||
- `POST /api/system/update/check` - Check for updates
|
||||
- `POST /api/system/update/run` - Run update
|
||||
- `POST /api/system/restart` - Restart system
|
||||
|
||||
#### WebSocket
|
||||
- `WS /ws/logs/{instance_name}` - Real-time log streaming for an instance
|
||||
- `WS /ws/system` - System-wide real-time updates
|
||||
|
||||
## Comparison with PyWebIO Backend
|
||||
|
||||
### PyWebIO Backend (Original)
|
||||
- **Location**: `module/webui/app.py`
|
||||
- **Entry Point**: `gui.py`
|
||||
- **Architecture**: Monolithic, UI generated from Python code
|
||||
- **Advantages**: Simpler development, no frontend/backend separation
|
||||
- **Disadvantages**: Tightly coupled, harder to extend, limited API access
|
||||
|
||||
### FastAPI Backend (New)
|
||||
- **Location**: `module/webui/fastapi_backend/`
|
||||
- **Entry Point**: `gui_fastapi.py`
|
||||
- **Architecture**: Separated backend (REST API) and frontend
|
||||
- **Advantages**:
|
||||
- Modern REST API
|
||||
- Can be used by multiple clients (web, mobile, CLI)
|
||||
- Better separation of concerns
|
||||
- Easier to test and extend
|
||||
- Real-time updates via WebSocket
|
||||
- **Disadvantages**: More code to maintain, requires frontend development
|
||||
|
||||
## Migration Path
|
||||
|
||||
Both backends can coexist:
|
||||
- Use `python gui.py` for the original PyWebIO interface
|
||||
- Use `python gui_fastapi.py` for the new FastAPI interface
|
||||
|
||||
Users can gradually migrate from PyWebIO to FastAPI as features are completed.
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Endpoints
|
||||
|
||||
1. Create or modify files in `routes/`
|
||||
2. Add Pydantic models for request/response validation
|
||||
3. Register the router in `main.py`
|
||||
4. Update the frontend to use the new endpoints
|
||||
|
||||
### Reusing Existing CSS
|
||||
|
||||
The frontend reuses CSS from `assets/gui/css/`:
|
||||
- `alas.css` - Base styles
|
||||
- `alas-pc.css` - Desktop styles
|
||||
- `light-alas.css` / `dark-alas.css` - Theme styles
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test that the app loads
|
||||
python -c "from module.webui.fastapi_backend.main import app; print('OK')"
|
||||
|
||||
# Start the server
|
||||
python gui_fastapi.py
|
||||
|
||||
# Access the interface
|
||||
# Open browser to http://localhost:23467
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Complete configuration editor UI
|
||||
- [ ] Enhanced log viewer with filtering
|
||||
- [ ] Scheduler visualization
|
||||
- [ ] Task queue management
|
||||
- [ ] Mobile-responsive design improvements
|
||||
- [ ] Authentication/authorization
|
||||
- [ ] API documentation (Swagger UI at /docs)
|
||||
3
module/webui/fastapi_backend/__init__.py
Normal file
3
module/webui/fastapi_backend/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
FastAPI backend for ArisuAutoSweeper WebUI
|
||||
"""
|
||||
103
module/webui/fastapi_backend/main.py
Normal file
103
module/webui/fastapi_backend/main.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""
|
||||
FastAPI main application
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from module.webui.fastapi_backend.routes import config, process, system
|
||||
from module.webui.fastapi_backend.websocket_handler import router as ws_router
|
||||
from module.webui.setting import State
|
||||
from module.webui import lang
|
||||
from module.webui.updater import updater
|
||||
from module.webui.process_manager import ProcessManager
|
||||
from module.logger import logger
|
||||
|
||||
# Get base directory
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="ArisuAutoSweeper",
|
||||
description="FastAPI backend for ArisuAutoSweeper WebUI",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Setup templates
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
# Mount CSS assets from the main assets folder
|
||||
assets_path = Path(__file__).resolve().parent.parent.parent.parent / "assets" / "gui"
|
||||
if assets_path.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets_path)), name="assets")
|
||||
|
||||
# Include API routers
|
||||
app.include_router(config.router, prefix="/api/config", tags=["config"])
|
||||
app.include_router(process.router, prefix="/api/process", tags=["process"])
|
||||
app.include_router(system.router, prefix="/api/system", tags=["system"])
|
||||
app.include_router(ws_router, prefix="/ws", tags=["websocket"])
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize application on startup"""
|
||||
logger.info("FastAPI WebUI starting up")
|
||||
State.init()
|
||||
lang.reload()
|
||||
updater.event = State.manager.Event()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Cleanup on shutdown"""
|
||||
logger.info("FastAPI WebUI shutting down")
|
||||
for alas in ProcessManager._processes.values():
|
||||
alas.stop()
|
||||
State.clearup()
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""Serve the main page"""
|
||||
from module.config.utils import alas_instance
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"title": "ArisuAutoSweeper",
|
||||
"instances": alas_instance(),
|
||||
"theme": State.deploy_config.Theme,
|
||||
"language": lang.LANG
|
||||
}
|
||||
return templates.TemplateResponse("index.html", context)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Factory function to create the FastAPI app"""
|
||||
return app
|
||||
3
module/webui/fastapi_backend/routes/__init__.py
Normal file
3
module/webui/fastapi_backend/routes/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
API routes for FastAPI backend
|
||||
"""
|
||||
119
module/webui/fastapi_backend/routes/config.py
Normal file
119
module/webui/fastapi_backend/routes/config.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""
|
||||
Configuration management API endpoints
|
||||
"""
|
||||
from typing import Dict, List, Any
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from module.config.utils import alas_instance, alas_template, filepath_args, read_file
|
||||
from module.webui.fake import load_config, get_config_mod
|
||||
from module.webui.setting import State
|
||||
from module.logger import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConfigValue(BaseModel):
|
||||
"""Config value update model"""
|
||||
path: str
|
||||
value: Any
|
||||
|
||||
|
||||
@router.get("/instances")
|
||||
async def get_instances():
|
||||
"""Get list of all alas instances"""
|
||||
return {
|
||||
"instances": alas_instance(),
|
||||
"templates": alas_template()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{instance_name}")
|
||||
async def get_config(instance_name: str):
|
||||
"""Get configuration for a specific instance"""
|
||||
try:
|
||||
config_obj = load_config(instance_name)
|
||||
config_data = config_obj.read_file(instance_name)
|
||||
mod = get_config_mod(instance_name)
|
||||
|
||||
# Get menu and args for this instance
|
||||
menu = read_file(filepath_args("menu", mod))
|
||||
args = read_file(filepath_args("args", mod))
|
||||
|
||||
return {
|
||||
"name": instance_name,
|
||||
"mod": mod,
|
||||
"config": config_data,
|
||||
"menu": menu,
|
||||
"args": args
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=404, detail=f"Config not found: {instance_name}")
|
||||
|
||||
|
||||
@router.post("/{instance_name}")
|
||||
async def update_config(instance_name: str, updates: List[ConfigValue]):
|
||||
"""Update configuration values"""
|
||||
try:
|
||||
config_obj = load_config(instance_name)
|
||||
config_data = config_obj.read_file(instance_name)
|
||||
|
||||
# Apply updates
|
||||
for update in updates:
|
||||
path_parts = update.path.split(".")
|
||||
# Navigate to the nested dict and update
|
||||
current = config_data
|
||||
for part in path_parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[path_parts[-1]] = update.value
|
||||
|
||||
# Save config
|
||||
config_obj.write_file(instance_name, config_data)
|
||||
|
||||
logger.info(f"Updated config for {instance_name}")
|
||||
return {"status": "success", "message": "Config updated"}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_instance(name: str, copy_from: str = "template-aas"):
|
||||
"""Create a new alas instance"""
|
||||
try:
|
||||
# Validate name
|
||||
if name in alas_instance():
|
||||
raise HTTPException(status_code=400, detail="Instance already exists")
|
||||
|
||||
if set(name) & set(".\\/:*?\"'<>|"):
|
||||
raise HTTPException(status_code=400, detail="Invalid characters in name")
|
||||
|
||||
if name.lower().startswith("template"):
|
||||
raise HTTPException(status_code=400, detail="Cannot start with 'template'")
|
||||
|
||||
# Copy config
|
||||
origin_config = load_config(copy_from).read_file(copy_from)
|
||||
State.config_updater.write_file(name, origin_config, get_config_mod(copy_from))
|
||||
|
||||
logger.info(f"Created new instance: {name}")
|
||||
return {"status": "success", "name": name}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{instance_name}")
|
||||
async def delete_instance(instance_name: str):
|
||||
"""Delete an alas instance"""
|
||||
try:
|
||||
# Add implementation for deleting instance
|
||||
# This would need to be added based on how configs are stored
|
||||
raise HTTPException(status_code=501, detail="Delete not implemented")
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
90
module/webui/fastapi_backend/routes/process.py
Normal file
90
module/webui/fastapi_backend/routes/process.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""
|
||||
Process management API endpoints
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from module.webui.process_manager import ProcessManager
|
||||
from module.webui.updater import updater
|
||||
from module.logger import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ProcessCommand(BaseModel):
|
||||
"""Process command model"""
|
||||
task: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{instance_name}/status")
|
||||
async def get_process_status(instance_name: str):
|
||||
"""Get process status"""
|
||||
try:
|
||||
alas = ProcessManager.get_manager(instance_name)
|
||||
return {
|
||||
"name": instance_name,
|
||||
"alive": alas.alive,
|
||||
"state": alas.state,
|
||||
"config_name": alas.config_name
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=404, detail=f"Process not found: {instance_name}")
|
||||
|
||||
|
||||
@router.post("/{instance_name}/start")
|
||||
async def start_process(instance_name: str, command: ProcessCommand = ProcessCommand()):
|
||||
"""Start a process"""
|
||||
try:
|
||||
alas = ProcessManager.get_manager(instance_name)
|
||||
alas.start(command.task, updater.event if command.task is None else None)
|
||||
logger.info(f"Started process: {instance_name}")
|
||||
return {"status": "success", "message": f"Started {instance_name}"}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{instance_name}/stop")
|
||||
async def stop_process(instance_name: str):
|
||||
"""Stop a process"""
|
||||
try:
|
||||
alas = ProcessManager.get_manager(instance_name)
|
||||
alas.stop()
|
||||
logger.info(f"Stopped process: {instance_name}")
|
||||
return {"status": "success", "message": f"Stopped {instance_name}"}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{instance_name}/restart")
|
||||
async def restart_process(instance_name: str):
|
||||
"""Restart a process"""
|
||||
try:
|
||||
alas = ProcessManager.get_manager(instance_name)
|
||||
alas.stop()
|
||||
alas.start(None, updater.event)
|
||||
logger.info(f"Restarted process: {instance_name}")
|
||||
return {"status": "success", "message": f"Restarted {instance_name}"}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_all_processes():
|
||||
"""Get status of all processes"""
|
||||
try:
|
||||
processes = []
|
||||
for name, alas in ProcessManager._processes.items():
|
||||
processes.append({
|
||||
"name": name,
|
||||
"alive": alas.alive,
|
||||
"state": alas.state
|
||||
})
|
||||
return {"processes": processes}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
115
module/webui/fastapi_backend/routes/system.py
Normal file
115
module/webui/fastapi_backend/routes/system.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""
|
||||
System management API endpoints
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from module.webui.updater import updater
|
||||
from module.webui.setting import State
|
||||
from module.webui import lang
|
||||
from module.logger import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LanguageSetting(BaseModel):
|
||||
"""Language setting model"""
|
||||
language: str
|
||||
|
||||
|
||||
class ThemeSetting(BaseModel):
|
||||
"""Theme setting model"""
|
||||
theme: str
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def get_system_info():
|
||||
"""Get system information"""
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"language": lang.LANG,
|
||||
"theme": State.deploy_config.Theme,
|
||||
"deploy_config": {
|
||||
"host": State.deploy_config.WebuiHost,
|
||||
"port": State.deploy_config.WebuiPort,
|
||||
"password_enabled": State.deploy_config.Password is not None,
|
||||
"remote_access": State.deploy_config.EnableRemoteAccess,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/language")
|
||||
async def set_language(setting: LanguageSetting):
|
||||
"""Set system language"""
|
||||
try:
|
||||
lang.set_language(setting.language)
|
||||
State.deploy_config.Language = setting.language
|
||||
return {"status": "success", "language": setting.language}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/theme")
|
||||
async def set_theme(setting: ThemeSetting):
|
||||
"""Set system theme"""
|
||||
try:
|
||||
State.deploy_config.Theme = setting.theme
|
||||
State.theme = setting.theme
|
||||
return {"status": "success", "theme": setting.theme}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/update/status")
|
||||
async def get_update_status():
|
||||
"""Get update status"""
|
||||
try:
|
||||
return {
|
||||
"state": updater.state,
|
||||
"branch": updater.Branch,
|
||||
"local_commit": updater.get_commit(short_sha1=True),
|
||||
"upstream_commit": updater.get_commit(f"origin/{updater.Branch}", short_sha1=True)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/update/check")
|
||||
async def check_update():
|
||||
"""Check for updates"""
|
||||
try:
|
||||
updater.check_update()
|
||||
return {"status": "success", "message": "Checking for updates"}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/update/run")
|
||||
async def run_update():
|
||||
"""Run update"""
|
||||
try:
|
||||
updater.run_update()
|
||||
return {"status": "success", "message": "Update started"}
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_system():
|
||||
"""Restart the system"""
|
||||
try:
|
||||
if State.restart_event is not None:
|
||||
State.restart_event.set()
|
||||
return {"status": "success", "message": "Restart initiated"}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Restart not enabled")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
371
module/webui/fastapi_backend/templates/index.html
Normal file
371
module/webui/fastapi_backend/templates/index.html
Normal file
@ -0,0 +1,371 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Custom CSS - reuse existing styles -->
|
||||
<link rel="stylesheet" href="/assets/css/alas.css">
|
||||
<link rel="stylesheet" href="/assets/css/alas-pc.css">
|
||||
{% if theme == 'dark' %}
|
||||
<link rel="stylesheet" href="/assets/css/dark-alas.css">
|
||||
{% else %}
|
||||
<link rel="stylesheet" href="/assets/css/light-alas.css">
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"aside menu content";
|
||||
grid-template-columns: 4rem 12rem 1fr;
|
||||
grid-template-rows: 3.3125rem 1fr;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #dee2e6);
|
||||
}
|
||||
|
||||
.aside {
|
||||
grid-area: aside;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border-color, #dee2e6);
|
||||
}
|
||||
|
||||
.menu {
|
||||
grid-area: menu;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0.5rem;
|
||||
border-right: 1px solid var(--border-color, #dee2e6);
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.instance-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-running { background: #28a745; color: white; }
|
||||
.status-stopped { background: #6c757d; color: white; }
|
||||
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h3 style="margin: 0;">{{ title }}</h3>
|
||||
<div class="ms-auto">
|
||||
<span id="status-indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aside Navigation -->
|
||||
<div class="aside">
|
||||
<div class="text-center py-3">
|
||||
<button class="btn btn-sm btn-primary instance-btn" onclick="showHome()">Home</button>
|
||||
</div>
|
||||
<div id="instance-list">
|
||||
{% for instance in instances %}
|
||||
<div class="text-center py-2">
|
||||
<button class="btn btn-sm btn-outline-primary instance-btn"
|
||||
onclick="selectInstance('{{ instance }}')">{{ instance }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div class="menu">
|
||||
<div id="menu-content">
|
||||
<button class="btn btn-sm btn-menu w-100 mb-2" onclick="showOverview()">Overview</button>
|
||||
<button class="btn btn-sm btn-menu w-100 mb-2" onclick="showConfig()">Config</button>
|
||||
<button class="btn btn-sm btn-menu w-100 mb-2" onclick="showLogs()">Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="content">
|
||||
<div id="main-content">
|
||||
<h2>Welcome to ArisuAutoSweeper</h2>
|
||||
<p>Select an instance from the sidebar to get started.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">System Information</h5>
|
||||
<div id="system-info">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Language & Theme</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Language:</label>
|
||||
<select class="form-select" id="language-select" onchange="changeLanguage()">
|
||||
<option value="zh-CN" {% if language == 'zh-CN' %}selected{% endif %}>简体中文</option>
|
||||
<option value="en-US" {% if language == 'en-US' %}selected{% endif %}>English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Theme:</label>
|
||||
<select class="form-select" id="theme-select" onchange="changeTheme()">
|
||||
<option value="default" {% if theme != 'dark' %}selected{% endif %}>Light</option>
|
||||
<option value="dark" {% if theme == 'dark' %}selected{% endif %}>Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Custom JavaScript -->
|
||||
<script>
|
||||
let currentInstance = null;
|
||||
let ws = null;
|
||||
|
||||
// Load system info on startup
|
||||
async function loadSystemInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/system/info');
|
||||
const data = await response.json();
|
||||
document.getElementById('system-info').innerHTML = `
|
||||
<p><strong>Version:</strong> ${data.version}</p>
|
||||
<p><strong>Language:</strong> ${data.language}</p>
|
||||
<p><strong>Theme:</strong> ${data.theme}</p>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error loading system info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showHome() {
|
||||
document.getElementById('main-content').innerHTML = `
|
||||
<h2>Welcome to ArisuAutoSweeper</h2>
|
||||
<p>Select an instance from the sidebar to get started.</p>
|
||||
`;
|
||||
loadSystemInfo();
|
||||
}
|
||||
|
||||
async function selectInstance(instance) {
|
||||
currentInstance = instance;
|
||||
document.getElementById('main-content').innerHTML = `
|
||||
<h2>${instance}</h2>
|
||||
<div id="instance-overview">Loading...</div>
|
||||
`;
|
||||
await loadInstanceOverview(instance);
|
||||
}
|
||||
|
||||
async function loadInstanceOverview(instance) {
|
||||
try {
|
||||
const statusResponse = await fetch(`/api/process/${instance}/status`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
const alive = status.alive;
|
||||
const statusClass = alive ? 'status-running' : 'status-stopped';
|
||||
const statusText = alive ? 'Running' : 'Stopped';
|
||||
|
||||
document.getElementById('instance-overview').innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Status</h5>
|
||||
<p>Status: <span class="status-badge ${statusClass}">${statusText}</span></p>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-success" onclick="startProcess('${instance}')">Start</button>
|
||||
<button class="btn btn-danger" onclick="stopProcess('${instance}')">Stop</button>
|
||||
<button class="btn btn-warning" onclick="restartProcess('${instance}')">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error loading instance:', error);
|
||||
document.getElementById('instance-overview').innerHTML = `
|
||||
<div class="alert alert-danger">Error loading instance information</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function startProcess(instance) {
|
||||
try {
|
||||
const response = await fetch(`/api/process/${instance}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message);
|
||||
await loadInstanceOverview(instance);
|
||||
} catch (error) {
|
||||
alert('Error starting process: ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(instance) {
|
||||
try {
|
||||
const response = await fetch(`/api/process/${instance}/stop`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message);
|
||||
await loadInstanceOverview(instance);
|
||||
} catch (error) {
|
||||
alert('Error stopping process: ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartProcess(instance) {
|
||||
try {
|
||||
const response = await fetch(`/api/process/${instance}/restart`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
alert(data.message);
|
||||
await loadInstanceOverview(instance);
|
||||
} catch (error) {
|
||||
alert('Error restarting process: ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
function showOverview() {
|
||||
if (currentInstance) {
|
||||
loadInstanceOverview(currentInstance);
|
||||
} else {
|
||||
alert('Please select an instance first');
|
||||
}
|
||||
}
|
||||
|
||||
function showConfig() {
|
||||
document.getElementById('main-content').innerHTML = `
|
||||
<h2>Configuration</h2>
|
||||
<p>Configuration editor is under development.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function showLogs() {
|
||||
if (!currentInstance) {
|
||||
alert('Please select an instance first');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('main-content').innerHTML = `
|
||||
<h2>Logs - ${currentInstance}</h2>
|
||||
<div class="log-container" id="log-output">
|
||||
<div>Connecting to log stream...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Connect WebSocket for logs
|
||||
connectLogStream(currentInstance);
|
||||
}
|
||||
|
||||
function connectLogStream(instance) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/logs/${instance}`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('log-output').innerHTML = '<div>Connected to log stream.</div>';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const logOutput = document.getElementById('log-output');
|
||||
if (logOutput) {
|
||||
const data = JSON.parse(event.data);
|
||||
logOutput.innerHTML += `<div>[${data.type}] ${JSON.stringify(data)}</div>`;
|
||||
logOutput.scrollTop = logOutput.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
}
|
||||
|
||||
async function changeLanguage() {
|
||||
const language = document.getElementById('language-select').value;
|
||||
try {
|
||||
await fetch('/api/system/language', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language })
|
||||
});
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
alert('Error changing language: ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTheme() {
|
||||
const theme = document.getElementById('theme-select').value;
|
||||
try {
|
||||
await fetch('/api/system/theme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme })
|
||||
});
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
alert('Error changing theme: ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
window.addEventListener('load', () => {
|
||||
loadSystemInfo();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
114
module/webui/fastapi_backend/websocket_handler.py
Normal file
114
module/webui/fastapi_backend/websocket_handler.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""
|
||||
WebSocket handler for real-time log streaming
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Set
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from module.webui.process_manager import ProcessManager
|
||||
from module.logger import logger
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manage WebSocket connections"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: Set[WebSocket] = set()
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.add(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.discard(websocket)
|
||||
|
||||
async def broadcast(self, message: str):
|
||||
for connection in self.active_connections.copy():
|
||||
try:
|
||||
await connection.send_text(message)
|
||||
except Exception:
|
||||
self.disconnect(connection)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/logs/{instance_name}")
|
||||
async def websocket_logs(websocket: WebSocket, instance_name: str):
|
||||
"""WebSocket endpoint for streaming logs"""
|
||||
await manager.connect(websocket)
|
||||
|
||||
try:
|
||||
alas = ProcessManager.get_manager(instance_name)
|
||||
|
||||
# Send initial connection message
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"instance": instance_name
|
||||
})
|
||||
|
||||
# Keep connection alive and send log updates
|
||||
while True:
|
||||
try:
|
||||
# Check if process is alive
|
||||
if hasattr(alas, 'alive') and alas.alive:
|
||||
await websocket.send_json({
|
||||
"type": "status",
|
||||
"alive": True,
|
||||
"state": alas.state
|
||||
})
|
||||
|
||||
# Wait a bit before next update
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check if client sent any message (to keep connection alive)
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_text(), timeout=0.1)
|
||||
if data == "ping":
|
||||
await websocket.send_text("pong")
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in WebSocket loop: {e}")
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"WebSocket disconnected for {instance_name}")
|
||||
except Exception as e:
|
||||
logger.exception(f"WebSocket error: {e}")
|
||||
finally:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
@router.websocket("/system")
|
||||
async def websocket_system(websocket: WebSocket):
|
||||
"""WebSocket endpoint for system-wide updates"""
|
||||
await manager.connect(websocket)
|
||||
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"message": "System WebSocket connected"
|
||||
})
|
||||
|
||||
# Keep connection alive
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_text(), timeout=10)
|
||||
if data == "ping":
|
||||
await websocket.send_text("pong")
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat
|
||||
await websocket.send_json({"type": "heartbeat"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in system WebSocket: {e}")
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("System WebSocket disconnected")
|
||||
except Exception as e:
|
||||
logger.exception(f"System WebSocket error: {e}")
|
||||
finally:
|
||||
manager.disconnect(websocket)
|
||||
@ -27,12 +27,12 @@ pponnxcr==2.0
|
||||
|
||||
# Webui
|
||||
pywebio==1.6.2
|
||||
starlette==0.14.2
|
||||
uvicorn[standard]==0.17.6
|
||||
starlette>=0.27.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
aiofiles
|
||||
fastapi>=0.100.0
|
||||
python-multipart
|
||||
websockets
|
||||
jinja2>=3.0.0
|
||||
|
||||
# GUI
|
||||
customtkinter
|
||||
|
||||
Loading…
Reference in New Issue
Block a user