Skip to content

Commit 4fa96bb

Browse files
authored
feat(web): show docker container for active SSH session (#49)
* feat(web): show container for active SSH terminal sessions * test(web): add coverage for terminal session container display
1 parent 5741fd1 commit 4fa96bb

File tree

8 files changed

+251
-41
lines changed

8 files changed

+251
-41
lines changed

packages/web/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"dev": "concurrently -k \"next dev\" \"node scripts/terminal-ws.mjs\"",
77
"build": "next build",
88
"start": "next start",
9-
"lint": "eslint"
9+
"lint": "eslint",
10+
"test": "vitest run"
1011
},
1112
"dependencies": {
1213
"@effect-template/lib": "workspace:*",
@@ -29,6 +30,7 @@
2930
"eslint": "^9",
3031
"eslint-config-next": "16.1.6",
3132
"tailwindcss": "^4",
32-
"typescript": "^5"
33+
"typescript": "^5",
34+
"vitest": "^4.0.17"
3335
}
3436
}

packages/web/src/app/page.tsx

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -533,9 +533,13 @@ export default function Home() {
533533

534534
useEffect(() => () => disposeTerminal(), [disposeTerminal])
535535

536-
const connectedLabel = activeProject?.displayName ?? "none"
536+
const activeTerminalSession = terminalSessions.find((session) => session.id === activeSessionId)
537+
const connectedContainerLabel = terminalStatus === "detached"
538+
? "none"
539+
: activeTerminalSession?.containerName ?? activeProject?.containerName ?? "unknown"
537540
const statusLabel = activeProject?.statusLabel ?? "unknown"
538541
const sshLabel = activeProject?.ssh ?? "-"
542+
const containerLabel = activeProject?.containerName ?? "-"
539543
const repoLabel = activeProject?.displayName ?? "-"
540544
const refLabel = activeProject?.repoRef ?? "-"
541545
const recreateStatus = activeProject?.recreateStatus
@@ -558,7 +562,7 @@ export default function Home() {
558562
<div className="brand">docker-git</div>
559563
<div className="status-chip">
560564
<span className="status-dot" />
561-
Connected · {connectedLabel}
565+
Connected SSH · {connectedContainerLabel}
562566
</div>
563567
</header>
564568

@@ -603,6 +607,7 @@ export default function Home() {
603607
<div className="topbar">
604608
<div className="topbar-meta">
605609
<span className="pill">SSH: {sshLabel}</span>
610+
<span className="pill">Container: {containerLabel}</span>
606611
<span className="pill">Repo: {repoLabel}</span>
607612
<span className="pill">Ref: {refLabel}</span>
608613
<span className="pill">Status: {statusLabel}</span>
@@ -685,43 +690,46 @@ export default function Home() {
685690
{showDetails ? "No active terminals" : "None"}
686691
</div>
687692
) : (
688-
terminalSessions.map((session) => (
689-
<div
690-
key={session.id}
691-
className={`list-item ${session.id === activeSessionId ? "active" : ""}`}
692-
role="button"
693-
tabIndex={0}
694-
onClick={() => handleSessionSelect(session)}
695-
onKeyDown={(event) => {
696-
if (event.key === "Enter" || event.key === " ") {
697-
event.preventDefault()
698-
handleSessionSelect(session)
699-
}
700-
}}
701-
>
702-
<div className="list-item-row">
703-
<strong>{session.displayName}</strong>
704-
<div className="list-item-actions">
705-
<span className={session.status === "connected" ? "badge" : "badge warn"}>
706-
{session.status}
707-
</span>
708-
<button
709-
className="icon-button"
710-
type="button"
711-
onClick={(event) => {
712-
event.stopPropagation()
713-
handleSessionClose(session)
714-
}}
715-
>
716-
delete
717-
</button>
693+
terminalSessions.map((session) => {
694+
const sessionContainer = session.containerName ?? session.projectId
695+
return (
696+
<div
697+
key={session.id}
698+
className={`list-item ${session.id === activeSessionId ? "active" : ""}`}
699+
role="button"
700+
tabIndex={0}
701+
onClick={() => handleSessionSelect(session)}
702+
onKeyDown={(event) => {
703+
if (event.key === "Enter" || event.key === " ") {
704+
event.preventDefault()
705+
handleSessionSelect(session)
706+
}
707+
}}
708+
>
709+
<div className="list-item-row">
710+
<strong>{session.displayName}</strong>
711+
<div className="list-item-actions">
712+
<span className={session.status === "connected" ? "badge" : "badge warn"}>
713+
{session.status}
714+
</span>
715+
<button
716+
className="icon-button"
717+
type="button"
718+
onClick={(event) => {
719+
event.stopPropagation()
720+
handleSessionClose(session)
721+
}}
722+
>
723+
delete
724+
</button>
725+
</div>
718726
</div>
727+
<small>
728+
{session.source} · {session.mode} · {sessionContainer}
729+
</small>
719730
</div>
720-
<small>
721-
{session.source} · {session.mode} · {session.projectId}
722-
</small>
723-
</div>
724-
))
731+
)
732+
})
725733
)}
726734
</div>
727735
</div>

packages/web/src/lib/api-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const TerminalSessionSchema = Schema.Struct({
108108
id: Schema.String,
109109
projectId: Schema.String,
110110
displayName: Schema.String,
111+
containerName: Schema.optional(Schema.String),
111112
mode: TerminalSessionModeSchema,
112113
source: Schema.String,
113114
status: TerminalSessionStatusSchema,

packages/web/src/lib/api-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export type TerminalSession = {
6565
readonly id: string
6666
readonly projectId: string
6767
readonly displayName: string
68+
readonly containerName?: string
6869
readonly mode: TerminalSessionMode
6970
readonly source: string
7071
readonly status: TerminalSessionStatus

packages/web/src/server/terminal-ws.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type TerminalSessionRegistry = {
2323
readonly id: string
2424
readonly projectId: string
2525
readonly displayName: string
26+
readonly containerName?: string
2627
readonly mode: TerminalSessionMode
2728
readonly source: string
2829
readonly status: TerminalSessionStatus
@@ -181,8 +182,15 @@ export const attachTerminalWs = (wss: WebSocketServer) => {
181182
const privateKey = fs.readFileSync(target.identityPath)
182183

183184
client.on("ready", () => {
184-
updateSession(sessionId, { status: "connected", displayName: details.displayName })
185-
sendMessage(socket, { type: "info", data: `[docker-git] attached to ${details.displayName}` })
185+
updateSession(sessionId, {
186+
status: "connected",
187+
displayName: details.displayName,
188+
containerName: details.containerName
189+
})
190+
sendMessage(socket, {
191+
type: "info",
192+
data: `[docker-git] attached to ${details.displayName} (${details.containerName})`
193+
})
186194

187195
client.shell(
188196
{
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from "node:fs"
2+
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
4+
5+
import { GET } from "../../src/app/api/terminal-sessions/route"
6+
7+
const sessionsFile = "/tmp/docker-git-terminal-sessions.json"
8+
9+
let previousSessionsFileContent: string | null = null
10+
11+
beforeEach(() => {
12+
previousSessionsFileContent = fs.existsSync(sessionsFile)
13+
? fs.readFileSync(sessionsFile, "utf8")
14+
: null
15+
})
16+
17+
afterEach(() => {
18+
if (previousSessionsFileContent === null) {
19+
if (fs.existsSync(sessionsFile)) {
20+
fs.unlinkSync(sessionsFile)
21+
}
22+
return
23+
}
24+
fs.writeFileSync(sessionsFile, previousSessionsFileContent, "utf8")
25+
})
26+
27+
describe("GET /api/terminal-sessions", () => {
28+
it("returns sessions with containerName", async () => {
29+
fs.writeFileSync(
30+
sessionsFile,
31+
JSON.stringify({
32+
sessions: [
33+
{
34+
id: "session-1",
35+
projectId: "/tmp/project",
36+
displayName: "org/repo",
37+
containerName: "dg-repo-issue-47",
38+
mode: "default",
39+
source: "web",
40+
status: "connected",
41+
connectedAt: "2026-02-16T15:00:00.000Z",
42+
updatedAt: "2026-02-16T15:00:01.000Z"
43+
}
44+
]
45+
}),
46+
"utf8"
47+
)
48+
49+
const response = GET()
50+
const body = await response.json()
51+
const sessions = Reflect.get(body as object, "sessions")
52+
expect(Array.isArray(sessions)).toBe(true)
53+
const first = Array.isArray(sessions) ? sessions[0] : null
54+
expect(first).toMatchObject({
55+
id: "session-1",
56+
containerName: "dg-repo-issue-47",
57+
status: "connected"
58+
})
59+
})
60+
61+
it("returns an empty list when sessions file is missing", async () => {
62+
if (fs.existsSync(sessionsFile)) {
63+
fs.unlinkSync(sessionsFile)
64+
}
65+
const response = GET()
66+
const body = await response.json()
67+
expect(body).toEqual({ sessions: [] })
68+
})
69+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Either } from "effect"
2+
import * as Schema from "effect/Schema"
3+
import { describe, expect, it } from "vitest"
4+
5+
import { ApiSchema } from "../../src/lib/api-schema"
6+
7+
const decodeTerminalSessions = Schema.decodeUnknownEither(ApiSchema.TerminalSessions)
8+
9+
describe("ApiSchema.TerminalSessions", () => {
10+
it("decodes sessions with containerName", () => {
11+
const payload = {
12+
sessions: [
13+
{
14+
id: "session-1",
15+
projectId: "/tmp/project",
16+
displayName: "org/repo",
17+
containerName: "dg-repo-issue-47",
18+
mode: "default",
19+
source: "web",
20+
status: "connected",
21+
connectedAt: "2026-02-16T15:00:00.000Z",
22+
updatedAt: "2026-02-16T15:00:01.000Z"
23+
}
24+
]
25+
}
26+
27+
const decoded = decodeTerminalSessions(payload)
28+
expect(Either.isRight(decoded)).toBe(true)
29+
if (Either.isLeft(decoded)) {
30+
return
31+
}
32+
expect(decoded.right.sessions[0]?.containerName).toBe("dg-repo-issue-47")
33+
})
34+
35+
it("keeps backward compatibility when containerName is absent", () => {
36+
const payload = {
37+
sessions: [
38+
{
39+
id: "session-legacy",
40+
projectId: "/tmp/project-legacy",
41+
displayName: "org/repo",
42+
mode: "default",
43+
source: "web",
44+
status: "connected",
45+
connectedAt: "2026-02-16T15:00:00.000Z",
46+
updatedAt: "2026-02-16T15:00:01.000Z"
47+
}
48+
]
49+
}
50+
51+
const decoded = decodeTerminalSessions(payload)
52+
expect(Either.isRight(decoded)).toBe(true)
53+
if (Either.isLeft(decoded)) {
54+
return
55+
}
56+
expect(decoded.right.sessions[0]?.containerName).toBeUndefined()
57+
})
58+
})

pnpm-lock.yaml

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)