buildmymcpserver/apps/web/app/(dashboard)/servers/[id]/page.tsx
Marco Sadjadi 31bfeed9dd
All checks were successful
Deploy to Production / deploy (push) Successful in 1m27s
feat(dashboard): delete button on server detail page
The DELETE /v1/servers/:id endpoint existed (tears down the runner
container + removes the row) but nothing in the UI called it, so
servers could only be removed via SSH+psql. Adds a danger-variant
button in the top-right of the detail header with a native confirm,
spinner state, and inline error surfacing. Redirects to /servers
on success.
2026-05-28 21:44:52 +02:00

668 lines
23 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { StatusPill } from '@/components/status-pill';
import { CodeBlock } from '@/components/code-block';
import { InstallSnippets } from '@/components/install-snippets';
import { StreamingLogs } from '@/components/streaming-logs';
import { Button } from '@/components/ui/button';
import { Input, Textarea, Label } from '@/components/input';
import { cn } from '@/lib/cn';
import type { ToolSpec } from '@bmm/types';
interface ServerDetail {
id: string;
name: string;
slug: string;
status: string;
publicUrl: string | null;
toolsSchema: ToolSpec[] | null;
currentVersion: number;
oauthEnabled: boolean;
createdAt: string;
updatedAt: string;
}
interface BuildSummary {
id: string;
version: number;
status: string;
startedAt: string | null;
finishedAt: string | null;
errorMessage: string | null;
}
type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate' | 'publish';
export default function ServerDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const [server, setServer] = useState<ServerDetail | null>(null);
const [builds, setBuilds] = useState<BuildSummary[]>([]);
const [tab, setTab] = useState<Tab>('overview');
const [iteratePrompt, setIteratePrompt] = useState('');
const [latestBuildId, setLatestBuildId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
async function refresh() {
const r = await apiFetch<{ server: ServerDetail; builds: BuildSummary[] }>(
`/v1/servers/${params.id}`,
);
setServer(r.server);
setBuilds(r.builds);
if (r.builds.length > 0 && !latestBuildId) {
setLatestBuildId(r.builds[0]!.id);
}
}
useEffect(() => {
refresh();
const t = setInterval(refresh, 4000);
return () => clearInterval(t);
}, [params.id]);
async function onIterate() {
if (!server) return;
const res = await apiFetch<{ build: { id: string } }>(
`/v1/servers/${server.id}/iterate`,
{ method: 'POST', body: JSON.stringify({ prompt: iteratePrompt, secrets: {} }) },
);
setLatestBuildId(res.build.id);
setIteratePrompt('');
setTab('logs');
}
async function onDelete() {
if (!server) return;
// Destructive — the running container is torn down and the row is gone.
// Browser confirm is enough at this scope (single operator, no users yet);
// upgrade to a typed-confirmation dialog once we have customer-tier data.
const sure = window.confirm(
`Delete "${server.name}" (${server.slug})? The running container is stopped and the server row is removed. This cannot be undone.`,
);
if (!sure) return;
setDeleting(true);
setDeleteError(null);
try {
await apiFetch(`/v1/servers/${server.id}`, { method: 'DELETE' });
router.push('/servers');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setDeleteError(detail?.detail ?? detail?.error ?? (e as Error).message);
setDeleting(false);
}
}
if (!server) {
return (
<div className="mx-auto max-w-7xl px-6 py-8 text-[12.5px] text-[--color-fg-muted]">Loading</div>
);
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'tools', label: 'Tools' },
{ id: 'logs', label: 'Logs' },
{ id: 'metrics', label: 'Metrics' },
{ id: 'secrets', label: 'Secrets' },
{ id: 'iterate', label: 'Iterate' },
{ id: 'publish', label: 'Publish' },
];
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="flex items-baseline justify-between gap-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-[22px] font-semibold tracking-tight">{server.name}</h1>
<StatusPill status={server.status as never} />
</div>
<div className="mt-1 flex items-center gap-3 text-[12.5px] text-[--color-fg-muted]">
<span className="mono">{server.slug}</span>
<span>·</span>
<span>v{server.currentVersion}</span>
{server.publicUrl && (
<>
<span>·</span>
<a
href={`${server.publicUrl}/mcp`}
target="_blank"
rel="noreferrer"
className="mono hover:text-[--color-fg]"
>
{server.publicUrl}/mcp
</a>
</>
)}
</div>
{deleteError && (
<div className="mt-2 text-[12px] text-[--color-danger]">
Delete failed: {deleteError}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="danger" size="sm" onClick={onDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete server'}
</Button>
</div>
</div>
<div className="mt-6 flex gap-1 border-b border-[--color-border]">
{tabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={cn(
'relative px-3 py-2 text-[12.5px] transition-colors duration-200 ease-out',
tab === t.id
? 'text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{t.label}
{tab === t.id && (
<span className="absolute inset-x-0 -bottom-px h-px bg-[--color-fg]" />
)}
</button>
))}
</div>
<div className="mt-6">
{tab === 'overview' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Endpoint
</div>
<div className="mt-3">
{server.publicUrl ? (
<CodeBlock code={`${server.publicUrl}/mcp`} />
) : (
<div className="text-[12.5px] text-[--color-fg-muted]">Not deployed yet.</div>
)}
</div>
<div className="mt-4 flex items-center gap-2 text-[12px] text-[--color-fg-muted]">
<span className="size-1.5 rounded-full bg-emerald-400" />
OAuth 2.1 enforced · Streamable HTTP
</div>
</div>
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Builds
</div>
<div className="mt-3 space-y-2">
{builds.slice(0, 5).map((b) => (
<div key={b.id} className="flex items-center justify-between text-[12.5px]">
<span className="mono text-[--color-fg-muted]">v{b.version}</span>
<StatusPill status={b.status as never} />
<span className="mono text-[11px] text-[--color-fg-subtle]">
{b.startedAt ? new Date(b.startedAt).toLocaleString() : '—'}
</span>
</div>
))}
</div>
</div>
{server.publicUrl && (
<div className="panel p-4 md:col-span-2">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Install
</div>
<div className="mt-3">
<InstallSnippets
input={{ name: server.name, slug: server.slug, publicUrl: server.publicUrl }}
/>
</div>
</div>
)}
</div>
)}
{tab === 'tools' && (
<div className="panel p-4">
{!server.toolsSchema || server.toolsSchema.length === 0 ? (
<div className="text-[12.5px] text-[--color-fg-muted]">No tools yet.</div>
) : (
<div className="space-y-3">
{server.toolsSchema.map((tool) => (
<div key={tool.name} className="rounded-md border border-[--color-border] p-3">
<div className="flex items-baseline gap-2">
<span className="mono text-[13px] font-semibold">{tool.name}</span>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p>
{Object.keys(tool.inputSchema ?? {}).length > 0 && (
<div className="mt-2">
<CodeBlock
label="input schema"
code={JSON.stringify(tool.inputSchema, null, 2)}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{tab === 'logs' && (
latestBuildId ? (
<StreamingLogs buildId={latestBuildId} />
) : (
<div className="panel p-4 text-[12.5px] text-[--color-fg-muted]">
No builds to stream yet.
</div>
)
)}
{tab === 'metrics' && (
<div className="panel p-4">
<div className="grid gap-4 md:grid-cols-3">
<Metric label="Calls (24h)" value="0" />
<Metric label="P95 latency" value="—" />
<Metric label="Error rate" value="0%" />
</div>
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
Live metrics begin streaming after the first tool call.
</p>
</div>
)}
{tab === 'secrets' && (
<div className="panel p-4 text-[12.5px] text-[--color-fg-muted]">
Secrets are AES-256-GCM encrypted at rest. They are injected as environment variables
into your container and are never echoed back. Use the wizard or CLI to rotate them.
</div>
)}
{tab === 'iterate' && (
<div className="panel p-4">
<Label hint="A new build will be queued; rolling deploy keeps current version live until ready.">
Extend the server
</Label>
<Textarea
rows={4}
className="mt-2"
value={iteratePrompt}
onChange={(e) => setIteratePrompt(e.target.value)}
placeholder="Add a tool: create_page that POSTs to /v1/pages with title and body."
/>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
size="md"
disabled={iteratePrompt.length < 10}
onClick={onIterate}
>
Queue rebuild
</Button>
</div>
</div>
)}
{tab === 'publish' && <PublishPanel serverId={server.id} serverStatus={server.status} />}
</div>
</div>
);
}
const CATEGORIES = [
'productivity',
'developer-tools',
'data',
'communication',
'finance',
'crm',
'analytics',
'devops',
'demo',
'other',
];
interface SecretHint {
key: string;
description: string;
howToGetUrl: string;
}
interface ExistingTemplate {
id: string;
slug: string;
title: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
}
function PublishPanel({ serverId, serverStatus }: { serverId: string; serverStatus: string }) {
const [title, setTitle] = useState('');
const [category, setCategory] = useState('other');
const [shortDescription, setShortDescription] = useState('');
const [longDescription, setLongDescription] = useState('');
const [secretHints, setSecretHints] = useState<SecretHint[]>([]);
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [publishedSlug, setPublishedSlug] = useState<string | null>(null);
const [existing, setExisting] = useState<ExistingTemplate | null | undefined>(undefined);
async function reloadExisting() {
try {
const r = await apiFetch<{ template: ExistingTemplate | null }>(
`/v1/servers/${serverId}/template`,
);
setExisting(r.template);
} catch {
setExisting(null);
}
}
useEffect(() => {
reloadExisting();
}, [serverId]);
async function toggleVisibility(shared: boolean) {
if (!existing) return;
await apiFetch(`/v1/templates/${existing.slug}/visibility`, {
method: 'PATCH',
body: JSON.stringify({ shared }),
});
reloadExisting();
}
if (serverStatus !== 'live') {
return (
<div className="panel p-4">
<p className="text-[13px] text-[--color-fg-muted]">
Server must be live before publishing as a template. Current status:{' '}
<span className="mono">{serverStatus}</span>.
</p>
</div>
);
}
// Already published — show shared status + view + unshare/reshare.
if (existing) {
const isTakedown = existing.status === 'takedown';
const isShared = existing.status === 'public';
return (
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[14px] font-semibold tracking-tight">Marketplace</h3>
<span
className={`mono rounded-full border px-2 py-0.5 text-[11px] ${
isTakedown
? 'border-red-400/40 bg-red-400/10 text-red-300'
: isShared
? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300'
: 'border-amber-400/40 bg-amber-400/10 text-amber-300'
}`}
>
{existing.status}
</span>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Published as <span className="mono">{existing.slug}</span> ·{' '}
{existing.forkCount} fork{existing.forkCount === 1 ? '' : 's'}
{existing.verified && ' · verified'}
</p>
{isTakedown && (
<p className="mt-2 text-[12px] text-[--color-danger]">
An admin removed this template from the marketplace. You can&apos;t re-share it.
</p>
)}
<div className="mt-3 flex gap-2">
<a
href={`/templates/${existing.slug}`}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-3 text-[12.5px] text-[--color-fg] transition-colors hover:bg-[--color-bg-subtle]"
>
View in marketplace
</a>
{!isTakedown && isShared && (
<Button variant="danger" size="md" onClick={() => toggleVisibility(false)}>
Unshare
</Button>
)}
{!isTakedown && !isShared && (
<Button variant="primary" size="md" onClick={() => toggleVisibility(true)}>
Re-share
</Button>
)}
</div>
</div>
);
}
if (existing === undefined) {
return <div className="panel p-4 text-[12.5px] text-[--color-fg-muted]">Loading</div>;
}
function addHint() {
setSecretHints((h) => [...h, { key: '', description: '', howToGetUrl: '' }]);
}
function updateHint(i: number, patch: Partial<SecretHint>) {
setSecretHints((h) => {
const next = [...h];
const cur = next[i];
if (!cur) return h;
next[i] = { ...cur, ...patch };
return next;
});
}
function removeHint(i: number) {
setSecretHints((h) => h.filter((_, j) => j !== i));
}
async function submit() {
setError(null);
if (title.length < 3) {
setError('Title needs at least 3 characters.');
return;
}
if (shortDescription.length < 10) {
setError('Short description needs at least 10 characters.');
return;
}
// Validate secret hints
for (const h of secretHints) {
if (!h.key) {
setError('Empty secret key — remove the row or fill it in.');
return;
}
if (!/^[A-Z][A-Z0-9_]*$/.test(h.key)) {
setError(`Secret key "${h.key}" must be UPPER_SNAKE_CASE.`);
return;
}
if (h.description.length < 1) {
setError(`Add a description for ${h.key}.`);
return;
}
}
setState('submitting');
try {
const res = await apiFetch<{ template: { slug: string } }>('/v1/templates', {
method: 'POST',
body: JSON.stringify({
serverId,
title,
category,
shortDescription,
longDescription: longDescription || undefined,
secretHints: secretHints.map((h) => ({
key: h.key,
description: h.description,
...(h.howToGetUrl ? { howToGetUrl: h.howToGetUrl } : {}),
})),
}),
});
setPublishedSlug(res.template.slug);
setState('success');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
setState('error');
}
}
if (state === 'success' && publishedSlug) {
return (
<div className="panel p-4">
<div className="text-[14px] font-semibold tracking-tight">Published 🎉</div>
<p className="mt-2 text-[12.5px] text-[--color-fg-muted]">
Your template is live on the marketplace.
</p>
<div className="mt-3 flex gap-2">
<a
href={`/templates/${publishedSlug}`}
className="inline-flex h-8 items-center rounded-md bg-[--color-accent] px-3 text-[12.5px] font-medium text-white transition-colors hover:bg-[#5557e8]"
>
Open in marketplace
</a>
</div>
</div>
);
}
return (
<div className="panel p-4 space-y-4">
<div>
<h3 className="text-[14px] font-semibold tracking-tight">Publish as template</h3>
<p className="mt-1 text-[12px] text-[--color-fg-muted]">
Share this server&apos;s spec on the public marketplace. Others fork in one click they
run their own container with their own credentials. Your secrets are never shared.
</p>
<p className="mt-2 rounded-md border border-amber-400/30 bg-amber-400/5 p-2 text-[11.5px] text-amber-200/90">
Publishing re-scans your generated code for banned patterns (eval, child_process,
dynamic import, setTimeout-eval) AND for hardcoded credentials (API keys, tokens,
private keys). Templates with detected leaks are rejected before they reach the
marketplace. Network egress allowlisting is on the roadmap for now any template
can reach any URL its code names.
</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_220px]">
<div className="space-y-1.5">
<Label htmlFor="t-title">Title</Label>
<Input
id="t-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Notion Reader"
maxLength={128}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-cat">Category</Label>
<select
id="t-cat"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[13px] focus:border-[--color-accent] focus:outline-none"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-short" hint={`${shortDescription.length}/280`}>
Short description
</Label>
<Input
id="t-short"
value={shortDescription}
onChange={(e) => setShortDescription(e.target.value)}
placeholder="Search and read pages from a Notion workspace."
maxLength={280}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-long">Long description (optional)</Label>
<Textarea
id="t-long"
rows={4}
value={longDescription}
onChange={(e) => setLongDescription(e.target.value)}
placeholder="What does it do, what doesn't it do, what should the user know before forking?"
/>
</div>
<div className="space-y-2">
<Label hint="Help future users understand which credentials they need and where to get them">
Credential hints
</Label>
{secretHints.length === 0 && (
<p className="text-[11.5px] text-[--color-fg-muted]">
None. This server doesn&apos;t need any credentials.
</p>
)}
{secretHints.map((h, i) => (
<div key={i} className="panel-subtle p-2.5 space-y-2">
<div className="grid grid-cols-[200px_1fr_auto] gap-2">
<Input
placeholder="NOTION_API_KEY"
value={h.key}
onChange={(e) => updateHint(i, { key: e.target.value.toUpperCase() })}
className="mono"
/>
<Input
placeholder="What is this credential? One sentence."
value={h.description}
onChange={(e) => updateHint(i, { description: e.target.value })}
/>
<Button variant="ghost" size="md" onClick={() => removeHint(i)}>
Remove
</Button>
</div>
<Input
placeholder="https://notion.so/my-integrations (optional 'How to get one' link)"
value={h.howToGetUrl}
onChange={(e) => updateHint(i, { howToGetUrl: e.target.value })}
/>
</div>
))}
<Button variant="ghost" size="sm" onClick={addHint}>
+ Add credential hint
</Button>
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex items-center justify-between border-t border-[--color-border] pt-3">
<p className="text-[11px] text-[--color-fg-subtle]">
By publishing, you agree the generated code may be inspected by others.
</p>
<Button
variant="primary"
size="md"
onClick={submit}
disabled={state === 'submitting'}
>
{state === 'submitting' ? 'Publishing…' : 'Publish'}
</Button>
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className="mt-1 text-[22px] font-semibold tracking-tight">{value}</div>
</div>
);
}