fix: Improve SQL analytics panel UX (#4749)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-05-26 09:54:23 -07:00
committed by GitHub
parent 9a99e9305f
commit a51c7c1d86
19 changed files with 297 additions and 50 deletions
+2
View File
@@ -162,10 +162,12 @@ declare module 'vue' {
PageWithLinks: typeof import('./components/PageWithLinks.vue')['default']
'Ph:arrowsMerge': typeof import('~icons/ph/arrows-merge')['default']
'Ph:boundingBoxFill': typeof import('~icons/ph/bounding-box-fill')['default']
'Ph:caretRight': typeof import('~icons/ph/caret-right')['default']
'Ph:circlesFour': typeof import('~icons/ph/circles-four')['default']
'Ph:command': typeof import('~icons/ph/command')['default']
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
'Ph:database': typeof import('~icons/ph/database')['default']
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
+142 -33
View File
@@ -1,37 +1,92 @@
<template>
<aside>
<header class="flex items-center gap-4">
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
<h2 class="text-sm"><RelativeTime :date="container.created" /></h2>
<aside class="flex flex-col gap-5 pb-8">
<header class="flex items-center gap-3 pr-8">
<ph:file-sql class="text-primary size-7 shrink-0" />
<div class="flex min-w-0 flex-col">
<h1 class="text-xl leading-tight font-semibold">{{ $t("analytics.title") }}</h1>
<p class="text-base-content/60 flex items-center gap-1.5 text-sm">
<span class="truncate">{{ container.name }}</span>
<span class="opacity-40">·</span>
<RelativeTime :date="container.created" />
</p>
</div>
</header>
<div class="mt-8 flex flex-col gap-2">
<section>
<label class="form-control">
<textarea
v-model="query"
class="textarea textarea-primary w-full font-mono text-lg"
:class="{ 'textarea-error!': error }"
:disabled="state === 'downloading'"
></textarea>
<div class="mt-2">
<span class="text-error" v-if="error">{{ error }}</span>
<span v-else-if="state === 'initializing'">{{ $t("analytics.creating_table") }}</span>
<span v-else-if="state === 'downloading'">{{
$t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) })
}}</span>
<span v-else-if="evaluating">{{ $t("analytics.evaluating_query") }}</span>
<span v-else>
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
<template v-if="results.numRows > pageLimit">{{
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
}}</template>
</span>
</div>
</label>
</section>
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
</div>
<section class="flex flex-col gap-2">
<textarea
ref="queryEl"
v-model="query"
class="textarea textarea-primary w-full resize-y font-mono text-sm leading-relaxed"
:class="{ 'textarea-error!': error }"
:disabled="state !== 'ready'"
rows="3"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
:aria-label="$t('analytics.title')"
@keydown.meta.enter.prevent="run"
@keydown.ctrl.enter.prevent="run"
></textarea>
<div class="flex min-h-6 items-center text-sm">
<div class="min-w-0 flex-1 truncate">
<span class="text-error" v-if="error">{{ error }}</span>
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'initializing'">
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.creating_table") }}
</span>
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'downloading'">
<span class="loading loading-spinner loading-xs"></span
>{{ $t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) }) }}
</span>
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="evaluating">
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.evaluating_query") }}
</span>
<span class="text-base-content/60" v-else>
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
<template v-if="results.numRows > pageLimit">{{
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
}}</template>
</span>
</div>
</div>
</section>
<section v-if="state === 'ready' && columns.length" class="flex flex-col gap-2 text-xs">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-base-content/50 font-medium">{{ $t("analytics.examples") }}</span>
<button
v-for="ex in examples"
:key="ex.key"
class="badge badge-sm badge-outline hover:border-primary hover:text-primary cursor-pointer"
@click="applyExample(ex.sql)"
>
{{ $t(ex.key, ex.params ?? {}) }}
</button>
</div>
<details class="group">
<summary
class="text-base-content/50 hover:text-base-content/80 flex w-fit cursor-pointer items-center gap-1 font-medium select-none"
>
<ph:caret-right class="size-3 transition-transform group-open:rotate-90" />
{{ $t("analytics.columns") }}
<span class="opacity-60">{{ columns.length }}</span>
</summary>
<div class="mt-2 flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
<button
v-for="col in columns"
:key="col.name"
class="badge badge-sm badge-ghost hover:border-primary hover:text-primary cursor-pointer font-mono"
:title="col.type"
@click="insertColumn(col.name)"
>
{{ col.name }}
</button>
</div>
</details>
</section>
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
</aside>
</template>
@@ -42,11 +97,15 @@ import { type Table } from "@apache-arrow/esnext-esm";
const { container } = defineProps<{ container: Container }>();
const query = ref("SELECT * FROM logs LIMIT 100");
const error = ref<string | null>(null);
const debouncedQuery = debouncedRef(query, 500);
const evaluating = ref(false);
const pageLimit = 1000;
const state = ref<"downloading" | "ready" | "initializing">("downloading");
const bytes = ref(0);
const columns = ref<{ name: string; type: string }[]>([]);
const queryEl = useTemplateRef<HTMLTextAreaElement>("queryEl");
const runQuery = ref(query.value);
watchDebounced(query, (v) => (runQuery.value = v), { debounce: 500 });
const url = withBase(
`/api/hosts/${container.host}/containers/${container.id}/logs?stdout=1&stderr=1&everything&jsonOnly`,
@@ -93,6 +152,12 @@ onMounted(async () => {
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited', map_inference_threshold = -1)`,
);
const described = await conn.query<{ column_name: any; column_type: any }>(`DESCRIBE logs`);
columns.value = described.toArray().map((row) => ({
name: String(row.column_name),
type: String(row.column_type),
}));
state.value = "ready";
} catch (e) {
console.error(e);
@@ -102,10 +167,54 @@ onMounted(async () => {
}
});
const examples = computed(() => {
const names = columns.value.map((c) => c.name);
const pick = ["level", "severity", "lvl", "status"].find((c) => names.includes(c)) ?? names[0];
const list: { key: string; sql: string; params?: Record<string, string> }[] = [
{ key: "analytics.example_all", sql: "SELECT * FROM logs LIMIT 100" },
{ key: "analytics.example_count", sql: "SELECT count(*) AS total FROM logs" },
];
if (pick) {
list.push({
key: "analytics.example_group",
params: { column: pick },
sql: `SELECT "${pick}", count(*) AS count FROM logs GROUP BY "${pick}" ORDER BY count DESC`,
});
}
return list;
});
function run() {
if (state.value !== "ready") return;
runQuery.value = query.value;
}
function applyExample(sql: string) {
query.value = sql;
nextTick(run);
}
function insertColumn(name: string) {
const text = `"${name}"`;
const el = queryEl.value;
if (!el) {
query.value += text;
return;
}
const start = el.selectionStart ?? query.value.length;
const end = el.selectionEnd ?? start;
query.value = query.value.slice(0, start) + text + query.value.slice(end);
nextTick(() => {
el.focus();
const pos = start + text.length;
el.setSelectionRange(pos, pos);
});
}
const results = computedAsync(
async () => {
if (state.value === "ready") {
return await conn.query<Record<string, any>>(debouncedQuery.value);
return await conn.query<Record<string, any>>(runQuery.value);
} else {
return empty;
}
+41 -17
View File
@@ -1,28 +1,39 @@
<template>
<table class="table-zebra table-pin-rows table-md table" v-if="!loading">
<div class="w-full overflow-x-auto" v-if="!loading">
<table class="table-zebra table-pin-rows table-md table" v-if="columns.length">
<thead>
<tr>
<th v-for="column in columns" :key="column" class="font-mono">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in table" :key="index">
<td v-for="column in columns" :key="column" class="max-w-md align-top">
<span v-if="format(row[column]) === null" class="text-base-content/30 italic">NULL</span>
<span v-else class="block truncate font-mono" :title="format(row[column]) ?? undefined">{{
format(row[column])
}}</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="text-base-content/50 flex flex-col items-center gap-2 py-16">
<ph:database class="size-8 opacity-40" />
<span>{{ $t("analytics.no_results") }}</span>
</div>
</div>
<table class="table-md table" v-else>
<thead>
<tr>
<th v-for="column in columns" :key="column">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table" :key="row">
<td v-for="column in columns" :key="column">{{ row[column] }}</td>
</tr>
</tbody>
</table>
<table class="table-md table animate-pulse" v-else>
<thead>
<tr>
<th v-for="_ in 3">
<th v-for="i in 3" :key="i">
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-50"></div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="_ in 9">
<td v-for="_ in 3">
<div class="bg-base-content/50 h-4 w-20 opacity-20"></div>
<tr v-for="i in 9" :key="i">
<td v-for="j in 3" :key="j">
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-20"></div>
</td>
</tr>
</tbody>
@@ -37,4 +48,17 @@ const { loading, table } = defineProps<{
}>();
const columns = computed(() => (table.numRows > 0 ? Object.keys(table.get(0) as Record<string, any>) : []));
function format(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "bigint") return value.toString();
if (typeof value === "object") {
try {
return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
} catch {
return String(value);
}
}
return String(value);
}
</script>
+7
View File
@@ -193,6 +193,13 @@ analytics:
evaluating_query: Evaluerer forespørgsel...
total_records: I alt {count} poster.
showing_first: Viser de første {count}.
title: SQL-analyse
no_results: Ingen rækker matcher din forespørgsel.
columns: Kolonner
examples: Eksempler
example_all: Alle logfiler
example_count: Antal rækker
example_group: Antal efter {column}
notifications:
title: Notifikationer
description: Konfigurer hvor og hvornår du vil modtage alarmer
+7
View File
@@ -193,6 +193,13 @@ analytics:
evaluating_query: Abfrage wird ausgewertet...
total_records: Insgesamt {count} Datensätze.
showing_first: Zeige die ersten {count}.
title: SQL-Analyse
no_results: Keine Zeilen entsprechen der Abfrage.
columns: Spalten
examples: Beispiele
example_all: Alle Logs
example_count: Anzahl Zeilen
example_group: Anzahl nach {column}
notifications:
title: Benachrichtigungen
description: Konfigurieren Sie, wo und wann Sie Alarme erhalten möchten
+7
View File
@@ -228,11 +228,18 @@ toasts:
title: Copied
message: Copied to clipboard
analytics:
title: SQL Analytics
creating_table: Creating temporary table...
downloading: Fetching container logs... ({size})
evaluating_query: Evaluating query...
total_records: Total {count} records.
showing_first: Showing first {count}.
no_results: No rows match your query.
columns: Columns
examples: Examples
example_all: All logs
example_count: Row count
example_group: Count by {column}
notifications:
title: Notifications
description: Configure where and when to receive alerts
+7
View File
@@ -221,6 +221,13 @@ analytics:
evaluating_query: Evaluando consulta...
total_records: Total {count} registros.
showing_first: Mostrando los primeros {count}.
title: Análisis SQL
no_results: Ninguna fila coincide con la consulta.
columns: Columnas
examples: Ejemplos
example_all: Todos los registros
example_count: Número de filas
example_group: Conteo por {column}
notifications:
title: Notificaciones
description: Configure dónde y cuándo recibir alertas
+7
View File
@@ -193,6 +193,13 @@ analytics:
evaluating_query: Évaluation de la requête...
total_records: Total de {count} enregistrements.
showing_first: Affichage des {count} premiers.
title: Analyse SQL
no_results: Aucune ligne ne correspond à la requête.
columns: Colonnes
examples: Exemples
example_all: Tous les journaux
example_count: Nombre de lignes
example_group: Nombre par {column}
notifications:
title: Notifications
description: Configurez où et quand recevoir des alertes
+7
View File
@@ -205,6 +205,13 @@ analytics:
evaluating_query: Mengevaluasi kueri...
total_records: Total {count} entri.
showing_first: Menampilkan {count} pertama.
title: Analitik SQL
no_results: Tidak ada baris yang cocok dengan kueri.
columns: Kolom
examples: Contoh
example_all: Semua log
example_count: Jumlah baris
example_group: Hitung berdasarkan {column}
notifications:
title: Notifikasi
description: Konfigurasikan di mana dan kapan menerima peringatan
+7
View File
@@ -193,6 +193,13 @@ analytics:
evaluating_query: Valutazione della query...
total_records: Totale {count} record.
showing_first: Mostrati i primi {count}.
title: Analisi SQL
no_results: Nessuna riga corrisponde alla query.
columns: Colonne
examples: Esempi
example_all: Tutti i log
example_count: Numero di righe
example_group: Conteggio per {column}
notifications:
title: Notifiche
description: Configura dove e quando ricevere avvisi
+7
View File
@@ -196,6 +196,13 @@ analytics:
evaluating_query: 쿼리를 실행하고 있습니다...
total_records: 총 {count}개 기록
showing_first: 처음 {count}개 표시
title: SQL 분석
no_results: 쿼리와 일치하는 행이 없습니다.
columns:
examples: 예시
example_all: 모든 로그
example_count: 행 수
example_group: "{column}별 개수"
notifications:
title: 알림
description: 알림을 받을 위치와 시간을 설정합니다
+7
View File
@@ -194,6 +194,13 @@ analytics:
evaluating_query: Query wordt geëvalueerd...
total_records: In totaal {count} resultaten.
showing_first: Eerste {count} worden weergegeven.
title: SQL-analyse
no_results: Geen rijen komen overeen met de query.
columns: Kolommen
examples: Voorbeelden
example_all: Alle logs
example_count: Aantal rijen
example_group: Aantal per {column}
notifications:
title: Meldingen
description: Configureer waar en wanneer je waarschuwingen ontvangt
+7
View File
@@ -200,6 +200,13 @@ analytics:
evaluating_query: Przetwarzanie zapytania...
total_records: Razem {count} rekordów.
showing_first: Pokazywanie pierwszych {count}.
title: Analiza SQL
no_results: Żaden wiersz nie pasuje do zapytania.
columns: Kolumny
examples: Przykłady
example_all: Wszystkie logi
example_count: Liczba wierszy
example_group: Liczba według {column}
notifications:
title: Powiadomienia
description: Skonfiguruj gdzie i kiedy otrzymywać alerty
+7
View File
@@ -192,6 +192,13 @@ analytics:
evaluating_query: Avaliando consulta...
total_records: Total de {count} registros.
showing_first: Mostrando os primeiros {count}.
title: Análise SQL
no_results: Nenhuma linha corresponde à consulta.
columns: Colunas
examples: Exemplos
example_all: Todos os logs
example_count: Número de linhas
example_group: Contagem por {column}
notifications:
title: Notificações
description: Configure onde e quando receber alertas
+7
View File
@@ -193,6 +193,13 @@ analytics:
evaluating_query: Выполнение запроса...
total_records: Всего {count} записей.
showing_first: Показаны первые {count}.
title: SQL-аналитика
no_results: Нет строк, соответствующих запросу.
columns: Столбцы
examples: Примеры
example_all: Все логи
example_count: Количество строк
example_group: Количество по {column}
notifications:
title: Уведомления
description: Настройте где и когда получать оповещения
+7
View File
@@ -198,6 +198,13 @@ analytics:
total_records: Skupaj zapisov {count}.
showing_first: Prikaz prvih {count}.
creating_table: Ustvarjanje začasne tabele...
title: Analiza SQL
no_results: Nobena vrstica se ne ujema s poizvedbo.
columns: Stolpci
examples: Primeri
example_all: Vsi dnevniki
example_count: Število vrstic
example_group: Štetje po {column}
notifications:
title: Obvestila
description: Nastavite kje in kdaj prejemati opozorila
+7
View File
@@ -193,6 +193,13 @@ analytics:
evaluating_query: Sorgu değerlendiriliyor...
total_records: Toplam {count} kayıt.
showing_first: İlk {count} gösteriliyor.
title: SQL Analizi
no_results: Sorgunuzla eşleşen satır yok.
columns: Sütunlar
examples: Örnekler
example_all: Tüm günlükler
example_count: Satır sayısı
example_group: "{column} bazında sayı"
notifications:
title: Bildirimler
description: Uyarıları nerede ve ne zaman alacağınızı yapılandırın
+7
View File
@@ -196,6 +196,13 @@ analytics:
evaluating_query: 正在評估查詢...
total_records: 共計 {count} 筆記錄。
showing_first: 顯示前 {count} 筆。
title: SQL 分析
no_results: 沒有符合查詢的資料列。
columns: 欄位
examples: 範例
example_all: 所有日誌
example_count: 資料列數
example_group: 依 {column} 計數
notifications:
title: 通知
description: 設定接收警報的位置和時間
+7
View File
@@ -193,6 +193,13 @@ analytics:
evaluating_query: 正在评估查询...
total_records: 总共 {count} 条记录。
showing_first: 显示前 {count} 条。
title: SQL 分析
no_results: 没有符合查询的行。
columns:
examples: 示例
example_all: 所有日志
example_count: 行数
example_group: 按 {column} 计数
notifications:
title: 通知
description: 配置接收警报的位置和时间