|
```dataviewjs |
|
const DEVICES_FOLDER = '"Permanent Notes/IPAM/Devices"'; |
|
const NETWORKS_FOLDER = '"Permanent Notes/IPAM/Networks"'; |
|
|
|
// ---------- helpers ---------- |
|
const norm = (s) => String(s ?? "").trim().toLowerCase().replace(/\s+/g, " "); |
|
|
|
function isIpLikeToken(t) { |
|
// digits/dots/slash only, and at least one dot |
|
return /^[0-9./]+$/.test(t) && t.includes("."); |
|
} |
|
|
|
function isNumberToken(t) { |
|
return /^[+-]?\d+(\.\d+)?$/.test(t); |
|
} |
|
|
|
// AND-search across tokens. |
|
// IP-like & numeric tokens use strict substring match. |
|
function rowMatchesQuery(query, haystack) { |
|
const q = String(query ?? "").trim(); |
|
if (!q) return true; |
|
|
|
const tokens = q.split(/\s+/).map(x => x.trim()).filter(Boolean); |
|
const hay = norm(haystack); |
|
|
|
return tokens.every(tok => { |
|
const t = norm(tok); |
|
if (!t) return true; |
|
|
|
if (isIpLikeToken(t) || isNumberToken(t)) return hay.includes(t); |
|
|
|
// default: substring match |
|
return hay.includes(t); |
|
}); |
|
} |
|
|
|
function makeObsidianLink(file) { |
|
const a = document.createElement("a"); |
|
a.setAttribute("data-href", file.path); |
|
a.setAttribute("href", file.path); |
|
a.classList.add("internal-link"); |
|
a.textContent = file.name; |
|
return a; |
|
} |
|
|
|
// IPv4 -> unsigned 32-bit int |
|
function ipv4ToU32(ip) { |
|
if (!ip) return null; |
|
const parts = String(ip).split(".").map(Number); |
|
if (parts.length !== 4) return null; |
|
if (parts.some(n => Number.isNaN(n) || n < 0 || n > 255)) return null; |
|
return (((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0); |
|
} |
|
|
|
// IP or CIDR -> { ipNum, prefix } |
|
function parseIpOrCidr(value) { |
|
const s = String(value ?? "").trim(); |
|
if (!s) return null; |
|
const [ipPart, prefixPart] = s.split("/"); |
|
const ipNum = ipv4ToU32(ipPart.trim()); |
|
if (ipNum === null) return null; |
|
let prefix = null; |
|
if (prefixPart != null && prefixPart.trim() !== "") { |
|
const p = Number(prefixPart.trim()); |
|
if (!Number.isNaN(p)) prefix = p; |
|
} |
|
return { ipNum, prefix }; |
|
} |
|
|
|
// Column typing: deterministic |
|
const columnType = { |
|
"VLAN ID": "num", |
|
|
|
"IP address (CIDR)": "ip", |
|
"Network address (CIDR)": "ip", |
|
"Broadcast address": "ip", |
|
"Gateway address": "ip", |
|
"Subnetmask": "ip", |
|
|
|
"Device": "text", |
|
"Device type": "text", |
|
"Network Device": "text", |
|
"IP note": "text", |
|
"IP configuration type": "text", |
|
"Network name": "text", |
|
}; |
|
|
|
function compareByType(a, b, type, asc = true) { |
|
const dir = asc ? 1 : -1; |
|
if (a == null) a = ""; |
|
if (b == null) b = ""; |
|
|
|
if (type === "num") { |
|
const na = Number(String(a).trim()); |
|
const nb = Number(String(b).trim()); |
|
const aOk = !Number.isNaN(na); |
|
const bOk = !Number.isNaN(nb); |
|
if (aOk && bOk) return na < nb ? -1 * dir : na > nb ? 1 * dir : 0; |
|
const sa = norm(a), sb = norm(b); |
|
return sa < sb ? -1 * dir : sa > sb ? 1 * dir : 0; |
|
} |
|
|
|
if (type === "ip") { |
|
const pa = parseIpOrCidr(a); |
|
const pb = parseIpOrCidr(b); |
|
|
|
if (pa && !pb) return -1 * dir; |
|
if (!pa && pb) return 1 * dir; |
|
if (!pa && !pb) { |
|
const sa = norm(a), sb = norm(b); |
|
return sa < sb ? -1 * dir : sa > sb ? 1 * dir : 0; |
|
} |
|
|
|
if (pa.ipNum < pb.ipNum) return -1 * dir; |
|
if (pa.ipNum > pb.ipNum) return 1 * dir; |
|
|
|
// prefix tiebreaker (nulls last) |
|
const aHas = pa.prefix != null; |
|
const bHas = pb.prefix != null; |
|
if (aHas && !bHas) return -1 * dir; |
|
if (!aHas && bHas) return 1 * dir; |
|
if (aHas && bHas) { |
|
if (pa.prefix < pb.prefix) return -1 * dir; |
|
if (pa.prefix > pb.prefix) return 1 * dir; |
|
} |
|
return 0; |
|
} |
|
|
|
const sa = norm(a), sb = norm(b); |
|
return sa < sb ? -1 * dir : sa > sb ? 1 * dir : 0; |
|
} |
|
|
|
// ---------- load data from Dataview index (FAST) ---------- |
|
const networks = dv.pages(NETWORKS_FOLDER) |
|
.where(p => p.type === "network" && p.vlan_id != null); |
|
|
|
const netByVlan = new Map(networks.map(n => [String(n.vlan_id), n])); |
|
|
|
// devices with YAML list `ips` |
|
const devices = dv.pages(DEVICES_FOLDER) |
|
.where(p => (p.type === "ap" || p.type === "device" || p.type === "switch" || p.type === "router" || p.type === "ups") && Array.isArray(p.ips)); |
|
//.where(p => Array.isArray(p.ips)); |
|
|
|
let rows = []; |
|
for (const d of devices) { |
|
for (const e of d.ips) { |
|
const vlan = String(e.vlan_id ?? "").trim(); |
|
const n = netByVlan.get(vlan) ?? null; |
|
|
|
rows.push({ |
|
"VLAN ID": vlan, |
|
"_deviceFile": d.file, |
|
"Device type": String(d.type ?? "").trim(), |
|
"IP address (CIDR)": String(e.ip ?? "").trim(), |
|
"IP configuration type": String(e.type ?? "").trim(), |
|
"Network Device": String(e.network_device_name ?? "").trim(), |
|
"IP note": String(e.note ?? "").trim(), |
|
|
|
"Network name": String(n?.network_name ?? ""), |
|
"Network address (CIDR)": String(n?.network_cidr ?? ""), |
|
"Broadcast address": String(n?.broadcast ?? ""), |
|
"Gateway address": String(n?.gateway ?? ""), |
|
"Subnetmask": String(n?.subnetmask ?? ""), |
|
}); |
|
} |
|
} |
|
|
|
// ---------- UI ---------- |
|
const columns = [ |
|
"Device", |
|
"Device type", |
|
"Network Device", |
|
"IP address (CIDR)", |
|
"IP configuration type", |
|
"IP note", |
|
"VLAN ID", |
|
"Network name", |
|
"Network address (CIDR)", |
|
"Broadcast address", |
|
"Gateway address", |
|
"Subnetmask", |
|
]; |
|
|
|
let sortCol = "IP address (CIDR)"; |
|
let sortAsc = true; |
|
|
|
const root = dv.el("div", "", { cls: "ipam-joined" }); |
|
|
|
const controls = root.createEl("div"); |
|
controls.style.display = "flex"; |
|
controls.style.gap = "0.75rem"; |
|
controls.style.alignItems = "center"; |
|
controls.style.marginBottom = "0.5rem"; |
|
|
|
controls.createEl("span", { text: "Search:" }); |
|
|
|
const search = controls.createEl("input", { type: "text", placeholder: "search…" }); |
|
search.style.flex = "1"; |
|
|
|
// Help / tooltip for search |
|
const helpWrap = controls.createEl("span"); |
|
helpWrap.style.position = "relative"; |
|
helpWrap.style.display = "inline-flex"; |
|
helpWrap.style.alignItems = "center"; |
|
|
|
const helpIcon = helpWrap.createEl("span", { text: " ?" }); |
|
helpIcon.style.cursor = "help"; |
|
helpIcon.style.fontWeight = "bold"; |
|
helpIcon.style.opacity = "0.7"; |
|
helpIcon.style.userSelect = "none"; |
|
|
|
// Tooltip box |
|
const helpBox = helpWrap.createEl("div"); |
|
helpBox.style.position = "absolute"; |
|
helpBox.style.top = "1.6em"; |
|
helpBox.style.right = "0"; |
|
helpBox.style.minWidth = "320px"; |
|
helpBox.style.maxWidth = "420px"; |
|
helpBox.style.padding = "10px 12px"; |
|
helpBox.style.borderRadius = "8px"; |
|
helpBox.style.boxShadow = "0 6px 20px rgba(0,0,0,0.25)"; |
|
helpBox.style.background = "var(--background-primary)"; |
|
helpBox.style.border = "1px solid var(--background-modifier-border)"; |
|
helpBox.style.fontSize = "0.85em"; |
|
helpBox.style.lineHeight = "1.4"; |
|
helpBox.style.display = "none"; |
|
helpBox.style.zIndex = "1000"; |
|
|
|
helpBox.innerHTML = ` |
|
<strong>Search help</strong><br><br> |
|
|
|
• Search matches <em>all columns</em> by default<br> |
|
• Multiple words are combined with <strong>AND</strong><br> |
|
• IPs & numbers use exact matching (no fuzzy guessing)<br><br> |
|
|
|
<strong>Restrict by column</strong><br> |
|
Use the dropdown, or type shortcuts:<br> |
|
<code>ip:192.168.1.1</code><br> |
|
<code>vlan:10</code><br> |
|
<code>net:servers</code><br> |
|
<code>gw:192.168.10.1</code><br> |
|
<code>mask:255.255.255.0</code><br><br> |
|
|
|
<strong>Examples</strong><br> |
|
<code>vlan:10 gateway</code><br> |
|
<code>ip:192.168.10.1</code> |
|
`; |
|
|
|
// show / hide behavior |
|
helpIcon.addEventListener("mouseenter", () => helpBox.style.display = "block"); |
|
helpIcon.addEventListener("mouseleave", () => helpBox.style.display = "none"); |
|
|
|
// allow click-to-toggle (useful on touch devices) |
|
helpIcon.addEventListener("click", () => { |
|
helpBox.style.display = helpBox.style.display === "none" ? "block" : "none"; |
|
}); |
|
|
|
// Column selector |
|
const colSelect = controls.createEl("select"); |
|
colSelect.style.minWidth = "220px"; |
|
const optAll = colSelect.createEl("option", { text: "All columns" }); |
|
optAll.value = "__all__"; |
|
for (const col of columns) { |
|
const opt = colSelect.createEl("option", { text: col }); |
|
opt.value = col; |
|
} |
|
|
|
const clearBtn = controls.createEl("button", { text: "Clear filters" }); |
|
clearBtn.style.whiteSpace = "nowrap"; |
|
|
|
const info = controls.createEl("span", { text: "" }); |
|
info.style.opacity = "0.75"; |
|
info.style.whiteSpace = "nowrap"; |
|
|
|
const tableWrap = root.createEl("div"); |
|
|
|
// --- per-column filter state --- |
|
const filters = {}; // col -> { mode: "select"|"text", value: []|string } |
|
|
|
// Choose filter control type per column |
|
function filterModeFor(col) { |
|
// checkbox-popup for low-cardinality columns |
|
// if (["VLAN ID", "IP address (CIDR)", "Device", "Network Device", "Network name", "Network address (CIDR)", "Subnetmask"].includes(col)) return "select"; |
|
// return "text"; |
|
return "select"; |
|
} |
|
|
|
// Build unique values for select filters |
|
function uniqueValuesFor(col) { |
|
const set = new Set(); |
|
for (const r of rows) { |
|
let v = (col === "Device") ? (r["_deviceFile"]?.name ?? "") : (r[col] ?? ""); |
|
v = String(v ?? "").trim(); |
|
if (v) set.add(v); |
|
} |
|
const arr = Array.from(set); |
|
arr.sort((a, b) => compareByType(a, b, columnType[col] ?? "text", true)); |
|
return arr; |
|
} |
|
|
|
// init filters |
|
for (const col of columns) { |
|
const mode = filterModeFor(col); |
|
filters[col] = { mode, value: mode === "select" ? [] : "" }; |
|
} |
|
|
|
// Optional: shortcut aliases |
|
const aliases = { |
|
vlan: "VLAN ID", |
|
ip: "IP address (CIDR)", |
|
dev: "Device", |
|
device: "Device", |
|
note: "IP note", |
|
net: "Network name", |
|
netaddr: "Network address (CIDR)", |
|
gw: "Gateway address", |
|
bc: "Broadcast address", |
|
mask: "Subnetmask", |
|
}; |
|
|
|
// Apply all filters to a row |
|
function rowPassesColumnFilters(r) { |
|
for (const col of columns) { |
|
const f = filters[col]; |
|
|
|
let cell = (col === "Device") ? (r["_deviceFile"]?.name ?? "") : (r[col] ?? ""); |
|
cell = String(cell ?? "").trim(); |
|
|
|
if (f.mode === "select") { |
|
// multi-select: empty = allow all |
|
if (Array.isArray(f.value) && f.value.length > 0) { |
|
if (!f.value.includes(cell)) return false; |
|
} |
|
} else { |
|
if (String(f.value ?? "").trim() && !rowMatchesQuery(f.value, cell)) return false; |
|
} |
|
} |
|
return true; |
|
} |
|
|
|
// ---------- checkbox popup implementation ---------- |
|
let activePopup = null; |
|
|
|
function openCheckboxPopup(col, anchorEl) { |
|
if (activePopup) { |
|
activePopup.remove(); |
|
activePopup = null; |
|
} |
|
|
|
const values = uniqueValuesFor(col); |
|
const selected = new Set(filters[col].value || []); |
|
|
|
const popup = document.createElement("div"); |
|
popup.style.position = "fixed"; // fixed so it stays near the header even when scrolling |
|
popup.style.zIndex = "5000"; |
|
popup.style.minWidth = "260px"; |
|
popup.style.maxHeight = "320px"; |
|
popup.style.overflow = "auto"; |
|
popup.style.padding = "10px"; |
|
popup.style.borderRadius = "10px"; |
|
popup.style.background = "var(--background-primary)"; |
|
popup.style.border = "1px solid var(--background-modifier-border)"; |
|
popup.style.boxShadow = "0 12px 30px rgba(0,0,0,0.35)"; |
|
|
|
// position under anchor |
|
const rect = anchorEl.getBoundingClientRect(); |
|
popup.style.left = `${Math.min(rect.left, window.innerWidth - 280)}px`; |
|
popup.style.top = `${rect.bottom + 6}px`; |
|
|
|
// title + active count |
|
const titleRow = popup.createEl("div"); |
|
titleRow.style.display = "flex"; |
|
titleRow.style.justifyContent = "space-between"; |
|
titleRow.style.alignItems = "center"; |
|
titleRow.style.gap = "10px"; |
|
titleRow.style.marginBottom = "8px"; |
|
|
|
titleRow.createEl("strong", { text: col }); |
|
const count = titleRow.createEl("span", { text: "" }); |
|
count.style.opacity = "0.75"; |
|
count.style.fontSize = "0.85em"; |
|
|
|
const searchBox = popup.createEl("input", { type: "text", placeholder: "Search values…" }); |
|
searchBox.style.width = "100%"; |
|
searchBox.style.marginBottom = "8px"; |
|
|
|
const btnRow = popup.createEl("div"); |
|
btnRow.style.display = "flex"; |
|
btnRow.style.gap = "6px"; |
|
btnRow.style.marginBottom = "8px"; |
|
|
|
const allBtn = btnRow.createEl("button", { text: "All" }); |
|
const noneBtn = btnRow.createEl("button", { text: "None" }); |
|
const closeBtn = btnRow.createEl("button", { text: "Close" }); |
|
closeBtn.style.marginLeft = "auto"; |
|
|
|
const list = popup.createEl("div"); |
|
|
|
function setFilterAndRerender() { |
|
filters[col].value = Array.from(selected); |
|
count.textContent = selected.size ? `${selected.size} selected` : "All"; |
|
render(); |
|
} |
|
|
|
function renderList(filterText = "") { |
|
list.empty(); |
|
const ft = norm(filterText); |
|
|
|
for (const v of values) { |
|
if (ft && !norm(v).includes(ft)) continue; |
|
|
|
const row = list.createEl("label"); |
|
row.style.display = "flex"; |
|
row.style.alignItems = "center"; |
|
row.style.gap = "8px"; |
|
row.style.cursor = "pointer"; |
|
row.style.padding = "2px 0"; |
|
|
|
const cb = row.createEl("input", { type: "checkbox" }); |
|
cb.checked = selected.has(v); |
|
|
|
cb.addEventListener("change", () => { |
|
if (cb.checked) selected.add(v); |
|
else selected.delete(v); |
|
setFilterAndRerender(); |
|
}); |
|
|
|
row.createEl("span", { text: v }); |
|
} |
|
} |
|
|
|
searchBox.addEventListener("input", () => renderList(searchBox.value)); |
|
|
|
allBtn.addEventListener("click", () => { |
|
values.forEach(v => selected.add(v)); |
|
setFilterAndRerender(); |
|
renderList(searchBox.value); |
|
}); |
|
|
|
noneBtn.addEventListener("click", () => { |
|
selected.clear(); // empty = All (no restriction) |
|
setFilterAndRerender(); |
|
renderList(searchBox.value); |
|
}); |
|
|
|
closeBtn.addEventListener("click", () => { |
|
popup.remove(); |
|
activePopup = null; |
|
}); |
|
|
|
document.body.appendChild(popup); |
|
activePopup = popup; |
|
setFilterAndRerender(); |
|
renderList(); |
|
|
|
// close when clicking outside |
|
setTimeout(() => { |
|
function onDocClick(e) { |
|
if (!popup.contains(e.target)) { |
|
popup.remove(); |
|
activePopup = null; |
|
document.removeEventListener("click", onDocClick); |
|
} |
|
} |
|
document.addEventListener("click", onDocClick); |
|
}, 0); |
|
} |
|
|
|
function render() { |
|
tableWrap.empty(); |
|
|
|
// parse optional key:value in the global search box |
|
let rawQ = search.value ?? ""; |
|
let query = rawQ; |
|
const m = String(rawQ).trim().match(/^(\w+)\s*:\s*(.+)$/); |
|
if (m) { |
|
const key = m[1].toLowerCase(); |
|
const val = m[2]; |
|
if (aliases[key]) { |
|
colSelect.value = aliases[key]; |
|
query = val; |
|
} |
|
} |
|
|
|
const selectedCol = colSelect.value; |
|
|
|
let filtered = rows |
|
.filter(rowPassesColumnFilters) |
|
.filter(r => { |
|
let hay; |
|
if (selectedCol !== "__all__") { |
|
if (selectedCol === "Device") hay = r["_deviceFile"]?.name ?? ""; |
|
else hay = r[selectedCol] ?? ""; |
|
} else { |
|
hay = [ |
|
r["VLAN ID"], |
|
r["IP address (CIDR)"], |
|
r["_deviceFile"]?.name ?? "", |
|
r["IP note"], |
|
r["Network name"], |
|
r["Network address (CIDR)"], |
|
r["Broadcast address"], |
|
r["Gateway address"], |
|
r["Subnetmask"], |
|
].join(" | "); |
|
} |
|
return rowMatchesQuery(query, hay); |
|
}); |
|
|
|
// sorting |
|
filtered.sort((a, b) => { |
|
if (sortCol === "Device") { |
|
return compareByType(a["_deviceFile"]?.name ?? "", b["_deviceFile"]?.name ?? "", "text", sortAsc); |
|
} |
|
const type = columnType[sortCol] ?? "text"; |
|
return compareByType(a[sortCol], b[sortCol], type, sortAsc); |
|
}); |
|
|
|
info.textContent = `${filtered.length} / ${rows.length}`; |
|
|
|
// table |
|
const t = tableWrap.createEl("table"); |
|
t.className = "dataview table-view-table"; |
|
|
|
const thead = t.createEl("thead"); |
|
|
|
// header row (sortable) |
|
const hr = thead.createEl("tr"); |
|
for (const col of columns) { |
|
const th = hr.createEl("th"); |
|
th.style.cursor = "pointer"; |
|
th.textContent = col + (col === sortCol ? (sortAsc ? " ▲" : " ▼") : ""); |
|
th.onclick = () => { |
|
if (sortCol === col) sortAsc = !sortAsc; |
|
else { sortCol = col; sortAsc = true; } |
|
render(); |
|
}; |
|
} |
|
|
|
// filter row (text inputs + checkbox popup buttons) |
|
const fr = thead.createEl("tr"); |
|
for (const col of columns) { |
|
const th = fr.createEl("th"); |
|
th.style.paddingTop = "6px"; |
|
th.style.paddingBottom = "6px"; |
|
|
|
const f = filters[col]; |
|
|
|
if (f.mode === "select") { |
|
const btn = th.createEl("button", { text: "Filter ▼" }); |
|
btn.style.width = "100%"; |
|
btn.style.cursor = "pointer"; |
|
btn.style.fontSize = "0.85em"; |
|
|
|
// show active state |
|
const active = Array.isArray(f.value) && f.value.length > 0; |
|
if (active) { |
|
btn.textContent = `Filter (${f.value.length}) ▼`; |
|
btn.style.fontWeight = "bold"; |
|
} |
|
|
|
btn.addEventListener("click", (ev) => { |
|
ev.stopPropagation(); |
|
openCheckboxPopup(col, btn); |
|
}); |
|
|
|
} else { |
|
const inp = th.createEl("input", { type: "text", placeholder: "filter…" }); |
|
inp.style.width = "100%"; |
|
inp.value = f.value; |
|
|
|
inp.addEventListener("input", () => { |
|
filters[col].value = inp.value; |
|
render(); |
|
}); |
|
} |
|
} |
|
|
|
const tbody = t.createEl("tbody"); |
|
for (const r of filtered) { |
|
const tr = tbody.createEl("tr"); |
|
for (const col of columns) { |
|
const td = tr.createEl("td"); |
|
if (col === "Device") td.appendChild(makeObsidianLink(r["_deviceFile"])); |
|
else td.setText(String(r[col] ?? "")); |
|
} |
|
} |
|
} |
|
|
|
// wiring |
|
search.addEventListener("input", render); |
|
colSelect.addEventListener("change", render); |
|
clearBtn.addEventListener("click", () => { |
|
search.value = ""; |
|
colSelect.value = "__all__"; |
|
for (const c of columns) { |
|
filters[c].value = (filters[c].mode === "select") ? [] : ""; |
|
} |
|
if (activePopup) { activePopup.remove(); activePopup = null; } |
|
render(); |
|
}); |
|
|
|
render(); |
|
``` |