webs unified

This commit is contained in:
Priec
2026-08-03 13:32:25 +02:00
parent 35d85de556
commit c8afe99d79
59 changed files with 1991 additions and 952 deletions

View File

@@ -0,0 +1,68 @@
{# GET / — crate::pages::analytics::ui::AnalyticsPage #}
{% extends "ui/base.html" %}
{% block title %}Analytics{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
{% endblock %}
{% block content %}
<main x-data="{ sql: '', copied: false }">
<section class="heading">
<div>
<p class="eyebrow">Reporting</p>
<h1>Analytics</h1>
<p>Query the read-only analytics API and chart the result.</p>
</div>
</section>
<div class="analytics">
<aside class="panel sidebar">
<h2>Available data</h2>
<p class="hint">Load a profile to see the table and column aliases accepted by analytics SQL.</p>
<div hx-get="/api/profiles" hx-trigger="load" hx-target="#profile" hx-swap="innerHTML">
<form class="catalog-load" hx-post="/api/catalog" hx-target="#catalog" hx-swap="innerHTML" hx-disabled-elt="button">
<label>Profile
<select id="profile" name="profile_name" hx-post="/api/catalog" hx-trigger="change"
hx-target="#catalog" hx-swap="innerHTML">
<option value="">Loading profiles…</option>
</select>
</label>
<button type="submit">Load schema</button>
</form>
</div>
<div id="catalog" aria-live="polite"><p class="hint">No schema loaded.</p></div>
</aside>
<section>
<form id="query-form" class="panel query-form" hx-post="/api/query" hx-include="#profile"
hx-target="#output" hx-swap="innerHTML" hx-disabled-elt="button">
<div class="query-row">
<label>Chart
<select name="chart_type">
<option value="bar">Bar</option>
<option value="line">Line</option>
<option value="pie">Pie</option>
<option value="scatter">Scatter</option>
<option value="table">Table</option>
</select>
</label>
<label>Max rows<input name="max_rows" type="number" min="1" value="1000"></label>
</div>
<label>SQL
<textarea id="sql" x-ref="sql" x-model="sql" name="sql" required spellcheck="false"
placeholder="Load the schema, then choose a starter query or write a SELECT query"></textarea>
</label>
<div class="actions">
<button type="submit">Run query</button>
<span class="htmx-indicator hint">Running…</span>
</div>
</form>
<section id="output" aria-live="polite"></section>
</section>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{# POST /api/catalog — crate::pages::analytics::ui::CatalogFragment #}
{% if tables.is_empty() %}
<p class="hint">This profile has no analytics tables.</p>
{% else %}
<p class="schema-summary"><strong>{{ tables.len() }}</strong> tables available</p>
<div class="catalog-tables">
{% for table in tables %}
<details class="catalog-table">
<summary>
<code>{{ table.name }}</code>
{%- if !table.base_currency.is_empty() %}<span class="currency">{{ table.base_currency }}</span>{% endif -%}
</summary>
<button type="button" class="starter-query" data-sql="{{ table.starter_query }}"
x-on:click="sql = $el.dataset.sql; $refs.sql.focus()">Use starter query</button>
<ul class="columns">
{% for column in table.columns %}
<li>
<button type="button" class="column-name" data-insert="{{ column.insert_text }}"
x-on:click="$refs.sql.setRangeText($el.dataset.insert, $refs.sql.selectionStart, $refs.sql.selectionEnd, 'end'); sql = $refs.sql.value; $refs.sql.focus()">{{ column.name }}</button>
<span>{{ column.details }}</span>
</li>
{% endfor %}
</ul>
{% if !table.links.is_empty() %}
<div class="links">
<span>Links</span>
<ul>
{% for link in table.links %}
<li><code>{{ link.source_column }}</code><code>{{ link.linked_table }}</code>{% if link.required %} (required){% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</details>
{% endfor %}
</div>
<details class="llm-context">
<summary>LLM schema context</summary>
<p class="hint">Copy this entire text and include it when asking an LLM to write a query.</p>
<textarea x-ref="llmSchema" readonly>{{ llm_context }}</textarea>
<button type="button" class="secondary"
x-on:click="navigator.clipboard.writeText($refs.llmSchema.value); copied = true">Copy context</button>
<span class="hint" x-show="copied" x-cloak>Copied</span>
</details>
{% endif %}

View File

@@ -0,0 +1,12 @@
{#
GET /api/profiles — crate::pages::analytics::ui::ProfileOptions
The swap target is a <select>, so even the failure case is an <option>.
#}
{%- if let Some(message) = error %}
<option value="">Could not load profiles: {{ message }}</option>
{%- else %}
<option value="">{% if profiles.is_empty() %}No profiles available{% else %}Choose a profile…{% endif %}</option>
{%- for profile in profiles %}
<option value="{{ profile.name }}">{{ profile.name }} ({{ profile.table_count }} tables)</option>
{%- endfor %}
{%- endif %}

View File

@@ -0,0 +1,22 @@
{#
POST /api/query — crate::pages::analytics::ui::QueryResult
Either a scrollable table or an ECharts container, depending on the chart
type the form asked for. `chart_option` is JSON built in Rust.
#}
<p class="result-meta">{{ row_count }} rows in {{ elapsed_ms }} ms{% if truncated %} (truncated){% endif %}</p>
{% match chart_option %}
{% when Some(option) %}
<div class="chart" data-option="{{ option }}"
x-init="echarts.init($el).setOption(JSON.parse($el.dataset.option))"></div>
{% when None %}
<div class="table-wrap">
<table>
<thead><tr>{% for column in columns %}<th>{{ column.name }}</th>{% endfor %}</tr></thead>
<tbody>
{%- for row in rows %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{%- endfor %}
</tbody>
</table>
</div>
{% endmatch %}