refactor: consolidate loading/error states and extract instance actions hook
Frontend: - Create reusable DataStates components (LoadingState, ErrorState, EmptyState) - Refactor 12 pages to use shared state components instead of inline JSX - Extract useInstanceActions hook to eliminate session action duplication - Update dashboard and sessions pages to use shared hook OpenSpec: - Archive completed mobile-app-usability change (44/44 tasks) - Archive completed add-config-profiles change (15/15 tasks) Quality: TypeScript check passes, production build succeeds
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
@@ -408,7 +409,7 @@ export const ConfigProfilesPage = () => {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p>Loading Config Profiles...</p>
|
||||
<LoadingState message="Loading Config Profiles..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -416,10 +417,7 @@ export const ConfigProfilesPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p className="text-error">Failed to load Config Profiles.</p>
|
||||
<button onClick={loadData}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load Config Profiles." onRetry={loadData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -31,7 +33,6 @@ export const HomePage = () => {
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
@@ -58,6 +59,15 @@ export const HomePage = () => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const {
|
||||
loadingSessionId: actionBusy,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
@@ -130,64 +140,6 @@ export const HomePage = () => {
|
||||
await loadHome();
|
||||
};
|
||||
|
||||
const handleOpen = (session: SessionView) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
};
|
||||
|
||||
const handleStop = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch {
|
||||
// error - session remains in state
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (session: SessionView) => {
|
||||
if (actionBusy === session.id) return;
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await startInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadHome();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
@@ -202,17 +154,9 @@ export const HomePage = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Unable to load your workspace overview.</p>
|
||||
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
@@ -260,7 +204,7 @@ export const HomePage = () => {
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="muted">No projects yet.</p>
|
||||
<EmptyState message="No projects yet." />
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
@@ -54,7 +55,7 @@ export const GitHistoryPage = () => {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p className="muted">Loading commit history...</p>
|
||||
<LoadingState message="Loading commit history..." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -62,11 +63,7 @@ export const GitHistoryPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load commit history</p>
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load commit history" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
listRepositories,
|
||||
} from "../api/git_repositories";
|
||||
import type { GitRepository } from "../api/git_repositories";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
@@ -48,19 +49,11 @@ export const GitRepositoriesPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading repositories..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load repositories" onRetry={reload} />}
|
||||
|
||||
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
|
||||
{isEmpty && <EmptyState message="No repositories yet. Create your first repository above." />}
|
||||
|
||||
{status === "ready" && safeRepositories.length > 0 && (
|
||||
<div className="repository-list">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
@@ -91,17 +92,9 @@ export const ProfilePage = () => {
|
||||
<section className="stack">
|
||||
<h1>Profile</h1>
|
||||
|
||||
{displayStatus === "loading" && <p className="muted">Loading profile...</p>}
|
||||
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
|
||||
|
||||
{displayStatus === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load profile</p>
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
|
||||
|
||||
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
|
||||
<div className="card stack">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Project } from "../types";
|
||||
@@ -101,19 +102,11 @@ export const ProjectsPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading projects...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load projects</p>
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
||||
|
||||
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
|
||||
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
|
||||
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
<div className="project-list">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
|
||||
@@ -164,26 +165,16 @@ export const RepoWorkspace = () => {
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="muted">Loading repositories...</p>
|
||||
<LoadingState message="Loading repositories..." />
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<EmptyState message="No repositories in this project yet." />
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
@@ -486,7 +477,7 @@ const FileBrowser = ({
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<p className="muted">No files in this repository yet.</p>
|
||||
<EmptyState message="No files in this repository yet." />
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
|
||||
+18
-115
@@ -7,18 +7,15 @@ import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
deleteInstance,
|
||||
stopInstance,
|
||||
startInstance,
|
||||
checkInstanceHealth,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { Icon } from "../components/icon";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { SessionCard } from "../components/session-card";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
import type { InstanceHealth } from "../api/sessions";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
@@ -34,11 +31,7 @@ export const SessionsPage = () => {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -83,7 +76,18 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
|
||||
const {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
@@ -154,116 +158,15 @@ export const SessionsPage = () => {
|
||||
await loadSessions();
|
||||
};
|
||||
|
||||
const handleStop = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
// Remove from local state immediately
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch (error) {
|
||||
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
const detail = axiosError.response.data?.detail;
|
||||
if (detail?.changed_files) {
|
||||
setDirtyDeleteSession(session);
|
||||
setDirtyDeleteFiles(detail.changed_files);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceDelete = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id
|
||||
);
|
||||
// Refresh sessions to get new URL
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await startInstance(session.project_id, session.repository_id, session.id);
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = (session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, '_blank', 'noopener,noreferrer');
|
||||
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
} else {
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading sessions...</p>}
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load sessions</p>
|
||||
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
|
||||
|
||||
{status === "ready" && (
|
||||
<>
|
||||
@@ -311,7 +214,7 @@ export const SessionsPage = () => {
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
|
||||
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
@@ -330,7 +233,7 @@ export const SessionsPage = () => {
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setDirtyDeleteSession(null)}
|
||||
onClick={clearDirtyDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
@@ -71,17 +72,13 @@ export const SettingsPage = () => {
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <section className="stack"><p className="muted">Loading settings...</p></section>;
|
||||
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load settings</p>
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load settings" onRetry={reload} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
@@ -87,7 +88,7 @@ export const SSHKeysPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "loading") return <div>Loading...</div>;
|
||||
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -130,19 +131,11 @@ export const SSHKeysPage = () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load SSH keys</p>
|
||||
<button className="secondary-button" onClick={() => loadKeys()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
|
||||
|
||||
<div className="keys-list">
|
||||
{safeKeys.length === 0 ? (
|
||||
<p className="muted">No SSH keys yet. Generate one above.</p>
|
||||
<EmptyState message="No SSH keys yet. Generate one above." />
|
||||
) : (
|
||||
safeKeys.map((key) => (
|
||||
<div key={key.id} className="key-card">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
@@ -491,7 +492,7 @@ export const ToolWorkshopPage = () => {
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p>Loading Tool Workshop...</p>
|
||||
<LoadingState message="Loading Tool Workshop..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -499,10 +500,7 @@ export const ToolWorkshopPage = () => {
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p className="text-error">Failed to load Tool Workshop.</p>
|
||||
<button onClick={loadData}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
<ErrorState message="Failed to load Tool Workshop." onRetry={loadData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1305,7 +1303,7 @@ export const ToolWorkshopPage = () => {
|
||||
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{toolConfigs.length === 0 ? (
|
||||
<p className="muted">No configurations for this tool type yet.</p>
|
||||
<EmptyState message="No configurations for this tool type yet." />
|
||||
) : (
|
||||
toolConfigs.map((config) => (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user