feat: accurate data, containment hull visualization, Tailscale scan
This commit is contained in:
parent
db605440af
commit
30a43755f3
131
atlas.json
131
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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
175
index.html
175
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; }
|
||||
</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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue