feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- Add admin SQL query tool with saved queries, schema explorer presets, and collapsible category sections (dev_tools module) - Add platform debug tool for admin diagnostics - Add loyalty settings page with owner-only access control - Fix loyalty settings owner check (use currentUser instead of window.__userData) - Replace HTTPException with AuthorizationException in loyalty routes - Expand loyalty module with PIN service, Apple Wallet, program management - Improve store login with platform detection and multi-platform support - Update billing feature gates and subscription services - Add store platform sync improvements and remove is_primary column - Add unit tests for loyalty (PIN, points, stamps, program services) - Update i18n translations across dev_tools locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
{# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Platform Resolution Debug{% endblock %}
|
||||
|
||||
{% block alpine_data %}platformDebug(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Simulates the middleware pipeline for each URL pattern to trace how platform & store context are resolved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<button @click="runAllTests()" :disabled="running"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
<span x-show="!running">Run All Tests</span>
|
||||
<span x-show="running">Running...</span>
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button>
|
||||
<button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button>
|
||||
<button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button>
|
||||
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button>
|
||||
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom test -->
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3>
|
||||
<div class="flex gap-3 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
|
||||
<input x-model="customHost" type="text" placeholder="localhost:8000"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
|
||||
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
|
||||
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
|
||||
</div>
|
||||
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
|
||||
Trace
|
||||
</button>
|
||||
</div>
|
||||
<!-- Custom result -->
|
||||
<template x-if="customResult">
|
||||
<div class="mt-4">
|
||||
<div x-html="renderTrace(customResult)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Test Results -->
|
||||
<div class="space-y-4">
|
||||
<template x-for="test in filteredTests" :key="test.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between"
|
||||
@click="test.expanded = !test.expanded">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Status indicator -->
|
||||
<div class="w-3 h-3 rounded-full"
|
||||
:class="{
|
||||
'bg-gray-300 dark:bg-gray-600': !test.result,
|
||||
'bg-green-500': test.result && test.pass,
|
||||
'bg-red-500': test.result && !test.pass,
|
||||
'animate-pulse bg-yellow-400': test.running
|
||||
}"></div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': test.group === 'dev',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain',
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain',
|
||||
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom'
|
||||
}" x-text="test.groupLabel"></span>
|
||||
<div>
|
||||
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<template x-if="test.result">
|
||||
<span class="text-xs font-mono px-2 py-1 rounded"
|
||||
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
|
||||
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
|
||||
</template>
|
||||
<template x-if="test.result">
|
||||
<button @click.stop="copyTrace(test)" title="Copy full trace"
|
||||
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
Copy
|
||||
</button>
|
||||
</template>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Detail -->
|
||||
<div x-show="test.expanded" x-cloak class="px-4 py-3">
|
||||
<template x-if="test.result">
|
||||
<div x-html="renderTrace(test.result)"></div>
|
||||
</template>
|
||||
<template x-if="!test.result && !test.running">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
|
||||
</template>
|
||||
<template x-if="test.running">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function platformDebug() {
|
||||
return {
|
||||
running: false,
|
||||
filterGroup: 'all',
|
||||
customHost: 'localhost:8000',
|
||||
customPath: '/platforms/loyalty/store/WIZATECH/login',
|
||||
customPlatformCode: '',
|
||||
customResult: null,
|
||||
tests: [
|
||||
// ── Dev path-based ──
|
||||
{
|
||||
id: 'dev-1', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/oms/store/ACME/login',
|
||||
description: 'Store login, OMS platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/oms/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-2', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/loyalty/store/ACME/login',
|
||||
description: 'Store login, Loyalty platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/loyalty/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-3', group: 'dev', groupLabel: 'Dev',
|
||||
label: 'API: /api/v1/store/auth/login (no body platform)',
|
||||
description: 'What middleware sees for the login API call — no platform_code in body',
|
||||
host: 'localhost:8000',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: null,
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-4', group: 'dev', groupLabel: 'Dev',
|
||||
label: 'API: /api/v1/store/auth/login + body platform_code=loyalty',
|
||||
description: 'Login API with loyalty in body (Source 2)',
|
||||
host: 'localhost:8000',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: 'loyalty',
|
||||
store_code_body: 'WIZATECH',
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-5', group: 'dev', groupLabel: 'Dev',
|
||||
label: 'API: /api/v1/store/auth/login + body platform_code=oms',
|
||||
description: 'Login API with oms in body (Source 2)',
|
||||
host: 'localhost:8000',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: 'oms',
|
||||
store_code_body: 'WIZATECH',
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-6', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/store/ACME/login (no /platforms/ prefix)',
|
||||
description: 'Store login without platform prefix on localhost',
|
||||
host: 'localhost:8000',
|
||||
path: '/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: null,
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-7', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/oms/storefront/ACME/account/login',
|
||||
description: 'Customer login, OMS platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/oms/storefront/ACME/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-8', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/loyalty/storefront/ACME/account/login',
|
||||
description: 'Customer login, Loyalty platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/loyalty/storefront/ACME/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
|
||||
// ── Prod domain-based (path-based demo/trial) ──
|
||||
{
|
||||
id: 'prod-d-1', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'omsflow.lu /store/ACME/login',
|
||||
description: 'OMS platform domain, store login',
|
||||
host: 'omsflow.lu',
|
||||
path: '/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-d-2', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'rewardflow.lu /store/ACME/login',
|
||||
description: 'Loyalty platform domain, store login',
|
||||
host: 'rewardflow.lu',
|
||||
path: '/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-d-3', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'omsflow.lu /api/v1/store/auth/login (API)',
|
||||
description: 'API call on OMS domain — middleware should detect platform',
|
||||
host: 'omsflow.lu',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-d-4', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'omsflow.lu /storefront/ACME/account/login',
|
||||
description: 'Customer login on OMS domain',
|
||||
host: 'omsflow.lu',
|
||||
path: '/storefront/ACME/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
|
||||
// ── Prod subdomain ──
|
||||
{
|
||||
id: 'prod-s-1', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme.omsflow.lu /store/login',
|
||||
description: 'OMS subdomain, store login',
|
||||
host: 'acme.omsflow.lu',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-s-2', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme.omsflow.lu /account/login',
|
||||
description: 'OMS subdomain, customer login',
|
||||
host: 'acme.omsflow.lu',
|
||||
path: '/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-s-3', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme-rewards.rewardflow.lu /store/login',
|
||||
description: 'Loyalty subdomain (custom_subdomain), store login',
|
||||
host: 'acme-rewards.rewardflow.lu',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-s-4', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme.omsflow.lu /api/v1/store/auth/login (API)',
|
||||
description: 'API call on OMS subdomain',
|
||||
host: 'acme.omsflow.lu',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
|
||||
// ── Prod custom domain ──
|
||||
// Uses real StoreDomain records:
|
||||
// wizatech.shop → store_id=1 (WIZATECH), platform_id=1 (oms)
|
||||
// fashionhub.store → store_id=4 (FASHIONHUB), platform_id=3 (loyalty)
|
||||
{
|
||||
id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'wizatech.shop /store/login',
|
||||
description: 'Custom domain → WIZATECH, OMS platform (StoreDomain.platform_id=1)',
|
||||
host: 'wizatech.shop',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-c-2', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'fashionhub.store /store/login',
|
||||
description: 'Custom domain → FASHIONHUB, Loyalty platform (StoreDomain.platform_id=3)',
|
||||
host: 'fashionhub.store',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-c-3', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'wizatech.shop /api/v1/store/auth/login (API)',
|
||||
description: 'API call on custom domain — middleware should detect platform',
|
||||
host: 'wizatech.shop',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-c-4', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'fashionhub.store /api/v1/store/auth/login (API)',
|
||||
description: 'API call on custom domain — middleware should detect platform',
|
||||
host: 'fashionhub.store',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
],
|
||||
|
||||
get filteredTests() {
|
||||
if (this.filterGroup === 'all') return this.tests;
|
||||
return this.tests.filter(t => t.group === this.filterGroup);
|
||||
},
|
||||
|
||||
async runAllTests() {
|
||||
this.running = true;
|
||||
for (const test of this.tests) {
|
||||
test.running = true;
|
||||
try {
|
||||
test.result = await this.traceRequest(test.host, test.path, test.platform_code_body, test.store_code_body);
|
||||
test.pass = test.expect_platform === null
|
||||
? true // No expectation
|
||||
: test.result.login_platform_code === test.expect_platform;
|
||||
} catch (e) {
|
||||
test.result = { error: e.message };
|
||||
test.pass = false;
|
||||
}
|
||||
test.running = false;
|
||||
}
|
||||
this.running = false;
|
||||
},
|
||||
|
||||
async runCustomTest() {
|
||||
try {
|
||||
this.customResult = await this.traceRequest(
|
||||
this.customHost, this.customPath, this.customPlatformCode || null
|
||||
);
|
||||
} catch (e) {
|
||||
this.customResult = { error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
async traceRequest(host, path, platformCodeBody, storeCodeBody) {
|
||||
let url = `/api/v1/admin/debug/platform-trace?host=${encodeURIComponent(host)}&path=${encodeURIComponent(path)}`;
|
||||
if (platformCodeBody) url += `&platform_code_body=${encodeURIComponent(platformCodeBody)}`;
|
||||
if (storeCodeBody) url += `&store_code_body=${encodeURIComponent(storeCodeBody)}`;
|
||||
const resp = await apiClient.get(url.replace('/api/v1', ''));
|
||||
return resp;
|
||||
},
|
||||
|
||||
copyTrace(test) {
|
||||
const r = test.result;
|
||||
if (!r || r.error) return;
|
||||
let text = `${test.label}\n${test.description}\n`;
|
||||
text += `Host: ${r.input_host} Path: ${r.input_path}`;
|
||||
if (r.input_platform_code_body) text += ` Body platform_code: ${r.input_platform_code_body}`;
|
||||
text += '\n\n';
|
||||
for (const step of r.steps) {
|
||||
text += `${step.step}\n`;
|
||||
if (step.note) text += `${step.note}\n`;
|
||||
if (step.result) text += JSON.stringify(step.result, null, 2) + '\n';
|
||||
text += '\n';
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
copyStepText(step) {
|
||||
let text = `${step.step}\n`;
|
||||
if (step.note) text += `${step.note}\n`;
|
||||
if (step.result) text += JSON.stringify(step.result, null, 2);
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
renderTrace(result) {
|
||||
if (result.error) {
|
||||
return `<div class="text-red-600 dark:text-red-400 text-sm font-mono">${result.error}</div>`;
|
||||
}
|
||||
|
||||
let html = `<div class="text-xs font-mono space-y-1">`;
|
||||
html += `<div class="mb-2 text-gray-500 dark:text-gray-400">`;
|
||||
html += `Host: <span class="text-gray-900 dark:text-white">${result.input_host}</span> `;
|
||||
html += `Path: <span class="text-gray-900 dark:text-white">${result.input_path}</span>`;
|
||||
if (result.input_platform_code_body) {
|
||||
html += ` Body platform_code: <span class="text-gray-900 dark:text-white">${result.input_platform_code_body}</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
for (let i = 0; i < result.steps.length; i++) {
|
||||
const step = result.steps[i];
|
||||
const isLast = step.step.startsWith('8.');
|
||||
const bgClass = isLast
|
||||
? 'bg-indigo-50 dark:bg-indigo-900/30 border-indigo-200 dark:border-indigo-700'
|
||||
: 'bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700';
|
||||
|
||||
html += `<div class="p-2 rounded border ${bgClass}">`;
|
||||
html += `<div class="flex items-start justify-between">`;
|
||||
html += `<div class="font-semibold text-gray-700 dark:text-gray-300">${step.step}</div>`;
|
||||
const stepData = btoa(unescape(encodeURIComponent(
|
||||
step.step + '\n' + (step.note || '') + '\n' + (step.result ? JSON.stringify(step.result, null, 2) : '')
|
||||
)));
|
||||
html += `<button onclick="navigator.clipboard.writeText(decodeURIComponent(escape(atob(this.dataset.text))))" data-text="${stepData}" class="ml-2 shrink-0 px-1.5 py-0.5 text-[10px] bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Copy</button>`;
|
||||
html += `</div>`;
|
||||
if (step.note) {
|
||||
html += `<div class="text-gray-500 dark:text-gray-400 mt-0.5">${step.note}</div>`;
|
||||
}
|
||||
if (step.result) {
|
||||
const jsonStr = JSON.stringify(step.result, null, 2);
|
||||
html += `<pre class="mt-1 text-green-700 dark:text-green-400 whitespace-pre-wrap">${jsonStr}</pre>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal file
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{# app/modules/dev_tools/templates/dev_tools/admin/sql-query.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/modals.html' import modal %}
|
||||
|
||||
{% block title %}SQL Query Tool{% endblock %}
|
||||
|
||||
{% block alpine_data %}sqlQueryTool(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('SQL Query Tool', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
|
||||
|
||||
<div class="flex gap-6">
|
||||
<!-- Left sidebar -->
|
||||
<div class="w-72 flex-shrink-0 space-y-4">
|
||||
<!-- Schema Explorer (preset queries) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<button @click="showPresets = !showPresets"
|
||||
class="flex items-center justify-between w-full text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span x-html="$icon('database', 'w-4 h-4')"></span>
|
||||
Schema Explorer
|
||||
</span>
|
||||
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<div x-show="showPresets" x-collapse class="mt-3">
|
||||
<template x-for="group in presetQueries" :key="group.category">
|
||||
<div class="mb-1">
|
||||
<button @click="toggleCategory(group.category)"
|
||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<span x-text="group.category"></span>
|
||||
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '−' : '+'"></span>
|
||||
</button>
|
||||
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
|
||||
<template x-for="preset in group.items" :key="preset.name">
|
||||
<li @click="loadPreset(preset)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
|
||||
<span class="truncate" x-text="preset.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Queries -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<span x-html="$icon('collection', 'w-4 h-4')"></span>
|
||||
Saved Queries
|
||||
</h3>
|
||||
<div x-show="loadingSaved" class="text-sm text-gray-500">Loading...</div>
|
||||
<div x-show="!loadingSaved && savedQueries.length === 0" class="text-sm text-gray-400">
|
||||
No saved queries yet.
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="q in savedQueries" :key="q.id">
|
||||
<li class="group flex items-center justify-between rounded-md px-2 py-1.5 text-sm cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
:class="activeSavedId === q.id ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'"
|
||||
@click="loadQuery(q)">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate font-medium" x-text="q.name"></div>
|
||||
<div class="text-xs text-gray-400" x-show="q.run_count > 0">
|
||||
Run <span x-text="q.run_count"></span> time<span x-show="q.run_count !== 1">s</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click.stop="deleteSavedQuery(q.id)"
|
||||
class="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500 transition-opacity"
|
||||
title="Delete">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- SQL Editor -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-4">
|
||||
<div class="p-4">
|
||||
<textarea
|
||||
x-model="sql"
|
||||
rows="8"
|
||||
class="w-full bg-gray-900 text-green-400 font-mono text-sm rounded-lg p-4 border border-gray-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 resize-y"
|
||||
placeholder="Enter your SQL query here... (SELECT only)"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Action bar -->
|
||||
<div class="flex items-center gap-3 px-4 pb-4">
|
||||
<button @click="executeQuery()"
|
||||
:disabled="running || !sql.trim()"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="!running" x-html="$icon('play', 'w-4 h-4 mr-1.5')"></span>
|
||||
<span x-show="running" x-html="$icon('spinner', 'w-4 h-4 mr-1.5')"></span>
|
||||
<span x-text="running ? 'Running...' : 'Run Query'"></span>
|
||||
<span class="ml-1.5 text-xs opacity-70">(Ctrl+Enter)</span>
|
||||
</button>
|
||||
|
||||
<button @click="openSaveModal()"
|
||||
:disabled="!sql.trim()"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-html="$icon('save', 'w-4 h-4 mr-1.5')"></span>
|
||||
Save Query
|
||||
</button>
|
||||
|
||||
<button @click="exportCsv()"
|
||||
x-show="rows.length > 0"
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
|
||||
<span x-html="$icon('download', 'w-4 h-4 mr-1.5')"></span>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execution info -->
|
||||
<div x-show="executionTimeMs !== null" class="mb-4 text-sm text-gray-600 dark:text-gray-400 flex items-center gap-4">
|
||||
<span>
|
||||
<span x-text="rowCount"></span> row<span x-show="rowCount !== 1">s</span> returned
|
||||
</span>
|
||||
<span x-show="truncated" class="text-amber-600 dark:text-amber-400 font-medium">
|
||||
(results truncated to 1000 rows)
|
||||
</span>
|
||||
<span>
|
||||
<span x-text="executionTimeMs"></span> ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div x-show="error" class="mb-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation-circle', 'w-5 h-5 text-red-500 mr-2 flex-shrink-0')"></span>
|
||||
<pre class="text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words" x-text="error"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results table -->
|
||||
<div x-show="columns.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<template x-for="col in columns" :key="col">
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap"
|
||||
x-text="col"></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<template x-for="(row, rowIdx) in rows" :key="rowIdx">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750">
|
||||
<template x-for="(cell, cellIdx) in row" :key="cellIdx">
|
||||
<td class="px-4 py-2 text-sm font-mono whitespace-nowrap max-w-xs truncate"
|
||||
:class="isNull(cell) ? 'text-gray-400 italic' : 'text-gray-900 dark:text-gray-100'"
|
||||
:title="formatCell(cell)"
|
||||
x-text="formatCell(cell)"></td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Query Modal -->
|
||||
{% call modal('saveQueryModal', 'Save Query', show_var='showSaveModal', size='sm', show_footer=false) %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input type="text" x-model="saveName"
|
||||
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g. Active users count"
|
||||
@keydown.enter="saveQuery()">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description (optional)</label>
|
||||
<input type="text" x-model="saveDescription"
|
||||
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Brief description of what this query does">
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button @click="showSaveModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveQuery()"
|
||||
:disabled="!saveName.trim() || saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||
<span x-text="saving ? 'Saving...' : 'Save'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('dev_tools_static', path='admin/js/sql-query.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user