posimai-atlas/index.html

2600 lines
102 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="ja" data-app-id="posimai-atlas">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<script>
(function () {
var t = localStorage.getItem('posimai-atlas-theme') || 'system';
var dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', t);
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="インフラ構成図・サービス依存マップ">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0C1221" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#EFF6FF" media="(prefers-color-scheme: light)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Atlas">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<title>Atlas</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<style>
/* ── Accent + Background Override ─────────────────── */
:root,
[data-theme="dark"] {
--accent: #22D3EE;
--accent-dim: rgba(34, 211, 238, 0.15);
--bg: #0C1221;
}
[data-theme="light"] {
--accent: #0891B2;
--accent-dim: rgba(8, 145, 178, 0.1);
--bg: #EFF6FF;
}
/* ── Node type colors ──────────────────────────────── */
:root {
--c-device: #22D3EE;
--c-server: #FB923C;
--c-network: #818CF8;
--c-cloud: #C084FC;
--c-service: #4ADE80;
--c-app: #38BDF8;
}
/* ── Layout ────────────────────────────────────────── */
html, body {
height: 100%;
overflow: hidden;
}
#graph-wrap {
position: fixed;
inset: 52px 0 0 0;
overflow: hidden;
}
#graph-svg {
width: 100%;
height: 100%;
display: block;
}
/* ── Aurora ────────────────────────────────────────── */
.aurora-bg {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.aurora-blob {
position: absolute;
border-radius: 50%;
will-change: transform;
opacity: 0;
}
[data-theme="dark"] .aurora-blob { opacity: 1; }
.aurora-blob-1 {
width: 700px; height: 460px;
background: radial-gradient(ellipse, rgba(34,211,238,0.18) 0%, transparent 68%);
top: -100px; right: -80px;
filter: blur(90px);
animation: aurora-1 18s ease-in-out infinite alternate;
}
.aurora-blob-2 {
width: 560px; height: 400px;
background: radial-gradient(ellipse, rgba(129,140,248,0.14) 0%, transparent 68%);
bottom: -60px; left: -80px;
filter: blur(100px);
animation: aurora-2 22s ease-in-out infinite alternate;
}
.aurora-blob-3 {
width: 440px; height: 340px;
background: radial-gradient(ellipse, rgba(192,132,252,0.11) 0%, transparent 68%);
top: 40%; right: 20%;
filter: blur(110px);
animation: aurora-3 14s ease-in-out infinite alternate;
}
@keyframes aurora-1 {
0% { transform: translate(0,0) scale(1); }
100% { transform: translate(120px,150px) scale(1.2); }
}
@keyframes aurora-2 {
0% { transform: translate(0,0) scale(1.05); }
100% { transform: translate(190px,-120px) scale(0.88); }
}
@keyframes aurora-3 {
0% { transform: translate(0,0) scale(0.95); }
100% { transform: translate(-140px,100px) scale(1.25); }
}
/* ── Filter bar ────────────────────────────────────── */
#filter-bar {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 10;
flex-wrap: wrap;
justify-content: center;
padding: 0 16px;
pointer-events: none;
}
.filter-chip {
pointer-events: all;
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px 4px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: rgba(12, 18, 33, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--text2);
transition: all 0.15s;
white-space: nowrap;
letter-spacing: 0;
}
[data-theme="light"] .filter-chip {
background: rgba(239, 246, 255, 0.85);
}
.filter-chip:hover { color: var(--text); }
.filter-chip.active {
border-color: var(--chip-color, var(--accent));
color: var(--text);
background: rgba(12, 18, 33, 0.85);
}
[data-theme="light"] .filter-chip.active {
background: rgba(239, 246, 255, 0.95);
}
.chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--chip-color, var(--accent));
flex-shrink: 0;
opacity: 0.5;
transition: opacity 0.15s;
}
.filter-chip.active .chip-dot { opacity: 1; }
.chip-count {
font-size: 10px;
opacity: 0.6;
margin-left: 1px;
}
/* ── SVG graph elements ────────────────────────────── */
.graph-edge {
stroke: rgba(255,255,255,0.07);
stroke-width: 1;
fill: none;
pointer-events: none;
transition: stroke 0.2s, stroke-width 0.2s;
}
[data-theme="light"] .graph-edge {
stroke: rgba(0,0,0,0.08);
}
.graph-edge.highlight {
stroke-width: 1.5;
}
.graph-node-circle {
stroke-width: 1.5;
transition: r 0.2s, stroke-width 0.2s;
cursor: pointer;
}
.graph-node-circle:hover {
stroke-width: 2.5;
}
.graph-node-label {
font-size: 11px;
font-family: 'Inter', sans-serif;
fill: var(--text2, #9CA3AF);
text-anchor: middle;
pointer-events: none;
font-weight: 400;
letter-spacing: -0.01em;
}
/* ── Detail panel ──────────────────────────────────── */
#detail-panel {
position: fixed;
z-index: 20;
background: rgba(12, 18, 33, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
transition: transform 0.25s cubic-bezier(0.2, 0.9, 0.2, 1);
overflow-y: auto;
}
[data-theme="light"] #detail-panel {
background: rgba(239, 246, 255, 0.92);
}
@media (min-width: 601px) {
#detail-panel {
top: 52px; right: 0; bottom: 0;
width: 300px;
border-top: none; border-right: none; border-bottom: none;
border-radius: 0;
transform: translateX(100%);
}
#detail-panel.open { transform: translateX(0); }
}
@media (max-width: 600px) {
#detail-panel {
bottom: 0; left: 0; right: 0;
max-height: 65vh;
border-bottom: none; border-left: none; border-right: none;
border-radius: 16px 16px 0 0;
transform: translateY(100%);
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
#detail-panel.open { transform: translateY(0); }
}
.detail-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: inherit;
backdrop-filter: blur(20px);
}
.detail-type-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
}
.detail-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
flex: 1;
line-height: 1.3;
}
.detail-type-label {
font-size: 10px;
color: var(--text3);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.detail-close {
flex-shrink: 0;
margin-top: -2px;
}
.detail-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-desc {
font-size: 13px;
color: var(--text2);
line-height: 1.6;
}
.detail-url a {
font-size: 12px;
color: var(--accent);
text-decoration: none;
word-break: break-all;
display: flex;
align-items: center;
gap: 5px;
}
.detail-url a:hover { text-decoration: underline; }
.detail-section-label {
font-size: 10px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.detail-connections {
display: flex;
flex-direction: column;
gap: 4px;
}
.conn-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text2);
padding: 5px 8px;
border-radius: 8px;
background: var(--surface);
}
.conn-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.conn-dir {
font-size: 10px;
color: var(--text3);
margin-left: auto;
}
.detail-actions {
display: flex;
gap: 8px;
padding: 0 16px 16px;
}
.detail-actions .btn {
flex: 1;
font-size: 12px;
padding: 7px 12px;
}
.detail-actions .btn-danger {
background: rgba(239,68,68,0.1);
color: #F87171;
border-color: rgba(239,68,68,0.25);
}
.detail-actions .btn-danger:hover {
background: rgba(239,68,68,0.2);
}
/* ── Status badge ──────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
border-radius: 10px;
}
.status-badge.active {
background: rgba(74,222,128,0.12);
color: #4ADE80;
}
.status-badge.inactive {
background: rgba(156,163,175,0.12);
color: #9CA3AF;
}
.status-badge.unknown {
background: rgba(251,146,60,0.12);
color: #FB923C;
}
.status-dot {
width: 5px; height: 5px;
border-radius: 50%;
background: currentColor;
}
/* ── Toolbar ───────────────────────────────────────── */
#toolbar {
position: fixed;
bottom: max(20px, env(safe-area-inset-bottom));
right: 16px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 20;
}
.tb-btn {
width: 40px; height: 40px;
border-radius: 12px;
background: rgba(12, 18, 33, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
color: var(--text2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
[data-theme="light"] .tb-btn {
background: rgba(239, 246, 255, 0.88);
}
.tb-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.tb-btn.accent-btn {
background: var(--accent-dim);
border-color: rgba(34,211,238,0.35);
color: var(--accent);
}
.tb-btn.accent-btn:hover {
background: rgba(34,211,238,0.22);
}
.tb-divider {
height: 1px;
background: var(--border);
margin: 0 4px;
}
/* ── Modal ─────────────────────────────────────────── */
#modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#modal-overlay.open {
opacity: 1;
pointer-events: all;
}
#modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.modal-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-label {
font-size: 11px;
font-weight: 500;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 13px;
padding: 8px 10px;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--accent);
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
.form-select option {
background: var(--surface);
}
.modal-footer {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.modal-footer .btn {
flex: 1;
font-size: 13px;
}
/* ── Edge modal (same base as modal-overlay) ──────── */
#edge-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#edge-modal-overlay.open {
opacity: 1;
pointer-events: all;
}
/* ── AI Context output ─────────────────────────────── */
#ai-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#ai-modal-overlay.open {
opacity: 1;
pointer-events: all;
}
#ai-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
width: 100%;
max-width: 560px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
#ai-context-text {
font-size: 11px;
font-family: 'Geist Mono', 'Fira Code', monospace;
color: var(--text2);
line-height: 1.6;
padding: 14px 16px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
flex: 1;
background: var(--surface2);
border-radius: 0;
}
/* ── Wizard ────────────────────────────────────────── */
#wizard-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(12, 18, 33, 0.97);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
#wizard-overlay[hidden] { display: none; }
#wizard-card {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
}
.wizard-logo {
width: 72px; height: 72px;
border-radius: 18px;
overflow: hidden;
margin-bottom: 18px;
box-shadow: 0 0 32px rgba(34,211,238,0.3);
}
.wizard-logo img { width: 100%; height: 100%; object-fit: cover; }
.wizard-title {
font-size: 22px;
font-weight: 600;
color: var(--text);
margin: 0 0 6px;
letter-spacing: -0.03em;
}
.wizard-subtitle {
font-size: 13px;
color: var(--text3);
margin: 0 0 32px;
}
.wizard-options {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.wizard-option {
display: flex;
align-items: center;
gap: 14px;
text-align: left;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
width: 100%;
color: var(--text);
}
.wizard-option:hover {
background: rgba(34,211,238,0.08);
border-color: rgba(34,211,238,0.3);
}
.wizard-option-icon {
width: 38px; height: 38px;
border-radius: 10px;
background: rgba(34,211,238,0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--accent);
}
.wizard-option-body { flex: 1; }
.wizard-option-title {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.wizard-option-desc {
font-size: 11px;
color: var(--text3);
}
.wizard-hint {
font-size: 11px;
color: var(--text3);
margin: 0;
}
/* ── Share banner ──────────────────────────────────── */
#share-banner {
position: fixed;
top: 52px;
left: 0; right: 0;
z-index: 15;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 16px;
background: rgba(34,211,238,0.08);
border-bottom: 1px solid rgba(34,211,238,0.18);
font-size: 12px;
color: var(--accent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
#share-banner[hidden] { display: none; }
.has-banner #graph-wrap { top: 88px; }
.has-banner #filter-bar { top: 96px; }
/* ── Edge delete button ─────────────────────────────── */
.conn-delete-btn {
flex-shrink: 0;
width: 20px; height: 20px;
border-radius: 5px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text3);
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
padding: 0;
margin-left: auto;
}
.conn-item:hover .conn-delete-btn { opacity: 1; }
.conn-delete-btn:hover {
background: rgba(239,68,68,0.15);
color: #F87171;
}
/* ── Health check button ───────────────────────────── */
#dp-health-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 5px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--text3);
cursor: pointer;
transition: all 0.15s;
width: fit-content;
}
#dp-health-btn:hover { border-color: var(--accent); color: var(--accent); }
#dp-health-btn.checking { opacity: 0.6; pointer-events: none; }
.health-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text3);
flex-shrink: 0;
}
.health-dot.online { background: #4ADE80; box-shadow: 0 0 6px #4ADE8088; }
.health-dot.offline { background: #F87171; }
.health-dot.limited { background: #FB923C; }
/* ── Edge legend ────────────────────────────────────── */
#edge-legend {
position: fixed;
bottom: max(76px, calc(env(safe-area-inset-bottom) + 76px));
right: 16px;
z-index: 18;
background: rgba(12,18,33,0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
display: none;
flex-direction: column;
gap: 5px;
min-width: 140px;
}
[data-theme="light"] #edge-legend { background: rgba(239,246,255,0.92); }
#edge-legend.open { display: flex; }
.legend-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text2);
}
.legend-line {
width: 22px; height: 2px;
border-radius: 1px;
flex-shrink: 0;
}
/* ── Edge label tooltip ────────────────────────────── */
#edge-tooltip {
position: fixed;
font-size: 10px;
color: var(--text3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
pointer-events: none;
z-index: 15;
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;
}
/* ── Metrics panel ────────────────────────────────── */
#dp-metrics {
display: none;
flex-direction: column;
gap: 10px;
}
#dp-metrics.visible { display: flex; }
.metric-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-header {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text3);
}
.metric-value { font-weight: 500; color: var(--text2); }
.metric-bar-track {
height: 4px;
background: rgba(255,255,255,0.07);
border-radius: 2px;
overflow: hidden;
}
[data-theme="light"] .metric-bar-track { background: rgba(0,0,0,0.07); }
.metric-bar-fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.5s ease;
}
.metric-bar-fill.warn { background: #FB923C; }
.metric-bar-fill.crit { background: #F87171; }
.metric-stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.metric-stat {
background: var(--surface2, rgba(255,255,255,0.04));
border-radius: 8px;
padding: 8px 10px;
font-size: 11px;
}
.metric-stat-label { color: var(--text3); margin-bottom: 2px; }
.metric-stat-val { font-size: 14px; font-weight: 600; color: var(--text); letter-spacing: -0.02em; }
.metric-open-btn {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--accent); text-decoration: none;
padding: 5px 0; border: none; background: none; cursor: pointer;
transition: opacity 0.15s;
}
.metric-open-btn:hover { opacity: 0.75; }
/* ── 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; }
/* ── Monitoring ───────────────────────────────────── */
#monitor-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text3);
flex-shrink: 0;
transition: background 0.3s;
}
#monitor-dot.active {
background: #4ADE80;
box-shadow: 0 0 6px #4ADE8088;
animation: mon-pulse 2.4s ease-in-out infinite;
}
#monitor-dot.checking {
background: var(--accent);
animation: mon-pulse 0.6s ease-in-out infinite;
}
@keyframes mon-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.monitor-interval-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text2);
}
.monitor-interval-row select {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 12px;
padding: 3px 6px;
outline: none;
}
</style>
</head>
<body>
<a href="#graph-wrap" class="skip-link" tabindex="0" style="position:absolute;top:-100%;left:8px;background:var(--accent);color:#0D0D0D;padding:8px 16px;border-radius:8px;font-weight:600;font-size:13px;z-index:10000;text-decoration:none">コンテンツへスキップ</a>
<!-- Settings panel -->
<aside class="settings-panel" id="settingsPanel" role="complementary">
<div class="settings-panel-header">
<span class="settings-panel-title">設定</span>
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
</button>
</div>
<div class="settings-panel-body">
<div>
<div class="settings-group-label">外観</div>
<div class="settings-item">
<div class="settings-item-label">テーマ</div>
<div class="theme-selector">
<button class="theme-btn" data-theme-val="dark"><i data-lucide="moon" style="width:12px;height:12px;stroke-width:1.75"></i>ダーク</button>
<button class="theme-btn" data-theme-val="light"><i data-lucide="sun" style="width:12px;height:12px;stroke-width:1.75"></i>ライト</button>
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
</div>
</div>
</div>
<div>
<div class="settings-group-label">データ</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:8px">
<button class="btn btn-secondary" id="btnImportJson" style="width:100%;font-size:12px">
<i data-lucide="upload" style="width:13px;height:13px;stroke-width:1.75"></i>
atlas.json をインポート
</button>
<button class="btn btn-secondary" id="btnExportJson" style="width:100%;font-size:12px">
<i data-lucide="download" style="width:13px;height:13px;stroke-width:1.75"></i>
atlas.json をエクスポート
</button>
<button class="btn btn-secondary" id="btnResetData" style="width:100%;font-size:12px;color:var(--text3)">
<i data-lucide="rotate-ccw" style="width:13px;height:13px;stroke-width:1.75"></i>
サンプルデータに戻す
</button>
</div>
</div>
<div>
<div class="settings-group-label">監視</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:8px">
<div class="monitor-interval-row">
<span>ヘルスチェック間隔</span>
<select id="monitor-interval-sel">
<option value="0">オフ</option>
<option value="2">2分</option>
<option value="5" selected>5分</option>
<option value="10">10分</option>
<option value="30">30分</option>
</select>
</div>
<div id="monitor-last-checked" style="font-size:11px;color:var(--text3)"></div>
</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>
<div class="settings-group-label">GitHub</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">
Personal Access Token でリポジトリ・連携サービスを検出しますread:user, repo スコープ)
</div>
<input class="form-input" id="github-token-input" type="password"
placeholder="ghp_..." style="font-size:12px">
<input class="form-input" id="github-org-input" type="text"
placeholder="org 名(例: posimai" style="font-size:12px">
<button class="btn btn-secondary" id="btnGithubScan" style="width:100%;font-size:12px">
<i data-lucide="github" style="width:13px;height:13px;stroke-width:1.75"></i>
GitHub スキャン
</button>
<div id="github-scan-status" class="scan-status"></div>
</div>
</div>
<div>
<div class="settings-group-label">Vercel</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">
API トークンでデプロイ中のプロジェクトを自動検出します
</div>
<input class="form-input" id="vercel-token-input" type="password"
placeholder="vercel token..." style="font-size:12px">
<button class="btn btn-secondary" id="btnVercelScan" style="width:100%;font-size:12px">
<i data-lucide="triangle" style="width:13px;height:13px;stroke-width:1.75"></i>
Vercel スキャン
</button>
<div id="vercel-scan-status" class="scan-status"></div>
</div>
</div>
</div>
</aside>
<div class="overlay" id="overlay" aria-hidden="true"></div>
<!-- Share banner (read-only mode) -->
<div id="share-banner" hidden>
<i data-lucide="eye" style="width:13px;height:13px;stroke-width:1.75"></i>
<span>シェアビュー(読み取り専用)</span>
<button id="share-banner-own-btn" style="margin-left:12px;font-size:11px;padding:3px 10px;border-radius:8px;background:var(--accent-dim);border:1px solid rgba(34,211,238,0.3);color:var(--accent);cursor:pointer">自分のデータを開く</button>
</div>
<!-- Aurora -->
<div class="aurora-bg" aria-hidden="true">
<div class="aurora-blob aurora-blob-1"></div>
<div class="aurora-blob aurora-blob-2"></div>
<div class="aurora-blob aurora-blob-3"></div>
</div>
<!-- Header -->
<header class="header">
<div class="header-brand">
<div class="header-dot" aria-hidden="true"></div>
<span class="header-title">Atlas</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<div style="display:flex;align-items:center;gap:5px">
<div id="monitor-dot" title="監視オフ"></div>
<span id="node-count" style="font-size:11px;color:var(--text3)"></span>
</div>
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
</button>
</div>
</header>
<!-- Filter bar -->
<div id="filter-bar" role="group" aria-label="ノードタイプフィルター"></div>
<!-- Graph -->
<div id="graph-wrap">
<svg id="graph-svg" role="img" aria-label="インフラ構成図"></svg>
</div>
<!-- Detail panel -->
<aside id="detail-panel" role="complementary" aria-label="ノード詳細">
<div class="detail-header">
<div class="detail-type-dot" id="dp-dot"></div>
<div style="flex:1;min-width:0">
<div class="detail-title" id="dp-title"></div>
<div class="detail-type-label" id="dp-type"></div>
</div>
<button class="icon-btn detail-close" id="detail-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="detail-body">
<div class="detail-desc" id="dp-desc"></div>
<div class="detail-url" id="dp-url"></div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<div id="dp-status"></div>
<button id="dp-health-btn" style="display:none">
<span class="health-dot" id="dp-health-dot"></span>
<span id="dp-health-label">接続確認</span>
</button>
</div>
<!-- メトリクス (ubuntu-pc など /api/health 対応ノード用) -->
<div id="dp-metrics">
<div class="detail-section-label">リアルタイム状態</div>
<div class="metric-row" id="metric-cpu-row">
<div class="metric-header">
<span>CPU</span>
<span class="metric-value" id="metric-cpu-val"></span>
</div>
<div class="metric-bar-track"><div class="metric-bar-fill" id="metric-cpu-bar" style="width:0%"></div></div>
</div>
<div class="metric-row" id="metric-mem-row">
<div class="metric-header">
<span>メモリ</span>
<span class="metric-value" id="metric-mem-val"></span>
</div>
<div class="metric-bar-track"><div class="metric-bar-fill" id="metric-mem-bar" style="width:0%"></div></div>
</div>
<div class="metric-stat-grid">
<div class="metric-stat">
<div class="metric-stat-label">稼働時間</div>
<div class="metric-stat-val" id="metric-uptime"></div>
</div>
<div class="metric-stat">
<div class="metric-stat-label">セッション</div>
<div class="metric-stat-val" id="metric-sessions"></div>
</div>
</div>
<a class="metric-open-btn" id="metric-open-link" href="#" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:12px;height:12px;stroke-width:1.75"></i>
posimai-dev を開く
</a>
</div>
<div id="dp-connections">
<div class="detail-section-label">接続</div>
<div class="detail-connections" id="dp-conn-list"></div>
</div>
</div>
<div class="detail-actions">
<button class="btn btn-secondary detail-close" id="dp-edit-btn" style="font-size:12px">
<i data-lucide="pencil" style="width:13px;height:13px;stroke-width:1.75"></i>編集
</button>
<button class="btn detail-close" id="dp-add-edge-btn" style="font-size:12px;background:var(--accent-dim);border-color:rgba(34,211,238,0.3);color:var(--accent)">
<i data-lucide="git-branch" style="width:13px;height:13px;stroke-width:1.75"></i>接続を追加
</button>
<button class="btn btn-secondary btn-danger" id="dp-delete-btn" style="flex:0 0 auto;width:36px;padding:7px">
<i data-lucide="trash-2" style="width:13px;height:13px;stroke-width:1.75"></i>
</button>
</div>
</aside>
<!-- Node edit/add modal -->
<div id="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div id="modal">
<div class="modal-header">
<span class="modal-title" id="modal-title">ノードを追加</span>
<button class="icon-btn" id="modal-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="f-node-id">
<div class="form-group">
<label class="form-label" for="f-label">名前</label>
<input class="form-input" id="f-label" type="text" placeholder="例: My Server">
</div>
<div class="form-group">
<label class="form-label" for="f-type">タイプ</label>
<select class="form-select" id="f-type">
<option value="device">デバイス</option>
<option value="server">サーバー</option>
<option value="network">ネットワーク</option>
<option value="cloud">クラウド</option>
<option value="service">サービス</option>
<option value="app">アプリ</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="f-desc">説明</label>
<textarea class="form-textarea" id="f-desc" placeholder="役割・用途を入力"></textarea>
</div>
<div class="form-group">
<label class="form-label" for="f-url">URL任意</label>
<input class="form-input" id="f-url" type="url" placeholder="https://...">
</div>
<div class="form-group">
<label class="form-label" for="f-status">ステータス</label>
<select class="form-select" id="f-status">
<option value="active">active</option>
<option value="inactive">inactive</option>
<option value="unknown">unknown</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modal-cancel-btn">キャンセル</button>
<button class="btn btn-primary" id="modal-save-btn">保存</button>
</div>
</div>
</div>
<!-- Edge add modal -->
<div id="edge-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="edge-modal-title">
<div id="edge-modal" style="background:var(--surface);border:1px solid var(--border);border-radius:16px;width:100%;max-width:380px;max-height:90vh;overflow-y:auto">
<div class="modal-header">
<span class="modal-title" id="edge-modal-title">接続を追加</span>
<button class="icon-btn" id="edge-modal-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="ef-from">接続元</label>
<select class="form-select" id="ef-from"></select>
</div>
<div class="form-group">
<label class="form-label" for="ef-to">接続先</label>
<select class="form-select" id="ef-to"></select>
</div>
<div class="form-group">
<label class="form-label" for="ef-type">タイプ</label>
<select class="form-select" id="ef-type">
<option value="connects">connects</option>
<option value="push">push</option>
<option value="trigger">trigger</option>
<option value="hosts">hosts</option>
<option value="runs-on">runs-on</option>
<option value="calls">calls</option>
<option value="dns">dns</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="ef-label">ラベル(任意)</label>
<input class="form-input" id="ef-label" type="text" placeholder="例: git push">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="edge-modal-cancel-btn">キャンセル</button>
<button class="btn btn-primary" id="edge-modal-save-btn">追加</button>
</div>
</div>
</div>
<!-- AI Context modal -->
<div id="ai-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="ai-modal-title">
<div id="ai-modal">
<div class="modal-header" style="border-bottom:1px solid var(--border)">
<span class="modal-title" id="ai-modal-title">AI Context</span>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn" id="ai-copy-btn" style="font-size:12px;padding:5px 12px;background:var(--accent-dim);border-color:rgba(34,211,238,0.3);color:var(--accent)">
<i data-lucide="clipboard-copy" style="width:13px;height:13px;stroke-width:1.75"></i>コピー
</button>
<button class="icon-btn" id="ai-modal-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
</div>
<pre id="ai-context-text"></pre>
</div>
</div>
<!-- Toolbar -->
<div id="toolbar" role="toolbar" aria-label="グラフ操作">
<button class="tb-btn accent-btn" id="btn-ai" aria-label="AI Context を生成" title="AI Context">
<i data-lucide="brain-circuit" style="width:17px;height:17px;stroke-width:1.5"></i>
</button>
<button class="tb-btn" id="btn-share" aria-label="URLでシェア" title="URLでシェア">
<i data-lucide="share-2" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
<div class="tb-divider"></div>
<button class="tb-btn" id="btn-add-node" aria-label="ノードを追加" title="ノードを追加">
<i data-lucide="plus" style="width:17px;height:17px;stroke-width:1.75"></i>
</button>
<button class="tb-btn" id="btn-legend" aria-label="凡例" title="接続タイプ凡例">
<i data-lucide="info" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
<button class="tb-btn" id="btn-fit" aria-label="画面に合わせる" title="フィット">
<i data-lucide="maximize-2" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
</div>
<!-- Edge type legend -->
<div id="edge-legend" role="complementary" aria-label="接続タイプ凡例">
<div style="font-size:10px;font-weight:600;color:var(--text3);letter-spacing:.05em;margin-bottom:2px">接続タイプ</div>
<div class="legend-row"><span class="legend-line" style="background:rgba(34,211,238,0.5)"></span>push / calls</div>
<div class="legend-row"><span class="legend-line" style="background:rgba(192,132,252,0.5)"></span>trigger</div>
<div class="legend-row"><span class="legend-line" style="background:rgba(74,222,128,0.4)"></span>hosts</div>
<div class="legend-row"><span class="legend-line" style="background:rgba(251,146,60,0.4)"></span>runs-on</div>
<div class="legend-row"><span class="legend-line" style="background:rgba(129,140,248,0.5)"></span>dns</div>
<div class="legend-row"><span class="legend-line" style="background:rgba(255,255,255,0.15)"></span>connects</div>
</div>
<!-- Edge label tooltip -->
<div id="edge-tooltip"></div>
<!-- Wizard (first-run) -->
<div id="wizard-overlay" hidden>
<div id="wizard-card">
<div class="wizard-logo">
<img src="/logo.png" alt="Atlas" width="72" height="72">
</div>
<h1 class="wizard-title">Atlas へようこそ</h1>
<p class="wizard-subtitle">インフラ構成図・サービス依存マップ</p>
<div class="wizard-options">
<button class="wizard-option" id="w-sample">
<div class="wizard-option-icon">
<i data-lucide="layout-dashboard" style="width:18px;height:18px;stroke-width:1.5"></i>
</div>
<div class="wizard-option-body">
<div class="wizard-option-title">サンプルを使う</div>
<div class="wizard-option-desc">一般的な開発環境のデータで試してみる</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;stroke-width:1.5;color:var(--text3)"></i>
</button>
<button class="wizard-option" id="w-empty">
<div class="wizard-option-icon">
<i data-lucide="plus-circle" style="width:18px;height:18px;stroke-width:1.5"></i>
</div>
<div class="wizard-option-body">
<div class="wizard-option-title">空から始める</div>
<div class="wizard-option-desc">自分の環境をゼロから追加する</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;stroke-width:1.5;color:var(--text3)"></i>
</button>
<button class="wizard-option" id="w-import">
<div class="wizard-option-icon">
<i data-lucide="upload" style="width:18px;height:18px;stroke-width:1.5"></i>
</div>
<div class="wizard-option-body">
<div class="wizard-option-title">ファイルから読み込む</div>
<div class="wizard-option-desc">既存の atlas.json をアップロード</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;stroke-width:1.5;color:var(--text3)"></i>
</button>
</div>
<p class="wizard-hint">ノードは後からいつでも追加・編集できます</p>
</div>
</div>
<!-- Hidden file input (shared by wizard + settings import) -->
<input type="file" id="fileInput" accept=".json" style="display:none">
<div id="toast" role="status" aria-live="polite"></div>
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script>
// ── Constants ──────────────────────────────────────────────────
const STORAGE_KEY = 'posimai-atlas-data';
const TYPE_COLORS = {
device: '#22D3EE',
server: '#FB923C',
network: '#818CF8',
cloud: '#C084FC',
service: '#4ADE80',
app: '#38BDF8',
};
const TYPE_LABELS = {
device: 'デバイス',
server: 'サーバー',
network: 'ネットワーク',
cloud: 'クラウド',
service: 'サービス',
app: 'アプリ',
};
const EDGE_COLORS = {
push: 'rgba(34,211,238,0.28)',
trigger: 'rgba(192,132,252,0.28)',
hosts: 'rgba(74,222,128,0.22)',
calls: 'rgba(34,211,238,0.22)',
dns: 'rgba(129,140,248,0.25)',
'runs-on': 'rgba(251,146,60,0.22)',
};
// ── Data management ────────────────────────────────────────────
let atlasData = null;
let selectedNodeId = null;
let hiddenTypes = new Set();
let simulation = null;
let svgZoom = null;
let svgG = null;
let isReadOnly = false;
let currentSimNodes = [];
let currentSimEdges = [];
async function loadData() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try { atlasData = JSON.parse(saved); return; } catch (e) {}
}
// No saved data — wizard will handle initialization
}
function saveData() {
atlasData.meta.updated = new Date().toISOString().slice(0, 10);
localStorage.setItem(STORAGE_KEY, JSON.stringify(atlasData));
}
// ── 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();
const W = svg.node().clientWidth;
const H = svg.node().clientHeight;
// Defs: arrowhead marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 -4 8 8')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L8,0L0,4')
.attr('fill', 'rgba(255,255,255,0.2)');
// Zoom container
svgG = svg.append('g');
svgZoom = d3.zoom()
.scaleExtent([0.2, 5])
.on('zoom', event => svgG.attr('transform', event.transform));
svg.call(svgZoom)
.on('click', (event) => {
if (event.target === svg.node()) closeDetail();
});
// Filtered nodes/edges
const nodes = visibleNodes();
const edges = visibleEdges(nodes);
// Clone objects for simulation (D3 mutates them)
const simNodes = nodes.map(n => ({ ...n }));
currentSimNodes = simNodes;
const nodeMap = new Map(simNodes.map(n => [n.id, n]));
const simEdges = edges.map(e => ({
...e,
source: nodeMap.get(e.from) || e.from,
target: nodeMap.get(e.to) || e.to,
}));
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))
.force('charge', d3.forceManyBody().strength(-320))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collision', d3.forceCollide(42));
// Edges
linkSel = svgG.append('g')
.selectAll('line')
.data(simEdges)
.enter().append('line')
.attr('class', 'graph-edge')
.attr('stroke', d => EDGE_COLORS[d.type] || 'rgba(255,255,255,0.07)')
.attr('marker-end', 'url(#arrow)');
// Nodes
const nodeGroup = svgG.append('g')
.selectAll('g')
.data(simNodes)
.enter().append('g')
.attr('class', 'graph-node')
.style('cursor', 'pointer')
.call(d3.drag()
.on('start', dragStart)
.on('drag', dragging)
.on('end', dragEnd)
)
.on('click', (event, d) => {
event.stopPropagation();
selectNode(d.id, simNodes, simEdges);
});
nodeSel = nodeGroup.append('circle')
.attr('class', 'graph-node-circle')
.attr('r', 20)
.attr('fill', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
return TYPE_COLORS[d.type] + (node?.status === 'inactive' ? '0A' : '18');
})
.attr('stroke', d => TYPE_COLORS[d.type])
.style('filter', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
return node?.status === 'inactive' ? 'none' : `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`;
})
.style('opacity', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
return node?.status === 'inactive' ? 0.45 : 1;
});
labelSel = nodeGroup.append('text')
.attr('class', 'graph-node-label')
.attr('dy', 36)
.text(d => d.label);
// Tick
simulation.on('tick', () => {
linkSel
.attr('x1', d => offsetPoint(d.source, d.target, 22).x)
.attr('y1', d => offsetPoint(d.source, d.target, 22).y)
.attr('x2', d => offsetPoint(d.target, d.source, 26).x)
.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
if (selectedNodeId) {
updateHighlight(simNodes, simEdges);
}
updateNodeCount();
}
function offsetPoint(from, to, r) {
const dx = (to.x ?? 0) - (from.x ?? 0);
const dy = (to.y ?? 0) - (from.y ?? 0);
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
return { x: (from.x ?? 0) + (dx / dist) * r, y: (from.y ?? 0) + (dy / dist) * r };
}
function dragStart(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
}
function dragging(event, d) {
d.fx = event.x; d.fy = event.y;
}
function dragEnd(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
}
function visibleNodes() {
return atlasData.nodes.filter(n => !hiddenTypes.has(n.type));
}
function visibleEdges(nodes) {
const ids = new Set(nodes.map(n => n.id));
return atlasData.edges.filter(e => ids.has(e.from) && ids.has(e.to));
}
// ── Fit to screen ──────────────────────────────────────────────
function fitGraph() {
const svg = d3.select('#graph-svg');
const W = svg.node().clientWidth;
const H = svg.node().clientHeight;
const bounds = svgG.node().getBBox();
if (!bounds.width || !bounds.height) return;
const pad = 60;
const scale = Math.min(
(W - pad * 2) / bounds.width,
(H - pad * 2) / bounds.height,
1.2
);
const tx = W / 2 - scale * (bounds.x + bounds.width / 2);
const ty = H / 2 - scale * (bounds.y + bounds.height / 2);
d3.select('#graph-svg')
.transition().duration(400)
.call(svgZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
// ── Node selection & detail panel ─────────────────────────────
function selectNode(id, simNodes, simEdges) {
selectedNodeId = id;
updateHighlight(simNodes, simEdges);
showDetail(id, simNodes, simEdges);
}
function updateHighlight(simNodes, simEdges) {
if (!nodeSel || !linkSel) return;
const connectedIds = new Set();
const connectedEdges = new Set();
if (selectedNodeId) {
simEdges.forEach((e, i) => {
const sid = typeof e.source === 'object' ? e.source.id : e.source;
const tid = typeof e.target === 'object' ? e.target.id : e.target;
if (sid === selectedNodeId || tid === selectedNodeId) {
connectedIds.add(sid);
connectedIds.add(tid);
connectedEdges.add(i);
}
});
}
nodeSel
.attr('r', d => d.id === selectedNodeId ? 24 : 20)
.attr('stroke-width', d => d.id === selectedNodeId ? 2.5 : connectedIds.has(d.id) ? 2 : 1.5)
.attr('fill', d => {
if (d.id === selectedNodeId) return TYPE_COLORS[d.type] + '30';
if (connectedIds.has(d.id)) return TYPE_COLORS[d.type] + '20';
return selectedNodeId ? TYPE_COLORS[d.type] + '0A' : TYPE_COLORS[d.type] + '18';
})
.style('filter', d => {
if (d.id === selectedNodeId) return `drop-shadow(0 0 16px ${TYPE_COLORS[d.type]}99)`;
if (connectedIds.has(d.id)) return `drop-shadow(0 0 10px ${TYPE_COLORS[d.type]}66)`;
return selectedNodeId ? 'none' : `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`;
});
linkSel
.attr('stroke', (d, i) => {
if (connectedEdges.has(i)) return EDGE_COLORS[d.type] || 'rgba(255,255,255,0.35)';
return selectedNodeId ? 'rgba(255,255,255,0.03)' : (EDGE_COLORS[d.type] || 'rgba(255,255,255,0.07)');
})
.attr('stroke-width', (d, i) => connectedEdges.has(i) ? 1.8 : 1);
labelSel.style('opacity', d => {
if (!selectedNodeId) return 1;
return d.id === selectedNodeId || connectedIds.has(d.id) ? 1 : 0.3;
});
}
function showDetail(id, simNodes, simEdges) {
const node = atlasData.nodes.find(n => n.id === id);
if (!node) return;
document.getElementById('dp-dot').style.background = TYPE_COLORS[node.type] || '#9CA3AF';
document.getElementById('dp-title').textContent = node.label;
document.getElementById('dp-type').textContent = TYPE_LABELS[node.type] || node.type;
document.getElementById('dp-desc').textContent = node.description || '';
const urlEl = document.getElementById('dp-url');
if (node.url) {
urlEl.innerHTML = `<a href="${node.url}" target="_blank" rel="noopener"><i data-lucide="external-link" style="width:12px;height:12px;stroke-width:1.75"></i>${node.url}</a>`;
} else {
urlEl.innerHTML = '';
}
const statusEl = document.getElementById('dp-status');
const s = node.status || 'unknown';
statusEl.innerHTML = `<span class="status-badge ${s}"><span class="status-dot"></span>${s}</span>`;
// Health check button
const healthBtn = document.getElementById('dp-health-btn');
const healthDot = document.getElementById('dp-health-dot');
const healthLabel = document.getElementById('dp-health-label');
if (node.url && !isReadOnly) {
healthBtn.style.display = 'flex';
healthBtn.classList.remove('checking');
healthDot.className = 'health-dot';
healthLabel.textContent = '接続確認';
healthBtn.onclick = () => checkNodeHealth(node.id, node.url);
} else {
healthBtn.style.display = 'none';
}
// Connections
const connList = document.getElementById('dp-conn-list');
const related = atlasData.edges.filter(e => e.from === id || e.to === id);
if (related.length === 0) {
connList.innerHTML = '<div style="font-size:12px;color:var(--text3)">接続なし</div>';
} else {
connList.innerHTML = related.map(e => {
const isOut = e.from === id;
const otherId = isOut ? e.to : e.from;
const other = atlasData.nodes.find(n => n.id === otherId);
const color = TYPE_COLORS[other?.type] || '#9CA3AF';
const delBtn = isReadOnly ? '' :
`<button class="conn-delete-btn" onclick="deleteEdge('${e.from}','${e.to}')" aria-label="接続を削除" title="削除">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>`;
return `<div class="conn-item">
<span class="conn-dot" style="background:${color}"></span>
<span>${other?.label || otherId}</span>
${e.label ? `<span style="font-size:10px;color:var(--text3)">${e.label}</span>` : ''}
<span class="conn-dir" style="margin-left:auto;margin-right:4px">${isOut ? 'out' : 'in'}</span>
${delBtn}
</div>`;
}).join('');
}
// Lucide icons in detail panel
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById('detail-panel')] });
document.getElementById('detail-panel').classList.add('open');
}
function closeDetail() {
selectedNodeId = null;
document.getElementById('detail-panel').classList.remove('open');
if (nodeSel) {
nodeSel.attr('r', 20).attr('stroke-width', 1.5)
.attr('fill', d => TYPE_COLORS[d.type] + '18')
.style('filter', d => `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`);
}
if (linkSel) {
linkSel.attr('stroke', d => EDGE_COLORS[d.type] || 'rgba(255,255,255,0.07)')
.attr('stroke-width', 1);
}
if (labelSel) labelSel.style('opacity', 1);
}
// ── Filter bar ─────────────────────────────────────────────────
function buildFilterBar() {
const bar = document.getElementById('filter-bar');
const types = [...new Set(atlasData.nodes.map(n => n.type))];
bar.innerHTML = types.map(type => {
const count = atlasData.nodes.filter(n => n.type === type).length;
const active = !hiddenTypes.has(type) ? 'active' : '';
const color = TYPE_COLORS[type] || '#9CA3AF';
return `<button class="filter-chip ${active}" data-type="${type}" style="--chip-color:${color}">
<span class="chip-dot"></span>
${TYPE_LABELS[type] || type}
<span class="chip-count">${count}</span>
</button>`;
}).join('');
bar.querySelectorAll('.filter-chip').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.type;
if (hiddenTypes.has(t)) hiddenTypes.delete(t);
else hiddenTypes.add(t);
btn.classList.toggle('active');
closeDetail();
initGraph();
setTimeout(fitGraph, 500);
});
});
}
function updateNodeCount() {
const visible = visibleNodes().length;
const total = atlasData.nodes.length;
document.getElementById('node-count').textContent = visible === total
? `${total} nodes`
: `${visible}/${total} nodes`;
}
// ── AI Context generator ───────────────────────────────────────
function generateAIContext() {
const { meta, nodes, edges } = atlasData;
const typeSummary = {};
nodes.forEach(n => {
typeSummary[n.type] = (typeSummary[n.type] || 0) + 1;
});
const lines = [
`# インフラ構成 — ${meta.description}`,
`更新日: ${meta.updated}`,
'',
'## ノード一覧',
'| 名前 | タイプ | 説明 | URL | ステータス |',
'|------|--------|------|-----|----------|',
...nodes.map(n =>
`| ${n.label} | ${TYPE_LABELS[n.type] || n.type} | ${n.description || '—'} | ${n.url || '—'} | ${n.status || '—'} |`
),
'',
'## 接続関係',
...edges.map(e => {
const from = nodes.find(n => n.id === e.from)?.label || e.from;
const to = nodes.find(n => n.id === e.to)?.label || e.to;
return `- ${from} → ${to}${e.label ? ` (${e.label})` : ''}${e.type !== 'connects' ? ` [${e.type}]` : ''}`;
}),
'',
'## 統計',
`- 総ノード数: ${nodes.length}`,
`- 総接続数: ${edges.length}`,
...Object.entries(typeSummary).map(([t, c]) => `- ${TYPE_LABELS[t] || t}: ${c}`),
];
return lines.join('\n');
}
// ── Node CRUD ──────────────────────────────────────────────────
function openAddModal() {
document.getElementById('modal-title').textContent = 'ノードを追加';
document.getElementById('f-node-id').value = '';
document.getElementById('f-label').value = '';
document.getElementById('f-type').value = 'device';
document.getElementById('f-desc').value = '';
document.getElementById('f-url').value = '';
document.getElementById('f-status').value = 'active';
openModal('modal-overlay');
}
function openEditModal(id) {
const node = atlasData.nodes.find(n => n.id === id);
if (!node) return;
document.getElementById('modal-title').textContent = 'ノードを編集';
document.getElementById('f-node-id').value = node.id;
document.getElementById('f-label').value = node.label;
document.getElementById('f-type').value = node.type;
document.getElementById('f-desc').value = node.description || '';
document.getElementById('f-url').value = node.url || '';
document.getElementById('f-status').value = node.status || 'active';
openModal('modal-overlay');
}
function saveNode() {
const id = document.getElementById('f-node-id').value;
const label = document.getElementById('f-label').value.trim();
if (!label) return;
const node = {
id: id || label.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''),
label,
type: document.getElementById('f-type').value,
description: document.getElementById('f-desc').value.trim(),
url: document.getElementById('f-url').value.trim() || undefined,
status: document.getElementById('f-status').value,
};
if (id) {
const idx = atlasData.nodes.findIndex(n => n.id === id);
if (idx >= 0) atlasData.nodes[idx] = node;
} else {
// Ensure unique ID
let base = node.id, suffix = 2;
while (atlasData.nodes.find(n => n.id === node.id)) {
node.id = `${base}-${suffix++}`;
}
atlasData.nodes.push(node);
}
saveData();
closeModal('modal-overlay');
closeDetail();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
showToast(id ? '更新しました' : '追加しました');
}
function deleteNode(id) {
if (!confirm(`「${atlasData.nodes.find(n => n.id === id)?.label}」を削除しますか?`)) return;
atlasData.nodes = atlasData.nodes.filter(n => n.id !== id);
atlasData.edges = atlasData.edges.filter(e => e.from !== id && e.to !== id);
saveData();
closeDetail();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
showToast('削除しました');
}
// ── Edge CRUD ──────────────────────────────────────────────────
function openAddEdgeModal(fromId) {
const sel = (id, val) => {
const el = document.getElementById(id);
el.innerHTML = atlasData.nodes
.map(n => `<option value="${n.id}" ${n.id === val ? 'selected' : ''}>${n.label}</option>`)
.join('');
};
sel('ef-from', fromId);
sel('ef-to', '');
document.getElementById('ef-type').value = 'connects';
document.getElementById('ef-label').value = '';
openModal('edge-modal-overlay');
}
function saveEdge() {
const from = document.getElementById('ef-from').value;
const to = document.getElementById('ef-to').value;
if (!from || !to || from === to) { showToast('接続元と接続先を選択してください'); return; }
atlasData.edges.push({
from, to,
type: document.getElementById('ef-type').value,
label: document.getElementById('ef-label').value.trim() || undefined,
});
saveData();
closeModal('edge-modal-overlay');
initGraph();
showToast('接続を追加しました');
}
// ── Modal helpers ──────────────────────────────────────────────
function openModal(id) {
document.getElementById(id).classList.add('open');
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById(id)] });
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
}
// ── Toast ──────────────────────────────────────────────────────
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2200);
}
// ── Export ─────────────────────────────────────────────────────
function exportJson() {
const blob = new Blob([JSON.stringify(atlasData, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'atlas.json';
a.click();
URL.revokeObjectURL(a.href);
}
// ── Periodic health monitoring ─────────────────────────────────
const MONITOR_KEY = 'posimai-atlas-monitor-interval'; // value: minutes (0=off)
let monitorTimer = null;
function loadMonitorPref() {
return parseInt(localStorage.getItem(MONITOR_KEY) || '5', 10);
}
function saveMonitorPref(minutes) {
localStorage.setItem(MONITOR_KEY, String(minutes));
}
function setMonitorDot(state) { // 'off' | 'active' | 'checking'
const dot = document.getElementById('monitor-dot');
if (!dot) return;
dot.className = state === 'off' ? '' : state;
dot.title = state === 'off' ? '監視オフ' : state === 'checking' ? '確認中...' : `${loadMonitorPref()}分ごとに監視中`;
}
async function runHealthCheckAll() {
const targets = atlasData.nodes.filter(n => n.url);
if (targets.length === 0) return;
setMonitorDot('checking');
let changed = false;
for (const node of targets) {
const prev = node.status;
const result = await checkUrlHealth(node.url);
const next = result === 'offline' ? 'inactive' : 'active';
if (next !== prev) {
node.status = next;
changed = true;
if (next === 'inactive') {
showToast(`${node.label} が応答していません`);
}
}
}
if (changed) {
saveData();
// Refresh node visuals (update opacity for inactive nodes)
updateNodeStatusVisuals();
// Refresh detail panel if open
if (selectedNodeId) showDetail(selectedNodeId, currentSimNodes, currentSimEdges);
}
const now = new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
const el = document.getElementById('monitor-last-checked');
if (el) el.textContent = `最終確認: ${now}`;
setMonitorDot('active');
}
async function checkUrlHealth(url) {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 7000);
const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal });
clearTimeout(timer);
return (res.type === 'opaque' || res.ok) ? 'online' : 'offline';
} catch (e) {
return 'offline';
}
}
function updateNodeStatusVisuals() {
if (!nodeSel) return;
nodeSel
.attr('fill', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
const alpha = node?.status === 'inactive' ? '0A' : '18';
return TYPE_COLORS[d.type] + alpha;
})
.style('filter', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
if (node?.status === 'inactive') return 'none';
return `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`;
})
.style('opacity', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
return node?.status === 'inactive' ? 0.45 : 1;
});
if (labelSel) {
labelSel.style('opacity', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
return node?.status === 'inactive' ? 0.45 : 1;
});
}
}
function startMonitoring(minutes) {
if (monitorTimer) { clearInterval(monitorTimer); monitorTimer = null; }
if (!minutes || minutes <= 0) { setMonitorDot('off'); return; }
saveMonitorPref(minutes);
runHealthCheckAll(); // immediate
monitorTimer = setInterval(runHealthCheckAll, minutes * 60 * 1000);
setMonitorDot('active');
}
function stopMonitoring() {
if (monitorTimer) { clearInterval(monitorTimer); monitorTimer = null; }
setMonitorDot('off');
}
function applyMonitorSetting(minutes) {
const sel = document.getElementById('monitor-interval-sel');
if (sel) sel.value = String(minutes);
if (minutes > 0) startMonitoring(minutes);
else stopMonitoring();
}
// ── 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;
}
}
// ── GitHub scan ────────────────────────────────────────────────
async function runGithubScan() {
const token = document.getElementById('github-token-input').value.trim();
const org = document.getElementById('github-org-input').value.trim();
const statusEl = document.getElementById('github-scan-status');
const btn = document.getElementById('btnGithubScan');
if (!token) { showToast('GitHub トークンを入力してください'); return; }
statusEl.className = 'scan-status visible';
statusEl.textContent = 'スキャン中...';
btn.disabled = true;
try {
const apiBase = 'https://api.soar-enrich.com/brain/api';
const url = `${apiBase}/atlas/github-scan?token=${encodeURIComponent(token)}${org ? '&org=' + encodeURIComponent(org) : ''}`;
const res = await fetch(url);
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
const raw = await res.json();
const repos = Array.isArray(raw) ? raw : (raw.repos || []);
const isFallback = !Array.isArray(raw) && raw.fallback;
let added = 0;
// Add GitHub org/user as cloud node if not present
const githubId = 'github';
repos.forEach(repo => {
const nodeId = `gh-${repo.name}`;
if (atlasData.nodes.find(n => n.id === nodeId)) return;
atlasData.nodes.push({
id: nodeId,
label: repo.name,
type: 'app',
description: repo.description || `GitHub リポジトリ: ${repo.full_name}`,
url: repo.html_url,
status: repo.archived ? 'inactive' : 'active',
parent: githubId,
});
// edge: github → vercel if homepage looks like vercel
if (repo.homepage && repo.homepage.includes('vercel.app')) {
const vercelId = 'vercel';
if (!atlasData.edges.find(e => e.from === githubId && e.to === vercelId)) {
// already exists globally, skip per-repo
}
atlasData.edges.push({ from: nodeId, to: vercelId, type: 'hosts', label: 'deploy' });
}
added++;
});
saveData();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
statusEl.className = 'scan-status visible ok';
const fallbackNote = isFallback ? ' (ユーザーリポジトリにフォールバック)' : '';
statusEl.textContent = `${repos.length} リポジトリ検出 — ${added} 件追加${fallbackNote}`;
showToast(`GitHub: ${added} リポジトリを追加しました${fallbackNote}`);
} catch (e) {
statusEl.className = 'scan-status visible err';
statusEl.textContent = `エラー: ${e.message}`;
} finally {
btn.disabled = false;
}
}
// ── Vercel scan ─────────────────────────────────────────────────
async function runVercelScan() {
const token = document.getElementById('vercel-token-input').value.trim();
const statusEl = document.getElementById('vercel-scan-status');
const btn = document.getElementById('btnVercelScan');
if (!token) { showToast('Vercel トークンを入力してください'); 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/vercel-scan?token=${encodeURIComponent(token)}`);
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
const data = await res.json();
const projects = data.projects || [];
let added = 0;
projects.forEach(proj => {
const nodeId = `vercel-${proj.name}`;
if (atlasData.nodes.find(n => n.id === nodeId)) return;
const prodUrl = proj.targets?.production?.alias?.[0]
? `https://${proj.targets.production.alias[0]}`
: null;
atlasData.nodes.push({
id: nodeId,
label: proj.name,
type: 'app',
description: `Vercel プロジェクト: ${proj.framework || '静的'}`,
url: prodUrl,
status: 'active',
parent: 'vercel',
});
added++;
});
saveData();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
statusEl.className = 'scan-status visible ok';
statusEl.textContent = `${projects.length} プロジェクト検出 — ${added} 件追加`;
showToast(`Vercel: ${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);
document.getElementById('btn-add-node').addEventListener('click', openAddModal);
document.getElementById('btn-share').addEventListener('click', () => {
const url = generateShareURL();
navigator.clipboard.writeText(url)
.then(() => showToast('シェア URL をコピーしました'))
.catch(() => {
// Fallback: prompt
prompt('シェア URL をコピーしてください', url);
});
});
document.getElementById('share-banner-own-btn').addEventListener('click', () => {
history.replaceState(null, '', location.pathname);
location.reload();
});
document.getElementById('btn-ai').addEventListener('click', () => {
document.getElementById('ai-context-text').textContent = generateAIContext();
openModal('ai-modal-overlay');
});
document.getElementById('detail-close-btn').addEventListener('click', closeDetail);
document.getElementById('dp-edit-btn').addEventListener('click', () => openEditModal(selectedNodeId));
document.getElementById('dp-add-edge-btn').addEventListener('click', () => openAddEdgeModal(selectedNodeId));
document.getElementById('dp-delete-btn').addEventListener('click', () => deleteNode(selectedNodeId));
document.getElementById('modal-close-btn').addEventListener('click', () => closeModal('modal-overlay'));
document.getElementById('modal-cancel-btn').addEventListener('click', () => closeModal('modal-overlay'));
document.getElementById('modal-save-btn').addEventListener('click', saveNode);
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal('modal-overlay');
});
document.getElementById('edge-modal-close-btn').addEventListener('click', () => closeModal('edge-modal-overlay'));
document.getElementById('edge-modal-cancel-btn').addEventListener('click', () => closeModal('edge-modal-overlay'));
document.getElementById('edge-modal-save-btn').addEventListener('click', saveEdge);
document.getElementById('edge-modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal('edge-modal-overlay');
});
document.getElementById('ai-modal-close-btn').addEventListener('click', () => closeModal('ai-modal-overlay'));
document.getElementById('ai-modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal('ai-modal-overlay');
});
document.getElementById('ai-copy-btn').addEventListener('click', () => {
navigator.clipboard.writeText(document.getElementById('ai-context-text').textContent)
.then(() => showToast('クリップボードにコピーしました'));
});
document.getElementById('btnImportJson').addEventListener('click', () => {
document.getElementById('fileInput').click();
});
document.getElementById('btnExportJson').addEventListener('click', () => {
exportJson();
showToast('エクスポートしました');
});
document.getElementById('btnResetData').addEventListener('click', () => {
if (!confirm('サンプルデータに戻しますか?現在のデータは失われます。')) return;
localStorage.removeItem(STORAGE_KEY);
location.reload();
});
document.getElementById('btnTailscaleScan').addEventListener('click', runTailscaleScan);
document.getElementById('tailscale-token-input').addEventListener('keydown', e => {
if (e.key === 'Enter') runTailscaleScan();
});
document.getElementById('monitor-interval-sel').addEventListener('change', e => {
applyMonitorSetting(parseInt(e.target.value, 10));
});
document.getElementById('btnGithubScan').addEventListener('click', runGithubScan);
document.getElementById('btnVercelScan').addEventListener('click', runVercelScan);
// File input (wizard + settings import)
document.getElementById('fileInput').addEventListener('change', e => {
const isWizard = !document.getElementById('wizard-overlay').hasAttribute('hidden');
handleImportFile(e.target.files[0], isWizard);
});
// Wizard buttons
document.getElementById('w-sample').addEventListener('click', async () => {
const res = await fetch('/atlas.json');
const data = await res.json();
finishWizard(data);
});
document.getElementById('w-empty').addEventListener('click', () => {
finishWizard({
meta: { owner: '', description: 'My infrastructure', updated: new Date().toISOString().slice(0, 10), version: '1' },
nodes: [],
edges: [],
});
});
document.getElementById('w-import').addEventListener('click', () => {
document.getElementById('fileInput').click();
});
// Legend toggle
document.getElementById('btn-legend').addEventListener('click', () => {
document.getElementById('edge-legend').classList.toggle('open');
});
document.addEventListener('click', e => {
if (!e.target.closest('#edge-legend') && !e.target.closest('#btn-legend')) {
document.getElementById('edge-legend').classList.remove('open');
}
});
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if (e.target.matches('input, textarea, select')) return;
if (e.key === 'Escape') {
if (document.getElementById('modal-overlay').classList.contains('open')) { closeModal('modal-overlay'); return; }
if (document.getElementById('edge-modal-overlay').classList.contains('open')) { closeModal('edge-modal-overlay'); return; }
if (document.getElementById('ai-modal-overlay').classList.contains('open')) { closeModal('ai-modal-overlay'); return; }
closeDetail();
}
if (e.key === 'f' || e.key === 'F') fitGraph();
});
window.addEventListener('resize', () => {
if (simulation) {
const svg = d3.select('#graph-svg');
simulation.force('center', d3.forceCenter(
svg.node().clientWidth / 2,
svg.node().clientHeight / 2
));
simulation.alpha(0.1).restart();
}
});
}
// ── SW registration ────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// ── Health check ───────────────────────────────────────────────
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
async function checkNodeHealth(nodeId, url) {
const btn = document.getElementById('dp-health-btn');
const dot = document.getElementById('dp-health-dot');
const label = document.getElementById('dp-health-label');
btn.classList.add('checking');
label.textContent = '確認中...';
dot.className = 'health-dot';
// メトリクスパネルを非表示にリセット
const metricsEl = document.getElementById('dp-metrics');
metricsEl.classList.remove('visible');
const node = atlasData.nodes.find(n => n.id === nodeId);
const healthUrl = (node && node.health_url) ? node.health_url : null;
let result = 'offline';
// /api/health があればメトリクス取得を試みる
if (healthUrl) {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 6000);
const res = await fetch(healthUrl, { signal: ctrl.signal });
clearTimeout(timer);
if (res.ok) {
const data = await res.json();
result = data.ok ? 'online' : 'limited';
// メトリクス描画
const cpuPct = data.cpu_pct || 0;
const memPct = Math.round((data.mem_used_mb / data.mem_total_mb) * 100) || 0;
document.getElementById('metric-cpu-val').textContent = `${cpuPct}%`;
const cpuBar = document.getElementById('metric-cpu-bar');
cpuBar.style.width = `${cpuPct}%`;
cpuBar.className = 'metric-bar-fill' + (cpuPct > 80 ? ' crit' : cpuPct > 60 ? ' warn' : '');
document.getElementById('metric-mem-val').textContent =
`${data.mem_used_mb} / ${data.mem_total_mb} MB (${memPct}%)`;
const memBar = document.getElementById('metric-mem-bar');
memBar.style.width = `${memPct}%`;
memBar.className = 'metric-bar-fill' + (memPct > 85 ? ' crit' : memPct > 65 ? ' warn' : '');
document.getElementById('metric-uptime').textContent =
formatUptime(data.uptime_s || 0);
document.getElementById('metric-sessions').textContent =
`${data.active_sessions || 0} 本`;
if (node.url) {
const openLink = document.getElementById('metric-open-link');
openLink.href = node.url;
}
metricsEl.classList.add('visible');
if (window.lucide) lucide.createIcons({ nodes: [metricsEl] });
} else {
result = 'offline';
}
} catch (e) {
result = 'offline';
}
} else {
// /api/health なし → 従来の HEAD リクエスト
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 6000);
const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal });
clearTimeout(timer);
result = res.type === 'opaque' ? 'limited' : (res.ok ? 'online' : 'offline');
} catch (e) {
result = 'offline';
}
}
const labelMap = { online: 'online', limited: '到達可能', offline: '到達不可' };
dot.className = `health-dot ${result}`;
label.textContent = labelMap[result];
btn.classList.remove('checking');
if (node) {
node.status = result === 'offline' ? 'inactive' : 'active';
saveData();
document.getElementById('dp-status').innerHTML =
`<span class="status-badge ${node.status}"><span class="status-dot"></span>${node.status}</span>`;
}
}
// ── URL Sharing ────────────────────────────────────────────────
function generateShareURL() {
const json = JSON.stringify(atlasData);
const b64 = btoa(unescape(encodeURIComponent(json)));
return `${location.origin}${location.pathname}#atlas=${b64}`;
}
function checkShareURL() {
const hash = location.hash;
if (!hash.startsWith('#atlas=')) return false;
try {
const b64 = hash.slice(7);
const json = decodeURIComponent(escape(atob(b64)));
const data = JSON.parse(json);
if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) return false;
atlasData = data;
isReadOnly = true;
return true;
} catch (e) {
return false;
}
}
function applyReadOnly() {
const banner = document.getElementById('share-banner');
banner.removeAttribute('hidden');
document.body.classList.add('has-banner');
if (window.lucide) lucide.createIcons({ nodes: [banner] });
document.getElementById('btn-add-node').style.display = 'none';
}
// ── Edge delete ────────────────────────────────────────────────
function deleteEdge(from, to) {
if (isReadOnly) return;
atlasData.edges = atlasData.edges.filter(e => !(e.from === from && e.to === to));
saveData();
const keepId = selectedNodeId;
initGraph();
if (keepId) {
selectedNodeId = keepId;
updateHighlight(currentSimNodes, currentSimEdges);
showDetail(keepId, currentSimNodes, currentSimEdges);
}
showToast('接続を削除しました');
}
// ── Wizard ─────────────────────────────────────────────────────
function showWizard() {
const overlay = document.getElementById('wizard-overlay');
overlay.removeAttribute('hidden');
if (window.lucide) lucide.createIcons({ nodes: [overlay] });
}
function finishWizard(data) {
atlasData = data;
saveData();
document.getElementById('wizard-overlay').setAttribute('hidden', '');
buildFilterBar();
initGraph();
setTimeout(fitGraph, 800);
if (atlasData.nodes.length === 0) {
setTimeout(openAddModal, 700);
}
}
// ── File import (shared: wizard + settings) ────────────────────
function handleImportFile(file, isWizard) {
if (!file) return;
const reader = new FileReader();
reader.onload = evt => {
try {
const data = JSON.parse(evt.target.result);
if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) throw new Error();
if (isWizard) {
finishWizard(data);
} else {
atlasData = data;
saveData();
closeDetail();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
showToast('インポートしました');
}
} catch (e) {
showToast('atlas.json の形式が正しくありません');
}
document.getElementById('fileInput').value = '';
};
reader.readAsText(file);
}
// ── Init ───────────────────────────────────────────────────────
loadData().then(() => {
bindEvents();
// 1. Check for shared atlas in URL hash (read-only mode)
if (checkShareURL()) {
buildFilterBar();
initGraph();
applyReadOnly();
setTimeout(fitGraph, 800);
return;
}
// 2. Normal flow
if (!atlasData) {
showWizard();
} else {
buildFilterBar();
initGraph();
setTimeout(fitGraph, 800);
// Restore monitoring preference
const savedMinutes = loadMonitorPref();
if (savedMinutes > 0) {
// Delay start so graph is ready
setTimeout(() => applyMonitorSetting(savedMinutes), 1200);
}
}
});
</script>
</body>
</html>