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, instance: ToolInstance,
) -> None: ) -> None:
"""Check a single instance and handle state transitions.""" """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: try:
container_info = get_container_status(instance.container_id or "") container_info = get_container_status(instance.container_id)
except Exception: except Exception:
logger.exception( logger.exception(
"Health check failed for instance %s", "Health check failed for instance %s",
@@ -135,7 +143,11 @@ class HealthMonitor:
previous = self._last_known_state.get(instance.id) previous = self._last_known_state.get(instance.id)
# Determine new status # 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 first check or state changed
if previous is None or not self._snapshots_equal(previous, snapshot): if previous is None or not self._snapshots_equal(previous, snapshot):
@@ -144,10 +156,34 @@ class HealthMonitor:
) )
self._last_known_state[instance.id] = snapshot self._last_known_state[instance.id] = snapshot
def _derive_status(self, snapshot: HealthSnapshot) -> str: def _derive_status(
"""Derive instance status from health snapshot.""" 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": if snapshot.container_status != "running":
return "error" return "error"
if snapshot.tunnel_healthy is False: if snapshot.tunnel_healthy is False:
return "unhealthy" return "unhealthy"
return "running" return "running"
@@ -223,6 +259,16 @@ class HealthMonitor:
# Create notification for instance owner (fire-and-forget) # Create notification for instance owner (fire-and-forget)
# Only send warnings and errors; skip "recovered" info notifications. # Only send warnings and errors; skip "recovered" info notifications.
if new_status == "error": 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" category = "instance"
severity = "error" severity = "error"
title = "Container failed" title = "Container failed"
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
import { formatRelativeTime } from "../../../utils/time"; import { formatRelativeTime } from "../../../utils/time";
import type { NotificationItem as NotificationItemType } from "../../../api/notifications"; import type { NotificationItem as NotificationItemType } from "../../../api/notifications";
@@ -17,6 +18,18 @@ const severityIconMap: Record<string, IconName> = {
success: "success", 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({ export function NotificationItem({
notification, notification,
onMarkRead, onMarkRead,
@@ -24,6 +37,11 @@ export function NotificationItem({
}: NotificationItemProps) { }: NotificationItemProps) {
const isUnread = notification.read_at === null; const isUnread = notification.read_at === null;
const iconName = severityIconMap[notification.severity] ?? "info"; const iconName = severityIconMap[notification.severity] ?? "info";
const [expanded, setExpanded] = useState(false);
const hasDetails =
!!notification.message ||
Object.keys(notification.metadata || {}).length > 0;
return ( return (
<li <li
@@ -35,11 +53,36 @@ export function NotificationItem({
</div> </div>
<div className="notification-item-content"> <div className="notification-item-content">
<div className="notification-item-title">{notification.title}</div> <div className="notification-item-title">{notification.title}</div>
{notification.message && (
<div className="notification-item-message">
{notification.message}
</div>
)}
<div className="notification-item-time"> <div className="notification-item-time">
{formatRelativeTime(notification.created_at)} {formatRelativeTime(notification.created_at)}
</div> </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>
<div className="notification-item-actions"> <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 && ( {isUnread && (
<button <button
type="button" type="button"
@@ -3,121 +3,175 @@ import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
import type { ProjectWithRepos, WorkspaceSummary } from "../../../types"; import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
interface Props { interface Props {
project: ProjectWithRepos; project: ProjectWithRepos;
expanded: boolean; expanded: boolean;
deleteConfirm: boolean; deleteConfirm: boolean;
workspaceLoading: string | null; workspaceLoading: string | null;
showCreateForm: string | null; showCreateForm: string | null;
onToggle: () => void; onToggle: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
onCancelDelete: () => void; onCancelDelete: () => void;
onCreateWorkspace: (repoId: string) => void; onCreateWorkspace: (repoId: string) => void;
onWorkspaceAction: (repoId: string, workspace: WorkspaceSummary, action: "sync" | "delete") => void; onWorkspaceAction: (
onCancelCreate: () => void; repoId: string,
onCreated: () => void; workspace: WorkspaceSummary,
action: "sync" | "delete",
) => void;
onCancelCreate: () => void;
onCreated: () => void;
} }
export const ProjectCard = ({ export const ProjectCard = ({
project, project,
expanded, expanded,
deleteConfirm, deleteConfirm,
workspaceLoading, workspaceLoading,
showCreateForm, showCreateForm,
onToggle, onToggle,
onEdit, onEdit,
onDelete, onDelete,
onConfirmDelete, onConfirmDelete,
onCancelDelete, onCancelDelete,
onCreateWorkspace, onCreateWorkspace,
onWorkspaceAction, onWorkspaceAction,
onCancelCreate, onCancelCreate,
onCreated, onCreated,
}: Props) => { }: Props) => {
return ( return (
<article className="card project-card"> <article className="card project-card">
<div className="project-info-row"> <div className="project-info-row">
<button className="project-toggle" onClick={onToggle} type="button" aria-expanded={expanded}> <button
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" /> className="project-toggle"
<div> onClick={onToggle}
<h3>{project.name}</h3> type="button"
{project.description && <p className="muted project-description">{project.description}</p>} aria-expanded={expanded}
</div> >
{project.repositories?.length > 0 && ( <Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
<span className="repo-count"> <div>
{project.repositories.length} repo{project.repositories.length > 1 ? "s" : ""} <h3>{project.name}</h3>
</span> {project.description && (
)} <p className="muted project-description">{project.description}</p>
</button> )}
<div className="project-actions"> </div>
<button className="ghost-button" onClick={onEdit} type="button"> {project.repositories?.length > 0 && (
<Icon name="edit" size="sm" /> Edit <span className="repo-count">
</button> {project.repositories.length} repo
{deleteConfirm ? ( {project.repositories.length > 1 ? "s" : ""}
<div className="delete-confirm"> </span>
<span>Are you sure?</span> )}
<button className="danger-button" onClick={onConfirmDelete} type="button"> </button>
<Icon name="delete" size="sm" /> Delete <div className="project-actions">
</button> <button className="ghost-button" onClick={onEdit} type="button">
<button className="ghost-button" onClick={onCancelDelete} type="button"> <Icon name="edit" size="sm" /> Edit
<Icon name="cancel" size="sm" /> Cancel </button>
</button> {deleteConfirm ? (
</div> <div className="delete-confirm">
) : ( <span>Are you sure?</span>
<button className="ghost-button danger-text" onClick={onDelete} type="button"> <button
<Icon name="delete" size="sm" /> Delete className="danger-button"
</button> onClick={onConfirmDelete}
)} type="button"
</div> >
</div> <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 && ( {expanded && (
<div className="project-detail"> <div className="project-detail">
{(project.repositories || []).length === 0 ? ( {(project.repositories || []).length === 0 ? (
<p className="muted">No repositories yet.</p> <p className="muted">No repositories yet.</p>
) : ( ) : (
<div className="repo-list"> <div className="repo-list">
{(project.repositories || []).map((repo) => ( {(project.repositories || []).map((repo) => (
<div key={repo.id} className="repo-block"> <div key={repo.id} className="repo-block">
<div className="repo-header"> <div className="repo-header">
<h4>{repo.name}</h4> <h4>{repo.name}</h4>
<button className="btn btn-sm btn-primary" onClick={() => onCreateWorkspace(repo.id)} type="button"> <button
<Icon name="add" size="sm" /> New Workspace className="btn btn-sm btn-primary"
</button> onClick={() => onCreateWorkspace(repo.id)}
</div> type="button"
{showCreateForm === repo.id && ( >
<WorkspaceCreateForm defaultProjectId={project.id} defaultRepoId={repo.id} onSubmit={onCreated} onCancel={onCancelCreate} /> <Icon name="add" size="sm" /> New Workspace
)} </button>
{repo.workspaces.length === 0 ? ( </div>
<p className="muted">No workspaces.</p> {showCreateForm === repo.id && (
) : ( <WorkspaceCreateForm
<div className="workspace-grid"> defaultProjectId={project.id}
{repo.workspaces.map((ws) => ( defaultRepoId={repo.id}
<div key={ws.id} className={`workspace-chip ${ws.status}`}> onSubmit={onCreated}
<a href={`/workspaces/${ws.id}`}>{ws.name}</a> onCancel={onCancelCreate}
<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> {repo.workspaces.length === 0 ? (
)} <p className="muted">No workspaces.</p>
<div className="ws-actions"> ) : (
<button type="button" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "sync")}> <div className="workspace-grid">
<Icon name="refresh" size="sm" /> {repo.workspaces.map((ws) => (
</button> <div
<button type="button" className="danger-text" disabled={workspaceLoading === ws.id} onClick={() => onWorkspaceAction(repo.id, ws, "delete")}> key={ws.id}
<Icon name="delete" size="sm" /> className={`workspace-chip ${ws.status}`}
</button> >
</div> <a href={`/workspaces/${ws.id}`}>{ws.name}</a>
</div> <span className="ws-branch">
))} <Icon name="branch" size="sm" /> {ws.branch}
</div> </span>
)} {ws.instance_count > 0 && (
</div> <span className="ws-instances">
))} {ws.instance_count} tool
</div> {ws.instance_count > 1 ? "s" : ""}
)} </span>
</div> )}
)} <div className="ws-actions">
</article> <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 { 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 { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
type CreateMode = "clone" | "blank"; type CreateMode = "clone" | "blank";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid"; type UrlValidationStatus =
| "idle"
| "validating"
| "valid"
| "needs-parsing"
| "invalid";
interface RepositoryCreateDialogProps { interface RepositoryCreateDialogProps {
projectId: string; projectId: string;
open: boolean; open: boolean;
title: string; title: string;
onClose: () => void; onClose: () => void;
onCreated: () => Promise<void> | void; onCreated: () => Promise<void> | void;
} }
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => { export const RepositoryCreateDialog = ({
const [createMode, setCreateMode] = useState<CreateMode>("clone"); projectId,
const [formName, setFormName] = useState(""); open,
const [owner, setOwner] = useState(""); title,
const [repoName, setRepoName] = useState(""); onClose,
const [advancedUrl, setAdvancedUrl] = useState(""); onCreated,
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false); }: RepositoryCreateDialogProps) => {
const [formError, setFormError] = useState<string | null>(null); const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [urlValidation, setUrlValidation] = useState<{ const [formName, setFormName] = useState("");
status: UrlValidationStatus; const [owner, setOwner] = useState("");
result: URLParseResult | null; const [repoName, setRepoName] = useState("");
}>({ status: "idle", result: null }); const [advancedUrl, setAdvancedUrl] = useState("");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]); const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
const [selectedSshKey, setSelectedSshKey] = useState<string>(""); const [formError, setFormError] = useState<string | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | 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(() => { useEffect(() => {
if (!open && debounceTimer.current) { if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current); clearTimeout(debounceTimer.current);
debounceTimer.current = null; debounceTimer.current = null;
} }
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const loadKeys = async () => { const loadKeys = async () => {
try { try {
const data = await listSSHKeys(); const data = await listSSHKeys();
setSshKeys(data); setSshKeys(data);
} catch { } catch {
// ignore // ignore
} }
}; };
void loadKeys(); void loadKeys();
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (!useAdvancedUrl) { if (!useAdvancedUrl) {
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
return; return;
} }
if (debounceTimer.current) { if (debounceTimer.current) {
clearTimeout(debounceTimer.current); clearTimeout(debounceTimer.current);
} }
if (!advancedUrl.trim()) { if (!advancedUrl.trim()) {
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
return; return;
} }
setUrlValidation({ status: "validating", result: null }); setUrlValidation({ status: "validating", result: null });
debounceTimer.current = setTimeout(async () => { debounceTimer.current = setTimeout(async () => {
try { try {
const result = await parseGitUrl(advancedUrl.trim()); const result = await parseGitUrl(advancedUrl.trim());
if (result.is_valid_clone_url) { if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result }); setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) { } else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result }); setUrlValidation({ status: "needs-parsing", result });
} else { } else {
setUrlValidation({ status: "invalid", result }); setUrlValidation({ status: "invalid", result });
} }
} catch { } catch {
setUrlValidation({ status: "invalid", result: null }); setUrlValidation({ status: "invalid", result: null });
} }
}, 300); }, 300);
return () => { return () => {
if (debounceTimer.current) { if (debounceTimer.current) {
clearTimeout(debounceTimer.current); clearTimeout(debounceTimer.current);
} }
}; };
}, [advancedUrl, open, useAdvancedUrl]); }, [advancedUrl, open, useAdvancedUrl]);
const resetForm = () => { const resetForm = () => {
setCreateMode("clone"); setCreateMode("clone");
setFormName(""); setFormName("");
setOwner(""); setOwner("");
setRepoName(""); setRepoName("");
setAdvancedUrl(""); setAdvancedUrl("");
setUseAdvancedUrl(true); setUseAdvancedUrl(true);
setFormError(null); setFormError(null);
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
setSelectedSshKey(""); setSelectedSshKey("");
}; };
const handleClose = () => { const handleClose = () => {
resetForm(); resetForm();
onClose(); onClose();
}; };
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setFormError(null); setFormError(null);
if (!formName.trim()) { if (!formName.trim()) {
setFormError("Repository name is required"); setFormError("Repository name is required");
return; return;
} }
try { try {
const input: GitRepositoryCreate = { const input: GitRepositoryCreate = {
name: formName.trim(), name: formName.trim(),
remote_url: undefined, remote_url: undefined,
}; };
if (createMode === "clone") { if (createMode === "clone") {
if (useAdvancedUrl) { if (useAdvancedUrl) {
if (!advancedUrl.trim()) { if (!advancedUrl.trim()) {
setFormError("Remote URL is required for advanced cloning"); setFormError("Remote URL is required for advanced cloning");
return; return;
} }
input.remote_url = advancedUrl.trim(); input.remote_url = advancedUrl.trim();
} else { } else {
if (!owner.trim() || !repoName.trim()) { if (!owner.trim() || !repoName.trim()) {
setFormError("Owner and repository name are required"); setFormError("Owner and repository name are required");
return; return;
} }
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
} }
if (selectedSshKey) { if (selectedSshKey) {
input.ssh_key_id = selectedSshKey; input.ssh_key_id = selectedSshKey;
} }
} }
await createRepository(projectId, input); await createRepository(projectId, input);
handleClose(); handleClose();
await onCreated(); await onCreated();
} catch (error: unknown) { } catch (error: unknown) {
const response = error as { response?: { data?: { detail?: string } } }; const response = error as { response?: { data?: { detail?: string } } };
const detail = response.response?.data?.detail; const detail = response.response?.data?.detail;
setFormError(typeof detail === "string" ? detail : "Failed to create repository"); setFormError(
} typeof detail === "string" ? detail : "Failed to create repository",
}; );
}
};
const handleUseSuggestedUrl = () => { const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) { if (urlValidation.result?.base_url) {
setAdvancedUrl(urlValidation.result.base_url); setAdvancedUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
setFormError(null); setFormError(null);
} }
}; };
const getUrlInputClass = () => { const getUrlInputClass = () => {
switch (urlValidation.status) { switch (urlValidation.status) {
case "valid": case "valid":
return "valid-url"; return "valid-url";
case "needs-parsing": case "needs-parsing":
return "needs-parsing-url"; return "needs-parsing-url";
case "invalid": case "invalid":
return "invalid-url"; return "invalid-url";
default: default:
return ""; return "";
} }
}; };
if (!open) return null; if (!open) return null;
return ( return (
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog"> <div className="dialog">
<h3>{title}</h3> <h3>{title}</h3>
<p className="muted"> <p className="muted">
Clone an existing repository from git.commumedia.org, or create a blank bare repo here. Clone an existing repository from git.commumedia.org, or create a
</p> blank bare repo here.
<form onSubmit={handleSubmit} className="stack"> </p>
<div className="form-field"> <form onSubmit={handleSubmit} className="stack">
<label> <div className="form-field">
<input <label>
type="radio" <input
name="repository-mode" type="radio"
checked={createMode === "clone"} name="repository-mode"
onChange={() => setCreateMode("clone")} checked={createMode === "clone"}
/> onChange={() => setCreateMode("clone")}
Clone existing repository />
</label> Clone existing repository
<label> </label>
<input <label>
type="radio" <input
name="repository-mode" type="radio"
checked={createMode === "blank"} name="repository-mode"
onChange={() => setCreateMode("blank")} checked={createMode === "blank"}
/> onChange={() => setCreateMode("blank")}
Create blank repository />
</label> Create blank repository
</div> </label>
<label className="form-field"> </div>
Repository name <label className="form-field">
<input Repository name
type="text" <input
value={formName} type="text"
onChange={(event) => setFormName(event.target.value)} value={formName}
placeholder="repository-name" onChange={(event) => setFormName(event.target.value)}
/> placeholder="repository-name"
</label> />
{createMode === "clone" && !useAdvancedUrl && ( </label>
<> {createMode === "clone" && !useAdvancedUrl && (
<label className="form-field"> <>
Owner <label className="form-field">
<input Owner
type="text" <input
value={owner} type="text"
onChange={(event) => setOwner(event.target.value)} value={owner}
placeholder="owner" onChange={(event) => setOwner(event.target.value)}
/> placeholder="owner"
</label> />
<label className="form-field"> </label>
Repository <label className="form-field">
<input Repository
type="text" <input
value={repoName} type="text"
onChange={(event) => setRepoName(event.target.value)} value={repoName}
placeholder="repo-name" onChange={(event) => setRepoName(event.target.value)}
/> placeholder="repo-name"
</label> />
<label className="form-field"> </label>
SSH Key <label className="form-field">
<select SSH Key
value={selectedSshKey} <select
onChange={(event) => setSelectedSshKey(event.target.value)} value={selectedSshKey}
> onChange={(event) => setSelectedSshKey(event.target.value)}
<option value="">Select SSH key (optional)...</option> >
{sshKeys.map((k) => ( <option value="">Select SSH key (optional)...</option>
<option key={k.id} value={k.id}> {sshKeys.map((k) => (
{k.name} <option key={k.id} value={k.id}>
</option> {k.name}
))} </option>
</select> ))}
</label> </select>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p> </label>
<button <p className="muted">
type="button" SSH target: git@git.commumedia.org:{owner || "owner"}/
className="secondary-button small" {repoName || "repo"}.git
onClick={() => setUseAdvancedUrl(true)} </p>
> <button
Use full URL instead type="button"
</button> className="secondary-button small"
</> onClick={() => setUseAdvancedUrl(true)}
)} >
{createMode === "clone" && useAdvancedUrl && ( Use full URL instead
<> </button>
<label className="form-field"> </>
Remote URL )}
<input {createMode === "clone" && useAdvancedUrl && (
type="text" <>
value={advancedUrl} <label className="form-field">
onChange={(event) => setAdvancedUrl(event.target.value)} Remote URL
placeholder="https://github.com/user/repo.git" <input
className={getUrlInputClass()} type="text"
/> value={advancedUrl}
{urlValidation.status === "validating" && ( onChange={(event) => setAdvancedUrl(event.target.value)}
<span className="validation-status validating">Validating...</span> placeholder="https://github.com/user/repo.git"
)} className={getUrlInputClass()}
{urlValidation.status === "valid" && ( />
<span className="validation-status valid"> {urlValidation.status === "validating" && (
<Icon name="success" size="sm" /> Valid git URL <span className="validation-status validating">
</span> Validating...
)} </span>
{urlValidation.status === "needs-parsing" && urlValidation.result && ( )}
<div className="url-suggestion"> {urlValidation.status === "valid" && (
<span className="validation-status warning"> <span className="validation-status valid">
<Icon name="warning" size="sm" /> This looks like a browser URL <Icon name="success" size="sm" /> Valid git URL
</span> </span>
<div className="suggestion-actions"> )}
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span> {urlValidation.status === "needs-parsing" &&
<button urlValidation.result && (
type="button" <div className="url-suggestion">
className="secondary-button small" <span className="validation-status warning">
onClick={handleUseSuggestedUrl} <Icon name="warning" size="sm" /> This looks like a
> browser URL
Use Suggested </span>
</button> <div className="suggestion-actions">
</div> <span className="suggested-url">
</div> Suggested: {urlValidation.result.base_url}
)} </span>
{urlValidation.status === "invalid" && ( <button
<span className="validation-status invalid"> type="button"
<Icon name="error" size="sm" /> Invalid URL className="secondary-button small"
</span> onClick={handleUseSuggestedUrl}
)} >
</label> Use Suggested
<label className="form-field"> </button>
SSH Key </div>
<select </div>
value={selectedSshKey} )}
onChange={(event) => setSelectedSshKey(event.target.value)} {urlValidation.status === "invalid" && (
> <span className="validation-status invalid">
<option value="">Select SSH key (optional)...</option> <Icon name="error" size="sm" /> Invalid URL
{sshKeys.map((k) => ( </span>
<option key={k.id} value={k.id}> )}
{k.name} </label>
</option> <label className="form-field">
))} SSH Key
</select> <select
</label> value={selectedSshKey}
<button onChange={(event) => setSelectedSshKey(event.target.value)}
type="button" >
className="secondary-button small" <option value="">Select SSH key (optional)...</option>
onClick={() => setUseAdvancedUrl(false)} {sshKeys.map((k) => (
> <option key={k.id} value={k.id}>
Use owner/repo instead {k.name}
</button> </option>
</> ))}
)} </select>
{formError && ( </label>
<div className="error-message"> <button
<p className="error-text">{formError}</p> type="button"
</div> className="secondary-button small"
)} onClick={() => setUseAdvancedUrl(false)}
<div className="dialog-actions"> >
<button className="secondary-button" onClick={handleClose} type="button"> Use owner/repo instead
<Icon name="cancel" size="sm" /> </button>
Cancel </>
</button> )}
<button className="primary-button" type="submit"> {formError && (
<Icon name="add" size="sm" /> <div className="error-message">
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"} <p className="error-text">{formError}</p>
</button> </div>
</div> )}
</form> <div className="dialog-actions">
</div> <button
</div> 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(); const mockRepos = vi.fn();
vi.mock("../api/dashboard", () => ({ vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args) getDashboardSummary: (...args: unknown[]) => mockDashboard(...args),
})); }));
vi.mock("../api/sessions", () => ({ vi.mock("../api/sessions", () => ({
getUserSessions: (...args: unknown[]) => mockSessions(...args), getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(), createInstance: vi.fn(),
startInstance: vi.fn(), startInstance: vi.fn(),
stopInstance: vi.fn(), stopInstance: vi.fn(),
deleteInstance: vi.fn(), deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn() recreateInstanceTunnel: vi.fn(),
})); }));
vi.mock("../api/projects", () => ({ vi.mock("../api/projects", () => ({
listProjects: (...args: unknown[]) => mockProjects(...args) listProjects: (...args: unknown[]) => mockProjects(...args),
})); }));
vi.mock("../api/git-repositories", () => ({ vi.mock("../api/git-repositories", () => ({
listRepositories: (...args: unknown[]) => mockRepos(...args) listRepositories: (...args: unknown[]) => mockRepos(...args),
})); }));
vi.mock("../api/tool-types", () => ({ vi.mock("../api/tool-types", () => ({
listToolTypes: vi.fn().mockResolvedValue([]) listToolTypes: vi.fn().mockResolvedValue([]),
})); }));
describe("HomePage", () => { describe("HomePage", () => {
beforeEach(() => { beforeEach(() => {
mockDashboard.mockReset(); mockDashboard.mockReset();
mockSessions.mockReset(); mockSessions.mockReset();
mockProjects.mockReset(); mockProjects.mockReset();
mockRepos.mockReset(); mockRepos.mockReset();
}); });
it("shows overview sections", async () => { it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] }); mockDashboard.mockResolvedValue({
mockSessions.mockResolvedValue([]); projects: 1,
mockProjects.mockResolvedValue([]); repositories: 2,
mockRepos.mockResolvedValue([]); sshKeys: 3,
recentActivity: [],
});
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
render( render(
<MemoryRouter> <MemoryRouter>
<HomePage /> <HomePage />
</MemoryRouter> </MemoryRouter>,
); );
expect(screen.getByText("Loading overview...")).toBeInTheDocument(); expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0); expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Workspaces")).toBeInTheDocument(); expect(screen.getByText("Workspaces")).toBeInTheDocument();
}); });
}); });
it("shows retry action when home load fails", async () => { it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed")); mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed")); mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed")); mockProjects.mockRejectedValueOnce(new Error("failed"));
render( render(
<MemoryRouter> <MemoryRouter>
<HomePage /> <HomePage />
</MemoryRouter> </MemoryRouter>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument(); 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); 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 { .notification-item-actions {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;