commit 71d3faa36c0a4d855eb2cbbbcbb69c63917ae46c Author: Malin Date: Fri May 1 19:36:21 2026 +0200 feat: k6 load testing web UI with Docker - Multi-stage Dockerfile builds k6 from source (grafana/k6) - Express backend with SSE live output streaming - SQLite-backed test history with delete support - Frontend: URL input, VUs, duration, RPS limit, custom headers - Persisted via Docker volume, listens on port 8118 Co-Authored-By: Claude Sonnet 4.6 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9ec2b00 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..482ff42 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Build k6 from source +FROM golang:1.22-alpine AS k6-builder + +RUN apk add --no-cache git + +WORKDIR /src +RUN git clone --depth=1 https://github.com/grafana/k6.git . +RUN go build -o /usr/local/bin/k6 . + +# Final image +FROM node:20-alpine + +# Copy k6 binary from builder +COPY --from=k6-builder /usr/local/bin/k6 /usr/local/bin/k6 + +# Install backend dependencies +WORKDIR /app/backend +COPY backend/package.json . +RUN npm install --production + +COPY backend/server.js . + +# Copy frontend +COPY frontend/ /app/frontend/ + +# Data volume for SQLite history +RUN mkdir -p /data +VOLUME /data + +ENV PORT=8118 +ENV DB_PATH=/data/history.db + +EXPOSE 8118 + +CMD ["node", "/app/backend/server.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..685d4db --- /dev/null +++ b/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "k6-web-ui-backend", + "version": "1.0.0", + "description": "Web UI backend for k6 load testing", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "better-sqlite3": "^9.4.3", + "uuid": "^9.0.0" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..1938943 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,246 @@ +const express = require('express'); +const { spawn } = require('child_process'); +const Database = require('better-sqlite3'); +const { v4: uuidv4 } = require('uuid'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const app = express(); +app.use(express.json()); +app.use(express.static(path.join(__dirname, '../frontend'))); + +// Init SQLite +const DB_PATH = process.env.DB_PATH || '/data/history.db'; +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); +const db = new Database(DB_PATH); + +db.exec(` + CREATE TABLE IF NOT EXISTS tests ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + vus INTEGER NOT NULL, + duration INTEGER NOT NULL, + rps_limit INTEGER, + http_method TEXT NOT NULL DEFAULT 'GET', + request_body TEXT, + headers TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at INTEGER NOT NULL, + finished_at INTEGER, + summary TEXT, + output TEXT + ) +`); + +// Stream active test outputs (testId -> {process, clients}) +const activeTests = {}; + +// Generate a k6 script from test params +function generateScript(params) { + const { url, vus, duration, rpsLimit, httpMethod, requestBody, headers } = params; + + const parsedHeaders = (() => { + try { return JSON.parse(headers || '{}'); } catch { return {}; } + })(); + + const headersJs = Object.entries(parsedHeaders) + .map(([k, v]) => ` '${k.replace(/'/g, "\\'")}': '${v.replace(/'/g, "\\'")}',`) + .join('\n'); + + const bodyLine = (httpMethod !== 'GET' && httpMethod !== 'HEAD' && requestBody) + ? `const body = \`${requestBody.replace(/`/g, '\\`')}\`;` + : 'const body = null;'; + + const rpsOption = rpsLimit && rpsLimit > 0 + ? ` rps: ${rpsLimit},` + : ''; + + return `import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Rate, Counter } from 'k6/metrics'; + +const reqDuration = new Trend('req_duration'); +const errorRate = new Rate('error_rate'); +const requestCount = new Counter('request_count'); + +export const options = { + vus: ${vus}, + duration: '${duration}s', +${rpsOption} + thresholds: { + http_req_duration: ['p(95)<5000'], + error_rate: ['rate<0.1'], + }, +}; + +${bodyLine} + +export default function () { + const params = { + headers: { +${headersJs} + }, + timeout: '30s', + }; + + const res = http.${httpMethod.toLowerCase()}('${url}'${httpMethod !== 'GET' && httpMethod !== 'HEAD' ? ', body' : ''}, params); + + const ok = check(res, { + 'status is 2xx': (r) => r.status >= 200 && r.status < 300, + 'response time < 5s': (r) => r.timings.duration < 5000, + }); + + reqDuration.add(res.timings.duration); + errorRate.add(!ok); + requestCount.add(1); + + sleep(0.1); +} +`; +} + +// POST /api/tests — start a new test +app.post('/api/tests', (req, res) => { + const { + url, + vus = 10, + duration = 30, + rpsLimit = 0, + httpMethod = 'GET', + requestBody = '', + headers = '{}', + } = req.body; + + if (!url || !url.startsWith('http')) { + return res.status(400).json({ error: 'Valid URL required (must start with http/https)' }); + } + + const id = uuidv4(); + const createdAt = Date.now(); + + db.prepare(` + INSERT INTO tests (id, url, vus, duration, rps_limit, http_method, request_body, headers, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?) + `).run(id, url, vus, duration, rpsLimit || null, httpMethod, requestBody, headers, createdAt); + + const script = generateScript({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers }); + const tmpScript = path.join(os.tmpdir(), `k6-script-${id}.js`); + fs.writeFileSync(tmpScript, script); + + let outputBuffer = ''; + const clients = new Set(); + activeTests[id] = { clients }; + + const k6 = spawn('k6', ['run', '--out', 'json=-', tmpScript]); + + const broadcast = (data) => { + outputBuffer += data; + for (const client of clients) { + client.write(`data: ${JSON.stringify({ chunk: data })}\n\n`); + } + }; + + k6.stdout.on('data', (data) => { + // k6 JSON metrics go to stdout when using --out json=- + // We collect but don't broadcast raw JSON (it's noisy) + }); + + k6.stderr.on('data', (data) => { + broadcast(data.toString()); + }); + + k6.on('close', (code) => { + fs.unlink(tmpScript, () => {}); + + // Parse summary from output + const summary = parseSummary(outputBuffer); + const finishedAt = Date.now(); + const status = code === 0 ? 'completed' : 'failed'; + + db.prepare(` + UPDATE tests SET status=?, finished_at=?, summary=?, output=? WHERE id=? + `).run(status, finishedAt, JSON.stringify(summary), outputBuffer, id); + + for (const client of clients) { + client.write(`data: ${JSON.stringify({ done: true, status, summary })}\n\n`); + client.end(); + } + delete activeTests[id]; + }); + + res.json({ id }); +}); + +// GET /api/tests/:id/stream — SSE stream of live output +app.get('/api/tests/:id/stream', (req, res) => { + const { id } = req.params; + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const test = db.prepare('SELECT * FROM tests WHERE id=?').get(id); + if (!test) { + res.write(`data: ${JSON.stringify({ error: 'Test not found' })}\n\n`); + return res.end(); + } + + // If already finished, send stored output and done + if (test.status !== 'running') { + if (test.output) res.write(`data: ${JSON.stringify({ chunk: test.output })}\n\n`); + res.write(`data: ${JSON.stringify({ done: true, status: test.status, summary: JSON.parse(test.summary || 'null') })}\n\n`); + return res.end(); + } + + // Otherwise subscribe to live output + const active = activeTests[id]; + if (!active) { + res.write(`data: ${JSON.stringify({ done: true, status: 'completed' })}\n\n`); + return res.end(); + } + + active.clients.add(res); + req.on('close', () => active.clients.delete(res)); +}); + +// GET /api/tests — list all tests +app.get('/api/tests', (req, res) => { + const rows = db.prepare('SELECT id, url, vus, duration, rps_limit, http_method, status, created_at, finished_at, summary FROM tests ORDER BY created_at DESC LIMIT 50').all(); + res.json(rows.map(r => ({ ...r, summary: r.summary ? JSON.parse(r.summary) : null }))); +}); + +// GET /api/tests/:id — single test detail +app.get('/api/tests/:id', (req, res) => { + const row = db.prepare('SELECT * FROM tests WHERE id=?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'Not found' }); + res.json({ ...row, summary: row.summary ? JSON.parse(row.summary) : null }); +}); + +// DELETE /api/tests/:id — delete a test from history +app.delete('/api/tests/:id', (req, res) => { + db.prepare('DELETE FROM tests WHERE id=?').run(req.params.id); + res.json({ ok: true }); +}); + +// Parse k6 terminal output for key metrics +function parseSummary(output) { + const summary = {}; + const patterns = { + http_reqs: /http_reqs\.*\s+([\d.]+)\s+([\d.]+\/s)/, + http_req_duration: /http_req_duration\.*\s+avg=([\d.]+\w+)\s+min=([\d.]+\w+)\s+med=([\d.]+\w+)\s+max=([\d.]+\w+)\s+p\(90\)=([\d.]+\w+)\s+p\(95\)=([\d.]+\w+)/, + http_req_failed: /http_req_failed\.*\s+([\d.]+%)/, + vus: /vus\.*\s+(\d+)\s+min=(\d+)\s+max=(\d+)/, + iterations: /iterations\.*\s+([\d.]+)\s+([\d.]+\/s)/, + }; + + for (const [key, re] of Object.entries(patterns)) { + const m = output.match(re); + if (m) summary[key] = m.slice(1); + } + return summary; +} + +const PORT = process.env.PORT || 8118; +app.listen(PORT, () => console.log(`k6 Web UI running on http://0.0.0.0:${PORT}`)); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed50701 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + k6-ui: + build: . + ports: + - "8118:8118" + volumes: + - k6-data:/data + restart: unless-stopped + +volumes: + k6-data: diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..10e7ecf --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,279 @@ +'use strict'; + +// Tab switching +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + btn.classList.add('active'); + document.getElementById(`tab-${tab}`).classList.add('active'); + if (tab === 'history') loadHistory(); + }); +}); + +// Sync range <-> number inputs +function syncRangeNumber(rangeId, numId) { + const range = document.getElementById(rangeId); + const num = document.getElementById(numId); + range.addEventListener('input', () => num.value = range.value); + num.addEventListener('input', () => range.value = num.value); +} +syncRangeNumber('vus', 'vus-num'); +syncRangeNumber('duration', 'duration-num'); + +// Show/hide request body for non-GET methods +const methodSel = document.getElementById('httpMethod'); +const bodyGroup = document.getElementById('body-group'); +methodSel.addEventListener('change', () => { + const m = methodSel.value; + bodyGroup.style.display = (m !== 'GET' && m !== 'HEAD') ? '' : 'none'; +}); + +// ---- Run Test ---- +const form = document.getElementById('test-form'); +const startBtn = document.getElementById('start-btn'); +const resultPanel = document.getElementById('result-panel'); +const outputLog = document.getElementById('output-log'); +const resultStatus= document.getElementById('result-status'); +const summaryPanel= document.getElementById('summary-panel'); +const summaryCards= document.getElementById('summary-cards'); + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const url = document.getElementById('url').value.trim(); + const vus = parseInt(document.getElementById('vus-num').value, 10); + const duration = parseInt(document.getElementById('duration-num').value, 10); + const rpsLimit = parseInt(document.getElementById('rpsLimit').value, 10) || 0; + const httpMethod = document.getElementById('httpMethod').value; + const requestBody = document.getElementById('requestBody').value.trim(); + const headers = document.getElementById('headers').value.trim() || '{}'; + + // Validate headers JSON + try { JSON.parse(headers); } catch { + alert('Custom Headers must be valid JSON (or leave empty).'); + return; + } + + startBtn.disabled = true; + startBtn.textContent = '⏳ Running…'; + outputLog.textContent = ''; + summaryPanel.style.display = 'none'; + resultPanel.style.display = ''; + resultStatus.textContent = 'running'; + resultStatus.className = 'badge running'; + + let res; + try { + res = await fetch('/api/tests', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, vus, duration, rpsLimit, httpMethod, requestBody, headers }), + }); + } catch (err) { + alert('Failed to start test: ' + err.message); + resetBtn(); + return; + } + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + alert('Error: ' + err.error); + resetBtn(); + return; + } + + const { id } = await res.json(); + streamTest(id); +}); + +function streamTest(id) { + const es = new EventSource(`/api/tests/${id}/stream`); + + es.onmessage = (e) => { + const data = JSON.parse(e.data); + + if (data.chunk) { + outputLog.textContent += data.chunk; + outputLog.scrollTop = outputLog.scrollHeight; + } + + if (data.done) { + es.close(); + resultStatus.textContent = data.status; + resultStatus.className = `badge ${data.status}`; + if (data.summary) renderSummary(data.summary); + resetBtn(); + } + + if (data.error) { + es.close(); + outputLog.textContent += '\n[error] ' + data.error; + resetBtn(); + } + }; + + es.onerror = () => { + es.close(); + resetBtn(); + }; +} + +function resetBtn() { + startBtn.disabled = false; + startBtn.textContent = '▶ Run Test'; +} + +function renderSummary(summary) { + if (!summary || Object.keys(summary).length === 0) return; + summaryPanel.style.display = ''; + summaryCards.innerHTML = ''; + + const cards = []; + + if (summary.http_reqs) { + cards.push({ label: 'Total Requests', value: summary.http_reqs[0], cls: 'info' }); + cards.push({ label: 'Req/s', value: summary.http_reqs[1], cls: 'info' }); + } + if (summary.http_req_duration) { + cards.push({ label: 'Avg Duration', value: summary.http_req_duration[0], cls: 'info' }); + cards.push({ label: 'p(95) Duration', value: summary.http_req_duration[5], cls: 'info' }); + cards.push({ label: 'Max Duration', value: summary.http_req_duration[3], cls: 'info' }); + } + if (summary.http_req_failed) { + const pct = parseFloat(summary.http_req_failed[0]); + cards.push({ label: 'Failure Rate', value: summary.http_req_failed[0], cls: pct > 5 ? 'bad' : 'good' }); + } + if (summary.iterations) { + cards.push({ label: 'Iterations', value: summary.iterations[0], cls: 'info' }); + } + + for (const c of cards) { + const div = document.createElement('div'); + div.className = 'summary-card'; + div.innerHTML = `
${c.label}
${c.value}
`; + summaryCards.appendChild(div); + } +} + +// ---- History ---- +async function loadHistory() { + const container = document.getElementById('history-list'); + + let tests; + try { + const res = await fetch('/api/tests'); + tests = await res.json(); + } catch { + container.innerHTML = '

Failed to load history.

'; + return; + } + + if (!tests.length) { + container.innerHTML = '

No tests yet.

'; + return; + } + + container.innerHTML = ''; + + for (const t of tests) { + const item = document.createElement('div'); + item.className = 'history-item'; + item.dataset.id = t.id; + + const date = new Date(t.created_at).toLocaleString(); + const dur = t.finished_at ? `${Math.round((t.finished_at - t.created_at) / 1000)}s` : `${t.duration}s`; + + let summaryLine = ''; + if (t.summary) { + const s = t.summary; + const parts = []; + if (s.http_reqs) parts.push(`${s.http_reqs[0]} reqs @ ${s.http_reqs[1]}`); + if (s.http_req_duration) parts.push(`avg ${s.http_req_duration[0]}`); + if (s.http_req_failed) parts.push(`${s.http_req_failed[0]} failed`); + summaryLine = parts.join(' · '); + } + + item.innerHTML = ` +
+
+ ${t.status} + ${escHtml(t.url)} + ${t.http_method} · ${t.vus} VUs · ${dur} +
+
${date}
+ ${summaryLine ? `
${escHtml(summaryLine)}
` : ''} +
+
+ +
+ `; + + item.querySelector('.btn-del').addEventListener('click', async (e) => { + e.stopPropagation(); + if (!confirm('Delete this test from history?')) return; + await fetch(`/api/tests/${t.id}`, { method: 'DELETE' }); + loadHistory(); + }); + + item.addEventListener('click', () => showHistoryDetail(t.id, item)); + + container.appendChild(item); + } +} + +async function showHistoryDetail(id, itemEl) { + // Toggle: if detail already open for this item, close it + const existing = document.getElementById('history-detail'); + if (existing) { + const wasThis = existing.dataset.for === id; + existing.remove(); + if (wasThis) return; + } + + const res = await fetch(`/api/tests/${id}`); + const test = await res.json(); + + const detail = document.createElement('div'); + detail.id = 'history-detail'; + detail.dataset.for = id; + + let summaryHtml = ''; + if (test.summary && Object.keys(test.summary).length) { + const s = test.summary; + const rows = [ + s.http_reqs && `Total Requests${s.http_reqs[0]} (${s.http_reqs[1]})`, + s.http_req_duration && `Duration avg/med/p95/max${s.http_req_duration[0]} / ${s.http_req_duration[2]} / ${s.http_req_duration[5]} / ${s.http_req_duration[3]}`, + s.http_req_failed && `Failure Rate${s.http_req_failed[0]}`, + s.iterations && `Iterations${s.iterations[0]} (${s.iterations[1]})`, + ].filter(Boolean).join(''); + if (rows) summaryHtml = ` + ${rows}
`; + } + + detail.innerHTML = ` +

