7440720b7b
- Add SessionOperationsContext + SessionProgressPanel for global, non-blocking lifecycle progress (create/start/stop/restart/delete/ recreate-tunnel) driven by SSE events. - Promote SessionsContext to authoritative shared session state with refresh, addOrUpdateSession, and removeSession helpers. - Wire AppShell, DashboardPage, SessionsPage, useInstanceActions, ToolStarter, and InstanceList into shared state so lists update immediately after create/delete without manual refresh. - Remove legacy blocking overlays from CreateSessionForm, SessionCard, and InstanceList; keep disabled states and inline spinners only. - Update DashboardPage tests to wrap with SessionsProvider and SessionOperationsProvider. - Add .cache/ to .gitignore. Quality gates: npm run typecheck, npm run lint, npm test -- --run (82 passed).
260 lines
6.3 KiB
TypeScript
260 lines
6.3 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
import type { InstanceEventPayload } from "../types/events";
|
|
|
|
export type OperationType =
|
|
| "create"
|
|
| "start"
|
|
| "stop"
|
|
| "restart"
|
|
| "delete"
|
|
| "recreate-tunnel";
|
|
|
|
export type OperationStatus = "pending" | "active" | "success" | "error";
|
|
|
|
export interface Operation {
|
|
id: string;
|
|
type: OperationType;
|
|
instanceId: string;
|
|
displayName: string;
|
|
status: OperationStatus;
|
|
message: string;
|
|
step: number;
|
|
createdAt: number;
|
|
}
|
|
|
|
export interface SessionOperationsContextType {
|
|
operations: Operation[];
|
|
startOperation: (
|
|
type: OperationType,
|
|
instanceId: string,
|
|
displayName: string,
|
|
) => string;
|
|
updateOperationFromEvent: (event: InstanceEventPayload) => void;
|
|
completeOperation: (
|
|
instanceId: string,
|
|
type: OperationType,
|
|
outcome: "success" | "error",
|
|
message?: string,
|
|
) => void;
|
|
dismissOperation: (id: string) => void;
|
|
}
|
|
|
|
const SessionOperationsContext =
|
|
createContext<SessionOperationsContextType | undefined>(undefined);
|
|
|
|
let operationIdCounter = 0;
|
|
|
|
function actionLabel(type: OperationType): string {
|
|
switch (type) {
|
|
case "create":
|
|
return "Creating";
|
|
case "start":
|
|
return "Starting";
|
|
case "stop":
|
|
return "Stopping";
|
|
case "restart":
|
|
return "Restarting";
|
|
case "delete":
|
|
return "Deleting";
|
|
case "recreate-tunnel":
|
|
return "Recreating tunnel";
|
|
default:
|
|
return "Working";
|
|
}
|
|
}
|
|
|
|
function messageForEvent(
|
|
type: OperationType,
|
|
event: InstanceEventPayload,
|
|
): string {
|
|
if (event.message) return event.message;
|
|
|
|
switch (event.event) {
|
|
case "instance.created":
|
|
return "Created";
|
|
case "instance.started":
|
|
return "Starting container";
|
|
case "instance.restarted":
|
|
return "Restarting container";
|
|
case "instance.stopped":
|
|
return "Stopped";
|
|
case "instance.deleted":
|
|
return "Deleted";
|
|
case "instance.health_changed":
|
|
if (event.status === "running") return "Running";
|
|
if (event.status === "unhealthy") return "Unhealthy";
|
|
return `Status: ${event.status ?? event.event}`;
|
|
case "instance.error":
|
|
return event.message ?? "Error";
|
|
default:
|
|
return event.message ?? actionLabel(type);
|
|
}
|
|
}
|
|
|
|
function stepForEvent(event: InstanceEventPayload): number {
|
|
switch (event.event) {
|
|
case "instance.created":
|
|
return 1;
|
|
case "instance.started":
|
|
case "instance.restarted":
|
|
return 2;
|
|
case "instance.health_changed":
|
|
if (event.status === "running") return 4;
|
|
if (event.status === "unhealthy") return 4;
|
|
return 3;
|
|
case "instance.error":
|
|
return 4;
|
|
case "instance.stopped":
|
|
return 4;
|
|
case "instance.deleted":
|
|
return 4;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
export const SessionOperationsProvider = ({
|
|
children,
|
|
}: {
|
|
children: ReactNode;
|
|
}) => {
|
|
const [operations, setOperations] = useState<Operation[]>([]);
|
|
|
|
const startOperation = useCallback(
|
|
(type: OperationType, instanceId: string, displayName: string): string => {
|
|
const id = `op-${++operationIdCounter}`;
|
|
const operation: Operation = {
|
|
id,
|
|
type,
|
|
instanceId,
|
|
displayName,
|
|
status: "pending",
|
|
message: actionLabel(type),
|
|
step: 0,
|
|
createdAt: Date.now(),
|
|
};
|
|
setOperations((prev) => [operation, ...prev].slice(0, 20));
|
|
return id;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const updateOperationFromEvent = useCallback(
|
|
(event: InstanceEventPayload) => {
|
|
setOperations((prev) => {
|
|
const matches = prev.filter(
|
|
(op) => op.instanceId === event.instance_id && op.status !== "success",
|
|
);
|
|
if (matches.length === 0) return prev;
|
|
|
|
const updated = new Map<string, Operation>();
|
|
for (const op of prev) updated.set(op.id, op);
|
|
|
|
for (const op of matches) {
|
|
const nextStep = stepForEvent(event);
|
|
const message = messageForEvent(op.type, event);
|
|
let nextStatus: OperationStatus = op.status;
|
|
|
|
if (event.event === "instance.error") {
|
|
nextStatus = "error";
|
|
} else if (
|
|
event.event === "instance.health_changed" &&
|
|
event.status === "running"
|
|
) {
|
|
nextStatus = "success";
|
|
} else if (event.event === "instance.deleted") {
|
|
nextStatus = "success";
|
|
} else if (event.event === "instance.stopped") {
|
|
nextStatus = "success";
|
|
} else if (nextStatus === "pending") {
|
|
nextStatus = "active";
|
|
}
|
|
|
|
updated.set(op.id, {
|
|
...op,
|
|
status: nextStatus,
|
|
message,
|
|
step: Math.max(op.step, nextStep),
|
|
});
|
|
}
|
|
|
|
return Array.from(updated.values());
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const completeOperation = useCallback(
|
|
(
|
|
instanceId: string,
|
|
type: OperationType,
|
|
outcome: "success" | "error",
|
|
message?: string,
|
|
) => {
|
|
setOperations((prev) => {
|
|
const match = prev.find(
|
|
(op) => op.instanceId === instanceId && op.type === type,
|
|
);
|
|
if (!match) return prev;
|
|
return prev.map((op) =>
|
|
op.id === match.id
|
|
? {
|
|
...op,
|
|
status: outcome,
|
|
message:
|
|
message ?? (outcome === "success" ? "Done" : "Failed"),
|
|
step: 4,
|
|
}
|
|
: op,
|
|
);
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const dismissOperation = useCallback((id: string) => {
|
|
setOperations((prev) => prev.filter((op) => op.id !== id));
|
|
}, []);
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
operations,
|
|
startOperation,
|
|
updateOperationFromEvent,
|
|
completeOperation,
|
|
dismissOperation,
|
|
}),
|
|
[
|
|
operations,
|
|
startOperation,
|
|
updateOperationFromEvent,
|
|
completeOperation,
|
|
dismissOperation,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<SessionOperationsContext.Provider value={value}>
|
|
{children}
|
|
</SessionOperationsContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useSessionOperations = (): SessionOperationsContextType => {
|
|
const context = useContext(SessionOperationsContext);
|
|
if (context === undefined) {
|
|
throw new Error(
|
|
"useSessionOperations must be used within a SessionOperationsProvider",
|
|
);
|
|
}
|
|
return context;
|
|
};
|