posimai-atlas/index.html

2985 lines
117 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
--surface: #111827;
--surface2: #1A2235;
--border: rgba(255,255,255,0.07);
}
[data-theme="light"] {
--accent: #0891B2;
--accent-dim: rgba(8, 145, 178, 0.1);
--bg: #EFF6FF;
--surface: rgba(255,255,255,0.85);
--surface2: rgba(255,255,255,0.6);
--border: rgba(0,0,0,0.07);
}
/* ── 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;
}
.tb-btn.active {
background: var(--accent-dim);
border-color: rgba(34,211,238,0.35);
color: var(--accent);
}
/* ── List view ─────────────────────────────────────────── */
#list-view {
display: none;
position: fixed;
inset: 52px 0 0 0;
overflow-y: auto;
z-index: 5;
padding: 20px 24px;
}
#list-view.visible { display: block; }
.list-group {
margin-bottom: 24px;
}
.list-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.list-group-dot {
width: 8px; height: 8px;
border-radius: 50%;
}
.list-group-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
}
.list-group-count {
font-size: 11px;
color: var(--text3);
margin-left: auto;
}
.list-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.list-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
position: relative;
overflow: hidden;
}
/* top glow line — color set inline via --card-color */
.list-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: var(--card-color, transparent);
opacity: 0;
transition: opacity 0.2s;
}
.list-card:hover {
background: var(--surface2);
border-color: rgba(255,255,255,0.12);
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
}
.list-card:hover::after {
opacity: 0.7;
}
.list-card-top {
display: flex;
align-items: center;
gap: 8px;
}
.list-card-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
flex: 1;
}
.list-card-status {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.list-card-status.active { background: var(--ok); box-shadow: 0 0 5px var(--ok); }
.list-card-status.inactive { background: var(--crit); }
.list-card-status.unknown { background: var(--text3); }
.list-card-desc {
font-size: 11px;
color: var(--text3);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.list-card-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.list-card-url {
font-size: 10px;
color: var(--accent);
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
flex: 1;
}
.list-card-url:hover { opacity: 1; }
.list-card-conns {
font-size: 10px;
color: var(--text3);
white-space: nowrap;
}
/* ── 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>
<!-- List view container -->
<div id="list-view" role="main" aria-label="ノード一覧"></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>
<button class="tb-btn" id="btn-mermaid" aria-label="Mermaid としてコピー" title="Mermaid export">
<i data-lucide="workflow" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
<div class="tb-divider"></div>
<button class="tb-btn" id="btn-view-graph" aria-label="グラフビュー" title="グラフビュー">
<i data-lucide="network" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
<button class="tb-btn" id="btn-view-list" aria-label="リストビュー" title="リストビュー">
<i data-lucide="layout-list" style="width:15px;height:15px;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 = [];
// ── Health metrics cache: nodeId → { cpu_pct, mem_pct, status } ──
const nodeHealthCache = new Map();
const VPS_API = 'https://api.soar-enrich.com/brain/api';
const VPS_GET = VPS_API + '/site/config/public?user=maita';
const VPS_SET = VPS_API + '/site/config/atlas_data';
const APIKEY_KEY = 'posimai_api_key';
async function loadData() {
// 1. VPS から取得を試みる(クラウドが正)
try {
const res = await fetch(VPS_GET, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
const data = await res.json();
const cloud = data.config?.atlas_data;
if (cloud && cloud.nodes) {
atlasData = cloud;
localStorage.setItem(STORAGE_KEY, JSON.stringify(atlasData));
return;
}
}
} catch { /* fallthrough to local cache */ }
// 2. ローカルキャッシュ
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try { atlasData = JSON.parse(saved); return; } catch (e) {}
}
// 3. No saved data — wizard will handle initialization
}
let _saveTimer = null;
function saveData() {
atlasData.meta.updated = new Date().toISOString().slice(0, 10);
const payload = JSON.stringify(atlasData);
localStorage.setItem(STORAGE_KEY, payload);
// VPS に debounce 保存3s
clearTimeout(_saveTimer);
_saveTimer = setTimeout(async () => {
const key = localStorage.getItem(APIKEY_KEY);
if (!key) return;
try {
await fetch(VPS_SET, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key },
body: JSON.stringify({ value: atlasData }),
signal: AbortSignal.timeout(6000)
});
} catch { /* silent — local is still saved */ }
}, 3000);
}
// ── 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 || n.health_url);
if (targets.length === 0) return;
setMonitorDot('checking');
let changed = false;
for (const node of targets) {
const prev = node.status;
let next = prev;
if (node.health_url) {
// Rich health check — get metrics too
if (location.protocol === 'https:' && node.health_url.startsWith('http:')) continue;
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 7000);
const res = await fetch(node.health_url, { signal: ctrl.signal });
clearTimeout(timer);
if (res.ok) {
const data = await res.json();
next = data.ok ? 'active' : 'inactive';
const cpuPct = data.cpu_pct || 0;
const memPct = data.mem_total_mb
? Math.round((data.mem_used_mb / data.mem_total_mb) * 100) : 0;
const health = cpuPct > 80 || memPct > 85 ? 'crit'
: cpuPct > 60 || memPct > 65 ? 'warn' : 'ok';
nodeHealthCache.set(node.id, { cpu_pct: cpuPct, mem_pct: memPct, health });
} else {
next = 'inactive';
nodeHealthCache.delete(node.id);
}
} catch (e) {
next = 'inactive';
nodeHealthCache.delete(node.id);
}
} else {
const result = await checkUrlHealth(node.url);
// 'skipped' = HTTP endpoint from HTTPS context — leave status unchanged
if (result !== 'skipped') {
next = result === 'offline' ? 'inactive' : 'active';
}
nodeHealthCache.delete(node.id);
}
if (next !== prev) {
node.status = next;
changed = true;
if (next === 'inactive') showToast(`${node.label} が応答していません`);
}
}
// Always refresh visuals to reflect updated health cache colors
updateNodeStatusVisuals();
if (changed) {
saveData();
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) {
// HTTPS ページから HTTP エンドポイントへのリクエストは混合コンテンツとしてブロックされる
// → ブラウザが「保護されていない通信」警告を出すため、スキップして 'skipped' を返す
if (location.protocol === 'https:' && url.startsWith('http:')) return 'skipped';
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 nodeHealthColor(nodeId) {
const h = nodeHealthCache.get(nodeId);
if (!h) return null;
if (h.health === 'crit') return '#F87171';
if (h.health === 'warn') return '#FB923C';
return null; // ok — use type color
}
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';
const baseColor = nodeHealthColor(d.id) || TYPE_COLORS[d.type];
return baseColor + alpha;
})
.attr('stroke', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
if (node?.status === 'inactive') return TYPE_COLORS[d.type];
return nodeHealthColor(d.id) || TYPE_COLORS[d.type];
})
.style('filter', d => {
const node = atlasData.nodes.find(n => n.id === d.id);
if (node?.status === 'inactive') return 'none';
const glowColor = nodeHealthColor(d.id) || TYPE_COLORS[d.type];
return `drop-shadow(0 0 8px ${glowColor}88)`;
})
.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;
}
}
// ── List view ──────────────────────────────────────────────────
let currentView = 'graph'; // 'graph' | 'list'
function renderListView() {
const el = document.getElementById('list-view');
const groups = {};
for (const node of atlasData.nodes) {
if (!groups[node.type]) groups[node.type] = [];
groups[node.type].push(node);
}
const typeOrder = ['device','server','service','app','cloud','network'];
const html = typeOrder
.filter(t => groups[t]?.length)
.map(type => {
const nodes = groups[type];
const color = TYPE_COLORS[type] || '#9CA3AF';
const label = TYPE_LABELS[type] || type;
const cards = nodes.map(node => {
const connCount = atlasData.edges.filter(
e => e.from === node.id || e.to === node.id
).length;
const statusCls = node.status === 'active' ? 'active'
: node.status === 'inactive' ? 'inactive' : 'unknown';
const urlDisplay = node.url
? node.url.replace(/^https?:\/\//, '').replace(/\/$/, '')
: '';
return `
<div class="list-card" style="--card-color:${color}"
onclick="switchToGraph('${node.id}')">
<div class="list-card-top">
<div class="list-card-status ${statusCls}"></div>
<span class="list-card-name">${node.label}</span>
</div>
${node.description
? `<div class="list-card-desc">${node.description}</div>` : ''}
<div class="list-card-footer">
${urlDisplay
? `<a class="list-card-url" href="${node.url}" target="_blank"
rel="noopener" onclick="event.stopPropagation()">${urlDisplay}</a>`
: '<span></span>'}
<span class="list-card-conns">${connCount} 接続</span>
</div>
</div>`;
}).join('');
return `
<div class="list-group">
<div class="list-group-header">
<div class="list-group-dot" style="background:${color}"></div>
<span class="list-group-label">${label}</span>
<span class="list-group-count">${nodes.length}</span>
</div>
<div class="list-cards">${cards}</div>
</div>`;
}).join('');
el.innerHTML = html;
}
function switchToGraph(nodeId) {
setView('graph');
if (nodeId) {
setTimeout(() => {
if (currentSimNodes.length) {
showDetail(nodeId, currentSimNodes, currentSimEdges);
}
}, 100);
}
}
function setView(view) {
currentView = view;
const graphWrap = document.getElementById('graph-wrap');
const listEl = document.getElementById('list-view');
const btnGraph = document.getElementById('btn-view-graph');
const btnList = document.getElementById('btn-view-list');
if (view === 'list') {
renderListView();
listEl.classList.add('visible');
graphWrap.style.display = 'none';
btnList.classList.add('active');
btnGraph.classList.remove('active');
} else {
listEl.classList.remove('visible');
graphWrap.style.display = '';
btnGraph.classList.add('active');
btnList.classList.remove('active');
}
}
// ── Mermaid export ─────────────────────────────────────────────
function generateMermaid() {
const lines = ['flowchart LR'];
// Node definitions
for (const node of atlasData.nodes) {
const safe = node.id.replace(/[^a-zA-Z0-9_]/g, '_');
const label = node.label.replace(/"/g, "'");
lines.push(` ${safe}["${label}"]`);
}
lines.push('');
// Edges
for (const edge of atlasData.edges) {
const from = edge.from.replace(/[^a-zA-Z0-9_]/g, '_');
const to = edge.to.replace(/[^a-zA-Z0-9_]/g, '_');
const lbl = edge.label ? ` |${edge.label}|` : '';
lines.push(` ${from} -->${lbl} ${to}`);
}
return lines.join('\n');
}
// ── Event bindings ─────────────────────────────────────────────
function bindEvents() {
document.getElementById('btn-fit').addEventListener('click', fitGraph);
document.getElementById('btn-add-node').addEventListener('click', openAddModal);
document.getElementById('btn-view-graph').addEventListener('click', () => setView('graph'));
document.getElementById('btn-view-list').addEventListener('click', () => setView('list'));
document.getElementById('btn-mermaid').addEventListener('click', () => {
const text = generateMermaid();
navigator.clipboard.writeText(text)
.then(() => showToast('Mermaid をクリップボードにコピーしました'))
.catch(() => prompt('Mermaid をコピーしてください', text));
});
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();
});
// ── API キーの永続化 ──────────────────────────────────────
const TOKEN_KEYS = {
'tailscale-token-input': 'posimai-atlas-token-tailscale',
'github-token-input': 'posimai-atlas-token-github',
'github-org-input': 'posimai-atlas-token-github-org',
'vercel-token-input': 'posimai-atlas-token-vercel',
};
// ページロード時に復元
Object.entries(TOKEN_KEYS).forEach(([id, key]) => {
const val = localStorage.getItem(key);
if (val) document.getElementById(id).value = val;
});
// 入力のたびに保存
Object.entries(TOKEN_KEYS).forEach(([id, key]) => {
document.getElementById(id).addEventListener('input', e => {
if (e.target.value) localStorage.setItem(key, e.target.value);
else localStorage.removeItem(key);
});
});
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 リクエスト
// HTTP endpoint from HTTPS context はブロックされるのでスキップ
if (location.protocol === 'https:' && url && url.startsWith('http:')) {
result = 'limited';
} else {
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();
document.getElementById('btn-view-graph').classList.add('active');
// 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>