From 0530fc1781a460ea957e8fb618eff77f1cd6a256 Mon Sep 17 00:00:00 2001 From: Caio Ricciuti <63287675+caioricciuti@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:59:46 +0200 Subject: [PATCH] fix: six small fixes (audit ch_user, history delete 404, encoded ClickHouse user names, login connector label, /settings alias, telemetry missing tables) --- internal/audit/audit.go | 15 +++++++++------ internal/database/query_history.go | 12 ++++++++++-- internal/database/query_history_test.go | 18 ++++++++++-------- internal/server/handlers/admin.go | 15 ++++++++------- .../server/handlers/governance_auditlog.go | 3 ++- internal/server/handlers/query_history.go | 6 ++++++ .../server/handlers/sso_per_person_test.go | 6 +++--- internal/server/server.go | 16 +++++++++++----- site/src/content/docs/docs/audit-log.md | 6 +++--- site/src/content/docs/docs/cant-login.md | 4 ++-- site/src/content/docs/docs/monitoring.md | 4 ++++ .../governance/AuditLogSection.svelte | 5 +++++ .../components/telemetry/LogsSection.svelte | 10 ++++++++++ .../telemetry/MetricsSection.svelte | 10 ++++++++++ .../components/telemetry/TracesSection.svelte | 10 ++++++++++ ui/src/lib/routes.ts | 8 ++++++++ ui/src/lib/stores/router.svelte.ts | 12 ++++++++++-- ui/src/lib/types/api.ts | 2 ++ ui/src/lib/utils/ch-error.test.ts | 19 ++++++++++++++++++- ui/src/lib/utils/ch-error.ts | 12 ++++++++++++ ui/src/pages/Login.svelte | 5 +++-- 21 files changed, 156 insertions(+), 42 deletions(-) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index f4fe4395..d6d2ce5b 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -22,12 +22,14 @@ import ( // Event is a single audit record forwarded to sinks. type Event struct { - Action string `json:"action"` - Username string `json:"username,omitempty"` - ConnectionID string `json:"connection_id,omitempty"` - Details string `json:"details,omitempty"` - IPAddress string `json:"ip_address,omitempty"` - Timestamp string `json:"timestamp"` + Action string `json:"action"` + Username string `json:"username,omitempty"` + // ClickhouseUser is the ClickHouse account the action ran as, when known. + ClickhouseUser string `json:"ch_user,omitempty"` + ConnectionID string `json:"connection_id,omitempty"` + Details string `json:"details,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + Timestamp string `json:"timestamp"` } // Sink delivers an event to one destination. Implementations must be safe for @@ -133,6 +135,7 @@ func (StdoutSink) Emit(e Event) error { slog.Info("audit", "action", e.Action, "username", e.Username, + "ch_user", e.ClickhouseUser, "connection_id", e.ConnectionID, "ip_address", e.IPAddress, "details", e.Details, diff --git a/internal/database/query_history.go b/internal/database/query_history.go index 5644980d..95a5929f 100644 --- a/internal/database/query_history.go +++ b/internal/database/query_history.go @@ -172,15 +172,23 @@ func (db *DB) GetQueryHistory(actor, connectionID, search, status string, limit, } // DeleteQueryHistoryEntry deletes one entry if it belongs to the actor on the -// given connection (same scoping as List and Clear). +// given connection (same scoping as List and Clear). It returns sql.ErrNoRows +// when no such entry exists for that actor, so callers can answer 404. func (db *DB) DeleteQueryHistoryEntry(id, actor, connectionID string) error { - _, err := db.conn.Exec( + res, err := db.conn.Exec( `DELETE FROM query_history WHERE id = ? AND actor = ? AND COALESCE(connection_id, '') = COALESCE(?, '')`, id, actor, nilIfEmpty(connectionID), ) if err != nil { return fmt.Errorf("delete query history entry: %w", err) } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("delete query history entry: %w", err) + } + if n == 0 { + return sql.ErrNoRows + } return nil } diff --git a/internal/database/query_history_test.go b/internal/database/query_history_test.go index cf75448a..85e69a07 100644 --- a/internal/database/query_history_test.go +++ b/internal/database/query_history_test.go @@ -1,6 +1,8 @@ package database import ( + "database/sql" + "errors" "fmt" "testing" ) @@ -201,17 +203,17 @@ func TestQueryHistory_DeleteAndClear(t *testing.T) { insertHistoryAt(t, db, "h2", "alice", "conn-1", "SELECT 2", "success", "datetime('now', '-2 minutes')") insertHistoryAt(t, db, "h3", "bob", "conn-1", "SELECT 3", "success", "datetime('now', '-3 minutes')") - // Deleting someone else's entry is a silent no-op. - if err := db.DeleteQueryHistoryEntry("h3", "alice", "conn-1"); err != nil { - t.Fatalf("DeleteQueryHistoryEntry foreign: %v", err) + // Deleting someone else's entry deletes nothing and reports not found. + if err := db.DeleteQueryHistoryEntry("h3", "alice", "conn-1"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("DeleteQueryHistoryEntry foreign: want sql.ErrNoRows, got %v", err) } if entries, _ := db.GetQueryHistory("bob", "conn-1", "", "", 50, 0); len(entries) != 1 { t.Fatalf("bob's entry should survive alice's delete") } - // Deleting own entry from a different connection is also a no-op. - if err := db.DeleteQueryHistoryEntry("h1", "alice", "conn-2"); err != nil { - t.Fatalf("DeleteQueryHistoryEntry wrong conn: %v", err) + // Deleting own entry from a different connection is also not found. + if err := db.DeleteQueryHistoryEntry("h1", "alice", "conn-2"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("DeleteQueryHistoryEntry wrong conn: want sql.ErrNoRows, got %v", err) } if entries, _ := db.GetQueryHistory("alice", "conn-1", "", "", 50, 0); len(entries) != 2 { t.Fatalf("delete must be connection-scoped") @@ -278,8 +280,8 @@ func TestQueryHistory_SSOPeopleSharingAccountAreIsolated(t *testing.T) { // Bob cannot delete alice's row by id, nor the shared pre-migration row. for _, id := range []string{"a1", "shared", "p1"} { - if err := db.DeleteQueryHistoryEntry(id, ssoBob, "conn-1"); err != nil { - t.Fatalf("DeleteQueryHistoryEntry(%s): %v", id, err) + if err := db.DeleteQueryHistoryEntry(id, ssoBob, "conn-1"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("DeleteQueryHistoryEntry(%s): want sql.ErrNoRows, got %v", id, err) } } if got := historyIDs(t, db, ssoAlice, "conn-1"); len(got) != 2 { diff --git a/internal/server/handlers/admin.go b/internal/server/handlers/admin.go index eec3f3c4..52b1167e 100644 --- a/internal/server/handlers/admin.go +++ b/internal/server/handlers/admin.go @@ -234,9 +234,10 @@ func (h *AdminHandler) GetUserRoles(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, roles) } -// roleUsernameParam returns the decoded {username} path parameter. Role keys -// can hold ':' and '@' ("sso:", or a ClickHouse user named like an -// email), which the UI percent-encodes; chi returns the raw escaped segment. +// roleUsernameParam returns the decoded {username} path parameter, for role +// and ClickHouse user routes alike. Names can hold ':' and '@' ("sso:", +// or a ClickHouse user named like an email), which the UI percent-encodes; +// chi returns the raw escaped segment. func roleUsernameParam(r *http.Request) (string, bool) { username, err := url.PathUnescape(chi.URLParam(r, "username")) if err != nil || strings.TrimSpace(username) == "" { @@ -645,8 +646,8 @@ func (h *AdminHandler) UpdateClickHouseUserPassword(w http.ResponseWriter, r *ht return } - username := strings.TrimSpace(chi.URLParam(r, "username")) - if username == "" { + username, ok := roleUsernameParam(r) + if !ok { writeError(w, http.StatusBadRequest, "username is required") return } @@ -772,8 +773,8 @@ func (h *AdminHandler) DeleteClickHouseUser(w http.ResponseWriter, r *http.Reque return } - username := strings.TrimSpace(chi.URLParam(r, "username")) - if username == "" { + username, ok := roleUsernameParam(r) + if !ok { writeError(w, http.StatusBadRequest, "username is required") return } diff --git a/internal/server/handlers/governance_auditlog.go b/internal/server/handlers/governance_auditlog.go index 8391eb4f..04c551fd 100644 --- a/internal/server/handlers/governance_auditlog.go +++ b/internal/server/handlers/governance_auditlog.go @@ -104,12 +104,13 @@ func (h *GovernanceHandler) GetAuditLogsExport(w http.ResponseWriter, r *http.Re w.Header().Set("Content-Disposition", `attachment; filename="ch-ui-audit-logs.csv"`) cw := csv.NewWriter(w) defer cw.Flush() - _ = cw.Write([]string{"created_at", "action", "username", "connection_id", "ip_address", "details"}) + _ = cw.Write([]string{"created_at", "action", "username", "ch_user", "connection_id", "ip_address", "details"}) for _, l := range logs { _ = cw.Write([]string{ l.CreatedAt, l.Action, derefStr(l.Username), + derefStr(l.ChUser), derefStr(l.ConnectionID), derefStr(l.IPAddress), derefStr(l.Details), diff --git a/internal/server/handlers/query_history.go b/internal/server/handlers/query_history.go index 0b0c01c3..171aacdd 100644 --- a/internal/server/handlers/query_history.go +++ b/internal/server/handlers/query_history.go @@ -1,6 +1,8 @@ package handlers import ( + "database/sql" + "errors" "log/slog" "net/http" "strconv" @@ -74,6 +76,10 @@ func (h *QueryHistoryHandler) DeleteEntry(w http.ResponseWriter, r *http.Request } if err := h.DB.DeleteQueryHistoryEntry(id, middleware.Actor(session), session.ConnectionID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "History entry not found") + return + } slog.Error("Failed to delete query history entry", "error", err, "id", id) writeError(w, http.StatusInternalServerError, "Failed to delete history entry") return diff --git a/internal/server/handlers/sso_per_person_test.go b/internal/server/handlers/sso_per_person_test.go index 715a2d22..ec30c04e 100644 --- a/internal/server/handlers/sso_per_person_test.go +++ b/internal/server/handlers/sso_per_person_test.go @@ -105,9 +105,9 @@ func TestQueryHistorySSOPeopleIsolated(t *testing.T) { if err := db.Conn().QueryRow(`SELECT id FROM query_history WHERE actor = 'alice@example.com'`).Scan(&aliceID); err != nil { t.Fatal(err) } - // Bob deletes alice's row by id and clears his own: alice, the password - // user and the shared row all survive. - if res := ssoRequest(r, p.bob, "DELETE", "/history/"+aliceID, ""); res.Code != 200 { + // Bob deleting alice's row by id gets 404 and deletes nothing; clearing + // his own leaves alice, the password user and the shared row in place. + if res := ssoRequest(r, p.bob, "DELETE", "/history/"+aliceID, ""); res.Code != 404 { t.Fatalf("delete: %d %s", res.Code, res.Body) } if res := ssoRequest(r, p.bob, "DELETE", "/history/", ""); res.Code != 200 { diff --git a/internal/server/server.go b/internal/server/server.go index 322e8a85..982201fd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -104,12 +104,18 @@ func New(cfg *config.Config, db *database.DB, frontendFS fs.FS, agents *embedded if !auditGate.Allow() { return } + // ch_user only when it differs from the actor, as stored. + chUser := deref(p.ClickhouseUser) + if chUser == deref(p.Username) { + chUser = "" + } auditFwd.Emit(audit.Event{ - Action: p.Action, - Username: deref(p.Username), - ConnectionID: deref(p.ConnectionID), - Details: deref(p.Details), - IPAddress: deref(p.IPAddress), + Action: p.Action, + Username: deref(p.Username), + ClickhouseUser: chUser, + ConnectionID: deref(p.ConnectionID), + Details: deref(p.Details), + IPAddress: deref(p.IPAddress), }) } } diff --git a/site/src/content/docs/docs/audit-log.md b/site/src/content/docs/docs/audit-log.md index 332456bd..e7cb6d5a 100644 --- a/site/src/content/docs/docs/audit-log.md +++ b/site/src/content/docs/docs/audit-log.md @@ -69,9 +69,9 @@ an optional **target** (e.g. the affected user or connection), structured For people who sign in with [SSO](/docs/sso) (v2.13.2+), the actor is the person's email, and the ClickHouse account the action ran as (the shared service account) is kept in a separate `ch_user` field. `ch_user` is only set -when it differs from the actor. It is returned by `GET -/api/governance/audit-logs` and the JSON export; the CSV export and SIEM -forwarding carry the actor only. Rows written before v2.13.2 keep the service +when it differs from the actor. It shows as the **ClickHouse user** column on +the page and is carried by `GET /api/governance/audit-logs`, the JSON and CSV +exports, and SIEM forwarding. Rows written before v2.13.2 keep the service account as the actor. ## Using it diff --git a/site/src/content/docs/docs/cant-login.md b/site/src/content/docs/docs/cant-login.md index 9a6c9ed4..78018b57 100644 --- a/site/src/content/docs/docs/cant-login.md +++ b/site/src/content/docs/docs/cant-login.md @@ -10,8 +10,8 @@ Use this guide if the login screen appears but sign-in fails, local URL is wrong | What you see | Likely cause | What to do | |---|---|---| | `Authentication failed` / `Invalid credentials` | ClickHouse rejected the username or password | Retry with the correct username/password for the selected connection. Each rejected attempt counts toward the lockout | -| `Connection unavailable` with `Connection offline`, or `Connection "" is offline` | The connector for this connection is not connected (the second form appears when the login page already knows the connection is offline) | Start the connector/agent for the connection, then retry. If the local URL is wrong, use **Open setup guide**, update setup and restart CH-UI | -| `Connection unavailable` with `Connection to ClickHouse failed` | ClickHouse did not answer the credential check (timeout, connection refused, network or TLS error, tunnel dropped) | Check that ClickHouse is up and reachable from the connector, start the connector if it is stopped, then retry. These attempts do not count toward the lockout | +| `Connector offline` under the connection picker, `Connection unavailable` with `Connection offline`, or `Connection "" is offline` | The connector for this connection is not connected (the second form appears when the login page already knows the connection is offline) | Start the connector/agent for the connection, then retry. If the local URL is wrong, use **Open setup guide**, update setup and restart CH-UI | +| `Connection unavailable` with `Connection to ClickHouse failed`, while the picker says `Connector online` | ClickHouse did not answer the credential check (timeout, connection refused, network or TLS error, tunnel dropped). `Connector online` only means the connector is connected; ClickHouse is checked when you sign in | Check that ClickHouse is up and reachable from the connector, start the connector if it is stopped, then retry. These attempts do not count toward the lockout | | `Login temporarily blocked` / `Too many login attempts` | Temporary lockout after repeated failed logins (3 per user or 5 per IP within 15 minutes) | Wait for the retry window shown (`3m`, then `5m`, then capped at `10m`); if URL was wrong, fix setup and restart before retry | | `No connections configured` | Embedded local connection not reachable/initialized | Start CH-UI with explicit local URL and connection name | diff --git a/site/src/content/docs/docs/monitoring.md b/site/src/content/docs/docs/monitoring.md index 5f816d24..9adb29f5 100644 --- a/site/src/content/docs/docs/monitoring.md +++ b/site/src/content/docs/docs/monitoring.md @@ -87,6 +87,10 @@ Each forwarded event is JSON: } ``` +Events that ran as a different ClickHouse account than `username` (people +signed in with SSO share a service account) also carry `ch_user`, the +ClickHouse account used. + - **stdout**: pick this up with any log pipeline (Fluent Bit, Vector, Loki, CloudWatch, Datadog agent). - **file**: tail the JSONL file with a log shipper. diff --git a/ui/src/lib/components/governance/AuditLogSection.svelte b/ui/src/lib/components/governance/AuditLogSection.svelte index 38812f39..1b76ba46 100644 --- a/ui/src/lib/components/governance/AuditLogSection.svelte +++ b/ui/src/lib/components/governance/AuditLogSection.svelte @@ -62,6 +62,7 @@ { key: 'created_at', label: 'Time', mono: true, width: '160px', format: (v) => formatDate(v) }, { key: 'action', label: 'Action', width: '220px' }, { key: 'username', label: 'User', format: (v) => (v ? String(v) : '—') }, + { key: 'ch_user', label: 'ClickHouse user', mono: true, format: (v) => (v ? String(v) : '—') }, { key: 'details', label: 'Details', mono: true, truncate: true, width: '45%', sortable: false, format: (v) => (v ? String(v) : '—') }, { key: 'ip_address', label: 'IP', mono: true, format: (v) => (v ? String(v) : '—') }, ] @@ -178,6 +179,10 @@
User
{selected.username || '—'}
+
+
ClickHouse user
+
{selected.ch_user || '—'}
+
IP
{selected.ip_address || '—'}
diff --git a/ui/src/lib/components/telemetry/LogsSection.svelte b/ui/src/lib/components/telemetry/LogsSection.svelte index 76021de9..32b6665a 100644 --- a/ui/src/lib/components/telemetry/LogsSection.svelte +++ b/ui/src/lib/components/telemetry/LogsSection.svelte @@ -14,6 +14,7 @@ import LogDetailPanel from './LogDetailPanel.svelte' import SavedSearchMenu from './SavedSearchMenu.svelte' import { setSection } from '../../stores/nav.svelte' + import { isMissingTableError } from '../../utils/ch-error' import { searchLogs, logsHistogram, logsFacets } from '../../api/telemetry' import type { TelemetrySource, LogRow, LogFacets, HistogramBucket } from '../../types/telemetry' import { encodeAbsoluteDashboardRange } from '../../utils/dashboard-time' @@ -378,6 +379,14 @@
+ {#if error && isMissingTableError(error)} + setSection('sources') }} + /> + {:else} {#if error}
{error}
{/if} @@ -443,4 +452,5 @@ /> {/if} + {/if} {/if} diff --git a/ui/src/lib/components/telemetry/MetricsSection.svelte b/ui/src/lib/components/telemetry/MetricsSection.svelte index 51081e15..83ef5397 100644 --- a/ui/src/lib/components/telemetry/MetricsSection.svelte +++ b/ui/src/lib/components/telemetry/MetricsSection.svelte @@ -9,6 +9,7 @@ import TimeRangeSelector from '../dashboard/TimeRangeSelector.svelte' import MetricQueryCard, { defaultAggregation, type MetricCardSpec } from './MetricQueryCard.svelte' import { setSection } from '../../stores/nav.svelte' + import { isMissingTableError } from '../../utils/ch-error' import { metricsCatalog } from '../../api/telemetryPro' import type { TelemetrySource } from '../../types/telemetry' import type { MetricCatalogEntry, MetricType, MetricAggregation } from '../../types/telemetryPro' @@ -183,6 +184,14 @@ + {#if error && isMissingTableError(error)} + setSection('sources') }} + /> + {:else} {#if error}
{error}
{/if} @@ -215,4 +224,5 @@ {/if} + {/if} {/if} diff --git a/ui/src/lib/components/telemetry/TracesSection.svelte b/ui/src/lib/components/telemetry/TracesSection.svelte index 280acdda..6ed611f2 100644 --- a/ui/src/lib/components/telemetry/TracesSection.svelte +++ b/ui/src/lib/components/telemetry/TracesSection.svelte @@ -15,6 +15,7 @@ import TraceFacets from './TraceFacets.svelte' import TraceView from './TraceView.svelte' import { setSection } from '../../stores/nav.svelte' + import { isMissingTableError } from '../../utils/ch-error' import { searchTraces, tracesHistogram, tracesFacets } from '../../api/telemetryPro' import type { TelemetrySource } from '../../types/telemetry' import type { TraceSummary, TraceFacets as TraceFacetsT, TraceHistogramBucket } from '../../types/telemetryPro' @@ -237,6 +238,14 @@ + {#if error && isMissingTableError(error)} + setSection('sources') }} + /> + {:else} {#if error}
{error}
{/if} @@ -301,4 +310,5 @@ {/if} + {/if} {/if} diff --git a/ui/src/lib/routes.ts b/ui/src/lib/routes.ts index 96722f1a..bee42e85 100644 --- a/ui/src/lib/routes.ts +++ b/ui/src/lib/routes.ts @@ -52,6 +52,14 @@ export function isPageRouteType(type: string | undefined | null): type is PageRo return !!type && type in PAGE_ROUTES } +/** + * Old paths that still arrive from docs, emails and bookmarks. The router + * rewrites them to the current path, keeping the query string. + */ +export const PATH_ALIASES: Record = { + '/settings': PAGE_ROUTES.settings.path, +} + /** Path -> route type, for parsing the URL. */ export const PATH_TO_PAGE: Record = Object.fromEntries( (Object.keys(PAGE_ROUTES) as PageRoute[]).map((k) => [PAGE_ROUTES[k].path, k]), diff --git a/ui/src/lib/stores/router.svelte.ts b/ui/src/lib/stores/router.svelte.ts index 6a7a99a6..f273b09c 100644 --- a/ui/src/lib/stores/router.svelte.ts +++ b/ui/src/lib/stores/router.svelte.ts @@ -1,6 +1,6 @@ import { withBase, stripBase } from '../basePath' import { getActiveTab, getTabs, openHomeTab, setActiveTab } from './tabs.svelte' -import { isPageRouteType, PAGE_ROUTES, PATH_TO_PAGE, type PageRoute } from '../routes' +import { isPageRouteType, PAGE_ROUTES, PATH_ALIASES, PATH_TO_PAGE, type PageRoute } from '../routes' import { syncSectionFromUrl } from './nav.svelte' // ── Current route type (reactive) ──────────────────────────────── @@ -133,7 +133,15 @@ export function pushPipelineList(): void { // ── Parse current URL ─────────────────────────────────────────── export function parseRoute(): { type: string; dashboardId?: string; pipelineId?: string } { - const path = stripBase(window.location.pathname) + let path = stripBase(window.location.pathname) + + // Old path (/settings?section=license): move the URL to the current one + // so ?section= and later navigation see the canonical path. + const alias = PATH_ALIASES[path] + if (alias) { + history.replaceState(null, '', withBase(alias) + window.location.search + window.location.hash) + path = alias + } // /dashboards/:id const dashMatch = path.match(/^\/dashboards\/(.+)$/) diff --git a/ui/src/lib/types/api.ts b/ui/src/lib/types/api.ts index ffaad92d..4aa47b6d 100644 --- a/ui/src/lib/types/api.ts +++ b/ui/src/lib/types/api.ts @@ -174,6 +174,8 @@ export interface AuditLog { id: string action: string username: string | null + /** ClickHouse account the action ran as; omitted when it equals username */ + ch_user?: string | null details: string | null ip_address: string | null created_at: string diff --git a/ui/src/lib/utils/ch-error.test.ts b/ui/src/lib/utils/ch-error.test.ts index 2c7c26ce..523fbe28 100644 --- a/ui/src/lib/utils/ch-error.test.ts +++ b/ui/src/lib/utils/ch-error.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { parseCHError, byteToCharOffset } from './ch-error' +import { parseCHError, byteToCharOffset, isMissingTableError } from './ch-error' describe('byteToCharOffset', () => { it('is identity for ASCII', () => { @@ -164,3 +164,20 @@ describe('parseCHError', () => { expect(p.hint).toBeNull() }) }) + +describe('isMissingTableError', () => { + it('matches unknown table and database by code or name', () => { + expect(isMissingTableError("Code: 60. DB::Exception: Table otel.otel_logs does not exist. (UNKNOWN_TABLE) (version 24.8.4.13)")).toBe(true) + expect(isMissingTableError("ClickHouse error: Code: 81. DB::Exception: Database otel does not exist. (UNKNOWN_DATABASE)")).toBe(true) + }) + + it('matches the empty DESCRIBE message from the telemetry API', () => { + expect(isMissingTableError('table otel.otel_traces has no columns or does not exist')).toBe(true) + }) + + it('leaves other errors alone', () => { + expect(isMissingTableError('Code: 62. DB::Exception: Syntax error (SYNTAX_ERROR)')).toBe(false) + expect(isMissingTableError('Code: 516. DB::Exception: default: Authentication failed (AUTHENTICATION_FAILED)')).toBe(false) + expect(isMissingTableError('connection refused')).toBe(false) + }) +}) diff --git a/ui/src/lib/utils/ch-error.ts b/ui/src/lib/utils/ch-error.ts index 105f6da7..d4e2701b 100644 --- a/ui/src/lib/utils/ch-error.ts +++ b/ui/src/lib/utils/ch-error.ts @@ -134,3 +134,15 @@ export function parseCHError(raw: string): ParsedCHError { raw, } } + +/** + * True when ClickHouse says the table or database does not exist + * (UNKNOWN_TABLE 60, UNKNOWN_DATABASE 81), or the telemetry API reports an + * empty DESCRIBE. Telemetry uses it to show setup help instead of the raw error. + */ +export function isMissingTableError(raw: string): boolean { + const { code, name } = parseCHError(raw) + if (code === 60 || code === 81) return true + if (name === 'UNKNOWN_TABLE' || name === 'UNKNOWN_DATABASE') return true + return /has no columns or does not exist/.test(raw) +} diff --git a/ui/src/pages/Login.svelte b/ui/src/pages/Login.svelte index 763fd01d..a032679b 100644 --- a/ui/src/pages/Login.svelte +++ b/ui/src/pages/Login.svelte @@ -229,10 +229,11 @@ {@const selected = connections.find((c) => c.id === selectedId)} {#if selected}

+ {#if selected.online} - Connected + Connector online {:else} - Unreachable + Connector offline {/if}

{/if}