Upload 15 files
Browse files- app.py +14 -162
- config.py +26 -0
- routers/__init__.py +21 -0
- routers/files.py +130 -0
- routers/ports.py +70 -0
- routers/proxy.py +150 -0
- routers/terminal.py +187 -0
- routers/zones.py +71 -0
- static/app.js +98 -7
- static/index.html +31 -3
- static/style.css +413 -0
- storage.py +48 -0
app.py
CHANGED
|
@@ -1,181 +1,33 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
from
|
|
|
|
|
|
|
|
|
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
|
| 7 |
import uvicorn
|
| 8 |
-
from fastapi import FastAPI
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
-
from fastapi.responses import
|
| 11 |
|
| 12 |
-
import
|
| 13 |
-
import
|
| 14 |
-
from terminal import terminal_ws, kill_terminal, active_terminals
|
| 15 |
|
| 16 |
|
| 17 |
@asynccontextmanager
|
| 18 |
async def lifespan(app: FastAPI):
|
| 19 |
yield
|
| 20 |
-
# Cleanup terminals khi shutdown
|
| 21 |
for zone_name in list(active_terminals.keys()):
|
| 22 |
kill_terminal(zone_name)
|
| 23 |
|
| 24 |
|
| 25 |
app = FastAPI(title="HugPanel", lifespan=lifespan)
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
@app.get("/api/zones")
|
| 31 |
-
def api_list_zones():
|
| 32 |
-
return zones.list_zones()
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
@app.post("/api/zones")
|
| 36 |
-
def api_create_zone(name: str = Form(...), description: str = Form("")):
|
| 37 |
-
try:
|
| 38 |
-
result = zones.create_zone(name, description)
|
| 39 |
-
return result
|
| 40 |
-
except ValueError as e:
|
| 41 |
-
raise HTTPException(400, str(e))
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
@app.delete("/api/zones/{zone_name}")
|
| 45 |
-
def api_delete_zone(zone_name: str):
|
| 46 |
-
try:
|
| 47 |
-
kill_terminal(zone_name)
|
| 48 |
-
zones.delete_zone(zone_name)
|
| 49 |
-
return {"ok": True}
|
| 50 |
-
except ValueError as e:
|
| 51 |
-
raise HTTPException(400, str(e))
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
# ── File Manager API ─────────────────────────────────────
|
| 55 |
-
|
| 56 |
-
@app.get("/api/zones/{zone_name}/files")
|
| 57 |
-
def api_list_files(zone_name: str, path: str = Query("")):
|
| 58 |
-
try:
|
| 59 |
-
return zones.list_files(zone_name, path)
|
| 60 |
-
except ValueError as e:
|
| 61 |
-
raise HTTPException(400, str(e))
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
@app.get("/api/zones/{zone_name}/files/read")
|
| 65 |
-
def api_read_file(zone_name: str, path: str = Query(...)):
|
| 66 |
-
try:
|
| 67 |
-
content = zones.read_file(zone_name, path)
|
| 68 |
-
return {"content": content, "path": path}
|
| 69 |
-
except ValueError as e:
|
| 70 |
-
raise HTTPException(400, str(e))
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
@app.get("/api/zones/{zone_name}/files/download")
|
| 74 |
-
def api_download_file(zone_name: str, path: str = Query(...)):
|
| 75 |
-
try:
|
| 76 |
-
zone_path = zones.get_zone_path(zone_name)
|
| 77 |
-
target = zones._safe_path(zone_path, path)
|
| 78 |
-
if not target.is_file():
|
| 79 |
-
raise HTTPException(404, "File không tồn tại")
|
| 80 |
-
return FileResponse(target, filename=target.name)
|
| 81 |
-
except ValueError as e:
|
| 82 |
-
raise HTTPException(400, str(e))
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
@app.post("/api/zones/{zone_name}/files/write")
|
| 86 |
-
def api_write_file(zone_name: str, path: str = Form(...), content: str = Form(...)):
|
| 87 |
-
try:
|
| 88 |
-
zones.write_file(zone_name, path, content)
|
| 89 |
-
return {"ok": True}
|
| 90 |
-
except ValueError as e:
|
| 91 |
-
raise HTTPException(400, str(e))
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
@app.post("/api/zones/{zone_name}/files/mkdir")
|
| 95 |
-
def api_create_folder(zone_name: str, path: str = Form(...)):
|
| 96 |
-
try:
|
| 97 |
-
zones.create_folder(zone_name, path)
|
| 98 |
-
return {"ok": True}
|
| 99 |
-
except ValueError as e:
|
| 100 |
-
raise HTTPException(400, str(e))
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
@app.post("/api/zones/{zone_name}/files/upload")
|
| 104 |
-
async def api_upload_file(zone_name: str, path: str = Form(""), file: UploadFile = File(...)):
|
| 105 |
-
try:
|
| 106 |
-
zone_path = zones.get_zone_path(zone_name)
|
| 107 |
-
dest = zones._safe_path(zone_path, os.path.join(path, file.filename))
|
| 108 |
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 109 |
-
content = await file.read()
|
| 110 |
-
dest.write_bytes(content)
|
| 111 |
-
return {"ok": True, "path": str(dest.relative_to(zone_path))}
|
| 112 |
-
except ValueError as e:
|
| 113 |
-
raise HTTPException(400, str(e))
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
@app.delete("/api/zones/{zone_name}/files")
|
| 117 |
-
def api_delete_file(zone_name: str, path: str = Query(...)):
|
| 118 |
-
try:
|
| 119 |
-
zones.delete_file(zone_name, path)
|
| 120 |
-
return {"ok": True}
|
| 121 |
-
except ValueError as e:
|
| 122 |
-
raise HTTPException(400, str(e))
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
@app.post("/api/zones/{zone_name}/files/rename")
|
| 126 |
-
def api_rename_file(zone_name: str, old_path: str = Form(...), new_name: str = Form(...)):
|
| 127 |
-
try:
|
| 128 |
-
zones.rename_item(zone_name, old_path, new_name)
|
| 129 |
-
return {"ok": True}
|
| 130 |
-
except ValueError as e:
|
| 131 |
-
raise HTTPException(400, str(e))
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# ── Port Management API ───────────────────────────────────
|
| 135 |
-
|
| 136 |
-
@app.get("/api/zones/{zone_name}/ports")
|
| 137 |
-
def api_list_ports(zone_name: str):
|
| 138 |
-
try:
|
| 139 |
-
return proxy.list_ports(zone_name)
|
| 140 |
-
except ValueError as e:
|
| 141 |
-
raise HTTPException(400, str(e))
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
@app.post("/api/zones/{zone_name}/ports")
|
| 145 |
-
def api_add_port(zone_name: str, port: int = Form(...), label: str = Form("")):
|
| 146 |
-
try:
|
| 147 |
-
return proxy.add_port(zone_name, port, label)
|
| 148 |
-
except ValueError as e:
|
| 149 |
-
raise HTTPException(400, str(e))
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
@app.delete("/api/zones/{zone_name}/ports/{port}")
|
| 153 |
-
def api_remove_port(zone_name: str, port: int):
|
| 154 |
-
try:
|
| 155 |
-
proxy.remove_port(zone_name, port)
|
| 156 |
-
return {"ok": True}
|
| 157 |
-
except ValueError as e:
|
| 158 |
-
raise HTTPException(400, str(e))
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
# ── Reverse Proxy ────────────────────────────────────────
|
| 162 |
-
|
| 163 |
-
@app.api_route("/port/{zone_name}/{port}/{subpath:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
| 164 |
-
async def proxy_route(request: Request, zone_name: str, port: int, subpath: str = ""):
|
| 165 |
-
return await proxy.proxy_http(request, zone_name, port, subpath)
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
@app.websocket("/port/{zone_name}/{port}/ws/{subpath:path}")
|
| 169 |
-
async def proxy_ws_route(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
|
| 170 |
-
await proxy.proxy_ws(websocket, zone_name, port, f"ws/{subpath}")
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
# ── Terminal WebSocket ────────────────────────────────────
|
| 174 |
-
|
| 175 |
-
@app.websocket("/ws/terminal/{zone_name}")
|
| 176 |
-
async def ws_terminal(websocket: WebSocket, zone_name: str):
|
| 177 |
-
await terminal_ws(websocket, zone_name)
|
| 178 |
-
|
| 179 |
|
| 180 |
# ── Static Files & SPA ──────────────────────────────────
|
| 181 |
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HugPanel — Multi-zone workspace for HuggingFace Spaces.
|
| 3 |
+
|
| 4 |
+
Open/Closed Principle: routers are auto-discovered from the routers/ package.
|
| 5 |
+
Adding a new feature = adding a new file in routers/ — no changes here.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
|
| 10 |
import uvicorn
|
| 11 |
+
from fastapi import FastAPI
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from fastapi.responses import FileResponse
|
| 14 |
|
| 15 |
+
from routers import discover_routers
|
| 16 |
+
from routers.terminal import active_terminals, kill_terminal
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@asynccontextmanager
|
| 20 |
async def lifespan(app: FastAPI):
|
| 21 |
yield
|
|
|
|
| 22 |
for zone_name in list(active_terminals.keys()):
|
| 23 |
kill_terminal(zone_name)
|
| 24 |
|
| 25 |
|
| 26 |
app = FastAPI(title="HugPanel", lifespan=lifespan)
|
| 27 |
|
| 28 |
+
# Auto-register all routers (Open/Closed — new router files are picked up automatically)
|
| 29 |
+
for router in discover_routers():
|
| 30 |
+
app.include_router(router)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# ── Static Files & SPA ──────────────────────────────────
|
| 33 |
|
config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized configuration for HugPanel.
|
| 3 |
+
|
| 4 |
+
Single Responsibility: all constants and paths live here.
|
| 5 |
+
Dependency Inversion: other modules depend on this abstraction, not on each other.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
# ── Paths ──────────────────────────────────────
|
| 13 |
+
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data/zones"))
|
| 14 |
+
ZONES_META = DATA_DIR.parent / "zones_meta.json"
|
| 15 |
+
|
| 16 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
# ── Validation ─────────────────────────────────
|
| 19 |
+
ZONE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,50}$")
|
| 20 |
+
|
| 21 |
+
# ── Port Limits ────────────────────────────────
|
| 22 |
+
MIN_PORT = 1024
|
| 23 |
+
MAX_PORT = 65535
|
| 24 |
+
|
| 25 |
+
# ── Terminal ───────────────────────────────────
|
| 26 |
+
SCROLLBACK_SIZE = 128 * 1024 # 128 KB
|
routers/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auto-discovery of API routers.
|
| 3 |
+
|
| 4 |
+
Open/Closed Principle: adding a new feature = adding a new file in this package.
|
| 5 |
+
No need to modify app.py — routers are discovered automatically.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import importlib
|
| 9 |
+
import pkgutil
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def discover_routers() -> list[APIRouter]:
|
| 15 |
+
"""Scan this package for modules exposing a `router` attribute."""
|
| 16 |
+
routers = []
|
| 17 |
+
for _, modname, _ in pkgutil.iter_modules(__path__):
|
| 18 |
+
mod = importlib.import_module(f".{modname}", __package__)
|
| 19 |
+
if hasattr(mod, "router"):
|
| 20 |
+
routers.append(mod.router)
|
| 21 |
+
return routers
|
routers/files.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
File management API.
|
| 3 |
+
|
| 4 |
+
Single Responsibility: only handles file CRUD operations within zones.
|
| 5 |
+
Separated from zone management (zones.py) — each has its own reason to change.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import shutil
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, Form, File, UploadFile, Query, HTTPException
|
| 14 |
+
from fastapi.responses import FileResponse
|
| 15 |
+
|
| 16 |
+
from storage import get_zone_path, safe_path
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/zones/{zone_name}/files", tags=["files"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("")
|
| 22 |
+
def list_files(zone_name: str, path: str = Query("")):
|
| 23 |
+
try:
|
| 24 |
+
zone_path = get_zone_path(zone_name)
|
| 25 |
+
target = safe_path(zone_path, path)
|
| 26 |
+
if not target.is_dir():
|
| 27 |
+
raise ValueError("Không phải thư mục")
|
| 28 |
+
return [
|
| 29 |
+
{
|
| 30 |
+
"name": item.name,
|
| 31 |
+
"is_dir": item.is_dir(),
|
| 32 |
+
"size": item.stat().st_size if item.is_file() else 0,
|
| 33 |
+
"modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat(),
|
| 34 |
+
}
|
| 35 |
+
for item in sorted(target.iterdir())
|
| 36 |
+
]
|
| 37 |
+
except ValueError as e:
|
| 38 |
+
raise HTTPException(400, str(e))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("/read")
|
| 42 |
+
def read_file(zone_name: str, path: str = Query(...)):
|
| 43 |
+
try:
|
| 44 |
+
zone_path = get_zone_path(zone_name)
|
| 45 |
+
target = safe_path(zone_path, path)
|
| 46 |
+
if not target.is_file():
|
| 47 |
+
raise ValueError("File không tồn tại")
|
| 48 |
+
return {"content": target.read_text(encoding="utf-8", errors="replace"), "path": path}
|
| 49 |
+
except ValueError as e:
|
| 50 |
+
raise HTTPException(400, str(e))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.get("/download")
|
| 54 |
+
def download_file(zone_name: str, path: str = Query(...)):
|
| 55 |
+
try:
|
| 56 |
+
zone_path = get_zone_path(zone_name)
|
| 57 |
+
target = safe_path(zone_path, path)
|
| 58 |
+
if not target.is_file():
|
| 59 |
+
raise HTTPException(404, "File không tồn tại")
|
| 60 |
+
return FileResponse(target, filename=target.name)
|
| 61 |
+
except ValueError as e:
|
| 62 |
+
raise HTTPException(400, str(e))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.post("/write")
|
| 66 |
+
def write_file(zone_name: str, path: str = Form(...), content: str = Form(...)):
|
| 67 |
+
try:
|
| 68 |
+
zone_path = get_zone_path(zone_name)
|
| 69 |
+
target = safe_path(zone_path, path)
|
| 70 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
target.write_text(content, encoding="utf-8")
|
| 72 |
+
return {"ok": True}
|
| 73 |
+
except ValueError as e:
|
| 74 |
+
raise HTTPException(400, str(e))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.post("/mkdir")
|
| 78 |
+
def create_folder(zone_name: str, path: str = Form(...)):
|
| 79 |
+
try:
|
| 80 |
+
zone_path = get_zone_path(zone_name)
|
| 81 |
+
target = safe_path(zone_path, path)
|
| 82 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
return {"ok": True}
|
| 84 |
+
except ValueError as e:
|
| 85 |
+
raise HTTPException(400, str(e))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post("/upload")
|
| 89 |
+
async def upload_file(zone_name: str, path: str = Form(""), file: UploadFile = File(...)):
|
| 90 |
+
try:
|
| 91 |
+
zone_path = get_zone_path(zone_name)
|
| 92 |
+
dest = safe_path(zone_path, os.path.join(path, file.filename))
|
| 93 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
content = await file.read()
|
| 95 |
+
dest.write_bytes(content)
|
| 96 |
+
return {"ok": True, "path": str(dest.relative_to(zone_path))}
|
| 97 |
+
except ValueError as e:
|
| 98 |
+
raise HTTPException(400, str(e))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.delete("")
|
| 102 |
+
def delete_file(zone_name: str, path: str = Query(...)):
|
| 103 |
+
try:
|
| 104 |
+
zone_path = get_zone_path(zone_name)
|
| 105 |
+
target = safe_path(zone_path, path)
|
| 106 |
+
if target == zone_path.resolve():
|
| 107 |
+
raise ValueError("Không thể xoá thư mục gốc zone")
|
| 108 |
+
if target.is_dir():
|
| 109 |
+
shutil.rmtree(target)
|
| 110 |
+
elif target.is_file():
|
| 111 |
+
target.unlink()
|
| 112 |
+
else:
|
| 113 |
+
raise ValueError("File/thư mục không tồn tại")
|
| 114 |
+
return {"ok": True}
|
| 115 |
+
except ValueError as e:
|
| 116 |
+
raise HTTPException(400, str(e))
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@router.post("/rename")
|
| 120 |
+
def rename_file(zone_name: str, old_path: str = Form(...), new_name: str = Form(...)):
|
| 121 |
+
try:
|
| 122 |
+
zone_path = get_zone_path(zone_name)
|
| 123 |
+
source = safe_path(zone_path, old_path)
|
| 124 |
+
if not source.exists():
|
| 125 |
+
raise ValueError("File/thư mục nguồn không tồn tại")
|
| 126 |
+
dest = safe_path(zone_path, str(Path(old_path).parent / new_name))
|
| 127 |
+
source.rename(dest)
|
| 128 |
+
return {"ok": True}
|
| 129 |
+
except ValueError as e:
|
| 130 |
+
raise HTTPException(400, str(e))
|
routers/ports.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Port management API.
|
| 3 |
+
|
| 4 |
+
Single Responsibility: only handles port CRUD for zones.
|
| 5 |
+
Reverse proxy logic is in proxy.py — separate reason to change.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Form, HTTPException
|
| 9 |
+
|
| 10 |
+
from config import MIN_PORT, MAX_PORT
|
| 11 |
+
from storage import load_meta, save_meta
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/zones/{zone_name}/ports", tags=["ports"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _validate_port(port: int):
|
| 17 |
+
if not (MIN_PORT <= port <= MAX_PORT):
|
| 18 |
+
raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _validate_zone(meta: dict, zone_name: str):
|
| 22 |
+
if zone_name not in meta:
|
| 23 |
+
raise ValueError(f"Zone '{zone_name}' does not exist")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("")
|
| 27 |
+
def list_ports(zone_name: str):
|
| 28 |
+
try:
|
| 29 |
+
meta = load_meta()
|
| 30 |
+
_validate_zone(meta, zone_name)
|
| 31 |
+
return meta[zone_name].get("ports", [])
|
| 32 |
+
except ValueError as e:
|
| 33 |
+
raise HTTPException(400, str(e))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.post("")
|
| 37 |
+
def add_port(zone_name: str, port: int = Form(...), label: str = Form("")):
|
| 38 |
+
try:
|
| 39 |
+
_validate_port(port)
|
| 40 |
+
meta = load_meta()
|
| 41 |
+
_validate_zone(meta, zone_name)
|
| 42 |
+
|
| 43 |
+
ports = meta[zone_name].setdefault("ports", [])
|
| 44 |
+
for p in ports:
|
| 45 |
+
if p["port"] == port:
|
| 46 |
+
raise ValueError(f"Port {port} already mapped in zone '{zone_name}'")
|
| 47 |
+
|
| 48 |
+
entry = {"port": port, "label": label or f"Port {port}"}
|
| 49 |
+
ports.append(entry)
|
| 50 |
+
save_meta(meta)
|
| 51 |
+
return entry
|
| 52 |
+
except ValueError as e:
|
| 53 |
+
raise HTTPException(400, str(e))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.delete("/{port}")
|
| 57 |
+
def remove_port(zone_name: str, port: int):
|
| 58 |
+
try:
|
| 59 |
+
meta = load_meta()
|
| 60 |
+
_validate_zone(meta, zone_name)
|
| 61 |
+
|
| 62 |
+
ports = meta[zone_name].get("ports", [])
|
| 63 |
+
before = len(ports)
|
| 64 |
+
meta[zone_name]["ports"] = [p for p in ports if p["port"] != port]
|
| 65 |
+
if len(meta[zone_name]["ports"]) == before:
|
| 66 |
+
raise ValueError(f"Port {port} not found in zone '{zone_name}'")
|
| 67 |
+
save_meta(meta)
|
| 68 |
+
return {"ok": True}
|
| 69 |
+
except ValueError as e:
|
| 70 |
+
raise HTTPException(400, str(e))
|
routers/proxy.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reverse proxy for virtual ports.
|
| 3 |
+
|
| 4 |
+
Single Responsibility: only handles HTTP/WebSocket proxying.
|
| 5 |
+
Port CRUD is in ports.py — separate concern.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
import httpx
|
| 12 |
+
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
| 13 |
+
from fastapi.responses import Response
|
| 14 |
+
|
| 15 |
+
from config import MIN_PORT, MAX_PORT
|
| 16 |
+
from storage import load_meta
|
| 17 |
+
|
| 18 |
+
router = APIRouter(tags=["proxy"])
|
| 19 |
+
|
| 20 |
+
# ── Shared HTTP client ────────────────────────
|
| 21 |
+
|
| 22 |
+
_HOP_HEADERS = frozenset({
|
| 23 |
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
| 24 |
+
"te", "trailers", "transfer-encoding", "upgrade",
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
_client: httpx.AsyncClient | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _get_client() -> httpx.AsyncClient:
|
| 31 |
+
global _client
|
| 32 |
+
if _client is None:
|
| 33 |
+
_client = httpx.AsyncClient(
|
| 34 |
+
timeout=httpx.Timeout(30.0, connect=5.0),
|
| 35 |
+
follow_redirects=False,
|
| 36 |
+
limits=httpx.Limits(max_connections=50),
|
| 37 |
+
)
|
| 38 |
+
return _client
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _validate_proxy_access(zone_name: str, port: int):
|
| 42 |
+
"""Validate port range and check it's registered for the zone."""
|
| 43 |
+
if not (MIN_PORT <= port <= MAX_PORT):
|
| 44 |
+
raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
|
| 45 |
+
meta = load_meta()
|
| 46 |
+
if zone_name not in meta:
|
| 47 |
+
raise ValueError(f"Zone '{zone_name}' does not exist")
|
| 48 |
+
ports = meta[zone_name].get("ports", [])
|
| 49 |
+
if not any(p["port"] == port for p in ports):
|
| 50 |
+
raise ValueError("Port not mapped")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ── HTTP Reverse Proxy ────────────────────────
|
| 54 |
+
|
| 55 |
+
@router.api_route(
|
| 56 |
+
"/port/{zone_name}/{port}/{subpath:path}",
|
| 57 |
+
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
| 58 |
+
)
|
| 59 |
+
async def proxy_http(request: Request, zone_name: str, port: int, subpath: str = ""):
|
| 60 |
+
try:
|
| 61 |
+
_validate_proxy_access(zone_name, port)
|
| 62 |
+
except ValueError:
|
| 63 |
+
return Response(content="Port not mapped", status_code=404)
|
| 64 |
+
|
| 65 |
+
target_url = f"http://127.0.0.1:{port}/{subpath}"
|
| 66 |
+
if request.url.query:
|
| 67 |
+
target_url += f"?{request.url.query}"
|
| 68 |
+
|
| 69 |
+
headers = {}
|
| 70 |
+
for key, value in request.headers.items():
|
| 71 |
+
if key.lower() not in _HOP_HEADERS and key.lower() != "host":
|
| 72 |
+
headers[key] = value
|
| 73 |
+
headers["host"] = f"127.0.0.1:{port}"
|
| 74 |
+
headers["x-forwarded-for"] = request.client.host if request.client else "127.0.0.1"
|
| 75 |
+
headers["x-forwarded-proto"] = request.url.scheme
|
| 76 |
+
headers["x-forwarded-prefix"] = f"/port/{zone_name}/{port}"
|
| 77 |
+
|
| 78 |
+
body = await request.body()
|
| 79 |
+
client = _get_client()
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
resp = await client.request(method=request.method, url=target_url, headers=headers, content=body)
|
| 83 |
+
except httpx.ConnectError:
|
| 84 |
+
return Response(
|
| 85 |
+
content=f"Cannot connect to port {port}. Make sure your server is running.",
|
| 86 |
+
status_code=502,
|
| 87 |
+
media_type="text/plain",
|
| 88 |
+
)
|
| 89 |
+
except httpx.TimeoutException:
|
| 90 |
+
return Response(content=f"Timeout connecting to port {port}", status_code=504, media_type="text/plain")
|
| 91 |
+
|
| 92 |
+
resp_headers = {}
|
| 93 |
+
for key, value in resp.headers.items():
|
| 94 |
+
if key.lower() not in _HOP_HEADERS and key.lower() != "content-encoding":
|
| 95 |
+
resp_headers[key] = value
|
| 96 |
+
|
| 97 |
+
return Response(content=resp.content, status_code=resp.status_code, headers=resp_headers)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ── WebSocket Reverse Proxy ──────────────────
|
| 101 |
+
|
| 102 |
+
@router.websocket("/port/{zone_name}/{port}/ws/{subpath:path}")
|
| 103 |
+
async def proxy_ws(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
|
| 104 |
+
try:
|
| 105 |
+
_validate_proxy_access(zone_name, port)
|
| 106 |
+
except ValueError:
|
| 107 |
+
await websocket.close(code=4004, reason="Port not mapped")
|
| 108 |
+
return
|
| 109 |
+
|
| 110 |
+
await websocket.accept()
|
| 111 |
+
target_url = f"ws://127.0.0.1:{port}/ws/{subpath}"
|
| 112 |
+
|
| 113 |
+
import websockets as ws_lib
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
async with ws_lib.connect(target_url) as backend_ws:
|
| 117 |
+
async def client_to_backend():
|
| 118 |
+
try:
|
| 119 |
+
while True:
|
| 120 |
+
msg = await websocket.receive()
|
| 121 |
+
if msg.get("type") == "websocket.disconnect":
|
| 122 |
+
break
|
| 123 |
+
if "text" in msg:
|
| 124 |
+
await backend_ws.send(msg["text"])
|
| 125 |
+
elif "bytes" in msg:
|
| 126 |
+
await backend_ws.send(msg["bytes"])
|
| 127 |
+
except (WebSocketDisconnect, Exception):
|
| 128 |
+
pass
|
| 129 |
+
|
| 130 |
+
async def backend_to_client():
|
| 131 |
+
try:
|
| 132 |
+
async for message in backend_ws:
|
| 133 |
+
if isinstance(message, str):
|
| 134 |
+
await websocket.send_text(message)
|
| 135 |
+
else:
|
| 136 |
+
await websocket.send_bytes(message)
|
| 137 |
+
except (WebSocketDisconnect, Exception):
|
| 138 |
+
pass
|
| 139 |
+
|
| 140 |
+
await asyncio.gather(client_to_backend(), backend_to_client())
|
| 141 |
+
except Exception:
|
| 142 |
+
try:
|
| 143 |
+
await websocket.send_text(json.dumps({"error": f"Cannot connect WebSocket to port {port}"}))
|
| 144 |
+
except Exception:
|
| 145 |
+
pass
|
| 146 |
+
finally:
|
| 147 |
+
try:
|
| 148 |
+
await websocket.close()
|
| 149 |
+
except Exception:
|
| 150 |
+
pass
|
routers/terminal.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Terminal WebSocket with persistent PTY sessions.
|
| 3 |
+
|
| 4 |
+
Single Responsibility: only handles PTY lifecycle and WebSocket communication.
|
| 5 |
+
Depends on storage.get_zone_path for path resolution (Dependency Inversion).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import collections
|
| 10 |
+
import fcntl
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import pty
|
| 14 |
+
import select
|
| 15 |
+
import struct
|
| 16 |
+
import termios
|
| 17 |
+
|
| 18 |
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 19 |
+
|
| 20 |
+
from config import SCROLLBACK_SIZE
|
| 21 |
+
from storage import get_zone_path
|
| 22 |
+
|
| 23 |
+
router = APIRouter(tags=["terminal"])
|
| 24 |
+
|
| 25 |
+
# Active terminals: {zone_name: {fd, pid, buffer, buffer_size, bg_task, ws}}
|
| 26 |
+
active_terminals: dict[str, dict] = {}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── PTY Management ────────────────────────────
|
| 30 |
+
|
| 31 |
+
def _spawn_shell(zone_name: str) -> dict:
|
| 32 |
+
"""Spawn a new PTY shell for a zone."""
|
| 33 |
+
zone_path = get_zone_path(zone_name)
|
| 34 |
+
master_fd, slave_fd = pty.openpty()
|
| 35 |
+
|
| 36 |
+
child_pid = os.fork()
|
| 37 |
+
if child_pid == 0:
|
| 38 |
+
os.setsid()
|
| 39 |
+
os.dup2(slave_fd, 0)
|
| 40 |
+
os.dup2(slave_fd, 1)
|
| 41 |
+
os.dup2(slave_fd, 2)
|
| 42 |
+
os.close(master_fd)
|
| 43 |
+
os.close(slave_fd)
|
| 44 |
+
os.chdir(str(zone_path))
|
| 45 |
+
env = os.environ.copy()
|
| 46 |
+
env["TERM"] = "xterm-256color"
|
| 47 |
+
env["HOME"] = str(zone_path)
|
| 48 |
+
env["PS1"] = f"[{zone_name}] \\w $ "
|
| 49 |
+
os.execvpe("/bin/bash", ["/bin/bash", "--norc"], env)
|
| 50 |
+
else:
|
| 51 |
+
os.close(slave_fd)
|
| 52 |
+
flag = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
| 53 |
+
fcntl.fcntl(master_fd, fcntl.F_SETFL, flag | os.O_NONBLOCK)
|
| 54 |
+
return {"fd": master_fd, "pid": child_pid, "buffer": collections.deque(), "buffer_size": 0}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _resize_terminal(zone_name: str, rows: int, cols: int):
|
| 58 |
+
if zone_name in active_terminals:
|
| 59 |
+
fd = active_terminals[zone_name]["fd"]
|
| 60 |
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
| 61 |
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _append_buffer(info: dict, data: bytes):
|
| 65 |
+
info["buffer"].append(data)
|
| 66 |
+
info["buffer_size"] += len(data)
|
| 67 |
+
while info["buffer_size"] > SCROLLBACK_SIZE:
|
| 68 |
+
old = info["buffer"].popleft()
|
| 69 |
+
info["buffer_size"] -= len(old)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _get_buffer(info: dict) -> bytes:
|
| 73 |
+
return b"".join(info["buffer"])
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _is_alive(zone_name: str) -> bool:
|
| 77 |
+
if zone_name not in active_terminals:
|
| 78 |
+
return False
|
| 79 |
+
try:
|
| 80 |
+
pid = active_terminals[zone_name]["pid"]
|
| 81 |
+
return os.waitpid(pid, os.WNOHANG) == (0, 0)
|
| 82 |
+
except ChildProcessError:
|
| 83 |
+
active_terminals.pop(zone_name, None)
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
async def _bg_reader(zone_name: str):
|
| 88 |
+
"""Background: continuously read PTY output into the ring buffer."""
|
| 89 |
+
info = active_terminals.get(zone_name)
|
| 90 |
+
if not info:
|
| 91 |
+
return
|
| 92 |
+
fd = info["fd"]
|
| 93 |
+
while _is_alive(zone_name):
|
| 94 |
+
await asyncio.sleep(0.02)
|
| 95 |
+
try:
|
| 96 |
+
r, _, _ = select.select([fd], [], [], 0)
|
| 97 |
+
if r:
|
| 98 |
+
data = os.read(fd, 4096)
|
| 99 |
+
if data:
|
| 100 |
+
_append_buffer(info, data)
|
| 101 |
+
ws = info.get("ws")
|
| 102 |
+
if ws:
|
| 103 |
+
try:
|
| 104 |
+
await ws.send_bytes(data)
|
| 105 |
+
except Exception:
|
| 106 |
+
info["ws"] = None
|
| 107 |
+
except (OSError, BlockingIOError):
|
| 108 |
+
pass
|
| 109 |
+
except Exception:
|
| 110 |
+
break
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def kill_terminal(zone_name: str):
|
| 114 |
+
"""Kill terminal process for a zone."""
|
| 115 |
+
if zone_name in active_terminals:
|
| 116 |
+
info = active_terminals.pop(zone_name)
|
| 117 |
+
bg = info.get("bg_task")
|
| 118 |
+
if bg:
|
| 119 |
+
bg.cancel()
|
| 120 |
+
try:
|
| 121 |
+
os.kill(info["pid"], 9)
|
| 122 |
+
os.waitpid(info["pid"], os.WNOHANG)
|
| 123 |
+
except (ProcessLookupError, ChildProcessError):
|
| 124 |
+
pass
|
| 125 |
+
try:
|
| 126 |
+
os.close(info["fd"])
|
| 127 |
+
except OSError:
|
| 128 |
+
pass
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ── WebSocket Handler ─────────────────────────
|
| 132 |
+
|
| 133 |
+
@router.websocket("/ws/terminal/{zone_name}")
|
| 134 |
+
async def terminal_ws(websocket: WebSocket, zone_name: str):
|
| 135 |
+
await websocket.accept()
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
get_zone_path(zone_name)
|
| 139 |
+
except ValueError as e:
|
| 140 |
+
await websocket.send_json({"error": str(e)})
|
| 141 |
+
await websocket.close()
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
# Spawn or reuse terminal
|
| 145 |
+
if not _is_alive(zone_name):
|
| 146 |
+
kill_terminal(zone_name)
|
| 147 |
+
try:
|
| 148 |
+
info = _spawn_shell(zone_name)
|
| 149 |
+
info["ws"] = None
|
| 150 |
+
active_terminals[zone_name] = info
|
| 151 |
+
info["bg_task"] = asyncio.create_task(_bg_reader(zone_name))
|
| 152 |
+
except Exception as e:
|
| 153 |
+
await websocket.send_json({"error": f"Cannot create terminal: {e}"})
|
| 154 |
+
await websocket.close()
|
| 155 |
+
return
|
| 156 |
+
|
| 157 |
+
info = active_terminals[zone_name]
|
| 158 |
+
fd = info["fd"]
|
| 159 |
+
|
| 160 |
+
# Replay buffered scrollback
|
| 161 |
+
buf = _get_buffer(info)
|
| 162 |
+
if buf:
|
| 163 |
+
await websocket.send_bytes(buf)
|
| 164 |
+
|
| 165 |
+
# Register this WebSocket as the active receiver
|
| 166 |
+
info["ws"] = websocket
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
while True:
|
| 170 |
+
msg = await websocket.receive()
|
| 171 |
+
if msg.get("type") == "websocket.disconnect":
|
| 172 |
+
break
|
| 173 |
+
if "text" in msg:
|
| 174 |
+
data = json.loads(msg["text"])
|
| 175 |
+
if data.get("type") == "resize":
|
| 176 |
+
_resize_terminal(zone_name, data.get("rows", 24), data.get("cols", 80))
|
| 177 |
+
elif data.get("type") == "input":
|
| 178 |
+
os.write(fd, data["data"].encode("utf-8"))
|
| 179 |
+
elif "bytes" in msg:
|
| 180 |
+
os.write(fd, msg["bytes"])
|
| 181 |
+
except WebSocketDisconnect:
|
| 182 |
+
pass
|
| 183 |
+
except Exception:
|
| 184 |
+
pass
|
| 185 |
+
finally:
|
| 186 |
+
if zone_name in active_terminals and active_terminals[zone_name].get("ws") is websocket:
|
| 187 |
+
active_terminals[zone_name]["ws"] = None
|
routers/zones.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Zone CRUD API.
|
| 3 |
+
|
| 4 |
+
Single Responsibility: only handles zone create/list/delete.
|
| 5 |
+
File management is in files.py, port management in ports.py.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import shutil
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Form, HTTPException
|
| 12 |
+
|
| 13 |
+
from config import DATA_DIR
|
| 14 |
+
from storage import load_meta, save_meta, validate_zone_name
|
| 15 |
+
from routers.terminal import kill_terminal
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/zones", tags=["zones"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("")
|
| 21 |
+
def list_zones():
|
| 22 |
+
meta = load_meta()
|
| 23 |
+
return [
|
| 24 |
+
{
|
| 25 |
+
"name": name,
|
| 26 |
+
"created": info.get("created", ""),
|
| 27 |
+
"description": info.get("description", ""),
|
| 28 |
+
"exists": (DATA_DIR / name).is_dir(),
|
| 29 |
+
}
|
| 30 |
+
for name, info in meta.items()
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("")
|
| 35 |
+
def create_zone(name: str = Form(...), description: str = Form("")):
|
| 36 |
+
try:
|
| 37 |
+
validate_zone_name(name)
|
| 38 |
+
zone_path = DATA_DIR / name
|
| 39 |
+
if zone_path.exists():
|
| 40 |
+
raise ValueError(f"Zone '{name}' đã tồn tại")
|
| 41 |
+
|
| 42 |
+
zone_path.mkdir(parents=True)
|
| 43 |
+
(zone_path / "README.md").write_text(
|
| 44 |
+
f"# {name}\n\nZone được tạo lúc {datetime.now().isoformat()}\n"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
meta = load_meta()
|
| 48 |
+
meta[name] = {"created": datetime.now().isoformat(), "description": description}
|
| 49 |
+
save_meta(meta)
|
| 50 |
+
return {"name": name, "path": str(zone_path)}
|
| 51 |
+
except ValueError as e:
|
| 52 |
+
raise HTTPException(400, str(e))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.delete("/{zone_name}")
|
| 56 |
+
def delete_zone(zone_name: str):
|
| 57 |
+
try:
|
| 58 |
+
validate_zone_name(zone_name)
|
| 59 |
+
zone_path = DATA_DIR / zone_name
|
| 60 |
+
if not zone_path.exists():
|
| 61 |
+
raise ValueError(f"Zone '{zone_name}' không tồn tại")
|
| 62 |
+
|
| 63 |
+
kill_terminal(zone_name)
|
| 64 |
+
shutil.rmtree(zone_path)
|
| 65 |
+
|
| 66 |
+
meta = load_meta()
|
| 67 |
+
meta.pop(zone_name, None)
|
| 68 |
+
save_meta(meta)
|
| 69 |
+
return {"ok": True}
|
| 70 |
+
except ValueError as e:
|
| 71 |
+
raise HTTPException(400, str(e))
|
static/app.js
CHANGED
|
@@ -14,6 +14,8 @@ let termDataDisposable = null;
|
|
| 14 |
let termResizeDisposable = null;
|
| 15 |
let termCurrentZone = null; // tracks which zone the terminal is connected to
|
| 16 |
let promptResolve = null;
|
|
|
|
|
|
|
| 17 |
|
| 18 |
// ── Init ──────────────────────────────────────
|
| 19 |
document.addEventListener("DOMContentLoaded", () => {
|
|
@@ -21,6 +23,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
| 21 |
loadZones();
|
| 22 |
initResizers();
|
| 23 |
bindEvents();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
});
|
| 25 |
|
| 26 |
// ── API ───────────────────────────────────────
|
|
@@ -77,7 +100,7 @@ async function openZone(name) {
|
|
| 77 |
|
| 78 |
// Reset editor
|
| 79 |
const editorContainer = document.getElementById("editor-container");
|
| 80 |
-
editorContainer.innerHTML = `<div class="editor-empty"><i data-lucide="mouse-pointer-click"></i><p>Double-click a file to open</p></div>`;
|
| 81 |
document.getElementById("editor-tabs").innerHTML = `<span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>`;
|
| 82 |
lucide.createIcons({ nodes: [editorContainer, document.getElementById("editor-tabs")] });
|
| 83 |
cmEditor = null;
|
|
@@ -90,6 +113,12 @@ async function openZone(name) {
|
|
| 90 |
await loadFiles();
|
| 91 |
loadPorts();
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
// Auto-connect terminal
|
| 94 |
setTimeout(() => connectTerminal(), 200);
|
| 95 |
}
|
|
@@ -139,7 +168,6 @@ async function loadFiles() {
|
|
| 139 |
}
|
| 140 |
|
| 141 |
function renderBreadcrumb() {
|
| 142 |
-
const bc = document.getElementById("breadcrumb");
|
| 143 |
const parts = currentPath ? currentPath.split("/").filter(Boolean) : [];
|
| 144 |
let html = `<span onclick="navigateTo('')">~</span>`;
|
| 145 |
let path = "";
|
|
@@ -148,7 +176,10 @@ function renderBreadcrumb() {
|
|
| 148 |
html += `<span class="sep">/</span>`;
|
| 149 |
html += `<span onclick="navigateTo('${escapeAttr(path)}')">${escapeHtml(part)}</span>`;
|
| 150 |
}
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
function renderFiles(files) {
|
|
@@ -160,7 +191,7 @@ function renderFiles(files) {
|
|
| 160 |
}
|
| 161 |
let html = "";
|
| 162 |
if (currentPath) {
|
| 163 |
-
html += `<div class="file-item" ondblclick="navigateUp()">
|
| 164 |
<span class="fi-icon fi-icon-back"><i data-lucide="corner-left-up"></i></span>
|
| 165 |
<span class="fi-name">..</span>
|
| 166 |
</div>`;
|
|
@@ -174,8 +205,10 @@ function renderFiles(files) {
|
|
| 174 |
const iconClass = f.is_dir ? "fi-icon-folder" : fileIconClass(f.name);
|
| 175 |
const iconName = f.is_dir ? "folder" : "file-text";
|
| 176 |
const size = f.is_dir ? "" : formatSize(f.size);
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
html += `<div class="file-item" ondblclick="${
|
| 179 |
<span class="fi-icon ${iconClass}"><i data-lucide="${iconName}"></i></span>
|
| 180 |
<span class="fi-name">${escapeHtml(f.name)}</span>
|
| 181 |
<span class="fi-size">${size}</span>
|
|
@@ -246,7 +279,7 @@ async function editFile(relPath) {
|
|
| 246 |
mode: getMode(filename),
|
| 247 |
theme: "material-darker",
|
| 248 |
lineNumbers: true,
|
| 249 |
-
lineWrapping:
|
| 250 |
indentWithTabs: false,
|
| 251 |
indentUnit: 4,
|
| 252 |
tabSize: 4,
|
|
@@ -267,8 +300,13 @@ async function editFile(relPath) {
|
|
| 267 |
cmEditor.on("change", () => {
|
| 268 |
const dot = document.getElementById("editor-modified");
|
| 269 |
if (dot) dot.style.display = "block";
|
|
|
|
|
|
|
| 270 |
});
|
| 271 |
|
|
|
|
|
|
|
|
|
|
| 272 |
// Focus editor
|
| 273 |
setTimeout(() => cmEditor.refresh(), 50);
|
| 274 |
} catch (e) {
|
|
@@ -286,6 +324,8 @@ async function saveFile() {
|
|
| 286 |
await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
|
| 287 |
const dot = document.getElementById("editor-modified");
|
| 288 |
if (dot) dot.style.display = "none";
|
|
|
|
|
|
|
| 289 |
toast("File saved", "success");
|
| 290 |
} catch (e) {
|
| 291 |
toast("Save failed: " + e.message, "error");
|
|
@@ -382,7 +422,7 @@ function connectTerminal() {
|
|
| 382 |
if (!term) {
|
| 383 |
term = new window.Terminal({
|
| 384 |
cursorBlink: true,
|
| 385 |
-
fontSize: 13,
|
| 386 |
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
|
| 387 |
theme: {
|
| 388 |
background: "#09090b",
|
|
@@ -680,6 +720,57 @@ function fileIconClass(name) {
|
|
| 680 |
return classes[ext] || "fi-icon-file";
|
| 681 |
}
|
| 682 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
// ── Event Binding ────────────────────────────
|
| 684 |
function bindEvents() {
|
| 685 |
document.getElementById("btn-add-zone").addEventListener("click", () => showModal("modal-overlay"));
|
|
|
|
| 14 |
let termResizeDisposable = null;
|
| 15 |
let termCurrentZone = null; // tracks which zone the terminal is connected to
|
| 16 |
let promptResolve = null;
|
| 17 |
+
let isMobile = window.matchMedia("(max-width: 768px)").matches;
|
| 18 |
+
let currentMobileTab = "files";
|
| 19 |
|
| 20 |
// ── Init ──────────────────────────────────────
|
| 21 |
document.addEventListener("DOMContentLoaded", () => {
|
|
|
|
| 23 |
loadZones();
|
| 24 |
initResizers();
|
| 25 |
bindEvents();
|
| 26 |
+
|
| 27 |
+
// Track mobile state on resize
|
| 28 |
+
const mq = window.matchMedia("(max-width: 768px)");
|
| 29 |
+
mq.addEventListener("change", (e) => {
|
| 30 |
+
isMobile = e.matches;
|
| 31 |
+
if (!isMobile) {
|
| 32 |
+
// Exiting mobile: reset panel visibility
|
| 33 |
+
toggleSidebar(false);
|
| 34 |
+
document.getElementById("panel-files").classList.remove("m-active");
|
| 35 |
+
document.getElementById("pane-editor").classList.remove("m-active");
|
| 36 |
+
document.getElementById("pane-terminal").classList.remove("m-active");
|
| 37 |
+
document.getElementById("panel-right").classList.remove("m-active");
|
| 38 |
+
} else if (currentZone) {
|
| 39 |
+
switchMobileTab(currentMobileTab);
|
| 40 |
+
}
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
// Apply initial mobile tab if on mobile
|
| 44 |
+
if (isMobile && currentZone) {
|
| 45 |
+
switchMobileTab("files");
|
| 46 |
+
}
|
| 47 |
});
|
| 48 |
|
| 49 |
// ── API ───────────────────────────────────────
|
|
|
|
| 100 |
|
| 101 |
// Reset editor
|
| 102 |
const editorContainer = document.getElementById("editor-container");
|
| 103 |
+
editorContainer.innerHTML = `<div class="editor-empty"><i data-lucide="mouse-pointer-click"></i><p>${isMobile ? 'Tap a file to open' : 'Double-click a file to open'}</p></div>`;
|
| 104 |
document.getElementById("editor-tabs").innerHTML = `<span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>`;
|
| 105 |
lucide.createIcons({ nodes: [editorContainer, document.getElementById("editor-tabs")] });
|
| 106 |
cmEditor = null;
|
|
|
|
| 113 |
await loadFiles();
|
| 114 |
loadPorts();
|
| 115 |
|
| 116 |
+
// Mobile: close sidebar, show files tab
|
| 117 |
+
if (isMobile) {
|
| 118 |
+
toggleSidebar(false);
|
| 119 |
+
switchMobileTab("files");
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
// Auto-connect terminal
|
| 123 |
setTimeout(() => connectTerminal(), 200);
|
| 124 |
}
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
function renderBreadcrumb() {
|
|
|
|
| 171 |
const parts = currentPath ? currentPath.split("/").filter(Boolean) : [];
|
| 172 |
let html = `<span onclick="navigateTo('')">~</span>`;
|
| 173 |
let path = "";
|
|
|
|
| 176 |
html += `<span class="sep">/</span>`;
|
| 177 |
html += `<span onclick="navigateTo('${escapeAttr(path)}')">${escapeHtml(part)}</span>`;
|
| 178 |
}
|
| 179 |
+
// Update both desktop and mobile breadcrumbs
|
| 180 |
+
document.getElementById("breadcrumb").innerHTML = html;
|
| 181 |
+
const mbc = document.getElementById("breadcrumb-mobile");
|
| 182 |
+
if (mbc) mbc.innerHTML = html;
|
| 183 |
}
|
| 184 |
|
| 185 |
function renderFiles(files) {
|
|
|
|
| 191 |
}
|
| 192 |
let html = "";
|
| 193 |
if (currentPath) {
|
| 194 |
+
html += `<div class="file-item" ondblclick="navigateUp()" onclick="if(isMobile)navigateUp()">
|
| 195 |
<span class="fi-icon fi-icon-back"><i data-lucide="corner-left-up"></i></span>
|
| 196 |
<span class="fi-name">..</span>
|
| 197 |
</div>`;
|
|
|
|
| 205 |
const iconClass = f.is_dir ? "fi-icon-folder" : fileIconClass(f.name);
|
| 206 |
const iconName = f.is_dir ? "folder" : "file-text";
|
| 207 |
const size = f.is_dir ? "" : formatSize(f.size);
|
| 208 |
+
const dblAction = f.is_dir ? `navigateTo('${escapeAttr(relPath)}')` : `editFile('${escapeAttr(relPath)}')`;
|
| 209 |
+
const tapAction = f.is_dir ? `navigateTo('${escapeAttr(relPath)}')` : `editFile('${escapeAttr(relPath)}')`;
|
| 210 |
|
| 211 |
+
html += `<div class="file-item" ondblclick="${dblAction}" onclick="if(isMobile){${tapAction}}">
|
| 212 |
<span class="fi-icon ${iconClass}"><i data-lucide="${iconName}"></i></span>
|
| 213 |
<span class="fi-name">${escapeHtml(f.name)}</span>
|
| 214 |
<span class="fi-size">${size}</span>
|
|
|
|
| 279 |
mode: getMode(filename),
|
| 280 |
theme: "material-darker",
|
| 281 |
lineNumbers: true,
|
| 282 |
+
lineWrapping: isMobile,
|
| 283 |
indentWithTabs: false,
|
| 284 |
indentUnit: 4,
|
| 285 |
tabSize: 4,
|
|
|
|
| 300 |
cmEditor.on("change", () => {
|
| 301 |
const dot = document.getElementById("editor-modified");
|
| 302 |
if (dot) dot.style.display = "block";
|
| 303 |
+
const mTab = document.querySelector('#mobile-tabs [data-tab="editor"]');
|
| 304 |
+
if (mTab) mTab.classList.add('has-dot');
|
| 305 |
});
|
| 306 |
|
| 307 |
+
// Auto-switch to editor tab on mobile
|
| 308 |
+
if (isMobile) switchMobileTab('editor');
|
| 309 |
+
|
| 310 |
// Focus editor
|
| 311 |
setTimeout(() => cmEditor.refresh(), 50);
|
| 312 |
} catch (e) {
|
|
|
|
| 324 |
await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
|
| 325 |
const dot = document.getElementById("editor-modified");
|
| 326 |
if (dot) dot.style.display = "none";
|
| 327 |
+
const mTab = document.querySelector('#mobile-tabs [data-tab="editor"]');
|
| 328 |
+
if (mTab) mTab.classList.remove('has-dot');
|
| 329 |
toast("File saved", "success");
|
| 330 |
} catch (e) {
|
| 331 |
toast("Save failed: " + e.message, "error");
|
|
|
|
| 422 |
if (!term) {
|
| 423 |
term = new window.Terminal({
|
| 424 |
cursorBlink: true,
|
| 425 |
+
fontSize: isMobile ? 11 : 13,
|
| 426 |
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
|
| 427 |
theme: {
|
| 428 |
background: "#09090b",
|
|
|
|
| 720 |
return classes[ext] || "fi-icon-file";
|
| 721 |
}
|
| 722 |
|
| 723 |
+
// ── Mobile: Sidebar ─────────────────────────
|
| 724 |
+
function toggleSidebar(open) {
|
| 725 |
+
const sidebar = document.getElementById("sidebar");
|
| 726 |
+
const backdrop = document.getElementById("sidebar-backdrop");
|
| 727 |
+
if (open) {
|
| 728 |
+
sidebar.classList.add("open");
|
| 729 |
+
backdrop.classList.add("open");
|
| 730 |
+
} else {
|
| 731 |
+
sidebar.classList.remove("open");
|
| 732 |
+
backdrop.classList.remove("open");
|
| 733 |
+
}
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
// ── Mobile: Tab Switching ───────────────────
|
| 737 |
+
function switchMobileTab(tab) {
|
| 738 |
+
currentMobileTab = tab;
|
| 739 |
+
|
| 740 |
+
// Update tab bar highlighting
|
| 741 |
+
document.querySelectorAll(".mobile-tab").forEach(btn => {
|
| 742 |
+
btn.classList.toggle("active", btn.dataset.tab === tab);
|
| 743 |
+
});
|
| 744 |
+
|
| 745 |
+
const panelFiles = document.getElementById("panel-files");
|
| 746 |
+
const panelRight = document.getElementById("panel-right");
|
| 747 |
+
const paneEditor = document.getElementById("pane-editor");
|
| 748 |
+
const paneTerminal = document.getElementById("pane-terminal");
|
| 749 |
+
|
| 750 |
+
// Remove all active
|
| 751 |
+
panelFiles.classList.remove("m-active");
|
| 752 |
+
panelRight.classList.remove("m-active");
|
| 753 |
+
paneEditor.classList.remove("m-active");
|
| 754 |
+
paneTerminal.classList.remove("m-active");
|
| 755 |
+
|
| 756 |
+
if (tab === "files") {
|
| 757 |
+
panelFiles.classList.add("m-active");
|
| 758 |
+
} else if (tab === "editor") {
|
| 759 |
+
panelRight.classList.add("m-active");
|
| 760 |
+
paneEditor.classList.add("m-active");
|
| 761 |
+
setTimeout(() => { if (cmEditor) cmEditor.refresh(); }, 50);
|
| 762 |
+
} else if (tab === "terminal") {
|
| 763 |
+
panelRight.classList.add("m-active");
|
| 764 |
+
paneTerminal.classList.add("m-active");
|
| 765 |
+
setTimeout(() => {
|
| 766 |
+
if (fitAddon) fitAddon.fit();
|
| 767 |
+
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
| 768 |
+
connectTerminal();
|
| 769 |
+
}
|
| 770 |
+
}, 50);
|
| 771 |
+
}
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
// ── Event Binding ────────────────────────────
|
| 775 |
function bindEvents() {
|
| 776 |
document.getElementById("btn-add-zone").addEventListener("click", () => showModal("modal-overlay"));
|
static/index.html
CHANGED
|
@@ -2,8 +2,12 @@
|
|
| 2 |
<html lang="vi">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>HugPanel</title>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
<!-- Lucide Icons -->
|
| 9 |
<script src="https://cdn.jsdelivr.net/npm/lucide@0.344.0/dist/umd/lucide.min.js"></script>
|
|
@@ -34,11 +38,15 @@
|
|
| 34 |
</head>
|
| 35 |
<body>
|
| 36 |
<div id="app">
|
|
|
|
|
|
|
|
|
|
| 37 |
<!-- Sidebar -->
|
| 38 |
<aside id="sidebar">
|
| 39 |
<div class="sidebar-brand">
|
| 40 |
<div class="brand-icon"><i data-lucide="layout-dashboard"></i></div>
|
| 41 |
<span class="brand-text">HugPanel</span>
|
|
|
|
| 42 |
</div>
|
| 43 |
|
| 44 |
<nav class="sidebar-nav">
|
|
@@ -81,12 +89,15 @@
|
|
| 81 |
<div id="workspace" class="view">
|
| 82 |
<div class="topbar">
|
| 83 |
<div class="topbar-left">
|
|
|
|
|
|
|
|
|
|
| 84 |
<span class="zone-indicator" id="zone-badge">
|
| 85 |
<i data-lucide="box"></i>
|
| 86 |
<span id="zone-title"></span>
|
| 87 |
</span>
|
| 88 |
-
<span class="topbar-sep"></span>
|
| 89 |
-
<div class="breadcrumb" id="breadcrumb"></div>
|
| 90 |
</div>
|
| 91 |
<div class="topbar-right">
|
| 92 |
<div class="port-controls" id="port-controls">
|
|
@@ -113,6 +124,7 @@
|
|
| 113 |
<button class="icon-btn-sm" id="btn-upload" title="Upload"><i data-lucide="upload"></i></button>
|
| 114 |
</div>
|
| 115 |
</div>
|
|
|
|
| 116 |
<div id="file-list" class="file-tree"></div>
|
| 117 |
<input type="file" id="file-upload-input" style="display:none" multiple>
|
| 118 |
</div>
|
|
@@ -151,6 +163,22 @@
|
|
| 151 |
</div>
|
| 152 |
</div>
|
| 153 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
</div>
|
| 155 |
</main>
|
| 156 |
</div>
|
|
|
|
| 2 |
<html lang="vi">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 6 |
<title>HugPanel</title>
|
| 7 |
+
<meta name="mobile-web-app-capable" content="yes">
|
| 8 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 9 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 10 |
+
<meta name="theme-color" content="#09090b">
|
| 11 |
<link rel="stylesheet" href="/static/style.css">
|
| 12 |
<!-- Lucide Icons -->
|
| 13 |
<script src="https://cdn.jsdelivr.net/npm/lucide@0.344.0/dist/umd/lucide.min.js"></script>
|
|
|
|
| 38 |
</head>
|
| 39 |
<body>
|
| 40 |
<div id="app">
|
| 41 |
+
<!-- Sidebar Backdrop (mobile) -->
|
| 42 |
+
<div id="sidebar-backdrop" class="sidebar-backdrop" onclick="toggleSidebar(false)"></div>
|
| 43 |
+
|
| 44 |
<!-- Sidebar -->
|
| 45 |
<aside id="sidebar">
|
| 46 |
<div class="sidebar-brand">
|
| 47 |
<div class="brand-icon"><i data-lucide="layout-dashboard"></i></div>
|
| 48 |
<span class="brand-text">HugPanel</span>
|
| 49 |
+
<button id="btn-close-sidebar" class="icon-btn-sm sidebar-close-btn" onclick="toggleSidebar(false)"><i data-lucide="x"></i></button>
|
| 50 |
</div>
|
| 51 |
|
| 52 |
<nav class="sidebar-nav">
|
|
|
|
| 89 |
<div id="workspace" class="view">
|
| 90 |
<div class="topbar">
|
| 91 |
<div class="topbar-left">
|
| 92 |
+
<button id="btn-hamburger" class="icon-btn-sm hamburger-btn" onclick="toggleSidebar(true)" title="Menu">
|
| 93 |
+
<i data-lucide="menu"></i>
|
| 94 |
+
</button>
|
| 95 |
<span class="zone-indicator" id="zone-badge">
|
| 96 |
<i data-lucide="box"></i>
|
| 97 |
<span id="zone-title"></span>
|
| 98 |
</span>
|
| 99 |
+
<span class="topbar-sep desktop-only"></span>
|
| 100 |
+
<div class="breadcrumb desktop-only" id="breadcrumb"></div>
|
| 101 |
</div>
|
| 102 |
<div class="topbar-right">
|
| 103 |
<div class="port-controls" id="port-controls">
|
|
|
|
| 124 |
<button class="icon-btn-sm" id="btn-upload" title="Upload"><i data-lucide="upload"></i></button>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
+
<div class="breadcrumb mobile-breadcrumb mobile-only" id="breadcrumb-mobile"></div>
|
| 128 |
<div id="file-list" class="file-tree"></div>
|
| 129 |
<input type="file" id="file-upload-input" style="display:none" multiple>
|
| 130 |
</div>
|
|
|
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
</div>
|
| 166 |
+
|
| 167 |
+
<!-- Mobile Bottom Tab Bar -->
|
| 168 |
+
<nav id="mobile-tabs" class="mobile-tabs">
|
| 169 |
+
<button class="mobile-tab active" data-tab="files" onclick="switchMobileTab('files')">
|
| 170 |
+
<i data-lucide="folder"></i>
|
| 171 |
+
<span>Files</span>
|
| 172 |
+
</button>
|
| 173 |
+
<button class="mobile-tab" data-tab="editor" onclick="switchMobileTab('editor')">
|
| 174 |
+
<i data-lucide="code-2"></i>
|
| 175 |
+
<span>Editor</span>
|
| 176 |
+
</button>
|
| 177 |
+
<button class="mobile-tab" data-tab="terminal" onclick="switchMobileTab('terminal')">
|
| 178 |
+
<i data-lucide="terminal-square"></i>
|
| 179 |
+
<span>Terminal</span>
|
| 180 |
+
</button>
|
| 181 |
+
</nav>
|
| 182 |
</div>
|
| 183 |
</main>
|
| 184 |
</div>
|
static/style.css
CHANGED
|
@@ -834,5 +834,418 @@ body {
|
|
| 834 |
font-size: 12px;
|
| 835 |
}
|
| 836 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
/* ── Utility ───────────────── */
|
| 838 |
.hidden { display: none !important; }
|
|
|
|
| 834 |
font-size: 12px;
|
| 835 |
}
|
| 836 |
|
| 837 |
+
/* ── Mobile Infrastructure ─── */
|
| 838 |
+
.sidebar-backdrop {
|
| 839 |
+
display: none;
|
| 840 |
+
position: fixed;
|
| 841 |
+
inset: 0;
|
| 842 |
+
background: rgba(0,0,0,0.5);
|
| 843 |
+
z-index: 49;
|
| 844 |
+
-webkit-tap-highlight-color: transparent;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.sidebar-close-btn { display: none; }
|
| 848 |
+
.hamburger-btn { display: none; }
|
| 849 |
+
.mobile-only { display: none !important; }
|
| 850 |
+
.mobile-tabs { display: none; }
|
| 851 |
+
|
| 852 |
+
/* ── Mobile Breakpoint ─────── */
|
| 853 |
+
@media (max-width: 768px) {
|
| 854 |
+
:root {
|
| 855 |
+
--sidebar-w: 280px;
|
| 856 |
+
--topbar-h: 48px;
|
| 857 |
+
--panel-header-h: 44px;
|
| 858 |
+
--mobile-tab-h: 56px;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
.desktop-only { display: none !important; }
|
| 862 |
+
.mobile-only { display: flex !important; }
|
| 863 |
+
|
| 864 |
+
/* Sidebar → slide-out drawer */
|
| 865 |
+
#sidebar {
|
| 866 |
+
position: fixed;
|
| 867 |
+
left: 0; top: 0; bottom: 0;
|
| 868 |
+
width: var(--sidebar-w);
|
| 869 |
+
transform: translateX(-100%);
|
| 870 |
+
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 871 |
+
z-index: 50;
|
| 872 |
+
box-shadow: none;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
#sidebar.open {
|
| 876 |
+
transform: translateX(0);
|
| 877 |
+
box-shadow: var(--shadow-lg);
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
.sidebar-backdrop.open {
|
| 881 |
+
display: block;
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
.sidebar-close-btn {
|
| 885 |
+
display: inline-flex;
|
| 886 |
+
margin-left: auto;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
.hamburger-btn { display: inline-flex; }
|
| 890 |
+
|
| 891 |
+
/* Sidebar brand spacing */
|
| 892 |
+
.sidebar-brand { padding: 12px 14px 10px; }
|
| 893 |
+
.brand-text { font-size: 14px; }
|
| 894 |
+
|
| 895 |
+
/* Sidebar items bigger touch targets */
|
| 896 |
+
.zone-list li {
|
| 897 |
+
padding: 10px 12px;
|
| 898 |
+
font-size: 14px;
|
| 899 |
+
min-height: 44px;
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
.zone-list li .zone-icon { width: 20px; height: 20px; }
|
| 903 |
+
|
| 904 |
+
/* Main takes full width */
|
| 905 |
+
#main { width: 100%; }
|
| 906 |
+
|
| 907 |
+
/* Topbar mobile */
|
| 908 |
+
.topbar {
|
| 909 |
+
height: var(--topbar-h);
|
| 910 |
+
padding: 0 8px;
|
| 911 |
+
gap: 4px;
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
.topbar-left { gap: 6px; }
|
| 915 |
+
|
| 916 |
+
.zone-indicator {
|
| 917 |
+
font-size: 11px;
|
| 918 |
+
padding: 3px 8px;
|
| 919 |
+
max-width: 120px;
|
| 920 |
+
overflow: hidden;
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
.zone-indicator #zone-title {
|
| 924 |
+
overflow: hidden;
|
| 925 |
+
text-overflow: ellipsis;
|
| 926 |
+
white-space: nowrap;
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
/* Port button compact */
|
| 930 |
+
.btn-toggle-ports span:not(.port-count) { display: none; }
|
| 931 |
+
#btn-toggle-ports { padding: 4px 8px; }
|
| 932 |
+
|
| 933 |
+
/* Mobile breadcrumb (inside files panel) */
|
| 934 |
+
.mobile-breadcrumb {
|
| 935 |
+
display: flex !important;
|
| 936 |
+
padding: 6px 12px;
|
| 937 |
+
border-bottom: 1px solid var(--border);
|
| 938 |
+
background: var(--bg-1);
|
| 939 |
+
overflow-x: auto;
|
| 940 |
+
-webkit-overflow-scrolling: touch;
|
| 941 |
+
flex-shrink: 0;
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
.mobile-breadcrumb span {
|
| 945 |
+
font-size: 13px;
|
| 946 |
+
padding: 4px 8px;
|
| 947 |
+
min-height: 32px;
|
| 948 |
+
display: flex;
|
| 949 |
+
align-items: center;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
/* Workspace body → stacked full-screen */
|
| 953 |
+
.workspace-body {
|
| 954 |
+
flex-direction: column;
|
| 955 |
+
position: relative;
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
/* Hide resizers on mobile */
|
| 959 |
+
.resizer-v, .resizer-h { display: none; }
|
| 960 |
+
|
| 961 |
+
/* Each panel = absolute full screen, switch via class */
|
| 962 |
+
.panel-files {
|
| 963 |
+
width: 100% !important;
|
| 964 |
+
max-width: 100%;
|
| 965 |
+
min-width: 100%;
|
| 966 |
+
border-right: none;
|
| 967 |
+
position: absolute;
|
| 968 |
+
inset: 0;
|
| 969 |
+
z-index: 1;
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
.panel-right {
|
| 973 |
+
position: absolute;
|
| 974 |
+
inset: 0;
|
| 975 |
+
z-index: 1;
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
.pane-editor {
|
| 979 |
+
position: absolute;
|
| 980 |
+
inset: 0;
|
| 981 |
+
z-index: 1;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.pane-terminal {
|
| 985 |
+
position: absolute;
|
| 986 |
+
inset: 0;
|
| 987 |
+
height: 100% !important;
|
| 988 |
+
z-index: 1;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
/* Only show active mobile panel */
|
| 992 |
+
.panel-files,
|
| 993 |
+
.pane-editor,
|
| 994 |
+
.pane-terminal {
|
| 995 |
+
display: none;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.panel-files.m-active { display: flex; }
|
| 999 |
+
.pane-editor.m-active { display: flex; }
|
| 1000 |
+
.pane-terminal.m-active { display: flex; }
|
| 1001 |
+
|
| 1002 |
+
/* When editor or terminal is active, show panel-right as container */
|
| 1003 |
+
.panel-right.m-active { display: flex; }
|
| 1004 |
+
|
| 1005 |
+
/* File items — bigger touch targets, always show actions */
|
| 1006 |
+
.file-item {
|
| 1007 |
+
min-height: 48px;
|
| 1008 |
+
height: auto;
|
| 1009 |
+
padding: 8px 12px;
|
| 1010 |
+
gap: 10px;
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.file-item .fi-icon { width: 20px; height: 20px; }
|
| 1014 |
+
.file-item .fi-icon svg { width: 18px; height: 18px; }
|
| 1015 |
+
.file-item .fi-name { font-size: 14px; }
|
| 1016 |
+
.file-item .fi-size { font-size: 12px; }
|
| 1017 |
+
|
| 1018 |
+
/* Always show file actions on touch */
|
| 1019 |
+
.file-item .fi-actions {
|
| 1020 |
+
opacity: 1;
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.fi-actions button {
|
| 1024 |
+
width: 34px; height: 34px;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
.fi-actions button svg { width: 16px; height: 16px; }
|
| 1028 |
+
|
| 1029 |
+
/* Panel headers bigger */
|
| 1030 |
+
.panel-header, .pane-header {
|
| 1031 |
+
height: var(--panel-header-h);
|
| 1032 |
+
padding: 0 12px;
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
.panel-title { font-size: 12px; }
|
| 1036 |
+
.panel-title svg { width: 15px; height: 15px; }
|
| 1037 |
+
|
| 1038 |
+
.icon-btn-sm {
|
| 1039 |
+
width: 36px; height: 36px;
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
.icon-btn-sm svg { width: 18px; height: 18px; }
|
| 1043 |
+
|
| 1044 |
+
/* Editor mobile */
|
| 1045 |
+
.editor-container .CodeMirror {
|
| 1046 |
+
font-size: 12px;
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
.pane-tab {
|
| 1050 |
+
padding: 6px 12px;
|
| 1051 |
+
font-size: 13px;
|
| 1052 |
+
}
|
| 1053 |
+
|
| 1054 |
+
/* Terminal mobile */
|
| 1055 |
+
.terminal-container .xterm {
|
| 1056 |
+
padding: 2px 0 2px 2px;
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
/* Bottom Tab Bar */
|
| 1060 |
+
.mobile-tabs {
|
| 1061 |
+
display: flex;
|
| 1062 |
+
height: var(--mobile-tab-h);
|
| 1063 |
+
background: var(--bg-1);
|
| 1064 |
+
border-top: 1px solid var(--border);
|
| 1065 |
+
flex-shrink: 0;
|
| 1066 |
+
z-index: 10;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
.mobile-tab {
|
| 1070 |
+
flex: 1;
|
| 1071 |
+
display: flex;
|
| 1072 |
+
flex-direction: column;
|
| 1073 |
+
align-items: center;
|
| 1074 |
+
justify-content: center;
|
| 1075 |
+
gap: 2px;
|
| 1076 |
+
background: none;
|
| 1077 |
+
border: none;
|
| 1078 |
+
color: var(--text-3);
|
| 1079 |
+
cursor: pointer;
|
| 1080 |
+
font-size: 10px;
|
| 1081 |
+
font-weight: 500;
|
| 1082 |
+
font-family: var(--font);
|
| 1083 |
+
padding: 6px 0;
|
| 1084 |
+
-webkit-tap-highlight-color: transparent;
|
| 1085 |
+
transition: color var(--transition);
|
| 1086 |
+
position: relative;
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
.mobile-tab svg { width: 20px; height: 20px; }
|
| 1090 |
+
|
| 1091 |
+
.mobile-tab.active {
|
| 1092 |
+
color: var(--accent);
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
.mobile-tab.active::after {
|
| 1096 |
+
content: '';
|
| 1097 |
+
position: absolute;
|
| 1098 |
+
top: 0;
|
| 1099 |
+
left: 25%; right: 25%;
|
| 1100 |
+
height: 2px;
|
| 1101 |
+
background: var(--accent);
|
| 1102 |
+
border-radius: 0 0 2px 2px;
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
.mobile-tab.has-dot::before {
|
| 1106 |
+
content: '';
|
| 1107 |
+
position: absolute;
|
| 1108 |
+
top: 6px;
|
| 1109 |
+
right: calc(50% - 16px);
|
| 1110 |
+
width: 6px; height: 6px;
|
| 1111 |
+
background: var(--accent);
|
| 1112 |
+
border-radius: 50%;
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
/* Modals full-width on mobile */
|
| 1116 |
+
.modal {
|
| 1117 |
+
width: 100%;
|
| 1118 |
+
max-width: 100vw;
|
| 1119 |
+
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
| 1120 |
+
margin: 0;
|
| 1121 |
+
position: fixed;
|
| 1122 |
+
bottom: 0;
|
| 1123 |
+
left: 0;
|
| 1124 |
+
right: 0;
|
| 1125 |
+
animation: slideUpModal 250ms ease;
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
.modal-sm { width: 100%; }
|
| 1129 |
+
|
| 1130 |
+
.modal form { padding: 16px; }
|
| 1131 |
+
|
| 1132 |
+
.form-group input[type="text"],
|
| 1133 |
+
.form-group input[type="number"] {
|
| 1134 |
+
padding: 12px 14px;
|
| 1135 |
+
font-size: 16px; /* prevents iOS zoom */
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
.modal-overlay {
|
| 1139 |
+
align-items: flex-end;
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
@keyframes slideUpModal {
|
| 1143 |
+
from { transform: translateY(100%); }
|
| 1144 |
+
to { transform: translateY(0); }
|
| 1145 |
+
}
|
| 1146 |
+
|
| 1147 |
+
/* Port panel on mobile */
|
| 1148 |
+
.port-panel {
|
| 1149 |
+
position: fixed;
|
| 1150 |
+
top: auto !important;
|
| 1151 |
+
bottom: var(--mobile-tab-h);
|
| 1152 |
+
left: 8px;
|
| 1153 |
+
right: 8px;
|
| 1154 |
+
width: auto;
|
| 1155 |
+
border-radius: var(--radius-lg);
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
/* Toast at top on mobile (avoid keyboard) */
|
| 1159 |
+
.toast-container {
|
| 1160 |
+
top: 12px;
|
| 1161 |
+
bottom: auto;
|
| 1162 |
+
left: 12px;
|
| 1163 |
+
right: 12px;
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
.toast { max-width: 100%; }
|
| 1167 |
+
|
| 1168 |
+
/* Welcome page mobile */
|
| 1169 |
+
.welcome-hero { padding: 20px; }
|
| 1170 |
+
.welcome-hero h1 { font-size: 22px; }
|
| 1171 |
+
.welcome-hero p { font-size: 13px; max-width: 280px; }
|
| 1172 |
+
.welcome-icon { width: 64px; height: 64px; }
|
| 1173 |
+
.welcome-icon svg { width: 28px; height: 28px; }
|
| 1174 |
+
.welcome-glow { width: 200px; height: 200px; }
|
| 1175 |
+
|
| 1176 |
+
/* Empty state mobile */
|
| 1177 |
+
.empty-state { padding: 30px 16px; }
|
| 1178 |
+
.editor-empty svg { width: 24px; height: 24px; }
|
| 1179 |
+
|
| 1180 |
+
/* Icon buttons bigger for touch */
|
| 1181 |
+
.icon-btn {
|
| 1182 |
+
width: 40px; height: 40px;
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
.icon-btn svg { width: 20px; height: 20px; }
|
| 1186 |
+
|
| 1187 |
+
/* Environment badges in sidebar */
|
| 1188 |
+
.sidebar-bottom { padding: 10px 14px; }
|
| 1189 |
+
.badge { font-size: 9px; padding: 3px 8px; }
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
/* ── Small phones (< 380px) ── */
|
| 1193 |
+
@media (max-width: 380px) {
|
| 1194 |
+
:root {
|
| 1195 |
+
--sidebar-w: 260px;
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
.zone-indicator { max-width: 90px; font-size: 10px; }
|
| 1199 |
+
.mobile-tab span { font-size: 9px; }
|
| 1200 |
+
.mobile-tab svg { width: 18px; height: 18px; }
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
/* ── Form input number (port) ── */
|
| 1204 |
+
.form-group input[type="number"] {
|
| 1205 |
+
width: 100%;
|
| 1206 |
+
padding: 8px 12px;
|
| 1207 |
+
background: var(--bg-0);
|
| 1208 |
+
border: 1px solid var(--border);
|
| 1209 |
+
border-radius: var(--radius);
|
| 1210 |
+
color: var(--text);
|
| 1211 |
+
font-size: 13px;
|
| 1212 |
+
outline: none;
|
| 1213 |
+
transition: border-color var(--transition);
|
| 1214 |
+
font-family: var(--font);
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
.form-group input[type="number"]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
|
| 1218 |
+
|
| 1219 |
+
/* ── Touch optimizations ───── */
|
| 1220 |
+
@media (pointer: coarse) {
|
| 1221 |
+
.file-item .fi-actions { opacity: 1; }
|
| 1222 |
+
|
| 1223 |
+
.file-item {
|
| 1224 |
+
min-height: 48px;
|
| 1225 |
+
-webkit-tap-highlight-color: transparent;
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
.zone-list li {
|
| 1229 |
+
min-height: 44px;
|
| 1230 |
+
-webkit-tap-highlight-color: transparent;
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
.icon-btn-sm {
|
| 1234 |
+
min-width: 36px;
|
| 1235 |
+
min-height: 36px;
|
| 1236 |
+
}
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
/* Safe area for notch devices */
|
| 1240 |
+
@supports (padding: env(safe-area-inset-bottom)) {
|
| 1241 |
+
.mobile-tabs {
|
| 1242 |
+
padding-bottom: env(safe-area-inset-bottom);
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
.modal {
|
| 1246 |
+
padding-bottom: env(safe-area-inset-bottom);
|
| 1247 |
+
}
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
/* ── Utility ───────────────── */
|
| 1251 |
.hidden { display: none !important; }
|
storage.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared storage layer — zone metadata and path utilities.
|
| 3 |
+
|
| 4 |
+
Single Responsibility: only handles metadata persistence and path resolution.
|
| 5 |
+
Dependency Inversion: routers depend on these abstractions instead of importing each other.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from config import DATA_DIR, ZONES_META, ZONE_NAME_PATTERN
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_meta() -> dict:
|
| 16 |
+
"""Load zones metadata from JSON file."""
|
| 17 |
+
if ZONES_META.exists():
|
| 18 |
+
return json.loads(ZONES_META.read_text(encoding="utf-8"))
|
| 19 |
+
return {}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def save_meta(meta: dict):
|
| 23 |
+
"""Save zones metadata to JSON file."""
|
| 24 |
+
ZONES_META.write_text(json.dumps(meta, indent=2, default=str), encoding="utf-8")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def validate_zone_name(name: str):
|
| 28 |
+
"""Validate zone name format. Raises ValueError if invalid."""
|
| 29 |
+
if not ZONE_NAME_PATTERN.match(name):
|
| 30 |
+
raise ValueError("Tên zone chỉ chứa a-z, A-Z, 0-9, _, - (tối đa 50 ký tự)")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def get_zone_path(name: str) -> Path:
|
| 34 |
+
"""Get the filesystem path for a zone, validating it exists."""
|
| 35 |
+
validate_zone_name(name)
|
| 36 |
+
zone_path = DATA_DIR / name
|
| 37 |
+
if not zone_path.is_dir():
|
| 38 |
+
raise ValueError(f"Zone '{name}' không tồn tại")
|
| 39 |
+
return zone_path
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def safe_path(zone_path: Path, rel_path: str) -> Path:
|
| 43 |
+
"""Resolve a relative path within a zone, preventing path traversal."""
|
| 44 |
+
target = (zone_path / rel_path).resolve()
|
| 45 |
+
zone_resolved = zone_path.resolve()
|
| 46 |
+
if target != zone_resolved and not str(target).startswith(str(zone_resolved) + os.sep):
|
| 47 |
+
raise ValueError("Truy cập ngoài zone không được phép")
|
| 48 |
+
return target
|