feat: interactive terminal — WS-bridged docker exec PTY (#46)

GET /services/:id/terminal upgrades to a WebSocket attached to a
`docker exec` PTY in the service's running replica. JSON frames both
ways: stdin/resize out, stdout/exit/error in. Shell probe tries each
candidate (bash→sh) since `command -v` needs a shell to run; distroless
images get a clean "no shell" error instead of an OCI failure dump.

- docker.Client gains ExecAttach + ExecResize wrappers
- Frontend: xterm.js replaces the one-off console section — live PTY,
  fit-addon resizes forwarded over the socket
- CLI: `containr shell <id>` bridges the local TTY (raw mode, SIGWINCH
  resize forwarding); `containr exec` keeps the one-off command path
- Access mirrors service logs: project manage scope required

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 11:13:34 +02:00
1 parent 5eac4a382b
commit 612520bbff
13 files changed
+570 -75

No files matched your search

+6 -3
View File
@@ -307,9 +307,12 @@ and wired refs, one click or one CLI call.
UI at `/operations` (Operate nav) with live refetch; `containr
operations` CLI + `containr_operations_get` MCP. Remaining: agent
commands once node fleet lands; flush/stuck-job tools.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra
container).
- [x] **Interactive terminal** — `GET /services/:id/terminal` upgrades
to a WebSocket bridged to a `docker exec` PTY (bash→sh fallback,
`TERM=xterm-256color`, resize frames). xterm.js Console section on the
service page; `containr shell <id>` bridges a local TTY; distroless
images get a clean "no shell" error. Remaining: `nodes/:id/terminal`
via agent.
- [x] **Team invites** — `POST /admin/invites` returns a one-time
link (sha256-hashed token at rest, optional email binding, TTL);
public `GET /auth/invites/:token` validates and
+3
View File
@@ -388,6 +388,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
// Interactive terminal — WebSocket → docker exec pty.
authed.GET("/services/:id/terminal", handleServiceTerminal)
// Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables)
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/docker"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// Interactive terminal: a WebSocket bridged to a `docker exec` pty in the
// service's container. Protocol is JSON text frames both ways:
// client → server: {"type":"stdin","data":"..."} | {"type":"resize","cols":N,"rows":N}
// server → client: {"type":"stdout","data":"..."} | {"type":"exit","code":N} | {"type":"error","message":"..."}
// Shell preference: $SHELL, then bash, then sh.
type terminalFrame struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Cols uint `json:"cols,omitempty"`
Rows uint `json:"rows,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
func handleServiceTerminal(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid service id")
return
}
projectID, found := serviceProjectID(db, serviceID)
if !found {
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
return
}
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
respondError(c, http.StatusNotFound, "NOT_FOUND", "service not found")
return
}
dockerClient, _ := c.Get("docker_client")
client, ok := dockerClient.(*docker.Client)
if !ok || client == nil {
respondError(c, http.StatusServiceUnavailable, "DOCKER_UNAVAILABLE", "Docker is not configured")
return
}
ctx := c.Request.Context()
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", "containr.service="+serviceID.String()),
), false)
if err != nil || len(containers) == 0 {
respondError(c, http.StatusConflict, "NO_CONTAINER", "service has no running container")
return
}
// Prefer a running container. With several matches, deterministically
// pick the first `containr-<service>-<n>` replica by name — that is the
// actual workload naming; auxiliary containers get ignored.
target := ""
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
for _, ctr := range containers {
if ctr.State == "running" && strings.HasPrefix(ctr.Names[0], "/containr-") {
target = ctr.ID
break
}
}
if target == "" {
for _, ctr := range containers {
if ctr.State == "running" {
target = ctr.ID
break
}
}
}
if target == "" {
target = containers[0].ID
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
send := func(f terminalFrame) bool {
return conn.WriteJSON(f) == nil
}
// Pick a shell that actually exists — distroless images may have none.
// `command -v` can't probe without a shell, so start each candidate and
// watch for the OCI "no such file" failure.
var stream types.HijackedResponse
var execID string
attached := false
for _, candidate := range []string{"/bin/bash", "/bin/sh", "sh"} {
exec, err := client.ExecCreate(ctx, target, docker.ExecConfig{
Cmd: []string{candidate},
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"},
})
if err != nil {
continue
}
s, err := client.ExecAttach(ctx, exec.ID)
if err != nil {
continue
}
if execStartFailed(ctx, client, exec.ID, s) {
s.Close()
continue
}
stream, execID, attached = s, exec.ID, true
break
}
if !attached {
send(terminalFrame{Type: "error", Message: "no shell available in this container image"})
return
}
defer stream.Close()
// The client sends a resize frame with its real size right after
// connecting; start at a sane default meanwhile.
_ = client.ExecResize(ctx, execID, 80, 24)
var wsWrite sync.Mutex
writeJSON := func(f terminalFrame) {
wsWrite.Lock()
defer wsWrite.Unlock()
_ = conn.WriteJSON(f)
}
// Container → client.
go func() {
buf := make([]byte, 8192)
for {
n, err := stream.Reader.Read(buf)
if n > 0 {
writeJSON(terminalFrame{Type: "stdout", Data: string(buf[:n])})
}
if err != nil {
if err != io.EOF {
log.Printf("terminal: exec stream ended: %v", err)
}
inspect, ierr := client.ExecInspect(context.Background(), execID)
code := 0
if ierr == nil {
code = inspect.ExitCode
}
writeJSON(terminalFrame{Type: "exit", Code: code})
_ = conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session ended"),
time.Now().Add(time.Second))
return
}
}
}()
// Client → container.
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var f terminalFrame
if err := json.Unmarshal(raw, &f); err != nil {
continue
}
switch f.Type {
case "stdin":
if _, err := stream.Conn.Write([]byte(f.Data)); err != nil {
return
}
case "resize":
_ = client.ExecResize(ctx, execID, f.Cols, f.Rows)
case "ping":
writeJSON(terminalFrame{Type: "pong"})
}
}
}
// execStartFailed reports whether an attached exec failed to launch
// (missing binary → non-zero exit, not running). The OCI failure takes a
// moment to surface, so a short grace period elapses before judging.
func execStartFailed(ctx context.Context, client *docker.Client, execID string, _ types.HijackedResponse) bool {
select {
case <-ctx.Done():
return true
case <-time.After(500 * time.Millisecond):
}
inspect, err := client.ExecInspect(ctx, execID)
if err != nil {
return false
}
return !inspect.Running && inspect.ExitCode != 0
}
+113
View File
@@ -0,0 +1,113 @@
package commands
import (
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// ShellCmd opens an interactive shell inside a service's running container.
var ShellCmd = &cobra.Command{
Use: "shell <service-id>",
Short: "Interactive shell inside the service container",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
serviceID := args[0]
cli, err := client()
if err != nil {
return err
}
base := strings.TrimRight(cli.BaseURL, "/")
wsURL := strings.Replace(base, "http", "ws", 1) + "/services/" + serviceID + "/terminal"
header := http.Header{}
header.Set("Authorization", "Bearer "+cli.Token)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return fmt.Errorf("terminal: %w", err)
}
defer conn.Close()
fd := int(os.Stdin.Fd())
raw := term.IsTerminal(fd)
var oldState *term.State
if raw {
oldState, err = term.MakeRaw(fd)
if err == nil {
defer term.Restore(fd, oldState)
}
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
// Forward SIGWINCH resizes.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
defer signal.Stop(sigCh)
go func() {
for range sigCh {
if w, h, err := term.GetSize(fd); err == nil {
_ = conn.WriteJSON(map[string]interface{}{"type": "resize", "cols": w, "rows": h})
}
}
}()
}
done := make(chan struct{})
// stdin → ws
go func() {
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
if werr := conn.WriteJSON(map[string]interface{}{
"type": "stdin", "data": string(buf[:n]),
}); werr != nil {
return
}
}
if err != nil {
return
}
}
}()
// ws → stdout
go func() {
defer close(done)
for {
var frame struct {
Type string `json:"type"`
Data string `json:"data"`
Code int `json:"code"`
Message string `json:"message"`
}
if err := conn.ReadJSON(&frame); err != nil {
return
}
switch frame.Type {
case "stdout":
_, _ = os.Stdout.WriteString(frame.Data)
case "exit":
fmt.Fprintf(os.Stderr, "\r\n[session ended — exit %d]\r\n", frame.Code)
return
case "error":
fmt.Fprintf(os.Stderr, "\r\nerror: %s\r\n", frame.Message)
return
}
}
}()
select {
case <-done:
case <-time.After(24 * time.Hour):
}
return nil
},
}
+1
View File
@@ -90,6 +90,7 @@ func init() {
rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd)
rootCmd.AddCommand(commands.ActivityCmd)
rootCmd.AddCommand(commands.ShellCmd)
rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd)
+15
View File
@@ -516,6 +516,21 @@ func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string)
return buf.String(), inspect.ExitCode, nil
}
// ExecAttach attaches to a running exec instance, returning the hijacked
// bidirectional stream. With Tty enabled the stream carries raw pty data
// (no stdcopy multiplexing).
func (c *Client) ExecAttach(ctx context.Context, execID string) (types.HijackedResponse, error) {
return c.cli.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
}
// ExecResize resizes the pty of a running exec instance.
func (c *Client) ExecResize(ctx context.Context, execID string, cols, rows uint) error {
return c.cli.ContainerExecResize(ctx, execID, container.ResizeOptions{
Height: rows,
Width: cols,
})
}
// GetImageInfo returns information about a Docker image
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
images, err := c.cli.ImageList(ctx, image.ListOptions{})
+17
View File
@@ -33,6 +33,8 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"axios": "^1.7.9",
@@ -4262,6 +4264,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xyflow/react": {
"version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
+2
View File
@@ -44,6 +44,8 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
"@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"axios": "^1.7.9",
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { Loader2 } from 'lucide-react';
import { getApiBaseUrl } from '@/lib/api-client';
type Frame = {
type: 'stdin' | 'resize' | 'stdout' | 'exit' | 'error' | 'ping' | 'pong';
data?: string;
cols?: number;
rows?: number;
code?: number;
message?: string;
};
// Interactive shell: WebSocket → docker exec pty in the service container.
export function ServiceTerminal({ serviceId }: { serviceId: string }) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<'connecting' | 'live' | 'ended' | 'error'>('connecting');
useEffect(() => {
if (!hostRef.current) return;
const term = new Terminal({
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
fontSize: 13,
cursorBlink: true,
convertEol: false,
theme: {
background: '#0a0a0c',
foreground: '#d4d4d8',
cursor: '#a1a1aa',
selectionBackground: '#3f3f46',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
const wsUrl = getApiBaseUrl().replace(/^http/, 'ws');
const ws = new WebSocket(`${wsUrl}/services/${serviceId}/terminal`);
ws.onopen = () => {
setStatus('live');
const { cols, rows } = term;
const frame: Frame = { type: 'resize', cols, rows };
ws.send(JSON.stringify(frame));
};
ws.onmessage = (ev) => {
let f: Frame;
try {
f = JSON.parse(ev.data);
} catch {
return;
}
if (f.type === 'stdout' && f.data) term.write(f.data);
else if (f.type === 'exit') {
setStatus('ended');
term.write(`\r\n\x1b[90m[session ended — exit ${f.code ?? 0}]\x1b[0m\r\n`);
} else if (f.type === 'error') {
setStatus('error');
term.write(`\r\n\x1b[31m${f.message ?? 'terminal error'}\x1b[0m\r\n`);
}
};
ws.onerror = () => setStatus((s) => (s === 'live' ? s : 'error'));
ws.onclose = () => setStatus((s) => (s === 'error' ? s : 'ended'));
const dataSub = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stdin', data } satisfies Frame));
}
});
const observer = new ResizeObserver(() => {
fit.fit();
if (ws.readyState === WebSocket.OPEN) {
const { cols, rows } = term;
ws.send(JSON.stringify({ type: 'resize', cols, rows } satisfies Frame));
}
});
observer.observe(hostRef.current);
return () => {
observer.disconnect();
dataSub.dispose();
ws.close();
term.dispose();
};
}, [serviceId]);
return (
<div className="relative">
{status === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0a0c]/80 rounded-[var(--radius-md)]">
<Loader2 size={18} className="animate-spin text-[var(--accent-primary)]" />
</div>
)}
<div
ref={hostRef}
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden p-2"
style={{ background: '#0a0a0c', height: 420 }}
/>
</div>
);
}
@@ -26,7 +26,6 @@ import {
deleteScalingPolicy,
getServiceScalingState,
manualScaleService,
execInService,
rollbackDeployment,
cancelDeployment,
updateServiceVariables,
@@ -59,6 +58,7 @@ import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
import { ServiceTerminal } from '@/features/service/components/ServiceTerminal';
import {
Activity,
FileText,
@@ -338,21 +338,6 @@ export function ServiceDetailPage() {
},
});
// --- 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: () => {
@@ -1927,64 +1912,10 @@ export function ServiceDetailPage() {
</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>
<p className="text-sm text-[var(--text-secondary)]">Interactive shell inside the service container</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(--surface-muted)] border border-[var(--border-subtle)] 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-on)] 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-subtle)] bg-[var(--surface-muted)] 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>
)}
<ServiceTerminal serviceId={serviceId} />
</div>
)}
+60
View File
@@ -1056,6 +1056,66 @@ export interface paths {
patch?: never;
trace?: never;
};
"/services/{id}/terminal": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Interactive terminal (WebSocket)
* @description Upgrades to a WebSocket bridged to a `docker exec` PTY in the service's running container (bash if present, else sh). Cookie/bearer auth as usual. Client→server JSON frames: `{"type":"stdin","data":"..."}`, `{"type":"resize","cols":N,"rows":N}`. Server→client frames: `{"type":"stdout","data":"..."}`, `{"type":"exit","code":N}`, `{"type":"error","message":"..."}`.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Switching protocols — WebSocket session established */
101: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found or not visible */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service has no running container */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Docker unavailable */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/internal/wake/{id}": {
parameters: {
query?: never;
+29
View File
@@ -720,6 +720,35 @@ paths:
items:
type: string
/services/{id}/terminal:
get:
tags:
- Services
summary: Interactive terminal (WebSocket)
description: >-
Upgrades to a WebSocket bridged to a `docker exec` PTY in the
service's running container (bash if present, else sh).
Cookie/bearer auth as usual.
Client→server JSON frames: `{"type":"stdin","data":"..."}`,
`{"type":"resize","cols":N,"rows":N}`.
Server→client frames: `{"type":"stdout","data":"..."}`,
`{"type":"exit","code":N}`, `{"type":"error","message":"..."}`.
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'101':
description: Switching protocols — WebSocket session established
'404':
description: Service not found or not visible
'409':
description: Service has no running container
'503':
description: Docker unavailable
/internal/wake/{id}:
get:
tags:
+2
View File
@@ -86,6 +86,8 @@ operations live ops view: active deploys, queue depth,
24h failures, cron runs, backups
activity enriched activity feed; --severity/--category/
--resource/--project/--limit filters
shell <service-id> interactive PTY shell inside the service
container (bash→sh fallback; resizes follow)
banners list|create|update|delete announcement banners (admin);
active banners print on `auth status`
invites list|create|revoke team invite links (admin); create