DuckDB-UI / index.html
amaye15's picture
UI
3bcb6cf
raw
history blame
19.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DuckDB Explorer</title>
<style>
/* --- Keep the existing CSS from the previous answer --- */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f4f7f6;
color: #333;
}
header {
background-color: #4CAF50; /* Changed color slightly */
color: white;
padding: 15px 20px; /* Slightly more padding */
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-size: 1.2em; /* Bigger title */
font-weight: bold;
}
/* Style for the loader inside the header */
header .loader {
border: 3px solid #f3f3f3; /* Light grey */
border-top: 3px solid #fff; /* White */
border-radius: 50%;
width: 18px;
height: 18px;
animation: spin 1s linear infinite;
display: none; /* Hidden by default */
margin-left: 15px; /* Space from title */
}
.container {
display: flex;
flex: 1;
overflow: hidden; /* Prevent overall container scroll */
}
#sidebar {
width: 220px; /* Slightly wider */
background-color: #e9ecef;
padding: 15px;
overflow-y: auto;
border-right: 1px solid #dee2e6;
}
#sidebar h3 {
margin-top: 0;
margin-bottom: 15px; /* More space */
color: #495057;
border-bottom: 1px solid #ced4da;
padding-bottom: 10px;
}
#tableList {
list-style: none;
padding: 0;
margin: 0;
}
#tableList li {
padding: 8px 10px; /* More padding */
cursor: pointer;
border-radius: 4px;
margin-bottom: 5px;
transition: background-color 0.2s, color 0.2s; /* Add color transition */
font-size: 0.95em;
color: #343a40;
}
#tableList li:hover {
background-color: #d4dadf;
}
#tableList li.active {
background-color: #007bff; /* Bootstrap primary blue */
color: white;
font-weight: bold;
}
#mainContent {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden; /* Prevent main content scroll */
}
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden; /* Child takes scroll */
gap: 15px; /* Add gap between content boxes */
}
#schemaDisplay, #dataDisplayContainer, #queryResultContainer {
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 5px; /* Slightly more rounded */
padding: 15px;
margin-bottom: 0; /* Remove margin, use gap instead */
overflow: auto; /* Allow scrolling within these areas */
box-shadow: 0 1px 3px rgba(0,0,0,0.05); /* Subtle shadow */
}
#schemaDisplay h4, #dataDisplayContainer h4, #queryResultContainer h4 {
margin-top: 0;
color: #495057;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
margin-bottom: 15px;
}
#dataDisplayContainer {
flex: 1; /* Takes remaining space */
display: flex; /* Use flex for inner scroll */
flex-direction: column;
}
#dataDisplay {
flex: 1; /* Allow div itself to scroll */
overflow: auto;
min-height: 100px; /* Ensure it has some height */
}
#queryArea {
padding-top: 20px;
border-top: 1px solid #dee2e6;
background-color: #f8f9fa; /* Slight background */
padding: 15px;
border-radius: 5px;
box-shadow: 0 -1px 3px rgba(0,0,0,0.05);
}
#queryArea h4 {
margin-top: 0;
margin-bottom: 10px;
color: #495057;
}
#queryArea textarea {
width: 100%;
min-height: 80px;
padding: 10px;
border: 1px solid #ced4da; /* Match theme */
border-radius: 4px;
box-sizing: border-box;
font-family: monospace;
margin-bottom: 10px;
resize: vertical; /* Allow vertical resize */
}
#queryArea button {
padding: 10px 20px;
background-color: #28a745; /* Bootstrap success green */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
font-weight: bold;
}
#queryArea button:hover {
background-color: #218838;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0; /* Remove margin */
font-size: 0.9em; /* Slightly smaller table font */
}
th, td {
border: 1px solid #e9ecef; /* Lighter border */
padding: 10px 12px; /* Adjust padding */
text-align: left;
white-space: nowrap;
max-width: 250px; /* Prevent very wide columns */
overflow: hidden;
text-overflow: ellipsis;
}
th {
background-color: #f8f9fa; /* Very light header */
font-weight: 600; /* Slightly bolder */
position: sticky; /* Sticky headers */
top: 0;
z-index: 1;
}
tr:nth-child(even) {
background-color: #fdfdfe; /* Very subtle striping */
}
#statusMessage {
padding: 10px 15px;
margin-top: 15px;
border-radius: 4px;
display: none; /* Hidden by default */
font-size: 0.9em;
}
#statusMessage.success {
background-color: #d1e7dd;
color: #0f5132;
border: 1px solid #badbcc;
}
#statusMessage.error {
background-color: #f8d7da;
color: #842029;
border: 1px solid #f5c2c7;
}
/* Loader animation */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<header>
<span>🦆 DuckDB Explorer</span>
<div class="loader" id="loadingIndicator"></div>
</header>
<div class="container">
<aside id="sidebar">
<h3>Tables</h3>
<ul id="tableList">
<li>Loading...</li>
</ul>
</aside>
<main id="mainContent">
<div class="content-area">
<div id="schemaDisplay">
<h4>Schema</h4>
<p>Select a table from the list.</p>
<table id="schemaTable"></table>
</div>
<div id="dataDisplayContainer">
<h4>Data <span id="tableDataHeader"></span></h4>
<p>Select a table from the list.</p>
<div id="dataDisplay">
<table id="dataTable"></table>
</div>
</div>
<div id="queryResultContainer" style="display: none;">
<h4>Query Result</h4>
<div id="queryResultDisplay">
<table id="queryResultTable"></table>
</div>
</div>
</div>
<div id="queryArea">
<h4>Custom SQL Query (SELECT/SHOW/PRAGMA only)</h4>
<textarea id="sqlInput" placeholder="Enter your SELECT query here... e.g., SELECT * FROM table_name LIMIT 10"></textarea>
<button id="runSqlButton">Run SQL</button>
</div>
<div id="statusMessage"></div>
</main>
</div>
<script>
// --- Keep the existing element variables ---
const tableList = document.getElementById('tableList');
const schemaDisplay = document.getElementById('schemaDisplay');
const schemaTable = document.getElementById('schemaTable');
const dataDisplayContainer = document.getElementById('dataDisplayContainer');
const dataDisplay = document.getElementById('dataDisplay');
const dataTable = document.getElementById('dataTable');
const tableDataHeader = document.getElementById('tableDataHeader');
const sqlInput = document.getElementById('sqlInput');
const runSqlButton = document.getElementById('runSqlButton');
const queryResultContainer = document.getElementById('queryResultContainer');
const queryResultDisplay = document.getElementById('queryResultDisplay');
const queryResultTable = document.getElementById('queryResultTable');
const statusMessage = document.getElementById('statusMessage');
const loadingIndicator = document.getElementById('loadingIndicator');
// --- API URL is now relative ---
const API_BASE_URL = '';
let currentTables = [];
let selectedTable = null;
// --- Utility Functions (keep existing showLoader, showStatus, clearStatus, renderTable, renderSchema) ---
function showLoader(show) {
loadingIndicator.style.display = show ? 'inline-block' : 'none';
}
function showStatus(message, isError = false) {
statusMessage.textContent = message;
statusMessage.className = isError ? 'error' : 'success';
statusMessage.style.display = 'block';
setTimeout(() => { statusMessage.style.display = 'none'; }, 5000);
}
function clearStatus() {
statusMessage.textContent = '';
statusMessage.style.display = 'none';
}
async function fetchAPI(endpoint, options = {}) {
showLoader(true);
clearStatus();
const url = `${API_BASE_URL}${endpoint}`; // API_BASE_URL is now ''
try {
const response = await fetch(url, options);
if (!response.ok) {
let errorDetail = `HTTP error! status: ${response.status}`;
try {
const errorJson = await response.json();
errorDetail += ` - ${errorJson.detail || JSON.stringify(errorJson)}`;
} catch (e) { /* Ignore */ }
throw new Error(errorDetail);
}
if (response.headers.get("content-type")?.includes("application/json")) {
return await response.json();
}
// Handle potential non-JSON success responses if needed
return await response.text();
} catch (error) {
console.error('API Fetch Error:', error);
showStatus(`Error: ${error.message}`, true);
throw error;
} finally {
showLoader(false);
}
}
function renderTable(data, tableElement) {
tableElement.innerHTML = '';
if (!data || data.length === 0) {
tableElement.innerHTML = '<tbody><tr><td>No data available.</td></tr></tbody>';
return;
}
const headers = Object.keys(data[0]);
const thead = tableElement.createTHead();
const headerRow = thead.insertRow();
headers.forEach(headerText => {
const th = document.createElement('th');
th.textContent = headerText;
headerRow.appendChild(th);
});
const tbody = tableElement.createTBody();
data.forEach(rowData => {
const row = tbody.insertRow();
headers.forEach(header => {
const cell = row.insertCell();
const value = rowData[header];
// Better null/undefined check and string conversion
cell.textContent = (value === null || value === undefined) ? 'NULL' : String(value);
});
});
}
function renderSchema(schemaData) {
const tableElement = schemaTable;
tableElement.innerHTML = '';
if (!schemaData || !schemaData.columns || schemaData.columns.length === 0) {
schemaDisplay.innerHTML = '<h4>Schema</h4><p>No schema information available.</p>';
return;
}
schemaDisplay.innerHTML = '<h4>Schema</h4>';
const thead = tableElement.createTHead();
const headerRow = thead.insertRow();
['Name', 'Type'].forEach(headerText => {
const th = document.createElement('th');
th.textContent = headerText;
headerRow.appendChild(th);
});
const tbody = tableElement.createTBody();
schemaData.columns.forEach(column => {
const row = tbody.insertRow();
row.insertCell().textContent = column.name;
row.insertCell().textContent = column.type;
});
}
// --- Event Handlers (Modified) ---
async function loadTables() {
// No need to get API_BASE_URL from input anymore
tableList.innerHTML = '<li>Loading tables...</li>'; // Indicate loading
schemaTable.innerHTML = ''; // Clear schema
dataTable.innerHTML = ''; // Clear data
tableDataHeader.textContent = '';
queryResultContainer.style.display = 'none';
try {
currentTables = await fetchAPI('/tables');
displayTables(currentTables);
showStatus("Tables loaded.", false);
// Clear placeholder texts
if (currentTables.length > 0) {
schemaDisplay.innerHTML = '<h4>Schema</h4><p>Select a table from the list.</p>';
dataDisplayContainer.querySelector('p').style.display = 'block'; // Show prompt
} else {
schemaDisplay.innerHTML = '<h4>Schema</h4><p>No tables found in the database.</p>';
dataDisplayContainer.querySelector('p').style.display = 'block';
}
} catch (error) {
tableList.innerHTML = '<li>Error loading tables.</li>';
}
}
// --- displayTables and handleTableSelection remain the same ---
function displayTables(tables) {
tableList.innerHTML = ''; // Clear list
if (tables.length === 0) {
tableList.innerHTML = '<li>No tables found.</li>';
return;
}
tables.sort().forEach(tableName => {
const li = document.createElement('li');
li.textContent = tableName;
li.dataset.tableName = tableName; // Store table name
li.onclick = () => handleTableSelection(li);
tableList.appendChild(li);
});
}
async function handleTableSelection(listItem) {
const currentActive = tableList.querySelector('.active');
if (currentActive) {
currentActive.classList.remove('active');
}
listItem.classList.add('active');
selectedTable = listItem.dataset.tableName;
if (!selectedTable) return;
queryResultContainer.style.display = 'none';
dataDisplayContainer.style.display = 'flex'; // Make sure it's flex
dataDisplayContainer.querySelector('p').style.display = 'none'; // Hide prompt
tableDataHeader.textContent = `for table "${selectedTable}"`;
schemaDisplay.innerHTML = '<h4>Schema</h4>'; // Keep header
schemaTable.innerHTML = '<tbody><tr><td>Loading schema...</td></tr></tbody>';
dataTable.innerHTML = '<tbody><tr><td>Loading data...</td></tr></tbody>';
try {
// Fetch schema and data concurrently
const [schemaResponse, tableDataResponse] = await Promise.all([
fetchAPI(`/tables/${selectedTable}/schema`),
fetchAPI(`/tables/${selectedTable}?limit=100`) // Default limit
]);
renderSchema(schemaResponse);
renderTable(tableDataResponse, dataTable);
} catch (error) {
// Error already shown by fetchAPI
schemaTable.innerHTML = '<tbody><tr><td colspan="2">Error loading schema.</td></tr></tbody>';
dataTable.innerHTML = '<tbody><tr><td>Error loading data.</td></tr></tbody>';
}
}
// --- runCustomQuery remains mostly the same ---
async function runCustomQuery() {
const sql = sqlInput.value.trim();
if (!sql) {
showStatus("SQL query cannot be empty.", true);
return;
}
// No need to check API_BASE_URL anymore
dataDisplayContainer.style.display = 'none'; // Hide table data
dataDisplayContainer.querySelector('p').style.display = 'none'; // Hide prompt
queryResultContainer.style.display = 'block'; // Show query results area
queryResultTable.innerHTML = '<tbody><tr><td>Running query...</td></tr></tbody>';
try {
const resultData = await fetchAPI('/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ sql: sql }),
});
renderTable(resultData, queryResultTable);
showStatus("Query executed successfully.", false);
} catch (error) {
queryResultTable.innerHTML = '<tbody><tr><td>Error executing query. See status message.</td></tr></tbody>';
// Error is shown by fetchAPI
}
}
// --- Initial Setup ---
// Remove connectButton listener
runSqlButton.onclick = runCustomQuery;
// Load tables automatically when the page loads
document.addEventListener('DOMContentLoaded', loadTables);
</script>
</body>
</html>