Add CSV export of the logs matching the current filters
- GET /api/export.csv streams every stored log matching the filters (newest first, up to EXPORT_MAX rows, 100000 by default) straight from VictoriaLogs, with dates in the time zone chosen in Settings. - Export button with two variants: CSV (comma, UTF-8) and CSV for Excel (semicolon + BOM, formula injection neutralized). - Store.QueryStream streams query results without buffering them. - Mobile: filter bar keeps two selects per row, count next to Export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84e71f741c
commit
328384032b
10 files changed
+316
-23
No files matched your search
+74
-8
@@ -66,6 +66,11 @@ const I18N = {
|
||||
msgTimeTitle: 'Timestamp from the message',
|
||||
fHostName: 'host name (DNS)', fHostIp: 'host IP',
|
||||
clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app',
|
||||
export: 'Export', exportCsvHint: 'Comma separated, UTF-8',
|
||||
exportExcel: 'CSV for Excel', exportExcelHint: 'Semicolon separated, for Excel in French',
|
||||
exportNote: (n) => `Up to ${n} logs matching the current filters, newest first. Dates use the time zone chosen in Settings.`,
|
||||
exporting: 'Exporting…', exportDone: (f) => `Downloaded: ${f}`, exportError: 'Export failed: ',
|
||||
err_export_pipes: 'Export does not support LogsQL pipes (| …): keep only the filter part',
|
||||
tabLocale: 'Localization', tabFilters: 'Filters', tabInterface: 'Interface', tabData: 'Data',
|
||||
themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
|
||||
themeHelp: 'System follows the light/dark preference of your computer or phone.',
|
||||
@@ -149,6 +154,11 @@ const I18N = {
|
||||
msgTimeTitle: 'Horodatage contenu dans le message',
|
||||
fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte',
|
||||
clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli',
|
||||
export: 'Exporter', exportCsvHint: 'Séparateur virgule, UTF-8',
|
||||
exportExcel: 'CSV pour Excel', exportExcelHint: 'Séparateur point-virgule, pour Excel en français',
|
||||
exportNote: (n) => `Jusqu'à ${n} logs correspondant aux filtres, du plus récent au plus ancien. Dates dans le fuseau choisi dans Paramètres.`,
|
||||
exporting: 'Export…', exportDone: (f) => `Téléchargé : ${f}`, exportError: 'Échec de l\'export : ',
|
||||
err_export_pipes: 'L\'export ne gère pas les pipes LogsQL (| …) : gardez seulement la partie filtre',
|
||||
tabLocale: 'Localisation', tabFilters: 'Filtres', tabInterface: 'Interface', tabData: 'Données',
|
||||
themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre',
|
||||
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.',
|
||||
@@ -283,17 +293,19 @@ async function api(url, opts = {}) {
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
|
||||
if (!res.ok) {
|
||||
// Known error codes are translated; otherwise show the server message.
|
||||
let msg = (data && data.error) || text || res.statusText;
|
||||
if (data && data.code && I18N[lang]['err_' + data.code]) {
|
||||
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (!res.ok) throw apiError(res, text, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Known error codes are translated; otherwise the server message is shown.
|
||||
function apiError(res, text, data) {
|
||||
let msg = (data && data.error) || text || res.statusText;
|
||||
if (data && data.code && I18N[lang]['err_' + data.code]) {
|
||||
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
|
||||
}
|
||||
return new Error(msg);
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const el = $('#toast');
|
||||
// A modal dialog sits above everything: move the toast into it so it stays visible.
|
||||
@@ -935,6 +947,7 @@ function renderStats() {
|
||||
|
||||
async function loadStats() {
|
||||
try { state.stats = await api('/api/stats'); } catch { state.stats = null; }
|
||||
if (state.stats && state.stats.exportMax) exportMax = state.stats.exportMax;
|
||||
renderStats();
|
||||
}
|
||||
|
||||
@@ -982,6 +995,59 @@ $('#tzSelect').addEventListener('change', onTimePrefsChange);
|
||||
$('#fmtSelect').addEventListener('change', onTimePrefsChange);
|
||||
setInterval(() => { if ($('#settingsDlg').open) updateTimePreview(); }, 1000);
|
||||
|
||||
/* ================= CSV export ================= */
|
||||
|
||||
let exportMax = 100000; // EXPORT_MAX on the server, refreshed from /api/stats
|
||||
|
||||
function setExportMenu(open) {
|
||||
$('#exportMenu').hidden = !open;
|
||||
$('#exportBtn').setAttribute('aria-expanded', String(open));
|
||||
if (open) $('#exportNote').textContent = t('exportNote', fmtNum(exportMax));
|
||||
}
|
||||
|
||||
async function exportCSV(excel) {
|
||||
const p = params();
|
||||
p.set('tz', timePrefs.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
if (excel) p.set('excel', '1');
|
||||
const btn = $('#exportBtn');
|
||||
const lbl = btn.querySelector('.lbl');
|
||||
btn.disabled = true;
|
||||
lbl.textContent = t('exporting');
|
||||
try {
|
||||
const res = await fetch('/api/export.csv?' + p);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch { /* not JSON */ }
|
||||
throw apiError(res, text, data);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const name = ((res.headers.get('Content-Disposition') || '').match(/filename="([^"]+)"/) || [])[1] || 'logstream.csv';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement('a'), { href: url, download: name });
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
toast(t('exportDone', name));
|
||||
} catch (e) {
|
||||
toast(t('exportError') + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
lbl.textContent = t('export');
|
||||
}
|
||||
}
|
||||
|
||||
$('#exportBtn').addEventListener('click', () => setExportMenu($('#exportMenu').hidden));
|
||||
$('#exportMenu').addEventListener('click', (ev) => {
|
||||
const b = ev.target.closest('[data-export]');
|
||||
if (!b) return;
|
||||
setExportMenu(false);
|
||||
exportCSV(b.dataset.export === 'excel');
|
||||
});
|
||||
document.addEventListener('click', (ev) => { if (!ev.target.closest('.export')) setExportMenu(false); });
|
||||
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExportMenu(false); });
|
||||
|
||||
/* ================= Purge ================= */
|
||||
|
||||
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
|
||||
|
||||
@@ -68,6 +68,21 @@
|
||||
<select id="app" data-i18n-aria="appAria"><option value="" data-i18n="allApps">All apps</option></select>
|
||||
<span class="spacer"></span>
|
||||
<span id="count" class="muted"></span>
|
||||
<div class="export">
|
||||
<button id="exportBtn" class="btn tool" type="button" aria-haspopup="menu" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
|
||||
<span class="lbl" data-i18n="export">Export</span>
|
||||
</button>
|
||||
<div id="exportMenu" class="menu" role="menu" hidden>
|
||||
<button type="button" role="menuitem" data-export="csv">
|
||||
<strong>CSV</strong><small data-i18n="exportCsvHint">Comma separated, UTF-8</small>
|
||||
</button>
|
||||
<button type="button" role="menuitem" data-export="excel">
|
||||
<strong data-i18n="exportExcel">CSV for Excel</strong><small data-i18n="exportExcelHint">Semicolon separated, for Excel in French</small>
|
||||
</button>
|
||||
<p id="exportNote" class="menu-note"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="histo" class="histo" data-i18n-aria="histoAria"></section>
|
||||
|
||||
+21
-2
@@ -199,6 +199,25 @@ body.busy .progress::after {
|
||||
.spacer { flex: 1; }
|
||||
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Export button and menu */
|
||||
.export { position: relative; }
|
||||
.btn.tool { height: 32px; padding: 0 10px; font-size: 13px; }
|
||||
.btn.tool svg { width: 16px; height: 16px; }
|
||||
.menu {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 40;
|
||||
width: 290px; padding: 6px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.menu button {
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%;
|
||||
padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; text-align: left;
|
||||
}
|
||||
.menu button:hover, .menu button:focus-visible { background: var(--panel-2); outline: 0; }
|
||||
.menu strong { font-size: 13px; font-weight: 600; }
|
||||
.menu small { font-size: 12px; color: var(--muted); }
|
||||
.menu-note { margin: 4px 0 0; padding: 8px 10px 4px; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--muted); }
|
||||
|
||||
/* ---------- Histogram ---------- */
|
||||
.histo { padding: 0 20px 6px; }
|
||||
.histo .bars {
|
||||
@@ -441,9 +460,9 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
|
||||
.search { order: 3; flex-basis: 100%; max-width: none; }
|
||||
.live .lbl { display: none; }
|
||||
.filters { padding: 10px 16px 6px; }
|
||||
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; }
|
||||
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; }
|
||||
.spacer { display: none; }
|
||||
#count { flex-basis: 100%; }
|
||||
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.histo { padding: 0 16px 6px; }
|
||||
.list, .error-banner { margin-left: 16px; margin-right: 16px; }
|
||||
.row {
|
||||
|
||||
Reference in new issue
Block a user