diff --git a/api/clickhouse-client.ts b/api/clickhouse-client.ts index 0952b02..ce044f5 100644 --- a/api/clickhouse-client.ts +++ b/api/clickhouse-client.ts @@ -5,7 +5,6 @@ import { CLICKHOUSE_USER, } from './lib/env.ts' import { respond } from '@01edu/api/response' -import { log } from './lib/log.ts' import { ARR, type Asserted, @@ -100,13 +99,13 @@ async function insertLogs( } }) - log.debug('Inserting logs into ClickHouse', { rows }) + console.debug('Inserting logs into ClickHouse', { rows }) try { await client.insert({ table: 'logs', values: rows, format: 'JSONEachRow' }) return respond.OK() } catch (error) { - log.error('Error inserting logs into ClickHouse:', { error }) + console.error('Error inserting logs into ClickHouse:', { error }) throw respond.InternalServerError() } } @@ -213,7 +212,7 @@ async function getLogs(dep: string, data: FetchTablesParams) { }) return (await rs.json()).data } catch (e) { - log.error('ClickHouse query failed', { error: e, query, params }) + console.error('ClickHouse query failed', { error: e, query, params }) throw respond.InternalServerError() } } diff --git a/api/lib/log.ts b/api/lib/log.ts deleted file mode 100644 index d7183b3..0000000 --- a/api/lib/log.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { logger } from '@01edu/api/log' -export const log = await logger({}) diff --git a/deno.lock b/deno.lock index e1b60a7..f179502 100644 --- a/deno.lock +++ b/deno.lock @@ -1159,6 +1159,9 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" } }, + "remote": { + "https://gistcdn.githack.com/kigiri/21df06d173fcdced5281b86ba6ac1382/raw/crypto.js": "e85976e655898538dbade9d87b05ca0a6bb167b3128cd4098622000a582f5f6d" + }, "workspace": { "dependencies": [ "jsr:@01edu/api-client@~0.1.3", diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index f399768..46b4346 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -46,6 +46,8 @@ type AnyRecord = Record const schema = api['GET/api/deployment/schema'].signal() // API signal for table data export const tableData = api['POST/api/deployment/table/data'].signal() +export const rowDetailsData = api['POST/api/deployment/table/data'].signal() +export const logDetailsData = api['POST/api/deployment/logs'].signal() // Effect to fetch schema when deployment URL changes effect(() => { @@ -217,7 +219,19 @@ function ResultsHeader() { - +
+ + +
) @@ -294,6 +308,14 @@ const TableCell = ({ value }: { value: unknown }) => { const stringValue = isObj ? JSON.stringify(value) : String(value ?? '') const isTooLong = stringValue.length > 100 + if (value === null || value === undefined || value === '') { + return ( + + null + + ) + } + if (isObj) { return ( ( const DataRow = ( { row, columns, index }: { row: AnyRecord; columns: string[]; index: number }, -) => ( - - - {columns.map((key, i) => ( - - - - ))} - -) +) => { + const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => t.table === tableName) + const pk = tableDef?.columns?.[0]?.name + const rowId = pk ? String(row[pk]) : undefined + + return ( + + + + {columns.map((key, i) => ( + + + + ))} + + + ) +} const TableHeader = ({ columns }: { columns: string[] }) => ( @@ -414,7 +448,20 @@ const TableFooter = ({ rows }: { rows: AnyRecord[] }) => { } const TableContent = ({ rows }: { rows: AnyRecord[] }) => { - const columns = Object.keys(rows[0] || {}) + let columns = Object.keys(rows[0] || {}) + + // If in tables view, use schema columns first + if (url.params.tab === 'tables') { + const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => t.table === tableName) + + if (tableDef?.columns) { + const schemaColumns = tableDef.columns.map((c) => c.name) + // Add any extra columns found in rows that aren't in schema (e.g. virtual columns) + const extraColumns = columns.filter((c) => !schemaColumns.includes(c)) + columns = [...schemaColumns, ...extraColumns] + } + } return ( @@ -437,6 +484,7 @@ const TableContent = ({ rows }: { rows: AnyRecord[] }) => { } const DataTable = () => { const tab = url.params.tab + const isPending = tab === 'tables' ? tableData.pending : querier.pending const rows = tab === 'tables' ? tableData.data?.rows || [] @@ -445,9 +493,20 @@ const DataTable = () => { : [] return ( -
+
+ {isPending && ( +
+
+
+ )}
-
+
@@ -774,14 +833,6 @@ function TabNavigation({ - )}
@@ -860,9 +911,190 @@ const dateFmtConfig = { fractionalSecondDigits: 3, } as const -const safeFormatTimestamp = (timestamp: Date) => - timestamp.toLocaleString('en-UK', dateFmtConfig) +const safeFormatTimestamp = (timestamp: Date) => { + // If timestamp is close to epoch (1970), assume it's in seconds and convert to ms + const time = timestamp.getTime() + const correctTime = time < 1000000000000 ? time * 1000 : time // 1e12 ms is roughly year 2001, 1e10 sec is year 2286 + return new Date(correctTime).toLocaleString('en-UK', dateFmtConfig) .split(', ').reverse().join(' ') +} + +// Derive severity text from severity number (matches DB schema) +const getSeverityText = ( + severityNumber: number, + existingText?: string | null, +): string => { + if (existingText) return existingText + if (severityNumber > 4 && severityNumber <= 8) return 'DEBUG' + if (severityNumber > 8 && severityNumber <= 12) return 'INFO' + if (severityNumber > 12 && severityNumber <= 16) return 'WARN' + if (severityNumber > 20 && severityNumber <= 24) return 'FATAL' + return 'ERROR' +} + +// Reusable copy button with hover reveal +const CopyButton = ({ text }: { text: string }) => ( + +) + +// Reusable info block for key-value display +const InfoBlock = ( + { label, value, mono = false, copy = false }: { + label: string + value?: string | number | null + mono?: boolean + copy?: boolean + }, +) => { + const displayValue = value == null ? '-' : String(value) + return ( +
+
+ {label} +
+
+
+ {displayValue} +
+ {copy && displayValue !== '-' && } +
+
+ ) +} + +// Recursive JSON value renderer with syntax highlighting +const JsonValue = ({ value }: { value: unknown }): JSX.Element => { + if (typeof value === 'object' && value !== null) { + if (Object.keys(value).length === 0) { + return empty object + } + return ( +
+ {Object.entries(value).map(([k, v]) => ( +
+ + {k}: + + +
+ ))} +
+ ) + } + const valStr = String(value) + const isNumber = !isNaN(Number(value)) && value !== '' + const isBool = valStr === 'true' || valStr === 'false' + const colorClass = isNumber + ? 'text-blue-500' + : isBool + ? 'text-secondary' + : 'text-base-content' + return {valStr} +} + +// Hex128 ID block with oklch colors +const Hex128Block = ( + { hex, type }: { hex: string; type: 'trace' | 'span' }, +) => { + const { hue, value } = parseHex128(hex) + const Icon = type === 'trace' ? Link2 : Hash + const label = type === 'trace' ? 'Trace ID' : 'Span ID' + + return ( +
+
+ +
+ {label} +
+
+
+
+ {hex} +
+ +
+
raw: {value}
+
+ ) +} + +// Severity block with icon and colors +const SeverityBlock = ( + { severityNumber, severityText }: { + severityNumber: number + severityText?: string | null + }, +) => { + const text = getSeverityText(severityNumber, severityText) + const config = severityConfig[text as keyof typeof severityConfig] + const Icon = config?.icon || Info + + return ( +
+
+ Severity +
+
+ +
+
{text}
+
Level {severityNumber}
+
+
+
+ ) +} + +// Body block for log message +const BodyBlock = ({ body }: { body: string }) => ( +
+
+
+ Body +
+ +
+
{body}
+
+) + +// Attributes block for JSON display +const AttributesBlock = ( + { attributes }: { attributes: Record }, +) => ( +
+
+ Attributes +
+
+ +
+
+) const logThreads = [ 'Timestamp', @@ -894,11 +1126,23 @@ const Hex128 = ({ hex, type }: { hex: string; type: 'trace' | 'span' }) => { function LogsViewer() { const filteredLogs = logData.data || [] + const isPending = logData.pending return ( -
+
+ {isPending && ( +
+
+
+ )}
-
+
@@ -917,15 +1161,10 @@ function LogsViewer() { {filteredLogs.map((log) => { const serverityNum = log.severity_number - const severity = (serverityNum < 9) - ? 'DEBUG' - : (serverityNum < 13) - ? 'INFO' - : (serverityNum < 17) - ? 'WARN' - : (serverityNum < 21) - ? 'ERROR' - : 'FATAL' + const severity = getSeverityText( + serverityNum, + log.severity_text, + ) const conf = severityConfig[ severity as keyof typeof severityConfig ] @@ -993,13 +1232,13 @@ function LogsViewer() {
- +
) -type DrawerTab = 'history' | 'insert' +const schemaPanel = +const TabViews = { + tables: ( +
+ {schemaPanel} + +
+ ), + queries: ( +
+ {schemaPanel} + +
+ ), + logs: , + // Add other tab views here as needed +} + +effect(() => { + const rowId = url.params['row-id'] + const dep = url.params.dep + + if (dep && rowId) { + const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => t.table === tableName) + const pk = tableDef?.columns?.[0]?.name + + if (tableName && pk) { + rowDetailsData.fetch({ + deployment: dep, + table: tableName, + filter: [{ + key: pk, + comparator: '=', + value: rowId, + }], + sort: [], + search: '', + limit: 1, + offset: 0, + }) + } + } +}) + +const RowDetails = () => { + const row = rowDetailsData.data?.rows?.[0] + + if (rowDetailsData.pending) { + return ( +
+ +
+ ) + } + + if (rowDetailsData.error) { + return ( +
+ Error loading row: {rowDetailsData.error.message} +
+ ) + } + + if (!row) { + return ( +
+ Row not found +
+ ) + } + + return ( +
+
+

Row Details

+ + + +
+ +
+ {Object.entries(row).map(([key, value]) => { + // Find column definition + const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => + t.table === tableName + ) + const colDef = tableDef?.columns?.find((c) => c.name === key) + const type = colDef?.type || 'String' + + const isObject = (type.includes('Map') || type.includes('Array') || + type.includes('Tuple') || type.includes('Nested') || + type.includes('JSON') || type.toLowerCase().includes('blob')) && + (typeof value === 'object' || typeof value === 'string') + const isNumber = type.includes('Int') || type.includes('Float') || + type.includes('Decimal') + const isBoolean = type.includes('Bool') + const isDate = type.includes('Date') || type.includes('Time') || + (key.endsWith('At') && + (typeof value === 'number' || !isNaN(Number(value)))) + + return ( +
+ + + {isObject + ? + : isBoolean + ? + : isDate + ? ( + + ) + : isNumber + ? + : } +
+ ) + })} +
+ +
+ +
+
+ ) +} + +// Input components for RowDetails +const ObjectInput = ({ value }: { value: unknown }) => ( +