Merge branch 'remove/session-operations-center' into dev
This commit is contained in:
@@ -8,10 +8,8 @@ import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|||||||
import { EventProvider } from "../state/events";
|
import { EventProvider } from "../state/events";
|
||||||
import { ToastProvider } from "../state/toast";
|
import { ToastProvider } from "../state/toast";
|
||||||
import { NotificationProvider } from "../state/notifications";
|
import { NotificationProvider } from "../state/notifications";
|
||||||
import { SessionOperationsProvider } from "../state/session-operations";
|
|
||||||
import { EventToastBridge } from "./features/notification/event-toast-bridge";
|
import { EventToastBridge } from "./features/notification/event-toast-bridge";
|
||||||
import { NotificationCenter } from "./features/notification/notification-center";
|
import { NotificationCenter } from "./features/notification/notification-center";
|
||||||
import { SessionProgressPanel } from "./features/session/session-progress-panel";
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { MobileNav } from "./features/mobile/mobile-nav";
|
import { MobileNav } from "./features/mobile/mobile-nav";
|
||||||
import { StartToolFAB } from "./features/tool/start-tool-fab";
|
import { StartToolFAB } from "./features/tool/start-tool-fab";
|
||||||
@@ -87,12 +85,10 @@ export const AppShell = () => {
|
|||||||
<EventProvider>
|
<EventProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<SessionOperationsProvider>
|
<EventToastBridge />
|
||||||
<EventToastBridge />
|
<div className="shell mobile-terminal-shell">
|
||||||
<div className="shell mobile-terminal-shell">
|
<Outlet />
|
||||||
<Outlet />
|
</div>
|
||||||
</div>
|
|
||||||
</SessionOperationsProvider>
|
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</EventProvider>
|
</EventProvider>
|
||||||
@@ -103,10 +99,8 @@ export const AppShell = () => {
|
|||||||
<EventProvider>
|
<EventProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<SessionOperationsProvider>
|
<EventToastBridge />
|
||||||
<EventToastBridge />
|
<div className="shell">
|
||||||
<SessionProgressPanel />
|
|
||||||
<div className="shell">
|
|
||||||
<header className="shell-header">
|
<header className="shell-header">
|
||||||
<Link className="brand" to="/">
|
<Link className="brand" to="/">
|
||||||
Headquarter
|
Headquarter
|
||||||
@@ -180,7 +174,6 @@ export const AppShell = () => {
|
|||||||
)}
|
)}
|
||||||
<StartToolFAB />
|
<StartToolFAB />
|
||||||
</div>
|
</div>
|
||||||
</SessionOperationsProvider>
|
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</EventProvider>
|
</EventProvider>
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import {
|
|
||||||
useSessionOperations,
|
|
||||||
type Operation,
|
|
||||||
} from "../../../state/session-operations";
|
|
||||||
import { useEventContext } from "../../../state/events";
|
|
||||||
import { Icon } from "../../icon";
|
|
||||||
|
|
||||||
interface StepConfig {
|
|
||||||
label: string;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps: StepConfig[] = [
|
|
||||||
{ label: "Created", index: 1 },
|
|
||||||
{ label: "Building", index: 2 },
|
|
||||||
{ label: "Starting", index: 3 },
|
|
||||||
{ label: "Ready", index: 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function OperationItem({
|
|
||||||
operation,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
operation: Operation;
|
|
||||||
onDismiss: () => void;
|
|
||||||
}) {
|
|
||||||
const isDone = operation.status === "success" || operation.status === "error";
|
|
||||||
const isError = operation.status === "error";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`session-operation-item ${operation.status}`}
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<div className="session-operation-header">
|
|
||||||
<div className="session-operation-title">
|
|
||||||
{isError ? (
|
|
||||||
<Icon name="error" size="sm" />
|
|
||||||
) : isDone ? (
|
|
||||||
<Icon name="success" size="sm" />
|
|
||||||
) : (
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
)}
|
|
||||||
<span className="session-operation-name">{operation.displayName}</span>
|
|
||||||
</div>
|
|
||||||
{isDone && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="session-operation-dismiss"
|
|
||||||
onClick={onDismiss}
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="session-operation-message">{operation.message}</p>
|
|
||||||
<div className="session-operation-steps">
|
|
||||||
{steps.map((step) => {
|
|
||||||
const active = operation.step >= step.index;
|
|
||||||
const current = operation.step === step.index && !isDone;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={step.label}
|
|
||||||
className={`session-operation-step ${active ? "active" : ""} ${current ? "current" : ""}`}
|
|
||||||
>
|
|
||||||
{step.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionProgressPanel() {
|
|
||||||
const { operations, updateOperationFromEvent, dismissOperation } =
|
|
||||||
useSessionOperations();
|
|
||||||
const { events } = useEventContext();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (events.length === 0) return;
|
|
||||||
const latestEvent = events[events.length - 1];
|
|
||||||
updateOperationFromEvent(latestEvent);
|
|
||||||
}, [events, updateOperationFromEvent]);
|
|
||||||
|
|
||||||
const visibleOperations = operations.filter(
|
|
||||||
(op) =>
|
|
||||||
op.status === "pending" ||
|
|
||||||
op.status === "active" ||
|
|
||||||
(op.status === "success" && Date.now() - op.createdAt < 5000) ||
|
|
||||||
op.status === "error",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (visibleOperations.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="session-progress-panel" role="region" aria-label="Session operations">
|
|
||||||
<div className="session-progress-panel-header">
|
|
||||||
<span className="session-progress-panel-title">Operations</span>
|
|
||||||
</div>
|
|
||||||
<div className="session-progress-panel-list">
|
|
||||||
{visibleOperations.map((operation) => (
|
|
||||||
<OperationItem
|
|
||||||
key={operation.id}
|
|
||||||
operation={operation}
|
|
||||||
onDismiss={() => dismissOperation(operation.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "../../../api/config-profiles";
|
} from "../../../api/config-profiles";
|
||||||
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
||||||
import { useSessions } from "../../../state/sessions";
|
import { useSessions } from "../../../state/sessions";
|
||||||
import { useSessionOperations } from "../../../state/session-operations";
|
|
||||||
import type { Workspace } from "../../../types/workspace";
|
import type { Workspace } from "../../../types/workspace";
|
||||||
import type { ToolInstance } from "../../../api/sessions";
|
import type { ToolInstance } from "../../../api/sessions";
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ export function ToolStarter({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: ToolStarterProps) {
|
}: ToolStarterProps) {
|
||||||
const { addOrUpdateSession } = useSessions();
|
const { addOrUpdateSession } = useSessions();
|
||||||
const { startOperation } = useSessionOperations();
|
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [toolTypesLoading, setToolTypesLoading] = useState(true);
|
const [toolTypesLoading, setToolTypesLoading] = useState(true);
|
||||||
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
|
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
|
||||||
@@ -156,7 +154,6 @@ export function ToolStarter({
|
|||||||
status: instance.status || "pending",
|
status: instance.status || "pending",
|
||||||
url: instance.url || null,
|
url: instance.url || null,
|
||||||
});
|
});
|
||||||
startOperation("create", instance.id, instance.display_name);
|
|
||||||
onStarted(instance);
|
onStarted(instance);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||||
@@ -171,7 +168,6 @@ export function ToolStarter({
|
|||||||
onStarted,
|
onStarted,
|
||||||
toolTypes,
|
toolTypes,
|
||||||
addOrUpdateSession,
|
addOrUpdateSession,
|
||||||
startOperation,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,17 +30,25 @@ describe("useEvents", () => {
|
|||||||
return EventSource.OPEN;
|
return EventSource.OPEN;
|
||||||
},
|
},
|
||||||
url: "http://localhost:8000/events/stream",
|
url: "http://localhost:8000/events/stream",
|
||||||
addEventListener: vi.fn((type: string, handler: (e: MessageEvent) => void) => {
|
addEventListener: vi.fn(
|
||||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
(type: string, handler: (e: MessageEvent) => void) => {
|
||||||
listeners.get(type)!.add(handler);
|
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||||
}),
|
listeners.get(type)!.add(handler);
|
||||||
removeEventListener: vi.fn((type: string, handler: (e: MessageEvent) => void) => {
|
},
|
||||||
listeners.get(type)?.delete(handler);
|
),
|
||||||
}),
|
removeEventListener: vi.fn(
|
||||||
|
(type: string, handler: (e: MessageEvent) => void) => {
|
||||||
|
listeners.get(type)?.delete(handler);
|
||||||
|
},
|
||||||
|
),
|
||||||
emit: (type: string, data: string) => {
|
emit: (type: string, data: string) => {
|
||||||
listeners.get(type)?.forEach((handler) => handler({ data } as MessageEvent));
|
listeners
|
||||||
|
.get(type)
|
||||||
|
?.forEach((handler) => handler({ data } as MessageEvent));
|
||||||
},
|
},
|
||||||
} as unknown as EventSource & { emit: (type: string, data: string) => void };
|
} as unknown as EventSource & {
|
||||||
|
emit: (type: string, data: string) => void;
|
||||||
|
};
|
||||||
mockedCreateEventSource.mockReturnValue(mockEs);
|
mockedCreateEventSource.mockReturnValue(mockEs);
|
||||||
mockedProbeEventStreamStatus.mockResolvedValue(null);
|
mockedProbeEventStreamStatus.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
import { useSessionOperations } from "../state/session-operations";
|
|
||||||
|
|
||||||
interface UseInstanceActionsOptions {
|
interface UseInstanceActionsOptions {
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
@@ -33,7 +32,6 @@ export function useInstanceActions(
|
|||||||
): UseInstanceActionsReturn {
|
): UseInstanceActionsReturn {
|
||||||
const { onRefresh } = options;
|
const { onRefresh } = options;
|
||||||
const { removeSession } = useSessions();
|
const { removeSession } = useSessions();
|
||||||
const { startOperation, completeOperation } = useSessionOperations();
|
|
||||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
|
||||||
null,
|
null,
|
||||||
@@ -66,7 +64,6 @@ export function useInstanceActions(
|
|||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
startOperation("start", session.id, session.display_name);
|
|
||||||
try {
|
try {
|
||||||
await startInstance(
|
await startInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -74,20 +71,17 @@ export function useInstanceActions(
|
|||||||
session.id,
|
session.id,
|
||||||
);
|
);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch {
|
|
||||||
completeOperation(session.id, "start", "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh, startOperation, completeOperation],
|
[loadingSessionId, onRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStop = useCallback(
|
const handleStop = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
startOperation("stop", session.id, session.display_name);
|
|
||||||
try {
|
try {
|
||||||
await stopInstance(
|
await stopInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -95,20 +89,17 @@ export function useInstanceActions(
|
|||||||
session.id,
|
session.id,
|
||||||
);
|
);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch {
|
|
||||||
completeOperation(session.id, "stop", "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh, startOperation, completeOperation],
|
[loadingSessionId, onRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
startOperation("delete", session.id, session.display_name);
|
|
||||||
try {
|
try {
|
||||||
await deleteInstance(
|
await deleteInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -120,7 +111,6 @@ export function useInstanceActions(
|
|||||||
removeSession(session.id);
|
removeSession(session.id);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completeOperation(session.id, "delete", "error");
|
|
||||||
const axiosError = error as {
|
const axiosError = error as {
|
||||||
response?: {
|
response?: {
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -139,20 +129,13 @@ export function useInstanceActions(
|
|||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[loadingSessionId, onRefresh, removeSession],
|
||||||
loadingSessionId,
|
|
||||||
onRefresh,
|
|
||||||
removeSession,
|
|
||||||
startOperation,
|
|
||||||
completeOperation,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleForceDelete = useCallback(
|
const handleForceDelete = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
startOperation("delete", session.id, session.display_name);
|
|
||||||
try {
|
try {
|
||||||
await deleteInstance(
|
await deleteInstance(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -164,26 +147,17 @@ export function useInstanceActions(
|
|||||||
setDirtyDeleteFiles([]);
|
setDirtyDeleteFiles([]);
|
||||||
removeSession(session.id);
|
removeSession(session.id);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch {
|
|
||||||
completeOperation(session.id, "delete", "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[loadingSessionId, onRefresh, removeSession],
|
||||||
loadingSessionId,
|
|
||||||
onRefresh,
|
|
||||||
removeSession,
|
|
||||||
startOperation,
|
|
||||||
completeOperation,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRecreateTunnel = useCallback(
|
const handleRecreateTunnel = useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
if (loadingSessionId === session.id) return;
|
if (loadingSessionId === session.id) return;
|
||||||
setLoadingSessionId(session.id);
|
setLoadingSessionId(session.id);
|
||||||
startOperation("recreate-tunnel", session.id, session.display_name);
|
|
||||||
try {
|
try {
|
||||||
await recreateInstanceTunnel(
|
await recreateInstanceTunnel(
|
||||||
session.project_id,
|
session.project_id,
|
||||||
@@ -191,18 +165,16 @@ export function useInstanceActions(
|
|||||||
session.id,
|
session.id,
|
||||||
);
|
);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
completeOperation(session.id, "recreate-tunnel", "success");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||||
?.detail || "Failed to recreate tunnel";
|
?.detail || "Failed to recreate tunnel";
|
||||||
completeOperation(session.id, "recreate-tunnel", "error", message);
|
|
||||||
alert(message);
|
alert(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadingSessionId, onRefresh, startOperation, completeOperation],
|
[loadingSessionId, onRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { HomePage } from "./DashboardPage";
|
import { HomePage } from "./DashboardPage";
|
||||||
import { SessionsProvider } from "../state/sessions";
|
import { SessionsProvider } from "../state/sessions";
|
||||||
import { SessionOperationsProvider } from "../state/session-operations";
|
|
||||||
|
|
||||||
const mockDashboard = vi.fn();
|
const mockDashboard = vi.fn();
|
||||||
const mockSessions = vi.fn();
|
const mockSessions = vi.fn();
|
||||||
@@ -59,9 +58,7 @@ describe("HomePage", () => {
|
|||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<SessionsProvider>
|
<SessionsProvider>
|
||||||
<SessionOperationsProvider>
|
|
||||||
<HomePage />
|
<HomePage />
|
||||||
</SessionOperationsProvider>
|
|
||||||
</SessionsProvider>
|
</SessionsProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
@@ -81,9 +78,7 @@ describe("HomePage", () => {
|
|||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<SessionsProvider>
|
<SessionsProvider>
|
||||||
<SessionOperationsProvider>
|
|
||||||
<HomePage />
|
<HomePage />
|
||||||
</SessionOperationsProvider>
|
|
||||||
</SessionsProvider>
|
</SessionsProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -383,129 +383,6 @@ a.nav-item,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Session Operations Progress Panel
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.session-progress-panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: var(--space-4);
|
|
||||||
right: var(--space-4);
|
|
||||||
width: min(360px, calc(100vw - 2rem));
|
|
||||||
max-height: min(480px, 60vh);
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--space-2);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-progress-panel-header {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-progress-panel-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-item {
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-item.error {
|
|
||||||
border-color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-title .icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-message {
|
|
||||||
margin: var(--space-1) 0 0;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-steps {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-step {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-step.active {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-step.current {
|
|
||||||
box-shadow: 0 0 0 1px var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-operation-dismiss {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--space-1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.session-progress-panel {
|
|
||||||
left: var(--space-2);
|
|
||||||
right: var(--space-2);
|
|
||||||
bottom: calc(var(--space-2) + 64px);
|
|
||||||
width: auto;
|
|
||||||
max-height: 35vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Terminal Styles
|
Terminal Styles
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
Reference in New Issue
Block a user