Files

400 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Schema Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#4c51bf',
secondary: '#5b21b6',
}
}
}
}
</script>
</head>
<body class="min-h-screen bg-gradient-to-br from-primary to-secondary p-4 sm:p-8 font-sans">
<!-- Admin Login -->
<div id="loginContainer" class="max-w-md mx-auto bg-white rounded-2xl shadow-2xl p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-800">Admin Login</h1>
<p class="text-gray-600 text-sm mt-1">Superuser access</p>
</div>
<form id="loginForm" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" id="email" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="admin@example.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" id="password" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="••••••••">
</div>
<button type="submit"
class="w-full py-2 px-4 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity">
Login
</button>
</form>
<div id="loginError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
</div>
<!-- Schema Viewer -->
<div id="schemaContainer" class="hidden max-w-4xl mx-auto bg-white rounded-2xl shadow-2xl p-8 mt-8">
<div class="flex flex-wrap gap-3 justify-between items-center mb-6">
<h2 id="schemaTitle" class="text-2xl font-bold text-gray-800">Collection Schema</h2>
<div class="flex gap-2 items-center">
<select id="collectionSelect" class="px-3 py-2 border rounded-lg text-sm">
<option value="">Select a collection…</option>
</select>
<button id="refreshSchemaBtn" type="button" class="px-3 py-2 bg-blue-600 text-white rounded-lg text-sm" disabled>Load Schema</button>
<input id="fieldName" class="px-3 py-2 border rounded-lg text-sm" placeholder="Job_Number" value="Job_Number" />
<button id="loadFieldBtn" type="button" class="px-3 py-2 bg-green-600 text-white rounded-lg text-sm" disabled>Load Field</button>
<button id="loadRecordsBtn" type="button" class="px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm" disabled>Load Records</button>
<button id="exportSchemaBtn" type="button" class="px-3 py-2 bg-orange-600 text-white rounded-lg text-sm" disabled>Export Schema</button>
<button id="logoutBtn" class="px-3 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition text-sm">
Logout
</button>
</div>
</div>
<div class="space-y-4">
<div id="metaPanel" class="text-xs text-gray-600 bg-gray-50 border rounded p-3 hidden"></div>
<table class="w-full border-collapse">
<thead>
<tr class="border-b-2 border-gray-300">
<th class="text-left py-2 px-4 font-semibold text-gray-700">Field Name</th>
<th class="text-left py-2 px-4 font-semibold text-gray-700">Type</th>
<th class="text-left py-2 px-4 font-semibold text-gray-700">Required</th>
<th class="text-left py-2 px-4 font-semibold text-gray-700">Details</th>
</tr>
</thead>
<tbody id="schemaTable">
<!-- Filled by JS -->
</tbody>
</table>
</div>
<div id="schemaError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
</div>
<script>
const loginContainer = document.getElementById('loginContainer');
const schemaContainer = document.getElementById('schemaContainer');
const loginError = document.getElementById('loginError');
const schemaError = document.getElementById('schemaError');
const schemaTable = document.getElementById('schemaTable');
let adminToken = null;
let collections = [];
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
loginError.classList.add('hidden');
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Login failed');
}
adminToken = result.token;
console.log('✓ Admin logged in');
await fetchCollections();
loginContainer.classList.add('hidden');
schemaContainer.classList.remove('hidden');
} catch (error) {
console.error(error);
loginError.textContent = error.message;
loginError.classList.remove('hidden');
}
});
const refreshBtn = document.getElementById('refreshSchemaBtn');
const fieldNameInput = document.getElementById('fieldName');
const loadFieldBtn = document.getElementById('loadFieldBtn');
const loadRecordsBtn = document.getElementById('loadRecordsBtn');
const exportSchemaBtn = document.getElementById('exportSchemaBtn');
refreshBtn.addEventListener('click', async () => {
console.log('Load Schema clicked');
refreshBtn.disabled = true;
const original = refreshBtn.textContent;
refreshBtn.textContent = 'Loading…';
try {
await fetchSchema();
} finally {
refreshBtn.textContent = original;
refreshBtn.disabled = !document.getElementById('collectionSelect').value;
}
});
document.getElementById('collectionSelect').addEventListener('change', async (e) => {
const hasSelection = !!e.target.value;
refreshBtn.disabled = !hasSelection;
loadFieldBtn.disabled = !hasSelection;
loadRecordsBtn.disabled = !hasSelection;
exportSchemaBtn.disabled = !hasSelection;
if (hasSelection) {
console.log('Collection selected:', e.target.value);
await fetchSchema();
}
});
document.getElementById('logoutBtn').addEventListener('click', () => {
adminToken = null;
schemaContainer.classList.add('hidden');
loginContainer.classList.remove('hidden');
document.getElementById('loginForm').reset();
});
async function fetchCollections() {
try {
console.log('Fetching collections…');
const response = await fetch('/api/collections', {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const result = await response.json();
console.log('Collections response:', result);
if (!response.ok) throw new Error(result.message || 'Failed to fetch collections');
collections = result.items || [];
const select = document.getElementById('collectionSelect');
select.innerHTML = '<option value="">Select a collection…</option>' +
collections.map(c => `<option value="${c.name}">${c.name} (${c.type})</option>`).join('');
// Auto-select env default if present in list
try {
const envDefault = 'Job_Info_TestEnv';
const found = collections.find(c => c.name === envDefault);
if (found) {
select.value = envDefault;
refreshBtn.disabled = false;
loadFieldBtn.disabled = false;
loadRecordsBtn.disabled = false;
exportSchemaBtn.disabled = false;
await fetchSchema();
}
} catch {}
} catch (error) {
console.error(error);
schemaError.textContent = error.message;
schemaError.classList.remove('hidden');
}
}
loadRecordsBtn.addEventListener('click', async () => {
const selected = document.getElementById('collectionSelect').value || '';
if (!selected) {
schemaError.textContent = 'Please select a collection';
schemaError.classList.remove('hidden');
return;
}
schemaError.classList.add('hidden');
schemaTable.innerHTML = '';
try {
console.log('Fetching records from collection:', selected);
const resp = await fetch(`/api/records?name=${encodeURIComponent(selected)}&perPage=10`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const result = await resp.json();
console.log('Records response:', result);
if (!resp.ok) throw new Error(result.message || 'Failed to fetch records');
const items = result.items || [];
document.getElementById('schemaTitle').textContent = `Records: ${result.collection} (total: ${result.totalItems})`;
const metaPanel = document.getElementById('metaPanel');
metaPanel.textContent = JSON.stringify({ totalItems: result.totalItems }, null, 2);
metaPanel.classList.remove('hidden');
if (!items.length) {
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No records found</td></tr>';
return;
}
schemaTable.innerHTML = items.map((rec) => `
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="py-3 px-4 font-mono text-xs text-gray-800">${rec.id}</td>
<td class="py-3 px-4"><span class="bg-indigo-100 text-indigo-800 text-xs px-2 py-1 rounded">record</span></td>
<td class="py-3 px-4 text-xs">${rec.job_number ?? ''}</td>
<td class="py-3 px-4 text-xs text-gray-600">${new Date(rec.created).toLocaleString()}</td>
</tr>
`).join('');
} catch (error) {
console.error(error);
schemaError.textContent = error.message;
schemaError.classList.remove('hidden');
}
});
loadFieldBtn.addEventListener('click', async () => {
const selected = document.getElementById('collectionSelect').value || '';
const field = fieldNameInput.value || 'job_number';
if (!selected) {
schemaError.textContent = 'Please select a collection';
schemaError.classList.remove('hidden');
return;
}
schemaError.classList.add('hidden');
schemaTable.innerHTML = '';
try {
console.log('Fetching field:', field, 'from collection:', selected);
const resp = await fetch(`/api/field?name=${encodeURIComponent(selected)}&field=${encodeURIComponent(field)}`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const result = await resp.json();
console.log('Field response:', result);
if (!resp.ok) throw new Error(result.message || 'Failed to fetch field');
const values = result.values || [];
const def = result.definition || null;
const derived = result.derived || false;
const resolvedField = result.resolvedField || result.field;
document.getElementById('schemaTitle').textContent = `Field: ${resolvedField} (collection: ${result.collection})`;
const metaPanel = document.getElementById('metaPanel');
metaPanel.textContent = JSON.stringify({ definition: def, derived, requested: result.field, resolved: resolvedField, totalItems: result.totalItems, nonNullCount: result.count }, null, 2);
metaPanel.classList.remove('hidden');
if (!values.length) {
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No values found for this field</td></tr>';
return;
}
schemaTable.innerHTML = values.map(v => `
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="py-3 px-4 font-mono text-sm text-gray-800">${result.field}</td>
<td class="py-3 px-4"><span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">value</span></td>
<td class="py-3 px-4 text-sm"></td>
<td class="py-3 px-4 text-sm text-gray-600">${String(v)}</td>
</tr>
`).join('');
} catch (error) {
console.error(error);
schemaError.textContent = error.message;
schemaError.classList.remove('hidden');
}
});
async function fetchSchema() {
schemaError.classList.add('hidden');
schemaTable.innerHTML = '';
document.getElementById('metaPanel').classList.add('hidden');
const selected = document.getElementById('collectionSelect').value || '';
if (!selected) {
schemaError.textContent = 'Please select a collection';
schemaError.classList.remove('hidden');
return;
}
document.getElementById('schemaTitle').textContent = `Collection Schema: ${selected}`;
try {
console.log('Fetching schema for:', selected);
const response = await fetch(`/api/schema?name=${encodeURIComponent(selected)}`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
const result = await response.json();
console.log('Schema response:', result);
if (!response.ok) {
throw new Error(result.message || 'Failed to fetch schema');
}
const fields = result.schema || [];
const meta = result.meta || {};
const metaPanel = document.getElementById('metaPanel');
metaPanel.textContent = JSON.stringify(meta, null, 2);
metaPanel.classList.remove('hidden');
if (fields.length === 0) {
const derived = result.derived || [];
const generated = result.generatedSchema || [];
if (generated.length) {
schemaTable.innerHTML = generated.map(field => `
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="py-3 px-4 font-mono text-sm text-gray-800">${field.name}</td>
<td class="py-3 px-4"><span class="bg-purple-100 text-purple-800 text-xs px-2 py-1 rounded">${field.type}</span></td>
<td class="py-3 px-4 text-sm">${field.required ? '✓' : ''}</td>
<td class="py-3 px-4 text-sm text-gray-600">generated</td>
</tr>
`).join('');
return;
}
if (derived.length) {
schemaTable.innerHTML = derived.map(name => `
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="py-3 px-4 font-mono text-sm text-gray-800">${name}</td>
<td class="py-3 px-4"><span class="bg-yellow-100 text-yellow-800 text-xs px-2 py-1 rounded">derived</span></td>
<td class="py-3 px-4 text-sm"></td>
<td class="py-3 px-4 text-sm text-gray-600">From sample record</td>
</tr>
`).join('');
return;
}
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No fields found</td></tr>';
return;
}
fields.forEach(field => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-200 hover:bg-gray-50';
row.innerHTML = `
<td class="py-3 px-4 font-mono text-sm text-gray-800">${field.name}</td>
<td class="py-3 px-4"><span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${field.type}</span></td>
<td class="py-3 px-4 text-sm">${field.required ? '✓' : ''}</td>
<td class="py-3 px-4 text-sm text-gray-600">${field.options ? JSON.stringify(field.options).substring(0, 60) + '...' : ''}</td>
`;
schemaTable.appendChild(row);
});
} catch (error) {
console.error(error);
schemaError.textContent = error.message;
schemaError.classList.remove('hidden');
}
}
exportSchemaBtn.addEventListener('click', async () => {
const selected = document.getElementById('collectionSelect').value || '';
if (!selected) {
schemaError.textContent = 'Please select a collection';
schemaError.classList.remove('hidden');
return;
}
schemaError.classList.add('hidden');
try {
console.log('Exporting schema for collection:', selected);
const resp = await fetch(`/api/export/schema?name=${encodeURIComponent(selected)}`, {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message || 'Export failed');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selected}-schema-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('✓ Schema exported');
} catch (error) {
console.error(error);
schemaError.textContent = error.message;
schemaError.classList.remove('hidden');
}
});
</script>
</body>
</html>