From 2034792a087ee0bb358ada08d6894342d8042c78 Mon Sep 17 00:00:00 2001 From: Polliog Date: Wed, 18 Mar 2026 17:56:10 +0100 Subject: [PATCH 1/8] fix: rename formatTimestamp to formatDate for clarity and add admin layout to prevent SSR issues --- CHANGELOG.md | 7 ++++ .../backend/migrations/032_hostname_index.sql | 17 +++++++++ packages/backend/src/modules/query/service.ts | 13 +++++-- .../src/routes/dashboard/admin/+layout.ts | 1 + .../admin/projects/[id]/+page.svelte | 2 +- .../engines/clickhouse/clickhouse-engine.ts | 14 +++++++ .../engines/clickhouse/query-translator.ts | 38 +++++++++++-------- .../src/engines/mongodb/mongodb-engine.ts | 8 +++- .../src/engines/timescale/query-translator.ts | 20 +++++----- 9 files changed, 88 insertions(+), 32 deletions(-) create mode 100644 packages/backend/migrations/032_hostname_index.sql create mode 100644 packages/frontend/src/routes/dashboard/admin/+layout.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1d1354..cb564a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to LogTide will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.4] - 2026-03-19 + +### Fixed +- Admin pages returned 502 Bad Gateway on direct load/reload: the admin layout (`+layout@.svelte`) breaks out of the dashboard layout chain, so `ssr = false` was not inherited; added a dedicated `+layout.ts` to the admin section +- `/dashboard/admin/projects/[id]` crashed with "Something went wrong" due to `formatDate` being called but not defined (function was named `formatTimestamp`) +- `GET /api/v1/logs/hostnames` taking 8+ seconds: the 6h window cap was only applied when `from` was absent — explicit `from` params (e.g. 24h range from the search page) bypassed it and triggered a full-range metadata scan; cap now clamps any window to 6h max. Added `limit: 500` to the distinct call. Per-engine optimizations: **ClickHouse** adds a `hostname` materialized column (computed at ingest, eliminates `JSONExtractString` at query time) and uses it directly in distinct queries; **TimescaleDB** adds a composite expression index `(project_id, (metadata->>'hostname'), time)` (migration 032); **MongoDB** adds a sparse compound index on `metadata.hostname`. All three engines also now extract the metadata field in a subquery (once per row vs 3×) + ## [0.8.3] - 2026-03-18 ### Added diff --git a/packages/backend/migrations/032_hostname_index.sql b/packages/backend/migrations/032_hostname_index.sql new file mode 100644 index 00000000..187d2860 --- /dev/null +++ b/packages/backend/migrations/032_hostname_index.sql @@ -0,0 +1,17 @@ +-- Migration 032: Composite expression index for hostname lookups (TimescaleDB) +-- +-- Migration 023 tried a standalone expression index on (metadata->>'hostname') but +-- TimescaleDB planner preferred seq scan because it had no way to narrow by project_id. +-- +-- A composite index (project_id, hostname_expr, time) lets the planner do an index +-- range scan scoped to a single project, then read distinct hostname values directly +-- from the index without touching row data. +-- +-- Note: ClickHouse and MongoDB handle this via engine-level changes in reservoir +-- (materialized column and compound index respectively). This migration only applies +-- to TimescaleDB instances. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_project_hostname + ON logs (project_id, (metadata->>'hostname'), time DESC) + WHERE metadata->>'hostname' IS NOT NULL + AND metadata->>'hostname' != ''; diff --git a/packages/backend/src/modules/query/service.ts b/packages/backend/src/modules/query/service.ts index 6be8fdb7..25b3e213 100644 --- a/packages/backend/src/modules/query/service.ts +++ b/packages/backend/src/modules/query/service.ts @@ -450,16 +450,20 @@ export class QueryService { * Hostnames are extracted from metadata.hostname field. * Cached for performance - used for filter dropdowns. * - * PERFORMANCE: Defaults to last 6 hours. Metadata extraction is expensive - * on large windows. With 5-minute cache, most requests are served from cache. + * PERFORMANCE: Window is capped at 6 hours regardless of what the caller passes. + * JSONB extraction is expensive on large datasets — 6h ≈ 350ms, 24h+ ≈ 8s+. + * For a filter dropdown this is an acceptable trade-off: hostnames are stable. + * With 5-minute cache, most requests are served from cache after the first hit. */ async getDistinctHostnames( projectId: string | string[], from?: Date, to?: Date ): Promise { - // PERFORMANCE: Default to last 6 hours - const effectiveFrom = from || new Date(Date.now() - 6 * 60 * 60 * 1000); + // PERFORMANCE: Cap window to 6h max. If the caller requests a longer window + // (e.g. 24h), silently clamp it — JSONB distinct over large ranges is O(rows). + const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000); + const effectiveFrom = !from || from < sixHoursAgo ? sixHoursAgo : from; // Try cache first const cacheKey = CacheManager.statsKey( @@ -482,6 +486,7 @@ export class QueryService { projectId, from: effectiveFrom, to: to ?? new Date(), + limit: 500, }); const hostnames = result.values; diff --git a/packages/frontend/src/routes/dashboard/admin/+layout.ts b/packages/frontend/src/routes/dashboard/admin/+layout.ts new file mode 100644 index 00000000..a3d15781 --- /dev/null +++ b/packages/frontend/src/routes/dashboard/admin/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte index b4549f74..fd7b7041 100644 --- a/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte @@ -70,7 +70,7 @@ } } - function formatTimestamp(date: string) { + function formatDate(date: string) { return new Date(date).toLocaleString(undefined, { month: "short", day: "numeric", diff --git a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts index dcc47fdf..52f5b57c 100644 --- a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts +++ b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts @@ -212,6 +212,20 @@ export class ClickHouseEngine extends StorageEngine { // projection may already exist } + // Materialized column for hostname — extracted from metadata JSON once at ingest time. + // Eliminates JSONExtractString() calls on every DISTINCT/filter query row. + // MATERIALIZE backfills existing data parts asynchronously during merges. + try { + await client.command({ + query: `ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS hostname String MATERIALIZED JSONExtractString(metadata, 'hostname')`, + }); + await client.command({ + query: `ALTER TABLE ${t} MATERIALIZE COLUMN hostname`, + }); + } catch { + // column may already exist + } + // Spans table await client.command({ query: ` diff --git a/packages/reservoir/src/engines/clickhouse/query-translator.ts b/packages/reservoir/src/engines/clickhouse/query-translator.ts index 9f0bc43a..43172ea3 100644 --- a/packages/reservoir/src/engines/clickhouse/query-translator.ts +++ b/packages/reservoir/src/engines/clickhouse/query-translator.ts @@ -257,24 +257,30 @@ export class ClickHouseQueryTranslator extends QueryTranslator { } } - let selectExpr: string; - if (params.field.startsWith('metadata.')) { - const jsonKey = params.field.slice('metadata.'.length); - selectExpr = `JSONExtractString(metadata, '${jsonKey}')`; - } else { - selectExpr = params.field; - } - - conditions.push(`${selectExpr} IS NOT NULL`); - conditions.push(`${selectExpr} != ''`); - const prewhereClause = prewhere.length > 0 ? ` PREWHERE ${prewhere.join(' AND ')}` : ''; const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : ''; - let query = `SELECT DISTINCT ${selectExpr} AS value FROM ${this.tableName}${prewhereClause}${whereClause} ORDER BY value ASC`; - - if (params.limit) { - query += ` LIMIT {p_limit:UInt32}`; - queryParams.p_limit = params.limit; + const limitClause = params.limit ? ` LIMIT {p_limit:UInt32}` : ''; + if (params.limit) queryParams.p_limit = params.limit; + + let query: string; + if (params.field === 'metadata.hostname') { + // Use the materialized `hostname` column — pre-computed at ingest, no JSON parsing at query time. + // Fast path: ClickHouse reads only the native string column, skipping the entire metadata blob. + const fullWhere = [...conditions, `hostname != ''`]; + const whereAll = fullWhere.length > 0 ? ` WHERE ${fullWhere.join(' AND ')}` : ''; + query = `SELECT DISTINCT hostname AS value FROM ${this.tableName}${prewhereClause}${whereAll} ORDER BY value ASC${limitClause}`; + } else if (params.field.startsWith('metadata.')) { + // For other metadata fields: extract JSON once in a subquery instead of 3x per row. + // JSONExtractString always returns '' for missing keys — IS NOT NULL is redundant. + const jsonKey = params.field.slice('metadata.'.length); + const extract = `JSONExtractString(metadata, '${jsonKey}')`; + query = `SELECT DISTINCT value FROM (SELECT ${extract} AS value FROM ${this.tableName}${prewhereClause}${whereClause}) WHERE value != '' ORDER BY value ASC${limitClause}`; + } else { + const selectExpr = params.field; + conditions.push(`${selectExpr} IS NOT NULL`); + conditions.push(`${selectExpr} != ''`); + const fullWhere = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : ''; + query = `SELECT DISTINCT ${selectExpr} AS value FROM ${this.tableName}${prewhereClause}${fullWhere} ORDER BY value ASC${limitClause}`; } return { query, parameters: [queryParams] }; diff --git a/packages/reservoir/src/engines/mongodb/mongodb-engine.ts b/packages/reservoir/src/engines/mongodb/mongodb-engine.ts index 7491c310..b61875dc 100644 --- a/packages/reservoir/src/engines/mongodb/mongodb-engine.ts +++ b/packages/reservoir/src/engines/mongodb/mongodb-engine.ts @@ -475,8 +475,10 @@ export class MongoDBEngine extends StorageEngine { const pipeline: Document[] = [ { $match: filter }, + // Pre-filter before $group to reduce the number of docs aggregated. + // Avoids grouping null/empty values that would be discarded afterwards. + { $match: { [mongoField]: { $exists: true, $ne: null, $gt: '' } } }, { $group: { _id: `$${mongoField}` } }, - { $match: { _id: { $ne: null } } }, { $sort: { _id: 1 } }, { $limit: limit }, { $project: { value: '$_id', _id: 0 } }, @@ -1224,7 +1226,9 @@ export class MongoDBEngine extends StorageEngine { await safeCreateIndex(col, { project_id: 1, service: 1, time: -1 }, 'idx_project_service_time'); await safeCreateIndex(col, { project_id: 1, level: 1, time: -1 }, 'idx_project_level_time'); await safeCreateIndex(col, { project_id: 1, service: 1, level: 1, time: -1 }, 'idx_project_service_level_time'); - await safeCreateIndex(col, { project_id: 1, hostname: 1, time: -1 }, 'idx_project_hostname_time'); + // hostname is stored in metadata.hostname (nested), not top-level — index accordingly. + // The top-level `hostname` field is always null (ingestion puts it in metadata). + await safeCreateIndex(col, { 'metadata.hostname': 1, project_id: 1, time: -1 }, 'idx_project_metadata_hostname_time', { sparse: true }); // Text index for $text search (not supported on time-series timeField/metaField) if (!this.useTimeSeries) { diff --git a/packages/reservoir/src/engines/timescale/query-translator.ts b/packages/reservoir/src/engines/timescale/query-translator.ts index 2ef2322a..146920ea 100644 --- a/packages/reservoir/src/engines/timescale/query-translator.ts +++ b/packages/reservoir/src/engines/timescale/query-translator.ts @@ -322,20 +322,22 @@ export class TimescaleQueryTranslator extends QueryTranslator { values.push(params.to); idx++; - let selectExpr: string; + const where = ` WHERE ${conditions.join(' AND ')}`; + let query: string; + if (params.field.startsWith('metadata.')) { + // Extract JSONB once in a subquery instead of 3x per row (SELECT + IS NOT NULL + != ''). const jsonKey = params.field.slice('metadata.'.length); - selectExpr = `metadata->>'${jsonKey}'`; + const extract = `metadata->>'${jsonKey}'`; + query = `SELECT DISTINCT value FROM (SELECT ${extract} AS value FROM ${this.table}${where}) sub WHERE value IS NOT NULL AND value != '' ORDER BY value ASC`; } else { - selectExpr = params.field; + const selectExpr = params.field; + conditions.push(`${selectExpr} IS NOT NULL`); + conditions.push(`${selectExpr} != ''`); + const fullWhere = ` WHERE ${conditions.join(' AND ')}`; + query = `SELECT DISTINCT ${selectExpr} AS value FROM ${this.table}${fullWhere} ORDER BY value ASC`; } - conditions.push(`${selectExpr} IS NOT NULL`); - conditions.push(`${selectExpr} != ''`); - - const where = ` WHERE ${conditions.join(' AND ')}`; - let query = `SELECT DISTINCT ${selectExpr} AS value FROM ${this.table}${where} ORDER BY value ASC`; - if (params.limit) { query += ` LIMIT $${idx}`; values.push(params.limit); From 3d23158ffd78a46c290bbabd77605dc6bf04a93d Mon Sep 17 00:00:00 2001 From: Polliog Date: Wed, 18 Mar 2026 18:03:08 +0100 Subject: [PATCH 2/8] fix: optimize log identifier retrieval by querying log_identifiers directly and adding indexes for improved performance --- CHANGELOG.md | 1 + .../backend/migrations/032_hostname_index.sql | 6 +++ .../backend/src/modules/correlation/routes.ts | 54 ++++++------------- .../engines/clickhouse/clickhouse-engine.ts | 13 +++++ 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb564a4a..38232efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Admin pages returned 502 Bad Gateway on direct load/reload: the admin layout (`+layout@.svelte`) breaks out of the dashboard layout chain, so `ssr = false` was not inherited; added a dedicated `+layout.ts` to the admin section - `/dashboard/admin/projects/[id]` crashed with "Something went wrong" due to `formatDate` being called but not defined (function was named `formatTimestamp`) +- `POST /api/v1/logs/identifiers/batch` slow: the route was calling `reservoir.getByIds` (hitting ClickHouse/TimescaleDB/MongoDB) only to verify project access, then querying `log_identifiers` (PostgreSQL) separately. Since `log_identifiers` already stores `log_id → project_id` + identifier data, the storage engine call is now bypassed entirely — one PostgreSQL query replaces the N×storage-engine-roundtrips loop. Added bloom filter skip index on `id` in ClickHouse and a standalone `id` index in TimescaleDB (migration 032) for `getByIds` used by `findCorrelatedLogs` - `GET /api/v1/logs/hostnames` taking 8+ seconds: the 6h window cap was only applied when `from` was absent — explicit `from` params (e.g. 24h range from the search page) bypassed it and triggered a full-range metadata scan; cap now clamps any window to 6h max. Added `limit: 500` to the distinct call. Per-engine optimizations: **ClickHouse** adds a `hostname` materialized column (computed at ingest, eliminates `JSONExtractString` at query time) and uses it directly in distinct queries; **TimescaleDB** adds a composite expression index `(project_id, (metadata->>'hostname'), time)` (migration 032); **MongoDB** adds a sparse compound index on `metadata.hostname`. All three engines also now extract the metadata field in a subquery (once per row vs 3×) ## [0.8.3] - 2026-03-18 diff --git a/packages/backend/migrations/032_hostname_index.sql b/packages/backend/migrations/032_hostname_index.sql index 187d2860..f7a3bfe0 100644 --- a/packages/backend/migrations/032_hostname_index.sql +++ b/packages/backend/migrations/032_hostname_index.sql @@ -15,3 +15,9 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_project_hostname ON logs (project_id, (metadata->>'hostname'), time DESC) WHERE metadata->>'hostname' IS NOT NULL AND metadata->>'hostname' != ''; + +-- Index for getByIds lookups (e.g. findCorrelatedLogs). +-- The primary key is (time, id) which requires knowing `time` to be useful. +-- A standalone index on id lets WHERE id = ANY(...) resolve without chunk scans. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_id + ON logs (id); diff --git a/packages/backend/src/modules/correlation/routes.ts b/packages/backend/src/modules/correlation/routes.ts index 9bf54e12..d5a9f592 100644 --- a/packages/backend/src/modules/correlation/routes.ts +++ b/packages/backend/src/modules/correlation/routes.ts @@ -331,45 +331,25 @@ export default async function correlationRoutes(fastify: FastifyInstance) { }); } - // Fetch logs by IDs across accessible projects (reservoir: works with any engine) - const allFoundLogs: Array<{ id: string; projectId: string }> = []; - for (const pid of searchProjectIds) { - const found = await reservoir.getByIds({ ids: logIds, projectId: pid }); - for (const log of found) { - allFoundLogs.push({ id: log.id, projectId: log.projectId }); - } - // Stop once we've found all requested logs - if (allFoundLogs.length >= logIds.length) break; - } - - if (allFoundLogs.length === 0) { - return reply.send({ - success: true, - data: { identifiers: {} }, - }); - } + // Query log_identifiers directly — log_identifiers is always in PostgreSQL and + // already contains log_id, project_id, and identifier data. No need to hit the + // storage engine (ClickHouse/TimescaleDB/MongoDB) at all. + // The project_id IN searchProjectIds clause enforces access control. + const rows = await db + .selectFrom('log_identifiers') + .select(['log_id', 'identifier_type', 'identifier_value', 'source_field']) + .where('log_id', 'in', logIds) + .where('project_id', 'in', searchProjectIds) + .execute(); - // Verify project access for the first log's project - const firstProjectId = allFoundLogs[0].projectId || projectId || ''; - const hasAccess = await verifyProjectAccess(request as any, firstProjectId); - if (!hasAccess) { - return reply.status(403).send({ - success: false, - error: 'Access denied to these logs', - }); - } - - // Only return identifiers for logs in accessible projects - const accessibleLogIds = allFoundLogs - .filter((log) => log.projectId === firstProjectId) - .map((log) => log.id); - - const identifiersMap = await correlationService.getLogIdentifiersBatch(accessibleLogIds); - - // Convert Map to plain object for JSON serialization const identifiers: Record> = {}; - for (const [logId, matches] of identifiersMap) { - identifiers[logId] = matches; + for (const row of rows) { + if (!identifiers[row.log_id]) identifiers[row.log_id] = []; + identifiers[row.log_id].push({ + type: row.identifier_type, + value: row.identifier_value, + sourceField: row.source_field, + }); } return reply.send({ diff --git a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts index 52f5b57c..d5e4c404 100644 --- a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts +++ b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts @@ -192,6 +192,19 @@ export class ClickHouseEngine extends StorageEngine { // index may already exist } + // Bloom filter on id — lets getByIds skip data granules that don't contain + // any of the requested UUIDs without a full project scan. + try { + await client.command({ + query: `ALTER TABLE ${t} ADD INDEX IF NOT EXISTS idx_id id TYPE bloom_filter(0.01) GRANULARITY 1`, + }); + await client.command({ + query: `ALTER TABLE ${t} MATERIALIZE INDEX idx_id`, + }); + } catch { + // index may already exist + } + try { await client.command({ query: `ALTER TABLE ${t} ADD INDEX IF NOT EXISTS idx_span_id span_id TYPE bloom_filter(0.01) GRANULARITY 1`, From 158d236c0e6412632d6b49aa71d0d216a7acf380 Mon Sep 17 00:00:00 2001 From: Polliog Date: Wed, 18 Mar 2026 18:15:52 +0100 Subject: [PATCH 3/8] fix: remove CONCURRENTLY from migration 032 (not supported on hypertables) --- packages/backend/migrations/032_hostname_index.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/backend/migrations/032_hostname_index.sql b/packages/backend/migrations/032_hostname_index.sql index f7a3bfe0..e524ebe4 100644 --- a/packages/backend/migrations/032_hostname_index.sql +++ b/packages/backend/migrations/032_hostname_index.sql @@ -10,8 +10,10 @@ -- Note: ClickHouse and MongoDB handle this via engine-level changes in reservoir -- (materialized column and compound index respectively). This migration only applies -- to TimescaleDB instances. +-- +-- Note: CONCURRENTLY is not supported on TimescaleDB hypertables. -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_project_hostname +CREATE INDEX IF NOT EXISTS idx_logs_project_hostname ON logs (project_id, (metadata->>'hostname'), time DESC) WHERE metadata->>'hostname' IS NOT NULL AND metadata->>'hostname' != ''; @@ -19,5 +21,5 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_project_hostname -- Index for getByIds lookups (e.g. findCorrelatedLogs). -- The primary key is (time, id) which requires knowing `time` to be useful. -- A standalone index on id lets WHERE id = ANY(...) resolve without chunk scans. -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_logs_id +CREATE INDEX IF NOT EXISTS idx_logs_id ON logs (id); From c411b0ae45a04b9973c69ada905a2a974c54534d Mon Sep 17 00:00:00 2001 From: Polliog Date: Wed, 18 Mar 2026 18:21:25 +0100 Subject: [PATCH 4/8] feat: add docker-compose configuration for ClickHouse and MongoDB test services --- packages/reservoir/docker-compose.test.yml | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/reservoir/docker-compose.test.yml diff --git a/packages/reservoir/docker-compose.test.yml b/packages/reservoir/docker-compose.test.yml new file mode 100644 index 00000000..53eda0ec --- /dev/null +++ b/packages/reservoir/docker-compose.test.yml @@ -0,0 +1,32 @@ +services: + clickhouse-test: + image: clickhouse/clickhouse-server:24.1 + container_name: reservoir-clickhouse-test + environment: + CLICKHOUSE_DB: logtide_test + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + ports: + - "18123:8123" + - "19000:9000" + tmpfs: + - /var/lib/clickhouse + healthcheck: + test: ["CMD", "clickhouse-client", "--query", "SELECT 1"] + interval: 5s + timeout: 3s + retries: 10 + + mongodb-test: + image: mongo:7 + container_name: reservoir-mongodb-test + ports: + - "27017:27017" + tmpfs: + - /data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 3s + retries: 10 From 6861fa4c0066f2dde8767657a6d8fbaa5f2a7e24 Mon Sep 17 00:00:00 2001 From: Polliog Date: Wed, 18 Mar 2026 22:52:13 +0100 Subject: [PATCH 5/8] add skeleton loaders and loading overlays across dashboard pages --- packages/frontend/src/app.css | 25 ++++++++ .../src/lib/components/ui/skeleton/index.ts | 5 ++ .../ui/skeleton/skeleton-table.svelte | 60 +++++++++++++++++++ .../components/ui/skeleton/skeleton.svelte | 36 +++++++++++ .../ui/skeleton/table-loading-overlay.svelte | 33 ++++++++++ .../src/routes/dashboard/+page.svelte | 16 ++++- .../admin/organizations/+page.svelte | 11 ++-- .../dashboard/admin/projects/+page.svelte | 11 ++-- .../routes/dashboard/admin/users/+page.svelte | 9 +-- .../src/routes/dashboard/alerts/+page.svelte | 10 ++-- .../src/routes/dashboard/errors/+page.svelte | 9 ++- .../routes/dashboard/projects/+page.svelte | 8 ++- .../src/routes/dashboard/search/+page.svelte | 9 ++- .../routes/dashboard/security/+page.svelte | 27 +++++++-- .../dashboard/security/incidents/+page.svelte | 9 ++- .../dashboard/settings/members/+page.svelte | 11 +--- .../src/routes/dashboard/traces/+page.svelte | 9 +-- 17 files changed, 242 insertions(+), 56 deletions(-) create mode 100644 packages/frontend/src/lib/components/ui/skeleton/index.ts create mode 100644 packages/frontend/src/lib/components/ui/skeleton/skeleton-table.svelte create mode 100644 packages/frontend/src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 packages/frontend/src/lib/components/ui/skeleton/table-loading-overlay.svelte diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css index 6f1a3de5..19eeb9b8 100644 --- a/packages/frontend/src/app.css +++ b/packages/frontend/src/app.css @@ -89,6 +89,31 @@ } } +/* Skeleton shimmer animation */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton { + background-color: hsl(var(--muted)); + background-image: linear-gradient( + 90deg, + hsl(var(--muted)) 0%, + hsl(var(--background) / 0.8) 50%, + hsl(var(--muted)) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.8s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + background-image: none; + } +} + /* High contrast mode support */ @media (prefers-contrast: more) { :root { diff --git a/packages/frontend/src/lib/components/ui/skeleton/index.ts b/packages/frontend/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 00000000..00aae27f --- /dev/null +++ b/packages/frontend/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,5 @@ +import Root from './skeleton.svelte'; +import SkeletonTable from './skeleton-table.svelte'; +import TableLoadingOverlay from './table-loading-overlay.svelte'; + +export { Root, Root as Skeleton, SkeletonTable, TableLoadingOverlay }; diff --git a/packages/frontend/src/lib/components/ui/skeleton/skeleton-table.svelte b/packages/frontend/src/lib/components/ui/skeleton/skeleton-table.svelte new file mode 100644 index 00000000..5101ba33 --- /dev/null +++ b/packages/frontend/src/lib/components/ui/skeleton/skeleton-table.svelte @@ -0,0 +1,60 @@ + + +
+ + + + {#each Array(columns) as _, i} + + {/each} + + + + {#each Array(rows) as _, rowIndex} + + {#each { length: columns } as _, colIndex} + + {/each} + + {/each} + +
+ +
+ +
+
diff --git a/packages/frontend/src/lib/components/ui/skeleton/skeleton.svelte b/packages/frontend/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 00000000..90ecc9f0 --- /dev/null +++ b/packages/frontend/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,36 @@ + + + diff --git a/packages/frontend/src/lib/components/ui/skeleton/table-loading-overlay.svelte b/packages/frontend/src/lib/components/ui/skeleton/table-loading-overlay.svelte new file mode 100644 index 00000000..29f35a36 --- /dev/null +++ b/packages/frontend/src/lib/components/ui/skeleton/table-loading-overlay.svelte @@ -0,0 +1,33 @@ + + +
+
+ {@render children?.()} +
+ {#if loading} +
+ +
+ {/if} +
diff --git a/packages/frontend/src/routes/dashboard/+page.svelte b/packages/frontend/src/routes/dashboard/+page.svelte index 3171b7e4..2b020fff 100644 --- a/packages/frontend/src/routes/dashboard/+page.svelte +++ b/packages/frontend/src/routes/dashboard/+page.svelte @@ -12,6 +12,7 @@ import RecentErrorsWidget from '$lib/components/dashboard/RecentErrorsWidget.svelte'; import EmptyDashboard from '$lib/components/dashboard/EmptyDashboard.svelte'; import Spinner from '$lib/components/Spinner.svelte'; + import { Skeleton } from '$lib/components/ui/skeleton'; import { layoutStore } from '$lib/stores/layout'; import Activity from '@lucide/svelte/icons/activity'; import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; @@ -230,9 +231,18 @@ {#if loading} -
- - Loading dashboard... + +
+ {#each Array(4) as _} + + {/each} +
+ + + +
+ +
{:else if error}
diff --git a/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte b/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte index 2e7cc0b8..61ba1716 100644 --- a/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte @@ -1,6 +1,7 @@