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