Compare commits

...

1 Commits

Author SHA1 Message Date
he d01228a21e feat(GUI): web interface for install and pos commands
- gui/main.py: FastAPI app with Jinja2 templates and static files
- gui/state.py: shared application state (runner, template env)
- gui/services/pos_runner.py: abstracted command execution layer
  with subprocess, SSE streaming, and sudo support
- gui/routes/: 8 route modules (dashboard, install, compose,
  network, vbox, system, media, ssh)
- gui/templates/: Bulma-based layout with sidebar navigation +
  section templates for all routes
- gui/static/css/style.css: dark theme styling
- gui/requirements.txt: fastapi, uvicorn, jinja2, python-multipart
- 1- Start_GUI.sh: entry point with sudo cache, pip install, uvicorn launch

Architecture: routes delegate to pos_runner which shells out to
actual pos-* scripts — no logic duplication. SSE streaming for
real-time output (install, compose up/down/logs, network scan, media).
2026-07-28 11:47:48 -04:00
30 changed files with 1182 additions and 0 deletions
+36
View File
@@ -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
View File
+21
View File
@@ -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")
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
jinja2>=3.1.0
python-multipart>=0.0.9
+19
View File
@@ -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,
]
+81
View File
@@ -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,
},
)
+26
View File
@@ -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,
},
)
+69
View File
@@ -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")
+39
View File
@@ -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")
+60
View File
@@ -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")
+26
View File
@@ -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}
+55
View File
@@ -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}
+38
View File
@@ -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}
View File
+135
View File
@@ -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
+12
View File
@@ -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"]),
)
+98
View File
@@ -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;
}
}
+40
View File
@@ -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>
+40
View File
@@ -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 %}
+25
View File
@@ -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 %}
+35
View File
@@ -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 %}
+32
View File
@@ -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 %}
+58
View File
@@ -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 %}
+56
View File
@@ -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 %}
+2
View File
@@ -0,0 +1,2 @@
<p>Target: <strong>{{ target }}</strong></p>
<pre class="log-box">{{ output }}</pre>
+48
View File
@@ -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 %}
+1
View File
@@ -0,0 +1 @@
<pre class="log-box">{{ output }}</pre>
+15
View File
@@ -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 %}
+76
View File
@@ -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 %}
+35
View File
@@ -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 %}