Mercury/ui/src/views/admin/Tables.vue
Matthew L McPeak 0eeb2ed207
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 2m57s
ci / publish (push) Successful in 2m26s
Initial Commit
2026-06-17 19:56:53 -04:00

571 lines
16 KiB
Vue

<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Tables</h2>
<span class="subtitle">Create and manage PostgreSQL tables in the public schema</span>
</div>
<NychButton @click="showCreate = true">+ New Table</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ tables.length }} {{ tables.length === 1 ? 'table' : 'tables' }}</span>
</div>
<template v-if="tables.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Columns</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tables" :key="t.table_name">
<td class="name-cell"><code>{{ t.table_name }}</code></td>
<td class="count-cell">{{ t.column_count }}</td>
<td class="actions-cell">
<NychButton size="small" severity="secondary" @click="openInspect(t.table_name)">Inspect</NychButton>
<NychButton size="small" severity="danger" @click="openDropPreview(t.table_name)">Drop</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon">▦</span>
<span class="empty-label">No tables yet</span>
<span class="empty-hint">Create a table to start storing data via the CRUD API.</span>
</div>
</div>
<!-- Create Table Dialog -->
<NychDialog v-model:visible="showCreate" header="New Table" :modal="true" :draggable="false" style="width: min(700px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Table Name</label>
<NychInputText v-model="createForm.name" placeholder="my_table" fluid autocomplete="off" />
<p class="hint">Lowercase letters, numbers, and underscores only.</p>
</div>
<div class="columns-section">
<div class="columns-header">
<label>Columns</label>
<NychButton type="button" size="small" @click="addColumn">+ Add Column</NychButton>
</div>
<div class="hint fixed-col-hint">An <code>id SERIAL PRIMARY KEY</code> column is always added automatically.</div>
<div class="column-row header-row">
<span>Name</span>
<span>Type</span>
<span>Nullable</span>
<span></span>
</div>
<div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
<NychInputText v-model="col.name" placeholder="column_name" fluid />
<NychSelect
v-model="col.col_type"
:options="COLUMN_TYPES"
fluid
/>
<div class="nullable-toggle">
<input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
<label :for="`nullable-${i}`">Yes</label>
</div>
<button type="button" class="remove-btn" @click="removeColumn(i)">✕</button>
</div>
<div class="empty-columns" v-if="createForm.columns.length === 0">
<span>Add at least one column.</span>
</div>
</div>
<NychButton type="submit" fluid :disabled="!canSubmitCreate">Create Table</NychButton>
</form>
</NychDialog>
<!-- Inspect Dialog (read-only) -->
<NychDialog v-model:visible="showInspect" :header="`Inspect — ${inspectPreview?.table_name ?? ''}`" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
<div v-if="inspectLoading" class="preview-loading">Loading table data…</div>
<div v-else-if="inspectPreview" class="preview-body">
<div class="preview-meta">
<div class="meta-row">
<span class="meta-label">Table</span>
<code class="meta-value">{{ inspectPreview.table_name }}</code>
</div>
<div class="meta-row">
<span class="meta-label">Rows</span>
<span class="meta-value">{{ inspectPreview.row_count.toLocaleString() }}</span>
</div>
<div class="meta-row">
<span class="meta-label">CRUD endpoint</span>
<code class="meta-value endpoint">/api/{{ inspectPreview.table_name }}</code>
</div>
</div>
<div class="schema-section">
<div class="section-label">Schema</div>
<table class="schema-table">
<thead>
<tr>
<th>Column</th>
<th>Type</th>
<th>Nullable</th>
</tr>
</thead>
<tbody>
<tr v-for="col in inspectPreview.columns" :key="col.column_name">
<td><code>{{ col.column_name }}</code></td>
<td><span class="type-badge">{{ col.data_type }}</span></td>
<td>{{ col.is_nullable === 'YES' ? 'yes' : 'no' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="sample-section" v-if="inspectPreview.sample_rows.length > 0">
<div class="section-label">
Sample data
<span class="sample-count">(first {{ inspectPreview.sample_rows.length }} of {{ inspectPreview.row_count.toLocaleString() }})</span>
</div>
<div class="sample-scroll">
<table class="data-table sample-table">
<thead>
<tr>
<th v-for="col in inspectPreview.columns" :key="col.column_name">{{ col.column_name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in inspectPreview.sample_rows" :key="i">
<td v-for="col in inspectPreview.columns" :key="col.column_name">
{{ row[col.column_name] ?? '—' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="empty-state sample-empty" v-else>
<span class="empty-icon">▦</span>
<span class="empty-label">Empty table</span>
<span class="empty-hint">Use <code>POST /api/{{ inspectPreview.table_name }}</code> to insert rows.</span>
</div>
</div>
</NychDialog>
<!-- Drop Preview Dialog -->
<NychDialog v-model:visible="showDropPreview" header="Drop Table" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
<div v-if="dropPreviewLoading" class="preview-loading">Loading table data…</div>
<div v-else-if="dropPreview" class="preview-body">
<div class="preview-meta">
<div class="meta-row">
<span class="meta-label">Table</span>
<code class="meta-value">{{ dropPreview.table_name }}</code>
</div>
<div class="meta-row">
<span class="meta-label">Rows</span>
<span class="meta-value row-count" :class="dropPreview.row_count > 0 ? 'has-data' : 'no-data'">
{{ dropPreview.row_count.toLocaleString() }}
</span>
</div>
</div>
<div class="warning-banner" v-if="dropPreview.row_count > 0">
⚠ This table contains {{ dropPreview.row_count.toLocaleString() }} {{ dropPreview.row_count === 1 ? 'row' : 'rows' }} that will be permanently deleted.
</div>
<div class="schema-section">
<div class="section-label">Schema</div>
<table class="schema-table">
<thead>
<tr>
<th>Column</th>
<th>Type</th>
<th>Nullable</th>
</tr>
</thead>
<tbody>
<tr v-for="col in dropPreview.columns" :key="col.column_name">
<td><code>{{ col.column_name }}</code></td>
<td><span class="type-badge">{{ col.data_type }}</span></td>
<td>{{ col.is_nullable === 'YES' ? 'yes' : 'no' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="sample-section" v-if="dropPreview.sample_rows.length > 0">
<div class="section-label">
Sample data
<span class="sample-count">(first {{ dropPreview.sample_rows.length }} of {{ dropPreview.row_count.toLocaleString() }})</span>
</div>
<div class="sample-scroll">
<table class="data-table sample-table">
<thead>
<tr>
<th v-for="col in dropPreview.columns" :key="col.column_name">{{ col.column_name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in dropPreview.sample_rows" :key="i">
<td v-for="col in dropPreview.columns" :key="col.column_name">
{{ row[col.column_name] ?? '' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="confirm-section">
<label class="confirm-label">Type <strong>{{ dropPreview.table_name }}</strong> to confirm:</label>
<NychInputText v-model="dropConfirmName" :placeholder="dropPreview.table_name" fluid />
</div>
<div class="drop-actions">
<NychButton severity="secondary" @click="showDropPreview = false">Cancel</NychButton>
<NychButton
severity="danger"
:disabled="dropConfirmName !== dropPreview.table_name"
@click="confirmDrop"
>
Drop Table
</NychButton>
</div>
</div>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const COLUMN_TYPES = [
'TEXT', 'INTEGER', 'BIGINT', 'SMALLINT', 'BOOLEAN',
'NUMERIC', 'FLOAT4', 'FLOAT8', 'UUID', 'TIMESTAMPTZ', 'DATE', 'JSONB',
]
interface TableInfo {
table_name: string
column_count: number
}
interface ColumnInfo {
column_name: string
data_type: string
is_nullable: string
}
interface TablePreview {
table_name: string
row_count: number
columns: ColumnInfo[]
sample_rows: Record<string, unknown>[]
}
interface ColumnDef {
name: string
col_type: string
nullable: boolean
}
const tables = ref<TableInfo[]>([])
// create
const showCreate = ref(false)
const createForm = ref<{ name: string; columns: ColumnDef[] }>({ name: '', columns: [] })
const canSubmitCreate = computed(
() => createForm.value.name.trim() !== '' && createForm.value.columns.length > 0,
)
function addColumn() {
createForm.value.columns.push({ name: '', col_type: 'TEXT', nullable: true })
}
function removeColumn(i: number) {
createForm.value.columns.splice(i, 1)
}
// inspect
const showInspect = ref(false)
const inspectLoading = ref(false)
const inspectPreview = ref<TablePreview | null>(null)
// drop
const showDropPreview = ref(false)
const dropPreviewLoading = ref(false)
const dropPreview = ref<TablePreview | null>(null)
const dropConfirmName = ref('')
async function load() {
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
tables.value = await res.json()
}
async function fetchPreview(tableName: string): Promise<TablePreview> {
const res = await fetch(`/api/admin/tables/${tableName}`, { headers: auth.authHeaders() })
return res.json()
}
async function submitCreate() {
const res = await fetch('/api/admin/tables', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(createForm.value),
})
if (res.ok) {
const createdName = createForm.value.name
showCreate.value = false
createForm.value = { name: '', columns: [] }
await load()
// immediately open inspect so the user can verify the new table
openInspect(createdName)
}
}
async function openInspect(tableName: string) {
showInspect.value = true
inspectLoading.value = true
inspectPreview.value = null
inspectPreview.value = await fetchPreview(tableName)
inspectLoading.value = false
}
async function openDropPreview(tableName: string) {
showDropPreview.value = true
dropPreviewLoading.value = true
dropPreview.value = null
dropConfirmName.value = ''
dropPreview.value = await fetchPreview(tableName)
dropPreviewLoading.value = false
}
async function confirmDrop() {
if (!dropPreview.value) return
await fetch(`/api/admin/tables/${dropPreview.value.table_name}`, {
method: 'DELETE',
headers: auth.authHeaders(),
})
showDropPreview.value = false
dropPreview.value = null
load()
}
onMounted(load)
</script>
<style scoped>
.name-cell { font-family: var(--font-mono); }
.count-cell { color: var(--text-muted); font-family: var(--font-mono); font-size: 0.82rem; }
.actions-cell { display: flex; gap: 0.4rem; }
/* ── Create form ────────────────────────────────── */
.columns-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.columns-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.fixed-col-hint {
margin-top: -0.25rem;
}
.column-row {
display: grid;
grid-template-columns: 1fr 1fr 5rem 2rem;
gap: 0.5rem;
align-items: center;
}
.header-row {
font-size: 0.75rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 0.25rem;
}
.nullable-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--text-muted);
padding-left: 0.25rem;
}
.remove-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 0.8rem;
padding: 0.25rem;
border-radius: 4px;
line-height: 1;
transition: color 0.1s, background 0.1s;
}
.remove-btn:hover {
color: var(--danger);
background: var(--danger-subtle);
}
.empty-columns {
font-size: 0.82rem;
color: var(--text-dim);
padding: 0.5rem 0.25rem;
}
/* ── Shared preview ─────────────────────────────── */
.preview-loading {
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem 0;
text-align: center;
}
.preview-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.preview-meta {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.meta-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.meta-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
font-weight: 600;
}
.meta-value {
font-size: 1rem;
color: var(--text-high);
}
.endpoint {
font-size: 0.85rem;
color: var(--primary);
}
.row-count.has-data { color: var(--danger); font-weight: 700; }
.row-count.no-data { color: var(--success); }
.warning-banner {
background: var(--danger-subtle);
border: 1px solid var(--danger-border);
color: var(--danger);
border-radius: 6px;
padding: 0.65rem 0.9rem;
font-size: 0.85rem;
font-weight: 500;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 0.4rem;
}
.sample-count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--text-muted);
}
.schema-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.schema-table th,
.schema-table td {
padding: 0.35rem 0.6rem;
border-bottom: 1px solid var(--border-lo);
text-align: left;
}
.schema-table th {
color: var(--text-dim);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.type-badge {
font-family: var(--font-mono);
font-size: 0.75rem;
background: var(--surface-2);
border: 1px solid var(--border-lo);
border-radius: 4px;
padding: 0.1rem 0.4rem;
color: var(--text-label);
}
.sample-scroll {
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-lo);
border-radius: 6px;
}
.sample-table {
min-width: 100%;
}
.sample-empty {
padding: 1.5rem;
border: 1px dashed var(--border-lo);
border-radius: 8px;
}
/* ── Drop confirm ───────────────────────────────── */
.confirm-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.confirm-label {
font-size: 0.85rem;
color: var(--text-label);
}
.drop-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.25rem;
}
</style>