261 lines
8.3 KiB
TypeScript
261 lines
8.3 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import type { GitlabStatus, Repo, SpawnJob, SpawnResponse } from "./protocol";
|
|
import { Route } from "./protocol";
|
|
import { errMessage, fetchJson } from "./api";
|
|
import type { SessionsStore } from "./store";
|
|
|
|
const POLL_MS: number = 1500;
|
|
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
|
|
|
|
interface Props {
|
|
store: SessionsStore;
|
|
pushToast: (text: string) => void;
|
|
}
|
|
|
|
export default function SpawnView({ store, pushToast }: Props) {
|
|
const navigate = useNavigate();
|
|
|
|
const [status, setStatus] = useState<GitlabStatus | null>(null);
|
|
const [pat, setPat] = useState<string>("");
|
|
const [repos, setRepos] = useState<Repo[] | null>(null);
|
|
const [query, setQuery] = useState<string>("");
|
|
const [selected, setSelected] = useState<Repo | null>(null);
|
|
const [branch, setBranch] = useState<string>("");
|
|
const [busy, setBusy] = useState<boolean>(false);
|
|
const [error, setError] = useState<string>("");
|
|
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
|
|
|
|
const timerRef = useRef<number | null>(null);
|
|
const tickRef = useRef<number>(0);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const loadStatus = async (): Promise<GitlabStatus> => {
|
|
const s = await fetchJson<GitlabStatus>(Route.GitlabStatus);
|
|
setStatus(s);
|
|
return s;
|
|
};
|
|
|
|
const loadRepos = async (): Promise<void> => {
|
|
const list = await fetchJson<Repo[]>(Route.GitlabRepos);
|
|
setRepos(list);
|
|
};
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
try {
|
|
const s = await loadStatus();
|
|
if (s.connected) await loadRepos();
|
|
} catch (err) {
|
|
setError(errMessage(err));
|
|
}
|
|
})();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const connect = async (): Promise<void> => {
|
|
if (pat.trim().length === 0) {
|
|
setError("token required");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await fetchJson(Route.GitlabConnect, { method: "POST", body: JSON.stringify({ token: pat.trim() }) });
|
|
setPat("");
|
|
await loadStatus();
|
|
await loadRepos();
|
|
} catch (err) {
|
|
setError(errMessage(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const pick = (repo: Repo): void => {
|
|
setSelected(repo);
|
|
setBranch(repo.defaultBranch);
|
|
};
|
|
|
|
const filtered: Repo[] = (repos ?? []).filter((r) => r.path.toLowerCase().includes(query.toLowerCase()));
|
|
|
|
const spawn = async (): Promise<void> => {
|
|
if (selected === null) {
|
|
setError("pick a repo first");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const body = { repo: selected.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}) };
|
|
const res = await fetchJson<SpawnResponse>(Route.Spawn, { method: "POST", body: JSON.stringify(body) });
|
|
setSpawning(res);
|
|
startPolling(res.sessionId);
|
|
} catch (err) {
|
|
setError(errMessage(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const startPolling = (sessionId: string): void => {
|
|
tickRef.current = 0;
|
|
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
|
timerRef.current = window.setInterval(() => {
|
|
tickRef.current += 1;
|
|
if (tickRef.current > POLL_MAX_TICKS) {
|
|
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
return;
|
|
}
|
|
void (async () => {
|
|
try {
|
|
await fetchJson<SpawnJob[]>(Route.SpawnStatus).catch(() => undefined);
|
|
await store.refresh();
|
|
const s = store.sessions.find((x) => x.id === sessionId);
|
|
if (s !== undefined && s.online) {
|
|
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
navigate(`/s/${sessionId}`);
|
|
}
|
|
} catch (err) {
|
|
pushToast(errMessage(err));
|
|
}
|
|
})();
|
|
}, POLL_MS);
|
|
};
|
|
|
|
const jobLine = (): string => {
|
|
if (spawning === null) return "";
|
|
const job: SpawnJob | undefined = store.spawnJobs.find((j) => j.sessionId === spawning.sessionId);
|
|
if (job !== undefined) return `${job.repo}: ${job.state}`;
|
|
return "waiting for session to come online…";
|
|
};
|
|
|
|
if (status === null) {
|
|
return (
|
|
<div className="page">
|
|
<h1>Spawn</h1>
|
|
<p className="empty">{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!status.connected) {
|
|
return (
|
|
<div className="page">
|
|
<h1>Spawn</h1>
|
|
<form
|
|
className="spawn-card"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void connect();
|
|
}}
|
|
>
|
|
<h2>Connect GitLab</h2>
|
|
<p className="repo-meta" style={{ margin: "0 0 10px" }}>
|
|
Paste a personal access token (api scope). Stored daemon-side only.
|
|
</p>
|
|
<input
|
|
type="password"
|
|
value={pat}
|
|
placeholder="glpat-…"
|
|
aria-label="GitLab personal access token"
|
|
onChange={(e) => setPat(e.target.value)}
|
|
/>
|
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
|
<div className="actions">
|
|
<button className="btn-primary" type="submit" disabled={busy}>
|
|
{busy ? "Connecting…" : "Connect"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<h1>Spawn</h1>
|
|
|
|
{spawning !== null && (
|
|
<div className="spawn-card">
|
|
<h2>Spawning…</h2>
|
|
<p className="spawn-job-line">{jobLine()}</p>
|
|
<p className="repo-meta">container {spawning.containerId.slice(0, 12)}</p>
|
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{spawning === null && (
|
|
<>
|
|
<div className="spawn-card">
|
|
<h2>Repository</h2>
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
placeholder="filter repos…"
|
|
aria-label="Filter repositories"
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
/>
|
|
{repos === null && <p className="repo-meta">loading repos…</p>}
|
|
{repos !== null && filtered.length === 0 && <p className="repo-meta">no matching repos</p>}
|
|
<div className="repo-list" style={{ marginTop: 10 }}>
|
|
{filtered.map((r) => (
|
|
<div
|
|
key={r.path}
|
|
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={selected?.path === r.path}
|
|
onClick={() => pick(r)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") pick(r);
|
|
}}
|
|
>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div className="repo-path">{r.path}</div>
|
|
<div className="repo-meta">
|
|
default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="spawn-card">
|
|
<h2>Branch</h2>
|
|
<div className="row">
|
|
<input
|
|
type="text"
|
|
value={branch}
|
|
aria-label="Branch"
|
|
placeholder={selected?.defaultBranch ?? "main"}
|
|
disabled={selected === null}
|
|
onChange={(e) => setBranch(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
disabled={selected === null || busy}
|
|
aria-label="Spawn container"
|
|
onClick={() => void spawn()}
|
|
>
|
|
{busy ? "Spawning…" : "Spawn"}
|
|
</button>
|
|
</div>
|
|
{selected === null && <p className="repo-meta" style={{ marginTop: 8 }}>select a repo above</p>}
|
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|