diff --git a/web/src/pages/Projects.jsx b/web/src/pages/Projects.jsx
index b8d29cf..e86fb2b 100644
--- a/web/src/pages/Projects.jsx
+++ b/web/src/pages/Projects.jsx
@@ -6,9 +6,16 @@ import {
FolderOpenIcon,
ArchiveBoxIcon,
TrashIcon,
+ WrenchScrewdriverIcon,
} from '@heroicons/react/24/outline';
import Header from '../components/Header';
-import { createProject, getProjects, updateProject, deleteProject } from '../api/client';
+import {
+ createProject,
+ getProjects,
+ updateProject,
+ deleteProject,
+ repairProjectLinkHealth,
+} from '../api/client';
import { useAuthStore } from '../stores/authStore';
import { useToastStore } from '../stores/toastStore';
import { normalizeProjectSlug } from '../utils/projectSlug';
@@ -230,6 +237,7 @@ export default function Projects() {
const [isSavingProject, setIsSavingProject] = useState(false);
const [deletingProjectId, setDeletingProjectId] = useState(null);
const [togglingArchiveProjectId, setTogglingArchiveProjectId] = useState(null);
+ const [isRepairingLinks, setIsRepairingLinks] = useState(false);
const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
@@ -342,6 +350,22 @@ export default function Projects() {
}
};
+ const handleRepairMissingLinks = async () => {
+ setIsRepairingLinks(true);
+ try {
+ const result = await repairProjectLinkHealth({ limit: 200 });
+ showToast(
+ `Link repair complete · repaired ${result?.repaired ?? 0}, failed ${result?.failed ?? 0}`,
+ result?.failed ? 'warning' : 'success',
+ );
+ await loadProjects();
+ } catch (err) {
+ showToast(err?.response?.data?.error?.message || err.message || 'Failed to repair project links', 'error');
+ } finally {
+ setIsRepairingLinks(false);
+ }
+ };
+
return (
@@ -358,16 +382,27 @@ export default function Projects() {
{isAdmin() && (
-
+
+
+
+
)}
diff --git a/web/src/pages/Projects.test.jsx b/web/src/pages/Projects.test.jsx
index 6d3a6b8..26e5636 100644
--- a/web/src/pages/Projects.test.jsx
+++ b/web/src/pages/Projects.test.jsx
@@ -8,21 +8,25 @@ vi.mock('../api/client', () => ({
createProject: vi.fn(),
updateProject: vi.fn(),
deleteProject: vi.fn(),
+ repairProjectLinkHealth: vi.fn(),
}));
vi.mock('../stores/authStore', () => ({
useAuthStore: () => ({ isAdmin: () => true }),
}));
+const showToast = vi.fn();
+
vi.mock('../stores/toastStore', () => ({
- useToastStore: () => ({ showToast: vi.fn() }),
+ useToastStore: () => ({ showToast }),
}));
-const { getProjects, updateProject } = await import('../api/client');
+const { getProjects, updateProject, repairProjectLinkHealth } = await import('../api/client');
describe('Projects', () => {
beforeEach(() => {
vi.clearAllMocks();
+ showToast.mockReset();
});
it('renders registry summary and project cards', async () => {
@@ -159,4 +163,37 @@ describe('Projects', () => {
expect(updateProject).toHaveBeenCalledWith('p1', expect.objectContaining({ status: 'archived' }));
});
});
+
+ it('repairs missing links from the projects registry', async () => {
+ getProjects.mockResolvedValue([
+ {
+ id: 'p1',
+ slug: 'project-alpha',
+ name: 'Project Alpha',
+ description: 'Sample project description',
+ root_path: '/projects/project-alpha',
+ status: 'active',
+ assigned_agents: 2,
+ updated_at: '2026-03-12T19:00:00.000Z',
+ },
+ ]);
+ repairProjectLinkHealth.mockResolvedValue({ repaired: 2, failed: 0 });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Fix missing links' })).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Fix missing links' }));
+
+ await waitFor(() => {
+ expect(repairProjectLinkHealth).toHaveBeenCalledWith({ limit: 200 });
+ expect(showToast).toHaveBeenCalledWith('Link repair complete · repaired 2, failed 0', 'success');
+ });
+ });
});