575 lines
16 KiB
Vue
575 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" label="+ New Table" />
|
|
</div>
|
|
|
|
<div class="table-card">
|
|
<div class="table-card-header">
|
|
<span class="count">{{ tables.length }} {{ tables.length === 1 ? 'table' : 'tables' }}</span>
|
|
</div>
|
|
<div v-if="loading" class="loading-overlay">
|
|
<div class="loading-spinner"></div>
|
|
<span>Loading…</span>
|
|
</div>
|
|
<template v-else-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)" label="Inspect" />
|
|
<NychButton size="small" severity="danger" @click="openDropPreview(t.table_name)" label="Drop" />
|
|
</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" label="+ Add Column" />
|
|
</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" label="Create Table" />
|
|
</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" label="Cancel" />
|
|
<NychButton severity="danger" :disabled="dropConfirmName !== dropPreview.table_name" @click="confirmDrop" label="Drop Table" />
|
|
</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[]>([])
|
|
const loading = ref(false)
|
|
|
|
// 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() {
|
|
loading.value = true
|
|
try {
|
|
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
|
|
tables.value = await res.json()
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
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>
|