feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
@@ -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>
);
}