feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SshConnection, TestResponse } from '../../lib/ssh-types';
|
||||
import { SshConnectionForm } from './SshConnectionForm';
|
||||
import { SshHostKeyDialog } from './SshHostKeyDialog';
|
||||
import { SshPublicKeyDialog } from './SshPublicKeyDialog';
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: SshConnection[];
|
||||
}
|
||||
|
||||
interface CreateResponse {
|
||||
connection: SshConnection;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
|
||||
const res = await fetch('/api/ssh/connections', { credentials: 'include' });
|
||||
if (res.status === 404) {
|
||||
return { list: [], sshDisabled: true };
|
||||
}
|
||||
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
||||
const data: ConnectionsResponse = await res.json();
|
||||
return { list: data.connections ?? [], sshDisabled: false };
|
||||
}
|
||||
|
||||
async function apiCreate(body: Record<string, unknown>): Promise<CreateResponse> {
|
||||
const res = await fetch('/api/ssh/connections', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
return (await res.json()) as CreateResponse;
|
||||
}
|
||||
|
||||
async function apiGetPublicKey(id: string): Promise<string | null> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
const json = (await res.json()) as { publicKey?: string | null };
|
||||
return json.publicKey ?? null;
|
||||
}
|
||||
|
||||
async function apiPatch(id: string, body: Record<string, unknown>): Promise<SshConnection> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
const json = (await res.json()) as { connection: SshConnection };
|
||||
return json.connection;
|
||||
}
|
||||
|
||||
async function apiDelete(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
}
|
||||
|
||||
async function apiTest(id: string): Promise<TestResponse> {
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/test`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
return (await res.json()) as TestResponse;
|
||||
}
|
||||
|
||||
async function apiVerifyHostKey(
|
||||
id: string,
|
||||
body: { fingerprint: string; token: string; reason?: string },
|
||||
): Promise<void> {
|
||||
const endpoint = body.reason ? 'replace-host-key' : 'verify-host-key';
|
||||
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(parseApiError(txt, res.status));
|
||||
}
|
||||
}
|
||||
|
||||
function parseApiError(rawText: string, status: number): string {
|
||||
try {
|
||||
const parsed = JSON.parse(rawText);
|
||||
if (parsed?.error) {
|
||||
return parsed.detail ? `${parsed.error}: ${typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)}` : parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
interface SshConnectionsPanelProps {
|
||||
/** Render personal+globals (user mode) or only globals via admin endpoints. */
|
||||
scope?: 'user';
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}) {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['ssh', 'connections'],
|
||||
queryFn: fetchConnections,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null);
|
||||
const [pubKeyDialog, setPubKeyDialog] = useState<{
|
||||
publicKey: string;
|
||||
label?: string;
|
||||
freshlyGenerated: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: apiCreate,
|
||||
onSuccess: (resp) => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
setCreating(false);
|
||||
showToast?.('SSH 接続を作成しました', 'success');
|
||||
// If the server returned a public key (always for keypairSource=generate;
|
||||
// also surfaced for provided keys), open the dialog so the user can
|
||||
// copy it into authorized_keys.
|
||||
if (resp.publicKey) {
|
||||
setPubKeyDialog({
|
||||
publicKey: resp.publicKey,
|
||||
label: resp.connection.label,
|
||||
freshlyGenerated: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const showPubKeyMutation = useMutation({
|
||||
mutationFn: async ({ id, label }: { id: string; label: string }) => {
|
||||
const pk = await apiGetPublicKey(id);
|
||||
return { publicKey: pk, label };
|
||||
},
|
||||
onSuccess: ({ publicKey, label }) => {
|
||||
if (publicKey) {
|
||||
setPubKeyDialog({ publicKey, label, freshlyGenerated: false });
|
||||
} else {
|
||||
showToast?.('公開鍵の取得に失敗しました', 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : '公開鍵取得失敗', 'error');
|
||||
},
|
||||
});
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) => apiPatch(id, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
setEditingId(null);
|
||||
showToast?.('SSH 接続を更新しました', 'success');
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: apiDelete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
showToast?.('SSH 接続を削除しました', 'success');
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : '削除失敗', 'error');
|
||||
},
|
||||
});
|
||||
const testMutation = useMutation({
|
||||
mutationFn: apiTest,
|
||||
onSuccess: (response, id) => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
// Surface result. pass = already verified; first_observe/mismatch = needs confirm.
|
||||
if (response.verdict === 'pass') {
|
||||
showToast?.(`ホストキーは一致しています (${response.fingerprint.slice(0, 20)}…)`, 'success');
|
||||
} else if (response.verdict === 'first_observe' || response.verdict === 'mismatch') {
|
||||
setTestResult({ id, test: response, replaceMode: response.verdict === 'mismatch' });
|
||||
} else if (response.verdict === 'alg_not_allowed') {
|
||||
showToast?.('ホストキーのアルゴリズムが許可リストにありません', 'error');
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast?.(e instanceof Error ? e.message : 'テスト失敗', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
async function handleVerifyHostKey(connId: string, args: { fingerprint: string; token: string; reason?: string }) {
|
||||
await apiVerifyHostKey(connId, args);
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
|
||||
showToast?.('ホストキーを検証しました', 'success');
|
||||
}
|
||||
|
||||
if (data?.sshDisabled) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-2">SSH 接続</h2>
|
||||
<div className="text-xs text-slate-600 bg-surface border border-hairline rounded-md p-3 leading-relaxed">
|
||||
SSH サブシステムは無効です。<code className="font-mono">config.yaml</code> の{' '}
|
||||
<code className="font-mono">ssh.enabled: true</code> と環境変数{' '}
|
||||
<code className="font-mono">MCP_ENCRYPTION_KEY</code> 設定後にサーバーを再起動してください。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const owned = (data?.list ?? []).filter(c => c.ownerId !== null);
|
||||
const globals = (data?.list ?? []).filter(c => c.ownerId === null);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">SSH 接続</h2>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
エージェントの SshExec / SshUpload / SshDownload ツールから利用される SSH 接続を管理します。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreating(true); setEditingId(null); }}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
|
||||
disabled={creating}
|
||||
>
|
||||
+ 新規作成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-xs text-slate-400">Loading…</div>}
|
||||
{error && <div className="text-xs text-red-500">読み込みに失敗しました: {String(error)}</div>}
|
||||
|
||||
{creating && (
|
||||
<section className="mb-5 border border-accent/40 rounded-md bg-white p-4">
|
||||
<h3 className="text-xs font-semibold text-slate-700 mb-2">新規 SSH 接続</h3>
|
||||
<SshConnectionForm
|
||||
existing={null}
|
||||
adminContext={false}
|
||||
onSubmit={async (body) => { await createMutation.mutateAsync(body); }}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<SectionHeader title="自分の接続" count={owned.length} />
|
||||
{owned.length === 0 && !creating && (
|
||||
<div className="text-xs text-slate-400 px-3 py-4">
|
||||
まだ接続がありません。「+ 新規作成」から登録してください。
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-hairline mb-6">
|
||||
{owned.map(c => (
|
||||
<ConnectionRow
|
||||
key={c.id}
|
||||
connection={c}
|
||||
isOwner
|
||||
editing={editingId === c.id}
|
||||
onEdit={() => { setEditingId(c.id); setCreating(false); }}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
onPatch={async (body) => { await patchMutation.mutateAsync({ id: c.id, body }); }}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`接続 "${c.label}" を削除しますか?`)) {
|
||||
deleteMutation.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
onTest={() => testMutation.mutate(c.id)}
|
||||
testing={testMutation.isPending && testMutation.variables === c.id}
|
||||
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
||||
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{globals.length > 0 && (
|
||||
<>
|
||||
<SectionHeader title="グローバル接続 (info-only)" count={globals.length} />
|
||||
<p className="text-2xs text-slate-500 px-3 mb-2">
|
||||
管理者が登録した共有接続です。ピース側で grant が設定されている場合のみツールから利用できます。
|
||||
</p>
|
||||
<ul className="divide-y divide-hairline">
|
||||
{globals.map(c => (
|
||||
<ConnectionRow
|
||||
key={c.id}
|
||||
connection={c}
|
||||
isOwner={false}
|
||||
editing={false}
|
||||
onEdit={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onPatch={async () => {}}
|
||||
onDelete={() => {}}
|
||||
onTest={() => testMutation.mutate(c.id)}
|
||||
testing={testMutation.isPending && testMutation.variables === c.id}
|
||||
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
|
||||
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<SshHostKeyDialog
|
||||
test={testResult.test}
|
||||
replaceMode={testResult.replaceMode}
|
||||
onClose={() => setTestResult(null)}
|
||||
onVerify={(args) => handleVerifyHostKey(testResult.id, args)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pubKeyDialog && (
|
||||
<SshPublicKeyDialog
|
||||
publicKey={pubKeyDialog.publicKey}
|
||||
label={pubKeyDialog.label}
|
||||
freshlyGenerated={pubKeyDialog.freshlyGenerated}
|
||||
onClose={() => setPubKeyDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-exported for use by other panels that render ConnectionRow. */
|
||||
export type ShowPublicKeyHandler = (args: { id: string; label: string }) => void;
|
||||
|
||||
function SectionHeader({ title, count }: { title: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<span>{title}</span>
|
||||
<span className="text-slate-400 font-mono">({count})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionRowProps {
|
||||
connection: SshConnection;
|
||||
isOwner: boolean;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onPatch: (body: Record<string, unknown>) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
onShowPublicKey: () => void;
|
||||
showingPublicKey: boolean;
|
||||
}
|
||||
|
||||
function ConnectionRow(props: ConnectionRowProps) {
|
||||
const {
|
||||
connection: c, isOwner, editing,
|
||||
onEdit, onCancelEdit, onPatch, onDelete, onTest, testing,
|
||||
onShowPublicKey, showingPublicKey,
|
||||
} = props;
|
||||
const verified = c.hostKeyVerifiedAt !== null;
|
||||
const pending = c.hostKeyPending;
|
||||
const disabled = c.disabledByAdmin || !c.enabled;
|
||||
|
||||
return (
|
||||
<li className="py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-slate-900 truncate">{c.label}</span>
|
||||
<ScopeBadge owner={c.ownerId} />
|
||||
<HostKeyBadge verified={verified} pending={pending} />
|
||||
{disabled && <Badge color="red">{c.disabledByAdmin ? 'admin-disabled' : 'disabled'}</Badge>}
|
||||
{c.allowRemoteUnrestricted && <Badge color="amber">remote: unrestricted</Badge>}
|
||||
{c.allowPrivateAddresses && <Badge color="amber">private addrs</Badge>}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-600 font-mono mt-1 truncate">
|
||||
{c.username}@{c.host}:{c.port}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 mt-0.5">
|
||||
id: <CopyableUuid value={c.id} />
|
||||
{' · '}path-prefix: <span className="font-mono">{c.remotePathPrefix}</span>
|
||||
{c.keyFingerprint && (
|
||||
<>
|
||||
{' · '}key fp: <span className="font-mono">{c.keyFingerprint.slice(0, 24)}…</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{c.disabledByAdminReason && (
|
||||
<div className="text-2xs text-red-700 mt-0.5">理由: {c.disabledByAdminReason}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{testing ? 'テスト中…' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowPublicKey}
|
||||
disabled={showingPublicKey}
|
||||
title="authorized_keys に貼る公開鍵を表示"
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{showingPublicKey ? '取得中…' : '公開鍵'}
|
||||
</button>
|
||||
{isOwner && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface"
|
||||
>
|
||||
編集
|
||||
</button>
|
||||
)}
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="px-2 h-7 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="mt-3 ml-1 pl-3 border-l-2 border-accent/30">
|
||||
<SshConnectionForm
|
||||
existing={c}
|
||||
adminContext={false}
|
||||
onSubmit={async (body) => { await onPatch(body); }}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a connection UUID with a click-to-copy action. The full UUID is
|
||||
* shown inline so agents that ask "give me the connection_id" can be
|
||||
* answered by selecting/copying without opening any sub-view.
|
||||
*/
|
||||
function CopyableUuid({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API can fail in non-secure contexts; user can still select manually.
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={`クリックで UUID をコピー: ${value}`}
|
||||
className="font-mono hover:underline cursor-pointer text-slate-600 hover:text-accent-deep"
|
||||
>
|
||||
{copied ? '✓ コピーしました' : value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeBadge({ owner }: { owner: string | null }) {
|
||||
return owner === null ? (
|
||||
<Badge color="slate">global</Badge>
|
||||
) : (
|
||||
<Badge color="blue">personal</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function HostKeyBadge({ verified, pending }: { verified: boolean; pending: boolean }) {
|
||||
if (pending) return <Badge color="amber">host-key pending</Badge>;
|
||||
if (verified) return <Badge color="emerald">host-key verified</Badge>;
|
||||
return <Badge color="slate">host-key untested</Badge>;
|
||||
}
|
||||
|
||||
function Badge({ color, children }: { color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
|
||||
const cls: Record<typeof color, string> = {
|
||||
slate: 'bg-slate-100 text-slate-600',
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
emerald: 'bg-emerald-50 text-emerald-700',
|
||||
amber: 'bg-amber-50 text-amber-700',
|
||||
red: 'bg-red-50 text-red-700',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${cls[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user