Skip to content

Commit 693792a

Browse files
authored
feat(app): show launch time and sort projects by last start (#57) (#59)
1 parent 18f37a2 commit 693792a

File tree

8 files changed

+241
-45
lines changed

8 files changed

+241
-45
lines changed

packages/app/src/docker-git/menu-actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/p
1313
import { Effect, Match, pipe } from "effect"
1414

1515
import { startCreateView } from "./menu-create.js"
16-
import { loadSelectView } from "./menu-select.js"
16+
import { loadSelectView } from "./menu-select-load.js"
1717
import { resumeTui, suspendTui } from "./menu-shared.js"
1818
import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js"
1919

packages/app/src/docker-git/menu-render-select.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,42 @@ const formatRepoRef = (repoRef: string): string => {
1818
return trimmed.length > 0 ? trimmed : "main"
1919
}
2020

21-
const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 })
21+
const stoppedRuntime = (): SelectProjectRuntime => ({
22+
running: false,
23+
sshSessions: 0,
24+
startedAtIso: null,
25+
startedAtEpochMs: null
26+
})
27+
28+
const pad2 = (value: number): string => value.toString().padStart(2, "0")
29+
30+
const formatUtcTimestamp = (epochMs: number, withSeconds: boolean): string => {
31+
const date = new Date(epochMs)
32+
const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : ""
33+
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${
34+
pad2(
35+
date.getUTCHours()
36+
)
37+
}:${pad2(date.getUTCMinutes())}${seconds} UTC`
38+
}
39+
40+
const renderStartedAtCompact = (runtime: SelectProjectRuntime): string =>
41+
runtime.startedAtEpochMs === null ? "-" : formatUtcTimestamp(runtime.startedAtEpochMs, false)
42+
43+
const renderStartedAtDetailed = (runtime: SelectProjectRuntime): string =>
44+
runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true)
2245

2346
const runtimeForProject = (
2447
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
2548
item: ProjectItem
2649
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime()
2750

2851
const renderRuntimeLabel = (runtime: SelectProjectRuntime): string =>
29-
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`
52+
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${
53+
renderStartedAtCompact(
54+
runtime
55+
)
56+
}`
3057

3158
export const selectTitle = (purpose: SelectPurpose): string =>
3259
Match.value(purpose).pipe(
@@ -61,9 +88,10 @@ export const buildSelectLabels = (
6188
items.map((item, index) => {
6289
const prefix = index === selected ? ">" : " "
6390
const refLabel = formatRepoRef(item.repoRef)
91+
const runtime = runtimeForProject(runtimeByProject, item)
6492
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
65-
? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
66-
: ""
93+
? ` [${renderRuntimeLabel(runtime)}]`
94+
: ` [started=${renderStartedAtCompact(runtime)}]`
6795
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
6896
})
6997

@@ -101,6 +129,7 @@ const commonRows = (
101129
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
102130
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
103131
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
132+
el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
104133
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
105134
]
106135

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
2+
import { Effect, pipe } from "effect"
3+
4+
import { loadRuntimeByProject } from "./menu-select-runtime.js"
5+
import { startSelectView } from "./menu-select.js"
6+
import type { MenuEnv, MenuViewContext } from "./menu-types.js"
7+
8+
export const loadSelectView = <E>(
9+
effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
10+
purpose: "Connect" | "Down" | "Info" | "Delete",
11+
context: Pick<MenuViewContext, "setView" | "setMessage">
12+
): Effect.Effect<void, E, MenuEnv> =>
13+
pipe(
14+
effect,
15+
Effect.flatMap((items) =>
16+
pipe(
17+
loadRuntimeByProject(items),
18+
Effect.flatMap((runtimeByProject) =>
19+
Effect.sync(() => {
20+
if (items.length === 0) {
21+
context.setMessage(
22+
purpose === "Down"
23+
? "No running docker-git containers."
24+
: "No docker-git projects found."
25+
)
26+
return
27+
}
28+
startSelectView(items, purpose, context, runtimeByProject)
29+
})
30+
)
31+
)
32+
)
33+
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
2+
3+
import type { SelectProjectRuntime } from "./menu-types.js"
4+
5+
const defaultRuntime = (): SelectProjectRuntime => ({
6+
running: false,
7+
sshSessions: 0,
8+
startedAtIso: null,
9+
startedAtEpochMs: null
10+
})
11+
12+
const runtimeForSort = (
13+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
14+
item: ProjectItem
15+
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? defaultRuntime()
16+
17+
const startedAtEpochForSort = (runtime: SelectProjectRuntime): number =>
18+
runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY
19+
20+
export const sortItemsByLaunchTime = (
21+
items: ReadonlyArray<ProjectItem>,
22+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
23+
): ReadonlyArray<ProjectItem> =>
24+
items.toSorted((left, right) => {
25+
const leftRuntime = runtimeForSort(runtimeByProject, left)
26+
const rightRuntime = runtimeForSort(runtimeByProject, right)
27+
const leftStartedAt = startedAtEpochForSort(leftRuntime)
28+
const rightStartedAt = startedAtEpochForSort(rightRuntime)
29+
30+
if (leftStartedAt !== rightStartedAt) {
31+
return rightStartedAt - leftStartedAt
32+
}
33+
if (leftRuntime.running !== rightRuntime.running) {
34+
return leftRuntime.running ? -1 : 1
35+
}
36+
return left.displayName.localeCompare(right.displayName)
37+
})

packages/app/src/docker-git/menu-select-runtime.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,20 @@ import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js"
77

88
const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
99

10-
const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 })
10+
const stoppedRuntime = (): SelectProjectRuntime => ({
11+
running: false,
12+
sshSessions: 0,
13+
startedAtIso: null,
14+
startedAtEpochMs: null
15+
})
1116

1217
const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'"
18+
const dockerZeroStartedAt = "0001-01-01T00:00:00Z"
19+
20+
type ContainerStartTime = {
21+
readonly startedAtIso: string
22+
readonly startedAtEpochMs: number
23+
}
1324

1425
const parseSshSessionCount = (raw: string): number => {
1526
const parsed = Number.parseInt(raw.trim(), 10)
@@ -19,6 +30,21 @@ const parseSshSessionCount = (raw: string): number => {
1930
return parsed
2031
}
2132

33+
const parseContainerStartedAt = (raw: string): ContainerStartTime | null => {
34+
const trimmed = raw.trim()
35+
if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) {
36+
return null
37+
}
38+
const startedAtEpochMs = Date.parse(trimmed)
39+
if (Number.isNaN(startedAtEpochMs)) {
40+
return null
41+
}
42+
return {
43+
startedAtIso: trimmed,
44+
startedAtEpochMs
45+
}
46+
}
47+
2248
const toRuntimeMap = (
2349
entries: ReadonlyArray<readonly [string, SelectProjectRuntime]>
2450
): Readonly<Record<string, SelectProjectRuntime>> => {
@@ -48,16 +74,35 @@ const countContainerSshSessions = (
4874
})
4975
)
5076

77+
const inspectContainerStartedAt = (
78+
containerName: string
79+
): Effect.Effect<ContainerStartTime | null, never, MenuEnv> =>
80+
pipe(
81+
runCommandCapture(
82+
{
83+
cwd: process.cwd(),
84+
command: "docker",
85+
args: ["inspect", "--format", "{{.State.StartedAt}}", containerName]
86+
},
87+
[0],
88+
(exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode })
89+
),
90+
Effect.match({
91+
onFailure: () => null,
92+
onSuccess: (raw) => parseContainerStartedAt(raw)
93+
})
94+
)
95+
5196
// CHANGE: enrich select items with runtime state and SSH session counts
5297
// WHY: prevent stopping/deleting containers that are currently used via SSH
5398
// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
5499
// REF: issue-47
55100
// SOURCE: n/a
56-
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
101+
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
57102
// PURITY: SHELL
58103
// EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
59-
// INVARIANT: stopped containers always have sshSessions = 0
60-
// COMPLEXITY: O(n + docker_ps + docker_exec)
104+
// INVARIANT: projects without a known container start have startedAt = null
105+
// COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
61106
export const loadRuntimeByProject = (
62107
items: ReadonlyArray<ProjectItem>
63108
): Effect.Effect<Readonly<Record<string, SelectProjectRuntime>>, never, MenuEnv> =>
@@ -68,13 +113,17 @@ export const loadRuntimeByProject = (
68113
items,
69114
(item) => {
70115
const running = runningNames.includes(item.containerName)
71-
if (!running) {
72-
const entry: readonly [string, SelectProjectRuntime] = [item.projectDir, stoppedRuntime()]
73-
return Effect.succeed(entry)
74-
}
116+
const sshSessionsEffect = running
117+
? countContainerSshSessions(item.containerName)
118+
: Effect.succeed(0)
75119
return pipe(
76-
countContainerSshSessions(item.containerName),
77-
Effect.map((sshSessions): SelectProjectRuntime => ({ running: true, sshSessions })),
120+
Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]),
121+
Effect.map(([sshSessions, startedAt]): SelectProjectRuntime => ({
122+
running,
123+
sshSessions,
124+
startedAtIso: startedAt?.startedAtIso ?? null,
125+
startedAtEpochMs: startedAt?.startedAtEpochMs ?? null
126+
})),
78127
Effect.map((runtime): readonly [string, SelectProjectRuntime] => [item.projectDir, runtime])
79128
)
80129
},

packages/app/src/docker-git/menu-select.ts

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ import {
77
listRunningProjectItems,
88
type ProjectItem
99
} from "@effect-template/lib/usecases/projects"
10-
1110
import { Effect, Match, pipe } from "effect"
12-
1311
import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
12+
import { sortItemsByLaunchTime } from "./menu-select-order.js"
1413
import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
1514
import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
1615
import type {
@@ -37,11 +36,12 @@ export const startSelectView = (
3736
context: Pick<SelectContext, "setView" | "setMessage">,
3837
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
3938
) => {
39+
const sortedItems = sortItemsByLaunchTime(items, runtimeByProject)
4040
context.setMessage(null)
4141
context.setView({
4242
_tag: "SelectProject",
4343
purpose,
44-
items,
44+
items: sortedItems,
4545
runtimeByProject,
4646
selected: 0,
4747
confirmDelete: false,
@@ -289,30 +289,3 @@ const handleSelectReturn = (
289289
Match.exhaustive
290290
)
291291
}
292-
293-
export const loadSelectView = <E>(
294-
effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
295-
purpose: "Connect" | "Down" | "Info" | "Delete",
296-
context: Pick<SelectContext, "setView" | "setMessage">
297-
): Effect.Effect<void, E, MenuEnv> =>
298-
pipe(
299-
effect,
300-
Effect.flatMap((items) =>
301-
pipe(
302-
loadRuntimeByProject(items),
303-
Effect.flatMap((runtimeByProject) =>
304-
Effect.sync(() => {
305-
if (items.length === 0) {
306-
context.setMessage(
307-
purpose === "Down"
308-
? "No running docker-git containers."
309-
: "No docker-git projects found."
310-
)
311-
return
312-
}
313-
startSelectView(items, purpose, context, runtimeByProject)
314-
})
315-
)
316-
)
317-
)
318-
)

packages/app/src/docker-git/menu-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export type ViewState =
8686
export type SelectProjectRuntime = {
8787
readonly running: boolean
8888
readonly sshSessions: number
89+
readonly startedAtIso: string | null
90+
readonly startedAtEpochMs: number | null
8991
}
9092

9193
export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { buildSelectLabels } from "../../src/docker-git/menu-render-select.js"
4+
import { sortItemsByLaunchTime } from "../../src/docker-git/menu-select-order.js"
5+
import type { SelectProjectRuntime } from "../../src/docker-git/menu-types.js"
6+
import { makeProjectItem } from "./fixtures/project-item.js"
7+
8+
const makeRuntime = (
9+
overrides: Partial<SelectProjectRuntime> = {}
10+
): SelectProjectRuntime => ({
11+
running: false,
12+
sshSessions: 0,
13+
startedAtIso: null,
14+
startedAtEpochMs: null,
15+
...overrides
16+
})
17+
18+
const emitProof = (message: string): void => {
19+
process.stdout.write(`[issue-57-proof] ${message}\n`)
20+
}
21+
22+
describe("menu-select order", () => {
23+
it("sorts projects by last container start time (newest first)", () => {
24+
const newest = makeProjectItem({ projectDir: "/home/dev/.docker-git/newest", displayName: "org/newest" })
25+
const older = makeProjectItem({ projectDir: "/home/dev/.docker-git/older", displayName: "org/older" })
26+
const neverStarted = makeProjectItem({ projectDir: "/home/dev/.docker-git/never", displayName: "org/never" })
27+
const startedNewest = "2026-02-17T11:30:00Z"
28+
const startedOlder = "2026-02-16T07:15:00Z"
29+
const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
30+
[newest.projectDir]: makeRuntime({
31+
running: true,
32+
sshSessions: 1,
33+
startedAtIso: startedNewest,
34+
startedAtEpochMs: Date.parse(startedNewest)
35+
}),
36+
[older.projectDir]: makeRuntime({
37+
running: true,
38+
sshSessions: 0,
39+
startedAtIso: startedOlder,
40+
startedAtEpochMs: Date.parse(startedOlder)
41+
}),
42+
[neverStarted.projectDir]: makeRuntime()
43+
}
44+
45+
const sorted = sortItemsByLaunchTime([neverStarted, older, newest], runtimeByProject)
46+
expect(sorted.map((item) => item.projectDir)).toEqual([
47+
newest.projectDir,
48+
older.projectDir,
49+
neverStarted.projectDir
50+
])
51+
emitProof("sorting by launch time works: newest container is selected first")
52+
})
53+
54+
it("shows container launch timestamp in select labels", () => {
55+
const item = makeProjectItem({ projectDir: "/home/dev/.docker-git/example", displayName: "org/example" })
56+
const startedAtIso = "2026-02-17T09:45:00Z"
57+
const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
58+
[item.projectDir]: makeRuntime({
59+
running: true,
60+
sshSessions: 2,
61+
startedAtIso,
62+
startedAtEpochMs: Date.parse(startedAtIso)
63+
})
64+
}
65+
66+
const connectLabel = buildSelectLabels([item], 0, "Connect", runtimeByProject)[0]
67+
const downLabel = buildSelectLabels([item], 0, "Down", runtimeByProject)[0]
68+
69+
expect(connectLabel).toContain("[started=2026-02-17 09:45 UTC]")
70+
expect(downLabel).toContain("running, ssh=2, started=2026-02-17 09:45 UTC")
71+
emitProof("UI labels show container start timestamp in Connect and Down views")
72+
})
73+
})

0 commit comments

Comments
 (0)