diff --git a/README.md b/README.md index c7cc2ca..50271df 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,154 @@ -# ๐Ÿš€ ParallelHTTP +
-A lightweight tool for testing APIs by sending multiple **parallel HTTP requests**. +# ParallelHTTP -It includes: +**Send parallel HTTP requests. Measure latency. Stress-test your APIs.** -- ๐Ÿ’ป **Web UI** -- ๐Ÿณ **Docker image** -- ๐Ÿ’ผ **CLI mode** -- ๐Ÿงฉ **REST API endpoint** +[![Go Version](https://img.shields.io/badge/go-1.24-00ADD8?logo=go&logoColor=white)](https://go.dev) +[![Docker Pulls](https://img.shields.io/docker/pulls/nicumicle/parallelhttp)](https://hub.docker.com/r/nicumicle/parallelhttp) +[![GitHub Release](https://img.shields.io/github/v/release/nicumicle/parallelhttp)](https://github.com/nicumicle/parallelhttp/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](./LICENSE) + +![Screenshot](./screenshot.png) + +
--- -## ๐Ÿ›  Features +## Overview -- Send multiple parallel HTTP requests -- Configure method, endpoint, body, timeout -- Track response time & status -- Aggregated summary: success/error, avg latency -- Export responses to CSV -- Web UI for interactive testing -- CLI for quick terminal runs +ParallelHTTP is a lightweight, zero-dependency tool for load testing and benchmarking HTTP APIs. Configure the number of concurrent requests, method, timeout, and duration โ€” then get back structured results with latency percentiles and per-request status codes. + +It ships as a single binary, a Docker image, and a Web UI โ€” so you can use whichever fits your workflow. --- -## โš ๏ธ Disclaimer +## Features -This tool is intended **only for testing APIs, servers, and domains that you own or have explicit permission to test**. -Running parallel or high-volume requests against systems without authorization may violate: +| | | +|---|---| +| Parallel requests | Fire N requests simultaneously and collect every response | +| Latency percentiles | P50, P90, P99 aggregated across all requests | +| Multiple output formats | `json`, `yaml`, or `text` | +| Web UI | Visual interface for interactive testing in the browser | +| CLI | Scriptable, CI-friendly terminal usage | +| CSV export | Download results directly from the Web UI | +| Test endpoint | Built-in endpoint that returns random responses for trying things out | -- Terms of Service -- Local or international laws -- Computer misuse or anti-fraud regulations -- Responsible use and security policies +--- -> [!IMPORTANT] -> By using this tool, **you agree that you are solely responsible for ensuring you have proper authorization** to test the target systems. -> The authors and contributors of this project assume **no liability** for any misuse, damage, or legal consequences resulting from unauthorized or unethical use. -> -> **Use responsibly. Test only what you own.** +## Quick Start ---- +The fastest way to get up and running: -## ๐Ÿ“ฆ Installation +```bash +# Run the Web UI with Docker โ€” no installation needed +docker run --rm -p 8080:8080 nicumicle/parallelhttp +``` -You can use ParallelHTTP via **binary**, **Docker**, **Web UI**, or **CLI**. +Then open [http://localhost:8080](http://localhost:8080) in your browser. + +--- -### 1๏ธโƒฃ Install Using Binaries (Recommended) +## Installation -Download the latest release from: -๐Ÿ‘‰ [**GitHub Releases**](https://github.com/nicumicle/parallelhttp/releases) +### Binary (Recommended) -Run: +Download the pre-built binary for your OS from the [Releases page](https://github.com/nicumicle/parallelhttp/releases). +**macOS / Linux:** ```bash +# Make it executable +chmod +x parallelhttp + +# Confirm it works ./parallelhttp --help ``` -### 2๏ธโƒฃ Install From Source +**Windows:** -```bash -git clone https://github.com/nicumicle/parallelhttp.git -cd parallelhttp +Download `parallelhttp.exe` and run it from Command Prompt or PowerShell: +```powershell +.\parallelhttp.exe --help ``` -## ๐Ÿ–ฅ๏ธ Web UI +--- -### โ–ถ๏ธ Option A โ€” Run the UI with Docker +### Docker -```shell -docker run --rm -p 8080:8080 -it nicumicle/parallelhttp +```bash +docker run --rm -p 8080:8080 nicumicle/parallelhttp ``` -Open in browser: -๐Ÿ‘‰ [http://localhost:8080](http://localhost:8080) - -### โ–ถ๏ธ Option B โ€” Run the UI from cli +--- -```shell -./parallelhttp --serve --port=8080 -``` +### Build from Source -Open in browser: -๐Ÿ‘‰ [http://localhost:8080](http://localhost:8080) +Requires [Go 1.24+](https://go.dev/dl/). -### โ–ถ๏ธ Option C โ€” Run the UI from Go +```bash +git clone https://github.com/nicumicle/parallelhttp.git +cd parallelhttp -```shell -go run cmd/service/main.go +# Build the binary +go build -o parallelhttp ./cmd/cli/main.go ``` -This service also provides a testing endpoint: - -Test endpoint (returns random responses) -๐Ÿ‘‰ [http://localhost:8080/test](http://localhost:8080/test) +--- -### ๐Ÿ“ท Screenshots +## Usage -![Screenshot](./screenshot.png) +### Web UI -Results: +Start the server using any of the following methods: -![Screenshot](./screenshot-2.png) +```bash +# Using the binary +./parallelhttp --serve --port=8080 -## ๐Ÿ’ผ CLI Usage +# Using Docker +docker run --rm -p 8080:8080 nicumicle/parallelhttp -You can run the CLI using the binary or from source. +# From source +go run cmd/service/main.go +``` -### โ–ถ๏ธ Option A โ€” CLI Binary +Open [http://localhost:8080](http://localhost:8080) in your browser. -```shell -./parallelhttp --help -``` +**Results:** -### โ–ถ๏ธ Option B โ€” Run CLI from Source +![Results Screenshot](./screenshot-2.png) -```shell -go run cmd/cli/main.go --help -``` +--- -``` -CLI Flags - -duration duration Max duration for all calls. 0 = no limit - -endpoint string Required. Target URL - -format string text | yaml | json (default: json) - -method string GET POST PUT PATCH DELETE (default: GET) - -parallel int Number of parallel requests (default: 1) - -timeout duration Request timeout (default: 10s) - -serve bool Starts the HTTP server - -port int Port for the HTTP Server(default: 8080) -``` +### CLI -Example +Run parallel requests directly from your terminal. -```shell -go run cmd/cli/main.go \ - --endpoint=http://localhost:8080/test \ - --parallel=5 \ +```bash +./parallelhttp \ + --endpoint=https://api.example.com/health \ + --parallel=10 \ --method=GET \ --timeout=2s \ - --duration=10s \ + --duration=30s \ --format=json ``` -Output: +**Flags:** + +| Flag | Required | Description | Default | +|------|----------|-------------|---------| +| `--endpoint` | Yes | Target URL | โ€” | +| `--parallel` | No | Number of concurrent requests | `1` | +| `--method` | No | HTTP method: `GET` `POST` `PUT` `PATCH` `DELETE` | `GET` | +| `--timeout` | No | Per-request timeout (e.g. `500ms`, `2s`) | `10s` | +| `--duration` | No | Total run duration (e.g. `30s`, `5m`). `0` = unlimited | `0` | +| `--format` | No | Output format: `json` `yaml` `text` | `json` | +| `--serve` | No | Start the Web UI server | โ€” | +| `--port` | No | Web UI server port | `8080` | + +**Example output (`--format=json`):** ```json { @@ -150,85 +156,66 @@ Output: { "response": { "status_code": 200, - "time": "2025-12-02T04:39:26.450811405+01:00", + "time": "2025-12-02T04:39:26.450811Z", "duration": 176680135, - "duration_h": "176.680135ms" - }, - "error": null, - "error_message": null - }, - { - "response": { - "status_code": 200, - "time": "2025-12-02T04:39:26.450838753+01:00", - "duration": 177105875, - "duration_h": "177.105875ms" - }, - "error": null, - "error_message": null - }, - { - "response": { - "status_code": 200, - "time": "2025-12-02T04:39:26.450989804+01:00", - "duration": 176999320, - "duration_h": "176.99932ms" - }, - "error": null, - "error_message": null - }, - { - "response": { - "status_code": 200, - "time": "2025-12-02T04:39:26.450761076+01:00", - "duration": 177158817, - "duration_h": "177.158817ms" - }, - "error": null, - "error_message": null - }, - { - "response": { - "status_code": 200, - "time": "2025-12-02T04:39:26.450879196+01:00", - "duration": 179940733, - "duration_h": "179.940733ms" + "duration_h": "176.68ms" }, "error": null, "error_message": null } ], "stats": { - "start_time": "2025-12-02T04:39:26.450727731+01:00", - "end_time": "2025-12-02T04:39:26.630824982+01:00", - "duration": "180.097251ms", + "start_time": "2025-12-02T04:39:26.450727Z", + "end_time": "2025-12-02T04:39:26.630824Z", + "duration": "180.09ms", "latency": { - "p50": "177.105875ms", - "p90": "179.940733ms", - "p99": "179.940733ms" + "p50": "177.10ms", + "p90": "179.94ms", + "p99": "179.94ms" } } } ``` -## ๐Ÿงฉ REST Endpoint +--- + +### REST API -When running the UI service: +When the server is running, a built-in test endpoint is available that returns random HTTP responses. It is useful for verifying your setup before pointing the tool at a real API. -```shell +```bash curl http://localhost:8080/test ``` -## ๐Ÿงก Credits +--- + +## Responsible Use + +> [!IMPORTANT] +> ParallelHTTP is intended **only for testing APIs, servers, and domains you own or have explicit permission to test.** +> +> Sending high-volume requests to systems without authorization may violate terms of service, computer misuse laws, or security policies. **The authors assume no liability for any misuse.** +> +> Test only what you own. + +--- + +## Contributing -Built with [Go](https://go.dev/), [Bootstrap](https://getbootstrap.com/), and curiosity. +Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request. +--- + +## License + +MIT โ€” see [LICENSE](./LICENSE) for details. + +--- -## ๐Ÿ“ License +
-Licensed under the MIT License. -See the full license here: [LICENSE](./LICENSE) +Built with [Go](https://go.dev/) and [Bootstrap](https://getbootstrap.com/). -## โญ Support +If you find this useful, give it a โญ -If you like this project, give it a โญ and share it with your friends! +
diff --git a/cmd/service/main.go b/cmd/service/main.go index b41265b..7c89cf4 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -34,7 +34,7 @@ func run() error { srvErr := make(chan error, 1) go func() { - log.Printf("Server started at: %d\n", port) + log.Printf("Server started at 0.0.0.0:%d\n", port) srvErr <- srv.ListenAndServe() }() diff --git a/screenshot-2.png b/screenshot-2.png index 696c62d..76d536d 100644 Binary files a/screenshot-2.png and b/screenshot-2.png differ diff --git a/screenshot.png b/screenshot.png index eae8748..5be8a28 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/static/index.html b/static/index.html index 2cb068e..6b3d2bb 100644 --- a/static/index.html +++ b/static/index.html @@ -1,4 +1,4 @@ - + @@ -9,20 +9,32 @@ rel="stylesheet" /> -
+

ParallelHTTP

Send multiple parallel requests to an endpoint and view each request's status, duration, and summary.

+
+
+ + +
+ + +
+ -
+ +
-
+
+ +
-
+
+

Results

- +
- +

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

No history yet. Run a test to start tracking.

+
+
+
+ +
diff --git a/static/script.js b/static/script.js index 668aff9..86a1b8e 100644 --- a/static/script.js +++ b/static/script.js @@ -1,68 +1,173 @@ -const form = document.getElementById('frm'); -const runBtn = document.getElementById('run'); -const downloadBtn = document.getElementById('downloadCsv'); -const statusEl = document.getElementById('status'); -const output = document.getElementById('output'); -const raw = document.getElementById('raw'); -const resultsArea = document.getElementById('resultsArea'); -const summaryEl = document.getElementById('summary'); -const latencyEl = document.getElementById('latency') -const url = window.location.origin + "/run" - -form.addEventListener('submit', async (ev) => { - ev.preventDefault(); - runBtn.disabled = true; - - output.classList.add('d-none'); - statusEl.textContent = 'Running...'; - resultsArea.innerHTML = ''; - summaryEl.innerHTML = ''; - latencyEl.innerHTML = '' - raw.textContent = ''; - downloadBtn.classList.add('d-none'); +// ==================== Constants ==================== +const API_URL = window.location.origin + "/run"; +const HISTORY_KEY = "parallelhttp_history"; +const MAX_HISTORY = 50; - const payload = { +// ==================== State ==================== +let tabs = []; +let activeTabId = null; +let tabCounter = 0; + +// ==================== Tab Helpers ==================== + +function createDefaultFormState() { + return { + method: 'GET', + endpoint: '', + body: '', + headers: '', + request_timeout: 10000, + max_duration: 30000, + parallel: 5, + }; +} + +function createTab(name) { + tabCounter++; + const id = `tab-${tabCounter}`; + const tab = { + id, + name: name || `Tab ${tabCounter}`, + form: createDefaultFormState(), + result: null, + running: false, + error: null, + }; + tabs.push(tab); + return tab; +} + +function getTab(id) { + return tabs.find(t => t.id === id); +} + +function closeTab(id) { + if (tabs.length <= 1) return; + const idx = tabs.findIndex(t => t.id === id); + if (idx === -1) return; + tabs.splice(idx, 1); + if (activeTabId === id) { + switchTab(tabs[Math.min(idx, tabs.length - 1)].id, true); + } else { + renderTabBar(); + } +} + +// ==================== Form State ==================== + +function readFormFromDOM() { + return { method: document.getElementById('method').value, - endpoint: document.getElementById('endpoint').value.trim(), - parallel: parseInt(document.getElementById('parallel').value, 10) || 1, + endpoint: document.getElementById('endpoint').value, + body: document.getElementById('body').value, + headers: document.getElementById('headers').value, request_timeout: parseInt(document.getElementById('request_timeout').value, 10) || 0, max_duration: parseInt(document.getElementById('max_duration').value, 10) || 0, + parallel: parseInt(document.getElementById('parallel').value, 10) || 1, }; +} - const bodyText = document.getElementById('body').value.trim(); - if (bodyText) { - try { payload.body = JSON.parse(bodyText); } catch { payload.body = bodyText; } - } +function writeFormToDOM(form) { + document.getElementById('method').value = form.method; + document.getElementById('endpoint').value = form.endpoint; + document.getElementById('body').value = form.body; + document.getElementById('headers').value = form.headers; + document.getElementById('request_timeout').value = form.request_timeout; + document.getElementById('max_duration').value = form.max_duration; + document.getElementById('parallel').value = form.parallel; +} - const headersText = document.getElementById('headers').value.trim(); - if (headersText) { - try { payload.headers = JSON.parse(headersText); } catch { payload.headers = headersText; } +// ==================== Tab Switching ==================== + +function switchTab(newId, skipSave) { + if (!skipSave && activeTabId) { + const current = getTab(activeTabId); + if (current) current.form = readFormFromDOM(); } + activeTabId = newId; + const tab = getTab(newId); + if (!tab) return; + writeFormToDOM(tab.form); + renderTabBar(); + renderOutput(tab); +} - try { - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); +// ==================== Tab Bar ==================== - const json = await res.json(); +function renderTabBar() { + const bar = document.getElementById('requestTabBar'); + const addBtn = document.getElementById('addTab'); + bar.querySelectorAll('.request-tab').forEach(el => el.remove()); - raw.textContent = JSON.stringify(json, null, 2); - renderResults(json); + tabs.forEach(tab => { + const el = document.createElement('div'); + el.className = `request-tab d-flex align-items-center gap-2 px-3 py-2${tab.id === activeTabId ? ' request-tab-active' : ''}`; + el.dataset.tabId = tab.id; - output.classList.remove('d-none'); - statusEl.textContent = ''; - downloadBtn.classList.remove('d-none'); + const label = document.createElement('span'); + label.className = 'tab-label'; + label.textContent = tab.name; + el.appendChild(label); - downloadBtn.onclick = () => downloadCSV(json.results || []); + if (tab.running) { + const spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm text-primary'; + spinner.setAttribute('role', 'status'); + el.appendChild(spinner); + } - } catch (err) { - statusEl.textContent = 'Error: ' + err.message; - } finally { - runBtn.disabled = false; + if (tabs.length > 1) { + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'tab-close-btn'; + closeBtn.innerHTML = '×'; + closeBtn.title = 'Close tab'; + closeBtn.addEventListener('click', e => { e.stopPropagation(); closeTab(tab.id); }); + el.appendChild(closeBtn); + } + + el.addEventListener('click', () => switchTab(tab.id)); + bar.insertBefore(el, addBtn); + }); +} + +// ==================== Output ==================== + +function renderOutput(tab) { + const output = document.getElementById('output'); + const statusEl = document.getElementById('status'); + const downloadBtn = document.getElementById('downloadCsv'); + const runBtn = document.getElementById('run'); + + runBtn.disabled = tab.running; + + if (tab.running) { + statusEl.textContent = 'Running...'; + output.classList.add('d-none'); + downloadBtn.classList.add('d-none'); + return; + } + + if (tab.error) { + statusEl.textContent = 'Error: ' + tab.error; + output.classList.add('d-none'); + downloadBtn.classList.add('d-none'); + return; + } + + if (tab.result) { + statusEl.textContent = ''; + renderResults(tab.result); + output.classList.remove('d-none'); + downloadBtn.classList.remove('d-none'); + downloadBtn.onclick = () => downloadCSV(tab.result.results || []); + return; } -}); + + statusEl.textContent = ''; + output.classList.add('d-none'); + downloadBtn.classList.add('d-none'); +} function renderResults(json) { const results = json.results || []; @@ -75,7 +180,7 @@ function renderResults(json) { ['Avg Duration', summary.avg_duration ?? '-'], ]; - summaryEl.innerHTML = cards.map(([title, val]) => ` + document.getElementById('summary').innerHTML = cards.map(([title, val]) => `
Requests
@@ -85,13 +190,13 @@ function renderResults(json) {
`).join(''); - const latencyCars = [ - ['P50', summary.latency.p50 ?? 0], - ['P90', summary.latency.p90 ?? 0], - ['P99', summary.latency.p99 ?? 0], + const latencyCards = [ + ['P50', summary.latency?.p50 ?? 0], + ['P90', summary.latency?.p90 ?? 0], + ['P99', summary.latency?.p99 ?? 0], ]; - - latencyEl.innerHTML = latencyCars.map(([title, val]) => ` + + document.getElementById('latency').innerHTML = latencyCards.map(([title, val]) => `
Latency
@@ -101,7 +206,6 @@ function renderResults(json) {
`).join(''); - // Bootstrap table let table = ` @@ -123,38 +227,207 @@ function renderResults(json) { - - `; + `; }); table += '
${r.status_code || '-'} ${r.duration || '-'} ${r.error || ''}
'; - resultsArea.innerHTML = table; + document.getElementById('resultsArea').innerHTML = table; + document.getElementById('raw').textContent = JSON.stringify(json, null, 2); } +// ==================== Run ==================== + +async function runCurrentTab() { + const tab = getTab(activeTabId); + if (!tab || tab.running) return; + + tab.form = readFormFromDOM(); + tab.running = true; + tab.error = null; + tab.result = null; + + renderTabBar(); + renderOutput(tab); + + const payload = { + method: tab.form.method, + endpoint: tab.form.endpoint.trim(), + parallel: tab.form.parallel || 1, + request_timeout: tab.form.request_timeout || 0, + max_duration: tab.form.max_duration || 0, + }; + + const bodyText = tab.form.body.trim(); + if (bodyText) { + try { payload.body = JSON.parse(bodyText); } catch { payload.body = bodyText; } + } + + const headersText = tab.form.headers.trim(); + if (headersText) { + try { payload.headers = JSON.parse(headersText); } catch { payload.headers = headersText; } + } + + try { + const res = await fetch(API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const json = await res.json(); + tab.result = json; + tab.error = null; + saveToHistory(tab.name, payload, json); + } catch (err) { + tab.error = err.message; + } finally { + tab.running = false; + renderTabBar(); + if (activeTabId === tab.id) { + renderOutput(tab); + } + } +} + +// ==================== History ==================== + +function loadHistory() { + try { + return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; + } catch { + return []; + } +} + +function saveToHistory(tabName, payload, result) { + const history = loadHistory(); + history.unshift({ + id: Date.now(), + timestamp: new Date().toISOString(), + tabName, + payload, + result, + }); + if (history.length > MAX_HISTORY) history.length = MAX_HISTORY; + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); + renderHistory(); +} + +function renderHistory() { + const history = loadHistory(); + const list = document.getElementById('historyList'); + const empty = document.getElementById('historyEmpty'); + + if (!history.length) { + list.innerHTML = ''; + empty.classList.remove('d-none'); + return; + } + + empty.classList.add('d-none'); + list.innerHTML = history.map(entry => { + const s = entry.result?.summary || {}; + const ts = new Date(entry.timestamp).toLocaleString(); + const method = escapeHtml(entry.payload?.method || '-'); + const endpoint = escapeHtml(entry.payload?.endpoint || '-'); + const total = s.total_requests ?? '-'; + const success = s.success_count ?? '-'; + const errors = s.error_count ?? '-'; + return ` +
+
+
+
+ ${method} + ${endpoint} + ${ts} +
+
+ Total: ${total}  + Success: ${success}  + Errors: ${errors} +
+
+ +
+
`; + }).join(''); + + list.querySelectorAll('.restore-btn').forEach(btn => { + btn.addEventListener('click', () => restoreFromHistory(parseInt(btn.dataset.historyId, 10))); + }); +} + +function restoreFromHistory(id) { + const entry = loadHistory().find(e => e.id === id); + if (!entry) return; + const tab = getTab(activeTabId); + if (!tab) return; + const p = entry.payload || {}; + tab.form = { + method: p.method || 'GET', + endpoint: p.endpoint || '', + body: p.body ? (typeof p.body === 'string' ? p.body : JSON.stringify(p.body, null, 2)) : '', + headers: p.headers ? (typeof p.headers === 'string' ? p.headers : JSON.stringify(p.headers, null, 2)) : '', + request_timeout: p.request_timeout || 10000, + max_duration: p.max_duration || 30000, + parallel: p.parallel || 5, + }; + writeFormToDOM(tab.form); +} + +// ==================== CSV ==================== + function downloadCSV(results) { if (!results?.length) return alert('No results to download'); - - const headers = ['#','Time','Status Code','Duration','Error']; + const headers = ['#', 'Time', 'Status Code', 'Duration', 'Error']; const rows = results.map((r, i) => [i + 1, r.time, r.status_code, r.duration, r.error || ''].join(',') ); - - const csvContent = '\uFEFF' + headers.join(',') + '\n' + rows.join('\n'); - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = url; - a.download = 'parallelhttp_results.csv'; + const csv = '\uFEFF' + headers.join(',') + '\n' + rows.join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const a = Object.assign(document.createElement('a'), { + href: URL.createObjectURL(blob), + download: 'parallel_results.csv', + }); a.click(); + URL.revokeObjectURL(a.href); +} - URL.revokeObjectURL(url); +// ==================== Utils ==================== + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); } -// Handle tooltips - document.addEventListener('DOMContentLoaded', function () { - const tooltipTriggerList = [].slice.call( - document.querySelectorAll('[data-bs-toggle="tooltip"]') - ); - tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el)); -}); \ No newline at end of file +// ==================== Init ==================== + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el)); + + const first = createTab('Tab 1'); + activeTabId = first.id; + renderTabBar(); + renderHistory(); + + document.getElementById('frm').addEventListener('submit', ev => { + ev.preventDefault(); + runCurrentTab(); + }); + + document.getElementById('addTab').addEventListener('click', () => { + const t = createTab(); + switchTab(t.id); + }); + + document.getElementById('clearHistory').addEventListener('click', () => { + if (!confirm('Clear all history?')) return; + localStorage.removeItem(HISTORY_KEY); + renderHistory(); + }); +}); diff --git a/static/style.css b/static/style.css index 10439c5..90eaa20 100644 --- a/static/style.css +++ b/static/style.css @@ -1,15 +1,95 @@ +/* ==================== Results tab pane ==================== */ .tab-content { background-color: #fff; - border:1px solid #ccc; + border: 1px solid #ccc; padding: 10px; } -.nav-tabs a.nav-link{ +.nav-tabs a.nav-link { background-color: #fff; color: #000; border: 1px solid #ccc; border-bottom: none; } -.nav-tabs a.nav-link.active{ +.nav-tabs a.nav-link.active { font-weight: bold; box-shadow: 0 -2px #b6cef1; -} \ No newline at end of file +} + +/* ==================== Request tab bar ==================== */ +#requestTabBar .request-tab { + cursor: pointer; + border: 1px solid #dee2e6; + border-bottom: none; + border-radius: 6px 6px 0 0; + background: #f0f4f8; + font-size: 0.875rem; + margin-bottom: -2px; + transition: background 0.12s; + user-select: none; + position: relative; + z-index: 0; + border-bottom: 2px solid #b6cef1; +} + +#requestTabBar .request-tab:hover { + background: #e4eaf0; +} + +#requestTabBar .request-tab-active { + background: #fff; + font-weight: 600; + border-color: #b6cef1; + border-bottom: 2px solid #fff; + z-index: 1; +} + +.request-tab-add { + border: 1px dashed #adb5bd; + border-bottom: none; + border-radius: 6px 6px 0 0; + background: transparent; + color: #6c757d; + padding: 0.35rem 0.7rem; + font-size: 0.875rem; + cursor: pointer; + margin-bottom: -2px; + transition: background 0.12s, color 0.12s; + position: relative; + z-index: 0; +} + +.request-tab-add:hover { + background: #e9ecef; + color: #343a40; +} + +.tab-close-btn { + background: none; + border: none; + padding: 0; + line-height: 1; + font-size: 1.1rem; + color: #6c757d; + cursor: pointer; + opacity: 0.55; +} + +.tab-close-btn:hover { + opacity: 1; + color: #dc3545; +} + +/* ==================== Form connected to tab bar ==================== */ +#frm { + border-top: 2px solid #b6cef1; + border-radius: 0 6px 6px 6px !important; +} + +/* ==================== History ==================== */ +.history-entry { + transition: background-color 0.12s; +} + +.history-entry:hover { + background-color: #f8f9fa !important; +}