add all files

This commit is contained in:
Rucus
2026-02-17 09:29:34 -06:00
parent b8c8d67c67
commit 782d203799
21925 changed files with 2433086 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
# SQL Agent Web UI
A lightweight FastAPI + vanilla JS frontend for interacting with the SugarScale SQL agent.
## Backend
Located at `db_agent/ui/backend/main.py`. It exposes:
- `POST /api/query` wraps the existing agent pipeline (LLM + optional SQL execution + logging).
- `GET /health` health probe.
- Serves the static frontend and index page.
### Run locally
```powershell
# Activate your virtual environment first
uvicorn db_agent.ui.backend.main:app --reload --host 0.0.0.0 --port 8000
```
The UI will be available at `http://localhost:8000/`. Adjust the host/port as needed.
## Frontend
Files under `db_agent/ui/frontend/`:
- `index.html` main page with the question form, quick-reference card, and results display.
- `static/app.js` handles form submission, fetches `/api/query`, renders results, and powers CSV/HTML exports plus reset logic.
- `static/styles.css` modern styling.
## Configuration Notes
- The backend reuses environment variables (`DB_SERVER`, `DB_DATABASE`, etc.) for SQL execution, so ensure they are set before starting the FastAPI server. Dot-source `db_agent/scripts/Set-DbEnv.ps1` to populate them in one step and supply `-Password (Read-Host -AsSecureString)` if you prefer an inline prompt.
- The backend expects the LLM endpoint configured in `db_agent/client.LlmConfig.base_url` (defaults to `http://192.168.0.30:8080`). Update if your TGI server runs elsewhere.
- Interaction logs still append to `db_agent/logs/query_log.jsonl` by default.
- Set `UI_BASIC_USER` and `UI_BASIC_PASSWORD` to enable HTTP Basic auth for the web UI/API. When present, every route except `/health` requires those credentials; omit both variables to leave the UI open (useful for local dev).
## UI Tips
- The form defaults to executing SQL and pre-populates table hints with `dbo.SugarLoadData`; use the Reset button to restore these defaults while clearing previous results.
- After running a query, download the result grid as CSV via **Export CSV** or grab a formatted HTML report with **Download Report** for sharing.
- The quick-reference panel beside the form lists common companies/destinations—update it as real-world values change so operators have fresh context.
## Next Ideas
- Add user authentication or API keys.
- Save favorite queries or table presets.
- Stream results for large datasets.

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import logging
import json
import base64
import os
import secrets
from pathlib import Path
from typing import Iterable, Literal, Optional
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from pydantic import BaseModel, Field
from db_agent.client import SqlAgentClient, default_client
from db_agent.log_utils import log_interaction
from db_agent.sql_executor import SqlExecutionResult, SqlValidationError, execute_sql
logger = logging.getLogger("db_agent_ui")
class BasicAuthMiddleware(BaseHTTPMiddleware):
"""Protect routes with HTTP Basic auth when credentials are configured."""
def __init__(
self,
app: FastAPI,
*,
username: str,
password: str,
exclude_prefixes: Iterable[str] = (),
) -> None:
super().__init__(app)
self.username = username
self.password = password
self.exclude_prefixes = tuple(exclude_prefixes)
async def dispatch(self, request: Request, call_next): # type: ignore[override]
if self.exclude_prefixes and request.url.path.startswith(self.exclude_prefixes):
return await call_next(request)
header = request.headers.get("Authorization")
if not header or not header.startswith("Basic "):
return self._unauthorized()
try:
decoded = base64.b64decode(header[6:]).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return self._unauthorized()
provided_user, _, provided_password = decoded.partition(":")
if not (secrets.compare_digest(provided_user, self.username) and secrets.compare_digest(provided_password, self.password)):
return self._unauthorized()
return await call_next(request)
@staticmethod
def _unauthorized() -> Response:
return Response(status_code=401, headers={"WWW-Authenticate": "Basic realm=\"SQL Agent UI\""})
class QueryRequest(BaseModel):
question: str = Field(..., description="Natural language question")
tables: Optional[list[str]] = Field(default=None, description="Optional table hints")
execute: bool = Field(default=False, description="Run the generated SQL against MSSQL")
max_rows: int = Field(default=500, ge=1, description="Row cap applied via TOP clause")
feedback: Optional[str] = Field(default=None, description="Optional user feedback tag")
class QueryResponse(BaseModel):
sql: str
summary: str
sanitized_sql: Optional[str] = None
rows: Optional[list[dict[str, object]]] = None
columns: Optional[list[str]] = None
row_count: Optional[int] = None
llm_warning: Optional[str] = None
execution_status: Optional[str] = None
execution_error: Optional[str] = None
user_feedback: Optional[str] = None
app = FastAPI(title="SugarScale SQL Agent UI", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
basic_user = os.getenv("UI_BASIC_USER")
basic_password = os.getenv("UI_BASIC_PASSWORD")
if basic_user and basic_password:
logger.info("Enabling HTTP Basic auth for UI endpoints")
app.add_middleware(
BasicAuthMiddleware,
username=basic_user,
password=basic_password,
exclude_prefixes=("/health",),
)
elif basic_user or basic_password:
logger.warning("UI basic auth is partially configured; set both UI_BASIC_USER and UI_BASIC_PASSWORD to enable it.")
frontend_dir = Path(__file__).resolve().parent.parent / "frontend"
static_dir = frontend_dir / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=static_dir), name="static")
_agent_client: Optional[SqlAgentClient] = None
def get_client() -> SqlAgentClient:
global _agent_client
if _agent_client is None:
_agent_client = default_client()
return _agent_client
class FeedbackRequest(BaseModel):
question: str = Field(..., description="Question that produced the answer")
sql: str = Field(..., description="Generated SQL that was reviewed")
summary: Optional[str] = None
sanitized_sql: Optional[str] = None
feedback: Literal["correct", "incorrect"] = Field(..., description="User feedback tag")
@app.post("/api/query", response_model=QueryResponse)
def query_endpoint(payload: QueryRequest) -> QueryResponse:
client = get_client()
response = client.query(question=payload.question, table_hints=payload.tables)
sql = response.get("sql", "").strip()
summary = response.get("summary", "").strip()
warning = response.get("llm_warning")
raw_response = json.dumps(response, ensure_ascii=False)
sanitized_sql = None
rows = None
columns = None
row_count = None
execution_status = None
execution_error = None
normalized_sql = sql.lstrip()
has_sql = bool(normalized_sql)
sql_is_select = normalized_sql.lower().startswith("select") if has_sql else False
if payload.execute:
if not has_sql:
warning = warning or "Model did not return executable SQL; nothing was run."
elif not sql_is_select:
warning = warning or "Model did not return a valid SELECT statement; execution skipped."
else:
try:
exec_result: SqlExecutionResult = execute_sql(
normalized_sql,
max_rows=payload.max_rows,
)
except SqlValidationError as exc:
execution_error = str(exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # pylint: disable=broad-except
logger.exception("SQL execution error")
execution_error = str(exc)
raise HTTPException(status_code=500, detail="SQL execution failed") from exc
else:
sanitized_sql = exec_result.sql
rows = exec_result.rows
columns = exec_result.columns
row_count = len(rows)
execution_status = "success"
log_interaction(
question=payload.question,
generated_sql=sql,
summary=summary,
sanitized_sql=sanitized_sql,
row_count=row_count,
execution_status=execution_status,
execution_error=execution_error,
raw_response=raw_response,
user_feedback=payload.feedback,
metadata={
"source": "ui",
"execute": payload.execute,
"tables": payload.tables,
"llm_warning": warning,
},
)
return QueryResponse(
sql=sql,
summary=summary,
sanitized_sql=sanitized_sql,
rows=rows,
columns=columns,
row_count=row_count,
llm_warning=warning,
execution_status=execution_status,
execution_error=execution_error,
user_feedback=payload.feedback,
)
@app.post("/api/feedback")
def record_feedback(payload: FeedbackRequest) -> dict[str, str]:
feedback_value = payload.feedback.lower()
log_interaction(
question=payload.question,
generated_sql=payload.sql,
summary=payload.summary or "",
sanitized_sql=payload.sanitized_sql,
row_count=None,
execution_status=None,
execution_error=None,
raw_response=None,
user_feedback=feedback_value,
metadata={
"source": "ui-feedback",
"feedback_only": True,
},
)
return {"status": "recorded"}
@app.get("/health")
def healthcheck() -> dict[str, str]:
return {"status": "ok"}
@app.get("/")
def index() -> FileResponse:
return FileResponse(frontend_dir / "index.html")

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SugarScale SQL Agent</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header>
<h1>SugarScale SQL Agent</h1>
<p>Ask natural-language questions and execute the generated SQL.</p>
</header>
<main>
<div class="form-columns">
<section class="panel form-panel">
<form id="query-form">
<label for="question">Question</label>
<textarea id="question" name="question" rows="3" placeholder="e.g., How many loads were completed yesterday?" required></textarea>
<label for="tables">Table hints (comma-separated)</label>
<input id="tables" name="tables" type="text" placeholder="dbo.SugarLoadData" value="dbo.SugarLoadData" />
<div class="options">
<label>
<input type="checkbox" id="execute" name="execute" checked /> Execute SQL
</label>
<label for="max-rows">Max rows</label>
<input id="max-rows" name="max_rows" type="number" min="1" value="500" />
</div>
<div class="actions">
<button type="submit">Run</button>
<button type="button" id="reset-form" class="secondary">Reset</button>
</div>
</form>
</section>
<section class="panel helper-panel">
<label>Quick Reference</label>
<p class="helper-intro">Use these static values to include detailed data in your questions.</p>
<div class="helper-grid">
<div>
<label>Companies</label>
<ul>
<li>BigDawg</li>
<li>CSC</li>
<li>LACA</li>
<li>LASUCA</li>
<li>Ralph Callier</li>
</ul>
</div>
<div>
<label>Destinations</label>
<ul>
<li>LSR</li>
<li>CSC</li>
<li>Barge</li>
<li>Other</li>
</ul>
</div>
</div>
<p class="helper-intro">Dates can be represented as 10-12-25, 10/12/25, ect. Time can be exact or as simple as 6am, 4pm, ect.</p>
<div>
<label>Sample Questions</label>
<ul>
<li>List all loads from yesterday.</li>
<li>Show all loads hauled by LACA last week.</li>
<li>Show all loads hauled to barge this week.</li>
<li>Total tons hauled on 10/14/25.</li>
</ul>
</div>
</section>
</div>
<section class="panel results-panel" id="results" hidden>
<h2>Results</h2>
<div class="warning" id="warning" hidden></div>
<div id="summary"></div>
<details>
<summary>Generated SQL</summary>
<pre><code id="generated-sql"></code></pre>
</details>
<details id="sanitized-block" hidden>
<summary>Clean SQL (executed)</summary>
<pre><code id="sanitized-sql"></code></pre>
</details>
<div id="row-count"></div>
<div class="table-wrapper" id="table-wrapper" hidden>
<table id="result-table">
<thead id="result-thead"></thead>
<tbody id="result-tbody"></tbody>
</table>
</div>
<div class="export" id="export-wrapper" hidden>
<button type="button" id="export-results" class="secondary">Export CSV</button>
<button type="button" id="export-report" class="secondary">Download Report</button>
</div>
<div id="feedback-panel" hidden>
<h3>Was this answer helpful?</h3>
<div class="feedback-actions">
<button type="button" id="feedback-correct">Mark Correct</button>
<button type="button" id="feedback-incorrect" class="secondary">Needs Work</button>
</div>
<p id="feedback-status" hidden></p>
</div>
</section>
<section class="panel error" id="error" hidden>
<h2>Error</h2>
<pre id="error-message"></pre>
</section>
</main>
<footer>
<small>Powered by the SugarScale SQL agent.</small>
</footer>
<script src="/static/app.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,365 @@
const form = document.getElementById("query-form");
const resultsPanel = document.getElementById("results");
const errorPanel = document.getElementById("error");
const warningEl = document.getElementById("warning");
const summaryEl = document.getElementById("summary");
const generatedSqlEl = document.getElementById("generated-sql");
const sanitizedBlock = document.getElementById("sanitized-block");
const sanitizedSqlEl = document.getElementById("sanitized-sql");
const rowCountEl = document.getElementById("row-count");
const tableWrapper = document.getElementById("table-wrapper");
const tableEl = document.getElementById("result-table");
const tableHeadEl = document.getElementById("result-thead");
const tableBodyEl = document.getElementById("result-tbody");
const errorMessageEl = document.getElementById("error-message");
const feedbackPanel = document.getElementById("feedback-panel");
const feedbackCorrectBtn = document.getElementById("feedback-correct");
const feedbackIncorrectBtn = document.getElementById("feedback-incorrect");
const feedbackStatus = document.getElementById("feedback-status");
const resetBtn = document.getElementById("reset-form");
const exportWrapper = document.getElementById("export-wrapper");
const exportBtn = document.getElementById("export-results");
const reportBtn = document.getElementById("export-report");
let latestResult = null;
function resetPanels() {
resultsPanel.hidden = true;
errorPanel.hidden = true;
warningEl.hidden = true;
warningEl.textContent = "";
summaryEl.textContent = "";
generatedSqlEl.textContent = "";
sanitizedBlock.hidden = true;
sanitizedSqlEl.textContent = "";
rowCountEl.textContent = "";
tableEl.hidden = true;
tableWrapper.hidden = true;
tableHeadEl.innerHTML = "";
tableBodyEl.innerHTML = "";
errorMessageEl.textContent = "";
feedbackPanel.hidden = true;
feedbackStatus.hidden = true;
feedbackStatus.textContent = "";
exportWrapper.hidden = true;
exportBtn.disabled = true;
if (reportBtn) {
reportBtn.disabled = true;
}
latestResult = null;
}
function renderTable(columns, rows) {
if (!columns || !rows || rows.length === 0) {
tableEl.hidden = true;
tableWrapper.hidden = true;
return false;
}
tableHeadEl.innerHTML = "";
tableBodyEl.innerHTML = "";
const headerRow = document.createElement("tr");
columns.forEach((col) => {
const th = document.createElement("th");
th.textContent = col;
headerRow.appendChild(th);
});
tableHeadEl.appendChild(headerRow);
rows.forEach((row) => {
const tr = document.createElement("tr");
columns.forEach((col) => {
const td = document.createElement("td");
const value = row[col];
td.textContent = value === null || value === undefined ? "" : String(value);
tr.appendChild(td);
});
tableBodyEl.appendChild(tr);
});
tableEl.hidden = false;
tableWrapper.hidden = false;
return true;
}
function toCsv(columns, rows) {
const escapeCell = (value) => {
if (value === null || value === undefined) {
return "";
}
const stringValue = String(value);
if (/[",\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
};
const header = columns.map(escapeCell).join(",");
const lines = rows.map((row) => columns.map((col) => escapeCell(row[col])).join(","));
return [header, ...lines].join("\n");
}
function downloadCsv(filename, columns, rows) {
const csvContent = toCsv(columns, rows);
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
resetPanels();
const question = document.getElementById("question").value.trim();
const tablesValue = document.getElementById("tables").value.trim();
const execute = document.getElementById("execute").checked;
const maxRows = parseInt(document.getElementById("max-rows").value, 10) || 500;
if (!question) {
errorPanel.hidden = false;
errorMessageEl.textContent = "Please enter a question.";
return;
}
const payload = {
question,
tables: tablesValue ? tablesValue.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
execute,
max_rows: maxRows,
feedback: null,
};
try {
const response = await fetch("/api/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
}
const data = await response.json();
summaryEl.textContent = data.summary || "(No summary provided)";
generatedSqlEl.textContent = data.sql || "";
if (data.llm_warning) {
warningEl.textContent = data.llm_warning;
warningEl.hidden = false;
}
if (data.sanitized_sql) {
sanitizedSqlEl.textContent = data.sanitized_sql;
sanitizedBlock.hidden = false;
}
if (typeof data.row_count === "number") {
rowCountEl.textContent = `Rows returned: ${data.row_count}`;
}
const hasRows = renderTable(data.columns, data.rows);
latestResult = {
question,
sql: data.sql || "",
summary: data.summary || "",
sanitized_sql: data.sanitized_sql || null,
columns: Array.isArray(data.columns) ? data.columns : [],
rows: Array.isArray(data.rows) ? data.rows : [],
warning: data.llm_warning || null,
rowCount: typeof data.row_count === "number" ? data.row_count : null,
};
feedbackPanel.hidden = false;
feedbackStatus.hidden = true;
feedbackStatus.textContent = "";
const canExportTable = hasRows && latestResult.rows.length > 0;
if (reportBtn) {
exportWrapper.hidden = false;
reportBtn.disabled = false;
exportBtn.disabled = !canExportTable;
} else if (canExportTable) {
exportWrapper.hidden = false;
exportBtn.disabled = false;
}
resultsPanel.hidden = false;
} catch (error) {
errorPanel.hidden = false;
errorMessageEl.textContent = error.message || String(error);
}
});
resetBtn.addEventListener("click", () => {
form.reset();
document.getElementById("tables").value = document.getElementById("tables").defaultValue;
document.getElementById("max-rows").value = document.getElementById("max-rows").defaultValue;
document.getElementById("execute").checked = document.getElementById("execute").defaultChecked;
resetPanels();
});
exportBtn.addEventListener("click", () => {
if (!latestResult || !latestResult.columns || latestResult.rows.length === 0) {
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `sql-agent-results-${timestamp}.csv`;
downloadCsv(filename, latestResult.columns, latestResult.rows);
});
function escapeHtml(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function buildReportHtml(result) {
const timestamp = new Date().toLocaleString();
const rowsHtml = result.rows
.map(
(row) =>
`<tr>${result.columns
.map((col) => `<td>${escapeHtml(row[col])}</td>`)
.join("")}</tr>`
)
.join("");
const tableSection =
result.columns.length && result.rows.length
? `<table><thead><tr>${result.columns
.map((col) => `<th>${escapeHtml(col)}</th>`)
.join("")}</tr></thead><tbody>${rowsHtml}</tbody></table>`
: `<p>No row data was returned.</p>`;
const sanitizedSection = result.sanitized_sql
? `<h3>Sanitized SQL</h3><pre>${escapeHtml(result.sanitized_sql)}</pre>`
: "";
const warningSection = result.warning
? `<div class="warning">${escapeHtml(result.warning)}</div>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SQL Agent Report</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 2rem auto; max-width: 960px; color: #0f172a; }
h1, h2, h3 { color: #1e3a8a; }
.meta { margin-bottom: 1.5rem; }
.meta dt { font-weight: 600; }
.meta dd { margin: 0 0 0.75rem 0; }
pre { background: #0f172a; color: #f8fafc; padding: 1rem; border-radius: 8px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; margin-top: 1.5rem; }
th, td { border: 1px solid #e2e8f0; padding: 0.75rem; text-align: left; }
th { background: #e2e8f0; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
footer { margin-top: 2rem; font-size: 0.85rem; color: #475569; }
</style>
</head>
<body>
<h1>SQL Agent Report</h1>
<dl class="meta">
<dt>Generated</dt>
<dd>${escapeHtml(timestamp)}</dd>
<dt>Question</dt>
<dd>${escapeHtml(result.question)}</dd>
<dt>Summary</dt>
<dd>${escapeHtml(result.summary || '(No summary provided)')}</dd>
<dt>Rows Returned</dt>
<dd>${result.rowCount !== null ? escapeHtml(result.rowCount) : 'n/a'}</dd>
</dl>
${warningSection}
<h2>Generated SQL</h2>
<pre>${escapeHtml(result.sql)}</pre>
${sanitizedSection}
<h2>Result Preview</h2>
${tableSection}
<footer>Report generated by the SugarScale SQL Agent UI.</footer>
</body>
</html>`;
}
function downloadReport(filename, html) {
const blob = new Blob([html], { type: "text/html;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
if (reportBtn) {
reportBtn.addEventListener("click", () => {
if (!latestResult) {
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `sql-agent-report-${timestamp}.html`;
const html = buildReportHtml(latestResult);
downloadReport(filename, html);
});
}
async function sendFeedback(tag) {
if (!latestResult) {
return;
}
const payload = {
question: latestResult.question,
sql: latestResult.sql,
summary: latestResult.summary,
sanitized_sql: latestResult.sanitized_sql,
feedback: tag,
};
feedbackStatus.hidden = false;
feedbackStatus.textContent = "Sending feedback...";
feedbackCorrectBtn.disabled = true;
feedbackIncorrectBtn.disabled = true;
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Feedback request failed with status ${response.status}`);
}
feedbackStatus.textContent = tag === "correct" ? "Marked as correct." : "Marked as needs work.";
} catch (error) {
feedbackStatus.textContent = error.message || String(error);
} finally {
feedbackCorrectBtn.disabled = false;
feedbackIncorrectBtn.disabled = false;
}
}
feedbackCorrectBtn.addEventListener("click", () => sendFeedback("correct"));
feedbackIncorrectBtn.addEventListener("click", () => sendFeedback("incorrect"));

View File

@@ -0,0 +1,218 @@
* {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 0;
background: #f4f6f8;
color: #0f172a;
}
header,
footer {
background: #0f172a;
color: #f8fafc;
padding: 1.5rem;
text-align: center;
}
main {
display: grid;
gap: 1.5rem;
max-width: 1000px;
margin: 2rem auto;
padding: 0 1rem;
}
.form-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
align-items: start;
}
.panel {
background: #ffffff;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
.panel.error {
border-left: 6px solid #ef4444;
}
.form-panel,
.helper-panel {
width: 100%;
max-width: 420px;
justify-self: center;
}
.helper-panel h3 {
margin-top: 0rem;
font-size: 1.05rem;
}
.helper-intro,
.helper-note {
color: #475569;
font-size: 0.95rem;
}
.helper-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
}
.helper-panel ul {
margin: 0.25rem 0 0;
padding-left: 1.25rem;
}
.results-panel {
width: 100%;
max-height: calc(100vh - 8rem);
overflow-y: auto;
}
.warning {
background: #fef3c7;
color: #92400e;
border-left: 4px solid #f59e0b;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
white-space: pre-wrap;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.35rem;
}
textarea,
input,
button {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
font-size: 1rem;
margin-bottom: 1rem;
}
textarea:focus,
input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.options {
display: flex;
gap: 1rem;
align-items: center;
}
.options label {
margin-bottom: 0;
font-weight: 500;
}
.actions {
display: flex;
gap: 1rem;
}
.actions button {
margin-bottom: 0;
width: auto;
flex: 1 1 auto;
}
.export {
margin-top: 1rem;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.export button {
width: auto;
}
button {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #fff;
border: none;
cursor: pointer;
font-weight: 600;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.25);
}
button.secondary {
background: linear-gradient(135deg, #64748b, #475569);
}
pre {
background: #0f172a;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
.results-panel .table-wrapper {
overflow-x: auto;
margin-top: 1rem;
}
.results-panel .table-wrapper table {
width: max-content;
min-width: 100%;
}
table {
border-collapse: collapse;
margin: 0;
}
th,
td {
border: 1px solid #e2e8f0;
padding: 0.65rem;
text-align: left;
}
th {
background: #f1f5f9;
}
#error-message {
white-space: pre-wrap;
}
#feedback-panel {
margin-top: 1.5rem;
}
.feedback-actions {
display: flex;
gap: 1rem;
margin-bottom: 0.75rem;
}
#feedback-status {
color: #0369a1;
font-weight: 600;
}