feat: service exec console

POST /services/:id/exec runs a one-off sh -c command inside the
service's container via ExecRun (30s ceiling, 64KB output cap), with
the standard service→project ownership check. Console section on the
service detail page submits commands and renders captured output with
exit codes.

Verified live: real exec output, stderr + exit code propagation, 404 on
foreign service, 401 unauthenticated, 400 on empty command.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 18:34:58 +02:00
1 parent 6d237eccf7
commit f357c7adee
7 files changed
+317 -4

No files matched your search

@@ -26,6 +26,7 @@ import {
deleteScalingPolicy,
getServiceScalingState,
manualScaleService,
execInService,
rollbackDeployment,
updateServiceVariables,
type CronJobEntity,
@@ -60,9 +61,10 @@ import {
ChevronDown,
GitPullRequest,
ExternalLink,
Terminal,
} from 'lucide-react';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'settings';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings';
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
@@ -72,6 +74,7 @@ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Act
{ key: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'scaling', label: 'Scaling', icon: Layers },
{ key: 'console', label: 'Console', icon: Terminal },
{ key: 'settings', label: 'Settings', icon: Settings },
];
@@ -283,6 +286,22 @@ export function ServiceDetailPage() {
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
onSuccess: invalidateScaling,
});
// --- Console ---
const [consoleInput, setConsoleInput] = useState('');
const [consoleHistory, setConsoleHistory] = useState<
Array<{ command: string; output: string; exitCode: number; error: string }>
>([]);
const execMutation = useMutation({
mutationFn: (command: string) => execInService(serviceId, command),
onSuccess: (result, command) => {
setConsoleHistory((prev) => [
{ command, output: result.output ?? '', exitCode: result.exit_code ?? 0, error: result.error ?? '' },
...prev,
]);
setConsoleInput('');
},
});
const triggerCronMutation = useMutation({
mutationFn: (id: string) => triggerCronJob(id),
onSuccess: () => {
@@ -1660,6 +1679,75 @@ export function ServiceDetailPage() {
</div>
)}
{activeSection === 'console' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Terminal size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2>
<p className="text-sm text-[var(--text-secondary)]">One-off commands via docker exec — 30s limit, 64KB output cap</p>
</div>
</div>
<form
className="flex items-center gap-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
const cmd = consoleInput.trim();
if (cmd && !execMutation.isPending) execMutation.mutate(cmd);
}}
>
<span className="text-sm font-mono text-[var(--accent-primary)]">$</span>
<input
value={consoleInput}
onChange={(e) => setConsoleInput(e.target.value)}
placeholder="e.g. env | sort | head -20"
disabled={execMutation.isPending}
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
/>
<button
type="submit"
disabled={execMutation.isPending || !consoleInput.trim()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{execMutation.isPending ? 'Running…' : 'Run'}
</button>
</form>
{execMutation.isError && (
<p className="mb-4 text-xs text-[var(--error)]">
{(execMutation.error as Error)?.message ?? 'Command failed'}
</p>
)}
{consoleHistory.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
Commands run inside the service's running container as its default user. No interactive shell — stdout/stderr is captured and returned.
</p>
) : (
<div className="space-y-3">
{consoleHistory.map((entry, i) => (
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-primary)] bg-[var(--bg-secondary)] p-3">
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
{entry.error ? 'error' : `exit ${entry.exitCode}`}
</span>
</div>
{(entry.output || entry.error) && (
<pre className="text-xs font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-all max-h-64 overflow-y-auto">
{entry.output}{entry.error ? `\n${entry.error}` : ''}
</pre>
)}
</div>
))}
</div>
)}
</div>
)}
{activeSection === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
+81
View File
@@ -809,6 +809,87 @@ export interface paths {
patch?: never;
trace?: never;
};
"/services/{id}/exec": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Execute command in service container
* @description Runs a one-off shell command via docker exec and returns captured output. 30s ceiling, 64KB output cap. For debugging only.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Service ID */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Shell command (executed via sh -c) */
command: string;
};
};
};
responses: {
/** @description Command executed (check exit_code/error for failure) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
output?: string;
exit_code?: number;
error?: string;
};
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description No running container for this service */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Docker unavailable */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/deployments/{id}/logs": {
parameters: {
query?: never;
+9
View File
@@ -1658,3 +1658,12 @@ export async function updateVulnerability(id: string, status: 'open' | 'resolved
export async function getSecurityMetrics(projectId: string): Promise<SecurityMetrics> {
return requestJson<SecurityMetrics>(`/projects/${encodeURIComponent(projectId)}/security/metrics`);
}
export type ExecResult = { output?: string; exit_code?: number; error?: string };
export async function execInService(serviceId: string, command: string): Promise<ExecResult> {
return requestJson<ExecResult>(`/services/${encodeURIComponent(serviceId)}/exec`, {
method: 'POST',
body: JSON.stringify({ command }),
});
}