feat: add health status UI to search and detail pages

Add comprehensive health status visibility across tool browsing:

Search Page (/tool/tool-search):
- Add health filter dropdown (All/Healthy Only/Broken Only)
- Show "Broken" badges on tool cards when import or execution fails
- Include health filter in Clear Filters button logic
- Update Tool interface with health fields

Detail Page (/tool/[...slug]):
- Add prominent warning banner for broken tools
- Display specific failure types (Import Failed / Execution Failed)
- Show health check error messages in code blocks
- Add manual "Recheck health" button with loading state
- Display last health check timestamp

Phase 3 of health check system implementation complete.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-04 16:30:04 +10:00
parent e18ebb9282
commit 64a05482e4
2 changed files with 120 additions and 5 deletions

View file

@ -29,6 +29,10 @@ interface Tool {
npmReadme: string | null;
npmAuthor: { name: string; email?: string; url?: string } | string | null;
npmMaintainers: Array<{ name: string; email?: string }> | null;
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
healthCheckError?: string | null;
lastHealthCheck?: string | null;
tpmjsMetadata: {
example?: string;
parameters?: Array<{
@ -77,6 +81,7 @@ export default function ToolDetailPage({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [slug, setSlug] = useState<string>('');
const [recheckLoading, setRecheckLoading] = useState(false);
useEffect(() => {
// Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer')
@ -137,6 +142,28 @@ export default function ToolDetailPage({
const authorName = typeof tool.npmAuthor === 'string' ? tool.npmAuthor : tool.npmAuthor?.name;
const recheckHealth = async () => {
setRecheckLoading(true);
try {
const response = await fetch(`/api/tools/${slug}`, {
method: 'POST',
});
if (!response.ok) {
const data = await response.json();
alert(data.error || 'Recheck failed');
return;
}
// Refresh page to show updated health
window.location.reload();
} catch {
alert('Failed to recheck health');
} finally {
setRecheckLoading(false);
}
};
return (
<div className="min-h-screen bg-background">
<AppHeader />
@ -181,6 +208,56 @@ export default function ToolDetailPage({
</div>
</div>
{/* Health warning banner */}
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
<div className="flex items-start gap-3">
<span className="text-xl mt-0.5"></span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-800 dark:text-red-300 mb-1">
This tool is currently broken
</h3>
<div className="space-y-1 text-sm text-red-700 dark:text-red-400">
{tool.importHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Import Failed
</Badge>
<span className="text-xs">Cannot load from Railway service</span>
</div>
)}
{tool.executionHealth === 'BROKEN' && (
<div className="flex items-center gap-2">
<Badge variant="error" size="sm">
Execution Failed
</Badge>
<span className="text-xs">Runtime error with test parameters</span>
</div>
)}
</div>
{tool.healthCheckError && (
<pre className="mt-2 p-2 rounded bg-red-100 dark:bg-red-900/30 text-xs font-mono text-red-800 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
{tool.healthCheckError}
</pre>
)}
{tool.lastHealthCheck && (
<p className="text-xs text-red-600 dark:text-red-500 mt-2">
Last checked: {new Date(tool.lastHealthCheck).toLocaleString()}
</p>
)}
<button
type="button"
onClick={recheckHealth}
disabled={recheckLoading}
className="mt-3 text-sm font-medium text-red-700 dark:text-red-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
>
{recheckLoading ? 'Rechecking...' : 'Recheck health →'}
</button>
</div>
</div>
</div>
)}
{/* Main grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column - Main content */}

View file

@ -26,6 +26,8 @@ interface Tool {
exportName: string;
description: string;
qualityScore: string;
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
package: {
npmPackageName: string;
npmVersion: string;
@ -45,6 +47,7 @@ export default function ToolSearchPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [healthFilter, setHealthFilter] = useState('all');
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -69,6 +72,13 @@ export default function ToolSearchPage(): React.ReactElement {
params.set('category', categoryFilter);
}
if (healthFilter === 'healthy') {
params.set('importHealth', 'HEALTHY');
params.set('executionHealth', 'HEALTHY');
} else if (healthFilter === 'broken') {
params.set('broken', 'true');
}
const response = await fetch(`/api/tools?${params.toString()}`);
const data = await response.json();
@ -98,7 +108,7 @@ export default function ToolSearchPage(): React.ReactElement {
};
fetchTools();
}, [searchQuery, activeTab, categoryFilter]);
}, [searchQuery, activeTab, categoryFilter, healthFilter]);
return (
<div className="min-h-screen bg-background">
@ -142,13 +152,29 @@ export default function ToolSearchPage(): React.ReactElement {
/>
</div>
{/* Health filter */}
<div className="flex items-center gap-2 min-w-[200px]">
<span className="text-sm font-medium text-foreground-secondary">Health:</span>
<Select
value={healthFilter}
onChange={(e) => setHealthFilter(e.target.value)}
size="sm"
options={[
{ value: 'all', label: 'All Tools' },
{ value: 'healthy', label: 'Healthy Only' },
{ value: 'broken', label: 'Broken Only' },
]}
/>
</div>
{/* Clear filters button */}
{categoryFilter !== 'all' && (
{(categoryFilter !== 'all' || healthFilter !== 'all') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setCategoryFilter('all');
setHealthFilter('all');
}}
>
Clear Filters
@ -191,7 +217,9 @@ export default function ToolSearchPage(): React.ReactElement {
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<CardTitle>
{tool.exportName !== 'default' ? tool.exportName : tool.package.npmPackageName}
{tool.exportName !== 'default'
? tool.exportName
: tool.package.npmPackageName}
</CardTitle>
<div className="text-sm text-foreground-secondary mt-1">
{tool.package.npmPackageName}
@ -199,7 +227,9 @@ export default function ToolSearchPage(): React.ReactElement {
</div>
{tool.package.npmRepository && (
<a
href={tool.package.npmRepository.url.replace('git+', '').replace('.git', '')}
href={tool.package.npmRepository.url
.replace('git+', '')
.replace('.git', '')}
target="_blank"
rel="noopener noreferrer"
className="text-foreground-secondary hover:text-foreground transition-colors"
@ -217,12 +247,20 @@ export default function ToolSearchPage(): React.ReactElement {
<Badge variant="secondary" size="sm">
{tool.package.category}
</Badge>
<span className="text-xs text-foreground-tertiary">v{tool.package.npmVersion}</span>
<span className="text-xs text-foreground-tertiary">
v{tool.package.npmVersion}
</span>
{tool.package.isOfficial && (
<Badge variant="default" size="sm">
Official
</Badge>
)}
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
<Badge variant="error" size="sm">
<Icon icon="x" size="sm" className="mr-1" />
Broken
</Badge>
)}
</div>
{/* Quality score and downloads */}