| <!-- This site was generated with the help of the generative AI model Claude Opus 4.6 --> |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>nifi-minifi-cpp — Status Page</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500&display=swap" rel="stylesheet"> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg: #f4f5f7; |
| --surface: #ffffff; |
| --border: #d8dae0; |
| --border-bright: #bfc2ca; |
| --text: #1a1a1e; |
| --muted: #6b7280; |
| --accent: #5b4fd6; |
| --green: #16a34a; |
| --green-dim: #dcfce7; |
| --red: #dc2626; |
| --red-dim: #fee2e2; |
| --yellow: #ca8a04; |
| --yellow-dim: #fef9c3; |
| --blue: #2563eb; |
| --blue-dim: #dbeafe; |
| } |
| |
| [data-theme="dark"] { |
| --bg: #0a0a0b; |
| --surface: #111114; |
| --border: #222228; |
| --border-bright: #333340; |
| --text: #e2e2e8; |
| --muted: #666672; |
| --accent: #7c6aff; |
| --green: #22c55e; |
| --green-dim: #15803d22; |
| --red: #ef4444; |
| --red-dim: #7f1d1d22; |
| --yellow: #eab308; |
| --yellow-dim: #71320a22; |
| --blue: #3b82f6; |
| --blue-dim: #1e3a5f22; |
| } |
| |
| html, body { |
| min-height: 100vh; |
| background: var(--bg); |
| color: var(--text); |
| font-family: 'IBM Plex Sans', sans-serif; |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| |
| body::before { |
| content: ''; |
| position: fixed; |
| inset: 0; |
| background-image: |
| linear-gradient(var(--border) 1px, transparent 1px), |
| linear-gradient(90deg, var(--border) 1px, transparent 1px); |
| background-size: 48px 48px; |
| opacity: 0.4; |
| pointer-events: none; |
| z-index: 0; |
| } |
| |
| .container { |
| position: relative; |
| z-index: 1; |
| max-width: 860px; |
| margin: 0 auto; |
| padding: 56px 24px 80px; |
| } |
| |
| .page-header { margin-bottom: 36px; } |
| |
| .repo-title { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 22px; |
| font-weight: 600; |
| color: var(--text); |
| letter-spacing: -0.02em; |
| } |
| .repo-title a { color: inherit; text-decoration: none; } |
| .repo-title a:hover { color: var(--accent); } |
| .repo-title .slash { color: var(--muted); font-weight: 300; } |
| |
| .page-subtitle { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 13px; |
| color: var(--muted); |
| margin-top: 6px; |
| letter-spacing: 0.05em; |
| } |
| |
| .page-meta { |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| margin-top: 10px; |
| flex-wrap: wrap; |
| } |
| |
| .last-updated { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| color: var(--muted); |
| } |
| |
| .overall-badge { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| font-weight: 500; |
| padding: 3px 10px; |
| border-radius: 20px; |
| letter-spacing: 0.05em; |
| } |
| .overall-badge.ok { background: var(--green-dim); color: var(--green); border: 1px solid #22c55e44; } |
| .overall-badge.degraded { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #eab30844; } |
| .overall-badge.outage { background: var(--red-dim); color: var(--red); border: 1px solid #ef444444; } |
| .overall-badge.loading { background: var(--blue-dim); color: var(--blue); border: 1px solid #3b82f644; } |
| |
| .refresh-btn { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| background: transparent; |
| border: 1px solid var(--border-bright); |
| color: var(--muted); |
| border-radius: 5px; |
| padding: 3px 10px; |
| cursor: pointer; |
| transition: all 0.15s; |
| } |
| .refresh-btn:hover { color: var(--text); border-color: var(--muted); } |
| |
| .divider { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| margin-bottom: 20px; |
| } |
| .divider span { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| color: var(--muted); |
| letter-spacing: 0.1em; |
| text-transform: uppercase; |
| white-space: nowrap; |
| } |
| .divider::before, .divider::after { |
| content: ''; |
| flex: 1; |
| height: 1px; |
| background: var(--border); |
| } |
| |
| .workflows-list { |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| } |
| |
| .workflow-card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 18px 22px; |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| transition: border-color 0.2s, transform 0.15s; |
| animation: fadeUp 0.35s ease both; |
| } |
| .workflow-card:hover { |
| border-color: var(--border-bright); |
| transform: translateY(-1px); |
| } |
| |
| @keyframes fadeUp { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .status-dot { |
| width: 10px; |
| height: 10px; |
| border-radius: 50%; |
| flex-shrink: 0; |
| } |
| .status-dot.success { background: var(--green); box-shadow: 0 0 8px var(--green); } |
| .status-dot.failure { background: var(--red); box-shadow: 0 0 8px var(--red); } |
| .status-dot.cancelled { background: var(--muted); } |
| .status-dot.skipped { background: var(--muted); opacity: 0.5; } |
| .status-dot.in_progress, |
| .status-dot.queued, |
| .status-dot.waiting { background: var(--blue); box-shadow: 0 0 8px var(--blue); animation: pulse 1.5s infinite; } |
| .status-dot.unknown { background: var(--muted); } |
| |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.35; } |
| } |
| |
| .workflow-info { flex: 1; min-width: 0; } |
| |
| .workflow-name { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 14px; |
| font-weight: 500; |
| color: var(--text); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .workflow-sub { |
| display: flex; |
| gap: 14px; |
| margin-top: 4px; |
| flex-wrap: wrap; |
| } |
| .workflow-sub span, |
| .workflow-sub a { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| color: var(--muted); |
| text-decoration: none; |
| } |
| .workflow-sub a:hover { color: var(--accent); } |
| .workflow-sub .err { color: var(--red) !important; } |
| |
| .history-bars { |
| display: flex; |
| gap: 2px; |
| align-items: flex-end; |
| flex-shrink: 0; |
| } |
| .bar { |
| width: 5px; |
| height: 20px; |
| border-radius: 2px; |
| background: var(--border-bright); |
| } |
| .bar.success { background: #bbf7d0; border: 1px solid #22c55e; } |
| .bar.failure { background: #fecaca; border: 1px solid #ef4444; } |
| .bar.in_progress, |
| .bar.queued { background: #bfdbfe; border: 1px solid #3b82f6; } |
| .bar.cancelled, |
| .bar.skipped { background: #d1d5db; } |
| |
| [data-theme="dark"] .bar.success { background: #22c55e55; border: 1px solid #22c55e88; } |
| [data-theme="dark"] .bar.failure { background: #ef444455; border: 1px solid #ef444488; } |
| [data-theme="dark"] .bar.in_progress, |
| [data-theme="dark"] .bar.queued { background: #3b82f655; border: 1px solid #3b82f688; } |
| [data-theme="dark"] .bar.cancelled, |
| [data-theme="dark"] .bar.skipped { background: #333340; } |
| |
| [data-theme="dark"] .overall-badge.ok { border-color: #22c55e44; } |
| [data-theme="dark"] .overall-badge.degraded { border-color: #eab30844; } |
| [data-theme="dark"] .overall-badge.outage { border-color: #ef444444; } |
| [data-theme="dark"] .overall-badge.loading { border-color: #3b82f644; } |
| |
| .status-label { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 12px; |
| font-weight: 500; |
| flex-shrink: 0; |
| text-align: right; |
| min-width: 80px; |
| } |
| .status-label.success { color: var(--green); } |
| .status-label.failure { color: var(--red); } |
| .status-label.in_progress, |
| .status-label.queued, |
| .status-label.waiting { color: var(--blue); } |
| .status-label.cancelled, |
| .status-label.skipped, |
| .status-label.unknown { color: var(--muted); } |
| |
| .state-msg { |
| text-align: center; |
| padding: 60px 24px; |
| font-family: 'IBM Plex Mono', monospace; |
| color: var(--muted); |
| font-size: 13px; |
| border: 1px dashed var(--border-bright); |
| border-radius: 8px; |
| line-height: 2; |
| } |
| .state-msg .icon { font-size: 32px; display: block; margin-bottom: 12px; } |
| |
| .footer-note { |
| text-align: center; |
| margin-top: 40px; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| color: var(--muted); |
| line-height: 2; |
| } |
| .footer-note a { color: var(--accent); text-decoration: none; } |
| .footer-note a:hover { text-decoration: underline; } |
| |
| .token-bar { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| margin-bottom: 20px; |
| padding: 14px 18px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| flex-wrap: wrap; |
| } |
| .token-bar.hidden { display: none; } |
| .token-bar label { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 12px; |
| color: var(--muted); |
| white-space: nowrap; |
| } |
| .token-bar input { |
| flex: 1; |
| min-width: 180px; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 12px; |
| background: var(--bg); |
| color: var(--text); |
| border: 1px solid var(--border-bright); |
| border-radius: 5px; |
| padding: 5px 10px; |
| outline: none; |
| transition: border-color 0.15s; |
| } |
| .token-bar input:focus { border-color: var(--accent); } |
| .token-bar input::placeholder { color: var(--muted); } |
| .token-bar button { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| background: transparent; |
| border: 1px solid var(--border-bright); |
| color: var(--muted); |
| border-radius: 5px; |
| padding: 5px 12px; |
| cursor: pointer; |
| transition: all 0.15s; |
| white-space: nowrap; |
| } |
| .token-bar button:hover { color: var(--text); border-color: var(--muted); } |
| .token-bar .token-status { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| color: var(--green); |
| } |
| .token-toggle { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| background: transparent; |
| border: 1px solid var(--border-bright); |
| color: var(--muted); |
| border-radius: 5px; |
| padding: 3px 10px; |
| cursor: pointer; |
| transition: all 0.15s; |
| } |
| .token-toggle:hover { color: var(--text); border-color: var(--muted); } |
| |
| .theme-toggle { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 11px; |
| background: transparent; |
| border: 1px solid var(--border-bright); |
| color: var(--muted); |
| border-radius: 5px; |
| padding: 3px 10px; |
| cursor: pointer; |
| transition: all 0.15s; |
| } |
| .theme-toggle:hover { color: var(--text); border-color: var(--muted); } |
| |
| @media (max-width: 600px) { |
| .history-bars { display: none; } |
| .status-label { min-width: unset; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| |
| <div class="page-header"> |
| <div class="repo-title"> |
| <a id="repoLink" href="#" target="_blank"> |
| <span id="repoOwner"></span><span class="slash"> / </span><span id="repoName"></span> |
| </a> |
| </div> |
| <div class="page-subtitle">Status Page</div> |
| <div class="page-meta"> |
| <span class="last-updated" id="lastUpdated">Loading…</span> |
| <span class="overall-badge loading" id="overallBadge">● LOADING</span> |
| <button class="refresh-btn" onclick="load()">↻ Refresh</button> |
| <button class="token-toggle" id="tokenToggle" onclick="toggleTokenBar()">🔑 Token</button> |
| <button class="theme-toggle" id="themeToggle" onclick="toggleTheme()">🌙 Dark</button> |
| </div> |
| </div> |
| |
| <div class="token-bar hidden" id="tokenBar"> |
| <label for="tokenInput">GitHub PAT:</label> |
| <input type="password" id="tokenInput" placeholder="ghp_... (stored in localStorage)" /> |
| <button onclick="saveToken()">Save</button> |
| <button onclick="clearToken()">Clear</button> |
| <span class="token-status" id="tokenStatus"></span> |
| <span style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--muted);width:100%;margin-top:4px;"> |
| Unauthenticated GitHub API requests are limited to 60/hr. Adding a <a href="https://github.com/settings/tokens" target="_blank" style="color:var(--accent);">personal access token</a> (no scopes needed for public repos) raises the limit to 5,000/hr. The token is only stored in your browser's localStorage. |
| </span> |
| </div> |
| |
| <div class="divider"><span>Workflows</span></div> |
| |
| <div class="workflows-list" id="workflowsList"> |
| <div class="state-msg"><span class="icon">⏳</span>Fetching workflow status…</div> |
| </div> |
| |
| <div class="footer-note"> |
| Click ↻ Refresh to update · |
| History bars show last 10 runs · |
| Data from <a href="https://docs.github.com/en/rest/actions/workflow-runs" target="_blank">GitHub Actions API</a> |
| </div> |
| |
| </div> |
| <script> |
| function detectRepo() { |
| const hostname = window.location.hostname; |
| const parts = hostname.split('.'); |
| if (parts.length >= 3 && parts[1] === 'github' && parts[2] === 'io') { |
| const owner = parts[0]; |
| const repo = window.location.pathname.split('/').filter(Boolean)[0] || ''; |
| if (owner && repo) return { owner, repo }; |
| } |
| throw new Error('Unable to detect GitHub repo from URL. Make sure this page is hosted on GitHub Pages with a URL like https://OWNER.github.io/REPO/'); |
| } |
| |
| const { owner: OWNER, repo: REPO } = detectRepo(); |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| document.getElementById('repoOwner').textContent = OWNER; |
| document.getElementById('repoName').textContent = REPO; |
| document.getElementById('repoLink').href = `https://github.com/${OWNER}/${REPO}`; |
| }); |
| |
| |
| const CONFIG = { |
| owner: OWNER, |
| repo: REPO, |
| branch: 'main', |
| workflows: [ |
| 'ci.yml', |
| 'memcheck_ci.yml', |
| 'compiler-support.yml', |
| 'create-release-artifacts.yml', |
| 'verify-package.yml' |
| ], |
| token: localStorage.getItem('gh_status_token') || '' |
| }; |
| |
| |
| function getHeaders() { |
| const h = { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; |
| if (CONFIG.token) h['Authorization'] = `Bearer ${CONFIG.token}`; |
| return h; |
| } |
| |
| async function fetchWorkflowName(file) { |
| try { |
| const r = await fetch( |
| `https://api.github.com/repos/${CONFIG.owner}/${CONFIG.repo}/actions/workflows/${file}`, |
| { headers: getHeaders() } |
| ); |
| if (!r.ok) return null; |
| const data = await r.json(); |
| return data.name || null; |
| } catch { return null; } |
| } |
| |
| async function fetchWorkflows() { |
| const { owner, repo, workflows } = CONFIG; |
| const results = []; |
| for (const file of workflows) { |
| try { |
| const r = await fetch( |
| `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${file}/runs?per_page=10&branch=${CONFIG.branch}`, |
| { headers: getHeaders() } |
| ); |
| if (!r.ok) { |
| const e = await r.json().catch(() => ({})); |
| const name = await fetchWorkflowName(file); |
| results.push({ file, name, error: e.message || `HTTP ${r.status}` }); |
| continue; |
| } |
| const data = await r.json(); |
| const runs = data.workflow_runs || []; |
| if (runs.length === 0) { |
| const name = await fetchWorkflowName(file); |
| results.push({ file, name, error: 'No runs found' }); |
| continue; |
| } |
| const latest = runs[0]; |
| const name = await fetchWorkflowName(file) || latest.name; |
| results.push({ |
| file, |
| name, |
| status: latest.status, |
| conclusion: latest.conclusion, |
| html_url: latest.html_url, |
| head_branch: latest.head_branch, |
| run_number: latest.run_number, |
| updated_at: latest.updated_at, |
| history: runs.map(r => r.conclusion || r.status) |
| }); |
| } catch (err) { |
| results.push({ file, error: err.message }); |
| } |
| } |
| return results; |
| } |
| |
| function effectiveStatus(wf) { |
| if (wf.error) return 'unknown'; |
| if (wf.status === 'completed') return wf.conclusion || 'unknown'; |
| return wf.status || 'unknown'; |
| } |
| |
| const LABELS = { |
| success: 'PASSING', failure: 'FAILING', cancelled: 'CANCELLED', |
| skipped: 'SKIPPED', in_progress: 'RUNNING', queued: 'QUEUED', |
| waiting: 'WAITING', unknown: 'UNKNOWN' |
| }; |
| |
| function timeAgo(iso) { |
| const diff = Math.floor((Date.now() - new Date(iso)) / 1000); |
| if (diff < 60) return `${diff}s ago`; |
| if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; |
| if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; |
| return `${Math.floor(diff / 86400)}d ago`; |
| } |
| |
| function renderCard(wf, idx) { |
| const s = effectiveStatus(wf); |
| const bars = (wf.history || []).slice(0, 10).map(h => { |
| const c = h === 'completed' ? 'success' : (h || 'unknown'); |
| return `<div class="bar ${c}" title="${c}"></div>`; |
| }).join(''); |
| return ` |
| <div class="workflow-card" style="animation-delay:${idx * 0.06}s"> |
| <div class="status-dot ${s}"></div> |
| <div class="workflow-info"> |
| <div class="workflow-name">${wf.name || wf.file}</div> |
| <div class="workflow-sub"> |
| <span>${wf.file}</span> |
| ${wf.head_branch ? `<span>branch: ${wf.head_branch}</span>` : ''} |
| ${wf.run_number ? `<a href="${wf.html_url}" target="_blank">run #${wf.run_number}</a>` : ''} |
| ${wf.updated_at ? `<span>${timeAgo(wf.updated_at)}</span>` : ''} |
| ${wf.error ? `<span class="err">⚠ ${wf.error}</span>` : ''} |
| </div> |
| </div> |
| <div class="history-bars">${bars}</div> |
| <div class="status-label ${s}">${LABELS[s] || s.toUpperCase()}</div> |
| </div>`; |
| } |
| |
| async function load() { |
| document.getElementById('lastUpdated').textContent = 'Refreshing…'; |
| document.getElementById('overallBadge').className = 'overall-badge loading'; |
| document.getElementById('overallBadge').textContent = '● LOADING'; |
| |
| try { |
| const workflows = await fetchWorkflows(); |
| const statuses = workflows.map(effectiveStatus); |
| const hasFail = statuses.some(s => s === 'failure'); |
| const hasRunning = statuses.some(s => ['in_progress','queued','waiting'].includes(s)); |
| const allOk = statuses.every(s => s === 'success'); |
| |
| const badge = document.getElementById('overallBadge'); |
| if (hasFail) { badge.className = 'overall-badge outage'; badge.textContent = '● DEGRADED'; } |
| else if (hasRunning) { badge.className = 'overall-badge loading'; badge.textContent = '● RUNNING'; } |
| else if (allOk) { badge.className = 'overall-badge ok'; badge.textContent = '● ALL PASSING'; } |
| else { badge.className = 'overall-badge degraded'; badge.textContent = '● PARTIAL'; } |
| |
| document.getElementById('lastUpdated').textContent = `Updated ${new Date().toLocaleTimeString()}`; |
| document.getElementById('workflowsList').innerHTML = workflows.map(renderCard).join(''); |
| } catch (err) { |
| document.getElementById('workflowsList').innerHTML = |
| `<div class="state-msg"><span class="icon">⚠️</span>${err.message}<br>You may be hitting the GitHub API rate limit (60 req/hr unauthenticated).<br>Click the 🔑 Token button above to add a GitHub PAT.</div>`; |
| showTokenBar(); |
| document.getElementById('overallBadge').className = 'overall-badge outage'; |
| document.getElementById('overallBadge').textContent = '● ERROR'; |
| document.getElementById('lastUpdated').textContent = `Failed at ${new Date().toLocaleTimeString()}`; |
| } |
| |
| } |
| |
| function toggleTokenBar() { |
| document.getElementById('tokenBar').classList.toggle('hidden'); |
| } |
| |
| function showTokenBar() { |
| document.getElementById('tokenBar').classList.remove('hidden'); |
| } |
| |
| function saveToken() { |
| const val = document.getElementById('tokenInput').value.trim(); |
| if (!val) return; |
| localStorage.setItem('gh_status_token', val); |
| CONFIG.token = val; |
| document.getElementById('tokenInput').value = ''; |
| document.getElementById('tokenStatus').textContent = '✓ saved'; |
| setTimeout(() => { document.getElementById('tokenStatus').textContent = '● token set'; }, 2000); |
| load(); |
| } |
| |
| function clearToken() { |
| localStorage.removeItem('gh_status_token'); |
| CONFIG.token = ''; |
| document.getElementById('tokenInput').value = ''; |
| document.getElementById('tokenStatus').textContent = '✓ cleared'; |
| setTimeout(() => { document.getElementById('tokenStatus').textContent = ''; }, 2000); |
| } |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| if (CONFIG.token) { |
| document.getElementById('tokenStatus').textContent = '● token set'; |
| } |
| }); |
| |
| function applyTheme(theme) { |
| document.documentElement.setAttribute('data-theme', theme); |
| const btn = document.getElementById('themeToggle'); |
| btn.textContent = theme === 'light' ? '🌙 Dark' : '☀ Light'; |
| } |
| |
| function toggleTheme() { |
| const current = document.documentElement.getAttribute('data-theme') || 'light'; |
| const next = current === 'light' ? 'dark' : 'light'; |
| localStorage.setItem('status_theme', next); |
| applyTheme(next); |
| } |
| |
| (function initTheme() { |
| const saved = localStorage.getItem('status_theme') || 'light'; |
| applyTheme(saved); |
| })(); |
| |
| load(); |
| </script> |
| </body> |
| </html> |