This commit is contained in:
iceBear67
2026-07-24 13:29:16 +00:00
commit 2275bd7dc4
29 changed files with 3641 additions and 0 deletions
+526
View File
@@ -0,0 +1,526 @@
'use strict';
/* ---------------- utilities ---------------- */
const $ = (sel, root = document) => root.querySelector(sel);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
/* ---------------- wasm engine worker ---------------- */
// The matching engine runs as WebAssembly inside a Web Worker. It is loaded
// lazily on the first Analyze (the wasm is several MB) and reused thereafter.
const engineWorker = (() => {
let worker = null;
let readyPromise = null;
let seq = 0;
const pending = new Map();
function ensure() {
if (readyPromise) return readyPromise;
worker = new Worker('worker.js');
readyPromise = new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const d = e.data || {};
if (d.type === 'ready') { resolve(); return; }
if (d.type === 'loaderror') { reject(new Error(d.error || 'failed to load engine')); return; }
if (d.id != null && pending.has(d.id)) {
const { resolve: res, reject: rej } = pending.get(d.id);
pending.delete(d.id);
if (d.error) rej(new Error(d.error)); else res(d.result);
}
};
worker.onerror = (e) => reject(new Error(e.message || 'engine worker error'));
});
return readyPromise;
}
return {
// Whether the engine is already initialized (used to tailor the spinner text).
get ready() { return readyPromise !== null; },
// Kick off loading without running an analysis.
preload() { return ensure(); },
async analyze(request) {
await ensure();
const id = ++seq;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, request });
});
},
};
})();
const storage = window.singvisStorage;
function toast(msg, kind = '') {
const root = $('#toast-root');
const t = document.createElement('div');
t.className = 'toast ' + kind;
t.textContent = msg;
root.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; setTimeout(() => t.remove(), 300); }, 3600);
}
const store = {
get(k, def) { try { const v = localStorage.getItem('singvis.' + k); return v == null ? def : JSON.parse(v); } catch { return def; } },
set(k, v) { try { localStorage.setItem('singvis.' + k, JSON.stringify(v)); } catch {} },
};
/* ---------------- app state ---------------- */
const state = {
profiles: [],
activeId: null,
settings: { dohServer: 'https://1.1.1.1/dns-query' },
draft: null, // { id?, name, config, inputs, ruleSetFiles }
lastResult: null,
analyzing: false,
};
const SAMPLE_CONFIG = `{
"dns": {
"servers": [
{ "tag": "proxy-dns", "type": "https", "server": "1.1.1.1", "detour": "proxy" },
{ "tag": "local-dns", "type": "udp", "server": "223.5.5.5", "detour": "direct" }
],
"rules": [
{ "rule_set": ["geosite-cn"], "action": "route", "server": "local-dns" },
{ "domain_keyword": ["ads"], "action": "reject" }
],
"final": "proxy-dns"
},
"route": {
"rules": [
{ "domain_suffix": ["google.com", "openai.com"], "outbound": "proxy" },
{ "rule_set": ["geosite-cn"], "outbound": "direct" },
{ "rule_set": ["geoip-cn"], "outbound": "direct" }
],
"rule_set": [
{ "type": "remote", "tag": "geosite-cn", "format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs" },
{ "type": "remote", "tag": "geoip-cn", "format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" }
],
"final": "proxy"
}
}`;
/* ---------------- init ---------------- */
async function init() {
$('#btn-settings').onclick = openSettings;
$('#btn-new').onclick = () => newDraft();
try {
state.settings = await storage.getSettings();
} catch (e) { /* keep defaults */ }
await loadProfiles();
if (state.profiles.length) selectProfile(state.profiles[0].id);
else newDraft();
}
async function loadProfiles() {
try { state.profiles = (await storage.listProfiles()) || []; }
catch (e) { state.profiles = []; }
renderSidebar();
}
function renderSidebar() {
const ul = $('#profile-list');
if (!state.profiles.length) {
ul.innerHTML = '<li class="empty-hint">No profiles yet. Create one to get started.</li>';
return;
}
ul.innerHTML = state.profiles.map((p) => `
<li class="profile-item ${p.id === state.activeId ? 'active' : ''}" data-id="${esc(p.id)}">
<span class="name">${esc(p.name || 'Untitled')}</span>
<span class="meta">updated ${new Date(p.updatedAt).toLocaleString()}</span>
</li>`).join('');
ul.querySelectorAll('.profile-item').forEach((li) => {
li.onclick = () => selectProfile(li.dataset.id);
});
}
async function selectProfile(id) {
try {
const p = await storage.getProfile(id);
state.activeId = id;
state.draft = { id: p.id, name: p.name || '', config: p.config || '', inputs: p.inputs || '', ruleSetFiles: p.ruleSetFiles || {} };
state.lastResult = null;
renderSidebar();
renderEditor();
} catch (e) { toast('Failed to load profile: ' + e.message, 'err'); }
}
function newDraft() {
state.activeId = null;
state.draft = { name: '', config: SAMPLE_CONFIG, inputs: 'www.google.com\nbaidu.com\nopenai.com\n1.1.1.1', ruleSetFiles: {} };
state.lastResult = null;
renderSidebar();
renderEditor();
}
/* ---------------- editor ---------------- */
function renderEditor() {
const d = state.draft;
const network = store.get('network', '');
const assume = store.get('assumeResolved', true);
const files = Object.entries(d.ruleSetFiles || {});
$('#content').innerHTML = `
<div class="card">
<div class="card-head">
<h2>${state.activeId ? 'Edit profile' : 'New profile'}</h2>
<div class="row" style="gap:8px">
<button class="btn small" id="btn-save">💾 Save</button>
${state.activeId ? '<button class="btn small danger" id="btn-delete">Delete</button>' : ''}
</div>
</div>
<div class="card-body">
<div class="field">
<label class="lbl">Profile name</label>
<input type="text" id="f-name" placeholder="My profile" value="${esc(d.name)}" />
</div>
<div class="field">
<label class="lbl">sing-box configuration <span class="hint">JSON (comments allowed)</span></label>
<textarea id="f-config" class="code" spellcheck="false" placeholder="Paste your sing-box config…">${esc(d.config)}</textarea>
</div>
<div class="field">
<label class="lbl">Local rule-set files <span class="hint">only needed for type:"local" rule sets — key by tag or path</span></label>
<input type="file" id="f-files" multiple />
<div class="file-list" id="file-list">
${files.map(([k, f]) => `
<div class="file-row" data-key="${esc(k)}">
<span class="fname">${esc(k)}</span>
<span class="fmeta">${esc(f.format)} · ${f.data ? (f.format === 'binary' ? Math.round(f.data.length * 0.75) : f.data.length) : 0} bytes</span>
<button class="btn small danger rm-file" data-key="${esc(k)}" style="margin-left:auto">remove</button>
</div>`).join('')}
</div>
</div>
<div class="field">
<label class="lbl">Domains / IPs to check <span class="hint">one per line — domains or raw IPs; # comments ignored</span></label>
<textarea id="f-inputs" class="inputs" spellcheck="false" placeholder="example.com&#10;1.1.1.1">${esc(d.inputs)}</textarea>
</div>
</div>
</div>
<div class="toolbar">
<button class="btn primary" id="btn-analyze">▶ Analyze</button>
<label class="check" title="Pre-resolve domains via DoH so ip_cidr / IP rule-set rules can match the resolved address">
<input type="checkbox" id="opt-assume" ${assume ? 'checked' : ''}/> Resolve IPs for IP rules
</label>
<div class="field" style="max-width:150px;margin:0">
<select id="opt-network" title="Assumed connection network for rules that filter on tcp/udp">
<option value="" ${network === '' ? 'selected' : ''}>network: any</option>
<option value="tcp" ${network === 'tcp' ? 'selected' : ''}>network: tcp</option>
<option value="udp" ${network === 'udp' ? 'selected' : ''}>network: udp</option>
</select>
</div>
<span class="spacer"></span>
<span class="muted mono" id="doh-indicator">DoH: ${esc(state.settings.dohServer || '—')}</span>
</div>
<div id="results"></div>
`;
$('#btn-save').onclick = saveProfile;
const del = $('#btn-delete'); if (del) del.onclick = deleteProfile;
$('#btn-analyze').onclick = analyze;
$('#f-files').onchange = handleFiles;
$('#opt-assume').onchange = (e) => store.set('assumeResolved', e.target.checked);
$('#opt-network').onchange = (e) => store.set('network', e.target.value);
$('#file-list').querySelectorAll('.rm-file').forEach((b) => {
b.onclick = () => { delete state.draft.ruleSetFiles[b.dataset.key]; syncDraftFromForm(); renderEditor(); };
});
if (state.lastResult) renderResults(state.lastResult);
}
function syncDraftFromForm() {
const d = state.draft;
const n = $('#f-name'); if (n) d.name = n.value;
const c = $('#f-config'); if (c) d.config = c.value;
const i = $('#f-inputs'); if (i) d.inputs = i.value;
}
function handleFiles(e) {
const files = Array.from(e.target.files || []);
syncDraftFromForm();
let pending = files.length;
if (!pending) return;
files.forEach((file) => {
const isBinary = /\.srs$/i.test(file.name);
const reader = new FileReader();
reader.onload = () => {
let data, format;
if (isBinary) {
const bytes = new Uint8Array(reader.result);
let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
data = btoa(bin); format = 'binary';
} else { data = reader.result; format = 'source'; }
state.draft.ruleSetFiles[file.name] = { format, data };
if (--pending === 0) renderEditor();
};
if (isBinary) reader.readAsArrayBuffer(file); else reader.readAsText(file);
});
}
async function saveProfile() {
syncDraftFromForm();
const d = state.draft;
if (!d.name.trim()) { toast('Please enter a profile name', 'err'); return; }
const payload = { name: d.name.trim(), config: d.config, inputs: d.inputs, ruleSetFiles: d.ruleSetFiles };
if (state.activeId) payload.id = state.activeId;
try {
const saved = await storage.saveProfile(payload);
state.activeId = saved.id;
await loadProfiles();
await selectProfile(saved.id);
toast('Profile saved', 'ok');
} catch (e) { toast('Save failed: ' + e.message, 'err'); }
}
async function deleteProfile() {
if (!state.activeId) return;
if (!confirm('Delete this profile?')) return;
try {
await storage.deleteProfile(state.activeId);
await loadProfiles();
if (state.profiles.length) selectProfile(state.profiles[0].id); else newDraft();
toast('Profile deleted', 'ok');
} catch (e) { toast('Delete failed: ' + e.message, 'err'); }
}
/* ---------------- analyze ---------------- */
async function analyze() {
syncDraftFromForm();
const d = state.draft;
const inputs = d.inputs.split('\n').map((s) => s.trim()).filter(Boolean);
if (!inputs.length) { toast('Add at least one domain or IP', 'err'); return; }
const btn = $('#btn-analyze');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Analyzing…';
const loadingEngine = !engineWorker.ready;
$('#results').innerHTML = `<div class="placeholder"><span class="spinner"></span> ${loadingEngine ? 'Loading engine (first run, ~a few MB)…' : 'Resolving &amp; matching…'}</div>`;
try {
const result = await engineWorker.analyze({
config: d.config,
inputs,
ruleSetFiles: d.ruleSetFiles,
dohServer: state.settings.dohServer,
network: store.get('network', ''),
assumeResolved: store.get('assumeResolved', true),
});
state.lastResult = result;
renderResults(result);
} catch (e) {
$('#results').innerHTML = `<div class="card"><div class="card-body" style="color:var(--reject)">⚠ ${esc(e.message)}</div></div>`;
} finally {
btn.disabled = false; btn.innerHTML = '▶ Analyze';
}
}
/* ---------------- results rendering ---------------- */
function statusBadge(status) {
const label = { match: 'MATCH', no_match: 'no match', unknown: 'UNKNOWN?' }[status] || status;
return `<span class="badge ${status}">${label}</span>`;
}
function renderResults(result) {
const root = $('#results');
if (!result.inputs || !result.inputs.length) { root.innerHTML = '<div class="placeholder">No inputs.</div>'; return; }
const warn = (result.warnings && result.warnings.length)
? `<div class="assume-warn">⚠ ${result.warnings.map(esc).join('<br>')}</div>` : '';
root.innerHTML = warn + result.inputs.map((it, i) => renderInputCard(it, i)).join('');
root.querySelectorAll('.result-head').forEach((h) => {
h.onclick = () => h.closest('.result-card').classList.toggle('open');
});
root.querySelectorAll('.step-head').forEach((h) => {
h.onclick = (e) => { e.stopPropagation(); h.closest('.step').classList.toggle('expanded'); };
});
}
function renderInputCard(it, idx) {
const chips = [];
if (it.kind === 'invalid') {
chips.push(`<span class="chip reject"><span class="k">error</span><span class="v">${esc(it.error || 'invalid')}</span></span>`);
} else {
if (it.dns && it.dns.decision) chips.push(dnsChip(it.dns.decision));
if (it.route && it.route.decision) chips.push(routeChip(it.route.decision));
}
const open = idx === 0 ? 'open' : '';
return `
<div class="card result-card ${open}">
<div class="result-head">
<span class="input-name">${esc(it.input)}</span>
<span class="badge kind-${esc(it.kind)}">${esc(it.kind)}</span>
<div class="outcome-chips">${chips.join('')}</div>
<span class="caret">▶</span>
</div>
<div class="result-body">
${it.kind === 'invalid' ? `<p class="muted">${esc(it.error || 'Could not parse this input.')}</p>` : ''}
${renderResolved(it.resolved)}
${it.dns ? `<div class="section-title">DNS routing (which server / dns action)</div>${renderTrace(it.dns, 'dns')}` : ''}
${it.route ? `<div class="section-title">Route matching (which rule / outbound)</div>${renderTrace(it.route, 'route')}` : ''}
</div>
</div>`;
}
function dnsChip(dec) {
if (dec.actionType === 'reject') return `<span class="chip reject"><span class="k">DNS</span><span class="v">reject</span></span>`;
const server = dec.server || (dec.actionType);
const detour = dec.serverInfo && dec.serverInfo.detour ? ` <span class="k">via</span> ${esc(dec.serverInfo.detour)}` : '';
return `<span class="chip dns"><span class="k">DNS</span><span class="v">${esc(server)}</span>${detour}</span>`;
}
function routeChip(dec) {
if (dec.actionType === 'reject') return `<span class="chip reject"><span class="k">route</span><span class="v">reject</span></span>`;
if (dec.actionType === 'hijack-dns') return `<span class="chip route"><span class="k">route</span><span class="v">hijack-dns</span></span>`;
return `<span class="chip route"><span class="k">route</span><span class="v">${esc(dec.outbound || '(default)')}</span></span>`;
}
function renderResolved(r) {
if (!r) return '';
if (r.error && !(r.ipv4 && r.ipv4.length) && !(r.ipv6 && r.ipv6.length)) {
return `<div class="section-title">Resolved (DoH)</div><p class="muted">resolution failed: ${esc(r.error)}</p>`;
}
const v4 = (r.ipv4 || []).map((ip) => `<span class="ip-chip">${esc(ip)}</span>`).join('');
const v6 = (r.ipv6 || []).map((ip) => `<span class="ip-chip">${esc(ip)}</span>`).join('');
if (!v4 && !v6) return `<div class="section-title">Resolved (DoH)</div><p class="muted">no A/AAAA records</p>`;
return `<div class="section-title">Resolved via ${esc(r.server)}</div><div class="ip-chips">${v4}${v6}</div>`;
}
function renderTrace(trace, kind) {
const steps = (trace.steps || []).map((s) => renderStep(s, kind, trace)).join('');
const stepsHtml = steps ? `<div class="steps">${steps}</div>` : `<p class="muted">No ${kind === 'dns' ? 'DNS' : 'route'} rules — the final is used directly.</p>`;
const note = trace.note ? `<p class="muted" style="margin-top:6px">${esc(trace.note)}</p>` : '';
return stepsHtml + note + renderDecision(trace.decision, kind);
}
function renderStep(s, kind, trace) {
const selected = (kind === 'route' && trace.selectedIndex === s.index) || (kind === 'dns' && trace.matchedIndex === s.index);
const cls = ['step', 's-' + s.status];
if (selected) cls.push('s-selected');
if (s.status === 'no_match') cls.push('dimmed');
const conds = s.type === 'logical' ? renderLogical(s) : (s.conditions || []).map(renderCond).join('');
const effect = s.effect ? `<div class="effect">↳ ${esc(s.effect)}</div>` : '';
const detailInner = conds || '<span class="muted">no conditions (matches all)</span>';
return `
<div class="step ${cls.join(' ')}">
<div class="step-head">
<span class="idx">${s.index}</span>
${statusBadge(s.status)}
<span class="summary" title="${esc(s.summary)}">${esc(s.summary)}</span>
<span class="action-text">${esc(s.actionText || '')}</span>
</div>
${effect}
<div class="step-detail">${detailInner}</div>
</div>`;
}
function renderLogical(s) {
const subs = (s.sub || []).map((sub) => {
const inner = sub.type === 'logical' ? renderLogical(sub) : (sub.conditions || []).map(renderCond).join('');
return `<div class="ruleset-box"><div class="rs-head">${statusBadge(sub.status)} <span class="mono">${esc(sub.summary)}</span></div>${inner}</div>`;
}).join('');
return `<div class="muted" style="margin-bottom:6px">logical <b>${esc(s.mode || 'and')}</b>${s.invert ? ' (inverted)' : ''}:</div>${subs}`;
}
function renderCond(c) {
const matched = c.matched ? `<span class="matched-val">✓ ${esc(c.matched)}</span>` : '';
const note = c.note ? `<div class="cnote">${esc(c.note)}</div>` : '';
const rs = c.ruleSet ? renderRuleSet(c.ruleSet) : '';
return `
<div class="cond">
<span class="cf">${esc(c.field)} ${statusBadge(c.status)}</span>
<span class="group-tag">${esc(c.group)}</span>
<span class="cv">${esc(c.value)} ${matched}</span>
${note}
</div>${rs}`;
}
function renderRuleSet(rs) {
const inner = (rs.rules || []).map((r) => {
const cs = r.type === 'logical' ? renderLogical(r) : (r.conditions || []).map(renderCond).join('');
return `<div style="margin-top:6px">${statusBadge(r.status)} <span class="mono">${esc(r.summary)}</span>${cs}</div>`;
}).join('');
const err = rs.error ? `<div class="rs-err">⚠ ${esc(rs.error)}</div>` : '';
const matched = rs.matchedIdx >= 0 ? ` · matched rule #${rs.matchedIdx}` : '';
return `
<div class="ruleset-box">
<div class="rs-head">${statusBadge(rs.status)} <b>rule_set</b> <span class="mono">${esc(rs.tag)}</span>
<span class="rs-meta">(${esc(rs.type || '?')} · ${rs.count || 0} rules${matched})</span>
</div>
${err}${inner}
</div>`;
}
function renderDecision(dec, kind) {
if (!dec) return '';
const assumed = dec.assumed ? `<span class="badge unknown" title="An earlier rule with undeterminable conditions (e.g. protocol/port) could change this outcome">depends on assumptions</span>` : '';
const fromFinal = dec.fromFinal ? `<span class="badge final">via ${kind === 'dns' ? 'dns.final' : 'route.final'}</span>` : '';
if (kind === 'dns') {
const cls = dec.actionType === 'reject' ? 'reject' : 'route';
let value, sub = '';
if (dec.actionType === 'reject') { value = 'reject'; }
else if (dec.actionType === 'predefined' || dec.actionType === 'respond') { value = dec.actionType; }
else {
value = dec.server || '(default)';
if (dec.serverInfo) {
const si = dec.serverInfo;
sub = `<span class="d-sub">${esc(si.type || '')}${si.address ? ' · ' + esc(si.address) : ''}${si.detour ? ' · via outbound <b>' + esc(si.detour) + '</b>' : ''}</span>`;
}
}
return `<div class="decision ${cls}">
<span class="d-label">DNS action</span>
<span class="d-value">${esc(dec.actionType)}</span><span class="arrow">→</span>
<span class="d-value">${esc(value)}</span>${sub} ${fromFinal} ${assumed}
</div>`;
}
// route
let cls = 'route', value;
if (dec.actionType === 'reject') { cls = 'reject'; value = 'reject'; }
else if (dec.actionType === 'hijack-dns') { value = 'hijack-dns'; }
else { value = dec.outbound || '(default outbound)'; }
return `<div class="decision ${cls}">
<span class="d-label">Final outbound</span>
<span class="d-value">${esc(value)}</span>
${dec.detail && dec.actionType === 'reject' ? `<span class="d-sub">${esc(dec.detail)}</span>` : ''}
${fromFinal} ${assumed}
</div>`;
}
/* ---------------- settings ---------------- */
function openSettings() {
const root = $('#modal-root');
root.innerHTML = `
<div class="modal-backdrop" id="settings-backdrop">
<div class="modal">
<div class="card-head"><h2>Settings</h2><button class="btn ghost small" id="s-close">✕</button></div>
<div class="card-body">
<div class="field">
<label class="lbl">HTTPS DNS (DoH) endpoint <span class="hint">DoH JSON API · must allow CORS</span></label>
<input type="url" id="s-doh" value="${esc(state.settings.dohServer || '')}" placeholder="https://1.1.1.1/dns-query" />
</div>
<div class="field">
<label class="lbl">Quick presets</label>
<div class="row" style="gap:6px">
${['https://1.1.1.1/dns-query', 'https://dns.google/dns-query', 'https://dns.quad9.net/dns-query', 'https://dns.alidns.com/dns-query']
.map((u) => `<button class="btn small preset" data-url="${esc(u)}">${esc(u.replace('https://', '').replace('/dns-query', ''))}</button>`).join('')}
</div>
</div>
<div class="row" style="justify-content:flex-end;margin-top:6px">
<button class="btn primary" id="s-save">Save</button>
</div>
</div>
</div>
</div>`;
const close = () => { root.innerHTML = ''; };
$('#s-close').onclick = close;
$('#settings-backdrop').onclick = (e) => { if (e.target.id === 'settings-backdrop') close(); };
root.querySelectorAll('.preset').forEach((b) => b.onclick = () => { $('#s-doh').value = b.dataset.url; });
$('#s-save').onclick = async () => {
const dohServer = $('#s-doh').value.trim() || 'https://1.1.1.1/dns-query';
try {
state.settings = await storage.saveSettings({ dohServer });
const ind = $('#doh-indicator'); if (ind) ind.textContent = 'DoH: ' + state.settings.dohServer;
close(); toast('Settings saved', 'ok');
} catch (e) { toast('Save failed: ' + e.message, 'err'); }
};
}
init();
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>sing-vis · sing-box route explainer</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%97%87%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo"></span>
<div>
<div class="title">sing-vis</div>
<div class="subtitle">sing-box DNS &amp; route rule explainer</div>
</div>
</div>
<div class="topbar-actions">
<button id="btn-settings" class="btn ghost" title="Settings">⚙ Settings</button>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<div class="sidebar-head">
<span>Profiles</span>
<button id="btn-new" class="btn small">+ New</button>
</div>
<ul id="profile-list" class="profile-list"></ul>
</aside>
<section class="content" id="content"></section>
</main>
<div id="modal-root"></div>
<div id="toast-root" class="toast-root"></div>
<script src="storage.js"></script>
<script src="app.js"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
'use strict';
// IndexedDB-backed persistence for profiles and settings — the browser-side
// replacement for the old file store. IndexedDB (not localStorage) is used
// because uploaded .srs rule-set files can exceed localStorage's ~5MB limit.
//
// Shapes match the former Go store exactly:
// Profile { id, name, config, inputs, createdAt, updatedAt, ruleSetFiles }
// Settings { dohServer }
//
// Exposed as window.singvisStorage (app.js is a classic script).
(function () {
const DB_NAME = 'singvis';
const DB_VERSION = 1;
const STORE_PROFILES = 'profiles';
const STORE_META = 'meta';
const SETTINGS_KEY = 'settings';
const DEFAULT_SETTINGS = { dohServer: 'https://1.1.1.1/dns-query' };
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_PROFILES)) {
db.createObjectStore(STORE_PROFILES, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error || new Error('failed to open IndexedDB'));
});
return dbPromise;
}
// tx runs fn(store) inside a transaction and resolves with `out` (a value fn
// can set) once the transaction completes.
async function tx(storeName, mode, fn) {
const db = await openDB();
return new Promise((resolve, reject) => {
const t = db.transaction(storeName, mode);
const store = t.objectStore(storeName);
let out;
const ret = (v) => { out = v; };
Promise.resolve(fn(store, ret)).catch(reject);
t.oncomplete = () => resolve(out);
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error || new Error('transaction aborted'));
});
}
function reqPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function newId() {
// Millisecond timestamp plus a short random suffix to avoid collisions when
// several profiles are created within the same millisecond.
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
const api = {
async listProfiles() {
const list = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
ret(await reqPromise(store.getAll()));
});
const out = list || [];
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return out;
},
async getProfile(id) {
const p = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
ret(await reqPromise(store.get(id)));
});
if (!p) throw new Error('profile not found');
return p;
},
// saveProfile creates (when profile.id is empty) or updates a profile,
// assigning id/createdAt/updatedAt the same way the old server did.
async saveProfile(profile) {
const now = Date.now();
const p = Object.assign({}, profile);
if (!p.id) {
p.id = newId();
p.createdAt = now;
}
if (!p.createdAt) p.createdAt = now;
p.updatedAt = now;
p.ruleSetFiles = p.ruleSetFiles || {};
await tx(STORE_PROFILES, 'readwrite', (store) => {
store.put(p);
});
return p;
},
async deleteProfile(id) {
await tx(STORE_PROFILES, 'readwrite', (store) => {
store.delete(id);
});
},
async getSettings() {
const s = await tx(STORE_META, 'readonly', async (store, ret) => {
ret(await reqPromise(store.get(SETTINGS_KEY)));
});
const merged = Object.assign({}, DEFAULT_SETTINGS, s || {});
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
return merged;
},
async saveSettings(settings) {
const merged = Object.assign({}, DEFAULT_SETTINGS, settings || {});
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
await tx(STORE_META, 'readwrite', (store) => {
store.put(merged, SETTINGS_KEY);
});
return merged;
},
defaultSettings() {
return Object.assign({}, DEFAULT_SETTINGS);
},
};
window.singvisStorage = api;
})();
+282
View File
@@ -0,0 +1,282 @@
:root {
--bg: #f6f7f9;
--bg-elev: #ffffff;
--bg-sunken: #eef0f3;
--border: #e2e5ea;
--border-strong: #cfd4dc;
--text: #1c2128;
--text-dim: #656d78;
--text-faint: #98a0ab;
--accent: #3b6cff;
--accent-weak: #e8eeff;
--match: #1a8a4a;
--match-bg: #e5f5ea;
--nomatch: #8a919b;
--nomatch-bg: #eceef1;
--unknown: #a66a00;
--unknown-bg: #fbefd8;
--reject: #c8321f;
--reject-bg: #fce7e3;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--radius: 10px;
--shadow: 0 1px 3px rgba(20,25,35,.06), 0 4px 16px rgba(20,25,35,.05);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1116;
--bg-elev: #161b22;
--bg-sunken: #0b0e13;
--border: #262c36;
--border-strong: #333b47;
--text: #e6edf3;
--text-dim: #9aa4b2;
--text-faint: #6b7683;
--accent: #5b86ff;
--accent-weak: #1a2540;
--match: #4ac57e;
--match-bg: #12291d;
--nomatch: #7d8794;
--nomatch-bg: #1a1f27;
--unknown: #e0a445;
--unknown-bg: #2a2113;
--reject: #f0715c;
--reject-bg: #2c1512;
--shadow: 0 1px 3px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.25);
}
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo {
font-size: 26px; color: var(--accent);
width: 40px; height: 40px; display: grid; place-items: center;
background: var(--accent-weak); border-radius: 10px;
}
.title { font-weight: 700; font-size: 17px; letter-spacing: -.2px; }
.subtitle { color: var(--text-dim); font-size: 12px; }
/* ---------- layout ---------- */
.layout { display: grid; grid-template-columns: 260px 1fr; min-height: calc(100vh - 65px); }
.sidebar {
border-right: 1px solid var(--border);
background: var(--bg-elev);
padding: 14px 12px; overflow-y: auto;
}
.sidebar-head {
display: flex; align-items: center; justify-content: space-between;
font-weight: 600; font-size: 12px; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-dim); margin-bottom: 10px; padding: 0 4px;
}
.profile-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.profile-item {
padding: 9px 10px; border-radius: 8px; cursor: pointer;
display: flex; flex-direction: column; gap: 2px; border: 1px solid transparent;
}
.profile-item:hover { background: var(--bg-sunken); }
.profile-item.active { background: var(--accent-weak); border-color: var(--accent); }
.profile-item .name { font-weight: 600; }
.profile-item .meta { font-size: 11px; color: var(--text-faint); }
.empty-hint { color: var(--text-faint); font-size: 12px; padding: 8px 4px; }
.content { padding: 22px 26px; overflow-y: auto; max-width: 1100px; width: 100%; }
/* ---------- forms ---------- */
.card {
background: var(--bg-elev); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 18px;
}
.card-head {
padding: 12px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.card-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
.card-body { padding: 16px; }
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
label.lbl { display: block; font-weight: 600; margin-bottom: 6px; font-size: 12.5px; }
label.lbl .hint { font-weight: 400; color: var(--text-faint); margin-left: 6px; font-size: 11.5px; }
input[type=text], input[type=url], textarea, select {
width: 100%; padding: 9px 11px; border: 1px solid var(--border-strong);
border-radius: 8px; background: var(--bg); color: var(--text);
font-family: inherit; font-size: 13.5px; outline: none;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
textarea { resize: vertical; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; }
textarea.code { min-height: 180px; white-space: pre; }
textarea.inputs { min-height: 90px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.row > .field { flex: 1; margin-bottom: 0; min-width: 160px; }
.check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
.check input { width: auto; }
/* ---------- buttons ---------- */
.btn {
border: 1px solid var(--border-strong); background: var(--bg-elev); color: var(--text);
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;
font-family: inherit; transition: background .12s, border-color .12s;
}
.btn:hover { background: var(--bg-sunken); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.07); }
.btn.ghost { background: transparent; border-color: transparent; }
.btn.ghost:hover { background: var(--bg-sunken); }
.btn.danger { color: var(--reject); border-color: transparent; background: transparent; }
.btn.danger:hover { background: var(--reject-bg); }
.btn.small { padding: 5px 10px; font-size: 12px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.toolbar .spacer { flex: 1; }
/* ---------- badges ---------- */
.badge {
display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px;
border-radius: 999px; font-size: 11px; font-weight: 700; letter-spacing: .2px;
white-space: nowrap;
}
.badge.kind-domain { background: var(--accent-weak); color: var(--accent); }
.badge.kind-ip { background: var(--bg-sunken); color: var(--text-dim); }
.badge.kind-invalid { background: var(--reject-bg); color: var(--reject); }
.badge.match { background: var(--match-bg); color: var(--match); }
.badge.no_match { background: var(--nomatch-bg); color: var(--nomatch); }
.badge.unknown { background: var(--unknown-bg); color: var(--unknown); }
.badge.reject { background: var(--reject-bg); color: var(--reject); }
.badge.final { background: var(--bg-sunken); color: var(--text-dim); }
/* ---------- results ---------- */
.result-card { margin-bottom: 16px; }
.result-head {
display: flex; align-items: center; gap: 12px; padding: 13px 16px; cursor: pointer;
border-bottom: 1px solid transparent;
}
.result-card.open .result-head { border-bottom-color: var(--border); }
.result-head .input-name { font-family: var(--mono); font-weight: 700; font-size: 14px; }
.result-head .caret { color: var(--text-faint); transition: transform .15s; margin-left: auto; }
.result-card.open .result-head .caret { transform: rotate(90deg); }
.outcome-chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 8px;
font-size: 12px; background: var(--bg-sunken); border: 1px solid var(--border);
}
.chip .k { color: var(--text-faint); font-weight: 600; }
.chip .v { font-family: var(--mono); font-weight: 700; }
.chip.route .v { color: var(--accent); }
.chip.dns .v { color: var(--text); }
.chip.reject { background: var(--reject-bg); border-color: transparent; }
.chip.reject .v { color: var(--reject); }
.result-body { padding: 4px 16px 16px; display: none; }
.result-card.open .result-body { display: block; }
.section-title {
font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-dim);
font-weight: 700; margin: 18px 0 8px;
}
.assume-warn {
background: var(--unknown-bg); color: var(--unknown); border: 1px solid transparent;
padding: 8px 12px; border-radius: 8px; font-size: 12.5px; margin: 6px 0 12px;
display: flex; gap: 8px; align-items: flex-start;
}
.ip-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ip-chip { font-family: var(--mono); font-size: 12px; background: var(--bg-sunken); border: 1px solid var(--border); padding: 3px 8px; border-radius: 6px; }
/* trace steps */
.steps { display: flex; flex-direction: column; gap: 6px; }
.step {
border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--bg);
}
.step.s-match { border-color: var(--match); }
.step.s-unknown { border-left: 3px solid var(--unknown); }
.step.s-selected { box-shadow: 0 0 0 2px var(--match) inset; }
.step-head {
display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer;
}
.step-head:hover { background: var(--bg-sunken); }
.step .idx {
font-family: var(--mono); font-size: 11px; color: var(--text-faint);
min-width: 26px; text-align: center; background: var(--bg-sunken); border-radius: 5px; padding: 1px 4px;
}
.step .summary { font-family: var(--mono); font-size: 12.5px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.step .action-text { font-size: 11.5px; color: var(--text-dim); white-space: nowrap; }
.step .effect { font-size: 11.5px; color: var(--unknown); padding: 0 12px 8px 48px; }
.step-detail { padding: 4px 12px 10px 12px; border-top: 1px dashed var(--border); display: none; }
.step.expanded .step-detail { display: block; }
.step.dimmed { opacity: .62; }
.cond {
display: grid; grid-template-columns: 130px auto 1fr; gap: 8px; align-items: baseline;
padding: 4px 0; font-size: 12.5px; border-bottom: 1px solid var(--border);
}
.cond:last-child { border-bottom: none; }
.cond .cf { font-weight: 600; font-family: var(--mono); font-size: 12px; }
.cond .cv { font-family: var(--mono); color: var(--text-dim); word-break: break-word; }
.cond .cnote { color: var(--text-faint); font-size: 11.5px; grid-column: 1 / -1; margin-left: 130px; }
.cond .group-tag { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .3px; }
.cond .matched-val { color: var(--match); font-family: var(--mono); font-size: 11.5px; }
.ruleset-box { margin: 6px 0 6px 12px; padding: 8px 10px; background: var(--bg-sunken); border-radius: 8px; border: 1px solid var(--border); }
.ruleset-box .rs-head { display: flex; gap: 8px; align-items: center; font-size: 12px; }
.ruleset-box .rs-meta { color: var(--text-faint); font-size: 11px; }
.ruleset-box .rs-err { color: var(--reject); font-size: 11.5px; margin-top: 4px; }
.decision {
margin-top: 10px; padding: 12px 14px; border-radius: 9px; border: 1px solid var(--border);
background: var(--bg-sunken); display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
.decision.route { border-color: var(--accent); background: var(--accent-weak); }
.decision.reject { border-color: var(--reject); background: var(--reject-bg); }
.decision .d-label { font-size: 12px; color: var(--text-dim); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
.decision .d-value { font-family: var(--mono); font-weight: 700; font-size: 15px; }
.decision.route .d-value { color: var(--accent); }
.decision.reject .d-value { color: var(--reject); }
.decision .d-sub { font-size: 12px; color: var(--text-dim); }
.arrow { color: var(--text-faint); }
/* ---------- modal ---------- */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(10,14,20,.5); display: grid; place-items: center; z-index: 50;
}
.modal {
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px;
width: min(560px, 92vw); box-shadow: var(--shadow); max-height: 88vh; overflow: auto;
}
.modal .card-head { position: sticky; top: 0; background: var(--bg-elev); }
/* ---------- toast ---------- */
.toast-root { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 60; }
.toast {
background: var(--bg-elev); border: 1px solid var(--border-strong); border-left: 3px solid var(--accent);
padding: 10px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 13px; max-width: 340px;
animation: slidein .2s ease;
}
.toast.err { border-left-color: var(--reject); }
.toast.ok { border-left-color: var(--match); }
@keyframes slidein { from { transform: translateX(20px); opacity: 0; } }
.muted { color: var(--text-dim); }
.mono { font-family: var(--mono); }
.spinner { display: inline-block; width: 15px; height: 15px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -3px; }
@keyframes spin { to { transform: rotate(360deg); } }
.placeholder { text-align: center; color: var(--text-faint); padding: 60px 20px; }
.placeholder .big { font-size: 40px; opacity: .5; margin-bottom: 10px; }
.file-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.file-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; background: var(--bg-sunken); padding: 6px 10px; border-radius: 7px; }
.file-row .fname { font-family: var(--mono); font-weight: 600; }
.file-row .fmeta { color: var(--text-faint); font-size: 11px; }
+58
View File
@@ -0,0 +1,58 @@
'use strict';
// Web Worker that hosts the sing-vis WebAssembly engine. It loads the Go runtime
// shim + singvis.wasm, keeps the Go program alive, and forwards analyze requests
// to the JS-exported `singvisAnalyze`. Running in a worker keeps the wasm work
// (DoH resolution, rule-set fetching, matching) off the UI thread.
//
// Message protocol:
// main → worker: { id, request } request = the analyze payload
// worker → main: { id, result } on success (result = parsed engine.Result)
// { id, error } on failure (error = message string)
// { type:'ready' } once the wasm engine is initialized
// { type:'loaderror', error } if the wasm failed to load
importScripts('wasm_exec.js');
// Resolved by the Go program (via the `singvisReady` callback it invokes in main)
// once `singvisAnalyze` has been registered on the global scope.
let signalStarted;
const started = new Promise((resolve) => { signalStarted = resolve; });
globalThis.singvisReady = () => signalStarted();
const ready = (async () => {
const go = new Go();
let instance;
try {
// Preferred path; requires the server to send Content-Type: application/wasm.
const res = await WebAssembly.instantiateStreaming(fetch('singvis.wasm'), go.importObject);
instance = res.instance;
} catch (streamErr) {
// Fallback for static servers that don't set the wasm MIME type.
const resp = await fetch('singvis.wasm');
if (!resp.ok) throw new Error('failed to load singvis.wasm (HTTP ' + resp.status + ')');
const bytes = await resp.arrayBuffer();
const res = await WebAssembly.instantiate(bytes, go.importObject);
instance = res.instance;
}
// Do NOT await: the Go main blocks forever (select{}) to keep serving calls.
go.run(instance);
await started;
})();
ready.then(
() => self.postMessage({ type: 'ready' }),
(err) => self.postMessage({ type: 'loaderror', error: (err && err.message) || String(err) })
);
self.onmessage = async (e) => {
const data = e.data || {};
if (data.id == null) return;
try {
await ready;
const out = await globalThis.singvisAnalyze(JSON.stringify(data.request || {}));
self.postMessage({ id: data.id, result: JSON.parse(out) });
} catch (err) {
self.postMessage({ id: data.id, error: (err && err.message) || String(err) });
}
};