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 type { Project } from "../types";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
|
import { SessionList } from "../components/session-list";
|
||||||
|
import { SessionCard } from "../components/session-card";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -31,8 +33,6 @@ export const HomePage = () => {
|
|||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
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 [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
@@ -149,12 +149,7 @@ export const HomePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (session: SessionView) => {
|
const handleStop = async (session: SessionView) => {
|
||||||
if (stopConfirmId !== session.id) {
|
|
||||||
setStopConfirmId(session.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActionBusy(session.id);
|
setActionBusy(session.id);
|
||||||
setStopConfirmId(null);
|
|
||||||
try {
|
try {
|
||||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
await loadHome();
|
await loadHome();
|
||||||
@@ -164,12 +159,7 @@ export const HomePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (session: SessionView) => {
|
const handleDelete = async (session: SessionView) => {
|
||||||
if (deleteConfirmId !== session.id) {
|
|
||||||
setDeleteConfirmId(session.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActionBusy(session.id);
|
setActionBusy(session.id);
|
||||||
setDeleteConfirmId(null);
|
|
||||||
try {
|
try {
|
||||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
setSessions((prev) => prev.filter((s) => s.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 (
|
return (
|
||||||
<section className="stack home-page">
|
<section className="stack home-page">
|
||||||
<header className="home-hero card">
|
<header className="home-hero card">
|
||||||
@@ -237,74 +237,20 @@ export const HomePage = () => {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Open sessions</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{activeSessions.length === 0 ? (
|
<SessionList
|
||||||
<p className="muted">No active sessions right now.</p>
|
sessions={safeSessions}
|
||||||
) : (
|
onOpen={handleOpen}
|
||||||
<div className="home-session-grid">
|
onStop={handleStop}
|
||||||
{activeSessions.map((session) => {
|
onDelete={handleDelete}
|
||||||
const health = tunnelHealth[session.id];
|
onRecreateTunnel={handleRecreateTunnel}
|
||||||
const isUnhealthy = health && !health.healthy;
|
actionBusyId={actionBusy}
|
||||||
return (
|
tunnelHealth={tunnelHealth}
|
||||||
<article className="card session-card" key={session.id}>
|
showGrouping={false}
|
||||||
<div className="stack-sm">
|
emptyMessage="No active sessions right now."
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card stack home-section">
|
<section className="card stack home-section">
|
||||||
@@ -350,25 +296,24 @@ export const HomePage = () => {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{recentSessions.length > 0 && (
|
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
|
||||||
<section className="card stack home-section">
|
<section className="card stack home-section">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Recent sessions</p>
|
<p className="eyebrow">Recent sessions</p>
|
||||||
<h2>{recentSessions.length}</h2>
|
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="recent-sessions-list">
|
<SessionList
|
||||||
{recentSessions.map((session) => (
|
sessions={safeSessions}
|
||||||
<article className="recent-session-item" key={session.id}>
|
onOpen={handleOpen}
|
||||||
<div className="recent-session-info">
|
onStart={handleStart}
|
||||||
<span className="recent-session-name">{session.display_name}</span>
|
onDelete={handleDelete}
|
||||||
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
actionBusyId={actionBusy}
|
||||||
</div>
|
showGrouping={false}
|
||||||
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
maxRecent={5}
|
||||||
</article>
|
emptyMessage="No recent sessions."
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+49
-324
@@ -16,6 +16,9 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
|
|||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
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";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -33,20 +36,7 @@ export const SessionsPage = () => {
|
|||||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||||
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 [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
const [loadingAction, setLoadingAction] = useState<string>("");
|
const [loadingAction, setLoadingAction] = useState<string>("");
|
||||||
|
|
||||||
@@ -125,7 +115,7 @@ export const SessionsPage = () => {
|
|||||||
probe_status: "unknown",
|
probe_status: "unknown",
|
||||||
last_probe_output: null,
|
last_probe_output: null,
|
||||||
error: "check failed",
|
error: "check failed",
|
||||||
},
|
} as InstanceHealth,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,16 +143,6 @@ export const SessionsPage = () => {
|
|||||||
void loadRepos();
|
void loadRepos();
|
||||||
}, [selectedProject]);
|
}, [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(
|
const lastSession = useMemo(
|
||||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||||
[sessions, lastSessionId]
|
[sessions, lastSessionId]
|
||||||
@@ -174,43 +154,55 @@ export const SessionsPage = () => {
|
|||||||
await loadSessions();
|
await loadSessions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
const handleStop = async (session: Session) => {
|
||||||
setLoadingSessionId(sessionId);
|
setLoadingSessionId(session.id);
|
||||||
setLoadingAction("Stopping...");
|
setLoadingAction("Stopping...");
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, sessionId);
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
setStopConfirmId(null);
|
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch {
|
} catch {
|
||||||
setStopConfirmId(null);
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
setLoadingAction("");
|
setLoadingAction("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
|
const handleDelete = async (session: Session) => {
|
||||||
setLoadingSessionId(sessionId);
|
setLoadingSessionId(session.id);
|
||||||
setLoadingAction("Deleting...");
|
setLoadingAction("Deleting...");
|
||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, sessionId, force);
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
setDeleteConfirmId(null);
|
|
||||||
setDirtyDeleteSession(null);
|
setDirtyDeleteSession(null);
|
||||||
setDirtyDeleteFiles([]);
|
setDirtyDeleteFiles([]);
|
||||||
// Remove from local state immediately
|
// Remove from local state immediately
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
||||||
if (axiosError.response?.status === 409) {
|
if (axiosError.response?.status === 409) {
|
||||||
const detail = axiosError.response.data?.detail;
|
const detail = axiosError.response.data?.detail;
|
||||||
if (detail?.changed_files) {
|
if (detail?.changed_files) {
|
||||||
setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null);
|
setDirtyDeleteSession(session);
|
||||||
setDirtyDeleteFiles(detail.changed_files);
|
setDirtyDeleteFiles(detail.changed_files);
|
||||||
setDeleteConfirmId(null);
|
|
||||||
return;
|
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 {
|
} finally {
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(null);
|
||||||
setLoadingAction("");
|
setLoadingAction("");
|
||||||
@@ -279,45 +271,18 @@ export const SessionsPage = () => {
|
|||||||
{lastSession && (
|
{lastSession && (
|
||||||
<div className="last-session-section">
|
<div className="last-session-section">
|
||||||
<h2>Last Session</h2>
|
<h2>Last Session</h2>
|
||||||
<div className="card last-session-card">
|
<SessionCard
|
||||||
<div className="last-session-info">
|
session={lastSession}
|
||||||
<h3>{lastSession.display_name}</h3>
|
onOpen={handleOpen}
|
||||||
<p className="muted">
|
onDelete={handleDelete}
|
||||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
isBusy={loadingSessionId === lastSession.id}
|
||||||
</p>
|
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Sessions */}
|
{/* Session List */}
|
||||||
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
|
<div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
|
||||||
{loadingSessionId && (
|
{loadingSessionId && (
|
||||||
<div className="loading-overlay">
|
<div className="loading-overlay">
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
@@ -326,250 +291,17 @@ export const SessionsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h2>
|
<SessionList
|
||||||
Active Sessions
|
sessions={sessions}
|
||||||
{activeSessions.length > 0 && (
|
onOpen={handleOpen}
|
||||||
<span className="badge">{activeSessions.length}</span>
|
onStop={handleStop}
|
||||||
)}
|
onDelete={handleDelete}
|
||||||
</h2>
|
onRecreateTunnel={handleRecreateTunnel}
|
||||||
{activeSessions.length === 0 ? (
|
actionBusyId={loadingSessionId}
|
||||||
<p className="muted">No active sessions</p>
|
tunnelHealth={tunnelHealth}
|
||||||
) : (
|
/>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 */}
|
{/* Create Session */}
|
||||||
<div className="create-session-section">
|
<div className="create-session-section">
|
||||||
<h2>Create New Session</h2>
|
<h2>Create New Session</h2>
|
||||||
@@ -612,14 +344,7 @@ export const SessionsPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="danger-button"
|
className="danger-button"
|
||||||
onClick={() =>
|
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||||
void handleDelete(
|
|
||||||
dirtyDeleteSession.id,
|
|
||||||
dirtyDeleteSession.project_id,
|
|
||||||
dirtyDeleteSession.repository_id,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Force Delete
|
Force Delete
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
## 1. Create SessionCard Component
|
## 1. Create SessionCard Component
|
||||||
|
|
||||||
- [ ] 1.1 Create `apps/web/src/components/session-card.tsx` with session display and actions
|
- [x] 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)
|
- [x] 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
|
- [x] 1.3 Display tool type name, session name, project/repo context
|
||||||
- [ ] 1.4 Add clone mode indicator and branch name when applicable
|
- [x] 1.4 Add clone mode indicator and branch name when applicable
|
||||||
- [ ] 1.5 Implement action buttons: Open (running), Start (stopped), Stop (running), Delete (all)
|
- [x] 1.5 Implement action buttons: Open (running), Start (stopped), Stop (running), Delete (all)
|
||||||
- [ ] 1.6 Add confirmation dialogs for Stop and Delete actions
|
- [x] 1.6 Add confirmation dialogs for Stop and Delete actions
|
||||||
- [ ] 1.7 Handle loading states during actions
|
- [x] 1.7 Handle loading states during actions
|
||||||
|
|
||||||
## 2. Create SessionList Component
|
## 2. Create SessionList Component
|
||||||
|
|
||||||
- [ ] 2.1 Create `apps/web/src/components/session-list.tsx` with grouping logic
|
- [x] 2.1 Create `apps/web/src/components/session-list.tsx` with grouping logic
|
||||||
- [ ] 2.2 Implement active sessions grouping (running, building, starting, probing)
|
- [x] 2.2 Implement active sessions grouping (running, building, starting, probing)
|
||||||
- [ ] 2.3 Implement recent sessions grouping (stopped, error, limited to last 5)
|
- [x] 2.3 Implement recent sessions grouping (stopped, error, limited to last 5)
|
||||||
- [ ] 2.4 Add section headers with counts
|
- [x] 2.4 Add section headers with counts
|
||||||
- [ ] 2.5 Handle empty states for each section
|
- [x] 2.5 Handle empty states for each section
|
||||||
- [ ] 2.6 Support filtering by status
|
- [x] 2.6 Support filtering by status
|
||||||
|
|
||||||
## 3. Update Dashboard Page
|
## 3. Update Dashboard Page
|
||||||
|
|
||||||
- [ ] 3.1 Import SessionCard and SessionList in dashboard.tsx
|
- [x] 3.1 Import SessionCard and SessionList in dashboard.tsx
|
||||||
- [ ] 3.2 Replace existing session grid rendering with SessionList
|
- [x] 3.2 Replace existing session grid rendering with SessionList
|
||||||
- [ ] 3.3 Remove duplicated session rendering code
|
- [x] 3.3 Remove duplicated session rendering code
|
||||||
- [ ] 3.4 Ensure "Open sessions" section uses unified components
|
- [x] 3.4 Ensure "Open sessions" section uses unified components
|
||||||
- [ ] 3.5 Ensure "Recent sessions" section uses unified components
|
- [x] 3.5 Ensure "Recent sessions" section uses unified components
|
||||||
|
|
||||||
## 4. Update Sessions Page
|
## 4. Update Sessions Page
|
||||||
|
|
||||||
- [ ] 4.1 Import SessionCard and SessionList in sessions.tsx
|
- [x] 4.1 Import SessionCard and SessionList in sessions.tsx
|
||||||
- [ ] 4.2 Replace existing session list rendering with SessionList
|
- [x] 4.2 Replace existing session list rendering with SessionList
|
||||||
- [ ] 4.3 Remove duplicated session rendering code
|
- [x] 4.3 Remove duplicated session rendering code
|
||||||
- [ ] 4.4 Keep page-level controls (create session button, filters)
|
- [x] 4.4 Keep page-level controls (create session button, filters)
|
||||||
- [ ] 4.5 Ensure "Last Session" section uses SessionCard
|
- [x] 4.5 Ensure "Last Session" section uses SessionCard
|
||||||
|
|
||||||
## 5. Styles and Polish
|
## 5. Styles and Polish
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user