Files
unkin-agent abc81e60c8
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
Add logviewer: web UI for the ClickHouse log store
Single Go binary with embedded Bootstrap 3 + jQuery UI, querying logs.raw
over the ClickHouse HTTP interface as the readonly logreader user. Runs
behind oauth2-proxy; the app does no auth itself. Server-side enforced
time bounds (15m default, 72h max), parameterized queries, raw-SQL WHERE
fragment wrapped with enforced bounds and LIMIT, tail polling with a
clamped cursor, facets, healthz. Woodpecker build/test plus tag-driven
image push to artifactapi docker-internal.
2026-08-23 16:39:22 +10:00

92 lines
2.1 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// maxExecutionSeconds bounds every ClickHouse query server-side.
const maxExecutionSeconds = 30
type chClient struct {
url string
user string
password string
http *http.Client
}
func newCHClient(chURL, user, password string) *chClient {
return &chClient{
url: strings.TrimRight(chURL, "/"),
user: user,
password: password,
http: &http.Client{Timeout: (maxExecutionSeconds + 5) * time.Second},
}
}
type chColumn struct {
Name string `json:"name"`
Type string `json:"type"`
}
type chResult struct {
Meta []chColumn `json:"meta"`
Data []map[string]any `json:"data"`
Rows int `json:"rows"`
}
// query POSTs sql to the ClickHouse HTTP interface. Every value in params is
// sent as a bound query parameter (param_<name>), never interpolated.
func (c *chClient) query(ctx context.Context, sql string, params map[string]string) (*chResult, error) {
q := url.Values{}
q.Set("max_execution_time", strconv.Itoa(maxExecutionSeconds))
q.Set("output_format_json_quote_64bit_integers", "0")
for k, v := range params {
q.Set("param_"+k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/?"+q.Encode(), strings.NewReader(sql))
if err != nil {
return nil, err
}
req.Header.Set("X-ClickHouse-User", c.user)
req.Header.Set("X-ClickHouse-Key", c.password)
req.Header.Set("Content-Type", "text/plain")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body))
if len(msg) > 500 {
msg = msg[:500]
}
return nil, fmt.Errorf("clickhouse: %s: %s", resp.Status, msg)
}
var res chResult
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("clickhouse: decode response: %w", err)
}
return &res, nil
}
func (c *chClient) ping(ctx context.Context) error {
_, err := c.query(ctx, "SELECT 1 FORMAT JSON", nil)
return err
}