refactor: extract shared validation and reduce duplication
- Extract tool_types validation to shared module (validate_compose_yaml, check_port_exposed, validate_required_variables) - Extract _get_user and _get_owned_project to auth/dependencies.py - Create useAsyncData hook and apply to 6 pages - Create extractErrorMessage utility - TypeScript and build pass
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type AsyncStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
interface UseAsyncDataResult<T> {
|
||||
data: T | null;
|
||||
status: AsyncStatus;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function useAsyncData<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
deps: React.DependencyList = []
|
||||
): UseAsyncDataResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [status, setStatus] = useState<AsyncStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetcher();
|
||||
setData(result);
|
||||
setStatus("ready");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load data");
|
||||
setStatus("error");
|
||||
}
|
||||
}, deps);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { data, status, error, reload };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import { MobileListView } from "../components/mobile-list-view";
|
||||
import { MobileDetailView } from "../components/mobile-detail-view";
|
||||
import { MobileEditView } from "../components/mobile-edit-view";
|
||||
@@ -127,16 +128,6 @@ export const ConfigProfilesPage = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const extractErrorMessage = (err: unknown): string => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
|
||||
const detail = axiosError?.response?.data?.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
||||
}
|
||||
return "Failed to save";
|
||||
};
|
||||
|
||||
// Include management functions
|
||||
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
|
||||
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
|
||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const GitHistoryPage = () => {
|
||||
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>("");
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!projectId || !repoId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||
setCommits(data.commits);
|
||||
setBranches(data.branches);
|
||||
if (data.branches.length > 0 && !selectedBranch) {
|
||||
setSelectedBranch(data.branches[0]);
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, repoId, selectedBranch]);
|
||||
const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
|
||||
async () => {
|
||||
if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
|
||||
return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||
},
|
||||
[projectId, repoId, selectedBranch]
|
||||
);
|
||||
|
||||
// Auto-select first branch when data loads
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
}, [loadHistory]);
|
||||
if (historyData?.branches.length && !selectedBranch) {
|
||||
setSelectedBranch(historyData.branches[0]);
|
||||
}
|
||||
}, [historyData?.branches, selectedBranch]);
|
||||
|
||||
const handleCommitClick = async (hash: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
@@ -70,7 +63,7 @@ export const GitHistoryPage = () => {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load commit history</p>
|
||||
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
@@ -78,6 +71,9 @@ export const GitHistoryPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const commits = historyData?.commits ?? [];
|
||||
const branches = historyData?.branches ?? [];
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -8,46 +8,35 @@ import {
|
||||
import type { GitRepository } from "../api/git_repositories";
|
||||
import { Icon } from "../components/icon";
|
||||
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||
|
||||
type RepoStatus = "loading" | "ready" | "error";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const GitRepositoriesPage = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
|
||||
async () => {
|
||||
if (!projectId) return [];
|
||||
return await listRepositories(projectId);
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadRepositories();
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && repositories.length === 0;
|
||||
const safeRepositories = repositories ?? [];
|
||||
const isEmpty = status === "ready" && safeRepositories.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -64,7 +53,7 @@ export const GitRepositoriesPage = () => {
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
@@ -73,9 +62,9 @@ export const GitRepositoriesPage = () => {
|
||||
|
||||
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
{status === "ready" && safeRepositories.length > 0 && (
|
||||
<div className="repository-list">
|
||||
{repositories.map((repo) => (
|
||||
{safeRepositories.map((repo) => (
|
||||
<article className="card repository-card" key={repo.id}>
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
@@ -131,7 +120,7 @@ export const GitRepositoriesPage = () => {
|
||||
open={showCreate}
|
||||
title="Create Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
onCreated={reload}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -3,37 +3,35 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { UserProfile } from "../api/profile";
|
||||
|
||||
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const { refreshSession } = useAuth();
|
||||
const [status, setStatus] = useState<ProfileStatus>("loading");
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
|
||||
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getProfile();
|
||||
setProfile(data);
|
||||
setName(data.name);
|
||||
setEmail(data.email);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProfile(null);
|
||||
setStatus("error");
|
||||
// Sync loaded profile into form fields
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setName(profile.name);
|
||||
setEmail(profile.email);
|
||||
setDisplayStatus("ready");
|
||||
setError(null);
|
||||
}
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
}, [loadProfile]);
|
||||
if (loadStatus === "error") {
|
||||
setDisplayStatus("error");
|
||||
}
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -45,16 +43,15 @@ export const ProfilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setDisplayStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
|
||||
setProfile(updated);
|
||||
await updateProfile({ name: name.trim(), email: email.trim() });
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to update profile");
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
}
|
||||
}, [name, email, refreshSession]);
|
||||
|
||||
@@ -73,19 +70,19 @@ export const ProfilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setDisplayStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await uploadAvatar(file);
|
||||
setProfile(updated);
|
||||
await uploadAvatar(file);
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
reload();
|
||||
setDisplayStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to upload avatar");
|
||||
setStatus("ready");
|
||||
setDisplayStatus("ready");
|
||||
}
|
||||
},
|
||||
[refreshSession]
|
||||
[refreshSession, reload]
|
||||
);
|
||||
|
||||
const avatarUrl = profile?.avatar_url ?? null;
|
||||
@@ -94,19 +91,19 @@ export const ProfilePage = () => {
|
||||
<section className="stack">
|
||||
<h1>Profile</h1>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading profile...</p>}
|
||||
{displayStatus === "loading" && <p className="muted">Loading profile...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
{displayStatus === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load profile</p>
|
||||
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "ready" || status === "saving") && profile && (
|
||||
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
|
||||
<div className="card stack">
|
||||
<div className="profile-avatar-section">
|
||||
<div className="avatar-preview">
|
||||
@@ -118,11 +115,11 @@ export const ProfilePage = () => {
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
{displayStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Uploading...
|
||||
@@ -146,7 +143,7 @@ export const ProfilePage = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-name">Name</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
id="profile-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
type="text"
|
||||
@@ -157,7 +154,7 @@ export const ProfilePage = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
id="profile-email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
@@ -170,11 +167,11 @@ export const ProfilePage = () => {
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={status === "saving"}
|
||||
disabled={displayStatus === "saving"}
|
||||
onClick={() => void handleSave()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
{displayStatus === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -11,14 +11,13 @@ import {
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Project } from "../types";
|
||||
|
||||
type ProjectsStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
@@ -26,21 +25,7 @@ export const ProjectsPage = () => {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProjects([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
const safeProjects = projects ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
@@ -88,7 +73,7 @@ export const ProjectsPage = () => {
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
await loadProjects();
|
||||
reload();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
@@ -98,13 +83,13 @@ export const ProjectsPage = () => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadProjects();
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && projects.length === 0;
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -121,7 +106,7 @@ export const ProjectsPage = () => {
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load projects</p>
|
||||
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
@@ -130,9 +115,9 @@ export const ProjectsPage = () => {
|
||||
|
||||
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
|
||||
|
||||
{status === "ready" && projects.length > 0 && (
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{projects.map((project) => (
|
||||
{safeProjects.map((project) => (
|
||||
<article className="card project-card" key={project.id}>
|
||||
<div className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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 { Icon } from "../components/icon";
|
||||
|
||||
type SettingsStatus = "loading" | "ready" | "error";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
@@ -26,7 +25,7 @@ type SettingsOutletContext = {
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
@@ -36,19 +35,12 @@ export const SettingsPage = () => {
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserConfig();
|
||||
setConfig(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync loaded config into local editable state
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
}, [loadConfig]);
|
||||
if (loadedConfig) {
|
||||
setConfig(loadedConfig);
|
||||
}
|
||||
}, [loadedConfig]);
|
||||
|
||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -86,7 +78,7 @@ export const SettingsPage = () => {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load settings</p>
|
||||
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
|
||||
<button className="secondary-button" onClick={() => reload()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||
@@ -17,23 +16,9 @@ export const SSHKeysPage = () => {
|
||||
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, []);
|
||||
|
||||
async function loadKeys() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listSSHKeys();
|
||||
setKeys(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load SSH keys");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const safeKeys = keys ?? [];
|
||||
|
||||
async function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -45,7 +30,7 @@ export const SSHKeysPage = () => {
|
||||
setNewKeyName("");
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setError("Failed to generate SSH key");
|
||||
setMutationError("Failed to generate SSH key");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -58,7 +43,7 @@ export const SSHKeysPage = () => {
|
||||
await deleteSSHKey(keyId);
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setError("Failed to delete SSH key");
|
||||
setMutationError("Failed to delete SSH key");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +59,9 @@ export const SSHKeysPage = () => {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||
setError(null);
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setError("Failed to sign payload");
|
||||
setMutationError("Failed to sign payload");
|
||||
} finally {
|
||||
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
@@ -94,15 +79,15 @@ export const SSHKeysPage = () => {
|
||||
signature: signature.trim(),
|
||||
});
|
||||
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||
setError(null);
|
||||
setMutationError(null);
|
||||
} catch {
|
||||
setError("Failed to verify signature");
|
||||
setMutationError("Failed to verify signature");
|
||||
} finally {
|
||||
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (status === "loading") return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
@@ -116,7 +101,7 @@ export const SSHKeysPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{mutationError && <div className="error">{mutationError}</div>}
|
||||
|
||||
<form onSubmit={handleGenerate} className="stack">
|
||||
<div className="form-group">
|
||||
@@ -145,11 +130,21 @@ 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>
|
||||
)}
|
||||
|
||||
<div className="keys-list">
|
||||
{keys.length === 0 ? (
|
||||
{safeKeys.length === 0 ? (
|
||||
<p className="muted">No SSH keys yet. Generate one above.</p>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
safeKeys.map((key) => (
|
||||
<div key={key.id} className="key-card">
|
||||
<div className="key-header">
|
||||
<h3>{key.name}</h3>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import { MobileListView } from "../components/mobile-list-view";
|
||||
import { MobileDetailView } from "../components/mobile-detail-view";
|
||||
import { MobileEditView } from "../components/mobile-edit-view";
|
||||
@@ -201,16 +202,6 @@ export const ToolWorkshopPage = () => {
|
||||
setShowFolderForm(false);
|
||||
};
|
||||
|
||||
const extractErrorMessage = (err: unknown): string => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
|
||||
const detail = axiosError?.response?.data?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map(d => typeof d === 'string' ? d : d.msg || JSON.stringify(d)).join(', ');
|
||||
}
|
||||
return "Failed to save";
|
||||
};
|
||||
|
||||
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setToolTypeError(null);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const extractErrorMessage = (err: unknown): string => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string | Array<{ msg?: string }> } } };
|
||||
const detail = axiosError?.response?.data?.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
|
||||
}
|
||||
return "Failed to save";
|
||||
};
|
||||
Reference in New Issue
Block a user