Compare commits

..

9 Commits

Author SHA1 Message Date
Bas Nijholt
967d68b14a revert: Remove mobile rainbow glow adjustments
Reverts #46 and #47. The reduced background-size caused a green
tint at rest. The improvement in animation visibility wasn't
worth the trade-off.
2025-12-18 16:16:31 -08:00
Bas Nijholt
b7614aeab7 fix(web): Adjust mobile rainbow glow to avoid green edge (#47)
500% background-size showed too much of the gradient at rest,
revealing green (#bfff80) at the button edge. 650% shows ~15%
of the gradient, landing safely on white while still improving
color visibility during animation.
2025-12-18 16:11:58 -08:00
Bas Nijholt
d931784935 fix(web): Make rainbow glow animation more visible on mobile (#46)
The 900% background-size meant only ~11% of the gradient was visible
at any time. On smaller screens, the rainbow colors would flash by
too quickly during the intro animation, appearing mostly white.

Use a CSS variable for background-size and reduce it to 500% on
mobile (<768px), showing ~20% of the gradient for a more visible
rainbow effect.
2025-12-18 15:53:03 -08:00
Bas Nijholt
4755065229 feat(web): Add collapsible blocks to console terminal and editor (#44) 2025-12-18 15:52:36 -08:00
Bas Nijholt
e86bbf7681 fix(web): Make task-not-found message more general (#45) 2025-12-18 15:37:08 -08:00
Bas Nijholt
be136eb916 fix(web): Show friendlier message when task not found after restart
After a self-update, the browser tries to reconnect to the old task_id
but the in-memory task registry is empty (new container). Show a
helpful message instead of a scary "Error" message.
2025-12-18 15:34:07 -08:00
Bas Nijholt
78a223878f fix(web): Use nohup for self-updates to survive container death (#41) 2025-12-18 15:29:37 -08:00
Bas Nijholt
f5be23d626 fix(web): Ensure URL updates after HTMX navigation in command palette (#43)
* fix(web): Ensure URL updates after HTMX navigation in command palette

Use history.pushState() after HTMX swap completes to ensure
window.location.pathname is correct when rebuilding commands.

* docs: Add rule about unchecked checklists in PR descriptions
2025-12-18 15:22:10 -08:00
Bas Nijholt
3bdc483c2a feat(web): Add rainbow glow effect to command palette button (#42) 2025-12-18 15:13:49 -08:00
9 changed files with 166 additions and 47 deletions

View File

@@ -57,6 +57,11 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
- **NEVER merge anything into main.** Always commit directly or use fast-forward/rebase.
- Never force push.
## Pull Requests
- Never include unchecked checklists (e.g., `- [ ] ...`) in PR descriptions. Either omit the checklist or use checked items.
- **NEVER run `gh pr merge`**. PRs are merged via the GitHub UI, not the CLI.
## Releases
Use `gh release create` to create releases. The tag is created automatically.

View File

@@ -1,3 +1,11 @@
/* Sidebar inputs - remove focus outline (DaisyUI 5 uses outline + outline-offset) */
#sidebar .input:focus,
#sidebar .input:focus-within,
#sidebar .select:focus {
outline: none;
outline-offset: 0;
}
/* Editors (Monaco) - wrapper makes it resizable */
.editor-wrapper {
resize: vertical;
@@ -53,3 +61,65 @@
background-position: 16em center;
}
}
/* Command palette FAB - rainbow glow effect */
@property --cmd-pos { syntax: "<number>"; inherits: true; initial-value: 100; }
@property --cmd-blur { syntax: "<number>"; inherits: true; initial-value: 10; }
@property --cmd-scale { syntax: "<number>"; inherits: true; initial-value: 1; }
@property --cmd-opacity { syntax: "<number>"; inherits: true; initial-value: 0.3; }
#cmd-fab {
--g: linear-gradient(to right, #fff, #fff, #0ff, #00f, #8000ff, #e066a3, #f00, #ff0, #bfff80, #fff, #fff);
all: unset;
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 50;
cursor: pointer;
transform: scale(var(--cmd-scale));
transition: --cmd-pos 3s, --cmd-blur 0.3s, --cmd-opacity 0.3s, --cmd-scale 0.2s cubic-bezier(.76,-.25,.51,1.13);
}
.cmd-fab-inner {
display: block;
padding: 0.6em 1em;
background: #1d232a;
border-radius: 8px;
font-size: 14px;
position: relative;
}
.cmd-fab-inner > span {
background: var(--g) no-repeat calc(var(--cmd-pos) * 1%) 0 / 900%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.15ch;
font-weight: 600;
}
.cmd-fab-inner::before, .cmd-fab-inner::after {
content: "";
position: absolute;
border-radius: 8px;
}
.cmd-fab-inner::before {
inset: -1.5px;
background: var(--g) no-repeat calc(var(--cmd-pos) * 1%) 0 / 900%;
border-radius: 9px;
z-index: -1;
opacity: var(--cmd-opacity);
}
.cmd-fab-inner::after {
inset: 0;
background: #000;
transform: translateY(10px);
z-index: -2;
filter: blur(calc(var(--cmd-blur) * 1px));
}
#cmd-fab:hover { --cmd-scale: 1.05; --cmd-pos: 0; --cmd-blur: 30; --cmd-opacity: 1; }
#cmd-fab:hover .cmd-fab-inner::after { background: var(--g); opacity: 0.3; }
#cmd-fab:active { --cmd-scale: 0.98; --cmd-blur: 15; }

View File

@@ -437,23 +437,30 @@ function tryReconnectToTask() {
tryInit(20);
}
// Play intro animation on command palette button
function playFabIntro() {
const fab = document.getElementById('cmd-fab');
if (!fab) return;
setTimeout(() => {
fab.style.setProperty('--cmd-pos', '0');
fab.style.setProperty('--cmd-opacity', '1');
fab.style.setProperty('--cmd-blur', '30');
setTimeout(() => {
fab.style.removeProperty('--cmd-pos');
fab.style.removeProperty('--cmd-opacity');
fab.style.removeProperty('--cmd-blur');
}, 3000);
}, 500);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initPage();
initKeyboardShortcuts();
playFabIntro();
// Try to reconnect to any active task
tryReconnectToTask();
// Handle ?action= parameter (from command palette navigation)
const params = new URLSearchParams(window.location.search);
const action = params.get('action');
if (action && window.location.pathname === '/') {
// Clear the URL parameter
history.replaceState({}, '', '/');
// Trigger the action
htmx.ajax('POST', `/api/${action}`, {swap: 'none'});
}
});
// Re-initialize after HTMX swaps main content
@@ -542,13 +549,21 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
let selected = 0;
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
const nav = (url) => () => window.location.href = url;
const nav = (url) => () => {
htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
history.pushState({}, '', url);
});
};
// Navigate to dashboard and trigger action (or just POST if already on dashboard)
const dashboardAction = (endpoint) => () => {
if (window.location.pathname === '/') {
htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
} else {
window.location.href = `/?action=${endpoint}`;
// Navigate via HTMX, then trigger action after swap
htmx.ajax('GET', '/', {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
history.pushState({}, '', '/');
htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
});
}
};
const cmd = (type, name, desc, action, icon = null) => ({ type, name, desc, action, icon });

View File

@@ -125,31 +125,45 @@ async def _run_cli_via_ssh(
"""Run a cf CLI command via SSH to the host.
Used for self-updates to ensure the command survives container restart.
Uses setsid to run command in a new session (completely detached), with
output going to a log file. We tail the log to stream output. When SSH
dies (container killed), the tail dies but the setsid process continues.
"""
try:
# Get the host for the web service
host = config.get_host(CF_WEB_SERVICE)
# Build the remote command - prepend common install locations to PATH
# since non-interactive SSH doesn't source profile files
cf_cmd = f"cf {' '.join(args)} --config={config.config_path}"
remote_cmd = f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH {cf_cmd}"
log_file = "/tmp/cf-self-update.log" # noqa: S108
# Show what we're doing (display the cf command, not the bash wrapper)
# Build the remote command:
# 1. setsid runs command in new session (survives SSH disconnect)
# 2. Output goes to log file
# 3. tail -f streams the log (dies when SSH dies, but command continues)
# 4. wait for tail or timeout after command should be done
remote_cmd = (
f"rm -f {log_file} && "
f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH "
f"setsid sh -c '{cf_cmd} > {log_file} 2>&1' & "
f"sleep 0.3 && "
f"tail -f {log_file} 2>/dev/null"
)
# Show what we're doing
await stream_to_task(
task_id,
f"{DIM}$ ssh {host.user}@{host.address} {cf_cmd}{RESET}{CRLF}",
f"{DIM}$ {cf_cmd}{RESET}{CRLF}",
)
await stream_to_task(
task_id,
f"{GREEN}Running via SSH (self-update protection){RESET}{CRLF}",
f"{GREEN}Running via SSH (detached with setsid){RESET}{CRLF}",
)
# Build SSH command using shared helper (tty=True for progress bars)
ssh_args = build_ssh_command(host, remote_cmd, tty=True)
# Build SSH command (no TTY needed, output comes from tail)
ssh_args = build_ssh_command(host, remote_cmd, tty=False)
# Set up environment with SSH agent
env = {**os.environ, "FORCE_COLOR": "1", "TERM": "xterm-256color"}
env = {**os.environ}
ssh_sock = get_ssh_auth_sock()
if ssh_sock:
env["SSH_AUTH_SOCK"] = ssh_sock
@@ -161,7 +175,7 @@ async def _run_cli_via_ssh(
env=env,
)
# Stream output
# Stream output until SSH dies (container killed) or command completes
if process.stdout:
async for line in process.stdout:
text = line.decode("utf-8", errors="replace")
@@ -170,7 +184,17 @@ async def _run_cli_via_ssh(
await stream_to_task(task_id, text)
exit_code = await process.wait()
tasks[task_id]["status"] = "completed" if exit_code == 0 else "failed"
# Exit code 255 means SSH connection closed (container died during down)
# This is expected for self-updates - setsid ensures command continues
if exit_code == 255: # noqa: PLR2004
await stream_to_task(
task_id,
f"{CRLF}{GREEN}Container restarting... refresh the page in a few seconds.{RESET}{CRLF}",
)
tasks[task_id]["status"] = "completed"
else:
tasks[task_id]["status"] = "completed" if exit_code == 0 else "failed"
tasks[task_id]["completed_at"] = time.time()
except Exception as e:

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% from "partials/components.html" import page_header %}
{% from "partials/icons.html" import terminal, save %}
{% from "partials/components.html" import page_header, collapse %}
{% from "partials/icons.html" import terminal, file_code, save %}
{% block title %}Console - Compose Farm{% endblock %}
{% block content %}
@@ -20,19 +20,14 @@
</div>
<!-- Terminal -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-2">
<h3 class="font-semibold flex items-center gap-2">{{ terminal() }} Terminal</h3>
<span class="text-xs opacity-50">Full shell access to selected host</span>
</div>
{% call collapse("Terminal", checked=True, icon=terminal(), subtitle="Full shell access to selected host") %}
<div id="console-terminal" class="w-full bg-base-300 rounded-lg overflow-hidden resize-y" style="height: 384px; min-height: 200px;"></div>
</div>
{% endcall %}
<!-- Editor -->
<div class="mb-6">
{% call collapse("Editor", checked=True, icon=file_code()) %}
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-4">
<h3 class="font-semibold">Editor</h3>
<input type="text" id="console-file-path" class="input input-sm input-bordered w-96" placeholder="Enter file path (e.g., ~/docker-compose.yaml)" value="{{ config_path }}">
<button class="btn btn-sm btn-outline" onclick="loadFile()">Open</button>
</div>
@@ -42,7 +37,7 @@
</div>
</div>
<div id="console-editor" class="resize-y overflow-hidden rounded-lg" style="height: 512px; min-height: 200px;"></div>
</div>
{% endcall %}
</div>
<script>
@@ -53,6 +48,13 @@ var consoleEditor = null;
var currentFilePath = null;
var currentHost = null;
// Helper to show status with monospace path
function setEditorStatus(prefix, path) {
const statusEl = document.getElementById('editor-status');
const escaped = path.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
statusEl.innerHTML = `${prefix} <code class="font-mono">${escaped}</code>`;
}
function connectConsole() {
const hostSelect = document.getElementById('console-host-select');
const host = hostSelect.value;
@@ -155,7 +157,7 @@ async function loadFile() {
return;
}
statusEl.textContent = `Loading ${path}...`;
setEditorStatus('Loading', path + '...');
try {
const response = await fetch(`/api/console/file?host=${encodeURIComponent(currentHost)}&path=${encodeURIComponent(path)}`);
@@ -172,7 +174,7 @@ async function loadFile() {
consoleEditor.setValue(data.content);
monaco.editor.setModelLanguage(consoleEditor.getModel(), language);
currentFilePath = path; // Only set after content is loaded
statusEl.textContent = `Loaded: ${path}`;
setEditorStatus('Loaded:', path);
} else {
statusEl.textContent = 'Editor not ready';
}
@@ -199,7 +201,7 @@ async function saveFile() {
return;
}
statusEl.textContent = `Saving ${currentFilePath}...`;
setEditorStatus('Saving', currentFilePath + '...');
try {
const content = consoleEditor.getValue();
@@ -215,7 +217,7 @@ async function saveFile() {
return;
}
statusEl.textContent = `Saved: ${currentFilePath}`;
setEditorStatus('Saved:', currentFilePath);
} catch (e) {
statusEl.textContent = `Error: ${e.message}`;
}

View File

@@ -28,8 +28,8 @@
</dialog>
<!-- Floating button to open command palette -->
<button id="cmd-fab" class="btn btn-circle glass shadow-lg fixed bottom-6 right-6 z-50 hover:ring hover:ring-base-content/50" title="Command Palette (⌘K)">
<span class="flex items-center gap-0.5 text-sm font-semibold">
<span class="opacity-70"></span><span>K</span>
</span>
<button id="cmd-fab" class="fixed bottom-6 right-6 z-50" title="Command Palette (⌘K)">
<div class="cmd-fab-inner">
<span>⌘ + K</span>
</div>
</button>

View File

@@ -9,12 +9,13 @@
{% endmacro %}
{# Collapsible section #}
{% macro collapse(title, id=None, checked=False, badge=None, icon=None) %}
{% macro collapse(title, id=None, checked=False, badge=None, icon=None, subtitle=None) %}
<div class="collapse collapse-arrow bg-base-100 shadow mb-4">
<input type="checkbox" {% if id %}id="{{ id }}"{% endif %} {% if checked %}checked{% endif %} />
<div class="collapse-title font-medium flex items-center gap-2">
<div class="collapse-title font-semibold flex items-center gap-2">
{% if icon %}{{ icon }}{% endif %}{{ title }}
{% if badge %}<code class="text-xs ml-2 opacity-60">{{ badge }}</code>{% endif %}
{% if subtitle %}<span class="text-xs opacity-50 font-normal">{{ subtitle }}</span>{% endif %}
</div>
<div class="collapse-content">
{{ caller() }}

View File

@@ -11,10 +11,10 @@
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-base-content/60 px-3 py-1">Services <span class="opacity-50" id="sidebar-count">({{ services | length }})</span></h4>
<div class="px-2 mb-2 flex flex-col gap-1">
<label class="input input-xs input-bordered flex items-center gap-2 bg-base-200">
<label class="input input-xs flex items-center gap-2 bg-base-200">
{{ search(14) }}<input type="text" id="sidebar-filter" placeholder="Filter..." onkeyup="sidebarFilter()" />
</label>
<select id="sidebar-host-select" class="select select-xs select-bordered bg-base-200 w-full" onchange="sidebarFilter()">
<select id="sidebar-host-select" class="select select-xs bg-base-200 w-full" onchange="sidebarFilter()">
<option value="">All hosts</option>
{% for h in hosts %}<option value="{{ h }}">{{ h }}</option>{% endfor %}
</select>

View File

@@ -261,7 +261,9 @@ async def terminal_websocket(websocket: WebSocket, task_id: str) -> None:
await websocket.accept()
if task_id not in tasks:
await websocket.send_text(f"{RED}Error: Task not found{RESET}{CRLF}")
await websocket.send_text(
f"{DIM}Task not found (expired or container restarted).{RESET}{CRLF}"
)
await websocket.close(code=4004)
return