(() => {
const API_BASE = String(window.QTMESH_API_BASE || 'https://api.qtmesh.dev').replace(/\/$/, '');
const APP_BASE = String(window.QTMESH_APP_BASE || 'https://qtmesh.dev').replace(/\/$/, '');
const authPanel = document.getElementById('authPanel');
const appPanel = document.getElementById('appPanel');
const flash = document.getElementById('flash');
const userCard = document.getElementById('userCard');
const userAvatar = document.getElementById('userAvatar');
const userPrimary = document.getElementById('userPrimary');
const userSecondaryText = document.getElementById('userSecondaryText');
const userGithubIcon = document.getElementById('userGithubIcon');
const projectDetailProjects = document.getElementById('projectDetailProjects');
const appPageTitle = document.getElementById('appPageTitle');
const appPageSubtitle = document.getElementById('appPageSubtitle');
const issuesByRule = document.getElementById('issuesByRule');
const metricProjects = document.getElementById('metricProjects');
const metricActive = document.getElementById('metricActive');
const metricScans = document.getElementById('metricScans');
const metricScanDelta = document.getElementById('metricScanDelta');
const metricWarnings = document.getElementById('metricWarnings');
const metricWarnDelta = document.getElementById('metricWarnDelta');
const metricErrors = document.getElementById('metricErrors');
const metricErrorDelta = document.getElementById('metricErrorDelta');
const qualityScoreValue = document.getElementById('qualityScoreValue');
const qualityChartWrap = document.getElementById('qualityChartWrap');
const qualityChartSvg = document.getElementById('qualityChartSvg');
const qualityChartPortal = document.getElementById('qualityChartPortal');
const qualitySparkCaption = document.getElementById('qualitySparkCaption');
let qualityPortalTimer = null;
const PAGE_ROUTES = {
dashboard: {
title: 'Overview',
subtitle: 'Monitor scan quality, badges, and project health in one place.',
},
projects: {
title: 'Projects',
subtitle: 'Repositories and pipelines that receive qtmesh scan JSON from CI.',
},
scans: {
title: 'Scans',
subtitle: 'Ingest reports, history, and quality scores per project.',
},
badges: {
title: 'Badges',
subtitle: 'Shields-compatible SVG badges for READMEs and dashboards.',
},
api: {
title: 'API & tokens',
subtitle: 'REST API, sessions, and project ingest tokens.',
},
rules: {
title: 'Rules',
subtitle: 'Remote qtmesh scan configuration per project (CLI can fetch with ingest token).',
},
};
function decodeHashSegment(seg) {
try {
return decodeURIComponent(String(seg || ''));
} catch {
return String(seg || '');
}
}
/** @returns {{ route: string, projectOwner: string | null, projectSlug: string | null }} */
function parseRouteHash() {
const raw = (location.hash || '').replace(/^#\/?/, '').trim();
const parts = raw.split('/').filter(Boolean);
const first = parts[0] || 'dashboard';
if (!PAGE_ROUTES[first]) {
return { route: 'dashboard', projectOwner: null, projectSlug: null };
}
if (first === 'projects' && parts.length >= 3) {
return {
route: 'projects',
projectOwner: decodeHashSegment(parts[1]),
projectSlug: decodeHashSegment(parts[2]),
};
}
if (first === 'rules') {
if (parts.length >= 3) {
return {
route: 'rules',
projectOwner: decodeHashSegment(parts[1]),
projectSlug: decodeHashSegment(parts[2]),
};
}
return { route: 'rules', projectOwner: null, projectSlug: null };
}
if (first === 'badges') {
if (parts.length >= 3) {
return {
route: 'badges',
projectOwner: decodeHashSegment(parts[1]),
projectSlug: decodeHashSegment(parts[2]),
};
}
return { route: 'badges', projectOwner: null, projectSlug: null };
}
return { route: first, projectOwner: null, projectSlug: null };
}
function applyRoute() {
const ri = parseRouteHash();
const key = PAGE_ROUTES[ri.route] ? ri.route : 'dashboard';
document.querySelectorAll('.app-page').forEach((el) => {
el.hidden = el.id !== 'page-' + key;
});
document.querySelectorAll('.sidebar-nav a[data-nav]').forEach((a) => {
a.classList.toggle('active', a.getAttribute('data-nav') === key);
});
const meta = PAGE_ROUTES[key];
if (appPageTitle) appPageTitle.textContent = meta.title;
if (appPageSubtitle) appPageSubtitle.textContent = meta.subtitle;
fillApiBasePlaceholders();
fillIngestScanExample();
fillIngestRulesExample();
const badgesPreset =
key === 'badges' && ri.projectOwner && ri.projectSlug
? ri.projectOwner + '/' + ri.projectSlug
: null;
initBadgesPage(badgesPreset);
if (key === 'api') loadIngestTokens();
if (key === 'scans') loadScansPage(state.scansListPage || 1);
if (key === 'projects' && projectDetailProjects) {
if (ri.projectOwner && ri.projectSlug) {
loadProject(ri.projectOwner, ri.projectSlug, '', projectDetailProjects).catch((err) => {
setFlash(err.message || 'Could not load project', 'err');
projectDetailProjects.innerHTML = '
Could not load this project.
';
});
} else {
projectDetailProjects.innerHTML = 'Select a project from the list.
';
}
}
if (key === 'rules') {
void loadRulesPage(ri.projectOwner, ri.projectSlug);
}
}
function navigateToProjectsProject(ownerSlug, slug) {
location.hash =
'#/projects/' +
encodeURIComponent(String(ownerSlug || '')) +
'/' +
encodeURIComponent(String(slug || ''));
}
/** When the route is '#/badges/owner/slug', preserve that project after project list refresh. */
function badgesPresetFromHash() {
const r = parseRouteHash();
return r.route === 'badges' && r.projectOwner && r.projectSlug
? r.projectOwner + '/' + r.projectSlug
: null;
}
function fillIngestScanExample() {
const el = document.getElementById('ingestScanExample');
if (!el) return;
el.textContent =
'POST ' + API_BASE + '/v1/ingest/scan\nAuthorization: Bearer ';
}
function fillIngestRulesExample() {
const el = document.getElementById('ingestRulesExample');
if (!el) return;
el.textContent =
'GET ' + API_BASE + '/v1/ingest/rules\nAuthorization: Bearer ';
}
function setRulesError(msg) {
const el = document.getElementById('rulesConfigError');
if (!el) return;
if (!msg) {
el.hidden = true;
el.textContent = '';
return;
}
el.hidden = false;
el.textContent = msg;
}
function rulesLinesToStringArray(s) {
return String(s || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
}
function rulesStringArrayToLines(arr) {
if (!Array.isArray(arr)) return '';
return arr.map((x) => String(x)).join('\n');
}
function rulesCommaToStringArray(s) {
return String(s || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
}
function rulesStringArrayToComma(arr) {
if (!Array.isArray(arr)) return '';
return arr.map((x) => String(x)).join(', ');
}
function rulesSetSelectOrAdd(id, value) {
const el = document.getElementById(id);
if (!el) return;
const s = String(value ?? '');
if ([...el.options].some((o) => o.value === s)) {
el.value = s;
return;
}
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
el.appendChild(opt);
el.value = s;
}
function applyRulesFormFromConfig(cfg) {
if (!cfg || typeof cfg !== 'object') return;
const scan = cfg.scan && typeof cfg.scan === 'object' ? cfg.scan : {};
const rules = cfg.rules && typeof cfg.rules === 'object' ? cfg.rules : {};
const fix = cfg.fix && typeof cfg.fix === 'object' ? cfg.fix : {};
const report = cfg.report && typeof cfg.report === 'object' ? cfg.report : {};
const v = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = val != null ? String(val) : '';
};
const c = (id, val) => {
const el = document.getElementById(id);
if (el) el.checked = Boolean(val);
};
v('rulesVersion', cfg.version != null ? cfg.version : 1);
v('rulesScanRoots', rulesStringArrayToLines(scan.roots));
v('rulesScanInclude', rulesStringArrayToLines(scan.include));
v('rulesScanExclude', rulesStringArrayToLines(scan.exclude));
v('rulesAllowedFormats', rulesStringArrayToComma(rules.allowed_formats));
v('rulesForbiddenExt', rulesStringArrayToComma(rules.forbidden_extensions));
v('rulesMinFileMb', rules.min_file_size_mb);
v('rulesMaxFileMb', rules.max_file_size_mb);
v('rulesMinMesh', rules.min_mesh_count);
v('rulesMaxMesh', rules.max_mesh_count);
v('rulesMinMat', rules.min_material_count);
v('rulesMaxMat', rules.max_material_count);
v('rulesMinVert', rules.min_vertex_count);
v('rulesMaxVert', rules.max_vertex_count);
c('rulesReqSkeleton', rules.require_skeleton);
c('rulesReqAnim', rules.require_animations);
v('rulesMinAnimKf', rules.min_anim_keyframes);
v('rulesMaxAnimKf', rules.max_anim_keyframes);
v('rulesMinAnimDur', rules.min_anim_duration);
v('rulesMaxAnimDur', rules.max_anim_duration);
c('rulesAllowEmbTex', rules.allow_embedded_textures !== false);
c('rulesReqTexExist', rules.require_textures_exist === true);
c('rulesAllowMissMat', rules.allow_missing_materials !== false);
rulesSetSelectOrAdd('rulesFileNameCase', rules.file_name_case != null ? String(rules.file_name_case) : '');
const animNamesEl = document.getElementById('rulesReqAnimNamesJson');
if (animNamesEl) {
try {
const a = rules.require_animation_names;
animNamesEl.value = JSON.stringify(Array.isArray(a) ? a : [], null, 2);
} catch {
animNamesEl.value = '[]';
}
}
const boneNamesEl = document.getElementById('rulesReqBoneNamesJson');
if (boneNamesEl) {
try {
const b = rules.require_bone_names;
boneNamesEl.value = JSON.stringify(Array.isArray(b) ? b : [], null, 2);
} catch {
boneNamesEl.value = '[]';
}
}
const scopes = cfg.scopes && typeof cfg.scopes === 'object' && !Array.isArray(cfg.scopes) ? cfg.scopes : {};
const sj = document.getElementById('rulesScopesJson');
if (sj) {
try {
sj.value = JSON.stringify(scopes, null, 2);
} catch {
sj.value = '{}';
}
}
c('rulesFixEnabled', fix.enabled);
c('rulesFixDryRun', fix.dry_run);
c('rulesFixOptMesh', fix.optimize_meshes);
c('rulesFixRenameAnim', fix.rename_animations);
v('rulesFixConvertFmt', fix.convert_to_format);
v('rulesFixOutDir', fix.output_dir);
rulesSetSelectOrAdd('rulesReportFormat', report.format || 'text');
v('rulesReportOut', report.output);
v('rulesReportSarif', report.sarif_output);
rulesSetSelectOrAdd('rulesReportFailOn', report.fail_on || 'error');
}
function collectRulesConfigFromForm() {
function readInt(id, fallback) {
const el = document.getElementById(id);
if (!el || el.value.trim() === '') return fallback;
const n = Number(el.value);
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function readFloat(id, fallback) {
const el = document.getElementById(id);
if (!el || el.value.trim() === '') return fallback;
const n = Number(el.value);
return Number.isFinite(n) ? n : fallback;
}
const versionEl = document.getElementById('rulesVersion');
const version = versionEl ? Number(versionEl.value) : 1;
if (!Number.isFinite(version) || version < 1) {
throw new Error('Config version must be a positive number');
}
let scopesObj = {};
const sj = document.getElementById('rulesScopesJson');
if (sj && sj.value.trim()) {
let p;
try {
p = JSON.parse(sj.value);
} catch {
throw new Error('Invalid scopes JSON');
}
if (typeof p !== 'object' || p === null || Array.isArray(p)) {
throw new Error('Scopes must be a JSON object');
}
scopesObj = p;
}
function parseStringArrayJson(id, label) {
const el = document.getElementById(id);
const raw = el ? el.value.trim() : '';
if (!raw) return [];
let p;
try {
p = JSON.parse(raw);
} catch {
throw new Error('Invalid JSON for ' + label);
}
if (!Array.isArray(p)) {
throw new Error(label + ' must be a JSON array');
}
return p.map((x) => String(x));
}
return {
version,
scan: {
roots: rulesLinesToStringArray(document.getElementById('rulesScanRoots')?.value),
include: rulesLinesToStringArray(document.getElementById('rulesScanInclude')?.value),
exclude: rulesLinesToStringArray(document.getElementById('rulesScanExclude')?.value),
},
rules: {
allowed_formats: rulesCommaToStringArray(document.getElementById('rulesAllowedFormats')?.value),
forbidden_extensions: rulesCommaToStringArray(document.getElementById('rulesForbiddenExt')?.value),
min_file_size_mb: readInt('rulesMinFileMb', 0),
max_file_size_mb: readInt('rulesMaxFileMb', 0),
min_mesh_count: readInt('rulesMinMesh', 0),
max_mesh_count: readInt('rulesMaxMesh', 0),
min_material_count: readInt('rulesMinMat', 0),
max_material_count: readInt('rulesMaxMat', 0),
min_vertex_count: readInt('rulesMinVert', 0),
max_vertex_count: readInt('rulesMaxVert', 0),
require_skeleton: document.getElementById('rulesReqSkeleton')?.checked ?? false,
require_animations: document.getElementById('rulesReqAnim')?.checked ?? false,
allow_embedded_textures: document.getElementById('rulesAllowEmbTex')?.checked ?? true,
require_textures_exist: document.getElementById('rulesReqTexExist')?.checked ?? false,
allow_missing_materials: document.getElementById('rulesAllowMissMat')?.checked ?? true,
file_name_case: String(document.getElementById('rulesFileNameCase')?.value ?? ''),
min_anim_keyframes: readInt('rulesMinAnimKf', 0),
max_anim_keyframes: readInt('rulesMaxAnimKf', 0),
min_anim_duration: readFloat('rulesMinAnimDur', 0),
max_anim_duration: readFloat('rulesMaxAnimDur', 0),
require_animation_names: parseStringArrayJson('rulesReqAnimNamesJson', 'require_animation_names'),
require_bone_names: parseStringArrayJson('rulesReqBoneNamesJson', 'require_bone_names'),
},
scopes: scopesObj,
fix: {
enabled: document.getElementById('rulesFixEnabled')?.checked ?? false,
dry_run: document.getElementById('rulesFixDryRun')?.checked ?? false,
optimize_meshes: document.getElementById('rulesFixOptMesh')?.checked ?? false,
rename_animations: document.getElementById('rulesFixRenameAnim')?.checked ?? false,
convert_to_format: String(document.getElementById('rulesFixConvertFmt')?.value || ''),
output_dir: String(document.getElementById('rulesFixOutDir')?.value || ''),
},
report: {
format: String(document.getElementById('rulesReportFormat')?.value || 'text'),
output: String(document.getElementById('rulesReportOut')?.value || ''),
sarif_output: String(document.getElementById('rulesReportSarif')?.value || ''),
fail_on: String(document.getElementById('rulesReportFailOn')?.value || 'error'),
},
};
}
async function loadRulesPage(presetOwner, presetSlug) {
const sel = document.getElementById('rulesProjectSelect');
const formFields = document.getElementById('rulesFormFields');
const formEmpty = document.getElementById('rulesFormEmpty');
if (!sel || !formFields) return;
const projects = state.projectsList;
sel.innerHTML = projects
.map(
(p) =>
'' +
escapeHtml(p.name) +
' '
)
.join('');
if (projects.length === 0) {
formFields.hidden = true;
if (formEmpty) formEmpty.hidden = false;
setRulesError('');
return;
}
formFields.hidden = false;
if (formEmpty) formEmpty.hidden = true;
let key = presetOwner && presetSlug ? presetOwner + '/' + presetSlug : '';
const valid =
key &&
projects.some((p) => String(p.ownerSlug || '') + '/' + String(p.slug || '') === key);
if (!valid) {
const p = projects[0];
location.hash =
'#/rules/' +
encodeURIComponent(String(p.ownerSlug || '')) +
'/' +
encodeURIComponent(String(p.slug || ''));
return;
}
sel.value = key;
setRulesError('');
try {
const firstSlash = key.indexOf('/');
const ownerSlug = firstSlash >= 0 ? key.slice(0, firstSlash) : key;
const slug = firstSlash >= 0 ? key.slice(firstSlash + 1) : '';
const data = await api(
'/v1/u/' + encodeURIComponent(ownerSlug) + '/p/' + encodeURIComponent(slug) + '/rules'
);
applyRulesFormFromConfig(data.config);
} catch (err) {
setRulesError(err.message || 'Could not load rules');
}
sel.onchange = () => {
const v = String(sel.value || '');
const i = v.indexOf('/');
const os = i >= 0 ? v.slice(0, i) : v;
const sl = i >= 0 ? v.slice(i + 1) : '';
location.hash = '#/rules/' + encodeURIComponent(os) + '/' + encodeURIComponent(sl);
};
}
function fillApiBasePlaceholders() {
document.querySelectorAll('[data-api-base]').forEach((node) => {
node.textContent = API_BASE;
});
}
window.addEventListener('hashchange', () => {
applyRoute();
});
const projectNameInput = document.getElementById('projectName');
const projectSlugInput = document.getElementById('projectSlug');
const projectRepoInput = document.getElementById('projectRepo');
const createProjectToggleBtn = document.getElementById('createProjectToggleBtn');
const createProjectBody = document.getElementById('createProjectBody');
const githubRepoPanel = document.getElementById('githubRepoPanel');
const githubRepoSelect = document.getElementById('githubRepoSelect');
const githubRefreshBtn = document.getElementById('githubRefreshBtn');
const githubLoginBtnSecondary = document.getElementById('githubLoginBtnSecondary');
const state = {
token: localStorage.getItem('qtmesh_cloud_session') || '',
user: null,
githubRepos: [],
projectSlugTouched: false,
qualityTrendPoints: [],
projectsList: [],
scansListPage: 1,
};
let projectDetailLoadSeq = 0;
const BADGE_KINDS = [
{ kind: 'status', title: 'Status', blurb: 'Pass, warning, or fail from the latest scan.' },
{ kind: 'score', title: 'Quality score', blurb: '0–100 score from the penalty formula.' },
{ kind: 'errors', title: 'Errors', blurb: 'Error count from the latest scan.' },
{ kind: 'warnings', title: 'Warnings', blurb: 'Warning count from the latest scan.' },
{ kind: 'models', title: 'Models', blurb: 'Model / mesh count.' },
{ kind: 'animations', title: 'Animations', blurb: 'Animation count.' },
{ kind: 'skeletons', title: 'Skeletons', blurb: 'Skeleton / rig count.' },
{ kind: 'materials', title: 'Materials', blurb: 'Material count.' },
];
function setFlash(msg, level = 'ok') {
flash.textContent = msg;
flash.className = 'status ' + level;
}
function setToken(token) {
state.token = token || '';
if (state.token) localStorage.setItem('qtmesh_cloud_session', state.token);
else localStorage.removeItem('qtmesh_cloud_session');
}
function consumeOAuthCallbackParams() {
const url = new URL(window.location.href);
const token = url.searchParams.get('token');
const oauthError = url.searchParams.get('oauth_error');
const authSource = url.searchParams.get('auth');
if (token) setToken(token);
if (token || oauthError || authSource) {
url.searchParams.delete('token');
url.searchParams.delete('oauth_error');
url.searchParams.delete('auth');
const nextUrl = url.pathname + (url.search ? url.search : '') + url.hash;
window.history.replaceState({}, '', nextUrl);
}
if (oauthError) {
const message = oauthError === 'invalid_oauth_state'
? 'GitHub login expired. Please try again.'
: oauthError === 'missing_oauth_params'
? 'GitHub login did not return required parameters.'
: oauthError === 'github_oauth_not_configured'
? 'GitHub login is not configured on this environment.'
: 'GitHub login failed.';
setFlash(message, 'err');
}
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (!headers.has('Content-Type') && options.body) headers.set('Content-Type', 'application/json');
if (state.token) headers.set('Authorization', 'Bearer ' + state.token);
const res = await fetch(API_BASE + path, { ...options, headers });
const text = await res.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch {
if (!res.ok) throw new Error('HTTP ' + res.status);
throw new Error('Invalid JSON response');
}
}
if (!res.ok) throw new Error((data && data.error) || 'HTTP ' + res.status);
return data;
}
function showAuth() {
authPanel.style.display = 'grid';
appPanel.style.display = 'none';
userCard.style.display = 'none';
state.user = null;
githubRepoPanel.style.display = 'none';
}
function showApp() {
authPanel.style.display = 'none';
appPanel.style.display = 'grid';
userCard.style.display = 'flex';
if (!location.hash || location.hash === '#') {
location.hash = '#/dashboard';
} else {
applyRoute();
}
}
function slugifyProjectName(name) {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
}
function projectApiPrefix(project) {
const owner = encodeURIComponent(String(project.ownerSlug || ''));
const slug = encodeURIComponent(String(project.slug || ''));
return '/v1/u/' + owner + '/p/' + slug;
}
function badgeMarkdown(project, kind) {
const badgeUrl = API_BASE + projectApiPrefix(project) + '/badges/qtmesh-' + kind + '.svg';
const linkUrl = APP_BASE;
return '[](' + linkUrl + ')';
}
function projectListKey(p) {
return String(p.ownerSlug || '') + '/' + String(p.slug || '');
}
function initBadgesPage(presetProjectKey) {
const select = document.getElementById('badgeProjectSelect');
const catalog = document.getElementById('badgeCatalog');
const block = document.getElementById('badgeMarkdownBlock');
if (!select || !catalog || !block) return;
const projects = state.projectsList;
if (!projects.length) {
select.disabled = true;
select.innerHTML = 'No projects yet ';
catalog.innerHTML = 'Create a project first, then return here to copy badge URLs and README markdown.
';
block.textContent = '';
return;
}
select.disabled = false;
const prevKey = select.value;
select.innerHTML = projects
.map((p) => {
const slug = String(p.slug || '');
const ownerSlug = String(p.ownerSlug || '');
const optKey = ownerSlug + '/' + slug;
const name = String(p.name || slug);
return (
'' +
escapeHtml(name) +
' '
);
})
.join('');
const keyMatches = (k) => k && projects.some((p) => projectListKey(p) === k);
const preset =
typeof presetProjectKey === 'string' && presetProjectKey.trim() ? presetProjectKey.trim() : '';
let chosen = '';
if (keyMatches(preset)) chosen = preset;
else if (keyMatches(prevKey)) chosen = prevKey;
else chosen = projectListKey(projects[0]);
if (chosen) select.value = chosen;
const key = select.value;
renderBadgeCatalog(key);
updateBadgesMarkdownForKey(key);
}
function renderBadgeCatalog(key) {
const el = document.getElementById('badgeCatalog');
if (!el) return;
if (!key) {
el.innerHTML = 'No project selected.
';
return;
}
const parts = String(key).split('/');
const ownerSlug = parts[0] || '';
const slug = parts[1] || '';
const base =
API_BASE + '/v1/u/' + encodeURIComponent(ownerSlug) + '/p/' + encodeURIComponent(slug) + '/badges/';
el.innerHTML =
'' +
BADGE_KINDS.map((b) => {
const src = base + 'qtmesh-' + b.kind + '.svg';
const path = '/v1/u/' + ownerSlug + '/p/' + slug + '/badges/qtmesh-' + b.kind + '.svg';
const fullUrl = API_BASE + path;
return (
'
'
);
}).join('') +
'
';
el.querySelectorAll('button[data-copy]').forEach((btn) => {
btn.addEventListener('click', async () => {
const url = btn.getAttribute('data-copy') || '';
if (!url) return;
try {
await navigator.clipboard.writeText(url);
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 1200);
} catch (_e) {
setFlash('Could not copy', 'err');
}
});
});
}
function updateBadgesMarkdownForKey(key) {
const el = document.getElementById('badgeMarkdownBlock');
if (!el) return;
if (!key) {
el.textContent = '';
return;
}
const parts = String(key).split('/');
const ownerSlug = parts[0] || '';
const slug = parts[1] || '';
el.textContent = BADGE_KINDS.map((b) => badgeMarkdown({ ownerSlug, slug }, b.kind)).join('\n');
}
function setCreateProjectExpanded(expanded) {
createProjectBody.classList.toggle('collapsed', !expanded);
createProjectToggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/\"/g, '"')
.replace(/'/g, ''');
}
async function openScanReportDialog(scanId) {
const dlg = document.getElementById('scanReportDialog');
const pre = document.getElementById('scanReportJson');
const titleEl = document.getElementById('scanReportTitle');
const metaEl = document.getElementById('scanReportMeta');
if (!dlg || !pre) return;
if (titleEl) titleEl.textContent = 'Scan report';
if (metaEl) metaEl.textContent = 'Loading…';
pre.textContent = '';
dlg.showModal();
try {
const data = await api('/v1/scans/' + encodeURIComponent(scanId) + '/report');
if (metaEl) metaEl.textContent = 'Scan ID: ' + scanId;
pre.textContent = JSON.stringify(data, null, 2);
} catch (err) {
if (metaEl) metaEl.textContent = '';
pre.textContent = err.message || 'Could not load scan report.';
}
}
async function loadScansPage(page) {
const mount = document.getElementById('scansPageMount');
const pag = document.getElementById('scansPagination');
const ind = document.getElementById('scansPageIndicator');
const prev = document.getElementById('scansPagePrev');
const next = document.getElementById('scansPageNext');
const headMeta = document.getElementById('scansPageHeadMeta');
if (!mount || !pag || !ind || !prev || !next) return;
if (!state.token) {
mount.innerHTML = 'Sign in to view scans.
';
pag.hidden = true;
if (headMeta) headMeta.textContent = '';
return;
}
const limit = 10;
const p = Math.max(1, Number(page) || 1);
state.scansListPage = p;
mount.innerHTML = 'Loading scans…
';
pag.hidden = true;
if (headMeta) headMeta.textContent = '';
try {
const data = await api(
'/v1/scans?page=' + encodeURIComponent(String(p)) + '&limit=' + encodeURIComponent(String(limit))
);
const scans = Array.isArray(data.scans) ? data.scans : [];
const total = Number(data.total) || 0;
const totalPages = Number(data.totalPages) || 0;
const curPage = Number(data.page) || p;
if (headMeta) {
headMeta.textContent = total ? total + ' scan' + (total === 1 ? '' : 's') + ' total' : '';
}
if (scans.length === 0) {
if (total > 0 && curPage > 1) {
loadScansPage(1);
return;
}
mount.innerHTML = 'No scans yet. Ingest a scan from CI to see it here.
';
pag.hidden = true;
return;
}
const thead =
'' +
'When Project Branch Status Score Scanned Errors Warnings Report ' +
' ';
const rows = scans
.map((scan) => {
const when = new Date(scan.ingestedAt).toLocaleString();
const ownerSlug = String(scan.ownerSlug || '');
const slug = String(scan.projectSlug || '');
const name = String(scan.projectName || slug);
return (
'' +
'' +
escapeHtml(when) +
' ' +
'' +
escapeHtml(name) +
' ' +
escapeHtml(ownerSlug + '/' + slug) +
'
' +
'' +
escapeHtml(String(scan.branch || '')) +
' ' +
'' +
escapeHtml(String(scan.status || '')) +
' ' +
'' +
(scan.score != null && scan.score !== '' ? escapeHtml(String(scan.score)) : '—') +
' ' +
'' +
escapeHtml(String(scan.scanned ?? '')) +
' ' +
'' +
escapeHtml(String(scan.errors ?? '')) +
' ' +
'' +
escapeHtml(String(scan.warnings ?? '')) +
' ' +
'View report ' +
' '
);
})
.join('');
mount.innerHTML =
'' + thead + '' + rows + '
';
mount.querySelectorAll('button[data-scan-id]').forEach((btn) => {
btn.addEventListener('click', () => {
const sid = btn.getAttribute('data-scan-id');
if (sid) void openScanReportDialog(sid);
});
});
if (totalPages > 1) {
pag.hidden = false;
ind.textContent = 'Page ' + curPage + ' of ' + totalPages;
prev.disabled = curPage <= 1;
next.disabled = curPage >= totalPages;
} else {
pag.hidden = true;
}
} catch (err) {
mount.innerHTML =
'' + escapeHtml(err.message || 'Could not load scans.') + '
';
pag.hidden = true;
if (headMeta) headMeta.textContent = '';
}
}
function statusClass(status) {
const normalized = String(status || 'no-data').toLowerCase();
if (normalized === 'pass') return 'pass';
if (normalized === 'warning' || normalized === 'warn') return 'warning';
if (normalized === 'error' || normalized === 'fail') return 'error';
return 'no-data';
}
function relativeTime(timestampMs) {
if (!timestampMs) return 'just now';
const delta = Date.now() - Number(timestampMs);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (delta < hour) return Math.max(1, Math.round(delta / minute)) + 'm ago';
if (delta < day) return Math.round(delta / hour) + 'h ago';
return Math.round(delta / day) + 'd ago';
}
function qualityChartLayout() {
const padL = 48;
const padR = 14;
const padT = 12;
const padB = 20;
const vbW = 440;
const vbH = 152;
return {
padL,
padR,
padT,
padB,
vbW,
vbH,
innerW: vbW - padL - padR,
innerH: vbH - padT - padB,
};
}
function yForScoreValue(score, L) {
const v = Math.min(100, Math.max(0, Number(score)));
return L.padT + L.innerH * (1 - v / 100);
}
function xForPointIndex(i, n, L) {
if (n <= 1) return L.padL + L.innerW / 2;
return L.padL + (i * L.innerW) / (n - 1);
}
function qualityChartGridSvg(L) {
const parts = [];
let g = 0;
for (g = 0; g <= 100; g += 10) {
const y = yForScoreValue(g, L);
const strong = g === 0 || g === 100;
parts.push(
' '
);
}
for (g = 0; g <= 100; g += 10) {
const y = yForScoreValue(g, L);
parts.push(
'' +
g +
' '
);
}
return parts.join('');
}
function commitShort(sha) {
if (!sha || typeof sha !== 'string') return '—';
return sha.length > 7 ? sha.slice(0, 7) : sha;
}
function buildQualityPortalHtml(p) {
const when = p.ingestedAt ? new Date(Number(p.ingestedAt)).toLocaleString() : '—';
const key = (p.ownerSlug ? p.ownerSlug + '/' : '') + (p.projectSlug || '');
return (
'' +
'
' +
escapeHtml(p.projectName || 'Project') +
'
' +
'
' +
escapeHtml(key) +
'
' +
'
' +
'Score ' +
escapeHtml(String(p.score ?? '')) +
' ' +
'Status ' +
escapeHtml(String(p.status || '')) +
' ' +
'Branch ' +
escapeHtml(String(p.branch || '')) +
' ' +
'Scanned ' +
escapeHtml(String(p.scanned ?? '')) +
' ' +
'Errors ' +
escapeHtml(String(p.errors ?? '')) +
' ' +
'Warnings ' +
escapeHtml(String(p.warnings ?? '')) +
' ' +
'Commit ' +
escapeHtml(commitShort(p.commitSha)) +
' ' +
'Ingested ' +
escapeHtml(when) +
' ' +
' '
);
}
function cancelHideQualityPortal() {
clearTimeout(qualityPortalTimer);
}
function scheduleHideQualityPortal() {
cancelHideQualityPortal();
qualityPortalTimer = setTimeout(() => {
if (qualityChartPortal) qualityChartPortal.hidden = true;
}, 140);
}
function positionQualityPortal(circle) {
if (!qualityChartWrap || !qualityChartPortal) return;
const cr = circle.getBoundingClientRect();
const wr = qualityChartWrap.getBoundingClientRect();
void qualityChartPortal.offsetWidth;
const pr = qualityChartPortal.getBoundingClientRect();
let left = cr.left + cr.width / 2 - wr.left;
let top = cr.bottom - wr.top + 8;
if (top + pr.height > wr.height - 6) {
top = cr.top - wr.top - pr.height - 8;
}
top = Math.max(6, top);
const half = pr.width / 2;
left = Math.max(half + 10, Math.min(left, wr.width - half - 10));
qualityChartPortal.style.left = left + 'px';
qualityChartPortal.style.top = top + 'px';
qualityChartPortal.style.transform = 'translateX(-50%)';
}
function showQualityPortal(idx) {
cancelHideQualityPortal();
const pt = state.qualityTrendPoints[idx];
if (!pt || !qualityChartPortal || !qualityChartSvg) return;
const circles = qualityChartSvg.querySelectorAll('.quality-chart-point');
const circle = circles[idx];
if (!circle) return;
qualityChartPortal.innerHTML = buildQualityPortalHtml(pt);
qualityChartPortal.hidden = false;
requestAnimationFrame(() => {
positionQualityPortal(circle);
});
}
function wireQualityChartPoints() {
if (!qualityChartSvg) return;
qualityChartSvg.querySelectorAll('.quality-chart-point').forEach((el, idx) => {
el.addEventListener('mouseenter', () => showQualityPortal(idx));
el.addEventListener('mouseleave', scheduleHideQualityPortal);
el.addEventListener('focus', () => showQualityPortal(idx));
el.addEventListener('blur', scheduleHideQualityPortal);
el.addEventListener('touchstart', () => showQualityPortal(idx), { passive: true });
});
}
function renderQualityChartSvg(points) {
state.qualityTrendPoints = Array.isArray(points) ? points : [];
if (!qualityChartSvg) return;
const L = qualityChartLayout();
const parts = [qualityChartGridSvg(L)];
const n = state.qualityTrendPoints.length;
if (n === 0) {
qualityChartSvg.innerHTML = parts.join('');
return;
}
const scores = state.qualityTrendPoints.map((p) => Number(p.score));
if (n >= 2) {
const linePoints = scores
.map((s, i) => xForPointIndex(i, n, L).toFixed(2) + ',' + yForScoreValue(s, L).toFixed(2))
.join(' ');
parts.push(' ');
} else {
const x = xForPointIndex(0, 1, L);
const y = yForScoreValue(scores[0], L);
const w = Math.min(40, L.innerW / 2);
parts.push(
' '
);
}
state.qualityTrendPoints.forEach((p, i) => {
const x = xForPointIndex(i, n, L);
const y = yForScoreValue(p.score, L);
const tip = (p.projectName || 'Scan') + ' · ' + String(p.score) + '/100';
parts.push(
'' +
escapeHtml(tip) +
' '
);
});
qualityChartSvg.innerHTML = parts.join('');
wireQualityChartPoints();
}
if (qualityChartPortal) {
qualityChartPortal.addEventListener('mouseenter', cancelHideQualityPortal);
qualityChartPortal.addEventListener('mouseleave', scheduleHideQualityPortal);
}
if (qualityChartWrap) {
qualityChartWrap.addEventListener('mouseleave', () => {
scheduleHideQualityPortal();
});
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !qualityChartPortal || qualityChartPortal.hidden) return;
cancelHideQualityPortal();
qualityChartPortal.hidden = true;
});
function setQualityScorePill(lastScore) {
qualityScoreValue.classList.remove('score-good', 'score-mid', 'score-low');
if (lastScore === null || lastScore === undefined) {
qualityScoreValue.textContent = '—';
return;
}
const n = Math.round(Number(lastScore));
if (!Number.isFinite(n)) {
qualityScoreValue.textContent = '—';
return;
}
qualityScoreValue.textContent = String(n);
if (n >= 90) qualityScoreValue.classList.add('score-good');
else if (n >= 70) qualityScoreValue.classList.add('score-mid');
else qualityScoreValue.classList.add('score-low');
}
async function refreshQualityScoreCard() {
if (!state.token) return;
try {
const data = await api('/v1/dashboard/quality-trend');
const points = Array.isArray(data.points) ? data.points : [];
const last = data.lastScore;
if (last === null || last === undefined) {
setQualityScorePill(null);
renderQualityChartSvg([]);
if (qualitySparkCaption) {
qualitySparkCaption.textContent =
'No scans yet. Ingest a scan to see your score (0–100) and trend.';
}
return;
}
setQualityScorePill(last);
renderQualityChartSvg(points);
if (qualitySparkCaption) {
const n = points.length;
qualitySparkCaption.textContent =
n <= 1
? 'Hover a point for scan details. More ingests build the trend (newest on the right).'
: 'Hover points for scan details. Last ' + n + ' scans, 0–100 grid (newest on the right).';
}
} catch (_err) {
setQualityScorePill(null);
state.qualityTrendPoints = [];
renderQualityChartSvg([]);
if (qualityChartPortal) qualityChartPortal.hidden = true;
if (qualitySparkCaption) {
qualitySparkCaption.textContent = 'Could not load quality trend.';
}
}
}
function renderRecentScans(projects) {
const entries = projects
.map((project) => ({
name: project.name,
branch: project.latest?.branch || 'main',
status: project.latest?.status || 'no-data',
scanned: Number(project.latest?.scanned || 0),
ingestedAt: project.latest?.ingestedAt || 0,
}))
.sort((a, b) => Number(b.ingestedAt || 0) - Number(a.ingestedAt || 0))
.slice(0, 5);
const mounts = document.querySelectorAll('[data-recent-scans-mount]');
let html = '';
if (entries.length === 0) {
html = 'No scans yet.
';
} else {
html = entries.map((entry) =>
'' +
'
' +
'' + escapeHtml(entry.name) + ' ' +
'' + escapeHtml(entry.branch) + ' · ' + escapeHtml(relativeTime(entry.ingestedAt)) + ' ' +
'
' +
'
' + escapeHtml(entry.status) + ' ' +
'
'
).join('');
}
mounts.forEach((el) => {
el.innerHTML = html;
});
}
async function refreshDashboardRuleIssues() {
if (!issuesByRule) return;
try {
const data = await api('/v1/dashboard/rule-issues');
const rules = Array.isArray(data.rules) ? data.rules : [];
if (rules.length === 0) {
issuesByRule.innerHTML = 'No rule findings on the latest scan per project yet.
';
return;
}
const maxValue = Math.max(...rules.map((r) => Number(r.count) || 0), 1);
issuesByRule.innerHTML = rules
.map((item) => {
const n = Number(item.count) || 0;
const width = Math.round((n / maxValue) * 100);
return (
'' +
'
' +
'
' +
escapeHtml(String(item.rule || '')) +
' ' +
'
' +
'
' +
'
' +
n +
' ' +
'
'
);
})
.join('');
} catch (_err) {
issuesByRule.innerHTML = 'Could not load issues by rule.
';
}
}
function updateDashboardOverview(projects) {
const summary = projects.reduce((acc, project) => {
const latest = project.latest || {};
const status = String(latest.status || 'no-data').toLowerCase();
acc.scanned += Number(latest.scanned || 0);
acc.warnings += Number(latest.warnings || 0);
acc.errors += Number(latest.errors || 0);
if (status !== 'no-data') acc.active += 1;
return acc;
}, { scanned: 0, warnings: 0, errors: 0, active: 0 });
metricProjects.textContent = String(projects.length);
metricActive.textContent = summary.active + ' active';
metricScans.textContent = String(summary.scanned);
metricWarnings.textContent = String(summary.warnings);
metricErrors.textContent = String(summary.errors);
metricScanDelta.textContent = '↑ ' + Math.max(0, Math.round(summary.scanned * 0.22)) + ' this week';
metricWarnDelta.textContent = '↓ ' + Math.max(0, Math.round(summary.warnings * 0.18)) + ' this week';
metricErrorDelta.textContent = '↓ ' + Math.max(0, Math.round(summary.errors * 0.12)) + ' this week';
renderRecentScans(projects);
void refreshDashboardRuleIssues();
}
function renderUserCard() {
if (!state.user || !state.token) {
userCard.style.display = 'none';
return;
}
const primary = (state.user.name && state.user.name.trim()) || state.user.email || 'User';
userPrimary.textContent = primary;
userSecondaryText.textContent = state.user.email || '';
if (state.user.githubLogin) {
userGithubIcon.style.display = 'inline-flex';
userSecondaryText.title = '@' + state.user.githubLogin;
} else {
userGithubIcon.style.display = 'none';
userSecondaryText.removeAttribute('title');
}
if (state.user.avatarUrl) {
userAvatar.src = state.user.avatarUrl;
userAvatar.style.display = 'block';
} else {
userAvatar.removeAttribute('src');
userAvatar.style.display = 'none';
}
userCard.style.display = 'flex';
}
function projectRowHtml(project) {
const latest = project.latest || {};
const status = latest.status || 'no-data';
const apiPrefix = projectApiPrefix(project);
const os = escapeHtml(String(project.ownerSlug || ''));
const s = escapeHtml(String(project.slug || ''));
return (
'' +
'' +
'' +
escapeHtml(project.name) +
' ' +
' ' +
'' + escapeHtml(status) + ' ' +
'' +
(latest.scanned ?? 0) +
' ' +
'' +
(latest.errors ?? 0) +
' ' +
'' +
(latest.warnings ?? 0) +
' ' +
'' +
'' +
' ' +
' ' +
' '
);
}
async function refreshProjects() {
const data = await api('/v1/projects');
state.projectsList = Array.isArray(data.projects) ? data.projects : [];
const mounts = document.querySelectorAll('[data-projects-mount]');
if (state.projectsList.length === 0) {
const empty = 'No projects yet. Create your first one above. ';
mounts.forEach((m) => {
m.innerHTML = empty;
});
updateDashboardOverview([]);
await refreshQualityScoreCard();
initBadgesPage(badgesPresetFromHash());
if (parseRouteHash().route === 'scans') loadScansPage(state.scansListPage || 1);
return;
}
const tableHtml =
'Project Status Scanned Errors Warnings Score ' +
state.projectsList.map(projectRowHtml).join('') +
'
';
mounts.forEach((m) => {
m.innerHTML = tableHtml;
});
updateDashboardOverview(state.projectsList);
await refreshQualityScoreCard();
initBadgesPage(badgesPresetFromHash());
if (parseRouteHash().route === 'scans') loadScansPage(state.scansListPage || 1);
mounts.forEach((m) => {
m.querySelectorAll('button[data-slug]').forEach((btn) => {
btn.addEventListener('click', () => {
const slug = btn.getAttribute('data-slug');
const ownerSlug = btn.getAttribute('data-owner');
navigateToProjectsProject(ownerSlug, slug);
});
});
});
const rr = parseRouteHash();
if (rr.route === 'rules') {
void loadRulesPage(rr.projectOwner, rr.projectSlug);
}
}
function wireBadgeSnippetSelection(project, mountEl) {
const root = mountEl || projectDetailProjects;
if (!root) return;
const snippetEl = root.querySelector('#badgeMarkdownSnippet');
const badges = root.querySelectorAll('[data-badge-kind]');
if (!snippetEl || badges.length === 0) return;
function select(kind) {
snippetEl.textContent = badgeMarkdown(project, kind);
badges.forEach((badge) => {
badge.classList.toggle('active', badge.getAttribute('data-badge-kind') === kind);
});
}
select('status');
badges.forEach((badge) => {
badge.addEventListener('click', () => select(badge.getAttribute('data-badge-kind')));
});
}
async function loadProject(ownerSlug, slug, ingestToken = '', detailMount) {
const mount = detailMount ?? projectDetailProjects;
if (!mount) return;
const seq = ++projectDetailLoadSeq;
const apiPrefix = '/v1/u/' + encodeURIComponent(String(ownerSlug || '')) + '/p/' + encodeURIComponent(String(slug || ''));
const data = await api(apiPrefix);
if (seq !== projectDetailLoadSeq) return;
const scans = data.recentScans || [];
const tokenSection = ingestToken
? 'Project ingest token (shown once) ' + ingestToken + ' '
: '';
mount.innerHTML = '' +
'
' + data.project.name + ' ' + data.project.slug + '
' +
'
role: ' + data.project.role + '
' +
'
' +
tokenSection +
'' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'Badge markdown ' +
'Click one of the badges to switch the snippet.
' +
' ' +
'Recent scans ' +
(scans.length === 0
? 'No scans yet.
'
: 'When Status Score Branch Scanned Errors Warnings ' +
scans.map((scan) => '' + new Date(scan.ingestedAt).toLocaleString() + ' ' + scan.status + ' ' +
(scan.score ?? '-') + ' ' + scan.branch + ' ' + scan.scanned + ' ' + scan.errors + ' ' + scan.warnings + ' ').join('') +
'
') +
'Ingest endpoint POST ' +
API_BASE +
'/v1/ingest/scan\nAuthorization: Bearer <project ingest token> ';
wireBadgeSnippetSelection(data.project, mount);
}
function clearProjectForm() {
projectNameInput.value = '';
projectSlugInput.value = '';
projectRepoInput.value = '';
state.projectSlugTouched = false;
}
function selectedGithubRepo() {
const index = Number(githubRepoSelect.value);
if (!Number.isInteger(index) || index < 0 || index >= state.githubRepos.length) return null;
return state.githubRepos[index];
}
function syncFormWithSelectedGithubRepo() {
const repo = selectedGithubRepo();
if (!repo) return;
projectNameInput.value = repo.name;
projectSlugInput.value = slugifyProjectName(repo.name);
projectRepoInput.value = repo.htmlUrl;
state.projectSlugTouched = false;
}
function renderGithubRepoOptions() {
githubRepoSelect.innerHTML = '';
if (state.githubRepos.length === 0) {
const option = document.createElement('option');
option.value = '-1';
option.textContent = 'No public repositories found';
githubRepoSelect.appendChild(option);
return;
}
state.githubRepos.forEach((repo, index) => {
const option = document.createElement('option');
option.value = String(index);
option.textContent = repo.fullName;
githubRepoSelect.appendChild(option);
});
githubRepoSelect.value = '0';
syncFormWithSelectedGithubRepo();
}
async function refreshGithubRepos() {
if (!state.user || !state.user.githubLogin) {
githubRepoPanel.style.display = 'none';
return;
}
githubRepoPanel.style.display = 'block';
githubRefreshBtn.disabled = true;
try {
const login = encodeURIComponent(state.user.githubLogin);
const url = 'https://api.github.com/users/' + login + '/repos?per_page=100&sort=updated';
const res = await fetch(url, {
headers: {
Accept: 'application/vnd.github+json',
},
});
if (!res.ok) throw new Error('Could not fetch repositories from GitHub');
const repos = await res.json();
if (!Array.isArray(repos)) throw new Error('Invalid repository list response');
state.githubRepos = repos
.filter((repo) => typeof repo?.name === 'string' && typeof repo?.html_url === 'string')
.map((repo) => ({
name: repo.name,
fullName: repo.full_name || repo.name,
htmlUrl: repo.html_url,
}));
renderGithubRepoOptions();
} catch (err) {
state.githubRepos = [];
renderGithubRepoOptions();
setFlash(err.message || 'Failed to load GitHub repositories', 'err');
} finally {
githubRefreshBtn.disabled = false;
}
}
async function createProject(payload) {
const data = await api('/v1/projects', { method: 'POST', body: JSON.stringify(payload) });
setFlash('Project created. Save ingest token now.', 'ok');
clearProjectForm();
await refreshProjects();
await loadIngestTokens();
location.hash =
'#/projects/' +
encodeURIComponent(String(data.project.ownerSlug || '')) +
'/' +
encodeURIComponent(String(data.project.slug || ''));
applyRoute();
await loadProject(data.project.ownerSlug, data.project.slug, data.ingestToken, projectDetailProjects);
return data;
}
function showTokenReveal(token) {
const pre = document.getElementById('tokenRevealValue');
const dlg = document.getElementById('tokenRevealDialog');
if (!pre || !dlg) return;
pre.textContent = token;
dlg.showModal();
}
async function onRegenerateIngestToken(ownerSlug, projectSlug) {
if (!ownerSlug || !projectSlug) return;
if (!window.confirm('Regenerate all ingest tokens for this project? Existing tokens stop working immediately.')) {
return;
}
try {
const data = await api(
'/v1/u/' +
encodeURIComponent(ownerSlug) +
'/p/' +
encodeURIComponent(projectSlug) +
'/tokens/regenerate',
{ method: 'POST' }
);
showTokenReveal(data.ingestToken);
await loadIngestTokens();
} catch (err) {
setFlash(err.message || 'Regenerate failed', 'err');
}
}
async function loadIngestTokens() {
const mount = document.getElementById('ingestTokensTable');
if (!mount || !state.token) return;
try {
const data = await api('/v1/ingest-tokens');
const tokens = data.tokens || [];
if (tokens.length === 0) {
mount.innerHTML =
'No active ingest tokens. Create a project to get a token (shown once in the project panel), or use Regenerate after opening a project on the Projects page.
';
return;
}
mount.innerHTML =
'Project Label Created Actions ' +
tokens
.map((t) => {
const ownerSlug = String(t.ownerSlug || '');
const slug = String(t.projectSlug || '');
const created = t.createdAt ? new Date(Number(t.createdAt)).toLocaleString() : '—';
return (
'' +
'' +
escapeHtml(t.projectName) +
' ' +
escapeHtml(ownerSlug + '/' + slug) +
'
' +
'' +
escapeHtml(t.name) +
' ' +
'' +
escapeHtml(created) +
' ' +
'' +
'Regenerate ' +
' '
);
})
.join('') +
'
';
mount.querySelectorAll('[data-token-regenerate-project]').forEach((btn) => {
btn.addEventListener('click', () => {
onRegenerateIngestToken(
btn.getAttribute('data-token-regenerate-owner'),
btn.getAttribute('data-token-regenerate-project')
);
});
});
} catch (_err) {
mount.innerHTML = 'Could not load ingest tokens.
';
}
}
function setAuthenticatedUser(user) {
state.user = user || null;
renderUserCard();
}
document.getElementById('registerBtn').addEventListener('click', async () => {
try {
const payload = {
name: document.getElementById('regName').value.trim(),
email: document.getElementById('regEmail').value.trim(),
password: document.getElementById('regPassword').value,
};
const data = await api('/v1/auth/register', { method: 'POST', body: JSON.stringify(payload) });
setToken(data.token);
setAuthenticatedUser(data.user);
showApp();
await refreshProjects();
await refreshGithubRepos();
setFlash('Account created.');
} catch (err) {
setFlash(err.message || 'Register failed', 'err');
}
});
document.getElementById('loginBtn').addEventListener('click', async () => {
try {
const payload = {
email: document.getElementById('loginEmail').value.trim(),
password: document.getElementById('loginPassword').value,
};
const data = await api('/v1/auth/login', { method: 'POST', body: JSON.stringify(payload) });
setToken(data.token);
setAuthenticatedUser(data.user);
showApp();
await refreshProjects();
await refreshGithubRepos();
setFlash('Logged in.');
} catch (err) {
setFlash(err.message || 'Login failed', 'err');
}
});
function startGithubLogin() {
const path = window.location.pathname;
const hash = window.location.hash && window.location.hash !== '#' ? window.location.hash : '#/dashboard';
const returnTo = window.location.origin + path + hash;
window.location.assign(API_BASE + '/v1/auth/github/start?return_to=' + encodeURIComponent(returnTo));
}
document.getElementById('githubLoginBtn').addEventListener('click', startGithubLogin);
if (githubLoginBtnSecondary) {
githubLoginBtnSecondary.addEventListener('click', startGithubLogin);
}
document.getElementById('logoutBtn').addEventListener('click', async () => {
try {
await api('/v1/auth/logout', { method: 'POST' });
} catch (_err) {}
setToken('');
state.githubRepos = [];
state.projectsList = [];
showAuth();
if (projectDetailProjects) {
projectDetailProjects.innerHTML = 'Select a project from the list.
';
}
setFlash('Logged out.');
});
projectNameInput.addEventListener('input', () => {
if (!state.projectSlugTouched || projectSlugInput.value.trim() === '') {
projectSlugInput.value = slugifyProjectName(projectNameInput.value);
state.projectSlugTouched = false;
}
});
projectSlugInput.addEventListener('input', () => {
const normalized = slugifyProjectName(projectSlugInput.value);
projectSlugInput.value = normalized;
const generated = slugifyProjectName(projectNameInput.value);
state.projectSlugTouched = normalized !== '' && normalized !== generated;
});
githubRepoSelect.addEventListener('change', () => {
syncFormWithSelectedGithubRepo();
});
githubRefreshBtn.addEventListener('click', async () => {
await refreshGithubRepos();
});
const badgeProjectSelect = document.getElementById('badgeProjectSelect');
if (badgeProjectSelect) {
badgeProjectSelect.addEventListener('change', () => {
const key = badgeProjectSelect.value;
renderBadgeCatalog(key);
updateBadgesMarkdownForKey(key);
});
}
const scansPagePrev = document.getElementById('scansPagePrev');
const scansPageNext = document.getElementById('scansPageNext');
if (scansPagePrev) {
scansPagePrev.addEventListener('click', () => {
const cur = state.scansListPage || 1;
if (cur > 1) loadScansPage(cur - 1);
});
}
if (scansPageNext) {
scansPageNext.addEventListener('click', () => {
loadScansPage((state.scansListPage || 1) + 1);
});
}
createProjectToggleBtn.addEventListener('click', () => {
const expanded = createProjectToggleBtn.getAttribute('aria-expanded') === 'true';
setCreateProjectExpanded(!expanded);
});
document.getElementById('createProjectBtn').addEventListener('click', async () => {
try {
await createProject({
name: projectNameInput.value.trim(),
slug: projectSlugInput.value.trim().toLowerCase(),
repoUrl: projectRepoInput.value.trim() || null,
});
} catch (err) {
setFlash(err.message || 'Project creation failed', 'err');
}
});
const rulesSaveBtn = document.getElementById('rulesSaveBtn');
if (rulesSaveBtn) {
rulesSaveBtn.addEventListener('click', async () => {
const sel = document.getElementById('rulesProjectSelect');
if (!sel) return;
const v = String(sel.value || '');
const i = v.indexOf('/');
const ownerSlug = i >= 0 ? v.slice(0, i) : v;
const slug = i >= 0 ? v.slice(i + 1) : '';
let parsed;
try {
parsed = collectRulesConfigFromForm();
} catch (err) {
setRulesError(err.message || 'Invalid form');
return;
}
try {
await api(
'/v1/u/' + encodeURIComponent(ownerSlug) + '/p/' + encodeURIComponent(slug) + '/rules',
{ method: 'PUT', body: JSON.stringify({ config: parsed }) }
);
setFlash('Rules saved.');
setRulesError('');
} catch (err) {
setRulesError(err.message || 'Save failed');
}
});
}
const tokenRevealCopy = document.getElementById('tokenRevealCopy');
const tokenRevealClose = document.getElementById('tokenRevealClose');
if (tokenRevealCopy) {
tokenRevealCopy.addEventListener('click', async () => {
const pre = document.getElementById('tokenRevealValue');
if (!pre || !pre.textContent) return;
try {
await navigator.clipboard.writeText(pre.textContent);
setFlash('Copied to clipboard.');
} catch (_e) {
setFlash('Could not copy', 'err');
}
});
}
if (tokenRevealClose) {
tokenRevealClose.addEventListener('click', () => {
const dlg = document.getElementById('tokenRevealDialog');
if (dlg) dlg.close();
});
}
const scanReportClose = document.getElementById('scanReportClose');
const scanReportCopy = document.getElementById('scanReportCopy');
if (scanReportClose) {
scanReportClose.addEventListener('click', () => {
const dlg = document.getElementById('scanReportDialog');
if (dlg) dlg.close();
});
}
if (scanReportCopy) {
scanReportCopy.addEventListener('click', async () => {
const pre = document.getElementById('scanReportJson');
if (!pre || !pre.textContent) return;
try {
await navigator.clipboard.writeText(pre.textContent);
setFlash('JSON copied.');
} catch (_e) {
setFlash('Could not copy', 'err');
}
});
}
(async function boot() {
consumeOAuthCallbackParams();
setCreateProjectExpanded(false);
if (!state.token) {
showAuth();
return;
}
try {
const me = await api('/v1/auth/me');
setAuthenticatedUser(me.user);
showApp();
} catch (_err) {
setToken('');
showAuth();
return;
}
try {
await refreshProjects();
await refreshGithubRepos();
} catch (err) {
setFlash(err.message || 'Could not load dashboard data', 'err');
}
})();
})();