From 752700843a592135b963aeaf228fe0d172a490d6 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:56:21 +0000 Subject: [PATCH] feat(app): show launch time and sort projects by last start (#57) --- packages/app/src/docker-git/menu-actions.ts | 2 +- .../app/src/docker-git/menu-render-select.ts | 37 +++++++++- .../app/src/docker-git/menu-select-load.ts | 33 +++++++++ .../app/src/docker-git/menu-select-order.ts | 37 ++++++++++ .../app/src/docker-git/menu-select-runtime.ts | 69 +++++++++++++++--- packages/app/src/docker-git/menu-select.ts | 33 +-------- packages/app/src/docker-git/menu-types.ts | 2 + .../docker-git/menu-select-order.test.ts | 73 +++++++++++++++++++ 8 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 packages/app/src/docker-git/menu-select-load.ts create mode 100644 packages/app/src/docker-git/menu-select-order.ts create mode 100644 packages/app/tests/docker-git/menu-select-order.test.ts diff --git a/packages/app/src/docker-git/menu-actions.ts b/packages/app/src/docker-git/menu-actions.ts index 97558bf6..8cda3dfc 100644 --- a/packages/app/src/docker-git/menu-actions.ts +++ b/packages/app/src/docker-git/menu-actions.ts @@ -13,7 +13,7 @@ import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/p import { Effect, Match, pipe } from "effect" import { startCreateView } from "./menu-create.js" -import { loadSelectView } from "./menu-select.js" +import { loadSelectView } from "./menu-select-load.js" import { resumeTui, suspendTui } from "./menu-shared.js" import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js" diff --git a/packages/app/src/docker-git/menu-render-select.ts b/packages/app/src/docker-git/menu-render-select.ts index 50a79f39..f6363a42 100644 --- a/packages/app/src/docker-git/menu-render-select.ts +++ b/packages/app/src/docker-git/menu-render-select.ts @@ -18,7 +18,30 @@ const formatRepoRef = (repoRef: string): string => { return trimmed.length > 0 ? trimmed : "main" } -const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 }) +const stoppedRuntime = (): SelectProjectRuntime => ({ + running: false, + sshSessions: 0, + startedAtIso: null, + startedAtEpochMs: null +}) + +const pad2 = (value: number): string => value.toString().padStart(2, "0") + +const formatUtcTimestamp = (epochMs: number, withSeconds: boolean): string => { + const date = new Date(epochMs) + const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : "" + return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${ + pad2( + date.getUTCHours() + ) + }:${pad2(date.getUTCMinutes())}${seconds} UTC` +} + +const renderStartedAtCompact = (runtime: SelectProjectRuntime): string => + runtime.startedAtEpochMs === null ? "-" : formatUtcTimestamp(runtime.startedAtEpochMs, false) + +const renderStartedAtDetailed = (runtime: SelectProjectRuntime): string => + runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true) const runtimeForProject = ( runtimeByProject: Readonly>, @@ -26,7 +49,11 @@ const runtimeForProject = ( ): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime() const renderRuntimeLabel = (runtime: SelectProjectRuntime): string => - `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}` + `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${ + renderStartedAtCompact( + runtime + ) + }` export const selectTitle = (purpose: SelectPurpose): string => Match.value(purpose).pipe( @@ -61,9 +88,10 @@ export const buildSelectLabels = ( items.map((item, index) => { const prefix = index === selected ? ">" : " " const refLabel = formatRepoRef(item.repoRef) + const runtime = runtimeForProject(runtimeByProject, item) const runtimeSuffix = purpose === "Down" || purpose === "Delete" - ? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]` - : "" + ? ` [${renderRuntimeLabel(runtime)}]` + : ` [started=${renderStartedAtCompact(runtime)}]` return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}` }) @@ -101,6 +129,7 @@ const commonRows = ( el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`), el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`), el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`), + el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`), el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`) ] diff --git a/packages/app/src/docker-git/menu-select-load.ts b/packages/app/src/docker-git/menu-select-load.ts new file mode 100644 index 00000000..9786777c --- /dev/null +++ b/packages/app/src/docker-git/menu-select-load.ts @@ -0,0 +1,33 @@ +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { Effect, pipe } from "effect" + +import { loadRuntimeByProject } from "./menu-select-runtime.js" +import { startSelectView } from "./menu-select.js" +import type { MenuEnv, MenuViewContext } from "./menu-types.js" + +export const loadSelectView = ( + effect: Effect.Effect, E, MenuEnv>, + purpose: "Connect" | "Down" | "Info" | "Delete", + context: Pick +): Effect.Effect => + pipe( + effect, + Effect.flatMap((items) => + pipe( + loadRuntimeByProject(items), + Effect.flatMap((runtimeByProject) => + Effect.sync(() => { + if (items.length === 0) { + context.setMessage( + purpose === "Down" + ? "No running docker-git containers." + : "No docker-git projects found." + ) + return + } + startSelectView(items, purpose, context, runtimeByProject) + }) + ) + ) + ) + ) diff --git a/packages/app/src/docker-git/menu-select-order.ts b/packages/app/src/docker-git/menu-select-order.ts new file mode 100644 index 00000000..6d703bbd --- /dev/null +++ b/packages/app/src/docker-git/menu-select-order.ts @@ -0,0 +1,37 @@ +import type { ProjectItem } from "@effect-template/lib/usecases/projects" + +import type { SelectProjectRuntime } from "./menu-types.js" + +const defaultRuntime = (): SelectProjectRuntime => ({ + running: false, + sshSessions: 0, + startedAtIso: null, + startedAtEpochMs: null +}) + +const runtimeForSort = ( + runtimeByProject: Readonly>, + item: ProjectItem +): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? defaultRuntime() + +const startedAtEpochForSort = (runtime: SelectProjectRuntime): number => + runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY + +export const sortItemsByLaunchTime = ( + items: ReadonlyArray, + runtimeByProject: Readonly> +): ReadonlyArray => + items.toSorted((left, right) => { + const leftRuntime = runtimeForSort(runtimeByProject, left) + const rightRuntime = runtimeForSort(runtimeByProject, right) + const leftStartedAt = startedAtEpochForSort(leftRuntime) + const rightStartedAt = startedAtEpochForSort(rightRuntime) + + if (leftStartedAt !== rightStartedAt) { + return rightStartedAt - leftStartedAt + } + if (leftRuntime.running !== rightRuntime.running) { + return leftRuntime.running ? -1 : 1 + } + return left.displayName.localeCompare(right.displayName) + }) diff --git a/packages/app/src/docker-git/menu-select-runtime.ts b/packages/app/src/docker-git/menu-select-runtime.ts index 07ef0a0b..4ac331fc 100644 --- a/packages/app/src/docker-git/menu-select-runtime.ts +++ b/packages/app/src/docker-git/menu-select-runtime.ts @@ -7,9 +7,20 @@ import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js" const emptyRuntimeByProject = (): Readonly> => ({}) -const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 }) +const stoppedRuntime = (): SelectProjectRuntime => ({ + running: false, + sshSessions: 0, + startedAtIso: null, + startedAtEpochMs: null +}) const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'" +const dockerZeroStartedAt = "0001-01-01T00:00:00Z" + +type ContainerStartTime = { + readonly startedAtIso: string + readonly startedAtEpochMs: number +} const parseSshSessionCount = (raw: string): number => { const parsed = Number.parseInt(raw.trim(), 10) @@ -19,6 +30,21 @@ const parseSshSessionCount = (raw: string): number => { return parsed } +const parseContainerStartedAt = (raw: string): ContainerStartTime | null => { + const trimmed = raw.trim() + if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) { + return null + } + const startedAtEpochMs = Date.parse(trimmed) + if (Number.isNaN(startedAtEpochMs)) { + return null + } + return { + startedAtIso: trimmed, + startedAtEpochMs + } +} + const toRuntimeMap = ( entries: ReadonlyArray ): Readonly> => { @@ -48,16 +74,35 @@ const countContainerSshSessions = ( }) ) +const inspectContainerStartedAt = ( + containerName: string +): Effect.Effect => + pipe( + runCommandCapture( + { + cwd: process.cwd(), + command: "docker", + args: ["inspect", "--format", "{{.State.StartedAt}}", containerName] + }, + [0], + (exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode }) + ), + Effect.match({ + onFailure: () => null, + onSuccess: (raw) => parseContainerStartedAt(raw) + }) + ) + // CHANGE: enrich select items with runtime state and SSH session counts // WHY: prevent stopping/deleting containers that are currently used via SSH // QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас" // REF: issue-47 // SOURCE: n/a -// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)} +// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)} // PURITY: SHELL // EFFECT: Effect, never, MenuEnv> -// INVARIANT: stopped containers always have sshSessions = 0 -// COMPLEXITY: O(n + docker_ps + docker_exec) +// INVARIANT: projects without a known container start have startedAt = null +// COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect) export const loadRuntimeByProject = ( items: ReadonlyArray ): Effect.Effect>, never, MenuEnv> => @@ -68,13 +113,17 @@ export const loadRuntimeByProject = ( items, (item) => { const running = runningNames.includes(item.containerName) - if (!running) { - const entry: readonly [string, SelectProjectRuntime] = [item.projectDir, stoppedRuntime()] - return Effect.succeed(entry) - } + const sshSessionsEffect = running + ? countContainerSshSessions(item.containerName) + : Effect.succeed(0) return pipe( - countContainerSshSessions(item.containerName), - Effect.map((sshSessions): SelectProjectRuntime => ({ running: true, sshSessions })), + Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]), + Effect.map(([sshSessions, startedAt]): SelectProjectRuntime => ({ + running, + sshSessions, + startedAtIso: startedAt?.startedAtIso ?? null, + startedAtEpochMs: startedAt?.startedAtEpochMs ?? null + })), Effect.map((runtime): readonly [string, SelectProjectRuntime] => [item.projectDir, runtime]) ) }, diff --git a/packages/app/src/docker-git/menu-select.ts b/packages/app/src/docker-git/menu-select.ts index 80f9bec4..65c28b3a 100644 --- a/packages/app/src/docker-git/menu-select.ts +++ b/packages/app/src/docker-git/menu-select.ts @@ -7,10 +7,9 @@ import { listRunningProjectItems, type ProjectItem } from "@effect-template/lib/usecases/projects" - import { Effect, Match, pipe } from "effect" - import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js" +import { sortItemsByLaunchTime } from "./menu-select-order.js" import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js" import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js" import type { @@ -37,11 +36,12 @@ export const startSelectView = ( context: Pick, runtimeByProject: Readonly> = emptyRuntimeByProject() ) => { + const sortedItems = sortItemsByLaunchTime(items, runtimeByProject) context.setMessage(null) context.setView({ _tag: "SelectProject", purpose, - items, + items: sortedItems, runtimeByProject, selected: 0, confirmDelete: false, @@ -289,30 +289,3 @@ const handleSelectReturn = ( Match.exhaustive ) } - -export const loadSelectView = ( - effect: Effect.Effect, E, MenuEnv>, - purpose: "Connect" | "Down" | "Info" | "Delete", - context: Pick -): Effect.Effect => - pipe( - effect, - Effect.flatMap((items) => - pipe( - loadRuntimeByProject(items), - Effect.flatMap((runtimeByProject) => - Effect.sync(() => { - if (items.length === 0) { - context.setMessage( - purpose === "Down" - ? "No running docker-git containers." - : "No docker-git projects found." - ) - return - } - startSelectView(items, purpose, context, runtimeByProject) - }) - ) - ) - ) - ) diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 755e8206..0dbd5c0d 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -86,6 +86,8 @@ export type ViewState = export type SelectProjectRuntime = { readonly running: boolean readonly sshSessions: number + readonly startedAtIso: string | null + readonly startedAtEpochMs: number | null } export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [ diff --git a/packages/app/tests/docker-git/menu-select-order.test.ts b/packages/app/tests/docker-git/menu-select-order.test.ts new file mode 100644 index 00000000..3c1b344f --- /dev/null +++ b/packages/app/tests/docker-git/menu-select-order.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" + +import { buildSelectLabels } from "../../src/docker-git/menu-render-select.js" +import { sortItemsByLaunchTime } from "../../src/docker-git/menu-select-order.js" +import type { SelectProjectRuntime } from "../../src/docker-git/menu-types.js" +import { makeProjectItem } from "./fixtures/project-item.js" + +const makeRuntime = ( + overrides: Partial = {} +): SelectProjectRuntime => ({ + running: false, + sshSessions: 0, + startedAtIso: null, + startedAtEpochMs: null, + ...overrides +}) + +const emitProof = (message: string): void => { + process.stdout.write(`[issue-57-proof] ${message}\n`) +} + +describe("menu-select order", () => { + it("sorts projects by last container start time (newest first)", () => { + const newest = makeProjectItem({ projectDir: "/home/dev/.docker-git/newest", displayName: "org/newest" }) + const older = makeProjectItem({ projectDir: "/home/dev/.docker-git/older", displayName: "org/older" }) + const neverStarted = makeProjectItem({ projectDir: "/home/dev/.docker-git/never", displayName: "org/never" }) + const startedNewest = "2026-02-17T11:30:00Z" + const startedOlder = "2026-02-16T07:15:00Z" + const runtimeByProject: Readonly> = { + [newest.projectDir]: makeRuntime({ + running: true, + sshSessions: 1, + startedAtIso: startedNewest, + startedAtEpochMs: Date.parse(startedNewest) + }), + [older.projectDir]: makeRuntime({ + running: true, + sshSessions: 0, + startedAtIso: startedOlder, + startedAtEpochMs: Date.parse(startedOlder) + }), + [neverStarted.projectDir]: makeRuntime() + } + + const sorted = sortItemsByLaunchTime([neverStarted, older, newest], runtimeByProject) + expect(sorted.map((item) => item.projectDir)).toEqual([ + newest.projectDir, + older.projectDir, + neverStarted.projectDir + ]) + emitProof("sorting by launch time works: newest container is selected first") + }) + + it("shows container launch timestamp in select labels", () => { + const item = makeProjectItem({ projectDir: "/home/dev/.docker-git/example", displayName: "org/example" }) + const startedAtIso = "2026-02-17T09:45:00Z" + const runtimeByProject: Readonly> = { + [item.projectDir]: makeRuntime({ + running: true, + sshSessions: 2, + startedAtIso, + startedAtEpochMs: Date.parse(startedAtIso) + }) + } + + const connectLabel = buildSelectLabels([item], 0, "Connect", runtimeByProject)[0] + const downLabel = buildSelectLabels([item], 0, "Down", runtimeByProject)[0] + + expect(connectLabel).toContain("[started=2026-02-17 09:45 UTC]") + expect(downLabel).toContain("running, ssh=2, started=2026-02-17 09:45 UTC") + emitProof("UI labels show container start timestamp in Connect and Down views") + }) +})