DuckDB-UI / index.html
amaye15's picture
UI
c43b33b
raw
history blame
17.3 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>
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;
color: white;
padding: 10px 20px;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
header input[type="text"] {
flex-grow: 1;
margin-right: 10px;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
header button {
padding: 8px 15px;
background-color: #367c39;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
header button:hover {
background-color: #2a622d;
}
.container {
display: flex;
flex: 1;
overflow: hidden; /* Prevent overall container scroll */
}
#sidebar {
width: 200px;
background-color: #e9ecef;
padding: 15px;
overflow-y: auto;
border-right: 1px solid #dee2e6;
}
#sidebar h3 {
margin-top: 0;
color: #495057;
}
#tableList {
list-style: none;
padding: 0;
margin: 0;
}
#tableList li {
padding: 8px 5px;
cursor: pointer;
border-radius: 4px;
margin-bottom: 5px;
transition: background-color 0.2s;
}
#tableList li:hover, #tableList li.active {
background-color: #d4dadf;
}
#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 */
}
#schemaDisplay, #dataDisplayContainer, #queryResultContainer {
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
overflow: auto; /* Allow scrolling within these areas */
}
#dataDisplayContainer {
flex: 1; /* Takes remaining space */
}
#queryArea {
margin-top: auto; /* Push query area to bottom if space */
padding-top: 20px;
border-top: 1px solid #dee2e6;
}
#queryArea textarea {
width: 100%;
min-height: 80px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
font-family: monospace;
margin-bottom: 10px;
}
#queryArea button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
#queryArea button:hover {
background-color: #0056b3;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
white-space: nowrap;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
#statusMessage {
padding: 10px;
margin-top: 10px;
border-radius: 4px;
display: none; /* Hidden by default */
}
#statusMessage.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
#statusMessage.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loader {
border: 4px solid #f3f3f3; /* Light grey */
border-top: 4px solid #3498db; /* Blue */
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: none; /* Hidden by default */
margin-left: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<header>
<label for="apiUrl" style="margin-right: 10px; font-weight: bold; color: white;">API URL:</label>
<input type="text" id="apiUrl" value="http://localhost:8000" placeholder="Enter API Base URL">
<button id="connectButton">Connect</button>
<div class="loader" id="loadingIndicator"></div>
</header>
<div class="container">
<aside id="sidebar">
<h3>Tables</h3>
<ul id="tableList">
<!-- Table names will be loaded here -->
</ul>
</aside>
<main id="mainContent">
<div class="content-area">
<div id="schemaDisplay">
<h4>Schema</h4>
<p>Select a table from the list to view its schema.</p>
<table id="schemaTable"></table>
</div>
<div id="dataDisplayContainer">
<h4>Data <span id="tableDataHeader"></span></h4>
<p>Select a table from the list to view its data (limited rows).</p>
<div id="dataDisplay" style="max-height: 300px; overflow-y: auto;">
<table id="dataTable"></table>
</div>
</div>
<div id="queryResultContainer" style="display: none;">
<h4>Query Result</h4>
<div id="queryResultDisplay" style="max-height: 300px; overflow-y: auto;">
<table id="queryResultTable"></table>
</div>
</div>
</div>
<div id="queryArea">
<h4>Custom SQL Query (SELECT only)</h4>
<textarea id="sqlInput" placeholder="Enter your SELECT query here..."></textarea>
<button id="runSqlButton">Run SQL</button>
</div>
<div id="statusMessage"></div>
</main>
</div>
<script>
const apiUrlInput = document.getElementById('apiUrl');
const connectButton = document.getElementById('connectButton');
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');
let API_BASE_URL = '';
let currentTables = [];
let selectedTable = null;
// --- Utility Functions ---
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';
// Automatically hide after a few seconds
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}`;
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 if response is not JSON */ }
throw new Error(errorDetail);
}
// Handle empty responses for non-JSON endpoints if necessary
if (response.headers.get("content-type")?.includes("application/json")) {
return await response.json();
}
return await response.text(); // Or handle other types
} catch (error) {
console.error('API Fetch Error:', error);
showStatus(`Error: ${error.message}`, true);
throw error; // Re-throw to stop further processing
} finally {
showLoader(false);
}
}
function renderTable(data, tableElement) {
tableElement.innerHTML = ''; // Clear previous content
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();
// Handle null or undefined gracefully
cell.textContent = rowData[header] === null || rowData[header] === undefined ? 'NULL' : String(rowData[header]);
});
});
}
function renderSchema(schemaData) {
const tableElement = schemaTable;
tableElement.innerHTML = ''; // Clear previous
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>'; // Reset header
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 ---
async function loadTables() {
API_BASE_URL = apiUrlInput.value.trim().replace(/\/$/, ''); // Remove trailing slash
if (!API_BASE_URL) {
showStatus("API URL cannot be empty.", true);
return;
}
try {
// Optional: Ping root or health endpoint first
// await fetchAPI('/');
currentTables = await fetchAPI('/tables');
displayTables(currentTables);
showStatus("Connected. Tables loaded.", false);
// Clear previous displays
schemaDisplay.innerHTML = '<h4>Schema</h4><p>Select a table from the list.</p>';
dataTable.innerHTML = '';
tableDataHeader.textContent = '';
queryResultContainer.style.display = 'none';
} catch (error) {
tableList.innerHTML = '<li>Error loading tables.</li>';
}
}
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) {
// Remove active class from previously selected
const currentActive = tableList.querySelector('.active');
if (currentActive) {
currentActive.classList.remove('active');
}
// Add active class to newly selected
listItem.classList.add('active');
selectedTable = listItem.dataset.tableName;
if (!selectedTable) return;
queryResultContainer.style.display = 'none'; // Hide query results
dataDisplayContainer.style.display = 'flex'; // Show table data area
tableDataHeader.textContent = `for table "${selectedTable}"`;
schemaTable.innerHTML = '<tbody><tr><td>Loading schema...</td></tr></tbody>';
dataTable.innerHTML = '<tbody><tr><td>Loading data...</td></tr></tbody>';
try {
const [schemaData, tableData] = await Promise.all([
fetchAPI(`/tables/${selectedTable}/schema`),
fetchAPI(`/tables/${selectedTable}?limit=100`) // Load first 100 rows
]);
renderSchema(schemaData);
renderTable(tableData, dataTable);
} catch (error) {
schemaTable.innerHTML = '<tbody><tr><td>Error loading schema.</td></tr></tbody>';
dataTable.innerHTML = '<tbody><tr><td>Error loading data.</td></tr></tbody>';
}
}
async function runCustomQuery() {
const sql = sqlInput.value.trim();
if (!sql) {
showStatus("SQL query cannot be empty.", true);
return;
}
if (!API_BASE_URL) {
showStatus("Connect to the API first (enter URL and press Connect).", true);
return;
}
dataDisplayContainer.style.display = 'none'; // Hide table data
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.</td></tr></tbody>';
// fetchAPI already shows the status
}
}
// --- Initial Setup ---
connectButton.onclick = loadTables;
runSqlButton.onclick = runCustomQuery;
// Optional: Load tables on page load if API URL is preset
// if (apiUrlInput.value) {
// loadTables();
// }
</script>
</body>
</html>