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:
parent
787410a7e0
commit
f0ecdab824
2 changed files with 120 additions and 5 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue