refactor: unify session creation form into CreateSessionForm component
This commit is contained in:
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
||||||
|
import type { Project } from "../types";
|
||||||
|
import type { GitRepository } from "../api/git_repositories";
|
||||||
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
|
|
||||||
|
interface CreateSessionFormProps {
|
||||||
|
projects: Project[];
|
||||||
|
repositories: GitRepository[];
|
||||||
|
toolTypes: ToolType[];
|
||||||
|
fixedProjectId?: string;
|
||||||
|
fixedRepoId?: string;
|
||||||
|
showCloneMode?: boolean;
|
||||||
|
onProjectChange?: (projectId: string) => void;
|
||||||
|
onSuccess?: (instance: ToolInstance) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateSessionForm = ({
|
||||||
|
projects,
|
||||||
|
repositories,
|
||||||
|
toolTypes,
|
||||||
|
fixedProjectId,
|
||||||
|
fixedRepoId,
|
||||||
|
showCloneMode = false,
|
||||||
|
onProjectChange,
|
||||||
|
onSuccess,
|
||||||
|
onCancel,
|
||||||
|
submitLabel = "Create Session",
|
||||||
|
className = "",
|
||||||
|
}: CreateSessionFormProps) => {
|
||||||
|
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||||
|
const [branch, setBranch] = useState("main");
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load SSH keys when clone mode is shown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCloneMode) return;
|
||||||
|
const loadKeys = async () => {
|
||||||
|
try {
|
||||||
|
const keys = await listSSHKeys();
|
||||||
|
setSshKeys(keys);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadKeys();
|
||||||
|
}, [showCloneMode]);
|
||||||
|
|
||||||
|
// Filter repositories by selected project
|
||||||
|
const availableRepos = selectedProject
|
||||||
|
? repositories.filter((r) => r.project_id === selectedProject)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const projectId = fixedProjectId || selectedProject;
|
||||||
|
const repoId = fixedRepoId || selectedRepo;
|
||||||
|
|
||||||
|
if (!projectId || !repoId || !selectedToolType) {
|
||||||
|
setError("Project, repository, and tool type are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCloneMode && cloneMode === "clone") {
|
||||||
|
const repo = repositories.find((r) => r.id === repoId);
|
||||||
|
if (!repo?.ssh_key_id) {
|
||||||
|
setError("Repository must have an SSH key assigned for clone mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("creating");
|
||||||
|
setProgress("Creating instance...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
selectedToolType,
|
||||||
|
displayName || undefined,
|
||||||
|
showCloneMode ? cloneMode : undefined,
|
||||||
|
showCloneMode && cloneMode === "clone" ? branch : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
setProgress("Starting container...");
|
||||||
|
await startInstance(projectId, repoId, instance.id);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
if (!fixedProjectId) setSelectedProject("");
|
||||||
|
if (!fixedRepoId) setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setDisplayName("");
|
||||||
|
setCloneMode("mount");
|
||||||
|
setBranch("main");
|
||||||
|
setStatus("idle");
|
||||||
|
|
||||||
|
onSuccess?.(instance);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
setError("Failed to create session");
|
||||||
|
setProgress("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubmitting = status === "creating";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`create-session-form-wrapper ${className}`}>
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="loading-content">
|
||||||
|
<Icon name="loading" size="lg" />
|
||||||
|
<p>{progress || "Creating session..."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="stack create-session-form">
|
||||||
|
<div className="form-row">
|
||||||
|
{!fixedProjectId && (
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSelectedProject(value);
|
||||||
|
setSelectedRepo("");
|
||||||
|
onProjectChange?.(value);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fixedRepoId && (
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select
|
||||||
|
value={selectedRepo}
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
disabled={!selectedProject || isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{availableRepos.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Tool Type
|
||||||
|
<select
|
||||||
|
value={selectedToolType}
|
||||||
|
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCloneMode && (
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Repository Access
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cloneMode"
|
||||||
|
value="mount"
|
||||||
|
checked={cloneMode === "mount"}
|
||||||
|
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
Mount (live sync)
|
||||||
|
</label>
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cloneMode"
|
||||||
|
value="clone"
|
||||||
|
checked={cloneMode === "clone"}
|
||||||
|
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
Clone fresh copy
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cloneMode === "clone" && (
|
||||||
|
<label className="form-field">
|
||||||
|
Branch
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
placeholder="main"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="form-field">
|
||||||
|
Display Name (optional)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My Development Environment"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
{submitLabel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,7 +4,6 @@ import { Icon } from "./icon";
|
|||||||
import type { ToolInstance } from "../api/sessions";
|
import type { ToolInstance } from "../api/sessions";
|
||||||
import {
|
import {
|
||||||
checkInstanceHealth,
|
checkInstanceHealth,
|
||||||
createInstance,
|
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
listInstances,
|
listInstances,
|
||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
stopInstance,
|
stopInstance,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
@@ -27,8 +27,6 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [selectedToolType, setSelectedToolType] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Stop confirmation
|
// Stop confirmation
|
||||||
@@ -83,18 +81,9 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [instances, projectId, repoId]);
|
}, [instances, projectId, repoId]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreateSuccess = async () => {
|
||||||
if (!selectedToolType) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
|
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setSelectedToolType("");
|
|
||||||
setDisplayName("");
|
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
|
||||||
setError("Failed to create instance");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = async (instanceId: string) => {
|
const handleStart = async (instanceId: string) => {
|
||||||
@@ -309,48 +298,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h2>Launch Tool</h2>
|
<h2>Launch Tool</h2>
|
||||||
<div className="stack">
|
<CreateSessionForm
|
||||||
<label className="form-field">
|
projects={[]}
|
||||||
Tool Type
|
repositories={[]}
|
||||||
<select
|
toolTypes={toolTypes}
|
||||||
value={selectedToolType}
|
fixedProjectId={projectId}
|
||||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
fixedRepoId={repoId}
|
||||||
>
|
onSuccess={handleCreateSuccess}
|
||||||
<option value="">Select a tool...</option>
|
onCancel={() => setShowCreate(false)}
|
||||||
{toolTypes.map((tool) => (
|
submitLabel="Launch"
|
||||||
<option key={tool.id} value={tool.id}>
|
|
||||||
{tool.display_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Display Name (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="My Development Environment"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => setShowCreate(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => void handleCreate()}
|
|
||||||
disabled={!selectedToolType}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Launch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { updateUserConfig } from "../api/settings";
|
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";
|
||||||
|
|
||||||
type HomeStatus = "loading" | "ready" | "error";
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -29,10 +30,6 @@ export const HomePage = () => {
|
|||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
const [selectedRepo, setSelectedRepo] = useState("");
|
|
||||||
const [selectedToolType, setSelectedToolType] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
|
||||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
@@ -133,24 +130,10 @@ export const HomePage = () => {
|
|||||||
[safeSessions]
|
[safeSessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async (event: React.FormEvent) => {
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||||
event.preventDefault();
|
|
||||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
|
||||||
|
|
||||||
setSaveState("saving");
|
|
||||||
try {
|
|
||||||
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
|
||||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setDisplayName("");
|
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
setSelectedRepo("");
|
|
||||||
setSelectedToolType("");
|
|
||||||
setSaveState("idle");
|
|
||||||
await loadHome();
|
await loadHome();
|
||||||
} catch {
|
|
||||||
setSaveState("error");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = (session: SessionView) => {
|
const handleOpen = (session: SessionView) => {
|
||||||
@@ -358,41 +341,13 @@ export const HomePage = () => {
|
|||||||
<h2>Start a session</h2>
|
<h2>Start a session</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
<CreateSessionForm
|
||||||
<div className="form-row">
|
projects={projects}
|
||||||
<label className="form-field">
|
repositories={repositories}
|
||||||
Project
|
toolTypes={toolTypes}
|
||||||
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
||||||
<option value="">Select project...</option>
|
onSuccess={handleCreateSuccess}
|
||||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
|
||||||
<option value="">Select repository...</option>
|
|
||||||
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Tool type
|
|
||||||
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
|
||||||
<option value="">Select tool...</option>
|
|
||||||
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="form-field">
|
|
||||||
Display name
|
|
||||||
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
|
||||||
</label>
|
|
||||||
<div className="form-actions">
|
|
||||||
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
|
||||||
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
|
||||||
</button>
|
|
||||||
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{recentSessions.length > 0 && (
|
{recentSessions.length > 0 && (
|
||||||
|
|||||||
+12
-229
@@ -14,13 +14,11 @@ import {
|
|||||||
recreateInstanceTunnel,
|
recreateInstanceTunnel,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { createInstance } from "../api/sessions";
|
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
|
|
||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
type CreateStatus = "idle" | "creating" | "error";
|
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -31,18 +29,7 @@ export const SessionsPage = () => {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
|
||||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
|
||||||
const [createProgress, setCreateProgress] = useState("");
|
|
||||||
|
|
||||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
|
||||||
const [branch, setBranch] = useState("main");
|
|
||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
|
||||||
|
|
||||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
@@ -107,17 +94,7 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadSshKeys = async () => {
|
|
||||||
try {
|
|
||||||
const data = await listSSHKeys();
|
|
||||||
setSshKeys(data);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void loadSshKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Poll health every 30 seconds for active instances
|
// Poll health every 30 seconds for active instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -191,58 +168,10 @@ export const SessionsPage = () => {
|
|||||||
[sessions, lastSessionId]
|
[sessions, lastSessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||||
e.preventDefault();
|
|
||||||
setCreateError(null);
|
|
||||||
|
|
||||||
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
|
||||||
setCreateError("Project, repository, and tool type are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cloneMode === "clone") {
|
|
||||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
|
||||||
if (!repo?.ssh_key_id) {
|
|
||||||
setCreateError("Repository must have an SSH key assigned for clone mode");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreateStatus("creating");
|
|
||||||
setCreateProgress("Creating instance...");
|
|
||||||
try {
|
|
||||||
const instance = await createInstance(
|
|
||||||
selectedProject,
|
|
||||||
selectedRepo,
|
|
||||||
selectedToolType,
|
|
||||||
displayName || undefined,
|
|
||||||
cloneMode,
|
|
||||||
cloneMode === "clone" ? branch : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
setCreateProgress("Starting container...");
|
|
||||||
// Auto-start the instance
|
|
||||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
|
||||||
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setCreateStatus("idle");
|
|
||||||
setCreateProgress("");
|
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
setSelectedRepo("");
|
|
||||||
setSelectedToolType("");
|
|
||||||
setDisplayName("");
|
|
||||||
setCloneMode("mount");
|
|
||||||
setBranch("main");
|
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch (error) {
|
|
||||||
setCreateStatus("error");
|
|
||||||
setCreateProgress("");
|
|
||||||
const axiosError = error as { response?: { data?: { detail?: string } } };
|
|
||||||
const message = axiosError.response?.data?.detail;
|
|
||||||
setCreateError(
|
|
||||||
typeof message === "string" ? message : "Failed to create session"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||||
@@ -630,164 +559,18 @@ export const SessionsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Session */}
|
{/* Create Session */}
|
||||||
<div className={`create-session-section ${createStatus === "creating" ? "dimmed" : ""}`}>
|
<div className="create-session-section">
|
||||||
{createStatus === "creating" && (
|
|
||||||
<div className="loading-overlay">
|
|
||||||
<div className="loading-content">
|
|
||||||
<Icon name="loading" size="lg" />
|
|
||||||
<p>{createProgress || "Creating session..."}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h2>Create New Session</h2>
|
<h2>Create New Session</h2>
|
||||||
<form onSubmit={handleCreate} className="card stack create-session-form">
|
<CreateSessionForm
|
||||||
<div className="form-row">
|
projects={projects}
|
||||||
<label className="form-field">
|
repositories={repositories}
|
||||||
Project
|
toolTypes={toolTypes}
|
||||||
<select
|
showCloneMode={true}
|
||||||
value={selectedProject}
|
onProjectChange={(projectId) => {
|
||||||
onChange={(e) => {
|
setSelectedProject(projectId);
|
||||||
setSelectedProject(e.target.value);
|
|
||||||
setSelectedRepo("");
|
|
||||||
}}
|
}}
|
||||||
>
|
onSuccess={handleCreateSuccess}
|
||||||
<option value="">Select project...</option>
|
|
||||||
{projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Repository
|
|
||||||
<select
|
|
||||||
value={selectedRepo}
|
|
||||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
|
||||||
disabled={!selectedProject}
|
|
||||||
>
|
|
||||||
<option value="">Select repository...</option>
|
|
||||||
{repositories.map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>
|
|
||||||
{r.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Tool Type
|
|
||||||
<select
|
|
||||||
value={selectedToolType}
|
|
||||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select tool...</option>
|
|
||||||
{toolTypes.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.display_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-field">
|
|
||||||
Repository Access
|
|
||||||
<div className="radio-group">
|
|
||||||
<label className="radio-label">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="cloneMode"
|
|
||||||
value="mount"
|
|
||||||
checked={cloneMode === "mount"}
|
|
||||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
|
||||||
/>
|
/>
|
||||||
Mount (live sync)
|
|
||||||
</label>
|
|
||||||
<label className="radio-label">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="cloneMode"
|
|
||||||
value="clone"
|
|
||||||
checked={cloneMode === "clone"}
|
|
||||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
|
||||||
/>
|
|
||||||
Clone fresh copy
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{cloneMode === "clone" && (
|
|
||||||
<>
|
|
||||||
<label className="form-field">
|
|
||||||
Branch
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={branch}
|
|
||||||
onChange={(e) => setBranch(e.target.value)}
|
|
||||||
placeholder="main"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{selectedRepo && (
|
|
||||||
<div className="form-field ssh-key-info">
|
|
||||||
{(() => {
|
|
||||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
|
||||||
if (!repo) return null;
|
|
||||||
if (repo.ssh_key_id) {
|
|
||||||
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
|
||||||
return (
|
|
||||||
<span className="success-text">
|
|
||||||
SSH key: {key?.name || "Assigned"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="warning-text">
|
|
||||||
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="form-field">
|
|
||||||
Display Name (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="My Development Environment"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{createError && <p className="error-text">{createError}</p>}
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
type="submit"
|
|
||||||
disabled={createStatus === "creating"}
|
|
||||||
>
|
|
||||||
{createStatus === "creating" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create Session
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dirty Delete Confirmation Modal */}
|
{/* Dirty Delete Confirmation Modal */}
|
||||||
|
|||||||
Reference in New Issue
Block a user