Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d01228a21e |
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "[+] Linux_post_install — Web GUI"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Check python3 ──────────────────────────────────────────────
|
||||||
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
echo "ERROR: python3 not found — install it first: sudo apt install python3 python3-pip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cache sudo credentials ─────────────────────────────────────
|
||||||
|
echo "[!] Root access is required for some operations (install, firewall, etc.)"
|
||||||
|
echo " Your credentials will be cached for the session."
|
||||||
|
sudo -v
|
||||||
|
# Keep sudo alive in background
|
||||||
|
while true; do sudo -n true; sleep 60; kill -0 "$$" 2>/dev/null || exit; done 2>/dev/null &
|
||||||
|
|
||||||
|
# ── Install Python deps ────────────────────────────────────────
|
||||||
|
echo "[+] Installing Python dependencies..."
|
||||||
|
python3 -m pip install --quiet -r gui/requirements.txt 2>/dev/null || {
|
||||||
|
python3 -m pip install --user --quiet -r gui/requirements.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Launch ─────────────────────────────────────────────────────
|
||||||
|
PORT="${PORT:-8080}"
|
||||||
|
HOST="${HOST:-0.0.0.0}"
|
||||||
|
|
||||||
|
echo "[+] Starting web GUI at http://$HOST:$PORT"
|
||||||
|
echo " Press Ctrl+C to stop."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
python3 -m uvicorn gui.main:app --host "$HOST" --port "$PORT" --reload
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from gui.routes import routers
|
||||||
|
|
||||||
|
app = FastAPI(title="Linux_post_install")
|
||||||
|
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
if static_dir.is_dir():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
for router in routers:
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from gui.routes.dashboard import router as dashboard_router
|
||||||
|
from gui.routes.install import router as install_router
|
||||||
|
from gui.routes.compose import router as compose_router
|
||||||
|
from gui.routes.network import router as network_router
|
||||||
|
from gui.routes.vbox import router as vbox_router
|
||||||
|
from gui.routes.system import router as system_router
|
||||||
|
from gui.routes.media import router as media_router
|
||||||
|
from gui.routes.ssh import router as ssh_router
|
||||||
|
|
||||||
|
routers = [
|
||||||
|
dashboard_router,
|
||||||
|
install_router,
|
||||||
|
compose_router,
|
||||||
|
network_router,
|
||||||
|
vbox_router,
|
||||||
|
system_router,
|
||||||
|
media_router,
|
||||||
|
ssh_router,
|
||||||
|
]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/compose")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def compose_page(request: Request):
|
||||||
|
services = runner.list_services()
|
||||||
|
deployed = runner.list_deployed()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"compose/index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "compose",
|
||||||
|
"services": services,
|
||||||
|
"deployed": deployed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{service}", response_class=HTMLResponse)
|
||||||
|
async def service_detail(request: Request, service: str):
|
||||||
|
deployed = (Path("/srv") / service).is_dir()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"compose/detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "compose",
|
||||||
|
"service": service,
|
||||||
|
"deployed": deployed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{service}/stream/{action}")
|
||||||
|
async def service_stream(service: str, action: str):
|
||||||
|
async def generate():
|
||||||
|
cmd_map = {
|
||||||
|
"up": f"pos docker compose up {service}",
|
||||||
|
"down": f"pos docker compose down {service}",
|
||||||
|
"restart": f"pos docker compose restart {service}",
|
||||||
|
"logs": f"pos docker compose logs {service}",
|
||||||
|
}
|
||||||
|
cmd = cmd_map.get(action)
|
||||||
|
if not cmd:
|
||||||
|
yield f"event: error\ndata: Unknown action: {action}\n\n"
|
||||||
|
return
|
||||||
|
async for event in runner.stream(cmd):
|
||||||
|
yield f"event: {event['event']}\ndata: {event['data']}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/set")
|
||||||
|
async def config_set(key: str = Form(...), value: str = Form(...)):
|
||||||
|
code, out, err = await runner.run(f"pos docker compose config set {key}={value}")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_class=HTMLResponse)
|
||||||
|
async def config_page(request: Request):
|
||||||
|
code, out, _ = await runner.run("pos docker compose config")
|
||||||
|
config_file = Path.home() / ".config" / "linux_post_install" / "compose.env"
|
||||||
|
current = ""
|
||||||
|
if config_file.is_file():
|
||||||
|
current = config_file.read_text()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"compose/config.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "compose",
|
||||||
|
"current": current,
|
||||||
|
"status": out,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
docker_ok = bool(runner.script_path("docker"))
|
||||||
|
compose_count = len(runner.list_services())
|
||||||
|
deployed_count = len(runner.list_deployed())
|
||||||
|
app_categories = runner.list_app_categories()
|
||||||
|
total_apps = sum(len(apps) for apps in app_categories.values())
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "dashboard",
|
||||||
|
"docker_ok": docker_ok,
|
||||||
|
"compose_count": compose_count,
|
||||||
|
"deployed_count": deployed_count,
|
||||||
|
"total_apps": total_apps,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/install")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def install_page(request: Request):
|
||||||
|
app_categories = runner.list_app_categories()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"install/index.html",
|
||||||
|
{"request": request, "active": "install", "app_categories": app_categories},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def install_stream(phases: str = "1,2,3,4", apps: str = ""):
|
||||||
|
phase_map = {
|
||||||
|
"1": ("preinstall", False),
|
||||||
|
"2": ("scripts", True),
|
||||||
|
"3": ("postinstall", False),
|
||||||
|
"4": ("scalepoint", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
repo = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
selected = [p.strip() for p in phases.split(",") if p.strip() in phase_map]
|
||||||
|
|
||||||
|
for phase_num in selected:
|
||||||
|
name, needs_sudo = phase_map[phase_num]
|
||||||
|
script = phase_map[phase_num][0]
|
||||||
|
yield {"event": "phase_start", "data": f"Phase {phase_num}: {script}"}
|
||||||
|
|
||||||
|
if needs_sudo:
|
||||||
|
gen = runner.stream_sudo(f"bash {repo}/install.sh --steps {phase_num}")
|
||||||
|
else:
|
||||||
|
gen = runner.stream(f"bash {repo}/install.sh --steps {phase_num}")
|
||||||
|
|
||||||
|
async for event in gen:
|
||||||
|
yield event
|
||||||
|
|
||||||
|
if apps:
|
||||||
|
app_list = apps.split(",")
|
||||||
|
for app in app_list:
|
||||||
|
app = app.strip()
|
||||||
|
if not app:
|
||||||
|
continue
|
||||||
|
yield {"event": "phase_start", "data": f"Installing app: {app}"}
|
||||||
|
category, name = app.split("/", 1)
|
||||||
|
script = repo / "apps" / category / f"{name}.sh"
|
||||||
|
if script.is_file():
|
||||||
|
async for event in runner.stream_sudo(f"bash {script}"):
|
||||||
|
yield event
|
||||||
|
else:
|
||||||
|
yield {"event": "error", "data": f"App script not found: {app}"}
|
||||||
|
|
||||||
|
yield {"event": "exit", "data": "0"}
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
async for event in event_stream():
|
||||||
|
yield f"event: {event['event']}\ndata: {event['data']}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/media")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def media_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"media/index.html", {"request": request, "active": "media"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mp3/stream")
|
||||||
|
async def media_mp3_stream(url: str = Form(...)):
|
||||||
|
async def generate():
|
||||||
|
async for event in runner.stream(f"pos media mp3 {url}"):
|
||||||
|
yield f"event: {event['event']}\ndata: {event['data']}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mp4/formats")
|
||||||
|
async def media_mp4_formats(url: str = Form(...)):
|
||||||
|
code, out, err = await runner.run(f"yt-dlp -F {url}")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mp4/stream")
|
||||||
|
async def media_mp4_stream(url: str = Form(...), format_id: str = Form(...)):
|
||||||
|
async def generate():
|
||||||
|
async for event in runner.stream(
|
||||||
|
f"yt-dlp -f {format_id} --merge-output-format mp4 --embed-thumbnail --add-metadata -o '~/Videos/%(title)s.%(ext)s' {url}"
|
||||||
|
):
|
||||||
|
yield f"event: {event['event']}\ndata: {event['data']}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, Form, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/network")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def network_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"network/index.html", {"request": request, "active": "network"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ip", response_class=HTMLResponse)
|
||||||
|
async def network_ip(request: Request):
|
||||||
|
code, out, err = await runner.run("pos network ip")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"network/ip.html",
|
||||||
|
{"request": request, "active": "network", "output": out or err, "code": code},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checkport", response_class=HTMLResponse)
|
||||||
|
async def network_checkport(request: Request, target: str = Form(...)):
|
||||||
|
code, out, err = await runner.run(f"pos network checkport {target}")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"network/checkport.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "network",
|
||||||
|
"output": out or err,
|
||||||
|
"code": code,
|
||||||
|
"target": target,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan", response_class=HTMLResponse)
|
||||||
|
async def network_scan_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"network/scan.html", {"request": request, "active": "network"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan/stream")
|
||||||
|
async def network_scan_stream(
|
||||||
|
target: str = Form(...), full: bool = Form(False), retries: int = Form(1)
|
||||||
|
):
|
||||||
|
async def generate():
|
||||||
|
cmd = f"pos network scan {target}"
|
||||||
|
if full:
|
||||||
|
cmd += " --full"
|
||||||
|
if retries > 1:
|
||||||
|
cmd += f" --retries {retries}"
|
||||||
|
async for event in runner.stream(cmd):
|
||||||
|
yield f"event: {event['event']}\ndata: {event['data']}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ssh")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def ssh_page(request: Request):
|
||||||
|
code, out, err = await runner.run("pos ssh load-keys")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"ssh/index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "ssh",
|
||||||
|
"output": out or err,
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/load")
|
||||||
|
async def ssh_load():
|
||||||
|
code, out, err = await runner.run("pos ssh load-keys")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/system")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/firewall", response_class=HTMLResponse)
|
||||||
|
async def firewall_page(request: Request):
|
||||||
|
code, out, _ = await runner.run("sudo ufw status verbose")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"system/firewall.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "system",
|
||||||
|
"status": out,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/firewall/enable")
|
||||||
|
async def firewall_enable():
|
||||||
|
code, out, err = await runner.run("sudo ufw --force enable")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/firewall/disable")
|
||||||
|
async def firewall_disable():
|
||||||
|
code, out, err = await runner.run("sudo ufw disable")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/firewall/allow")
|
||||||
|
async def firewall_allow(port: str = Form(...), protocol: str = Form("tcp")):
|
||||||
|
code, out, err = await runner.run(f"sudo ufw allow {port}/{protocol}")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/firewall/deny")
|
||||||
|
async def firewall_deny(port: str = Form(...), protocol: str = Form("tcp")):
|
||||||
|
code, out, err = await runner.run(f"sudo ufw deny {port}/{protocol}")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/firewall/delete")
|
||||||
|
async def firewall_delete(rule_num: str = Form(...)):
|
||||||
|
code, out, err = await runner.run(f"echo {rule_num} | sudo ufw delete")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/firewall/status")
|
||||||
|
async def firewall_status():
|
||||||
|
code, out, err = await runner.run("sudo ufw status verbose")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
|
from gui.state import runner, templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vbox")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def vbox_page(request: Request):
|
||||||
|
code, out, err = await runner.run("pos vbox ls")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"vbox/index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"active": "vbox",
|
||||||
|
"containers": out or err,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def vbox_create(
|
||||||
|
name: str = Form(...), image: str = Form("ubuntu:22.04"), directory: str = Form("")
|
||||||
|
):
|
||||||
|
cmd = f"pos vbox create {name} {image}"
|
||||||
|
if directory:
|
||||||
|
cmd += f" --dir {directory}"
|
||||||
|
code, out, err = await runner.run(cmd)
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{action}/{name}")
|
||||||
|
async def vbox_action(action: str, name: str):
|
||||||
|
if action not in ("start", "stop", "rm", "enter"):
|
||||||
|
return {"ok": False, "output": f"Unknown action: {action}"}
|
||||||
|
code, out, err = await runner.run(f"pos vbox {action} {name}")
|
||||||
|
return {"ok": code == 0, "output": out or err}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEvent(str, Enum):
|
||||||
|
STDOUT = "stdout"
|
||||||
|
STDERR = "stderr"
|
||||||
|
EXIT = "exit"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class PosRunner:
|
||||||
|
def __init__(self, repo_root: Optional[Path] = None):
|
||||||
|
self.repo_root = repo_root or Path(__file__).resolve().parents[2]
|
||||||
|
self.bin_dir = self.repo_root / "bin"
|
||||||
|
|
||||||
|
def _find_script(self, command: str) -> Optional[Path]:
|
||||||
|
parts = command.split()
|
||||||
|
name = f"pos-{'-'.join(parts)}"
|
||||||
|
candidate = self.bin_dir / name
|
||||||
|
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||||
|
return candidate
|
||||||
|
system = shutil.which(name)
|
||||||
|
if system:
|
||||||
|
return Path(system)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_command(self, command: str) -> list[str]:
|
||||||
|
script = self._find_script(command)
|
||||||
|
if script:
|
||||||
|
return [str(script)]
|
||||||
|
if command.startswith("sudo "):
|
||||||
|
return shlex.split(command)
|
||||||
|
return shlex.split(command)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
env: Optional[dict[str, str]] = None,
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
cmd = self._resolve_command(command)
|
||||||
|
merged = {**os.environ, **(env or {})}
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
cwd=cwd or self.repo_root,
|
||||||
|
env=merged,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||||
|
|
||||||
|
async def stream(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
env: Optional[dict[str, str]] = None,
|
||||||
|
) -> AsyncGenerator[dict, None]:
|
||||||
|
cmd = self._resolve_command(command)
|
||||||
|
merged = {**os.environ, **(env or {})}
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
cwd=cwd or self.repo_root,
|
||||||
|
env=merged,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
yield {"event": StreamEvent.ERROR, "data": f"Command not found: {command}"}
|
||||||
|
return
|
||||||
|
|
||||||
|
async def read_stream(stream, event_type: StreamEvent):
|
||||||
|
while True:
|
||||||
|
line = await stream.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
yield {"event": event_type, "data": line.decode(errors="replace").rstrip()}
|
||||||
|
|
||||||
|
async for line in read_stream(proc.stdout, StreamEvent.STDOUT):
|
||||||
|
yield line
|
||||||
|
async for line in read_stream(proc.stderr, StreamEvent.STDERR):
|
||||||
|
yield line
|
||||||
|
|
||||||
|
code = await proc.wait()
|
||||||
|
yield {"event": StreamEvent.EXIT, "data": str(code)}
|
||||||
|
|
||||||
|
async def stream_sudo(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
env: Optional[dict[str, str]] = None,
|
||||||
|
) -> AsyncGenerator[dict, None]:
|
||||||
|
async for event in self.stream(f"sudo {command}", cwd=cwd, env=env):
|
||||||
|
yield event
|
||||||
|
|
||||||
|
def script_path(self, name: str) -> Optional[str]:
|
||||||
|
p = self.bin_dir / name
|
||||||
|
if p.is_file():
|
||||||
|
return str(p)
|
||||||
|
system = shutil.which(name)
|
||||||
|
if system:
|
||||||
|
return system
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_services(self) -> list[str]:
|
||||||
|
scale_dir = Path("/usr/local/share/linux_post_install/scale-tail/services")
|
||||||
|
if not scale_dir.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(d.name for d in scale_dir.iterdir() if d.is_dir())
|
||||||
|
|
||||||
|
def list_deployed(self) -> list[str]:
|
||||||
|
services_base = os.environ.get("SERVICES_BASE", "/srv")
|
||||||
|
base = Path(services_base)
|
||||||
|
if not base.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(d.name for d in base.iterdir() if d.is_dir())
|
||||||
|
|
||||||
|
def list_app_categories(self) -> dict[str, list[str]]:
|
||||||
|
apps_dir = self.repo_root / "apps"
|
||||||
|
if not apps_dir.is_dir():
|
||||||
|
return {}
|
||||||
|
categories = {}
|
||||||
|
for d in sorted(apps_dir.iterdir()):
|
||||||
|
if d.is_dir() and d.name != "install":
|
||||||
|
scripts = sorted(f.stem for f in d.glob("*.sh"))
|
||||||
|
if scripts:
|
||||||
|
categories[d.name] = scripts
|
||||||
|
return categories
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
from gui.services.pos_runner import PosRunner
|
||||||
|
|
||||||
|
runner = PosRunner()
|
||||||
|
|
||||||
|
templates = Environment(
|
||||||
|
loader=FileSystemLoader(Path(__file__).parent / "templates"),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main {
|
||||||
|
margin-top: 3.25rem;
|
||||||
|
min-height: calc(100vh - 3.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #0a0a0a;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
border-right: 1px solid #1a1a1a;
|
||||||
|
min-height: calc(100vh - 3.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .menu-list a {
|
||||||
|
color: #b0b0b0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .menu-list a:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .menu-list a.is-active {
|
||||||
|
background: #3273dc;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
background: #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-box {
|
||||||
|
background: #111;
|
||||||
|
color: #00e676;
|
||||||
|
border: 1px solid #222;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
background: #141414;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
color: #b0b0b0;
|
||||||
|
border-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
border-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
.sidebar.is-active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Linux_post_install</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.3/dist/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar is-fixed-top" role="navigation">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item has-text-weight-bold" href="/dashboard">
|
||||||
|
Linux_post_install
|
||||||
|
</a>
|
||||||
|
<button class="navbar-burger" data-target="sidebar">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="columns is-gapless" id="main">
|
||||||
|
<aside class="column is-2 sidebar" id="sidebar">
|
||||||
|
<ul class="menu-list">
|
||||||
|
<li><a class="{{ 'is-active' if active == 'dashboard' else '' }}" href="/dashboard">Dashboard</a></li>
|
||||||
|
<li><a class="{{ 'is-active' if active == 'install' else '' }}" href="/install">Install</a></li>
|
||||||
|
<li><a class="{{ 'is-active' if active == 'compose' else '' }}" href="/compose">Docker Compose</a></li>
|
||||||
|
<li><a class="{{ 'is-active' if active == 'network' else '' }}" href="/network">Network</a></li>
|
||||||
|
<li><a class="{{ 'is-active' if active == 'vbox' else '' }}" href="/vbox">VBox</a></li>
|
||||||
|
<li><a class="{{ 'is-active' if active == 'system' else '' }}" href="/system/firewall">Firewall</a></li>
|
||||||
|
<li><a class="{{ 'is-active' if active == 'media' else '' }}" href="/media">Media</a></li>
|
||||||
|
<li><a class="{{ 'is-active' if active == 'ssh' else '' }}" href="/ssh">SSH</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<main class="column main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<nav class="breadcrumb"><ul><li><a href="/compose">Compose</a></li><li class="is-active"><a href="#">Config</a></li></ul></nav>
|
||||||
|
<h1 class="title">Global Compose Config</h1>
|
||||||
|
<div class="box">
|
||||||
|
<p class="subtitle">File: ~/.config/linux_post_install/compose.env</p>
|
||||||
|
<pre class="log-box">{{ current }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Set a Value</h2>
|
||||||
|
<form hx-post="/compose/config/set" hx-target="#config-result">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" name="key" placeholder="TS_AUTHKEY" required>
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="value" placeholder="value" required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="config-result"></div>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Config Keys</h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Key</th><th>Required</th><th>Default</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>TS_AUTHKEY</td><td>Yes</td><td>—</td><td>Tailscale auth key</td></tr>
|
||||||
|
<tr><td>TZ</td><td>No</td><td>Europe/Amsterdam</td><td>Timezone</td></tr>
|
||||||
|
<tr><td>DNS_SERVER</td><td>No</td><td>9.9.9.9</td><td>Custom DNS</td></tr>
|
||||||
|
<tr><td>SERVICES_BASE</td><td>No</td><td>/srv</td><td>Deployment directory</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<nav class="breadcrumb"><ul><li><a href="/compose">Compose</a></li><li class="is-active"><a href="#">{{ service }}</a></li></ul></nav>
|
||||||
|
<h1 class="title">{{ service }}</h1>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="button is-success" onclick="runAction('up')">Up</button>
|
||||||
|
<button class="button is-danger" onclick="runAction('down')">Down</button>
|
||||||
|
<button class="button is-warning" onclick="runAction('restart')">Restart</button>
|
||||||
|
<button class="button is-info" onclick="runAction('logs')">Logs</button>
|
||||||
|
</div>
|
||||||
|
<pre id="output" class="log-box"></pre>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
function runAction(action) {
|
||||||
|
const el = document.getElementById('output');
|
||||||
|
el.textContent = '';
|
||||||
|
const evt = new EventSource(`/compose/{{ service }}/stream/${action}`);
|
||||||
|
evt.addEventListener('stdout', e => { el.textContent += e.data + '\n'; });
|
||||||
|
evt.addEventListener('stderr', e => { el.textContent += e.data + '\n'; });
|
||||||
|
evt.addEventListener('exit', e => { evt.close(); });
|
||||||
|
evt.addEventListener('error', e => { el.textContent += '\nERROR: ' + e.data + '\n'; });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">Docker Compose</h1>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Available Services ({{ services | length }})</h2>
|
||||||
|
<div class="list is-hoverable" style="max-height:500px;overflow-y:auto;">
|
||||||
|
{% for svc in services %}
|
||||||
|
<a class="list-item" href="/compose/{{ svc }}">{{ svc }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Deployed ({{ deployed | length }})</h2>
|
||||||
|
<div class="list is-hoverable">
|
||||||
|
{% for svc in deployed %}
|
||||||
|
<a class="list-item" href="/compose/{{ svc }}">{{ svc }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not deployed %}
|
||||||
|
<p class="has-text-grey">No services deployed yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box mt-4">
|
||||||
|
<h2 class="subtitle">Global Config</h2>
|
||||||
|
<a class="button is-small" href="/compose/config">View / Edit Config</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">Dashboard</h1>
|
||||||
|
<div class="columns is-multiline">
|
||||||
|
<div class="column is-3">
|
||||||
|
<div class="box">
|
||||||
|
<p class="heading">Docker</p>
|
||||||
|
<p class="title">{{ 'Installed' if docker_ok else 'Not installed' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-3">
|
||||||
|
<div class="box">
|
||||||
|
<p class="heading">Available Services</p>
|
||||||
|
<p class="title">{{ compose_count }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-3">
|
||||||
|
<div class="box">
|
||||||
|
<p class="heading">Deployed Services</p>
|
||||||
|
<p class="title">{{ deployed_count }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-3">
|
||||||
|
<div class="box">
|
||||||
|
<p class="heading">Optional Apps</p>
|
||||||
|
<p class="title">{{ total_apps }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">Installation</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Select Phases</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox"><input type="checkbox" class="phase-checkbox" value="1" checked> Phase 1: System Packages</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox"><input type="checkbox" class="phase-checkbox" value="2" checked> Phase 2: Install Scripts</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox"><input type="checkbox" class="phase-checkbox" value="3" checked> Phase 3: Post-install Config</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox"><input type="checkbox" class="phase-checkbox" value="4" checked> Phase 4: ScaleTail Templates</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if app_categories %}
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Optional Apps</h2>
|
||||||
|
{% for category, apps in app_categories.items() %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h3 class="has-text-weight-bold">{{ category | capitalize }}</h3>
|
||||||
|
{% for app in apps %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox"><input type="checkbox" class="app-checkbox" value="{{ category }}/{{ app }}"> {{ app }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button class="button is-primary" onclick="startInstall()">Start Installation</button>
|
||||||
|
<div id="output" class="mt-4" style="display:none;">
|
||||||
|
<pre id="log" class="log-box"></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
function startInstall() {
|
||||||
|
const phases = Array.from(document.querySelectorAll('.phase-checkbox:checked')).map(c => c.value);
|
||||||
|
const apps = Array.from(document.querySelectorAll('.app-checkbox:checked')).map(c => c.value);
|
||||||
|
if (phases.length === 0 && apps.length === 0) return;
|
||||||
|
const el = document.getElementById('output');
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
el.style.display = 'block';
|
||||||
|
log.textContent = '';
|
||||||
|
const evt = new EventSource(`/install/stream?phases=${phases.join(',')}&apps=${apps.join(',')}`);
|
||||||
|
evt.addEventListener('phase_start', e => { log.textContent += '\n=== ' + e.data + ' ===\n'; });
|
||||||
|
evt.addEventListener('stdout', e => { log.textContent += e.data + '\n'; });
|
||||||
|
evt.addEventListener('stderr', e => { log.textContent += e.data + '\n'; });
|
||||||
|
evt.addEventListener('exit', e => { log.textContent += '\nDone (exit: ' + e.data + ')\n'; evt.close(); });
|
||||||
|
evt.addEventListener('error', e => { log.textContent += '\nERROR: ' + e.data + '\n'; });
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">Media Download</h1>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">MP3 (Audio)</h2>
|
||||||
|
<form hx-post="/media/mp3/stream" hx-trigger="submit" hx-target="#mp3-output">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="url" placeholder="https://youtube.com/watch?v=..." required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary">Download MP3</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<pre id="mp3-output" class="log-box mt-2"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">MP4 (Video)</h2>
|
||||||
|
<form hx-post="/media/mp4/formats" hx-target="#formats-output" hx-trigger="submit">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="url" placeholder="https://youtube.com/watch?v=..." required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-info">List Formats</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<pre id="formats-output" class="log-box mt-2"></pre>
|
||||||
|
<form hx-post="/media/mp4/stream" hx-trigger="submit" hx-target="#mp4-output" class="mt-3">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="format_id" placeholder="format code from above">
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="url" id="mp4-url-input">
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary">Download MP4</button>
|
||||||
|
</form>
|
||||||
|
<pre id="mp4-output" class="log-box mt-2"></pre>
|
||||||
|
<script>
|
||||||
|
document.querySelector('form[hx-post*="mp4/formats"]').addEventListener('htmx:afterRequest', function(evt) {
|
||||||
|
const url = evt.detail.elt.querySelector('[name=url]').value;
|
||||||
|
document.getElementById('mp4-url-input').value = url;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<p>Target: <strong>{{ target }}</strong></p>
|
||||||
|
<pre class="log-box">{{ output }}</pre>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">Network</h1>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">IP Info</h2>
|
||||||
|
<a class="button" href="/network/ip" hx-get="/network/ip" hx-target="#ip-result" hx-swap="innerHTML">Show IP Info</a>
|
||||||
|
<div id="ip-result"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Check Port</h2>
|
||||||
|
<form hx-post="/network/checkport" hx-target="#port-result">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="target" placeholder="192.168.1.1:80" required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary">Check</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="port-result"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Network Scan</h2>
|
||||||
|
<form hx-post="/network/scan/stream" hx-trigger="submit" hx-target="#scan-output">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="target" placeholder="192.168.1.0/24" required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary">Scan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox"><input type="checkbox" name="full"> Full scan (OS detection, services)</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<pre id="scan-output" class="log-box mt-2"></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<pre class="log-box">{{ output }}</pre>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">SSH Keys</h1>
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Load Keys into ssh-agent</h2>
|
||||||
|
<button class="button is-primary" hx-post="/ssh/load" hx-target="#ssh-result">Load Keys</button>
|
||||||
|
<div id="ssh-result" class="mt-2">
|
||||||
|
{% if output %}
|
||||||
|
<pre class="log-box">{{ output }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">Firewall</h1>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-6">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Status</h2>
|
||||||
|
<pre class="log-box">{{ status }}</pre>
|
||||||
|
<div class="buttons mt-2">
|
||||||
|
<button class="button is-success" hx-post="/system/firewall/enable" hx-target="#fw-result">Enable</button>
|
||||||
|
<button class="button is-danger" hx-post="/system/firewall/disable" hx-target="#fw-result">Disable</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-6">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Add Rule</h2>
|
||||||
|
<form hx-post="/system/firewall/allow" hx-target="#fw-result">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="port" placeholder="80 or 3000:3100" required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select name="protocol">
|
||||||
|
<option value="tcp">tcp</option>
|
||||||
|
<option value="udp">udp</option>
|
||||||
|
<option value="">both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-success">Allow</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form hx-post="/system/firewall/deny" hx-target="#fw-result" class="mt-2">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="port" placeholder="port number" required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select name="protocol">
|
||||||
|
<option value="tcp">tcp</option>
|
||||||
|
<option value="udp">udp</option>
|
||||||
|
<option value="">both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-danger">Deny</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Delete Rule</h2>
|
||||||
|
<form hx-post="/system/firewall/delete" hx-target="#fw-result">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input class="input" name="rule_num" placeholder="rule number (e.g. 3)" required>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-warning">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<button class="button is-small mt-2" hx-get="/system/firewall/status" hx-target="#fw-result">Show numbered rules</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="fw-result" class="mt-2"></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="section">
|
||||||
|
<h1 class="title">VBox — Disposable Docker VMs</h1>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-4">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Create</h2>
|
||||||
|
<form hx-post="/vbox/create" hx-target="#vbox-result">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Name</label>
|
||||||
|
<input class="input" name="name" placeholder="my-vm" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Image</label>
|
||||||
|
<input class="input" name="image" value="ubuntu:22.04">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Directory (optional)</label>
|
||||||
|
<input class="input" name="directory" placeholder="e.g. /home/user/project">
|
||||||
|
</div>
|
||||||
|
<button class="button is-primary">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Containers</h2>
|
||||||
|
<pre class="log-box">{{ containers }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="vbox-result"></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user