241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
import { useState } from "react";
|
|
import type { Session } from "../api/sessions";
|
|
import { Icon } from "./icon";
|
|
|
|
export interface SessionCardProps {
|
|
session: Session;
|
|
onOpen?: (session: Session) => void;
|
|
onStart?: (session: Session) => void;
|
|
onStop?: (session: Session) => void;
|
|
onDelete?: (session: Session) => void;
|
|
onRecreateTunnel?: (session: Session) => void;
|
|
isBusy?: boolean;
|
|
tunnelHealth?: {
|
|
healthy: boolean;
|
|
container_status: string;
|
|
container_health: string | null;
|
|
tunnel_status: string;
|
|
tunnel_status_code: number | null;
|
|
probe_status: string;
|
|
last_probe_output: string | null;
|
|
error: string | null;
|
|
} | null;
|
|
}
|
|
|
|
const statusConfig: Record<string, { color: string; label: string }> = {
|
|
running: { color: "green", label: "Running" },
|
|
building: { color: "yellow", label: "Building" },
|
|
starting: { color: "yellow", label: "Starting" },
|
|
probing: { color: "yellow", label: "Probing" },
|
|
pending: { color: "yellow", label: "Pending" },
|
|
stopped: { color: "gray", label: "Stopped" },
|
|
error: { color: "red", label: "Error" },
|
|
unhealthy: { color: "orange", label: "Unhealthy" },
|
|
};
|
|
|
|
export function SessionCard({
|
|
session,
|
|
onOpen,
|
|
onStart,
|
|
onStop,
|
|
onDelete,
|
|
onRecreateTunnel,
|
|
isBusy = false,
|
|
tunnelHealth = null,
|
|
}: SessionCardProps) {
|
|
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
|
|
const status = statusConfig[session.status] || { color: "gray", label: session.status };
|
|
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
|
|
const hasTunnelError = !isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
|
const hasAppError = !isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
|
|
|
const handleStop = () => {
|
|
if (showStopConfirm) {
|
|
setShowStopConfirm(false);
|
|
onStop?.(session);
|
|
} else {
|
|
setShowStopConfirm(true);
|
|
}
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (showDeleteConfirm) {
|
|
setShowDeleteConfirm(false);
|
|
onDelete?.(session);
|
|
} else {
|
|
setShowDeleteConfirm(true);
|
|
}
|
|
};
|
|
|
|
const handleCancelStop = () => setShowStopConfirm(false);
|
|
const handleCancelDelete = () => setShowDeleteConfirm(false);
|
|
|
|
const isActive = ["running", "building", "starting", "probing", "pending", "unhealthy"].includes(session.status);
|
|
|
|
return (
|
|
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
|
{isBusy && (
|
|
<div className="session-busy-overlay">
|
|
<Icon name="loading" size="md" />
|
|
</div>
|
|
)}
|
|
<div className="session-card-content">
|
|
<div className="session-card-header">
|
|
<div className="session-card-title">
|
|
<h4>{session.display_name}</h4>
|
|
<div className="session-card-status-badges">
|
|
<span className={`status-badge ${status.color}`}>{status.label}</span>
|
|
{hasTunnelError && (
|
|
<span className="status-badge error">Tunnel Error</span>
|
|
)}
|
|
{hasAppError && (
|
|
<span className="status-badge warning">App Error {tunnelHealth?.tunnel_status_code}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="muted session-card-meta">
|
|
{session.tool_type_name}
|
|
{session.project_name && ` · ${session.project_name}`}
|
|
{session.repository_name && ` · ${session.repository_name}`}
|
|
</p>
|
|
{session.clone_mode && (
|
|
<p className="muted session-card-meta">
|
|
<Icon name="branch" size="sm" />
|
|
{session.clone_mode === "clone"
|
|
? `Clone${session.branch ? ` (${session.branch})` : ""}`
|
|
: "Mount"}
|
|
</p>
|
|
)}
|
|
{session.url && (
|
|
<p className="session-card-url">
|
|
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
|
{session.url}
|
|
</a>
|
|
</p>
|
|
)}
|
|
{session.created_at && (
|
|
<p className="muted session-card-meta">
|
|
Created: {new Date(session.created_at).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="session-card-actions">
|
|
{isActive && (
|
|
<>
|
|
{session.url ? (
|
|
<a
|
|
href={session.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="secondary-button small"
|
|
>
|
|
<Icon name="external" size="sm" />
|
|
<span className="action-label">Open</span>
|
|
</a>
|
|
) : (
|
|
<button
|
|
className="secondary-button small"
|
|
onClick={() => onOpen?.(session)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="external" size="sm" />
|
|
<span className="action-label">Open</span>
|
|
</button>
|
|
)}
|
|
|
|
{hasTunnelError && onRecreateTunnel && (
|
|
<button
|
|
className="secondary-button small"
|
|
onClick={() => onRecreateTunnel(session)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="refresh" size="sm" />
|
|
<span className="action-label">Tunnel</span>
|
|
</button>
|
|
)}
|
|
|
|
{showStopConfirm ? (
|
|
<div className="confirm-inline">
|
|
<span className="confirm-text">Stop?</span>
|
|
<button
|
|
className="danger-button small"
|
|
onClick={handleStop}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
Stop
|
|
</button>
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={handleCancelStop}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={handleStop}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="stop" size="sm" />
|
|
<span className="action-label">Stop</span>
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!isActive && onStart && (
|
|
<button
|
|
className="secondary-button small"
|
|
onClick={() => onStart(session)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="play" size="sm" />
|
|
<span className="action-label">Start</span>
|
|
</button>
|
|
)}
|
|
|
|
{showDeleteConfirm ? (
|
|
<div className="confirm-inline">
|
|
<span className="confirm-text">Delete?</span>
|
|
<button
|
|
className="danger-button small"
|
|
onClick={handleDelete}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
Delete
|
|
</button>
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={handleCancelDelete}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="ghost-button small danger-text"
|
|
onClick={handleDelete}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|