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:
cedricandClaude Opus 5.5 committed 2026-09-28 16:26:40 +02:00
1 parent 84e71f741c
commit 328384032b
10 files changed
+316 -23

No files matched your search

+32 -12
View File
@@ -17,8 +17,9 @@ import (
// Store sends messages to VictoriaLogs in batches and queries it with LogsQL.
type Store struct {
base string
client *http.Client
base string
client *http.Client
streamClient *http.Client
in chan *Entry
batchSize int
flushEvery time.Duration
@@ -33,6 +34,9 @@ func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration) *
s := &Store{
base: strings.TrimRight(base, "/"),
client: &http.Client{Timeout: 60 * time.Second},
// No global timeout: a large export can take longer than a minute. The
// request context still cancels it when the browser goes away.
streamClient: &http.Client{},
in: make(chan *Entry, queueSize),
batchSize: batchSize,
flushEvery: flushEvery,
@@ -138,23 +142,37 @@ func (s *Store) post(u string, body []byte) error {
// Query runs a LogsQL query and returns one row per result.
func (s *Store) Query(ctx context.Context, query string) ([]map[string]any, error) {
form := url.Values{"query": {query}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/select/logsql/query", strings.NewReader(form.Encode()))
rows := []map[string]any{}
err := s.QueryStream(ctx, query, func(row map[string]any) error {
rows = append(rows, row)
return nil
})
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
return rows, nil
}
// QueryStream runs a LogsQL query and calls fn for each result as it arrives,
// without keeping the results in memory. fn is only called once VictoriaLogs
// has accepted the query; an error returned by fn stops the stream.
func (s *Store) QueryStream(ctx context.Context, query string, fn func(row map[string]any) error) error {
form := url.Values{"query": {query}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/select/logsql/query", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.streamClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("%s", strings.TrimSpace(string(msg)))
return fmt.Errorf("%s", strings.TrimSpace(string(msg)))
}
rows := []map[string]any{}
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 64*1024), 16<<20)
for sc.Scan() {
@@ -164,11 +182,13 @@ func (s *Store) Query(ctx context.Context, query string) ([]map[string]any, erro
}
var row map[string]any
if err := json.Unmarshal(line, &row); err != nil {
return nil, err
return err
}
if err := fn(row); err != nil {
return err
}
rows = append(rows, row)
}
return rows, sc.Err()
return sc.Err()
}
// Purge starts a VictoriaLogs task that deletes every stored log, and resets