diff --git a/atlas.json b/atlas.json index 9ac9408..d6181aa 100644 --- a/atlas.json +++ b/atlas.json @@ -10,36 +10,69 @@ "id": "windows-pc", "label": "Windows PC", "type": "device", - "description": "メイン開発機。Claude Code / VS Code / Git", + "description": "メイン開発機。Claude Code / VS Code / Git / Docker Desktop", "status": "active" }, { - "id": "iphone", - "label": "iPhone", + "id": "ubuntu-pc", + "label": "Ubuntu PC", "type": "device", - "description": "PWA テスト・モバイル確認", + "description": "Linux 開発・検証機。Tailscale 経由でアクセス", + "status": "active" + }, + { + "id": "android-phone", + "label": "Android (検証用)", + "type": "device", + "description": "PWA テスト・モバイル動作確認用スマートフォン", "status": "active" }, { "id": "synology", "label": "Synology NAS", "type": "device", - "description": "ローカルストレージ・Gitea ホスト", + "description": "ローカルバックアップ・Gitea ホスト。LAN 内常時稼働", "status": "active" }, + { + "id": "gitea", + "label": "Gitea", + "type": "service", + "description": "ローカル Git バックアップ。Synology 上で Docker 動作", + "url": "http://100.76.7.3:3000", + "status": "active", + "parent": "synology" + }, { "id": "vps-hetzner", "label": "VPS (Hetzner)", "type": "server", - "description": "Posimai API 本番サーバー。Docker コンテナで運用", + "description": "Posimai API 本番サーバー。Ubuntu 22.04、Docker で運用", "url": "https://api.soar-enrich.com", "status": "active" }, + { + "id": "docker", + "label": "Docker Engine", + "type": "service", + "description": "VPS 上のコンテナランタイム。Posimai API コンテナを管理", + "status": "active", + "parent": "vps-hetzner" + }, + { + "id": "posimai-api", + "label": "Posimai API", + "type": "service", + "description": "server.js。認証・記事・TTS・ジャーナル・習慣 API を提供", + "url": "https://api.soar-enrich.com", + "status": "active", + "parent": "vps-hetzner" + }, { "id": "tailscale", "label": "Tailscale", "type": "network", - "description": "デバイス間 VPN メッシュ。PC / iPhone / Synology / VPS を接続", + "description": "デバイス間 VPN メッシュ。全デバイスを安全に接続", "url": "https://tailscale.com", "status": "active" }, @@ -47,7 +80,7 @@ "id": "cloudflare", "label": "Cloudflare", "type": "network", - "description": "DNS 管理。api.soar-enrich.com 等のドメインを管理", + "description": "DNS 管理・CDN。api.soar-enrich.com 等のドメインを管理", "status": "active" }, { @@ -66,60 +99,50 @@ "url": "https://vercel.com", "status": "active" }, - { - "id": "stripe", - "label": "Stripe", - "type": "cloud", - "description": "決済処理。共同開発者と共有", - "url": "https://stripe.com", - "status": "active" - }, - { - "id": "gitea", - "label": "Gitea", - "type": "service", - "description": "ローカル Git バックアップ。Synology 上で Docker 動作", - "url": "http://100.76.7.3:3000", - "status": "active" - }, - { - "id": "docker", - "label": "Docker", - "type": "service", - "description": "VPS 上のコンテナランタイム。Posimai API コンテナを管理", - "status": "active" - }, - { - "id": "posimai-api", - "label": "Posimai API", - "type": "service", - "description": "server.js。認証・記事・TTS・ジャーナル API を提供", - "url": "https://api.soar-enrich.com", - "status": "active" - }, { "id": "posimai-apps", "label": "Posimai Apps", "type": "app", - "description": "25+ の posimai-* PWA 群。Vercel でホスト", + "description": "25+ の posimai-* PWA 群。Vercel でホスト、API を呼び出す", "url": "https://posimai-dashboard.vercel.app", + "status": "active", + "parent": "vercel" + }, + { + "id": "supabase", + "label": "Supabase", + "type": "cloud", + "description": "posimai-together のリアルタイムデータ・グループ共有機能", + "url": "https://supabase.com", + "status": "active" + }, + { + "id": "stripe", + "label": "Stripe", + "type": "cloud", + "description": "決済処理。共同開発者と共有、Kintone 連携実績あり", + "url": "https://stripe.com", "status": "active" } ], "edges": [ - { "from": "windows-pc", "to": "tailscale", "type": "connects", "label": "VPN" }, - { "from": "iphone", "to": "tailscale", "type": "connects", "label": "VPN" }, - { "from": "synology", "to": "tailscale", "type": "connects", "label": "VPN" }, - { "from": "vps-hetzner", "to": "tailscale", "type": "connects", "label": "VPN" }, - { "from": "windows-pc", "to": "gitea", "type": "push", "label": "git push" }, - { "from": "windows-pc", "to": "github", "type": "push", "label": "git push" }, - { "from": "github", "to": "vercel", "type": "trigger", "label": "auto deploy" }, - { "from": "vercel", "to": "posimai-apps", "type": "hosts" }, - { "from": "gitea", "to": "synology", "type": "runs-on" }, - { "from": "docker", "to": "vps-hetzner", "type": "runs-on" }, - { "from": "posimai-api", "to": "docker", "type": "runs-on", "label": "container" }, - { "from": "posimai-apps", "to": "posimai-api", "type": "calls", "label": "API" }, - { "from": "cloudflare", "to": "vps-hetzner", "type": "dns", "label": "DNS" }, - { "from": "posimai-apps", "to": "stripe", "type": "calls", "label": "payment" } + { "from": "windows-pc", "to": "tailscale", "type": "connects", "label": "VPN" }, + { "from": "ubuntu-pc", "to": "tailscale", "type": "connects", "label": "VPN" }, + { "from": "android-phone", "to": "tailscale", "type": "connects", "label": "VPN" }, + { "from": "synology", "to": "tailscale", "type": "connects", "label": "VPN" }, + { "from": "vps-hetzner", "to": "tailscale", "type": "connects", "label": "VPN" }, + { "from": "windows-pc", "to": "gitea", "type": "push", "label": "git push" }, + { "from": "windows-pc", "to": "github", "type": "push", "label": "git push" }, + { "from": "ubuntu-pc", "to": "gitea", "type": "push", "label": "git push" }, + { "from": "ubuntu-pc", "to": "github", "type": "push", "label": "git push" }, + { "from": "github", "to": "vercel", "type": "trigger", "label": "auto deploy" }, + { "from": "gitea", "to": "synology", "type": "runs-on" }, + { "from": "docker", "to": "vps-hetzner", "type": "runs-on" }, + { "from": "posimai-api", "to": "docker", "type": "runs-on", "label": "container" }, + { "from": "vercel", "to": "posimai-apps","type": "hosts" }, + { "from": "posimai-apps", "to": "posimai-api", "type": "calls", "label": "REST API" }, + { "from": "posimai-apps", "to": "supabase", "type": "calls", "label": "realtime" }, + { "from": "posimai-apps", "to": "stripe", "type": "calls", "label": "payment" }, + { "from": "cloudflare", "to": "vps-hetzner", "type": "dns", "label": "DNS" } ] } diff --git a/index.html b/index.html index ed55036..0f9c631 100644 --- a/index.html +++ b/index.html @@ -798,6 +798,30 @@ opacity: 0; transition: opacity 0.15s; } + + /* ── Containment hulls ────────────────────────────── */ + .group-hull { pointer-events: none; } + .group-hull-label { + font-size: 9px; + font-family: 'Inter', sans-serif; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + fill: var(--text3, #6B7280); + pointer-events: none; + opacity: 0.65; + } + + /* ── Tailscale scan ───────────────────────────────── */ + .scan-status { + font-size: 11px; + color: var(--text3); + padding: 3px 0; + display: none; + } + .scan-status.visible { display: block; } + .scan-status.ok { color: #4ADE80; } + .scan-status.err { color: #F87171; } @@ -840,6 +864,21 @@ +
+
Auto-discovery
+
+
+ Tailscale API キーで tailnet 上のデバイスを自動検出・追加します +
+ + +
+
+
@@ -1171,6 +1210,36 @@ function saveData() { // ── Graph rendering ──────────────────────────────────────────── let linkSel, nodeSel, labelSel; +// ── Containment hull helpers ─────────────────────────────────── +const hullLine = d3.line().curve(d3.curveCatmullRomClosed.alpha(0.5)); + +function buildGroupMap() { + // Returns Map + const map = new Map(); + atlasData.nodes.forEach(n => { + if (!n.parent) return; + if (!map.has(n.parent)) map.set(n.parent, [n.parent]); + map.get(n.parent).push(n.id); + }); + return map; +} + +function hullPath(simNodes, nodeIds) { + const PAD = 40; + const pts = []; + simNodes.forEach(n => { + if (!nodeIds.includes(n.id)) return; + const x = n.x ?? 0, y = n.y ?? 0; + pts.push([x - PAD, y - PAD], [x + PAD, y - PAD], + [x - PAD, y + PAD], [x + PAD, y + PAD], + [x, y - PAD], [x, y + PAD], + [x - PAD, y], [x + PAD, y]); + }); + const hull = d3.polygonHull(pts); + if (!hull || hull.length < 3) return null; + return hullLine(hull); +} + function initGraph() { const svg = d3.select('#graph-svg'); svg.selectAll('*').remove(); @@ -1219,6 +1288,25 @@ function initGraph() { })); currentSimEdges = simEdges; + // ── Containment group hulls (drawn beneath everything) ───── + const groupMap = buildGroupMap(); + const hullG = svgG.append('g').attr('class', 'hull-layer'); + const hullLabelG = svgG.append('g').attr('class', 'hull-label-layer'); + + groupMap.forEach((nodeIds, parentId) => { + const parentNode = atlasData.nodes.find(n => n.id === parentId); + const color = TYPE_COLORS[parentNode?.type] || '#ffffff'; + hullG.append('path') + .attr('class', `group-hull hull-${CSS.escape(parentId)}`) + .attr('fill', color + '09') + .attr('stroke', color + '28') + .attr('stroke-width', 1.5) + .attr('stroke-dasharray', '5 4'); + hullLabelG.append('text') + .attr('class', `group-hull-label hlabel-${CSS.escape(parentId)}`) + .text(parentNode?.label || parentId); + }); + // Simulation simulation = d3.forceSimulation(simNodes) .force('link', d3.forceLink(simEdges).id(d => d.id).distance(130).strength(0.6)) @@ -1273,6 +1361,22 @@ function initGraph() { .attr('y2', d => offsetPoint(d.target, d.source, 26).y); nodeGroup.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`); + + // Update containment hulls + groupMap.forEach((nodeIds, parentId) => { + const path = hullPath(simNodes, nodeIds); + hullG.select(`.hull-${CSS.escape(parentId)}`).attr('d', path || ''); + // Position label at the topmost hull point + const topNode = simNodes + .filter(n => nodeIds.includes(n.id)) + .reduce((a, b) => ((a.y ?? 0) < (b.y ?? 0) ? a : b), simNodes[0]); + if (topNode) { + hullLabelG.select(`.hlabel-${CSS.escape(parentId)}`) + .attr('x', topNode.x ?? 0) + .attr('y', (topNode.y ?? 0) - 46) + .attr('text-anchor', 'middle'); + } + }); }); // Re-apply selection highlight if needed @@ -1659,6 +1763,72 @@ function exportJson() { URL.revokeObjectURL(a.href); } +// ── Tailscale scan ───────────────────────────────────────────── +async function runTailscaleScan() { + const token = document.getElementById('tailscale-token-input').value.trim(); + const statusEl = document.getElementById('tailscale-scan-status'); + const btn = document.getElementById('btnTailscaleScan'); + + if (!token) { showToast('API キーを入力してください'); return; } + + statusEl.className = 'scan-status visible'; + statusEl.textContent = 'スキャン中...'; + btn.disabled = true; + + try { + const apiBase = 'https://api.soar-enrich.com/brain/api'; + const res = await fetch(`${apiBase}/atlas/tailscale-scan?token=${encodeURIComponent(token)}`); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(err.error || `HTTP ${res.status}`); + } + const data = await res.json(); + const devices = data.devices || []; + + let added = 0; + devices.forEach(device => { + const hostname = device.hostname || device.name || ''; + if (!hostname) return; + const nodeId = hostname.toLowerCase().replace(/[^\w-]/g, '-'); + const exists = atlasData.nodes.find(n => n.id === nodeId || + n.label.toLowerCase() === hostname.toLowerCase()); + if (!exists) { + atlasData.nodes.push({ + id: nodeId, + label: hostname, + type: 'device', + description: `${device.os || 'Unknown OS'} — Tailscale IP: ${(device.addresses || [])[0] || '—'}`, + status: device.authorized ? 'active' : 'inactive', + }); + // Only add edge if tailscale node exists + if (atlasData.nodes.find(n => n.id === 'tailscale')) { + atlasData.edges.push({ + from: nodeId, + to: 'tailscale', + type: 'connects', + label: 'VPN', + }); + } + added++; + } + }); + + saveData(); + buildFilterBar(); + initGraph(); + setTimeout(fitGraph, 500); + + statusEl.className = 'scan-status visible ok'; + statusEl.textContent = `${devices.length} デバイス検出 — ${added} 件追加`; + showToast(`Tailscale: ${added} デバイスを追加しました`); + } catch (e) { + statusEl.className = 'scan-status visible err'; + statusEl.textContent = `エラー: ${e.message}`; + } finally { + btn.disabled = false; + } +} + // ── Event bindings ───────────────────────────────────────────── function bindEvents() { document.getElementById('btn-fit').addEventListener('click', fitGraph); @@ -1722,6 +1892,11 @@ function bindEvents() { location.reload(); }); + document.getElementById('btnTailscaleScan').addEventListener('click', runTailscaleScan); + document.getElementById('tailscale-token-input').addEventListener('keydown', e => { + if (e.key === 'Enter') runTailscaleScan(); + }); + // File input (wizard + settings import) document.getElementById('fileInput').addEventListener('change', e => { const isWizard = !document.getElementById('wizard-overlay').hasAttribute('hidden');