mirror of
https://github.com/amir20/dozzle.git
synced 2026-06-23 04:10:12 +00:00
fix: Improve SQL analytics panel UX (#4749)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+2
@@ -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']
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 알림을 받을 위치와 시간을 설정합니다
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: Настройте где и когда получать оповещения
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 設定接收警報的位置和時間
|
||||
|
||||
@@ -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: 配置接收警报的位置和时间
|
||||
|
||||
Reference in New Issue
Block a user