mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
967d68b14a | ||
|
|
b7614aeab7 | ||
|
|
d931784935 | ||
|
|
4755065229 | ||
|
|
e86bbf7681 | ||
|
|
be136eb916 | ||
|
|
78a223878f | ||
|
|
f5be23d626 | ||
|
|
3bdc483c2a | ||
|
|
3a3591a0f7 | ||
|
|
7f8ea49d7f | ||
|
|
1e67bde96c |
@@ -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.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -12,19 +13,35 @@ from pydantic import ValidationError
|
||||
|
||||
from compose_farm.web.deps import STATIC_DIR, get_config
|
||||
from compose_farm.web.routes import actions, api, pages
|
||||
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
|
||||
async def _task_cleanup_loop() -> None:
|
||||
"""Periodically clean up stale completed tasks."""
|
||||
while True:
|
||||
await asyncio.sleep(TASK_TTL_SECONDS // 2) # Run every 5 minutes
|
||||
cleanup_stale_tasks()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
# Startup: pre-load config (ignore errors - handled per-request)
|
||||
with suppress(ValidationError, FileNotFoundError):
|
||||
get_config()
|
||||
|
||||
# Start background cleanup task
|
||||
cleanup_task = asyncio.create_task(_task_cleanup_loop())
|
||||
|
||||
yield
|
||||
# Shutdown: nothing to clean up
|
||||
|
||||
# Shutdown: cancel cleanup task
|
||||
cleanup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await cleanup_task
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -17,6 +17,10 @@ const editors = {};
|
||||
let monacoLoaded = false;
|
||||
let monacoLoading = false;
|
||||
|
||||
// LocalStorage key prefix for active tasks (scoped by page)
|
||||
const TASK_KEY_PREFIX = 'cf_task:';
|
||||
const getTaskKey = () => TASK_KEY_PREFIX + window.location.pathname;
|
||||
|
||||
// Language detection from file path
|
||||
const LANGUAGE_MAP = {
|
||||
'yaml': 'yaml', 'yml': 'yaml',
|
||||
@@ -131,11 +135,18 @@ function initTerminal(elementId, taskId) {
|
||||
const { term, fitAddon } = createTerminal(container);
|
||||
const ws = createWebSocket(`/ws/terminal/${taskId}`);
|
||||
|
||||
const taskKey = getTaskKey();
|
||||
ws.onopen = () => {
|
||||
term.write(`${ANSI.DIM}[Connected]${ANSI.RESET}${ANSI.CRLF}`);
|
||||
setTerminalLoading(true);
|
||||
localStorage.setItem(taskKey, taskId);
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
term.write(event.data);
|
||||
if (event.data.includes('[Done]') || event.data.includes('[Failed]')) {
|
||||
localStorage.removeItem(taskKey);
|
||||
}
|
||||
};
|
||||
ws.onmessage = (event) => term.write(event.data);
|
||||
ws.onclose = () => setTerminalLoading(false);
|
||||
ws.onerror = (error) => {
|
||||
term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`);
|
||||
@@ -407,26 +418,57 @@ function initPage() {
|
||||
initSaveButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect to an active task from localStorage
|
||||
*/
|
||||
function tryReconnectToTask() {
|
||||
const taskId = localStorage.getItem(getTaskKey());
|
||||
if (!taskId) return;
|
||||
|
||||
// Wait for xterm to be loaded
|
||||
const tryInit = (attempts) => {
|
||||
if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') {
|
||||
expandTerminal();
|
||||
initTerminal('terminal-output', taskId);
|
||||
} else if (attempts > 0) {
|
||||
setTimeout(() => tryInit(attempts - 1), 100);
|
||||
}
|
||||
};
|
||||
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();
|
||||
|
||||
// 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'});
|
||||
}
|
||||
// Try to reconnect to any active task
|
||||
tryReconnectToTask();
|
||||
});
|
||||
|
||||
// Re-initialize after HTMX swaps main content
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'main-content') {
|
||||
initPage();
|
||||
// Try to reconnect when navigating back to dashboard
|
||||
tryReconnectToTask();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -507,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 });
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from compose_farm.executor import build_ssh_command
|
||||
@@ -25,6 +26,25 @@ CRLF = "\r\n"
|
||||
# In-memory task registry
|
||||
tasks: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# How long to keep completed tasks (10 minutes)
|
||||
TASK_TTL_SECONDS = 600
|
||||
|
||||
|
||||
def cleanup_stale_tasks() -> int:
|
||||
"""Remove tasks that completed more than TASK_TTL_SECONDS ago.
|
||||
|
||||
Returns the number of tasks removed.
|
||||
"""
|
||||
cutoff = time.time() - TASK_TTL_SECONDS
|
||||
stale = [
|
||||
tid
|
||||
for tid, task in tasks.items()
|
||||
if task.get("completed_at") and task["completed_at"] < cutoff
|
||||
]
|
||||
for tid in stale:
|
||||
tasks.pop(tid, None)
|
||||
return len(stale)
|
||||
|
||||
|
||||
async def stream_to_task(task_id: str, message: str) -> None:
|
||||
"""Send a message to a task's output buffer."""
|
||||
@@ -77,10 +97,12 @@ async def run_cli_streaming(
|
||||
|
||||
exit_code = await process.wait()
|
||||
tasks[task_id]["status"] = "completed" if exit_code == 0 else "failed"
|
||||
tasks[task_id]["completed_at"] = time.time()
|
||||
|
||||
except Exception as e:
|
||||
await stream_to_task(task_id, f"{RED}Error: {e}{RESET}{CRLF}")
|
||||
tasks[task_id]["status"] = "failed"
|
||||
tasks[task_id]["completed_at"] = time.time()
|
||||
|
||||
|
||||
def _is_self_update(service: str, command: str) -> bool:
|
||||
@@ -103,29 +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
|
||||
remote_cmd = f"cf {' '.join(args)} --config={config.config_path}"
|
||||
cf_cmd = f"cf {' '.join(args)} --config={config.config_path}"
|
||||
log_file = "/tmp/cf-self-update.log" # noqa: S108
|
||||
|
||||
# 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} {remote_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
|
||||
ssh_args = build_ssh_command(host, remote_cmd)
|
||||
# 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
|
||||
@@ -137,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")
|
||||
@@ -146,11 +184,23 @@ 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:
|
||||
await stream_to_task(task_id, f"{RED}Error: {e}{RESET}{CRLF}")
|
||||
tasks[task_id]["status"] = "failed"
|
||||
tasks[task_id]["completed_at"] = time.time()
|
||||
|
||||
|
||||
async def run_compose_streaming(
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -285,5 +287,4 @@ async def terminal_websocket(websocket: WebSocket, task_id: str) -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
tasks.pop(task_id, None)
|
||||
# Task stays in memory for reconnection; cleanup_stale_tasks() handles expiry
|
||||
|
||||
Reference in New Issue
Block a user