Add mobile-responsive web template, gniza uninstall command, fix web commands for user mode
- Custom textual-serve template with viewport meta tag, full-viewport terminal sizing, and auto font-size for mobile (<768px) - Fix web install-service/remove-service/status to handle user-mode systemd (systemctl --user) alongside root mode - Add 'gniza uninstall' command that runs the uninstall script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
83
bin/gniza
83
bin/gniza
@@ -50,6 +50,7 @@ Commands:
|
|||||||
schedule install|show|remove
|
schedule install|show|remove
|
||||||
logs [--last] [--tail=N]
|
logs [--last] [--tail=N]
|
||||||
web start|install-service|remove-service|status [--port=PORT] [--host=HOST]
|
web start|install-service|remove-service|status [--port=PORT] [--host=HOST]
|
||||||
|
uninstall Run the uninstall script
|
||||||
version
|
version
|
||||||
|
|
||||||
If no command is given, the TUI is launched.
|
If no command is given, the TUI is launched.
|
||||||
@@ -150,6 +151,19 @@ run_cli() {
|
|||||||
|
|
||||||
if [[ "$all" == "true" || -z "$target" ]]; then
|
if [[ "$all" == "true" || -z "$target" ]]; then
|
||||||
backup_all_targets "$remote"
|
backup_all_targets "$remote"
|
||||||
|
elif [[ "$target" == *,* ]]; then
|
||||||
|
# Comma-separated targets (e.g. from schedule --target=web,db)
|
||||||
|
local IFS=','
|
||||||
|
local names
|
||||||
|
read -ra names <<< "$target"
|
||||||
|
local rc=0
|
||||||
|
for t in "${names[@]}"; do
|
||||||
|
t="${t#"${t%%[![:space:]]*}"}"
|
||||||
|
t="${t%"${t##*[![:space:]]}"}"
|
||||||
|
[[ -z "$t" ]] && continue
|
||||||
|
backup_target "$t" "$remote" || rc=$?
|
||||||
|
done
|
||||||
|
exit "$rc"
|
||||||
else
|
else
|
||||||
backup_target "$target" "$remote"
|
backup_target "$target" "$remote"
|
||||||
fi
|
fi
|
||||||
@@ -417,30 +431,73 @@ if [[ "$SUBCOMMAND" == "web" ]]; then
|
|||||||
PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui "${_web_args[@]}"
|
PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui "${_web_args[@]}"
|
||||||
;;
|
;;
|
||||||
install-service)
|
install-service)
|
||||||
_service_src="$GNIZA_DIR/etc/gniza-web.service"
|
if [[ $EUID -eq 0 ]]; then
|
||||||
_service_dst="/etc/systemd/system/gniza-web.service"
|
_service_src="$GNIZA_DIR/etc/gniza-web.service"
|
||||||
if [[ ! -f "$_service_src" ]]; then
|
_service_dst="/etc/systemd/system/gniza-web.service"
|
||||||
die "Service file not found: $_service_src"
|
if [[ ! -f "$_service_src" ]]; then
|
||||||
|
die "Service file not found: $_service_src"
|
||||||
|
fi
|
||||||
|
cp "$_service_src" "$_service_dst"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable gniza-web
|
||||||
|
systemctl restart gniza-web
|
||||||
|
else
|
||||||
|
_user_service_dir="$HOME/.config/systemd/user"
|
||||||
|
mkdir -p "$_user_service_dir"
|
||||||
|
cat > "$_user_service_dir/gniza-web.service" <<SVCEOF
|
||||||
|
[Unit]
|
||||||
|
Description=GNIZA Web Dashboard
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$(command -v python3) -m tui --web --host 0.0.0.0 --port 2323
|
||||||
|
WorkingDirectory=$GNIZA_DIR
|
||||||
|
Environment=GNIZA_DIR=$GNIZA_DIR
|
||||||
|
Environment=PYTHONPATH=$GNIZA_DIR
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
SVCEOF
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable gniza-web
|
||||||
|
systemctl --user restart gniza-web
|
||||||
fi
|
fi
|
||||||
cp "$_service_src" "$_service_dst"
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable gniza-web
|
|
||||||
systemctl restart gniza-web
|
|
||||||
echo "GNIZA web service installed and started."
|
echo "GNIZA web service installed and started."
|
||||||
echo "Access the dashboard at http://$(hostname -I | awk '{print $1}'):2323"
|
echo "Access the dashboard at http://$(hostname -I | awk '{print $1}'):2323"
|
||||||
;;
|
;;
|
||||||
remove-service)
|
remove-service)
|
||||||
systemctl stop gniza-web 2>/dev/null || true
|
if [[ $EUID -eq 0 ]]; then
|
||||||
systemctl disable gniza-web 2>/dev/null || true
|
systemctl stop gniza-web 2>/dev/null || true
|
||||||
rm -f /etc/systemd/system/gniza-web.service
|
systemctl disable gniza-web 2>/dev/null || true
|
||||||
systemctl daemon-reload
|
rm -f /etc/systemd/system/gniza-web.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
else
|
||||||
|
systemctl --user stop gniza-web 2>/dev/null || true
|
||||||
|
systemctl --user disable gniza-web 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.config/systemd/user/gniza-web.service"
|
||||||
|
systemctl --user daemon-reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
echo "GNIZA web service removed."
|
echo "GNIZA web service removed."
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
systemctl status gniza-web 2>/dev/null || echo "GNIZA web service is not installed."
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
systemctl status gniza-web 2>/dev/null || echo "GNIZA web service is not installed."
|
||||||
|
else
|
||||||
|
systemctl --user status gniza-web 2>/dev/null || echo "GNIZA web service is not installed."
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
*) die "Unknown web action: $_web_action (expected start|install-service|remove-service|status)" ;;
|
*) die "Unknown web action: $_web_action (expected start|install-service|remove-service|status)" ;;
|
||||||
esac
|
esac
|
||||||
|
elif [[ "$SUBCOMMAND" == "uninstall" ]]; then
|
||||||
|
_uninstall_script="$GNIZA_DIR/scripts/uninstall.sh"
|
||||||
|
if [[ -f "$_uninstall_script" ]]; then
|
||||||
|
exec bash "$_uninstall_script"
|
||||||
|
else
|
||||||
|
die "Uninstall script not found: $_uninstall_script"
|
||||||
|
fi
|
||||||
elif [[ -n "$SUBCOMMAND" ]]; then
|
elif [[ -n "$SUBCOMMAND" ]]; then
|
||||||
# Explicit subcommand: always CLI
|
# Explicit subcommand: always CLI
|
||||||
run_cli
|
run_cli
|
||||||
|
|||||||
@@ -112,12 +112,14 @@ def main():
|
|||||||
if web_key:
|
if web_key:
|
||||||
print(f"GNIZA web: login with user={web_user!r}")
|
print(f"GNIZA web: login with user={web_user!r}")
|
||||||
|
|
||||||
|
_templates_dir = str(Path(__file__).resolve().parent / "web_templates")
|
||||||
server = Server(
|
server = Server(
|
||||||
"python3 -m tui",
|
"python3 -m tui",
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
title="GNIZA Backup",
|
title="GNIZA Backup",
|
||||||
public_url=public_url,
|
public_url=public_url,
|
||||||
|
templates_path=_templates_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add HTTP Basic Auth if API key is configured
|
# Add HTTP Basic Auth if API key is configured
|
||||||
|
|||||||
206
tui/web_templates/app_index.html
Normal file
206
tui/web_templates/app_index.html
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
<link rel="stylesheet" href="{{ config.static.url }}css/xterm.css" />
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css?family=Roboto%20Mono"
|
||||||
|
/>
|
||||||
|
<script src="{{ config.static.url }}js/textual.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #0c181f;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-container {
|
||||||
|
position: absolute;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: var(--shadow-elevation-high);
|
||||||
|
}
|
||||||
|
.shade {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0c181f;
|
||||||
|
background-image: url("{{ config.static.url }}images/background.png");
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 640px;
|
||||||
|
height: 240px;
|
||||||
|
font-size: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
background-color: #12232d;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.-first-byte .intro-dialog,
|
||||||
|
body.-first-byte .intro-dialog .shade {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .textual-terminal {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.-first-byte .textual-terminal {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro svg {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body Button {
|
||||||
|
padding: 16px 32px;
|
||||||
|
background-color: #5e0ba7;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
border: none;
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
margin: 16px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button:hover {
|
||||||
|
background: #ac5af4;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closed-dialog {
|
||||||
|
opacity: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.-closed .closed-dialog {
|
||||||
|
opacity: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#start {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#start.-delay {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: fit terminal to viewport */
|
||||||
|
#terminal {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#terminal .xterm {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#terminal .xterm-viewport {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#terminal .xterm-screen {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function getStartUrl() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const params = new URLSearchParams(url.search);
|
||||||
|
params.delete("delay");
|
||||||
|
return url.pathname + "?" + params.toString();
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
const ping_url = document.body.dataset.pingurl;
|
||||||
|
if (ping_url) {
|
||||||
|
await fetch(ping_url, {
|
||||||
|
method: "GET",
|
||||||
|
mode: "no-cors",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.location.href = getStartUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select font size for mobile to fit ~80 columns
|
||||||
|
(function() {
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
if (!url.searchParams.has("fontsize") && window.innerWidth < 768) {
|
||||||
|
// xterm char width is ~0.6 * fontSize; target 80 cols
|
||||||
|
var fontSize = Math.max(6, Math.floor(window.innerWidth / (80 * 0.6)));
|
||||||
|
url.searchParams.set("fontsize", fontSize);
|
||||||
|
window.location.replace(url.toString());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body data-pingurl="{{ ping_url }}">
|
||||||
|
<div class="dialog-container intro-dialog">
|
||||||
|
<div class="shade"></div>
|
||||||
|
<div class="intro">
|
||||||
|
<svg
|
||||||
|
width="32px"
|
||||||
|
viewBox="0 0 2933 2261"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M2927.81 0H1677.1L1312.35 205.173H306.689L0.359375 434.921L204.029 893.177H735.972L644.784 2261H1060.36L1441.72 1974.97L2073.92 688.003H2334.4L2717.9 472.286L2927.81 0ZM1245.82 410.347L1276.32 277.656H330.85L153.929 410.347H1245.82ZM1229.16 482.83H100.972L251.134 820.694H813.449L722.26 2188.52H837.043L1229.16 482.83ZM1350.7 277.656L911.417 2188.52H1023.87L1662.19 615.52H2301.36L2451.52 277.656H1350.7ZM1460.19 205.173H2497.79L2733.69 72.4829H1696.09L1460.19 205.173Z"
|
||||||
|
fill="#ffffff"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div>{{ application.name or 'Textual Application' }}</div>
|
||||||
|
<button type="button" onClick="refresh()" id="start">Start</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-container closed-dialog">
|
||||||
|
<div class="shade"></div>
|
||||||
|
<div class="intro">
|
||||||
|
<div class="message">Session ended.</div>
|
||||||
|
<button type="button" onClick="refresh()">Restart</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="terminal"
|
||||||
|
class="textual-terminal"
|
||||||
|
data-session-websocket-url="{{ app_websocket_url }}"
|
||||||
|
data-font-size="{{ font_size }}"
|
||||||
|
></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user