first commit
This commit is contained in:
12
templates/_sidebar_tables.html.twig
Normal file
12
templates/_sidebar_tables.html.twig
Normal 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 %}
|
||||
175
templates/auth/login.html.twig
Normal file
175
templates/auth/login.html.twig
Normal 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 — JormunDB Administration Interface
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
628
templates/base.html.twig
Normal file
628
templates/base.html.twig
Normal 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=" 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>
|
||||
112
templates/dashboard/index.html.twig
Normal file
112
templates/dashboard/index.html.twig
Normal 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 %}
|
||||
240
templates/table/browse.html.twig
Normal file
240
templates/table/browse.html.twig
Normal 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 %}
|
||||
104
templates/table/create.html.twig
Normal file
104
templates/table/create.html.twig
Normal 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. 3–255 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 %}
|
||||
145
templates/table/item_form.html.twig
Normal file
145
templates/table/item_form.html.twig
Normal 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 %}
|
||||
· 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 %}
|
||||
208
templates/table/query.html.twig
Normal file
208
templates/table/query.html.twig
Normal 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="<"><</option>
|
||||
<option value="<="><=</option>
|
||||
<option value=">">></option>
|
||||
<option value=">=">>=</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 %}
|
||||
191
templates/table/structure.html.twig
Normal file
191
templates/table/structure.html.twig
Normal 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 %}
|
||||
Reference in New Issue
Block a user