feat: accurate data, containment hull visualization, Tailscale scan

This commit is contained in:
posimai 2026-03-29 18:09:32 +09:00
parent db605440af
commit 30a43755f3
2 changed files with 252 additions and 54 deletions

View File

@ -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" }
]
}

View File

@ -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; }
</style>
</head>
<body>
@ -840,6 +864,21 @@
</button>
</div>
</div>
<div>
<div class="settings-group-label">Auto-discovery</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:8px">
<div style="font-size:11px;color:var(--text3);line-height:1.5">
Tailscale API キーで tailnet 上のデバイスを自動検出・追加します
</div>
<input class="form-input" id="tailscale-token-input" type="password"
placeholder="tskey-api-..." style="font-size:12px">
<button class="btn btn-secondary" id="btnTailscaleScan" style="width:100%;font-size:12px">
<i data-lucide="scan-line" style="width:13px;height:13px;stroke-width:1.75"></i>
Tailscale スキャン
</button>
<div id="tailscale-scan-status" class="scan-status"></div>
</div>
</div>
</div>
</aside>
<div class="overlay" id="overlay" aria-hidden="true"></div>
@ -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<parentId, [parentId, ...childIds]>
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');