first commit

This commit is contained in:
2026-03-08 04:16:14 -04:00
commit 2bc6071b4e
38 changed files with 8444 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{% macro sidebar_table_links(tables, activeName) %}
{% for table in tables %}
<a
href="{{ path('table_browse', {name: table}) }}"
class="sidebar-table-link {% if table == activeName %}active{% endif %}"
x-show="filterMatches('{{ table }}')"
>
<i class="bi bi-table"></i>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ table }}">{{ table }}</span>
</a>
{% endfor %}
{% endmacro %}

View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jormun Admin — Sign In</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--jormun-teal: #0d9488;
--jormun-teal-mid: #14b8a6;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #f0fdfa;
display: flex;
align-items: center;
justify-content: center;
font-family: 'DM Mono', monospace;
}
/* Subtle background grid */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(13,148,136,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(13,148,136,0.04) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
}
.login-wrap {
width: 380px;
position: relative;
z-index: 1;
}
.login-logo {
text-align: center;
margin-bottom: 2rem;
}
.login-logo svg { width: 52px; height: 52px; }
.login-logo h1 {
font-family: 'Syne', sans-serif;
font-size: 1.6rem;
font-weight: 800;
color: var(--jormun-teal);
margin: 0.4rem 0 0.1rem;
letter-spacing: -0.02em;
}
.login-logo p {
font-size: 0.72rem;
color: #64748b;
margin: 0;
}
.login-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 2rem;
box-shadow: 0 4px 24px rgba(13,148,136,0.06);
}
.form-label {
font-size: 0.72rem;
font-weight: 500;
color: #374151;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 0.3rem;
}
.form-control {
font-family: 'DM Mono', monospace;
font-size: 0.82rem;
border: 1px solid #e2e8f0;
border-radius: 5px;
padding: 0.5rem 0.75rem;
}
.form-control:focus {
border-color: var(--jormun-teal);
box-shadow: 0 0 0 3px rgba(13,148,136,0.12);
}
.btn-login {
width: 100%;
background: var(--jormun-teal);
color: #fff;
border: none;
padding: 0.6rem;
border-radius: 6px;
font-family: 'Syne', sans-serif;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: background 0.12s;
margin-top: 1.25rem;
}
.btn-login:hover { background: #059669; }
.alert-error {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #991b1b;
border-radius: 5px;
padding: 0.5rem 0.75rem;
font-size: 0.78rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.login-footer {
text-align: center;
margin-top: 1.25rem;
font-size: 0.68rem;
color: #94a3b8;
}
</style>
</head>
<body>
<div class="login-wrap">
<div class="login-logo">
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 3C9.373 3 4 8.373 4 15c0 4.5 2.3 8.46 5.78 10.8" stroke="#0d9488" stroke-width="2.5" stroke-linecap="round"/>
<path d="M16 29c6.627 0 12-5.373 12-12 0-4.5-2.3-8.46-5.78-10.8" stroke="#14b8a6" stroke-width="2.5" stroke-linecap="round"/>
<path d="M9.78 25.8C11.5 27.2 13.65 28 16 28" stroke="#0d9488" stroke-width="2" stroke-linecap="round"/>
<path d="M22.22 6.2C20.5 4.8 18.35 4 16 4" stroke="#14b8a6" stroke-width="2" stroke-linecap="round"/>
<circle cx="9" cy="26" r="2" fill="#0d9488"/>
<circle cx="23" cy="6" r="2" fill="#14b8a6"/>
</svg>
<h1>JormunAdmin</h1>
<p>DynamoDB-compatible database administration</p>
</div>
<div class="login-card">
{% if error %}
<div class="alert-error">
<i class="bi bi-exclamation-triangle-fill"></i>
{{ error }}
</div>
{% endif %}
<form method="POST" action="{{ path('login') }}">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" placeholder="root" autofocus required>
</div>
<div class="mb-1">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" placeholder="••••••" required>
</div>
<button type="submit" class="btn-login">
<i class="bi bi-shield-lock"></i>
Sign In
</button>
</form>
</div>
<div class="login-footer">
Jormun Admin &mdash; JormunDB Administration Interface
</div>
</div>
</body>
</html>

628
templates/base.html.twig Normal file
View File

@@ -0,0 +1,628 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Jormun Admin{% endblock %}</title>
{# Bootstrap 5 - modern, light/dark built-in #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
{# Bootstrap Icons #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
{# Google Fonts: Syne (display) + DM Mono (data) #}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--jormun-teal: #0d9488;
--jormun-teal-mid: #14b8a6;
--jormun-teal-light: #ccfbf1;
--jormun-teal-xlight: #f0fdfa;
--jormun-dark: #0f2027;
--jormun-accent: #059669;
--sidebar-width: 230px;
--topbar-height: 52px;
}
/* ── Typography ── */
body {
font-family: 'DM Mono', monospace;
font-size: 0.82rem;
background: #f8fafb;
}
.font-display { font-family: 'Syne', sans-serif; }
/* ── Topbar ── */
.jormun-topbar {
height: var(--topbar-height);
background: #fff;
border-bottom: 1px solid #e2e8f0;
position: fixed;
top: 0; left: 0; right: 0;
z-index: 1030;
display: flex;
align-items: center;
padding: 0 1rem;
gap: 1rem;
}
.jormun-logo {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 1.1rem;
color: var(--jormun-teal);
letter-spacing: -0.02em;
text-decoration: none;
display: flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
}
.jormun-logo .logo-serpent {
width: 26px;
height: 26px;
flex-shrink: 0;
}
.jormun-logo span.sub {
font-weight: 400;
font-size: 0.7rem;
color: #94a3b8;
font-family: 'DM Mono', monospace;
}
/* ── Breadcrumbs ── */
.jormun-breadcrumb {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: #64748b;
flex: 1;
}
.jormun-breadcrumb a {
color: var(--jormun-teal);
text-decoration: none;
}
.jormun-breadcrumb a:hover { text-decoration: underline; }
.jormun-breadcrumb .sep { color: #cbd5e1; }
.jormun-breadcrumb .current {
color: #1e293b;
font-weight: 500;
}
/* ── Sidebar ── */
.jormun-sidebar {
width: var(--sidebar-width);
background: #fff;
border-right: 1px solid #e2e8f0;
position: fixed;
top: var(--topbar-height);
left: 0;
bottom: 0;
overflow-y: auto;
z-index: 900;
transition: transform 0.2s ease;
display: flex;
flex-direction: column;
}
.jormun-sidebar.collapsed {
transform: translateX(calc(-1 * var(--sidebar-width)));
}
.sidebar-section-label {
font-family: 'Syne', sans-serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #94a3b8;
padding: 0.75rem 1rem 0.3rem;
}
.sidebar-table-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.38rem 1rem;
color: #374151;
text-decoration: none;
font-size: 0.78rem;
border-left: 3px solid transparent;
transition: all 0.12s;
}
.sidebar-table-link:hover {
background: var(--jormun-teal-xlight);
color: var(--jormun-teal);
border-left-color: var(--jormun-teal-mid);
}
.sidebar-table-link.active {
background: var(--jormun-teal-light);
color: var(--jormun-teal);
border-left-color: var(--jormun-teal);
font-weight: 500;
}
.sidebar-table-link i { font-size: 0.75rem; opacity: 0.6; }
.sidebar-new-table {
margin: 0.5rem 0.75rem;
}
.sidebar-search {
padding: 0.5rem 0.75rem 0.3rem;
}
/* ── Main content ── */
.jormun-main {
margin-left: var(--sidebar-width);
margin-top: var(--topbar-height);
padding: 1.25rem 1.5rem;
min-height: calc(100vh - var(--topbar-height));
transition: margin-left 0.2s ease;
}
.jormun-main.sidebar-collapsed {
margin-left: 0;
}
/* ── Operation Tabs ── */
.op-tabs {
display: flex;
gap: 0.15rem;
margin-bottom: 1rem;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0;
}
.op-tab {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.85rem;
font-size: 0.78rem;
font-family: 'DM Mono', monospace;
color: #64748b;
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.12s;
}
.op-tab:hover {
color: var(--jormun-teal);
background: var(--jormun-teal-xlight);
}
.op-tab.active {
color: var(--jormun-teal);
border-bottom-color: var(--jormun-teal);
font-weight: 500;
}
.op-tab i { font-size: 0.8rem; }
/* ── Toasts ── */
.toast-zone {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.jormun-toast {
pointer-events: all;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1rem;
border-radius: 6px;
font-size: 0.78rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
border-left: 4px solid;
background: #fff;
min-width: 260px;
max-width: 400px;
animation: toast-in 0.22s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.jormun-toast.success { border-color: var(--jormun-teal); }
.jormun-toast.success i { color: var(--jormun-teal); }
.jormun-toast.error { border-color: #ef4444; }
.jormun-toast.error i { color: #ef4444; }
.jormun-toast.info { border-color: #3b82f6; }
.jormun-toast.info i { color: #3b82f6; }
/* ── Content card ── */
.content-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.content-card-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.content-card-header h6 {
font-family: 'Syne', sans-serif;
font-size: 0.78rem;
font-weight: 700;
color: #374151;
margin: 0;
}
/* ── Data table ── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.data-table th {
background: #f8fafc;
padding: 0.5rem 0.75rem;
text-align: left;
font-family: 'Syne', sans-serif;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
.data-table td {
padding: 0.45rem 0.75rem;
border-bottom: 1px solid #f1f5f9;
vertical-align: top;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-table tr:hover td { background: var(--jormun-teal-xlight); }
.data-table .pk-cell {
color: var(--jormun-teal);
font-weight: 500;
}
.data-table .null-val { color: #cbd5e1; font-style: italic; }
.data-table .action-cell { white-space: nowrap; }
/* ── Badges / pills ── */
.status-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.status-badge.active { background: #dcfce7; color: #166534; }
.status-badge.creating { background: #fef9c3; color: #854d0e; }
.status-badge.deleting { background: #fee2e2; color: #991b1b; }
.status-badge.error { background: #fee2e2; color: #991b1b; }
.status-badge.unknown { background: #f1f5f9; color: #64748b; }
.type-badge {
display: inline-block;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
background: var(--jormun-teal-light);
color: var(--jormun-teal);
}
/* ── Buttons ── */
.btn-jormun {
background: var(--jormun-teal);
color: #fff;
border: none;
padding: 0.38rem 0.9rem;
border-radius: 5px;
font-size: 0.78rem;
font-family: 'DM Mono', monospace;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transition: background 0.12s;
}
.btn-jormun:hover { background: var(--jormun-accent); color: #fff; }
.btn-jormun-outline {
background: transparent;
color: var(--jormun-teal);
border: 1px solid var(--jormun-teal);
padding: 0.35rem 0.85rem;
border-radius: 5px;
font-size: 0.78rem;
font-family: 'DM Mono', monospace;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
transition: all 0.12s;
}
.btn-jormun-outline:hover {
background: var(--jormun-teal);
color: #fff;
}
.btn-danger-sm {
background: transparent;
color: #ef4444;
border: 1px solid #fca5a5;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.72rem;
cursor: pointer;
font-family: 'DM Mono', monospace;
transition: all 0.12s;
}
.btn-danger-sm:hover { background: #ef4444; color: #fff; }
.btn-sm-edit {
background: transparent;
color: #3b82f6;
border: 1px solid #bfdbfe;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.72rem;
cursor: pointer;
font-family: 'DM Mono', monospace;
text-decoration: none;
transition: all 0.12s;
}
.btn-sm-edit:hover { background: #3b82f6; color: #fff; }
/* ── Form elements ── */
.jormun-input {
width: 100%;
padding: 0.4rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 5px;
font-family: 'DM Mono', monospace;
font-size: 0.78rem;
color: #1e293b;
background: #fff;
transition: border-color 0.12s;
}
.jormun-input:focus {
outline: none;
border-color: var(--jormun-teal);
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.12);
}
.jormun-select {
padding: 0.4rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 5px;
font-family: 'DM Mono', monospace;
font-size: 0.78rem;
color: #1e293b;
background: #fff;
}
/* ── JSON editor ── */
.json-editor {
width: 100%;
min-height: 280px;
padding: 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 5px;
font-family: 'DM Mono', monospace;
font-size: 0.78rem;
color: #1e293b;
resize: vertical;
line-height: 1.6;
}
.json-editor:focus {
outline: none;
border-color: var(--jormun-teal);
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.12);
}
/* ── Dark mode overrides ── */
[data-bs-theme="dark"] body { background: #0f172a; }
[data-bs-theme="dark"] .jormun-topbar,
[data-bs-theme="dark"] .jormun-sidebar {
background: #1e293b;
border-color: #334155;
}
[data-bs-theme="dark"] .content-card {
background: #1e293b;
border-color: #334155;
}
[data-bs-theme="dark"] .content-card-header {
background: #0f172a;
border-color: #334155;
}
[data-bs-theme="dark"] .data-table th {
background: #0f172a;
color: #94a3b8;
}
[data-bs-theme="dark"] .data-table td { border-color: #1e293b; }
[data-bs-theme="dark"] .sidebar-table-link { color: #94a3b8; }
[data-bs-theme="dark"] .jormun-input,
[data-bs-theme="dark"] .jormun-select,
[data-bs-theme="dark"] .json-editor {
background: #0f172a;
border-color: #334155;
color: #e2e8f0;
}
[data-bs-theme="dark"] .jormun-toast { background: #1e293b; }
/* ── Misc ── */
.cell-value { font-family: 'DM Mono', monospace; }
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #94a3b8;
}
.empty-state i { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; color: #cbd5e1; }
.stat-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.6rem;
background: #f1f5f9;
border-radius: 999px;
font-size: 0.7rem;
color: #64748b;
}
/* Sidebar toggle btn */
.sidebar-toggle {
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.sidebar-toggle:hover { background: #f1f5f9; color: var(--jormun-teal); }
</style>
{% block head %}{% endblock %}
</head>
<body x-data="jormunApp()" :data-bs-theme="darkMode ? 'dark' : 'light'">
{# ── Topbar ── #}
<header class="jormun-topbar">
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen" title="Toggle sidebar">
<i class="bi bi-layout-sidebar"></i>
</button>
<a href="{{ path('dashboard') }}" class="jormun-logo">
{# Serpent SVG icon — stylized ouroboros/coil #}
<svg class="logo-serpent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 3C9.373 3 4 8.373 4 15c0 4.5 2.3 8.46 5.78 10.8" stroke="#0d9488" stroke-width="2.5" stroke-linecap="round"/>
<path d="M16 29c6.627 0 12-5.373 12-12 0-4.5-2.3-8.46-5.78-10.8" stroke="#14b8a6" stroke-width="2.5" stroke-linecap="round"/>
<path d="M9.78 25.8C11.5 27.2 13.65 28 16 28" stroke="#0d9488" stroke-width="2" stroke-linecap="round"/>
<path d="M22.22 6.2C20.5 4.8 18.35 4 16 4" stroke="#14b8a6" stroke-width="2" stroke-linecap="round"/>
<circle cx="9" cy="26" r="2" fill="#0d9488"/>
<circle cx="23" cy="6" r="2" fill="#14b8a6"/>
</svg>
Jormun<span style="color:var(--jormun-teal-mid)">Admin</span>
<span class="sub">JormunDB</span>
</a>
{# Breadcrumbs #}
<nav class="jormun-breadcrumb">
{% block breadcrumbs %}
<a href="{{ path('dashboard') }}"><i class="bi bi-house"></i> Home</a>
{% endblock %}
</nav>
{# Right side controls #}
<div class="d-flex align-items-center gap-2 ms-auto">
<button class="sidebar-toggle" @click="toggleDark()" :title="darkMode ? 'Light mode' : 'Dark mode'">
<i :class="darkMode ? 'bi bi-sun' : 'bi bi-moon'"></i>
</button>
<a href="{{ path('logout') }}" class="sidebar-toggle" title="Logout">
<i class="bi bi-box-arrow-right"></i>
</a>
</div>
</header>
{# ── Sidebar ── #}
<aside class="jormun-sidebar" :class="{ 'collapsed': !sidebarOpen }">
<div class="sidebar-search">
<input
type="text"
class="jormun-input"
placeholder="&#xF52A; Filter tables…"
x-model="tableFilter"
style="font-family: 'DM Mono', monospace; font-size:0.75rem;"
>
</div>
<div class="sidebar-section-label">Tables</div>
{% block sidebar_tables %}{% endblock %}
<div style="padding: 0.5rem 0.75rem; margin-top: auto; border-top: 1px solid #f1f5f9;">
<a href="{{ path('table_create') }}" class="btn-jormun-outline w-100" style="justify-content:center;">
<i class="bi bi-plus-lg"></i> New Table
</a>
</div>
</aside>
{# ── Main ── #}
<main class="jormun-main" :class="{ 'sidebar-collapsed': !sidebarOpen }">
{% block content %}{% endblock %}
</main>
{# ── Toast Zone ── #}
<div class="toast-zone" x-data>
<template x-for="(t, i) in $store.toasts.list" :key="t.id">
<div class="jormun-toast" :class="t.type" x-show="t.visible" x-transition.opacity>
<i :class="{
'bi bi-check-circle-fill': t.type === 'success',
'bi bi-exclamation-triangle-fill': t.type === 'error',
'bi bi-info-circle-fill': t.type === 'info'
}"></i>
<span x-text="t.msg" style="flex:1;"></span>
<button @click="$store.toasts.dismiss(t.id)" style="background:none;border:none;cursor:pointer;color:#94a3b8;padding:0 0.2rem;">
<i class="bi bi-x"></i>
</button>
</div>
</template>
</div>
{# ── Server-side toast injection ── #}
{% if app.session.get('toast') %}
<script>
document.addEventListener('alpine:init', () => {
const t = {{ app.session.get('toast')|json_encode|raw }};
Alpine.store('toasts').add(t.type, t.msg);
});
</script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
// ── Toast store ──
Alpine.store('toasts', {
list: [],
_id: 0,
add(type, msg, duration = 4000) {
const id = ++this._id;
this.list.push({ id, type, msg, visible: true });
setTimeout(() => this.dismiss(id), duration);
},
dismiss(id) {
const t = this.list.find(x => x.id === id);
if (t) t.visible = false;
setTimeout(() => { this.list = this.list.filter(x => x.id !== id); }, 300);
}
});
});
function jormunApp() {
return {
sidebarOpen: true,
tableFilter: '',
darkMode: localStorage.getItem('jormun-dark') === '1',
toggleDark() {
this.darkMode = !this.darkMode;
localStorage.setItem('jormun-dark', this.darkMode ? '1' : '0');
},
filterMatches(name) {
if (!this.tableFilter) return true;
return name.toLowerCase().includes(this.tableFilter.toLowerCase());
}
};
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,112 @@
{% extends 'base.html.twig' %}
{% block title %}Jormun Admin — Dashboard{% endblock %}
{% block breadcrumbs %}
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
<span class="sep">/</span>
<span class="current">Dashboard</span>
{% endblock %}
{% block sidebar_tables %}
{% for table in tables %}
<a
href="{{ path('table_browse', {name: table.name}) }}"
class="sidebar-table-link"
x-show="filterMatches('{{ table.name }}')"
>
<i class="bi bi-table"></i>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ table.name }}</span>
</a>
{% endfor %}
{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h5 class="font-display mb-0" style="font-weight:700;font-size:1rem;">Dashboard</h5>
<div style="font-size:0.72rem;color:#64748b;">{{ tables|length }} table{{ tables|length != 1 ? 's' : '' }} in instance</div>
</div>
<a href="{{ path('table_create') }}" class="btn-jormun">
<i class="bi bi-plus-lg"></i> New Table
</a>
</div>
{% if error %}
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
</div>
{% endif %}
{% if tables is empty and not error %}
<div class="content-card">
<div class="empty-state">
<i class="bi bi-database-slash"></i>
<div style="font-family:'Syne',sans-serif;font-weight:700;font-size:0.9rem;color:#374151;margin-bottom:0.4rem;">No tables yet</div>
<div style="font-size:0.75rem;margin-bottom:1rem;">Create your first JormunDB table to get started.</div>
<a href="{{ path('table_create') }}" class="btn-jormun">
<i class="bi bi-plus-lg"></i> Create Table
</a>
</div>
</div>
{% else %}
<div class="content-card">
<div class="content-card-header">
<h6><i class="bi bi-database me-1" style="color:var(--jormun-teal);"></i> Tables</h6>
<span class="stat-pill"><i class="bi bi-layers"></i> {{ tables|length }} total</span>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>Table Name</th>
<th>Status</th>
<th>Item Count</th>
<th>Size</th>
<th>Partition Key</th>
<th>Sort Key</th>
<th style="width:120px;">Actions</th>
</tr>
</thead>
<tbody>
{% for table in tables %}
{% set pk = null %}
{% set sk = null %}
{% for k in table.keySchema %}
{% if k.KeyType == 'HASH' %}{% set pk = k.AttributeName %}{% endif %}
{% if k.KeyType == 'RANGE' %}{% set sk = k.AttributeName %}{% endif %}
{% endfor %}
<tr>
<td>
<a href="{{ path('table_browse', {name: table.name}) }}" style="color:var(--jormun-teal);text-decoration:none;font-weight:500;">
<i class="bi bi-table me-1" style="opacity:0.5;"></i>{{ table.name }}
</a>
</td>
<td>
<span class="status-badge {{ table.status|lower }}">{{ table.status }}</span>
</td>
<td>{{ table.itemCount|number_format }}</td>
<td>
{% if table.sizeBytes >= 1048576 %}
{{ (table.sizeBytes / 1048576)|number_format(1) }} MB
{% elseif table.sizeBytes >= 1024 %}
{{ (table.sizeBytes / 1024)|number_format(1) }} KB
{% else %}
{{ table.sizeBytes }} B
{% endif %}
</td>
<td><span class="type-badge">{{ pk ?? '—' }}</span></td>
<td><span class="{% if sk %}type-badge{% else %}null-val{% endif %}">{{ sk ?? 'none' }}</span></td>
<td class="action-cell">
<a href="{{ path('table_browse', {name: table.name}) }}" class="btn-sm-edit">
<i class="bi bi-eye"></i> Browse
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,240 @@
{% extends 'base.html.twig' %}
{% import '_sidebar_tables.html.twig' as macros %}
{% block title %}{{ tableName }} — Browse · Jormun Admin{% endblock %}
{% block breadcrumbs %}
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
<span class="sep">/</span>
<span class="current">{{ tableName }}</span>
<span class="sep">/</span>
<span class="current">Browse</span>
{% endblock %}
{% block sidebar_tables %}
{{ macros.sidebar_table_links(tables, tableName) }}
{% endblock %}
{% block content %}
{# Op Tabs #}
<nav class="op-tabs">
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab active">
<i class="bi bi-grid-3x3-gap"></i> Browse
</a>
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab">
<i class="bi bi-diagram-3"></i> Structure
</a>
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab">
<i class="bi bi-search"></i> Query
</a>
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab">
<i class="bi bi-plus-circle"></i> Insert
</a>
<span style="flex:1;"></span>
{# Drop table — in a confirmation modal #}
<button class="op-tab" style="color:#ef4444;" @click="showDrop = true">
<i class="bi bi-trash3"></i> Drop Table
</button>
</nav>
{# Error #}
{% if error %}
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
</div>
{% endif %}
{# Stats row #}
{% if not error %}
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
<span class="stat-pill"><i class="bi bi-table"></i> {{ tableName }}</span>
<span class="stat-pill"><i class="bi bi-check-circle" style="color:var(--jormun-teal);"></i> {{ (desc.TableStatus ?? 'UNKNOWN')|lower }}</span>
<span class="stat-pill"><i class="bi bi-collection"></i> ~{{ (desc.ItemCount ?? 0)|number_format }} items</span>
<span class="stat-pill"><i class="bi bi-eye"></i> showing {{ count }} of {{ scannedCount }} scanned</span>
</div>
{% endif %}
{# Main data card #}
<div class="content-card" x-data="browseTable()">
<div class="content-card-header">
<h6><i class="bi bi-grid-3x3-gap me-1" style="color:var(--jormun-teal);"></i> Rows</h6>
<div class="d-flex align-items-center gap-2">
<label style="font-size:0.72rem;color:#64748b;">Rows per page</label>
<select class="jormun-select" onchange="changeLimit(this.value)" style="width:auto;">
{% for l in [10, 25, 50, 100] %}
<option value="{{ l }}" {% if l == limit %}selected{% endif %}>{{ l }}</option>
{% endfor %}
</select>
</div>
</div>
{% if items is empty %}
<div class="empty-state">
<i class="bi bi-inbox"></i>
<div style="font-family:'Syne',sans-serif;font-weight:700;font-size:0.9rem;color:#374151;margin-bottom:0.3rem;">No items found</div>
<div style="font-size:0.75rem;">This table is empty.</div>
</div>
{% else %}
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th style="width:100px;">Actions</th>
{% for col in columns %}
<th>
{{ col }}
{% if keySchema.HASH is defined and keySchema.HASH.name == col %}
<span class="type-badge" title="Partition Key">PK</span>
{% endif %}
{% if keySchema.RANGE is defined and keySchema.RANGE.name == col %}
<span class="type-badge" title="Sort Key">SK</span>
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for item in items %}
{% set itemKey = {} %}
{% if keySchema.HASH is defined %}
{% set itemKey = itemKey|merge({(keySchema.HASH.name): item[keySchema.HASH.name]}) %}
{% endif %}
{% if keySchema.RANGE is defined %}
{% set itemKey = itemKey|merge({(keySchema.RANGE.name): item[keySchema.RANGE.name]}) %}
{% endif %}
<tr>
<td class="action-cell">
<a href="{{ path('table_item_edit', {name: tableName, key: itemKey|json_encode}) }}" class="btn-sm-edit">
<i class="bi bi-pencil"></i>
</a>
<button
class="btn-danger-sm ms-1"
@click="confirmDelete($el.dataset.key)"
data-key="{{ itemKey|json_encode|e('html_attr') }}"
>
<i class="bi bi-trash3"></i>
</button>
</td>
{% for col in columns %}
<td class="{% if keySchema.HASH is defined and keySchema.HASH.name == col %}pk-cell{% endif %} cell-value" title="{{ item[col] is defined ? item[col]|json_encode : 'null' }}">
{% if item[col] is defined %}
{% set val = item[col] %}
{% if val is iterable %}
<span style="color:#6366f1;" title="{{ val|json_encode }}">
<i class="bi bi-braces"></i> {{ val|json_encode|slice(0, 40) }}{% if val|json_encode|length > 40 %}{% endif %}
</span>
{% else %}
{{ val|e|slice(0, 60) }}{% if (val|e|length) > 60 %}{% endif %}
{% endif %}
{% else %}
<span class="null-val">NULL</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Pagination #}
<div style="padding:0.65rem 1rem;border-top:1px solid #f1f5f9;display:flex;align-items:center;gap:0.5rem;justify-content:space-between;">
<div style="font-size:0.72rem;color:#64748b;">
Showing {{ count }} item{{ count != 1 ? 's' : '' }}
</div>
<div class="d-flex gap-2">
{% if prevKeys is not empty %}
{% set prevStack = prevKeys[0:(prevKeys|length - 1)] %}
{% set prevLastKey = prevKeys|last %}
<a href="{{ path('table_browse', {name: tableName, lastKey: prevLastKey, prevKeys: prevStack|json_encode, limit: limit}) }}" class="btn-jormun-outline">
<i class="bi bi-chevron-left"></i> Prev
</a>
{% endif %}
{% if nextKey %}
{% set newPrevKeys = prevKeys %}
{% if currentKey %}
{% set newPrevKeys = newPrevKeys|merge([currentKey]) %}
{% else %}
{# first page, push empty marker #}
{% set newPrevKeys = newPrevKeys|merge(['null']) %}
{% endif %}
<a href="{{ path('table_browse', {name: tableName, lastKey: nextKey, prevKeys: newPrevKeys|json_encode, limit: limit}) }}" class="btn-jormun">
Next <i class="bi bi-chevron-right"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
{# Delete item modal #}
<div x-show="deleteModal" x-cloak style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9000;display:flex;align-items:center;justify-content:center;" @click.self="deleteModal=false">
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:420px;max-width:90vw;" @click.stop>
<h6 class="font-display" style="font-weight:700;margin-bottom:0.5rem;">
<i class="bi bi-trash3-fill" style="color:#ef4444;"></i> Delete Item
</h6>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:1rem;">This will permanently delete the item with key:</p>
<pre style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;padding:0.5rem;font-size:0.72rem;margin-bottom:1rem;" x-text="JSON.stringify(deleteKey, null, 2)"></pre>
<div class="d-flex gap-2 justify-content-end">
<button class="btn-jormun-outline" @click="deleteModal=false">Cancel</button>
<form method="POST" :action="deleteAction">
<input type="hidden" name="key" :value="JSON.stringify(deleteKey)">
<button type="submit" class="btn-jormun" style="background:#ef4444;">
<i class="bi bi-trash3"></i> Delete
</button>
</form>
</div>
</div>
</div>
</div>
{# Drop Table modal #}
<div x-data="{ showDrop: false }" x-cloak>
<div x-show="showDrop" style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9000;display:flex;align-items:center;justify-content:center;" @click.self="showDrop=false">
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:400px;max-width:90vw;">
<h6 class="font-display" style="font-weight:700;margin-bottom:0.5rem;color:#ef4444;">
<i class="bi bi-exclamation-triangle-fill"></i> Drop Table
</h6>
<p style="font-size:0.8rem;color:#374151;margin-bottom:1rem;">
Are you sure you want to drop <strong>{{ tableName }}</strong>? This action is <strong>irreversible</strong> and will delete all data.
</p>
<div class="d-flex gap-2 justify-content-end">
<button class="btn-jormun-outline" @click="showDrop=false">Cancel</button>
<form method="POST" action="{{ path('table_drop', {name: tableName}) }}">
<button type="submit" class="btn-jormun" style="background:#ef4444;">
<i class="bi bi-trash3-fill"></i> Drop Table
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function browseTable() {
return {
deleteModal: false,
deleteKey: null,
deleteAction: '',
confirmDelete(keyJson) {
this.deleteKey = JSON.parse(keyJson);
this.deleteAction = '{{ path('table_item_delete', {name: tableName}) }}';
this.deleteModal = true;
}
};
}
function changeLimit(val) {
const url = new URL(window.location.href);
url.searchParams.set('limit', val);
url.searchParams.delete('lastKey');
url.searchParams.delete('prevKeys');
window.location.href = url.toString();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends 'base.html.twig' %}
{% import '_sidebar_tables.html.twig' as macros %}
{% block title %}Create Table · Jormun Admin{% endblock %}
{% block breadcrumbs %}
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
<span class="sep">/</span>
<span class="current">Create Table</span>
{% endblock %}
{% block sidebar_tables %}
{{ macros.sidebar_table_links(tables, null) }}
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-lg-7 col-xl-6">
<div class="content-card">
<div class="content-card-header">
<h6><i class="bi bi-plus-square me-1" style="color:var(--jormun-teal);"></i> Create New Table</h6>
</div>
<div style="padding:1.25rem;">
{% if error %}
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.65rem 0.85rem;margin-bottom:1rem;font-size:0.78rem;">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ error }}
</div>
{% endif %}
<form method="POST" x-data="{ hasSk: false }">
{# Table name #}
<div class="mb-3">
<label class="form-label" style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">Table Name</label>
<input type="text" name="table_name" class="jormun-input" placeholder="my-table" required
pattern="[a-zA-Z0-9_.-]+" title="Letters, numbers, underscores, dots, hyphens">
<div style="font-size:0.68rem;color:#94a3b8;margin-top:0.25rem;">Letters, numbers, hyphens, underscores, dots. 3255 chars.</div>
</div>
<hr style="border-color:#f1f5f9;margin:1rem 0;">
{# Partition key #}
<div style="font-family:'Syne',sans-serif;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#374151;margin-bottom:0.75rem;">
<i class="bi bi-key" style="color:var(--jormun-teal);"></i> Partition Key
</div>
<div class="row g-2 mb-3">
<div class="col-8">
<input type="text" name="pk_name" class="jormun-input" placeholder="id" required>
</div>
<div class="col-4">
<select name="pk_type" class="jormun-select w-100">
<option value="S">String (S)</option>
<option value="N">Number (N)</option>
<option value="B">Binary (B)</option>
</select>
</div>
</div>
<hr style="border-color:#f1f5f9;margin:1rem 0;">
{# Sort key toggle #}
<div class="mb-3">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;font-size:0.78rem;">
<input type="checkbox" x-model="hasSk" style="accent-color:var(--jormun-teal);">
Add Sort Key
</label>
</div>
<div x-show="hasSk" x-transition>
<div style="font-family:'Syne',sans-serif;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#374151;margin-bottom:0.75rem;">
<i class="bi bi-sort-down" style="color:var(--jormun-teal);"></i> Sort Key
</div>
<div class="row g-2 mb-3">
<div class="col-8">
<input type="text" name="sk_name" class="jormun-input" placeholder="createdAt" :required="hasSk">
</div>
<div class="col-4">
<select name="sk_type" class="jormun-select w-100">
<option value="S">String (S)</option>
<option value="N">Number (N)</option>
<option value="B">Binary (B)</option>
</select>
</div>
</div>
</div>
<hr style="border-color:#f1f5f9;margin:1rem 0;">
<div style="background:var(--jormun-teal-xlight);border:1px solid var(--jormun-teal-light);border-radius:6px;padding:0.6rem 0.85rem;margin-bottom:1.25rem;font-size:0.75rem;color:#374151;">
<i class="bi bi-lightning-charge" style="color:var(--jormun-teal);"></i>
Table will be created with <strong>PAY_PER_REQUEST</strong> billing mode.
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn-jormun">
<i class="bi bi-plus-lg"></i> Create Table
</button>
<a href="{{ path('dashboard') }}" class="btn-jormun-outline">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,145 @@
{% extends 'base.html.twig' %}
{% import '_sidebar_tables.html.twig' as macros %}
{% block title %}{{ tableName }}{{ mode == 'new' ? 'Insert Item' : 'Edit Item' }} · Jormun Admin{% endblock %}
{% block breadcrumbs %}
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
<span class="sep">/</span>
<a href="{{ path('table_browse', {name: tableName}) }}">{{ tableName }}</a>
<span class="sep">/</span>
<span class="current">{{ mode == 'new' ? 'Insert Item' : 'Edit Item' }}</span>
{% endblock %}
{% block sidebar_tables %}
{{ macros.sidebar_table_links(tables, tableName) }}
{% endblock %}
{% block content %}
<nav class="op-tabs">
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab">
<i class="bi bi-grid-3x3-gap"></i> Browse
</a>
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab">
<i class="bi bi-diagram-3"></i> Structure
</a>
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab">
<i class="bi bi-search"></i> Query
</a>
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab {% if mode == 'new' %}active{% endif %}">
<i class="bi bi-plus-circle"></i> Insert
</a>
</nav>
<div class="row justify-content-center">
<div class="col-12 col-lg-8 col-xl-7">
<div class="content-card">
<div class="content-card-header">
<h6>
<i class="bi bi-{% if mode == 'new' %}plus-circle{% else %}pencil{% endif %} me-1" style="color:var(--jormun-teal);"></i>
{{ mode == 'new' ? 'Insert New Item' : 'Edit Item' }}
</h6>
<span style="font-size:0.72rem;color:#64748b;">{{ tableName }}</span>
</div>
<div style="padding:1.25rem;" x-data="itemForm()">
{% if error %}
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.65rem 0.85rem;margin-bottom:1rem;font-size:0.78rem;">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ error }}
</div>
{% endif %}
{# Key schema hint #}
<div style="background:var(--jormun-teal-xlight);border:1px solid var(--jormun-teal-light);border-radius:6px;padding:0.6rem 0.85rem;margin-bottom:1rem;font-size:0.75rem;color:#374151;">
<i class="bi bi-info-circle" style="color:var(--jormun-teal);"></i>
Required keys:
<strong>{{ keySchema.HASH.name ?? '?' }}</strong> ({{ keySchema.HASH.type ?? 'S' }})
{% if keySchema.RANGE is defined %}
+ <strong>{{ keySchema.RANGE.name }}</strong> ({{ keySchema.RANGE.type }})
{% endif %}
&nbsp;·&nbsp; Item is a JSON object. Additional attributes are free-form.
</div>
<form method="POST" @submit.prevent="submitForm">
{# JSON editor #}
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">
Item JSON
</label>
<div class="d-flex gap-2">
<button type="button" @click="formatJson()" class="btn-jormun-outline" style="font-size:0.7rem;padding:0.2rem 0.5rem;">
<i class="bi bi-braces"></i> Format
</button>
</div>
</div>
<textarea
name="item_json"
class="json-editor"
x-model="jsonText"
@input="validateJson()"
spellcheck="false"
placeholder='{
"{{ keySchema.HASH.name ?? 'id' }}": "my-value"{% if keySchema.RANGE is defined %},
"{{ keySchema.RANGE.name }}": "sort-value"{% endif %}
}'
></textarea>
<div x-show="jsonError" x-text="jsonError" style="color:#ef4444;font-size:0.72rem;margin-top:0.3rem;"></div>
<div x-show="!jsonError && jsonText.length > 2" style="color:var(--jormun-teal);font-size:0.72rem;margin-top:0.3rem;">
<i class="bi bi-check-circle"></i> Valid JSON
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn-jormun" :disabled="!!jsonError">
<i class="bi bi-{% if mode == 'new' %}plus-lg{% else %}check-lg{% endif %}"></i>
{{ mode == 'new' ? 'Insert Item' : 'Save Changes' }}
</button>
<a href="{{ path('table_browse', {name: tableName}) }}" class="btn-jormun-outline">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('itemForm', () => ({
jsonText: JSON.stringify({{ item ? item|json_encode|raw : '{}' }}, null, 2),
jsonError: '',
validateJson() {
try {
if (this.jsonText.trim()) JSON.parse(this.jsonText);
this.jsonError = '';
} catch(e) {
this.jsonError = 'JSON error: ' + e.message;
}
},
formatJson() {
try {
const parsed = JSON.parse(this.jsonText);
this.jsonText = JSON.stringify(parsed, null, 2);
this.jsonError = '';
} catch(e) {
this.jsonError = 'Cannot format: ' + e.message;
}
},
submitForm() {
try {
JSON.parse(this.jsonText);
this.$el.submit();
} catch(e) {
this.jsonError = 'Fix JSON errors before submitting.';
}
}
}));
});
</script>
{% endblock %}

View File

@@ -0,0 +1,208 @@
{% extends 'base.html.twig' %}
{% import '_sidebar_tables.html.twig' as macros %}
{% block title %}{{ tableName }} — Query · Jormun Admin{% endblock %}
{% block breadcrumbs %}
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
<span class="sep">/</span>
<a href="{{ path('table_browse', {name: tableName}) }}">{{ tableName }}</a>
<span class="sep">/</span>
<span class="current">Query</span>
{% endblock %}
{% block sidebar_tables %}
{{ macros.sidebar_table_links(tables, tableName) }}
{% endblock %}
{% block content %}
<nav class="op-tabs">
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab">
<i class="bi bi-grid-3x3-gap"></i> Browse
</a>
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab">
<i class="bi bi-diagram-3"></i> Structure
</a>
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab active">
<i class="bi bi-search"></i> Query
</a>
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab">
<i class="bi bi-plus-circle"></i> Insert
</a>
</nav>
<div x-data="queryBuilder()" class="row g-3">
{# Builder form #}
<div class="col-12 col-lg-4">
<div class="content-card">
<div class="content-card-header">
<h6><i class="bi bi-funnel me-1" style="color:var(--jormun-teal);"></i> Query Builder</h6>
</div>
<div style="padding:1rem;">
<form method="POST" action="{{ path('table_query', {name: tableName}) }}">
{# Mode toggle #}
<div class="mb-3">
<label class="form-label" style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">Mode</label>
<div class="d-flex gap-2">
<label style="display:flex;align-items:center;gap:0.3rem;font-size:0.78rem;cursor:pointer;">
<input type="radio" name="mode" value="query" x-model="queryMode" {% if mode != 'scan' %}checked{% endif %}> Query
</label>
<label style="display:flex;align-items:center;gap:0.3rem;font-size:0.78rem;cursor:pointer;">
<input type="radio" name="mode" value="scan" x-model="queryMode" {% if mode == 'scan' %}checked{% endif %}> Scan (full table)
</label>
</div>
</div>
{# Conditions (only for Query mode) #}
<div x-show="queryMode === 'query'">
<div class="mb-2" style="font-size:0.72rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:#374151;">
Key Conditions
</div>
<div class="mb-2" style="font-size:0.72rem;color:#64748b;">
PK: <span class="type-badge">{{ keySchema.HASH.name ?? '?' }}</span>
{% if keySchema.RANGE is defined %}
SK: <span class="type-badge">{{ keySchema.RANGE.name }}</span>
{% endif %}
</div>
<template x-for="(cond, i) in conditions" :key="i">
<div class="d-flex gap-1 mb-2 align-items-start">
<input
type="text"
:name="'cond_key[]'"
x-model="cond.key"
class="jormun-input"
placeholder="attribute"
style="width:35%;"
>
<select :name="'cond_op[]'" x-model="cond.op" class="jormun-select" style="width:28%;">
<option value="=">=</option>
<option value="<">&lt;</option>
<option value="<=">&lt;=</option>
<option value=">">&gt;</option>
<option value=">=">&gt;=</option>
<option value="begins_with">begins</option>
</select>
<input
type="text"
:name="'cond_val[]'"
x-model="cond.val"
class="jormun-input"
placeholder="value"
style="flex:1;"
>
<button type="button" @click="removeCond(i)" class="btn-danger-sm" style="flex-shrink:0;padding:0.35rem 0.5rem;">
<i class="bi bi-x"></i>
</button>
</div>
</template>
<button type="button" @click="addCond()" class="btn-jormun-outline w-100 mb-3" style="font-size:0.72rem;padding:0.3rem;">
<i class="bi bi-plus"></i> Add Condition
</button>
</div>
<button type="submit" class="btn-jormun w-100">
<i :class="queryMode === 'scan' ? 'bi bi-lightning' : 'bi bi-search'"></i>
<span x-text="queryMode === 'scan' ? 'Run Scan' : 'Run Query'"></span>
</button>
</form>
</div>
</div>
</div>
{# Results #}
<div class="col-12 col-lg-8">
{% if error %}
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
</div>
{% endif %}
{% if results is not null %}
<div class="content-card">
<div class="content-card-header">
<h6><i class="bi bi-table me-1" style="color:var(--jormun-teal);"></i> Results</h6>
<span class="stat-pill">{{ results|length }} item{{ results|length != 1 ? 's' : '' }}</span>
</div>
{% if results is empty %}
<div class="empty-state">
<i class="bi bi-search"></i>
<div>No items matched your query.</div>
</div>
{% else %}
{# Collect columns #}
{% set cols = {} %}
{% for item in results %}{% for k in item|keys %}{% set cols = cols|merge({(k): true}) %}{% endfor %}{% endfor %}
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
{% for col in cols|keys %}
<th>{{ col }}
{% if keySchema.HASH is defined and keySchema.HASH.name == col %}
<span class="type-badge">PK</span>
{% endif %}
{% if keySchema.RANGE is defined and keySchema.RANGE.name == col %}
<span class="type-badge">SK</span>
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for item in results %}
<tr>
{% for col in cols|keys %}
<td class="cell-value {% if keySchema.HASH is defined and keySchema.HASH.name == col %}pk-cell{% endif %}">
{% if item[col] is defined %}
{% set val = item[col] %}
{% if val is iterable %}
<span style="color:#6366f1;">{{ val|json_encode|slice(0, 50) }}{% if val|json_encode|length > 50 %}{% endif %}</span>
{% else %}
{{ val|e|slice(0, 60) }}{% if (val|e|length) > 60 %}{% endif %}
{% endif %}
{% else %}
<span class="null-val">NULL</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="content-card">
<div class="empty-state" style="padding:4rem 1rem;">
<i class="bi bi-search" style="color:#cbd5e1;"></i>
<div style="font-family:'Syne',sans-serif;font-weight:700;font-size:0.9rem;color:#374151;margin-bottom:0.3rem;">Build a query</div>
<div style="font-size:0.75rem;color:#94a3b8;">
Set conditions on the left and click Run Query, or use Scan for all items.
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function queryBuilder() {
return {
queryMode: '{{ mode }}',
conditions: [{ key: '{{ keySchema.HASH.name ?? '' }}', op: '=', val: '' }],
addCond() { this.conditions.push({ key: '', op: '=', val: '' }); },
removeCond(i) { if (this.conditions.length > 1) this.conditions.splice(i, 1); }
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,191 @@
{% extends 'base.html.twig' %}
{% import '_sidebar_tables.html.twig' as macros %}
{% block title %}{{ tableName }} — Structure · Jormun Admin{% endblock %}
{% block breadcrumbs %}
<a href="{{ path('dashboard') }}"><i class="bi bi-house-fill"></i> Home</a>
<span class="sep">/</span>
<a href="{{ path('table_browse', {name: tableName}) }}">{{ tableName }}</a>
<span class="sep">/</span>
<span class="current">Structure</span>
{% endblock %}
{% block sidebar_tables %}
{{ macros.sidebar_table_links(tables, tableName) }}
{% endblock %}
{% block content %}
<nav class="op-tabs">
<a href="{{ path('table_browse', {name: tableName}) }}" class="op-tab">
<i class="bi bi-grid-3x3-gap"></i> Browse
</a>
<a href="{{ path('table_structure', {name: tableName}) }}" class="op-tab active">
<i class="bi bi-diagram-3"></i> Structure
</a>
<a href="{{ path('table_query', {name: tableName}) }}" class="op-tab">
<i class="bi bi-search"></i> Query
</a>
<a href="{{ path('table_item_new', {name: tableName}) }}" class="op-tab">
<i class="bi bi-plus-circle"></i> Insert
</a>
</nav>
{% if error %}
<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.8rem;">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
</div>
{% endif %}
{% if desc %}
<div class="row g-3">
{# Table info #}
<div class="col-12 col-md-6">
<div class="content-card h-100">
<div class="content-card-header">
<h6><i class="bi bi-info-circle me-1" style="color:var(--jormun-teal);"></i> Table Info</h6>
</div>
<div style="padding:0.75rem 1rem;">
<table class="data-table">
<tr><td style="color:#64748b;width:40%;">Name</td><td><strong>{{ desc.TableName }}</strong></td></tr>
<tr><td style="color:#64748b;">Status</td><td><span class="status-badge {{ desc.TableStatus|lower }}">{{ desc.TableStatus }}</span></td></tr>
<tr><td style="color:#64748b;">Item Count</td><td>{{ (desc.ItemCount ?? 0)|number_format }}</td></tr>
<tr>
<td style="color:#64748b;">Size</td>
<td>
{% set bytes = desc.TableSizeBytes ?? 0 %}
{% if bytes >= 1048576 %}{{ (bytes / 1048576)|number_format(2) }} MB
{% elseif bytes >= 1024 %}{{ (bytes / 1024)|number_format(2) }} KB
{% else %}{{ bytes }} B{% endif %}
</td>
</tr>
<tr><td style="color:#64748b;">Billing Mode</td><td>{{ desc.BillingModeSummary.BillingMode ?? 'PROVISIONED' }}</td></tr>
{% if desc.CreationDateTime is defined %}
<tr><td style="color:#64748b;">Created</td><td>{{ desc.CreationDateTime|date('Y-m-d H:i:s') }}</td></tr>
{% endif %}
<tr><td style="color:#64748b;">Table ARN</td><td style="font-size:0.68rem;word-break:break-all;">{{ desc.TableArn ?? '—' }}</td></tr>
</table>
</div>
</div>
</div>
{# Key Schema #}
<div class="col-12 col-md-6">
<div class="content-card h-100">
<div class="content-card-header">
<h6><i class="bi bi-key me-1" style="color:var(--jormun-teal);"></i> Key Schema</h6>
</div>
<div style="padding:0.75rem 1rem;">
<table class="data-table">
<thead>
<tr>
<th>Attribute</th>
<th>Key Type</th>
<th>Data Type</th>
</tr>
</thead>
<tbody>
{% for key in desc.KeySchema %}
{% set attrType = 'S' %}
{% for attr in desc.AttributeDefinitions %}
{% if attr.AttributeName == key.AttributeName %}
{% set attrType = attr.AttributeType %}
{% endif %}
{% endfor %}
<tr>
<td class="pk-cell">{{ key.AttributeName }}</td>
<td>
<span class="status-badge {{ key.KeyType == 'HASH' ? 'active' : 'creating' }}">
{{ key.KeyType == 'HASH' ? 'Partition Key' : 'Sort Key' }}
</span>
</td>
<td>
<span class="type-badge">
{% if attrType == 'S' %}String (S)
{% elseif attrType == 'N' %}Number (N)
{% elseif attrType == 'B' %}Binary (B)
{% else %}{{ attrType }}{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# Secondary Indexes #}
{% if desc.GlobalSecondaryIndexes is defined and desc.GlobalSecondaryIndexes|length > 0 %}
<div class="col-12">
<div class="content-card">
<div class="content-card-header">
<h6><i class="bi bi-diagram-2 me-1" style="color:var(--jormun-teal);"></i> Global Secondary Indexes</h6>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>Index Name</th>
<th>Status</th>
<th>Partition Key</th>
<th>Sort Key</th>
<th>Projection</th>
<th>Item Count</th>
</tr>
</thead>
<tbody>
{% for gsi in desc.GlobalSecondaryIndexes %}
{% set gsiPk = null %}{% set gsiSk = null %}
{% for k in gsi.KeySchema %}
{% if k.KeyType == 'HASH' %}{% set gsiPk = k.AttributeName %}{% endif %}
{% if k.KeyType == 'RANGE' %}{% set gsiSk = k.AttributeName %}{% endif %}
{% endfor %}
<tr>
<td class="pk-cell">{{ gsi.IndexName }}</td>
<td><span class="status-badge {{ (gsi.IndexStatus ?? 'ACTIVE')|lower }}">{{ gsi.IndexStatus ?? 'ACTIVE' }}</span></td>
<td><span class="type-badge">{{ gsiPk }}</span></td>
<td>{% if gsiSk %}<span class="type-badge">{{ gsiSk }}</span>{% else %}<span class="null-val">none</span>{% endif %}</td>
<td>{{ gsi.Projection.ProjectionType ?? '—' }}</td>
<td>{{ (gsi.ItemCount ?? 0)|number_format }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% if desc.LocalSecondaryIndexes is defined and desc.LocalSecondaryIndexes|length > 0 %}
<div class="col-12">
<div class="content-card">
<div class="content-card-header">
<h6><i class="bi bi-diagram-2 me-1" style="color:var(--jormun-teal);"></i> Local Secondary Indexes</h6>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr><th>Index Name</th><th>Sort Key</th><th>Projection</th></tr>
</thead>
<tbody>
{% for lsi in desc.LocalSecondaryIndexes %}
{% set lsiSk = null %}
{% for k in lsi.KeySchema %}{% if k.KeyType == 'RANGE' %}{% set lsiSk = k.AttributeName %}{% endif %}{% endfor %}
<tr>
<td class="pk-cell">{{ lsi.IndexName }}</td>
<td><span class="type-badge">{{ lsiSk ?? '—' }}</span></td>
<td>{{ lsi.Projection.ProjectionType ?? '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}