diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 5d064ba..dff50be 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -159,6 +159,7 @@ public static class SessionEndpoint var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); var command = body.TryGetProperty("command", out var c) ? c.GetString() : null; + var mode = body.TryGetProperty("mode", out var m) ? m.GetString() ?? "background" : "background"; if (string.IsNullOrWhiteSpace(command)) { @@ -169,35 +170,97 @@ public static class SessionEndpoint var workingDir = !string.IsNullOrWhiteSpace(session.Workspace) ? session.Workspace : "/tmp"; - try + if (mode == "foreground") { - var scriptPath = $"/tmp/gott-exec-{Guid.NewGuid():N}.sh"; - await System.IO.File.WriteAllTextAsync(scriptPath, $"#!/bin/bash\ncd {workingDir}\n{command}\nrm -f {scriptPath}\n"); - - var psi = new ProcessStartInfo + // Synchron ausführen, auf Ergebnis warten + try { - FileName = "systemd-run", - Arguments = $"--scope --quiet bash {scriptPath}", - WorkingDirectory = workingDir, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + var psi = new ProcessStartInfo + { + FileName = "/bin/bash", + WorkingDirectory = workingDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; - var process = Process.Start(psi); - if (process != null) + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) + { + ctx.Response.StatusCode = 500; + await ctx.Response.WriteAsJsonAsync(new { error = "Prozess konnte nicht gestartet werden" }); + return; + } + + await proc.StandardInput.WriteAsync(command); + proc.StandardInput.Close(); + + using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + await proc.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + proc.Kill(entireProcessTree: true); + await ctx.Response.WriteAsJsonAsync(new { status = "timeout", output = "Timeout nach 30 Sekunden", exitCode = -1 }); + return; + } + + var stdout = await proc.StandardOutput.ReadToEndAsync(); + var stderr = await proc.StandardError.ReadToEndAsync(); + + var output = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(stdout)) output.Append(stdout); + if (!string.IsNullOrEmpty(stderr)) + { + if (output.Length > 0) output.Append("\n--- stderr ---\n"); + output.Append(stderr); + } + + await ctx.Response.WriteAsJsonAsync(new { status = "completed", output = output.ToString(), exitCode = proc.ExitCode }); + } + catch (Exception ex) { - process.WaitForExit(500); + ctx.Response.StatusCode = 500; + await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } - - ctx.Response.StatusCode = 202; - await ctx.Response.WriteAsJsonAsync(new { status = "started", command = command }); } - catch (Exception ex) + else { - ctx.Response.StatusCode = 500; - await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); + // Fire-and-forget via systemd-run + try + { + var scriptPath = $"/tmp/gott-exec-{Guid.NewGuid():N}.sh"; + await System.IO.File.WriteAllTextAsync(scriptPath, $"#!/bin/bash\ncd {workingDir}\n{command}\nrm -f {scriptPath}\n"); + + var psi = new ProcessStartInfo + { + FileName = "systemd-run", + Arguments = $"--scope --quiet bash {scriptPath}", + WorkingDirectory = workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + var process = Process.Start(psi); + if (process != null) + { + process.WaitForExit(500); + } + + ctx.Response.StatusCode = 202; + await ctx.Response.WriteAsJsonAsync(new { status = "started", command = command }); + } + catch (Exception ex) + { + ctx.Response.StatusCode = 500; + await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); + } } } diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 294042b..576ce78 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -449,3 +449,56 @@ opacity: var(--semi-transparent); padding: 2em; } + +/* Command Output Modal */ + +.cmd-output-modal { + max-width: 800px; +} + +.cmd-output-meta { + display: flex; + align-items: center; + gap: var(--badge-spacing); + padding-bottom: var(--box-spacing); + border-bottom: var(--border-width) solid var(--f1-border); + margin-bottom: var(--box-spacing); + flex-wrap: wrap; +} + +.cmd-output-meta code { + flex: 1; + font-family: var(--mono); + font-size: var(--mono-size); + opacity: var(--semi-transparent); +} + +.cmd-exit-code { + padding: var(--badge-padding); + border-radius: var(--border-radius); + font-size: var(--font-size); + background: var(--f3-bg); + border: var(--border-width) solid var(--f3-border); + white-space: nowrap; +} + +.cmd-exit-error { + color: var(--dangerous); + border-color: var(--dangerous); +} + +.cmd-output-content { + font-family: var(--mono); + font-size: var(--mono-size); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + max-height: 60vh; + overflow: auto; +} + +/* Message-Typ Button */ + +.cmd-type-message { + font-style: italic; +} diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css index ca17bf0..4b6e7c4 100644 --- a/wwwroot/css/form.css +++ b/wwwroot/css/form.css @@ -75,6 +75,11 @@ overflow: hidden; } +.custom-command-fields .cmd-type { + flex: 0 0 auto; + width: 10em; +} + .custom-command-fields .cmd-name { flex: 2; } diff --git a/wwwroot/index.html b/wwwroot/index.html index 7e01251..5d8c9be 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -86,7 +86,7 @@ - +
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 6560c8e..7d0ddf5 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -104,12 +104,14 @@ async function apiUpdateSession(sessionId, data) { return resp.json(); } -async function apiExecCommand(sessionId, command) { - return fetch(`/api/sessions/${sessionId}/exec`, { +async function apiExecCommand(sessionId, command, mode = 'background') { + const resp = await fetch(`/api/sessions/${sessionId}/exec`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command }) + body: JSON.stringify({ command, mode }) }); + if (mode === 'foreground') return resp.json(); + return resp; } async function apiGetSystemPrompt(sessionId) { diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index df775bc..418262f 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -65,22 +65,37 @@ registerPage('chat', { try { cmds = JSON.parse(session.customCommands || '[]') || []; } catch(e) {} cmds.forEach(cmd => { if (!cmd.name || !cmd.command) return; + const type = cmd.type || 'background'; const btn = document.createElement('button'); btn.className = 'custom-cmd-btn'; + if (type === 'message') btn.classList.add('cmd-type-message'); btn.textContent = cmd.name; - btn.title = cmd.command; + btn.title = type === 'message' ? cmd.command : `[${type}] ${cmd.command}`; btn.addEventListener('click', async () => { + if (type === 'message') { + // Nachricht in den Chat senden + const inputEl = document.getElementById('input'); + if (inputEl) { + inputEl.value = cmd.command; + inputEl.dispatchEvent(new Event('input')); + } + sendMessage(); + return; + } btn.disabled = true; btn.classList.add('running'); try { - await apiExecCommand(sessionId, cmd.command); + if (type === 'foreground') { + const result = await apiExecCommand(sessionId, cmd.command, 'foreground'); + showCommandOutputModal(cmd.name, cmd.command, result); + } else { + await apiExecCommand(sessionId, cmd.command, 'background'); + } } catch(e) { console.error('Exec failed:', e); } - setTimeout(() => { - btn.disabled = false; - btn.classList.remove('running'); - }, 1000); + btn.disabled = false; + btn.classList.remove('running'); }); cmdBar.appendChild(btn); }); diff --git a/wwwroot/js/pages/session-page.js b/wwwroot/js/pages/session-page.js index b89a52a..dd7b23c 100644 --- a/wwwroot/js/pages/session-page.js +++ b/wwwroot/js/pages/session-page.js @@ -3,16 +3,27 @@ let _sessionImageData = null; let _sessionPasteHandler = null; -function _createCommandRow(container, name = '', command = '') { +function _createCommandRow(container, name = '', command = '', type = 'background') { const item = document.createElement('div'); item.className = 'custom-command-item'; item.innerHTML = `
+ - +
`; + const typeSelect = item.querySelector('.cmd-type'); + typeSelect.value = type; + typeSelect.addEventListener('change', () => { + const cmdInput = item.querySelector('.cmd-command'); + cmdInput.placeholder = typeSelect.value === 'message' ? 'Nachricht...' : 'Befehl (z.B. ./deploy.sh)'; + }); item.querySelector('.remove-cmd-btn').addEventListener('click', () => item.remove()); container.appendChild(item); } @@ -27,7 +38,8 @@ function _getCommandsFromUI(container) { for (const item of items) { const name = item.querySelector('.cmd-name').value.trim(); const command = item.querySelector('.cmd-command').value.trim(); - if (name || command) commands.push({ name, command }); + const type = item.querySelector('.cmd-type').value; + if (name || command) commands.push({ name, command, type }); } return commands.length > 0 ? commands : null; } @@ -83,7 +95,7 @@ registerPage('session', { ? JSON.parse(session.customCommands) : session.customCommands; for (const cmd of cmds) { - _createCommandRow(commandsList, cmd.name || '', cmd.command || ''); + _createCommandRow(commandsList, cmd.name || '', cmd.command || '', cmd.type || 'background'); } } catch (e) { /* ignore parse errors */ } } diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index efd6926..87d8c3a 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -461,6 +461,61 @@ async function showSystemPromptModal(sessionId) { } } +// ── Command Output Modal ── + +function showCommandOutputModal(name, command, result) { + const overlay = document.createElement('div'); + overlay.className = 'sp-overlay'; + + const modal = document.createElement('div'); + modal.className = 'sp-modal cmd-output-modal'; + + // Header + const header = document.createElement('div'); + header.className = 'sp-header'; + const title = document.createElement('span'); + title.textContent = name; + const closeBtn = document.createElement('button'); + closeBtn.innerHTML = ''; + closeBtn.className = 'sp-close icon-btn'; + header.appendChild(title); + header.appendChild(closeBtn); + + // Body + const body = document.createElement('div'); + body.className = 'sp-body'; + + const exitCode = result.exitCode != null ? result.exitCode : '?'; + const isError = exitCode !== 0; + const statusText = result.status === 'timeout' ? 'Timeout' : (isError ? `Exit ${exitCode}` : `Exit ${exitCode}`); + + const meta = document.createElement('div'); + meta.className = 'cmd-output-meta'; + meta.innerHTML = `${esc(command)} ${esc(statusText)}`; + body.appendChild(meta); + + const output = document.createElement('pre'); + output.className = 'cmd-output-content'; + output.textContent = result.output || '(kein Output)'; + body.appendChild(output); + + modal.appendChild(header); + modal.appendChild(body); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Schließen + const close = () => overlay.remove(); + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + const onKey = (e) => { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } + }; + document.addEventListener('keydown', onKey); +} + // ── Meta Bar ── function setMeta(text) {