267 lines
8.3 KiB
HTML
267 lines
8.3 KiB
HTML
<!-- test -->
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Home-Lab diagram – live editor</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
:root{--bg:#f7f9fc;--text:#222;--accent:#0d6efd}
|
||
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;font-size:14px;background:var(--bg);color:var(--text)}
|
||
header{background:var(--accent);color:#fff;padding:.6rem 1rem;font-weight:600;display:flex;gap:.5rem;align-items:center}
|
||
header button{padding:.2rem .6rem;border:none;border-radius:4px;background:#fff;color:var(--accent);cursor:pointer}
|
||
.wrap{display:flex;height:calc(100vh - 40px)}
|
||
.panel{flex:1;display:flex;flex-direction:column;overflow:hidden;border-right:1px solid #ddd;transition:flex .3s ease}
|
||
.panel:last-child{border:none}
|
||
h3{margin:.4rem .6rem;font-size:1rem}
|
||
#src{flex:1;padding:.5rem;overflow:auto;background:#1e1e1e;color:#d4d4d4;font-family:"Consolas","Monaco",monospace;border:none;outline:none;white-space:pre-wrap}
|
||
#preview{flex:1;padding:.5rem;overflow:auto;background:#fff}
|
||
/* IMPORTANT: Let the SVG flow naturally and scroll if needed */
|
||
#preview svg{display:block;margin:0 auto;max-width:100%;height:auto}
|
||
#error{color:#d32f2f;background:#fff3cd;padding:.5rem;margin:.5rem;border-left:4px solid #d32f2f;display:none}
|
||
#zoomVal{margin-left:.5rem;font-size:.9rem;color:#fff}
|
||
/* collapsed state */
|
||
#srcPanel.collapsed{flex:0}
|
||
#srcPanel.collapsed #src{display:none}
|
||
/* Debug: uncomment to see element bounds */
|
||
/* #preview{outline:2px solid red} #preview svg{outline:2px solid blue} */
|
||
</style>
|
||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<button id="toggleBtn" onclick="togglePanel()">🗔 Hide source</button>
|
||
Home-Lab diagram – live Mermaid editor
|
||
<button onclick="saveSVG()">💾 SVG</button>
|
||
<button onclick="savePNG()">💾 PNG</button>
|
||
<button onclick="reset()">🔄 Reset</button>
|
||
<label style="margin-left:auto;display:flex;align-items:center;gap:.3rem;color:#fff">
|
||
Zoom: <input type="range" id="zoom" min="50" max="300" value="100" style="width:100px"><span id="zoomVal">100%</span>
|
||
</label>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<div class="panel" id="srcPanel">
|
||
<h3>Source (edit here)</h3>
|
||
<textarea id="src" spellcheck="false"></textarea>
|
||
</div>
|
||
<div class="panel">
|
||
<h3>Live preview</h3>
|
||
<div id="preview"></div>
|
||
<div id="error"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const DEFAULT = `flowchart TD
|
||
%% ---------- styles ----------
|
||
classDef internet fill:#e1f5ff,stroke:#007bff
|
||
classDef router fill:#fff3cd,stroke:#ffc107
|
||
classDef lan fill:#f8f9ff,stroke:#6c757d,stroke-width:2px
|
||
classDef core fill:#ffe6e6,stroke:#dc3545
|
||
classDef infra fill:#e6ffe6,stroke:#28a745
|
||
classDef worker fill:#f0e6ff,stroke:#6f42c1
|
||
classDef iot fill:#fff9e6,stroke:#fd7e14
|
||
|
||
%% ---------- nodes ----------
|
||
internet(🌐 Internet / Cloud):::internet
|
||
router[🛜 Router\nhub.lan\n192.168.1.1]:::router
|
||
|
||
subgraph LAN [🏠 LAN 192.168.1.0/24]
|
||
subgraph CORE [💻 Core server\n192.168.1.159]
|
||
traefik[🚦 Traefik]:::core
|
||
gitea[📚 Gitea]:::core
|
||
dokku[🐳 Dokku]:::core
|
||
auction[🧱 Auction stack]:::core
|
||
mi50[🧠 MI50 / Ollama]:::core
|
||
end
|
||
|
||
subgraph INFRA [🧭 Infra & DNS\n192.168.1.163]
|
||
adguard[🛡️ AdGuard]:::infra
|
||
artifactory[📦 Artifactory]:::infra
|
||
end
|
||
|
||
ha[🏡 Home Assistant\n192.168.1.193]:::infra
|
||
atlas[🧱 Atlas\n192.168.1.100]:::worker
|
||
|
||
iot1[📺 IoT-1]:::iot
|
||
iot2[📟 IoT-2]:::iot
|
||
end
|
||
|
||
subgraph TETHER [📶 Tether 192.168.137.0/24]
|
||
hermes[🛰️ Hermes]:::worker
|
||
plato[🛰️ Plato]:::worker
|
||
end
|
||
|
||
dev[👨💻 Dev laptop]:::internet
|
||
|
||
%% ---------- edges ----------
|
||
internet ==> router
|
||
router --> CORE
|
||
router --> INFRA
|
||
router --> ha
|
||
router --> atlas
|
||
router --> iot1
|
||
router --> iot2
|
||
|
||
dev ==> gitea
|
||
dev ==> dokku
|
||
dev ==> mi50
|
||
|
||
traefik --> gitea
|
||
traefik --> auction
|
||
traefik --> dokku
|
||
|
||
CORE -.->|DNS| adguard
|
||
ha -.->|DNS| adguard
|
||
atlas-.->|DNS| adguard
|
||
hermes-.->|DNS| adguard
|
||
plato-.->|DNS| adguard
|
||
|
||
CORE === TETHER
|
||
`;
|
||
|
||
function getStorage(key, fallback) {
|
||
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
|
||
}
|
||
function setStorage(key, val) {
|
||
try { localStorage.setItem(key, val); } catch {}
|
||
}
|
||
|
||
const srcEl = document.getElementById('src');
|
||
const errEl = document.getElementById('error');
|
||
const zoomEl = document.getElementById('zoom');
|
||
const zoomVal = document.getElementById('zoomVal');
|
||
const srcPanel = document.getElementById('srcPanel');
|
||
const toggleBtn = document.getElementById('toggleBtn');
|
||
|
||
srcEl.value = getStorage('labDiagram', DEFAULT);
|
||
if (getStorage('panelCollapsed', 'false') === 'true') {
|
||
srcPanel.classList.add('collapsed');
|
||
toggleBtn.textContent = '🗔 Show source';
|
||
}
|
||
|
||
function togglePanel() {
|
||
srcPanel.classList.toggle('collapsed');
|
||
const collapsed = srcPanel.classList.contains('collapsed');
|
||
toggleBtn.textContent = collapsed ? '🗔 Show source' : '🗔 Hide source';
|
||
setStorage('panelCollapsed', collapsed);
|
||
}
|
||
|
||
mermaid.initialize({startOnLoad: false, theme: 'default'});
|
||
|
||
function render() {
|
||
const src = srcEl.value;
|
||
setStorage('labDiagram', src);
|
||
errEl.style.display = 'none';
|
||
|
||
const preview = document.getElementById('preview');
|
||
preview.innerHTML = '';
|
||
|
||
const mermaidDiv = document.createElement('div');
|
||
mermaidDiv.className = 'mermaid';
|
||
mermaidDiv.textContent = src;
|
||
preview.appendChild(mermaidDiv);
|
||
|
||
// Render and fix sizing
|
||
mermaid.init(undefined, mermaidDiv).then(() => {
|
||
const svg = preview.querySelector('svg');
|
||
if (svg) {
|
||
// CRITICAL: Ensure SVG has viewBox for proper sizing
|
||
if (!svg.hasAttribute('viewBox')) {
|
||
const {width, height} = svg.getBBox();
|
||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||
}
|
||
svg.style.maxWidth = '100%';
|
||
svg.style.height = 'auto';
|
||
applyZoom();
|
||
}
|
||
}).catch(e => {
|
||
errEl.textContent = 'Mermaid error: ' + e.message;
|
||
errEl.style.display = 'block';
|
||
});
|
||
}
|
||
|
||
srcEl.addEventListener('input', () => {
|
||
clearTimeout(srcEl._t);
|
||
srcEl._t = setTimeout(render, 300);
|
||
});
|
||
|
||
zoomEl.addEventListener('input', () => {
|
||
zoomVal.textContent = zoomEl.value + '%';
|
||
applyZoom();
|
||
});
|
||
|
||
function applyZoom() {
|
||
const svg = document.querySelector('#preview svg');
|
||
if (svg) {
|
||
svg.style.transform = `scale(${zoomEl.value / 100})`;
|
||
}
|
||
}
|
||
|
||
function reset() {
|
||
if (confirm('Reset to default diagram?')) {
|
||
srcEl.value = DEFAULT;
|
||
zoomEl.value = 100;
|
||
zoomVal.textContent = '100%';
|
||
setStorage('labDiagram', DEFAULT);
|
||
render();
|
||
}
|
||
}
|
||
|
||
function saveSVG() {
|
||
const svg = document.querySelector('#preview svg');
|
||
if (!svg) return alert('Nothing to save yet.');
|
||
|
||
const clone = svg.cloneNode(true);
|
||
clone.removeAttribute('style');
|
||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||
|
||
const blob = new Blob([clone.outerHTML], {type: 'image/svg+xml'});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'home-lab.svg';
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||
}
|
||
|
||
function savePNG() {
|
||
const svg = document.querySelector('#preview svg');
|
||
if (!svg) return alert('Nothing to save yet.');
|
||
|
||
const {width, height} = svg.getBBox();
|
||
const canvasW = Math.max(width, 1200);
|
||
const canvasH = Math.max(height, 800);
|
||
|
||
const svgData = new XMLSerializer().serializeToString(svg);
|
||
const img = new Image();
|
||
|
||
img.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = canvasW;
|
||
canvas.height = canvasH;
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillRect(0, 0, canvasW, canvasH);
|
||
ctx.drawImage(img, 0, 0, canvasW, canvasH);
|
||
|
||
canvas.toBlob(blob => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'home-lab.png';
|
||
a.click();
|
||
});
|
||
};
|
||
|
||
img.onerror = () => alert('PNG conversion failed. Try SVG instead.');
|
||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData);
|
||
}
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
<!-- test auto-deploy $(date) -->
|