mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
1752 lines
58 KiB
TypeScript
1752 lines
58 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useEffect, useState } from "react";
|
|
|
|
type Step = 1 | 2;
|
|
type AuthMode = "sign-in" | "sign-up";
|
|
type ShellView = "workers" | "billing";
|
|
type WorkerStatusBucket = "ready" | "starting" | "attention" | "other";
|
|
|
|
type AuthUser = {
|
|
id: string;
|
|
email: string;
|
|
name: string | null;
|
|
};
|
|
|
|
type WorkerLaunch = {
|
|
workerId: string;
|
|
workerName: string;
|
|
status: string;
|
|
provider: string | null;
|
|
instanceUrl: string | null;
|
|
openworkUrl: string | null;
|
|
workspaceId: string | null;
|
|
clientToken: string | null;
|
|
hostToken: string | null;
|
|
};
|
|
|
|
type WorkerSummary = {
|
|
workerId: string;
|
|
workerName: string;
|
|
status: string;
|
|
instanceUrl: string | null;
|
|
provider: string | null;
|
|
isMine: boolean;
|
|
};
|
|
|
|
type WorkerTokens = {
|
|
clientToken: string | null;
|
|
hostToken: string | null;
|
|
openworkUrl: string | null;
|
|
workspaceId: string | null;
|
|
};
|
|
|
|
type WorkerListItem = {
|
|
workerId: string;
|
|
workerName: string;
|
|
status: string;
|
|
instanceUrl: string | null;
|
|
provider: string | null;
|
|
isMine: boolean;
|
|
createdAt: string | null;
|
|
};
|
|
|
|
type EventLevel = "info" | "success" | "warning" | "error";
|
|
|
|
type LaunchEvent = {
|
|
id: string;
|
|
level: EventLevel;
|
|
label: string;
|
|
detail: string;
|
|
at: string;
|
|
};
|
|
|
|
const LAST_WORKER_STORAGE_KEY = "openwork:web:last-worker";
|
|
const WORKER_STATUS_POLL_MS = 5000;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function shortValue(value: string): string {
|
|
if (value.length <= 18) {
|
|
return value;
|
|
}
|
|
return `${value.slice(0, 8)}...${value.slice(-6)}`;
|
|
}
|
|
|
|
function getErrorMessage(payload: unknown, fallback: string): string {
|
|
if (typeof payload === "string" && payload.trim().length > 0) {
|
|
const trimmed = payload.trim();
|
|
const lower = trimmed.toLowerCase();
|
|
if (lower.startsWith("<!doctype") || lower.startsWith("<html") || lower.includes("<body")) {
|
|
return `${fallback} Upstream returned an HTML error page.`;
|
|
}
|
|
if (trimmed.length > 240) {
|
|
return `${fallback} Upstream returned a non-JSON error payload.`;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
if (!isRecord(payload)) {
|
|
return fallback;
|
|
}
|
|
|
|
const message = payload.message;
|
|
if (typeof message === "string" && message.trim().length > 0) {
|
|
return message;
|
|
}
|
|
|
|
const error = payload.error;
|
|
if (typeof error === "string" && error.trim().length > 0) {
|
|
return error;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function getUser(payload: unknown): AuthUser | null {
|
|
if (!isRecord(payload) || !isRecord(payload.user)) {
|
|
return null;
|
|
}
|
|
|
|
const user = payload.user;
|
|
if (typeof user.id !== "string" || typeof user.email !== "string") {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: typeof user.name === "string" ? user.name : null
|
|
};
|
|
}
|
|
|
|
function getToken(payload: unknown): string | null {
|
|
if (!isRecord(payload)) {
|
|
return null;
|
|
}
|
|
return typeof payload.token === "string" ? payload.token : null;
|
|
}
|
|
|
|
function getCheckoutUrl(payload: unknown): string | null {
|
|
if (!isRecord(payload) || !isRecord(payload.polar)) {
|
|
return null;
|
|
}
|
|
return typeof payload.polar.checkoutUrl === "string" ? payload.polar.checkoutUrl : null;
|
|
}
|
|
|
|
function getWorker(payload: unknown): WorkerLaunch | null {
|
|
if (!isRecord(payload) || !isRecord(payload.worker)) {
|
|
return null;
|
|
}
|
|
|
|
const worker = payload.worker;
|
|
if (typeof worker.id !== "string" || typeof worker.name !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const instance = isRecord(payload.instance) ? payload.instance : null;
|
|
const tokens = isRecord(payload.tokens) ? payload.tokens : null;
|
|
|
|
return {
|
|
workerId: worker.id,
|
|
workerName: worker.name,
|
|
status: typeof worker.status === "string" ? worker.status : "unknown",
|
|
provider: instance && typeof instance.provider === "string" ? instance.provider : null,
|
|
instanceUrl: instance && typeof instance.url === "string" ? instance.url : null,
|
|
openworkUrl: instance && typeof instance.url === "string" ? instance.url : null,
|
|
workspaceId: null,
|
|
clientToken: tokens && typeof tokens.client === "string" ? tokens.client : null,
|
|
hostToken: tokens && typeof tokens.host === "string" ? tokens.host : null
|
|
};
|
|
}
|
|
|
|
function getWorkerSummary(payload: unknown): WorkerSummary | null {
|
|
if (!isRecord(payload) || !isRecord(payload.worker)) {
|
|
return null;
|
|
}
|
|
|
|
const worker = payload.worker;
|
|
if (typeof worker.id !== "string" || typeof worker.name !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const instance = isRecord(payload.instance) ? payload.instance : null;
|
|
|
|
return {
|
|
workerId: worker.id,
|
|
workerName: worker.name,
|
|
status: typeof worker.status === "string" ? worker.status : "unknown",
|
|
instanceUrl: instance && typeof instance.url === "string" ? instance.url : null,
|
|
provider: instance && typeof instance.provider === "string" ? instance.provider : null,
|
|
isMine: worker.isMine === true
|
|
};
|
|
}
|
|
|
|
function getWorkerTokens(payload: unknown): WorkerTokens | null {
|
|
if (!isRecord(payload) || !isRecord(payload.tokens)) {
|
|
return null;
|
|
}
|
|
|
|
const tokens = payload.tokens;
|
|
const connect = isRecord(payload.connect) ? payload.connect : null;
|
|
const clientToken = typeof tokens.client === "string" ? tokens.client : null;
|
|
const hostToken = typeof tokens.host === "string" ? tokens.host : null;
|
|
const openworkUrl = connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null;
|
|
const workspaceId = connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null;
|
|
|
|
if (!clientToken && !hostToken) {
|
|
return null;
|
|
}
|
|
|
|
return { clientToken, hostToken, openworkUrl, workspaceId };
|
|
}
|
|
|
|
function parseWorkerListItem(value: unknown): WorkerListItem | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const workerId = value.id;
|
|
const workerName = value.name;
|
|
if (typeof workerId !== "string" || typeof workerName !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const instance = isRecord(value.instance) ? value.instance : null;
|
|
const createdAt = typeof value.createdAt === "string" ? value.createdAt : null;
|
|
|
|
return {
|
|
workerId,
|
|
workerName,
|
|
status: typeof value.status === "string" ? value.status : "unknown",
|
|
instanceUrl: instance && typeof instance.url === "string" ? instance.url : null,
|
|
provider: instance && typeof instance.provider === "string" ? instance.provider : null,
|
|
isMine: value.isMine === true,
|
|
createdAt
|
|
};
|
|
}
|
|
|
|
function getWorkersList(payload: unknown): WorkerListItem[] {
|
|
if (!isRecord(payload) || !Array.isArray(payload.workers)) {
|
|
return [];
|
|
}
|
|
|
|
const rows: WorkerListItem[] = [];
|
|
for (const item of payload.workers) {
|
|
const parsed = parseWorkerListItem(item);
|
|
if (parsed) {
|
|
rows.push(parsed);
|
|
}
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function getWorkerStatusMeta(status: string): { label: string; bucket: WorkerStatusBucket } {
|
|
const normalized = status.trim().toLowerCase();
|
|
|
|
if (normalized === "healthy" || normalized === "ready") {
|
|
return { label: "Ready", bucket: "ready" };
|
|
}
|
|
|
|
if (normalized === "provisioning" || normalized === "starting") {
|
|
return { label: "Starting", bucket: "starting" };
|
|
}
|
|
|
|
if (normalized === "failed" || normalized === "suspended") {
|
|
return { label: "Needs attention", bucket: "attention" };
|
|
}
|
|
|
|
return { label: "Unknown", bucket: "other" };
|
|
}
|
|
|
|
function getWorkerStatusCopy(status: string): string {
|
|
const normalized = status.trim().toLowerCase();
|
|
switch (normalized) {
|
|
case "provisioning":
|
|
case "starting":
|
|
return "Starting... This may take a minute.";
|
|
case "healthy":
|
|
case "ready":
|
|
return "Ready to connect.";
|
|
case "failed":
|
|
return "Worker failed to start.";
|
|
case "suspended":
|
|
return "Worker is suspended.";
|
|
default:
|
|
return "Worker status unknown.";
|
|
}
|
|
}
|
|
|
|
function getWorkerAddressLabel(item: WorkerListItem): string {
|
|
if (!item.instanceUrl) {
|
|
return shortValue(item.workerId);
|
|
}
|
|
|
|
try {
|
|
return new URL(item.instanceUrl).host;
|
|
} catch {
|
|
return shortValue(item.instanceUrl);
|
|
}
|
|
}
|
|
|
|
function isWorkerLaunch(value: unknown): value is WorkerLaunch {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
typeof value.workerId === "string" &&
|
|
typeof value.workerName === "string" &&
|
|
typeof value.status === "string" &&
|
|
(typeof value.provider === "string" || value.provider === null) &&
|
|
(typeof value.instanceUrl === "string" || value.instanceUrl === null) &&
|
|
(typeof value.openworkUrl === "string" || value.openworkUrl === null || typeof value.openworkUrl === "undefined") &&
|
|
(typeof value.workspaceId === "string" || value.workspaceId === null || typeof value.workspaceId === "undefined") &&
|
|
(typeof value.clientToken === "string" || value.clientToken === null) &&
|
|
(typeof value.hostToken === "string" || value.hostToken === null)
|
|
);
|
|
}
|
|
|
|
function listItemToWorker(item: WorkerListItem, current: WorkerLaunch | null = null): WorkerLaunch {
|
|
return {
|
|
workerId: item.workerId,
|
|
workerName: item.workerName,
|
|
status: item.status,
|
|
provider: item.provider,
|
|
instanceUrl: item.instanceUrl,
|
|
openworkUrl: item.instanceUrl,
|
|
workspaceId: null,
|
|
clientToken: current?.workerId === item.workerId ? current.clientToken : null,
|
|
hostToken: current?.workerId === item.workerId ? current.hostToken : null
|
|
};
|
|
}
|
|
|
|
function normalizeUrl(value: string): string {
|
|
return value.trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function parseWorkspaceIdFromUrl(value: string): string | null {
|
|
const normalized = normalizeUrl(value);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(normalized);
|
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
const last = segments[segments.length - 1] ?? "";
|
|
const prev = segments[segments.length - 2] ?? "";
|
|
if (prev !== "w" || !last) {
|
|
return null;
|
|
}
|
|
return decodeURIComponent(last);
|
|
} catch {
|
|
const match = normalized.match(/\/w\/([^/?#]+)/);
|
|
if (!match?.[1]) {
|
|
return null;
|
|
}
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return match[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildWorkspaceUrl(instanceUrl: string, workspaceId: string): string {
|
|
return `${normalizeUrl(instanceUrl)}/w/${encodeURIComponent(workspaceId)}`;
|
|
}
|
|
|
|
function buildOpenworkDeepLink(
|
|
openworkUrl: string | null,
|
|
accessToken: string | null,
|
|
workerId: string | null,
|
|
workerName: string | null,
|
|
): string | null {
|
|
if (!openworkUrl || !accessToken) {
|
|
return null;
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
openworkHostUrl: openworkUrl,
|
|
openworkToken: accessToken,
|
|
source: "openwork-web"
|
|
});
|
|
|
|
if (workerId) {
|
|
params.set("workerId", workerId);
|
|
}
|
|
|
|
if (workerName) {
|
|
params.set("workerName", workerName);
|
|
}
|
|
|
|
return `openwork://connect-remote?${params.toString()}`;
|
|
}
|
|
|
|
function parseWorkspaceIdFromWorkspacesPayload(payload: unknown): string | null {
|
|
if (!isRecord(payload) || !Array.isArray(payload.items)) {
|
|
return null;
|
|
}
|
|
|
|
const activeId = typeof payload.activeId === "string" ? payload.activeId : null;
|
|
if (activeId && payload.items.some((item) => isRecord(item) && item.id === activeId)) {
|
|
return activeId;
|
|
}
|
|
|
|
for (const item of payload.items) {
|
|
if (isRecord(item) && typeof item.id === "string" && item.id.trim()) {
|
|
return item.id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function requestAbsoluteJson(url: string, init: RequestInit = {}, timeoutMs = 12000) {
|
|
const headers = new Headers(init.headers);
|
|
headers.set("Accept", "application/json");
|
|
|
|
const shouldAttachTimeout = !init.signal && timeoutMs > 0;
|
|
const timeoutController = shouldAttachTimeout ? new AbortController() : null;
|
|
const timeoutHandle = timeoutController
|
|
? setTimeout(() => {
|
|
timeoutController.abort();
|
|
}, timeoutMs)
|
|
: null;
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
...init,
|
|
headers,
|
|
credentials: "omit",
|
|
signal: init.signal ?? timeoutController?.signal
|
|
});
|
|
} finally {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
}
|
|
|
|
const text = await response.text();
|
|
let payload: unknown = null;
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch {
|
|
payload = text;
|
|
}
|
|
}
|
|
|
|
return { response, payload };
|
|
}
|
|
|
|
async function resolveOpenworkWorkspaceUrl(instanceUrl: string, accessToken: string): Promise<{ workspaceId: string; openworkUrl: string } | null> {
|
|
const baseUrl = normalizeUrl(instanceUrl);
|
|
const token = accessToken.trim();
|
|
if (!baseUrl || !token) {
|
|
return null;
|
|
}
|
|
|
|
const mountedWorkspaceId = parseWorkspaceIdFromUrl(baseUrl);
|
|
if (mountedWorkspaceId) {
|
|
return {
|
|
workspaceId: mountedWorkspaceId,
|
|
openworkUrl: baseUrl
|
|
};
|
|
}
|
|
|
|
const { response, payload } = await requestAbsoluteJson(`${baseUrl}/workspaces`, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
const workspaceId = parseWorkspaceIdFromWorkspacesPayload(payload);
|
|
if (!workspaceId) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
workspaceId,
|
|
openworkUrl: buildWorkspaceUrl(baseUrl, workspaceId)
|
|
};
|
|
}
|
|
|
|
async function requestJson(path: string, init: RequestInit = {}, timeoutMs = 30000) {
|
|
const headers = new Headers(init.headers);
|
|
headers.set("Accept", "application/json");
|
|
|
|
if (init.body && !headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
|
|
const shouldAttachTimeout = !init.signal && timeoutMs > 0;
|
|
const timeoutController = shouldAttachTimeout ? new AbortController() : null;
|
|
const timeoutHandle = timeoutController
|
|
? setTimeout(() => {
|
|
timeoutController.abort();
|
|
}, timeoutMs)
|
|
: null;
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/api/den${path}`, {
|
|
...init,
|
|
headers,
|
|
credentials: "include",
|
|
signal: init.signal ?? timeoutController?.signal
|
|
});
|
|
} finally {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
}
|
|
|
|
const text = await response.text();
|
|
let payload: unknown = null;
|
|
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch {
|
|
payload = text;
|
|
}
|
|
}
|
|
|
|
return { response, payload, text };
|
|
}
|
|
|
|
function CredentialRow({
|
|
label,
|
|
value,
|
|
placeholder,
|
|
canCopy,
|
|
copied,
|
|
onCopy
|
|
}: {
|
|
label: string;
|
|
value: string | null;
|
|
placeholder: string;
|
|
canCopy: boolean;
|
|
copied: boolean;
|
|
onCopy: () => void;
|
|
}) {
|
|
return (
|
|
<label className="grid gap-2">
|
|
<span className="px-0.5 text-[0.67rem] font-bold uppercase tracking-[0.11em] text-slate-500">{label}</span>
|
|
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-1.5">
|
|
<input
|
|
readOnly
|
|
value={value ?? placeholder}
|
|
className="min-w-0 flex-1 border-none bg-transparent px-2 py-1.5 font-mono text-xs text-slate-700 outline-none"
|
|
onClick={(event) => event.currentTarget.select()}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:border-[#1B29FF] hover:text-[#1B29FF] disabled:cursor-not-allowed disabled:opacity-50"
|
|
disabled={!canCopy}
|
|
onClick={onCopy}
|
|
>
|
|
{copied ? "Copied" : canCopy ? "Copy" : "N/A"}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function CloudControlPanel() {
|
|
const [step, setStep] = useState<Step>(1);
|
|
const [shellView, setShellView] = useState<ShellView>("workers");
|
|
|
|
const [authMode, setAuthMode] = useState<AuthMode>("sign-in");
|
|
const [name, setName] = useState("OpenWork Builder");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [authBusy, setAuthBusy] = useState(false);
|
|
const [authInfo, setAuthInfo] = useState("Sign in to launch and manage cloud workers.");
|
|
const [authError, setAuthError] = useState<string | null>(null);
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [authToken, setAuthToken] = useState<string | null>(null);
|
|
|
|
const [workerName, setWorkerName] = useState("Founder Ops Pilot");
|
|
const [worker, setWorker] = useState<WorkerLaunch | null>(null);
|
|
const [workerLookupId, setWorkerLookupId] = useState("");
|
|
const [workers, setWorkers] = useState<WorkerListItem[]>([]);
|
|
const [workersBusy, setWorkersBusy] = useState(false);
|
|
const [workersError, setWorkersError] = useState<string | null>(null);
|
|
const [launchBusy, setLaunchBusy] = useState(false);
|
|
const [actionBusy, setActionBusy] = useState<"status" | "token" | null>(null);
|
|
const [launchStatus, setLaunchStatus] = useState("Name your worker and click launch.");
|
|
const [launchError, setLaunchError] = useState<string | null>(null);
|
|
const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
|
|
const [paymentReturned, setPaymentReturned] = useState(false);
|
|
|
|
const [events, setEvents] = useState<LaunchEvent[]>([]);
|
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
|
const [tokenFetchedForWorkerId, setTokenFetchedForWorkerId] = useState<string | null>(null);
|
|
const [workerQuery, setWorkerQuery] = useState("");
|
|
const [workerStatusFilter, setWorkerStatusFilter] = useState<WorkerStatusBucket | "all">("all");
|
|
const [showLaunchForm, setShowLaunchForm] = useState(false);
|
|
|
|
const selectedWorker = workers.find((item) => item.workerId === workerLookupId) ?? null;
|
|
const activeWorker: WorkerLaunch | null =
|
|
worker && workerLookupId === worker.workerId
|
|
? worker
|
|
: selectedWorker
|
|
? listItemToWorker(selectedWorker, worker)
|
|
: worker;
|
|
|
|
const progressWidth = step === 1 ? "45%" : "100%";
|
|
const isShellStep = step === 2;
|
|
const openworkConnectUrl = activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null;
|
|
const hasWorkspaceScopedUrl = Boolean(openworkConnectUrl && /\/w\/[^/?#]+/.test(openworkConnectUrl));
|
|
const openworkDeepLink = buildOpenworkDeepLink(
|
|
openworkConnectUrl,
|
|
activeWorker?.clientToken ?? null,
|
|
activeWorker?.workerId ?? null,
|
|
activeWorker?.workerName ?? null,
|
|
);
|
|
|
|
const filteredWorkers = workers.filter((item) => {
|
|
const query = workerQuery.trim().toLowerCase();
|
|
const matchesQuery =
|
|
!query ||
|
|
item.workerName.toLowerCase().includes(query) ||
|
|
item.workerId.toLowerCase().includes(query);
|
|
|
|
if (!matchesQuery) {
|
|
return false;
|
|
}
|
|
|
|
if (workerStatusFilter === "all") {
|
|
return true;
|
|
}
|
|
|
|
return getWorkerStatusMeta(item.status).bucket === workerStatusFilter;
|
|
});
|
|
|
|
const selectedWorkerStatus = activeWorker?.status ?? selectedWorker?.status ?? "unknown";
|
|
const selectedStatusMeta = getWorkerStatusMeta(selectedWorkerStatus);
|
|
|
|
function appendEvent(level: EventLevel, label: string, detail: string) {
|
|
setEvents((current) => {
|
|
const next: LaunchEvent[] = [
|
|
{
|
|
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
level,
|
|
label,
|
|
detail,
|
|
at: new Date().toISOString()
|
|
},
|
|
...current
|
|
];
|
|
|
|
return next.slice(0, 10);
|
|
});
|
|
}
|
|
|
|
async function withResolvedOpenworkCredentials(candidate: WorkerLaunch, options: { quiet?: boolean } = {}) {
|
|
const existingConnectUrl = candidate.openworkUrl?.trim() ?? "";
|
|
const existingWorkspaceId = candidate.workspaceId?.trim() ?? "";
|
|
if (existingConnectUrl && existingWorkspaceId) {
|
|
return {
|
|
...candidate,
|
|
openworkUrl: existingConnectUrl,
|
|
workspaceId: existingWorkspaceId
|
|
};
|
|
}
|
|
|
|
const instanceUrl = candidate.instanceUrl?.trim() ?? "";
|
|
if (!instanceUrl) {
|
|
return {
|
|
...candidate,
|
|
openworkUrl: null,
|
|
workspaceId: null
|
|
};
|
|
}
|
|
|
|
const accessToken = candidate.clientToken?.trim() ?? "";
|
|
if (!accessToken) {
|
|
const mountedWorkspaceId = parseWorkspaceIdFromUrl(instanceUrl);
|
|
return {
|
|
...candidate,
|
|
openworkUrl: normalizeUrl(instanceUrl),
|
|
workspaceId: mountedWorkspaceId
|
|
};
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveOpenworkWorkspaceUrl(instanceUrl, accessToken);
|
|
if (resolved) {
|
|
return {
|
|
...candidate,
|
|
openworkUrl: resolved.openworkUrl,
|
|
workspaceId: resolved.workspaceId
|
|
};
|
|
}
|
|
} catch {
|
|
if (!options.quiet) {
|
|
appendEvent("warning", "Credential hint", "Could not resolve /w/ws_ URL yet. Using host URL fallback.");
|
|
}
|
|
}
|
|
|
|
return {
|
|
...candidate,
|
|
openworkUrl: normalizeUrl(instanceUrl),
|
|
workspaceId: parseWorkspaceIdFromUrl(instanceUrl)
|
|
};
|
|
}
|
|
|
|
async function refreshWorkers(options: { keepSelection?: boolean } = {}) {
|
|
if (!user) {
|
|
setWorkers([]);
|
|
setWorkersError(null);
|
|
return;
|
|
}
|
|
|
|
setWorkersBusy(true);
|
|
setWorkersError(null);
|
|
|
|
try {
|
|
const { response, payload } = await requestJson("/v1/workers?limit=20", {
|
|
method: "GET",
|
|
headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = getErrorMessage(payload, `Failed to load workers (${response.status}).`);
|
|
setWorkersError(message);
|
|
return;
|
|
}
|
|
|
|
const nextWorkers = getWorkersList(payload);
|
|
setWorkers(nextWorkers);
|
|
|
|
const currentSelection = options.keepSelection ? workerLookupId : "";
|
|
const nextSelectedId =
|
|
currentSelection && nextWorkers.some((item) => item.workerId === currentSelection)
|
|
? currentSelection
|
|
: nextWorkers[0]?.workerId ?? "";
|
|
|
|
setWorkerLookupId(nextSelectedId);
|
|
|
|
if (nextSelectedId && worker && worker.workerId === nextSelectedId) {
|
|
const selected = nextWorkers.find((item) => item.workerId === nextSelectedId) ?? null;
|
|
if (selected) {
|
|
setWorker((current) => listItemToWorker(selected, current));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown network error";
|
|
setWorkersError(message);
|
|
} finally {
|
|
setWorkersBusy(false);
|
|
}
|
|
}
|
|
|
|
async function copyToClipboard(field: string, value: string | null) {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
await navigator.clipboard.writeText(value);
|
|
setCopiedField(field);
|
|
setTimeout(() => {
|
|
setCopiedField((current) => (current === field ? null : current));
|
|
}, 1800);
|
|
}
|
|
|
|
async function refreshSession(quiet = false) {
|
|
const headers = new Headers();
|
|
if (authToken) {
|
|
headers.set("Authorization", `Bearer ${authToken}`);
|
|
}
|
|
|
|
const { response, payload } = await requestJson("/v1/me", { method: "GET", headers }, 12000);
|
|
|
|
if (!response.ok) {
|
|
setUser(null);
|
|
if (!quiet) {
|
|
setAuthError("No active session found. Sign in first.");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const sessionUser = getUser(payload);
|
|
if (!sessionUser) {
|
|
if (!quiet) {
|
|
setAuthError("Session response did not include a user.");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
setUser(sessionUser);
|
|
setAuthInfo(`Signed in as ${sessionUser.email}.`);
|
|
return sessionUser;
|
|
}
|
|
|
|
useEffect(() => {
|
|
void refreshSession(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!user) {
|
|
setWorkers([]);
|
|
setWorkersError(null);
|
|
return;
|
|
}
|
|
|
|
void refreshWorkers();
|
|
}, [user?.id, authToken]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const customerSessionToken = params.get("customer_session_token");
|
|
if (!customerSessionToken) {
|
|
return;
|
|
}
|
|
|
|
setPaymentReturned(true);
|
|
setCheckoutUrl(null);
|
|
setLaunchStatus("Checkout return detected. Click launch to continue worker provisioning.");
|
|
appendEvent("success", "Returned from checkout", `Session ${shortValue(customerSessionToken)}`);
|
|
|
|
params.delete("customer_session_token");
|
|
const nextQuery = params.toString();
|
|
const nextUrl = nextQuery ? `${window.location.pathname}?${nextQuery}` : window.location.pathname;
|
|
window.history.replaceState({}, "", nextUrl);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const raw = window.localStorage.getItem(LAST_WORKER_STORAGE_KEY);
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!isWorkerLaunch(parsed)) {
|
|
return;
|
|
}
|
|
|
|
const restored: WorkerLaunch = {
|
|
...parsed,
|
|
openworkUrl: parsed.openworkUrl ?? parsed.instanceUrl,
|
|
workspaceId: parsed.workspaceId ?? parseWorkspaceIdFromUrl(parsed.instanceUrl ?? ""),
|
|
clientToken: null,
|
|
hostToken: null
|
|
};
|
|
|
|
setWorker(restored);
|
|
setWorkerLookupId(restored.workerId);
|
|
setLaunchStatus(`Recovered worker ${restored.workerName}. ${getWorkerStatusCopy(restored.status)}`);
|
|
appendEvent("info", "Recovered worker context", `Worker ID ${restored.workerId}`);
|
|
} catch {
|
|
return;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || !worker) {
|
|
return;
|
|
}
|
|
|
|
const serializable: WorkerLaunch = {
|
|
...worker,
|
|
clientToken: null,
|
|
hostToken: null
|
|
};
|
|
|
|
window.localStorage.setItem(LAST_WORKER_STORAGE_KEY, JSON.stringify(serializable));
|
|
}, [worker]);
|
|
|
|
useEffect(() => {
|
|
if (user || checkoutUrl || paymentReturned || worker) {
|
|
setStep(2);
|
|
return;
|
|
}
|
|
|
|
setStep(1);
|
|
}, [worker, user, checkoutUrl, paymentReturned]);
|
|
|
|
useEffect(() => {
|
|
if (step !== 2) {
|
|
return;
|
|
}
|
|
|
|
if (workers.length === 0) {
|
|
setShowLaunchForm(true);
|
|
}
|
|
}, [step, workers.length]);
|
|
|
|
useEffect(() => {
|
|
if (!user || !worker) {
|
|
return;
|
|
}
|
|
if (worker.clientToken) {
|
|
return;
|
|
}
|
|
if (actionBusy !== null || launchBusy) {
|
|
return;
|
|
}
|
|
if (tokenFetchedForWorkerId === worker.workerId) {
|
|
return;
|
|
}
|
|
|
|
setTokenFetchedForWorkerId(worker.workerId);
|
|
void handleGenerateKey();
|
|
}, [actionBusy, launchBusy, tokenFetchedForWorkerId, user, worker]);
|
|
|
|
useEffect(() => {
|
|
if (!user || !worker || worker.status !== "provisioning") {
|
|
return;
|
|
}
|
|
if (actionBusy !== null || launchBusy) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const poll = async () => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
await handleCheckStatus({ workerId: worker.workerId, quiet: true, background: true });
|
|
};
|
|
|
|
void poll();
|
|
const interval = window.setInterval(() => {
|
|
void poll();
|
|
}, WORKER_STATUS_POLL_MS);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(interval);
|
|
};
|
|
}, [actionBusy, authToken, launchBusy, user?.id, worker?.workerId, worker?.status]);
|
|
|
|
async function handleAuthSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
|
|
setAuthBusy(true);
|
|
setAuthError(null);
|
|
|
|
try {
|
|
const endpoint = authMode === "sign-up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email";
|
|
const body =
|
|
authMode === "sign-up"
|
|
? {
|
|
name: name.trim() || "OpenWork Builder",
|
|
email: email.trim(),
|
|
password
|
|
}
|
|
: {
|
|
email: email.trim(),
|
|
password
|
|
};
|
|
|
|
const { response, payload } = await requestJson(endpoint, {
|
|
method: "POST",
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
setAuthError(getErrorMessage(payload, `Authentication failed with ${response.status}.`));
|
|
return;
|
|
}
|
|
|
|
const token = getToken(payload);
|
|
if (token) {
|
|
setAuthToken(token);
|
|
}
|
|
|
|
const payloadUser = getUser(payload);
|
|
if (payloadUser) {
|
|
setUser(payloadUser);
|
|
setAuthInfo(`Signed in as ${payloadUser.email}.`);
|
|
appendEvent("success", authMode === "sign-up" ? "Account created" : "Signed in", payloadUser.email);
|
|
} else {
|
|
const refreshed = await refreshSession(true);
|
|
if (!refreshed) {
|
|
setAuthInfo("Authentication succeeded, but session details are still syncing.");
|
|
} else {
|
|
appendEvent("success", authMode === "sign-up" ? "Account created" : "Signed in", refreshed.email);
|
|
}
|
|
}
|
|
|
|
setStep(2);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown network error";
|
|
setAuthError(message);
|
|
} finally {
|
|
setAuthBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleSignOut() {
|
|
if (authBusy) {
|
|
return;
|
|
}
|
|
|
|
setAuthBusy(true);
|
|
setAuthError(null);
|
|
|
|
try {
|
|
await requestJson("/api/auth/sign-out", {
|
|
method: "POST",
|
|
headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined,
|
|
body: JSON.stringify({})
|
|
});
|
|
} catch {
|
|
// Ignore sign-out transport issues and clear local session state anyway.
|
|
} finally {
|
|
setAuthBusy(false);
|
|
}
|
|
|
|
setUser(null);
|
|
setAuthToken(null);
|
|
setWorker(null);
|
|
setWorkers([]);
|
|
setWorkerLookupId("");
|
|
setWorkersError(null);
|
|
setLaunchError(null);
|
|
setCheckoutUrl(null);
|
|
setPaymentReturned(false);
|
|
setTokenFetchedForWorkerId(null);
|
|
setActionBusy(null);
|
|
setLaunchBusy(false);
|
|
setStep(1);
|
|
setShellView("workers");
|
|
setWorkerQuery("");
|
|
setWorkerStatusFilter("all");
|
|
setShowLaunchForm(false);
|
|
setAuthMode("sign-in");
|
|
setPassword("");
|
|
setAuthInfo("Sign in to launch and manage cloud workers.");
|
|
setLaunchStatus("Name your worker and click launch.");
|
|
setEvents([]);
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.removeItem(LAST_WORKER_STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
async function handleLaunchWorker() {
|
|
if (!user) {
|
|
setAuthError("Sign in before launching a worker.");
|
|
return;
|
|
}
|
|
|
|
setLaunchBusy(true);
|
|
setLaunchError(null);
|
|
setCheckoutUrl(null);
|
|
setLaunchStatus("Checking subscription and launch eligibility...");
|
|
appendEvent("info", "Launch requested", workerName.trim() || "Cloud worker");
|
|
|
|
try {
|
|
const { response, payload } = await requestJson(
|
|
"/v1/workers",
|
|
{
|
|
method: "POST",
|
|
headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined,
|
|
body: JSON.stringify({
|
|
name: workerName.trim() || "Cloud Worker",
|
|
destination: "cloud"
|
|
})
|
|
},
|
|
12000
|
|
);
|
|
|
|
if (response.status === 402) {
|
|
const url = getCheckoutUrl(payload);
|
|
setCheckoutUrl(url);
|
|
setLaunchStatus("Payment is required. Complete checkout and return to continue launch.");
|
|
setLaunchError(url ? null : "Checkout URL missing from paywall response.");
|
|
appendEvent("warning", "Paywall required", url ? "Checkout URL generated" : "Checkout URL missing");
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message = getErrorMessage(payload, `Launch failed with ${response.status}.`);
|
|
setLaunchError(message);
|
|
setLaunchStatus("Launch failed. Fix the error and retry.");
|
|
appendEvent("error", "Launch failed", message);
|
|
return;
|
|
}
|
|
|
|
const parsedWorker = getWorker(payload);
|
|
if (!parsedWorker) {
|
|
setLaunchError("Launch response was missing worker details.");
|
|
setLaunchStatus("Launch response format was unexpected.");
|
|
appendEvent("error", "Launch failed", "Worker payload missing");
|
|
return;
|
|
}
|
|
|
|
const resolvedWorker = await withResolvedOpenworkCredentials(parsedWorker);
|
|
setWorker(resolvedWorker);
|
|
setWorkerLookupId(parsedWorker.workerId);
|
|
setPaymentReturned(false);
|
|
setCheckoutUrl(null);
|
|
setShowLaunchForm(false);
|
|
|
|
if (resolvedWorker.status === "provisioning") {
|
|
setLaunchStatus("Provisioning started. This can take a few minutes, and we will keep checking automatically.");
|
|
appendEvent("info", "Provisioning started", `Worker ID ${parsedWorker.workerId}`);
|
|
} else {
|
|
setLaunchStatus(getWorkerStatusCopy(resolvedWorker.status));
|
|
appendEvent("success", "Worker launched", `Worker ID ${parsedWorker.workerId}`);
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof DOMException && error.name === "AbortError"
|
|
? "Launch request took longer than expected. Provisioning can continue in the background. Refresh worker status below."
|
|
: error instanceof Error
|
|
? error.message
|
|
: "Unknown network error";
|
|
|
|
setLaunchError(message);
|
|
setLaunchStatus("Launch request failed.");
|
|
appendEvent("error", "Launch failed", message);
|
|
} finally {
|
|
setLaunchBusy(false);
|
|
void refreshWorkers({ keepSelection: true });
|
|
}
|
|
}
|
|
|
|
async function handleCheckStatus(options: { workerId?: string; quiet?: boolean; background?: boolean } = {}) {
|
|
const quiet = options.quiet === true;
|
|
const background = options.background === true;
|
|
|
|
if (!user) {
|
|
if (!quiet) {
|
|
setLaunchError("Sign in before checking worker status.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const fallbackId = workerLookupId.trim() || worker?.workerId || workers[0]?.workerId || "";
|
|
const id = options.workerId ?? fallbackId;
|
|
if (!id) {
|
|
if (!quiet) {
|
|
setLaunchError("No worker selected yet. Launch one first, then use this panel.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
setWorkerLookupId(id);
|
|
|
|
if (!background) {
|
|
setActionBusy("status");
|
|
}
|
|
if (!quiet) {
|
|
setLaunchError(null);
|
|
}
|
|
|
|
try {
|
|
const { response, payload } = await requestJson(`/v1/workers/${encodeURIComponent(id)}`, {
|
|
method: "GET",
|
|
headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = getErrorMessage(payload, `Status check failed with ${response.status}.`);
|
|
if (!quiet) {
|
|
setLaunchError(message);
|
|
appendEvent("error", "Status check failed", message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const summary = getWorkerSummary(payload);
|
|
if (!summary) {
|
|
if (!quiet) {
|
|
setLaunchError("Status response was missing worker details.");
|
|
appendEvent("error", "Status check failed", "Worker summary missing");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const previousStatus = worker?.workerId === summary.workerId ? worker.status : null;
|
|
|
|
const nextWorker: WorkerLaunch =
|
|
worker && worker.workerId === summary.workerId
|
|
? {
|
|
...worker,
|
|
workerName: summary.workerName,
|
|
status: summary.status,
|
|
provider: summary.provider,
|
|
instanceUrl: summary.instanceUrl
|
|
}
|
|
: {
|
|
workerId: summary.workerId,
|
|
workerName: summary.workerName,
|
|
status: summary.status,
|
|
provider: summary.provider,
|
|
instanceUrl: summary.instanceUrl,
|
|
openworkUrl: summary.instanceUrl,
|
|
workspaceId: null,
|
|
clientToken: null,
|
|
hostToken: null
|
|
};
|
|
|
|
const resolvedWorker = await withResolvedOpenworkCredentials(nextWorker, { quiet: true });
|
|
setWorker(resolvedWorker);
|
|
|
|
setWorkerLookupId(summary.workerId);
|
|
|
|
if (!quiet) {
|
|
setLaunchStatus(`Worker ${summary.workerName} is currently ${summary.status}.`);
|
|
appendEvent("info", "Status refreshed", `${summary.workerName}: ${summary.status}`);
|
|
} else if (previousStatus && previousStatus !== summary.status) {
|
|
setLaunchStatus(getWorkerStatusCopy(summary.status));
|
|
|
|
if (summary.status === "healthy") {
|
|
appendEvent("success", "Provisioning complete", `${summary.workerName} is ready`);
|
|
} else if (summary.status === "failed") {
|
|
appendEvent("error", "Provisioning failed", `${summary.workerName} failed to provision`);
|
|
} else {
|
|
appendEvent("info", "Provisioning update", `${summary.workerName}: ${summary.status}`);
|
|
}
|
|
}
|
|
|
|
if (!background) {
|
|
void refreshWorkers({ keepSelection: true });
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown network error";
|
|
if (!quiet) {
|
|
setLaunchError(message);
|
|
appendEvent("error", "Status check failed", message);
|
|
}
|
|
} finally {
|
|
if (!background) {
|
|
setActionBusy(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleGenerateKey() {
|
|
if (!user) {
|
|
setLaunchError("Sign in before fetching a worker access token.");
|
|
return;
|
|
}
|
|
|
|
const id = workerLookupId.trim() || worker?.workerId || workers[0]?.workerId || "";
|
|
if (!id) {
|
|
setLaunchError("No worker selected yet. Launch one first, then fetch a token.");
|
|
return;
|
|
}
|
|
|
|
setWorkerLookupId(id);
|
|
|
|
setActionBusy("token");
|
|
setLaunchError(null);
|
|
|
|
try {
|
|
const { response, payload } = await requestJson(`/v1/workers/${encodeURIComponent(id)}/tokens`, {
|
|
method: "POST",
|
|
headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined,
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = getErrorMessage(payload, `Token fetch failed with ${response.status}.`);
|
|
setLaunchError(message);
|
|
appendEvent("error", "Token fetch failed", message);
|
|
return;
|
|
}
|
|
|
|
const tokens = getWorkerTokens(payload);
|
|
if (!tokens) {
|
|
setLaunchError("Token response returned no token values.");
|
|
appendEvent("error", "Token fetch failed", "Missing token payload");
|
|
return;
|
|
}
|
|
|
|
const nextWorker: WorkerLaunch =
|
|
worker && worker.workerId === id
|
|
? {
|
|
...worker,
|
|
openworkUrl: tokens.openworkUrl ?? worker.openworkUrl,
|
|
workspaceId: tokens.workspaceId ?? worker.workspaceId,
|
|
clientToken: tokens.clientToken,
|
|
hostToken: tokens.hostToken
|
|
}
|
|
: {
|
|
workerId: id,
|
|
workerName: "Existing worker",
|
|
status: "unknown",
|
|
provider: null,
|
|
instanceUrl: null,
|
|
openworkUrl: tokens.openworkUrl,
|
|
workspaceId: tokens.workspaceId,
|
|
clientToken: tokens.clientToken,
|
|
hostToken: tokens.hostToken
|
|
};
|
|
|
|
const resolvedWorker = await withResolvedOpenworkCredentials(nextWorker, { quiet: true });
|
|
setWorker(resolvedWorker);
|
|
|
|
setLaunchStatus("Worker is ready to connect.");
|
|
appendEvent("success", "Access token ready", `Worker ID ${id}`);
|
|
void refreshWorkers({ keepSelection: true });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown network error";
|
|
setLaunchError(message);
|
|
appendEvent("error", "Token fetch failed", message);
|
|
} finally {
|
|
setActionBusy(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className={`ow-card${isShellStep ? " ow-card-shell" : ""}`}>
|
|
{!isShellStep ? (
|
|
<div className="ow-progress-track">
|
|
<span className="ow-progress-fill" style={{ width: progressWidth }} />
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="ow-card-body">
|
|
|
|
{step === 1 ? (
|
|
<div className="ow-stack">
|
|
<div className="ow-heading-block">
|
|
<span className="ow-icon-chip">01</span>
|
|
<h1 className="ow-title">Welcome back</h1>
|
|
<p className="ow-subtitle">Sign in to launch and manage cloud workers.</p>
|
|
</div>
|
|
|
|
<form className="ow-stack" onSubmit={handleAuthSubmit}>
|
|
{authMode === "sign-up" ? (
|
|
<label className="ow-field-block">
|
|
<span className="ow-field-label">Name</span>
|
|
<input
|
|
className="ow-input"
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
autoComplete="name"
|
|
required
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
<label className="ow-field-block">
|
|
<span className="ow-field-label">Email</span>
|
|
<input
|
|
className="ow-input"
|
|
type="email"
|
|
value={email}
|
|
onChange={(event) => setEmail(event.target.value)}
|
|
autoComplete="email"
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<label className="ow-field-block">
|
|
<span className="ow-field-label">Password</span>
|
|
<input
|
|
className="ow-input"
|
|
type="password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
autoComplete={authMode === "sign-up" ? "new-password" : "current-password"}
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<button type="submit" className="ow-btn-primary" disabled={authBusy}>
|
|
{authBusy ? "Working..." : authMode === "sign-in" ? "Continue" : "Create account"}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="ow-inline-row">
|
|
<p className="ow-caption">{authMode === "sign-in" ? "Need an account?" : "Already have an account?"}</p>
|
|
<button
|
|
type="button"
|
|
className="ow-link"
|
|
onClick={() => setAuthMode((current) => (current === "sign-in" ? "sign-up" : "sign-in"))}
|
|
>
|
|
{authMode === "sign-in" ? "Create account" : "Switch to sign in"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="ow-note-box">
|
|
<p>{authInfo}</p>
|
|
{authError ? <p className="ow-error-text">{authError}</p> : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{step === 2 ? (
|
|
<div className="ow-app-shell bg-[#F4F5F7] p-3 md:p-4 rounded-[32px]">
|
|
<aside className="ow-app-nav">
|
|
<div className="ow-nav-group">
|
|
<p className="ow-nav-label">Workspace</p>
|
|
<button
|
|
type="button"
|
|
className={`ow-nav-item ${shellView === "workers" ? "is-active" : ""}`}
|
|
onClick={() => setShellView("workers")}
|
|
>
|
|
Workers
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`ow-nav-item ${shellView === "billing" ? "is-active" : ""}`}
|
|
onClick={() => setShellView("billing")}
|
|
>
|
|
Billing
|
|
</button>
|
|
</div>
|
|
|
|
<div className="ow-app-nav-footer">
|
|
<p className="ow-nav-label">Account</p>
|
|
<p className="ow-caption ow-nav-email">{(user?.email ?? email) || "account"}</p>
|
|
<button type="button" className="ow-link" onClick={() => void handleSignOut()} disabled={authBusy}>
|
|
{authBusy ? "Signing out..." : "Log out"}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{shellView === "workers" ? (
|
|
<>
|
|
<section className="ow-pane ow-workers-pane">
|
|
<div className="ow-pane-head ow-pane-head-row">
|
|
<div>
|
|
<p className="ow-section-title">Workers</p>
|
|
<p className="ow-caption">Pick a worker to see details.</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="ow-btn-primary ow-btn-compact ow-btn-primary-inline"
|
|
onClick={() => setShowLaunchForm((current) => !current)}
|
|
>
|
|
{showLaunchForm ? "Close" : "+ New Worker"}
|
|
</button>
|
|
</div>
|
|
|
|
{showLaunchForm ? (
|
|
<div className="ow-pane-block">
|
|
<label className="ow-field-block">
|
|
<span className="ow-field-label">Worker Name</span>
|
|
<input
|
|
className="ow-input"
|
|
value={workerName}
|
|
onChange={(event) => setWorkerName(event.target.value)}
|
|
maxLength={80}
|
|
/>
|
|
</label>
|
|
|
|
<button
|
|
type="button"
|
|
className="ow-btn-primary"
|
|
onClick={handleLaunchWorker}
|
|
disabled={!user || launchBusy || worker?.status === "provisioning"}
|
|
>
|
|
{launchBusy
|
|
? "Starting worker..."
|
|
: worker?.status === "provisioning"
|
|
? "Worker is starting..."
|
|
: `Launch "${workerName || "Cloud Worker"}"`}
|
|
</button>
|
|
|
|
{(launchStatus || launchError) && showLaunchForm ? (
|
|
<div className="ow-note-box">
|
|
<p>{launchStatus}</p>
|
|
{launchError ? <p className="ow-error-text">{launchError}</p> : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{checkoutUrl ? (
|
|
<div className="ow-paywall-box">
|
|
<p className="ow-paywall-title">Payment needed before launch</p>
|
|
<a href={checkoutUrl} rel="noreferrer" className="ow-btn-secondary ow-full">
|
|
Continue to checkout
|
|
</a>
|
|
<p className="ow-caption">After payment, come back and click launch again.</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="ow-filter-row">
|
|
<input
|
|
className="ow-input"
|
|
value={workerQuery}
|
|
onChange={(event) => setWorkerQuery(event.target.value)}
|
|
placeholder="Search workers"
|
|
aria-label="Search workers"
|
|
/>
|
|
<select
|
|
className="ow-select"
|
|
value={workerStatusFilter}
|
|
onChange={(event) => setWorkerStatusFilter(event.target.value as WorkerStatusBucket | "all")}
|
|
>
|
|
<option value="all">All statuses</option>
|
|
<option value="ready">Ready</option>
|
|
<option value="starting">Starting</option>
|
|
<option value="attention">Needs attention</option>
|
|
</select>
|
|
</div>
|
|
|
|
{workersBusy ? <p className="ow-caption">Loading workers...</p> : null}
|
|
{workersError ? <p className="ow-error-text">{workersError}</p> : null}
|
|
|
|
{filteredWorkers.length > 0 ? (
|
|
<ul className="ow-worker-list ow-worker-list-panel">
|
|
{filteredWorkers.map((item) => {
|
|
const meta = getWorkerStatusMeta(item.status);
|
|
return (
|
|
<li key={item.workerId}>
|
|
<button
|
|
type="button"
|
|
className={`ow-worker-select ${workerLookupId === item.workerId ? "is-active" : ""}`}
|
|
onClick={() => {
|
|
setWorkerLookupId(item.workerId);
|
|
setWorker((current) => listItemToWorker(item, current));
|
|
}}
|
|
>
|
|
<div className="ow-worker-head">
|
|
<div>
|
|
<p className="ow-step-title">{item.workerName}</p>
|
|
<p className="ow-worker-meta">{getWorkerAddressLabel(item)}</p>
|
|
</div>
|
|
<div className="ow-worker-badges">
|
|
<span className={`ow-status-pill is-${meta.bucket}`}>{meta.label}</span>
|
|
{item.isMine ? <span className="ow-badge">Yours</span> : null}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
|
|
{workers.length > 0 && filteredWorkers.length === 0 ? (
|
|
<p className="ow-caption">No workers match this filter.</p>
|
|
) : null}
|
|
|
|
{workers.length === 0 && !workersBusy ? (
|
|
<p className="ow-caption">No workers yet. Launch one to get started.</p>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="ow-pane ow-detail-pane">
|
|
{selectedWorker ? (
|
|
<>
|
|
<div className="ow-pane-head">
|
|
<p className="ow-section-title">Overview</p>
|
|
<div className="ow-worker-head">
|
|
<div>
|
|
<p className="ow-step-title">{activeWorker?.workerName ?? selectedWorker.workerName}</p>
|
|
<p className="ow-step-detail">{selectedStatusMeta.label}</p>
|
|
</div>
|
|
{selectedWorker.isMine ? <span className="ow-badge">Yours</span> : null}
|
|
</div>
|
|
<p className="ow-caption">{getWorkerStatusCopy(selectedWorkerStatus)}</p>
|
|
</div>
|
|
|
|
<div className="ow-overview-grid">
|
|
<div className="ow-overview-card">
|
|
<p className="ow-overview-label">Status</p>
|
|
<p className={`ow-overview-value is-${selectedStatusMeta.bucket}`}>{selectedStatusMeta.label}</p>
|
|
</div>
|
|
<div className="ow-overview-card">
|
|
<p className="ow-overview-label">Connect</p>
|
|
<p className="ow-overview-value">{openworkDeepLink ? "One-click ready" : "Preparing"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ow-inline-actions">
|
|
<button
|
|
type="button"
|
|
className="ow-btn-primary ow-btn-primary-inline ow-open-btn"
|
|
onClick={() => {
|
|
if (!openworkDeepLink) {
|
|
return;
|
|
}
|
|
window.location.href = openworkDeepLink;
|
|
}}
|
|
disabled={!openworkDeepLink || selectedStatusMeta.bucket !== "ready"}
|
|
>
|
|
{openworkDeepLink ? "Open in OpenWork" : "Preparing connection..."}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="ow-connection-block">
|
|
<p className="ow-field-label">Connection URL</p>
|
|
<div className="ow-copy-row">
|
|
<input
|
|
readOnly
|
|
value={openworkConnectUrl ?? "Connection URL is still preparing..."}
|
|
className="ow-input ow-mono"
|
|
onClick={(event) => event.currentTarget.select()}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="ow-btn-icon"
|
|
disabled={!openworkConnectUrl}
|
|
onClick={() => void copyToClipboard("openwork-url", openworkConnectUrl)}
|
|
>
|
|
{copiedField === "openwork-url" ? "Copied" : "Copy"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!openworkDeepLink || !openworkConnectUrl || (!hasWorkspaceScopedUrl && openworkConnectUrl) ? (
|
|
<div className="ow-note-box">
|
|
{!openworkDeepLink ? <p className="ow-caption">Getting connection details ready...</p> : null}
|
|
{!openworkConnectUrl ? <p className="ow-caption">Keep this page open for a moment.</p> : null}
|
|
{!hasWorkspaceScopedUrl && openworkConnectUrl ? <p className="ow-caption">Finishing your workspace URL...</p> : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<details className="ow-howto">
|
|
<summary>Manual connect details</summary>
|
|
<p className="ow-howto-copy">
|
|
If one-click open doesn't work, copy these into OpenWork: Add a worker > Connect remote.
|
|
</p>
|
|
<CredentialRow
|
|
label="OpenWork worker URL"
|
|
value={openworkConnectUrl}
|
|
placeholder="URL appears once ready"
|
|
canCopy={Boolean(openworkConnectUrl)}
|
|
copied={copiedField === "manual-openwork-url"}
|
|
onCopy={() => void copyToClipboard("manual-openwork-url", openworkConnectUrl)}
|
|
/>
|
|
|
|
<CredentialRow
|
|
label="Access token"
|
|
value={activeWorker?.clientToken ?? null}
|
|
placeholder="Use Worker actions to refresh"
|
|
canCopy={Boolean(activeWorker?.clientToken)}
|
|
copied={copiedField === "access-token"}
|
|
onCopy={() => void copyToClipboard("access-token", activeWorker?.clientToken ?? null)}
|
|
/>
|
|
</details>
|
|
|
|
<details className="ow-howto">
|
|
<summary>Worker actions</summary>
|
|
<div className="ow-inline-actions">
|
|
<button
|
|
type="button"
|
|
className="ow-btn-secondary"
|
|
onClick={() => void refreshWorkers({ keepSelection: true })}
|
|
disabled={workersBusy || actionBusy !== null}
|
|
>
|
|
{workersBusy ? "Refreshing..." : "Refresh list"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ow-btn-secondary"
|
|
onClick={() => void handleCheckStatus({ workerId: selectedWorker.workerId })}
|
|
disabled={actionBusy !== null}
|
|
>
|
|
{actionBusy === "status" ? "Checking..." : "Check status"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ow-btn-secondary"
|
|
onClick={handleGenerateKey}
|
|
disabled={actionBusy !== null}
|
|
>
|
|
{actionBusy === "token" ? "Fetching..." : "Refresh token"}
|
|
</button>
|
|
</div>
|
|
</details>
|
|
|
|
<details className="ow-howto">
|
|
<summary>Advanced details</summary>
|
|
<CredentialRow
|
|
label="Worker host URL"
|
|
value={activeWorker?.instanceUrl ?? null}
|
|
placeholder="Host URL"
|
|
canCopy={Boolean(activeWorker?.instanceUrl)}
|
|
copied={copiedField === "worker-host-url"}
|
|
onCopy={() => void copyToClipboard("worker-host-url", activeWorker?.instanceUrl ?? null)}
|
|
/>
|
|
|
|
<CredentialRow
|
|
label="Worker ID"
|
|
value={(activeWorker?.workerId ?? workerLookupId) || null}
|
|
placeholder="Worker ID"
|
|
canCopy={Boolean(activeWorker?.workerId || workerLookupId)}
|
|
copied={copiedField === "worker-id"}
|
|
onCopy={() => void copyToClipboard("worker-id", (activeWorker?.workerId ?? workerLookupId) || null)}
|
|
/>
|
|
|
|
{events.length > 0 ? (
|
|
<div className="ow-log-box">
|
|
<p className="ow-section-title">Recent activity</p>
|
|
<ul className="ow-log-list">
|
|
{events.map((entry) => (
|
|
<li key={entry.id} className={`ow-log-item level-${entry.level}`}>
|
|
<div className="ow-log-head">
|
|
<span>{entry.label}</span>
|
|
<span className="ow-mono">{new Date(entry.at).toLocaleTimeString()}</span>
|
|
</div>
|
|
<p>{entry.detail}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</details>
|
|
</>
|
|
) : (
|
|
<div className="ow-empty-detail">
|
|
<p className="ow-section-title">Select a worker</p>
|
|
<p className="ow-caption">Pick a worker from the list to see details and connect.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
) : (
|
|
<section className="ow-pane ow-billing-pane ow-pane-span-2">
|
|
<div className="ow-pane-head">
|
|
<p className="ow-section-title">Billing</p>
|
|
<p className="ow-caption">Handle checkout when launching a new worker.</p>
|
|
</div>
|
|
|
|
{checkoutUrl ? (
|
|
<div className="ow-paywall-box">
|
|
<p className="ow-paywall-title">Checkout in progress</p>
|
|
<a href={checkoutUrl} rel="noreferrer" className="ow-btn-secondary ow-full">
|
|
Continue to checkout
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<div className="ow-note-box">
|
|
<p>No payment action right now.</p>
|
|
<p className="ow-caption">Go to Workers and launch a worker when you're ready.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|