Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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">
|
||||
<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.port && session.status === "running" && (
|
||||
<p className="muted session-card-meta">Port: {session.port}</p>
|
||||
)}
|
||||
{session.created_at && (
|
||||
<p className="muted session-card-meta">
|
||||
Created: {new Date(session.created_at).toLocaleDateString()}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Session } from "../api/sessions";
|
||||
import { SessionCard } from "./session-card";
|
||||
import type { InstanceHealth } from "../api/sessions";
|
||||
|
||||
export interface SessionListProps {
|
||||
sessions: Session[];
|
||||
onOpen?: (session: Session) => void;
|
||||
onStart?: (session: Session) => void;
|
||||
onStop?: (session: Session) => void;
|
||||
onDelete?: (session: Session) => void;
|
||||
onRecreateTunnel?: (session: Session) => void;
|
||||
actionBusyId?: string | null;
|
||||
tunnelHealth?: Record<string, InstanceHealth>;
|
||||
showGrouping?: boolean;
|
||||
activeTitle?: string;
|
||||
recentTitle?: string;
|
||||
maxRecent?: number;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
|
||||
const recentStatuses = ["stopped", "error"];
|
||||
|
||||
export function SessionList({
|
||||
sessions,
|
||||
onOpen,
|
||||
onStart,
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
actionBusyId = null,
|
||||
tunnelHealth = {},
|
||||
showGrouping = true,
|
||||
activeTitle = "Active Sessions",
|
||||
recentTitle = "Recent Sessions",
|
||||
maxRecent = 5,
|
||||
emptyMessage = "No sessions",
|
||||
}: SessionListProps) {
|
||||
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
|
||||
const recentSessions = sessions
|
||||
.filter((s) => recentStatuses.includes(s.status))
|
||||
.slice(0, maxRecent);
|
||||
|
||||
if (!showGrouping) {
|
||||
return (
|
||||
<div className="sessions-grid">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="muted">{emptyMessage}</p>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onOpen={onOpen}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="session-list">
|
||||
{/* Active Sessions */}
|
||||
<div className="session-group">
|
||||
<div className="session-group-header">
|
||||
<h3>{activeTitle}</h3>
|
||||
{activeSessions.length > 0 && (
|
||||
<span className="badge">{activeSessions.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions</p>
|
||||
) : (
|
||||
<div className="sessions-grid">
|
||||
{activeSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onOpen={onOpen}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
{recentSessions.length > 0 && (
|
||||
<div className="session-group">
|
||||
<div className="session-group-header">
|
||||
<h3>{recentTitle}</h3>
|
||||
<span className="badge">{recentSessions.length}</span>
|
||||
</div>
|
||||
<div className="sessions-grid">
|
||||
{recentSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onOpen={onOpen}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { Icon } from "../components/icon";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { SessionCard } from "../components/session-card";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -31,8 +33,6 @@ export const HomePage = () => {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
@@ -149,12 +149,7 @@ export const HomePage = () => {
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
if (stopConfirmId !== session.id) {
|
||||
setStopConfirmId(session.id);
|
||||
return;
|
||||
}
|
||||
setActionBusy(session.id);
|
||||
setStopConfirmId(null);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
@@ -164,12 +159,7 @@ export const HomePage = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
if (deleteConfirmId !== session.id) {
|
||||
setDeleteConfirmId(session.id);
|
||||
return;
|
||||
}
|
||||
setActionBusy(session.id);
|
||||
setDeleteConfirmId(null);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
@@ -190,6 +180,16 @@ export const HomePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (session: SessionView) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await startInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
@@ -237,74 +237,20 @@ export const HomePage = () => {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{activeSessions.length}</h2>
|
||||
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions right now.</p>
|
||||
) : (
|
||||
<div className="home-session-grid">
|
||||
{activeSessions.map((session) => {
|
||||
const health = tunnelHealth[session.id];
|
||||
const isUnhealthy = health && !health.healthy;
|
||||
return (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name}</h3>
|
||||
<div className="row row-tight">
|
||||
{isUnhealthy && (
|
||||
<span className="status-badge error" title={health.error || "unhealthy"}>!</span>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
{stopConfirmId === session.id ? (
|
||||
<div className="stop-confirm-inline">
|
||||
<span className="confirm-text">Stop?</span>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" /> Stop
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => setStopConfirmId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
{deleteConfirmId === session.id ? (
|
||||
<div className="delete-confirm-inline">
|
||||
<span className="confirm-text">Delete?</span>
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
<button className="ghost-button small" type="button" onClick={() => setDeleteConfirmId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={actionBusy}
|
||||
tunnelHealth={tunnelHealth}
|
||||
showGrouping={false}
|
||||
emptyMessage="No active sessions right now."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
@@ -350,25 +296,24 @@ export const HomePage = () => {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{recentSessions.length}</h2>
|
||||
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{recentSessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">{session.display_name}</span>
|
||||
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onDelete={handleDelete}
|
||||
actionBusyId={actionBusy}
|
||||
showGrouping={false}
|
||||
maxRecent={5}
|
||||
emptyMessage="No recent sessions."
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
||||
+49
-324
@@ -16,6 +16,9 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { Icon } from "../components/icon";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { SessionCard } from "../components/session-card";
|
||||
import type { InstanceHealth } from "../api/sessions";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -33,20 +36,7 @@ export const SessionsPage = () => {
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
|
||||
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;
|
||||
}>>({});
|
||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [loadingAction, setLoadingAction] = useState<string>("");
|
||||
|
||||
@@ -125,7 +115,7 @@ export const SessionsPage = () => {
|
||||
probe_status: "unknown",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
} as InstanceHealth,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -153,16 +143,6 @@ export const SessionsPage = () => {
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const lastSession = useMemo(
|
||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||
[sessions, lastSessionId]
|
||||
@@ -174,43 +154,55 @@ export const SessionsPage = () => {
|
||||
await loadSessions();
|
||||
};
|
||||
|
||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||
setLoadingSessionId(sessionId);
|
||||
const handleStop = async (session: Session) => {
|
||||
setLoadingSessionId(session.id);
|
||||
setLoadingAction("Stopping...");
|
||||
try {
|
||||
await stopInstance(projectId, repoId, sessionId);
|
||||
setStopConfirmId(null);
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadSessions();
|
||||
} catch {
|
||||
setStopConfirmId(null);
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
|
||||
setLoadingSessionId(sessionId);
|
||||
const handleDelete = async (session: Session) => {
|
||||
setLoadingSessionId(session.id);
|
||||
setLoadingAction("Deleting...");
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, sessionId, force);
|
||||
setDeleteConfirmId(null);
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
// Remove from local state immediately
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch (error) {
|
||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
const detail = axiosError.response.data?.detail;
|
||||
if (detail?.changed_files) {
|
||||
setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null);
|
||||
setDirtyDeleteSession(session);
|
||||
setDirtyDeleteFiles(detail.changed_files);
|
||||
setDeleteConfirmId(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDeleteConfirmId(null);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceDelete = async (session: Session) => {
|
||||
setLoadingSessionId(session.id);
|
||||
setLoadingAction("Force deleting...");
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
setLoadingAction("");
|
||||
@@ -279,45 +271,18 @@ export const SessionsPage = () => {
|
||||
{lastSession && (
|
||||
<div className="last-session-section">
|
||||
<h2>Last Session</h2>
|
||||
<div className="card last-session-card">
|
||||
<div className="last-session-info">
|
||||
<h3>{lastSession.display_name}</h3>
|
||||
<p className="muted">
|
||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
||||
</p>
|
||||
{lastSession.url && (
|
||||
<p className="session-url">
|
||||
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
|
||||
{lastSession.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
||||
</div>
|
||||
<div className="last-session-actions">
|
||||
{lastSession.url ? (
|
||||
<a
|
||||
href={lastSession.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="primary-button"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<button className="primary-button" onClick={handleResumeLast} type="button">
|
||||
<Icon name="play" size="sm" />
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SessionCard
|
||||
session={lastSession}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
isBusy={loadingSessionId === lastSession.id}
|
||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Sessions */}
|
||||
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
|
||||
{/* Session List */}
|
||||
<div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
|
||||
{loadingSessionId && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
@@ -326,250 +291,17 @@ export const SessionsPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2>
|
||||
Active Sessions
|
||||
{activeSessions.length > 0 && (
|
||||
<span className="badge">{activeSessions.length}</span>
|
||||
)}
|
||||
</h2>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions</p>
|
||||
) : (
|
||||
<div className="sessions-grid">
|
||||
{activeSessions.map((session) => {
|
||||
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
|
||||
return (
|
||||
<div className="card session-card" key={session.id}>
|
||||
<div className="session-info">
|
||||
<h4>{session.display_name}</h4>
|
||||
<p className="muted">
|
||||
{session.tool_type_name} · {session.project_name}
|
||||
</p>
|
||||
{session.created_at && (
|
||||
<p className="session-meta">
|
||||
Started: {new Date(session.created_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
{session.clone_mode && (
|
||||
<p className="session-meta">
|
||||
Repo: {session.clone_mode === "clone" ? `clone (${session.branch || "main"})` : "mount"}
|
||||
</p>
|
||||
)}
|
||||
{session.url && (
|
||||
<p className="session-url">
|
||||
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||
{session.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
{session.status === "starting" && (
|
||||
<span className="status-badge starting">starting...</span>
|
||||
)}
|
||||
{session.status === "probing" && (
|
||||
<span className="status-badge probing">checking...</span>
|
||||
)}
|
||||
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||
<span className="status-badge error">tunnel error</span>
|
||||
)}
|
||||
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
|
||||
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
|
||||
)}
|
||||
{!isTerminalOnly && tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
|
||||
<div className="probe-output-section">
|
||||
<button
|
||||
className={`probe-toggle probe-${tunnelHealth[session.id].probe_status}`}
|
||||
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="info" size="sm" />
|
||||
Probe: {tunnelHealth[session.id].probe_status}
|
||||
{expandedProbeId === session.id ? " (hide)" : " (show)"}
|
||||
</button>
|
||||
{expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
|
||||
<pre className="probe-output">
|
||||
{tunnelHealth[session.id].last_probe_output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
{session.url ? (
|
||||
<a
|
||||
href={session.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => handleOpen(session)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleRecreateTunnel(session)}
|
||||
type="button"
|
||||
disabled={recreatingId === session.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
|
||||
</button>
|
||||
)}
|
||||
{stopConfirmId === session.id ? (
|
||||
<div className="stop-confirm-inline">
|
||||
<span className="confirm-text">Stop?</span>
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={() =>
|
||||
void handleStop(
|
||||
session.id,
|
||||
session.project_id,
|
||||
session.repository_id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => setStopConfirmId(session.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
{deleteConfirmId === session.id ? (
|
||||
<div className="delete-confirm-inline">
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={() =>
|
||||
void handleDelete(
|
||||
session.id,
|
||||
session.project_id,
|
||||
session.repository_id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => setDeleteConfirmId(session.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
onOpen={handleOpen}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={loadingSessionId}
|
||||
tunnelHealth={tunnelHealth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
{recentSessions.length > 0 && (
|
||||
<div className="recent-sessions-section">
|
||||
<h2>Recent Sessions</h2>
|
||||
<div className="recent-sessions-list">
|
||||
{recentSessions.map((session) => (
|
||||
<div className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">{session.display_name}</span>
|
||||
<span className="muted">
|
||||
{session.tool_type_name} · {session.project_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="recent-session-actions">
|
||||
{session.url ? (
|
||||
<a
|
||||
href={session.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ghost-button small"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => handleOpen(session)}
|
||||
type="button"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{deleteConfirmId === session.id ? (
|
||||
<div className="delete-confirm-inline">
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={() =>
|
||||
void handleDelete(
|
||||
session.id,
|
||||
session.project_id,
|
||||
session.repository_id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => setDeleteConfirmId(session.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Session */}
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
@@ -612,14 +344,7 @@ export const SessionsPage = () => {
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() =>
|
||||
void handleDelete(
|
||||
dirtyDeleteSession.id,
|
||||
dirtyDeleteSession.project_id,
|
||||
dirtyDeleteSession.repository_id,
|
||||
true
|
||||
)
|
||||
}
|
||||
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||
type="button"
|
||||
>
|
||||
Force Delete
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
## 1. Create SessionCard Component
|
||||
|
||||
- [ ] 1.1 Create `apps/web/src/components/session-card.tsx` with session display and actions
|
||||
- [ ] 1.2 Implement status indicator with color coding (running=green, building=yellow, stopped=gray, error=red)
|
||||
- [ ] 1.3 Display tool type name, session name, project/repo context
|
||||
- [ ] 1.4 Add clone mode indicator and branch name when applicable
|
||||
- [ ] 1.5 Implement action buttons: Open (running), Start (stopped), Stop (running), Delete (all)
|
||||
- [ ] 1.6 Add confirmation dialogs for Stop and Delete actions
|
||||
- [ ] 1.7 Handle loading states during actions
|
||||
- [x] 1.1 Create `apps/web/src/components/session-card.tsx` with session display and actions
|
||||
- [x] 1.2 Implement status indicator with color coding (running=green, building=yellow, stopped=gray, error=red)
|
||||
- [x] 1.3 Display tool type name, session name, project/repo context
|
||||
- [x] 1.4 Add clone mode indicator and branch name when applicable
|
||||
- [x] 1.5 Implement action buttons: Open (running), Start (stopped), Stop (running), Delete (all)
|
||||
- [x] 1.6 Add confirmation dialogs for Stop and Delete actions
|
||||
- [x] 1.7 Handle loading states during actions
|
||||
|
||||
## 2. Create SessionList Component
|
||||
|
||||
- [ ] 2.1 Create `apps/web/src/components/session-list.tsx` with grouping logic
|
||||
- [ ] 2.2 Implement active sessions grouping (running, building, starting, probing)
|
||||
- [ ] 2.3 Implement recent sessions grouping (stopped, error, limited to last 5)
|
||||
- [ ] 2.4 Add section headers with counts
|
||||
- [ ] 2.5 Handle empty states for each section
|
||||
- [ ] 2.6 Support filtering by status
|
||||
- [x] 2.1 Create `apps/web/src/components/session-list.tsx` with grouping logic
|
||||
- [x] 2.2 Implement active sessions grouping (running, building, starting, probing)
|
||||
- [x] 2.3 Implement recent sessions grouping (stopped, error, limited to last 5)
|
||||
- [x] 2.4 Add section headers with counts
|
||||
- [x] 2.5 Handle empty states for each section
|
||||
- [x] 2.6 Support filtering by status
|
||||
|
||||
## 3. Update Dashboard Page
|
||||
|
||||
- [ ] 3.1 Import SessionCard and SessionList in dashboard.tsx
|
||||
- [ ] 3.2 Replace existing session grid rendering with SessionList
|
||||
- [ ] 3.3 Remove duplicated session rendering code
|
||||
- [ ] 3.4 Ensure "Open sessions" section uses unified components
|
||||
- [ ] 3.5 Ensure "Recent sessions" section uses unified components
|
||||
- [x] 3.1 Import SessionCard and SessionList in dashboard.tsx
|
||||
- [x] 3.2 Replace existing session grid rendering with SessionList
|
||||
- [x] 3.3 Remove duplicated session rendering code
|
||||
- [x] 3.4 Ensure "Open sessions" section uses unified components
|
||||
- [x] 3.5 Ensure "Recent sessions" section uses unified components
|
||||
|
||||
## 4. Update Sessions Page
|
||||
|
||||
- [ ] 4.1 Import SessionCard and SessionList in sessions.tsx
|
||||
- [ ] 4.2 Replace existing session list rendering with SessionList
|
||||
- [ ] 4.3 Remove duplicated session rendering code
|
||||
- [ ] 4.4 Keep page-level controls (create session button, filters)
|
||||
- [ ] 4.5 Ensure "Last Session" section uses SessionCard
|
||||
- [x] 4.1 Import SessionCard and SessionList in sessions.tsx
|
||||
- [x] 4.2 Replace existing session list rendering with SessionList
|
||||
- [x] 4.3 Remove duplicated session rendering code
|
||||
- [x] 4.4 Keep page-level controls (create session button, filters)
|
||||
- [x] 4.5 Ensure "Last Session" section uses SessionCard
|
||||
|
||||
## 5. Styles and Polish
|
||||
|
||||
|
||||
Reference in New Issue
Block a user