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:
Developer
2026-06-06 09:01:01 +00:00
parent 2169b24875
commit 1ef9d66eed
6 changed files with 697 additions and 475 deletions
@@ -90,8 +90,16 @@ class HealthMonitor:
instance: ToolInstance,
) -> None:
"""Check a single instance and handle state transitions."""
# Skip instances that have never been assigned a container.
if not instance.container_id:
logger.debug(
"Skipping health check for instance %s: no container_id",
instance.id,
)
return
try:
container_info = get_container_status(instance.container_id or "")
container_info = get_container_status(instance.container_id)
except Exception:
logger.exception(
"Health check failed for instance %s",
@@ -135,7 +143,11 @@ class HealthMonitor:
previous = self._last_known_state.get(instance.id)
# Determine new status
new_status = self._derive_status(snapshot)
new_status = self._derive_status(
snapshot,
previous,
instance.status,
)
# If first check or state changed
if previous is None or not self._snapshots_equal(previous, snapshot):
@@ -144,10 +156,34 @@ class HealthMonitor:
)
self._last_known_state[instance.id] = snapshot
def _derive_status(self, snapshot: HealthSnapshot) -> str:
"""Derive instance status from health snapshot."""
def _derive_status(
self,
snapshot: HealthSnapshot,
previous: HealthSnapshot | None,
current_status: str | None,
) -> str:
"""Derive instance status from health snapshot.
Treats missing containers as an error only when the container was
previously known to be running. This avoids false "container failed"
alerts for instances that are still starting or have no container yet.
"""
if snapshot.container_status == "not_found":
# If the container was never seen running, assume it's still
# starting or was deleted intentionally; don't flag as error.
if previous is None and current_status == "starting":
return "starting"
if previous is not None and previous.container_status == "running":
return "error"
# Fall back to current status to avoid spurious errors.
return current_status or "error"
if snapshot.container_status == "exited":
return "error"
if snapshot.container_status != "running":
return "error"
if snapshot.tunnel_healthy is False:
return "unhealthy"
return "running"
@@ -223,6 +259,16 @@ class HealthMonitor:
# Create notification for instance owner (fire-and-forget)
# Only send warnings and errors; skip "recovered" info notifications.
if new_status == "error":
# Skip duplicate error notifications if already in error state.
if previous_status == "error":
return
# Skip "not_found" errors for containers that were never running
# (e.g. still starting, or intentionally stopped/deleted).
if (
snapshot.container_status == "not_found"
and (previous is None or previous.container_status != "running")
):
return
category = "instance"
severity = "error"
title = "Container failed"
@@ -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>
);
};
+53 -46
View File
@@ -10,72 +10,79 @@ const mockProjects = vi.fn();
const mockRepos = vi.fn();
vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args),
}));
vi.mock("../api/sessions", () => ({
getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(),
startInstance: vi.fn(),
stopInstance: vi.fn(),
deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn()
getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(),
startInstance: vi.fn(),
stopInstance: vi.fn(),
deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn(),
}));
vi.mock("../api/projects", () => ({
listProjects: (...args: unknown[]) => mockProjects(...args)
listProjects: (...args: unknown[]) => mockProjects(...args),
}));
vi.mock("../api/git-repositories", () => ({
listRepositories: (...args: unknown[]) => mockRepos(...args)
listRepositories: (...args: unknown[]) => mockRepos(...args),
}));
vi.mock("../api/tool-types", () => ({
listToolTypes: vi.fn().mockResolvedValue([])
listToolTypes: vi.fn().mockResolvedValue([]),
}));
describe("HomePage", () => {
beforeEach(() => {
mockDashboard.mockReset();
mockSessions.mockReset();
mockProjects.mockReset();
mockRepos.mockReset();
});
beforeEach(() => {
mockDashboard.mockReset();
mockSessions.mockReset();
mockProjects.mockReset();
mockRepos.mockReset();
});
it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({
projects: 1,
repositories: 2,
sshKeys: 3,
recentActivity: [],
});
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Workspaces")).toBeInTheDocument();
});
});
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Workspaces")).toBeInTheDocument();
});
});
it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed"));
it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed"));
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText("Unable to load your workspace overview."),
).toBeInTheDocument();
});
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
});
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
});
});
+38
View File
@@ -2474,6 +2474,44 @@ a:active,
color: var(--muted);
}
.notification-item-message {
font-size: 0.85rem;
color: var(--muted);
line-height: 1.35;
margin-top: 0.15rem;
word-break: break-word;
}
.notification-item-metadata {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
font-size: 0.8rem;
}
.notification-metadata-row {
display: flex;
gap: 0.5rem;
padding: 0.15rem 0;
}
.notification-metadata-row dt {
font-weight: 500;
color: var(--ink);
min-width: 6rem;
text-transform: capitalize;
flex-shrink: 0;
}
.notification-metadata-row dd {
margin: 0;
color: var(--muted);
font-family: monospace;
word-break: break-word;
}
.notification-item-actions {
display: flex;
gap: 0.35rem;