This commit is contained in:
2026-03-11 21:55:13 -04:00
parent 2bc6071b4e
commit bacbe5d35a
4 changed files with 193 additions and 224 deletions

33
.env
View File

@@ -1,33 +0,0 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
# DynamoDB / JormunDB connection
DYNAMO_REGION=us-east-1
DYNAMO_ENDPOINT=http://45.76.2.182:8002
DYNAMO_KEY=AKIAIOSFODNN7EXAMPLE
DYNAMO_SECRET=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

View File

@@ -15,10 +15,10 @@ class DynamoService
$this->client = new DynamoDbClient([
'region' => $_ENV['DYNAMO_REGION'] ?? 'us-east-1',
'version' => 'latest',
'endpoint' => $_ENV['DYNAMO_ENDPOINT'] ?? 'http://127.0.0.1:8002',
'endpoint' => $_ENV['DYNAMO_ENDPOINT'] ?? 'https://jormun-write-through-dev.vultrlabs.dev',
'credentials' => [
'key' => $_ENV['DYNAMO_KEY'] ?? 'AKIAIOSFODNN7EXAMPLE',
'secret' => $_ENV['DYNAMO_SECRET'] ?? 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
'key' => $_ENV['DYNAMO_KEY'],
'secret' => $_ENV['DYNAMO_SECRET'],
],
]);

View File

@@ -4,211 +4,213 @@
{% 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>
<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) }}
{{ 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>
<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 }}
<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>
<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>
<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;">
{% set limit = limit is defined ? limit : 25 %}
{% 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>
{% 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|e('html_attr') : 'null' }}">
{% if item[col] is defined %}
{% set val = item[col] %}
{% if val is iterable %}
<span style="color:#6366f1;" title="{{ val|json_encode|e('html_attr') }}">
<i class="bi bi-braces"></i> {{ val|json_encode|slice(0, 40) }}{% if val|json_encode|length > 40 %}{% endif %}
</span>
{% else %}
{% set valStr = val is iterable ? val|json_encode : val|e %}
{{ valStr|slice(0, 60) }}{% if valStr|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 %}
{# 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 %}
{% if nextKey %}
{% set newPrevKeys = prevKeys %}
{% if currentKey %}
{% set newPrevKeys = newPrevKeys|merge([currentKey]) %}
{% else %}
{# first page, push empty marker #}
{% set newPrevKeys = newPrevKeys|merge(['']) %}
{% 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>
{# 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 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 %}
@@ -216,25 +218,25 @@
{% block scripts %}
<script>
function browseTable() {
return {
deleteModal: false,
deleteKey: null,
deleteAction: '',
return {
deleteModal: false,
deleteKey: null,
deleteAction: '',
confirmDelete(keyJson) {
this.deleteKey = JSON.parse(keyJson);
this.deleteAction = '{{ path('table_item_delete', {name: tableName}) }}';
this.deleteModal = true;
}
};
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();
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

@@ -112,7 +112,7 @@
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('itemForm', () => ({
jsonText: JSON.stringify({{ item ? item|json_encode|raw : '{}' }}, null, 2),
jsonText: JSON.stringify(JSON.parse({{ (item is not empty) ? item|json_encode : '{}' }}), null, 2),
jsonError: '',
validateJson() {
try {