fix: reduce false container-failed notifications, add error details to UI
Backend (health_monitor.py): - Skip health checks for instances with no container_id - Treat 'not_found' as error only when container was previously running - Skip duplicate error notifications when already in error state - Skip 'not_found' notifications for containers that never ran Frontend (notification-item.tsx): - Display notification.message (detailed error text) - Add expandable Details section showing metadata (exit_code, previous_status, etc.) - New CSS styles for message and metadata display Quality gates: py_compile, tsc --noEmit, 80/80 tests pass
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../icon";
|
||||
import { formatRelativeTime } from "../../../utils/time";
|
||||
import type { NotificationItem as NotificationItemType } from "../../../api/notifications";
|
||||
@@ -17,6 +18,18 @@ const severityIconMap: Record<string, IconName> = {
|
||||
success: "success",
|
||||
};
|
||||
|
||||
function formatMetadataValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
onMarkRead,
|
||||
@@ -24,6 +37,11 @@ export function NotificationItem({
|
||||
}: NotificationItemProps) {
|
||||
const isUnread = notification.read_at === null;
|
||||
const iconName = severityIconMap[notification.severity] ?? "info";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasDetails =
|
||||
!!notification.message ||
|
||||
Object.keys(notification.metadata || {}).length > 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -35,11 +53,36 @@ export function NotificationItem({
|
||||
</div>
|
||||
<div className="notification-item-content">
|
||||
<div className="notification-item-title">{notification.title}</div>
|
||||
{notification.message && (
|
||||
<div className="notification-item-message">
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="notification-item-time">
|
||||
{formatRelativeTime(notification.created_at)}
|
||||
</div>
|
||||
{expanded && notification.metadata && (
|
||||
<dl className="notification-item-metadata">
|
||||
{Object.entries(notification.metadata).map(([key, value]) => (
|
||||
<div key={key} className="notification-metadata-row">
|
||||
<dt>{key.replace(/_/g, " ")}</dt>
|
||||
<dd>{formatMetadataValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
<div className="notification-item-actions">
|
||||
{hasDetails && (
|
||||
<button
|
||||
type="button"
|
||||
className="notification-item-action"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-label={expanded ? "Hide details" : "Show details"}
|
||||
>
|
||||
{expanded ? "Less" : "Details"}
|
||||
</button>
|
||||
)}
|
||||
{isUnread && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,121 +3,175 @@ import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
|
||||
import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
project: ProjectWithRepos;
|
||||
expanded: boolean;
|
||||
deleteConfirm: boolean;
|
||||
workspaceLoading: string | null;
|
||||
showCreateForm: string | null;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onCreateWorkspace: (repoId: string) => void;
|
||||
onWorkspaceAction: (repoId: string, workspace: WorkspaceSummary, action: "sync" | "delete") => void;
|
||||
onCancelCreate: () => void;
|
||||
onCreated: () => void;
|
||||
project: ProjectWithRepos;
|
||||
expanded: boolean;
|
||||
deleteConfirm: boolean;
|
||||
workspaceLoading: string | null;
|
||||
showCreateForm: string | null;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onCreateWorkspace: (repoId: string) => void;
|
||||
onWorkspaceAction: (
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
action: "sync" | "delete",
|
||||
) => void;
|
||||
onCancelCreate: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export const ProjectCard = ({
|
||||
project,
|
||||
expanded,
|
||||
deleteConfirm,
|
||||
workspaceLoading,
|
||||
showCreateForm,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onCreateWorkspace,
|
||||
onWorkspaceAction,
|
||||
onCancelCreate,
|
||||
onCreated,
|
||||
project,
|
||||
expanded,
|
||||
deleteConfirm,
|
||||
workspaceLoading,
|
||||
showCreateForm,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onCreateWorkspace,
|
||||
onWorkspaceAction,
|
||||
onCancelCreate,
|
||||
onCreated,
|
||||
}: Props) => {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-info-row">
|
||||
<button className="project-toggle" onClick={onToggle} type="button" aria-expanded={expanded}>
|
||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
||||
<div>
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted project-description">{project.description}</p>}
|
||||
</div>
|
||||
{project.repositories?.length > 0 && (
|
||||
<span className="repo-count">
|
||||
{project.repositories.length} repo{project.repositories.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="project-actions">
|
||||
<button className="ghost-button" onClick={onEdit} type="button">
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button className="danger-button" onClick={onConfirmDelete} type="button">
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
<button className="ghost-button" onClick={onCancelDelete} type="button">
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="ghost-button danger-text" onClick={onDelete} type="button">
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-info-row">
|
||||
<button
|
||||
className="project-toggle"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
||||
<div>
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted project-description">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{project.repositories?.length > 0 && (
|
||||
<span className="repo-count">
|
||||
{project.repositories.length} repo
|
||||
{project.repositories.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="project-actions">
|
||||
<button className="ghost-button" onClick={onEdit} type="button">
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={onConfirmDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="project-detail">
|
||||
{(project.repositories || []).length === 0 ? (
|
||||
<p className="muted">No repositories yet.</p>
|
||||
) : (
|
||||
<div className="repo-list">
|
||||
{(project.repositories || []).map((repo) => (
|
||||
<div key={repo.id} className="repo-block">
|
||||
<div className="repo-header">
|
||||
<h4>{repo.name}</h4>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => onCreateWorkspace(repo.id)} type="button">
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
{showCreateForm === repo.id && (
|
||||
<WorkspaceCreateForm defaultProjectId={project.id} defaultRepoId={repo.id} onSubmit={onCreated} onCancel={onCancelCreate} />
|
||||
)}
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted">No workspaces.</p>
|
||||
) : (
|
||||
<div className="workspace-grid">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<div key={ws.id} className={`workspace-chip ${ws.status}`}>
|
||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||
<span className="ws-branch"><Icon name="branch" size="sm" /> {ws.branch}</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="ws-instances">{ws.instance_count} tool{ws.instance_count > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
<div className="ws-actions">
|
||||
<button type="button" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "sync")}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button type="button" className="danger-text" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "delete")}>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
{expanded && (
|
||||
<div className="project-detail">
|
||||
{(project.repositories || []).length === 0 ? (
|
||||
<p className="muted">No repositories yet.</p>
|
||||
) : (
|
||||
<div className="repo-list">
|
||||
{(project.repositories || []).map((repo) => (
|
||||
<div key={repo.id} className="repo-block">
|
||||
<div className="repo-header">
|
||||
<h4>{repo.name}</h4>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => onCreateWorkspace(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
{showCreateForm === repo.id && (
|
||||
<WorkspaceCreateForm
|
||||
defaultProjectId={project.id}
|
||||
defaultRepoId={repo.id}
|
||||
onSubmit={onCreated}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted">No workspaces.</p>
|
||||
) : (
|
||||
<div className="workspace-grid">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`workspace-chip ${ws.status}`}
|
||||
>
|
||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||
<span className="ws-branch">
|
||||
<Icon name="branch" size="sm" /> {ws.branch}
|
||||
</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="ws-instances">
|
||||
{ws.instance_count} tool
|
||||
{ws.instance_count > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="ws-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "sync")
|
||||
}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-text"
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(repo.id, ws, "delete")
|
||||
}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,343 +1,377 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../../../api/git-repositories";
|
||||
import {
|
||||
createRepository,
|
||||
parseGitUrl,
|
||||
type GitRepositoryCreate,
|
||||
type URLParseResult,
|
||||
} from "../../../api/git-repositories";
|
||||
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
||||
import { Icon } from "../../icon";
|
||||
|
||||
type CreateMode = "clone" | "blank";
|
||||
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
||||
type UrlValidationStatus =
|
||||
| "idle"
|
||||
| "validating"
|
||||
| "valid"
|
||||
| "needs-parsing"
|
||||
| "invalid";
|
||||
|
||||
interface RepositoryCreateDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [urlValidation, setUrlValidation] = useState<{
|
||||
status: UrlValidationStatus;
|
||||
result: URLParseResult | null;
|
||||
}>({ status: "idle", result: null });
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
export const RepositoryCreateDialog = ({
|
||||
projectId,
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [urlValidation, setUrlValidation] = useState<{
|
||||
status: UrlValidationStatus;
|
||||
result: URLParseResult | null;
|
||||
}>({ status: "idle", result: null });
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await parseGitUrl(advancedUrl.trim());
|
||||
if (result.is_valid_clone_url) {
|
||||
setUrlValidation({ status: "valid", result });
|
||||
} else if (result.needs_parsing) {
|
||||
setUrlValidation({ status: "needs-parsing", result });
|
||||
} else {
|
||||
setUrlValidation({ status: "invalid", result });
|
||||
}
|
||||
} catch {
|
||||
setUrlValidation({ status: "invalid", result: null });
|
||||
}
|
||||
}, 300);
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await parseGitUrl(advancedUrl.trim());
|
||||
if (result.is_valid_clone_url) {
|
||||
setUrlValidation({ status: "valid", result });
|
||||
} else if (result.needs_parsing) {
|
||||
setUrlValidation({ status: "needs-parsing", result });
|
||||
} else {
|
||||
setUrlValidation({ status: "invalid", result });
|
||||
}
|
||||
} catch {
|
||||
setUrlValidation({ status: "invalid", result: null });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(true);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setSelectedSshKey("");
|
||||
};
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(true);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setSelectedSshKey("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
return;
|
||||
}
|
||||
input.remote_url = advancedUrl.trim();
|
||||
} else {
|
||||
if (!owner.trim() || !repoName.trim()) {
|
||||
setFormError("Owner and repository name are required");
|
||||
return;
|
||||
}
|
||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||
}
|
||||
if (selectedSshKey) {
|
||||
input.ssh_key_id = selectedSshKey;
|
||||
}
|
||||
}
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
return;
|
||||
}
|
||||
input.remote_url = advancedUrl.trim();
|
||||
} else {
|
||||
if (!owner.trim() || !repoName.trim()) {
|
||||
setFormError("Owner and repository name are required");
|
||||
return;
|
||||
}
|
||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||
}
|
||||
if (selectedSshKey) {
|
||||
input.ssh_key_id = selectedSshKey;
|
||||
}
|
||||
}
|
||||
|
||||
await createRepository(projectId, input);
|
||||
handleClose();
|
||||
await onCreated();
|
||||
} catch (error: unknown) {
|
||||
const response = error as { response?: { data?: { detail?: string } } };
|
||||
const detail = response.response?.data?.detail;
|
||||
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
|
||||
}
|
||||
};
|
||||
await createRepository(projectId, input);
|
||||
handleClose();
|
||||
await onCreated();
|
||||
} catch (error: unknown) {
|
||||
const response = error as { response?: { data?: { detail?: string } } };
|
||||
const detail = response.response?.data?.detail;
|
||||
setFormError(
|
||||
typeof detail === "string" ? detail : "Failed to create repository",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">Validating...</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(false)}
|
||||
>
|
||||
Use owner/repo instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="error-message">
|
||||
<p className="error-text">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={handleClose} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
<Icon name="add" size="sm" />
|
||||
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
Clone an existing repository from git.commumedia.org, or create a
|
||||
blank bare repo here.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted">
|
||||
SSH target: git@git.commumedia.org:{owner || "owner"}/
|
||||
{repoName || "repo"}.git
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">
|
||||
Validating...
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" &&
|
||||
urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a
|
||||
browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">
|
||||
Suggested: {urlValidation.result.base_url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(false)}
|
||||
>
|
||||
Use owner/repo instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="error-message">
|
||||
<p className="error-text">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
<Icon name="add" size="sm" />
|
||||
{createMode === "clone"
|
||||
? "Clone Repository"
|
||||
: "Create Blank Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user