Details: ${escHtml(test.url)}

+ ${summaryHtml} +
${escHtml(test.output || '(no output)')}
+ `; + + // Add style to table cells inline + detail.querySelectorAll('td').forEach((td, i) => { + td.style.padding = '.3rem .5rem'; + td.style.borderBottom = '1px solid var(--border)'; + if (i % 2 === 0) td.style.color = 'var(--muted)'; + }); + + itemEl.insertAdjacentElement('afterend', detail); +} + +document.getElementById('refresh-history').addEventListener('click', loadHistory); + +function escHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f302b1e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,104 @@ + + + + + + k6 Load Tester + + + +
+
+

⚡ k6 Load Tester

+ +
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ + + +
+ + +
+
+ + +
+ + +
+ + +
+
+

Test History

+ +
+
+

No tests yet.

+
+
+
+ + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..4453fd5 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,246 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --accent: #7c3aed; + --accent2: #a78bfa; + --text: #e2e8f0; + --muted: #64748b; + --green: #22c55e; + --red: #ef4444; + --yellow: #f59e0b; + --mono: 'Fira Code', 'Cascadia Code', 'Consolas', monospace; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.app { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; } + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +h1 { font-size: 1.6rem; font-weight: 700; color: var(--accent2); } + +nav { display: flex; gap: .5rem; } + +.tab-btn { + padding: .45rem 1.1rem; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: .9rem; + transition: all .15s; +} +.tab-btn:hover { color: var(--text); border-color: var(--accent); } +.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +.tab { display: none; } +.tab.active { display: block; } + +/* FORM */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.2rem; + margin-bottom: 1.5rem; +} + +.form-group { display: flex; flex-direction: column; gap: .4rem; } +.form-group.full { grid-column: 1 / -1; } + +label { font-size: .85rem; color: var(--muted); font-weight: 500; } +.hint { font-weight: 400; font-size: .78rem; } + +input[type=url], +input[type=text], +input[type=number], +select, +textarea { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: .55rem .75rem; + font-size: .9rem; + outline: none; + transition: border-color .15s; + width: 100%; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); } +textarea { font-family: var(--mono); resize: vertical; } + +.range-row { display: flex; align-items: center; gap: .75rem; } +.range-row input[type=range] { flex: 1; accent-color: var(--accent); } +.range-row input[type=number] { width: 80px; flex-shrink: 0; } + +.btn-primary { + background: var(--accent); + color: #fff; + border: none; + border-radius: 8px; + padding: .65rem 2rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background .15s, opacity .15s; +} +.btn-primary:hover { background: #6d28d9; } +.btn-primary:disabled { opacity: .5; cursor: not-allowed; } + +.btn-secondary { + background: transparent; + color: var(--accent2); + border: 1px solid var(--accent); + border-radius: 6px; + padding: .4rem 1rem; + font-size: .85rem; + cursor: pointer; + transition: background .15s; +} +.btn-secondary:hover { background: rgba(124,58,237,.15); } + +/* RESULT */ +.result-header { + display: flex; + align-items: center; + gap: 1rem; + margin: 2rem 0 1rem; +} +.result-header h2 { font-size: 1.1rem; } + +.badge { + font-size: .75rem; + font-weight: 600; + padding: .25rem .65rem; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: .05em; +} +.badge.running { background: rgba(245,158,11,.15); color: var(--yellow); } +.badge.completed{ background: rgba(34,197,94,.15); color: var(--green); } +.badge.failed { background: rgba(239,68,68,.15); color: var(--red); } + +#output-log { + background: #0a0c12; + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + font-family: var(--mono); + font-size: .8rem; + line-height: 1.6; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + color: #a5f3fc; +} + +/* SUMMARY CARDS */ +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + margin-top: .75rem; +} +.summary-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} +.summary-card .label { font-size: .75rem; color: var(--muted); margin-bottom: .3rem; } +.summary-card .value { font-size: 1.2rem; font-weight: 700; font-family: var(--mono); } +.summary-card .value.good { color: var(--green); } +.summary-card .value.bad { color: var(--red); } +.summary-card .value.info { color: var(--accent2); } + +/* HISTORY */ +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.history-item { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.2rem; + margin-bottom: .75rem; + display: grid; + grid-template-columns: 1fr auto; + gap: .5rem; + cursor: pointer; + transition: border-color .15s; +} +.history-item:hover { border-color: var(--accent); } + +.history-item .row1 { + display: flex; + align-items: center; + gap: .75rem; + flex-wrap: wrap; +} +.history-item .url { font-weight: 600; word-break: break-all; } +.history-item .meta { font-size: .8rem; color: var(--muted); } + +.history-item .actions { display: flex; align-items: flex-start; gap: .5rem; } +.history-item .btn-del { + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: .2rem; + border-radius: 4px; + transition: color .15s; +} +.history-item .btn-del:hover { color: var(--red); } + +.history-summary { font-size: .8rem; color: var(--muted); margin-top: .4rem; font-family: var(--mono); } + +.empty { color: var(--muted); font-size: .9rem; } + +/* Detail modal / inline */ +#history-detail { + margin-top: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.2rem; +} +#history-detail pre { + background: #0a0c12; + border: 1px solid var(--border); + border-radius: 6px; + padding: .75rem; + font-family: var(--mono); + font-size: .78rem; + max-height: 350px; + overflow-y: auto; + white-space: pre-wrap; + color: #a5f3fc; + margin-top: .75rem; +} + +@media (max-width: 600px) { + .form-grid { grid-template-columns: 1fr; } + .form-group.full { grid-column: 1; } +}