2026-03-29 03:21:18 +00:00
|
|
|
|
<!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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:36:42 +00:00
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:45:08 +00:00
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 08:06:44 +00:00
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 08:14:09 +00:00
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
2026-03-29 09:09:32 +00:00
|
|
|
|
|
|
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 22:48:10 +00:00
|
|
|
|
/* ── 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; }
|
|
|
|
|
|
|
2026-03-29 09:09:32 +00:00
|
|
|
|
/* ── 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; }
|
2026-03-29 09:51:38 +00:00
|
|
|
|
|
|
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
2026-03-29 03:21:18 +00:00
|
|
|
|
</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">
|
2026-03-29 03:45:08 +00:00
|
|
|
|
<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>
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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>
|
2026-03-29 03:45:08 +00:00
|
|
|
|
サンプルデータに戻す
|
2026-03-29 03:21:18 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-29 09:51:38 +00:00
|
|
|
|
<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>
|
2026-03-29 09:09:32 +00:00
|
|
|
|
<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>
|
2026-03-29 13:59:12 +00:00
|
|
|
|
<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>
|
2026-03-29 03:21:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
<div class="overlay" id="overlay" aria-hidden="true"></div>
|
|
|
|
|
|
|
2026-03-29 08:06:44 +00:00
|
|
|
|
<!-- 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>
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<!-- 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>
|
2026-03-29 09:51:38 +00:00
|
|
|
|
<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>
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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>
|
2026-03-29 08:14:09 +00:00
|
|
|
|
<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>
|
2026-03-30 22:48:10 +00:00
|
|
|
|
<!-- メトリクス (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>
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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">
|
2026-03-29 03:36:42 +00:00
|
|
|
|
<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">
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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>
|
2026-03-29 08:06:44 +00:00
|
|
|
|
<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>
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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>
|
2026-03-29 08:14:09 +00:00
|
|
|
|
<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>
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-29 08:14:09 +00:00
|
|
|
|
<!-- 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>
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<!-- Edge label tooltip -->
|
|
|
|
|
|
<div id="edge-tooltip"></div>
|
|
|
|
|
|
|
2026-03-29 03:45:08 +00:00
|
|
|
|
<!-- 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">
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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;
|
2026-03-29 08:06:44 +00:00
|
|
|
|
let isReadOnly = false;
|
|
|
|
|
|
let currentSimNodes = [];
|
|
|
|
|
|
let currentSimEdges = [];
|
2026-03-29 03:21:18 +00:00
|
|
|
|
|
|
|
|
|
|
async function loadData() {
|
|
|
|
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
|
|
|
|
if (saved) {
|
|
|
|
|
|
try { atlasData = JSON.parse(saved); return; } catch (e) {}
|
|
|
|
|
|
}
|
2026-03-29 03:45:08 +00:00
|
|
|
|
// No saved data — wizard will handle initialization
|
2026-03-29 03:21:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveData() {
|
|
|
|
|
|
atlasData.meta.updated = new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(atlasData));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Graph rendering ────────────────────────────────────────────
|
|
|
|
|
|
let linkSel, nodeSel, labelSel;
|
|
|
|
|
|
|
2026-03-29 09:09:32 +00:00
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
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 }));
|
2026-03-29 08:06:44 +00:00
|
|
|
|
currentSimNodes = simNodes;
|
2026-03-29 03:21:18 +00:00
|
|
|
|
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,
|
|
|
|
|
|
}));
|
2026-03-29 08:06:44 +00:00
|
|
|
|
currentSimEdges = simEdges;
|
2026-03-29 03:21:18 +00:00
|
|
|
|
|
2026-03-29 09:09:32 +00:00
|
|
|
|
// ── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
// 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)
|
2026-03-29 09:51:38 +00:00
|
|
|
|
.attr('fill', d => {
|
|
|
|
|
|
const node = atlasData.nodes.find(n => n.id === d.id);
|
|
|
|
|
|
return TYPE_COLORS[d.type] + (node?.status === 'inactive' ? '0A' : '18');
|
|
|
|
|
|
})
|
2026-03-29 03:21:18 +00:00
|
|
|
|
.attr('stroke', d => TYPE_COLORS[d.type])
|
2026-03-29 09:51:38 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
});
|
2026-03-29 03:21:18 +00:00
|
|
|
|
|
|
|
|
|
|
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})`);
|
2026-03-29 09:09:32 +00:00
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-29 03:21:18 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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>`;
|
|
|
|
|
|
|
2026-03-29 08:14:09 +00:00
|
|
|
|
// 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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
// 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';
|
2026-03-29 08:06:44 +00:00
|
|
|
|
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>`;
|
2026-03-29 03:21:18 +00:00
|
|
|
|
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>` : ''}
|
2026-03-29 08:06:44 +00:00
|
|
|
|
<span class="conn-dir" style="margin-left:auto;margin-right:4px">${isOut ? 'out' : 'in'}</span>
|
|
|
|
|
|
${delBtn}
|
2026-03-29 03:21:18 +00:00
|
|
|
|
</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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 09:51:38 +00:00
|
|
|
|
// ── 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 09:09:32 +00:00
|
|
|
|
// ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 13:59:12 +00:00
|
|
|
|
// ── 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}`); }
|
2026-03-29 14:18:10 +00:00
|
|
|
|
const raw = await res.json();
|
|
|
|
|
|
const repos = Array.isArray(raw) ? raw : (raw.repos || []);
|
|
|
|
|
|
const isFallback = !Array.isArray(raw) && raw.fallback;
|
2026-03-29 13:59:12 +00:00
|
|
|
|
|
|
|
|
|
|
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';
|
2026-03-29 14:18:10 +00:00
|
|
|
|
const fallbackNote = isFallback ? ' (ユーザーリポジトリにフォールバック)' : '';
|
|
|
|
|
|
statusEl.textContent = `${repos.length} リポジトリ検出 — ${added} 件追加${fallbackNote}`;
|
|
|
|
|
|
showToast(`GitHub: ${added} リポジトリを追加しました${fallbackNote}`);
|
2026-03-29 13:59:12 +00:00
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
// ── Event bindings ─────────────────────────────────────────────
|
|
|
|
|
|
function bindEvents() {
|
|
|
|
|
|
document.getElementById('btn-fit').addEventListener('click', fitGraph);
|
|
|
|
|
|
document.getElementById('btn-add-node').addEventListener('click', openAddModal);
|
2026-03-29 08:06:44 +00:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-03-29 03:21:18 +00:00
|
|
|
|
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('クリップボードにコピーしました'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-29 03:45:08 +00:00
|
|
|
|
document.getElementById('btnImportJson').addEventListener('click', () => {
|
|
|
|
|
|
document.getElementById('fileInput').click();
|
|
|
|
|
|
});
|
2026-03-29 03:21:18 +00:00
|
|
|
|
document.getElementById('btnExportJson').addEventListener('click', () => {
|
|
|
|
|
|
exportJson();
|
|
|
|
|
|
showToast('エクスポートしました');
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById('btnResetData').addEventListener('click', () => {
|
2026-03-29 03:45:08 +00:00
|
|
|
|
if (!confirm('サンプルデータに戻しますか?現在のデータは失われます。')) return;
|
2026-03-29 03:21:18 +00:00
|
|
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
|
|
|
|
location.reload();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-29 09:09:32 +00:00
|
|
|
|
document.getElementById('btnTailscaleScan').addEventListener('click', runTailscaleScan);
|
|
|
|
|
|
document.getElementById('tailscale-token-input').addEventListener('keydown', e => {
|
|
|
|
|
|
if (e.key === 'Enter') runTailscaleScan();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-29 09:51:38 +00:00
|
|
|
|
document.getElementById('monitor-interval-sel').addEventListener('change', e => {
|
|
|
|
|
|
applyMonitorSetting(parseInt(e.target.value, 10));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-29 13:59:12 +00:00
|
|
|
|
document.getElementById('btnGithubScan').addEventListener('click', runGithubScan);
|
|
|
|
|
|
document.getElementById('btnVercelScan').addEventListener('click', runVercelScan);
|
|
|
|
|
|
|
2026-03-29 03:45:08 +00:00
|
|
|
|
// 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-29 08:14:09 +00:00
|
|
|
|
// 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 08:14:09 +00:00
|
|
|
|
// ── Health check ───────────────────────────────────────────────
|
2026-03-30 22:48:10 +00:00
|
|
|
|
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`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 08:14:09 +00:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-03-30 22:48:10 +00:00
|
|
|
|
// メトリクスパネルを非表示にリセット
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-29 08:14:09 +00:00
|
|
|
|
let result = 'offline';
|
2026-03-30 22:48:10 +00:00
|
|
|
|
|
|
|
|
|
|
// /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';
|
|
|
|
|
|
}
|
2026-03-29 08:14:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 08:06:44 +00:00
|
|
|
|
// ── 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('接続を削除しました');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:45:08 +00:00
|
|
|
|
// ── 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', '');
|
2026-03-29 03:21:18 +00:00
|
|
|
|
buildFilterBar();
|
|
|
|
|
|
initGraph();
|
|
|
|
|
|
setTimeout(fitGraph, 800);
|
2026-03-29 03:45:08 +00:00
|
|
|
|
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();
|
2026-03-29 08:06:44 +00:00
|
|
|
|
// 1. Check for shared atlas in URL hash (read-only mode)
|
|
|
|
|
|
if (checkShareURL()) {
|
|
|
|
|
|
buildFilterBar();
|
|
|
|
|
|
initGraph();
|
|
|
|
|
|
applyReadOnly();
|
|
|
|
|
|
setTimeout(fitGraph, 800);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 2. Normal flow
|
2026-03-29 03:45:08 +00:00
|
|
|
|
if (!atlasData) {
|
|
|
|
|
|
showWizard();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
buildFilterBar();
|
|
|
|
|
|
initGraph();
|
|
|
|
|
|
setTimeout(fitGraph, 800);
|
2026-03-29 09:51:38 +00:00
|
|
|
|
// Restore monitoring preference
|
|
|
|
|
|
const savedMinutes = loadMonitorPref();
|
|
|
|
|
|
if (savedMinutes > 0) {
|
|
|
|
|
|
// Delay start so graph is ready
|
|
|
|
|
|
setTimeout(() => applyMonitorSetting(savedMinutes), 1200);
|
|
|
|
|
|
}
|
2026-03-29 03:45:08 +00:00
|
|
|
|
}
|
2026-03-29 03:21:18 +00:00
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|