diff --git a/Dockerfile b/Dockerfile index 75a0708..5f2316a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,18 +2,25 @@ FROM denoland/deno:latest AS builder WORKDIR /app -# Copy project files -COPY ./api /app/api -COPY ./tasks/vite.ts /app/tasks/vite.ts +# Copy lock COPY ./deno.json /app/deno.json COPY ./deno.lock /app/deno.lock -COPY ./web /app/web -# Cache dependencies -RUN deno cache --allow-scripts --lock=deno.lock api/server.ts tasks/vite.ts +# Install dependencies +RUN deno install # Build frontend (dist/web) and compile backend with static files -RUN deno task prod +COPY ./tasks/vite.ts /app/tasks/vite.ts +COPY ./web /app/web +RUN deno cache --allow-scripts --lock=deno.lock tasks/vite.ts web/index.tsx +ENV BASE_URL="/" +RUN deno task prod:vite + +# Build API +COPY ./api /app/api +COPY ./db /app/db +RUN deno cache --allow-scripts --lock=deno.lock api/server.ts +RUN deno task prod:api # Stage 2: Final image FROM debian:bookworm-slim @@ -21,6 +28,7 @@ WORKDIR /app # Copy compiled executable and Deno cache COPY --from=builder /app/dist/api /app/server +COPY --from=builder /app/db/functions /app/db/functions # Expose port from .env.prod (3021) EXPOSE 3021 diff --git a/README.md b/README.md index 0b30a8e..3c39b7d 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,27 @@ deno task docker:prod deno task docker:logs ``` +### Docker Compose + +Use the compose stack when you want ClickHouse and the app started together. It +has inline defaults, so no env file is required. + +```bash +docker compose up --build +``` + +Override any value from your shell or a local `.env` file. Example: + +```bash +PORT=8877 CLICKHOUSE_PASSWORD=strong-password docker compose up --build +``` + +The stack starts: + +- `clickhouse`: database server on ports `8123` and `9000` +- `clickhouse-init`: one-shot schema creation for the `logs` table +- `app`: compiled application on port `3021` + ### Available Tasks ```bash diff --git a/api/clickhouse-client.ts b/api/clickhouse-client.ts index 0a70e4f..0071b26 100644 --- a/api/clickhouse-client.ts +++ b/api/clickhouse-client.ts @@ -15,7 +15,7 @@ import { UNION, } from '@01edu/api/validator' -const LogSchema = OBJ({ +export const LogSchema = OBJ({ timestamp: NUM('The timestamp of the log event'), trace_id: NUM('A float64 representation of the trace ID'), span_id: optional(NUM('A float64 representation of the span ID')), @@ -41,7 +41,7 @@ export const LogSchemaOutput = OBJ({ service_instance_id: optional(STR('Service instance ID')), }, 'A log event') -const LogsInputSchema = UNION( +export const LogsInputSchema = UNION( LogSchema, ARR(LogSchema, 'An array of log events'), ) @@ -49,7 +49,7 @@ const LogsInputSchema = UNION( type Log = Asserted type LogsInput = Asserted -const client = createClient({ +export const client = createClient({ url: CLICKHOUSE_HOST, username: CLICKHOUSE_USER, password: CLICKHOUSE_PASSWORD, @@ -79,10 +79,7 @@ const numberToHex128 = (() => { } })() -async function insertLogs( - service_name: string, - data: LogsInput, -) { +export async function insertLogs(service_name: string, data: LogsInput) { const logsToInsert = Array.isArray(data) ? data : [data] if (logsToInsert.length === 0) return respond.NoContent() @@ -204,7 +201,7 @@ function inferParamType(key: string, value: string): string { return 'String' } -async function getLogs(dep: string, data: FetchTablesParams) { +export async function getLogs(dep: string, data: FetchTablesParams) { const { query, params } = buildLogsQuery(dep, data) try { const rs = await client.query({ @@ -244,4 +241,39 @@ async function getLogs(dep: string, data: FetchTablesParams) { // } // } -export { client, getLogs, insertLogs, LogSchema, LogsInputSchema } +export const initLogTable = async () => { + await client.ping() + await client.command({ + query: ` + CREATE TABLE IF NOT EXISTS logs ( + id UUID DEFAULT generateUUIDv4(), + -- Flattened resource fields + service_name LowCardinality(String), + service_version LowCardinality(String), + service_instance_id String, + + timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'), + observed_timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'), + trace_id FixedString(16), + span_id FixedString(16), + severity_number UInt8, + -- derived column, computed by DB from severity_number + severity_text LowCardinality(String) MATERIALIZED CASE + WHEN severity_number > 4 AND severity_number <= 8 THEN 'DEBUG' + WHEN severity_number > 8 AND severity_number <= 12 THEN 'INFO' + WHEN severity_number > 12 AND severity_number <= 16 THEN 'WARN' + WHEN severity_number > 20 AND severity_number <= 24 THEN 'FATAL' + ELSE 'ERROR' + END, + -- Often empty, but kept for OTEL spec compliance + body Nullable(String), + attributes JSON, + event_name LowCardinality(String) + ) + ENGINE = MergeTree + PARTITION BY toYYYYMMDD(timestamp) + ORDER BY (service_name, timestamp, trace_id) + SETTINGS index_granularity = 8192, min_bytes_for_wide_part = 0; + `, + }) +} diff --git a/api/lib/env.ts b/api/lib/env.ts index 12eff48..d635b78 100644 --- a/api/lib/env.ts +++ b/api/lib/env.ts @@ -1,10 +1,11 @@ -import { ENV } from '@01edu/api/env' +import { APP_ENV, ENV } from '@01edu/api/env' +export { APP_ENV } export const PORT = Number(ENV('PORT', '2119')) export const PICTURE_DIR = ENV('PICTURE_DIR', './.picture') -export const GOOGLE_CLIENT_ID = ENV('GOOGLE_CLIENT_ID') -export const CLIENT_SECRET = ENV('CLIENT_SECRET') -export const REDIRECT_URI = ENV('REDIRECT_URI') +export const GOOGLE_CLIENT_ID = ENV('GOOGLE_CLIENT_ID', '') +export const CLIENT_SECRET = ENV('CLIENT_SECRET', '') +export const REDIRECT_URI = ENV('REDIRECT_URI', `http://localhost:${PORT}`) export const ORIGIN = new URL(REDIRECT_URI).origin export const SECRET = ENV( 'SECRET', @@ -21,8 +22,11 @@ export const DB_SCHEMA_REFRESH_MS = Number( ENV('DB_SCHEMA_REFRESH_MS', `${24 * 60 * 60 * 1000}`), ) -export const STORE_URL = ENV('STORE_URL') -export const STORE_SECRET = ENV('STORE_SECRET') +export const STORE_URL = ENV('STORE_URL', '') +export const STORE_SECRET = ENV('STORE_SECRET', '') +const LOCAL_ENV = ENV('LOCAL_ENV', '') +export const isLocal = LOCAL_ENV === 'yes' || LOCAL_ENV === '1' || + LOCAL_ENV === 'true' export const GEMINI_API_KEY = ENV('GEMINI_API_KEY') diff --git a/api/lib/functions.ts b/api/lib/functions.ts index 6b7be0b..4fb002f 100644 --- a/api/lib/functions.ts +++ b/api/lib/functions.ts @@ -1,5 +1,5 @@ import { batch } from '/api/lib/json_store.ts' -import { join } from '@std/path' +import { join, toFileUrl } from '@std/path' import { ensureDir } from '@std/fs' import { log } from '/api/lib/logger.ts' @@ -36,13 +36,14 @@ export type LoadedFunction = { // Map const functionsMap = new Map() -let watcher: Deno.FsWatcher | null = null -const functionsDir = './db/functions' +const functionsDir = join(import.meta.dirname!, '../../db/functions') +const functionsDirUrl = toFileUrl( + functionsDir.endsWith('/') ? functionsDir : `${functionsDir}/`, +) export async function init() { await ensureDir(functionsDir) await loadAll() - startWatcher() } async function loadAll() { @@ -56,18 +57,15 @@ async function loadAll() { async function reloadProjectFunctions(slug: string) { const projectDir = join(functionsDir, slug) + const projectDirUrl = new URL(`${slug}/`, functionsDirUrl) const loaded: LoadedFunction[] = [] try { await batch(5, Deno.readDir(projectDir), async (entry) => { if (entry.isFile && entry.name.endsWith('.js')) { - const mainFile = join(projectDir, entry.name) - // Build a fresh import URL to bust cache - const importUrl = `file://${await Deno.realPath( - mainFile, - )}?t=${Date.now()}` + const mainFileUrl = new URL(entry.name, projectDirUrl) try { - const module = await import(importUrl) + const module = await import(`${mainFileUrl.href}?t=${Date.now()}`) // We expect a default export or specific named exports const fns = module.default if (fns && typeof fns === 'object') { @@ -106,40 +104,12 @@ async function reloadProjectFunctions(slug: string) { } } -function startWatcher() { - if (watcher) return - log.info('starting-function-watcher', { dir: functionsDir }) - watcher = Deno.watchFs(functionsDir, { recursive: true }) // Process events - ;(async () => { - for await (const event of watcher!) { - if (!['modify', 'create', 'remove', 'rename'].includes(event.kind)) { - continue - } - for (const path of event.paths) { - if (!path.endsWith('.js')) continue - const parts = path.split('/') - const fileName = parts.pop() - const slug = parts.pop() - if (!fileName || !slug) continue - await reloadProjectFunctions(slug) - } - } - })() -} - export function getProjectFunctions( slug: string, ): LoadedFunction[] | undefined { return functionsMap.get(slug) } -export function stopWatcher() { - if (watcher) { - watcher.close() - watcher = null - } -} - export async function applyReadTransformers( data: T, projectId: string, diff --git a/api/lib/functions_test.ts b/api/lib/functions_test.ts index 8914422..05bb365 100644 --- a/api/lib/functions_test.ts +++ b/api/lib/functions_test.ts @@ -105,5 +105,4 @@ Deno.test('Functions Module - Pipeline & Config', async () => { // Skipped } await new Promise((r) => setTimeout(r, 500)) - functions.stopWatcher() }) diff --git a/api/lib/local_ipc.ts b/api/lib/local_ipc.ts new file mode 100644 index 0000000..cda599c --- /dev/null +++ b/api/lib/local_ipc.ts @@ -0,0 +1,154 @@ +import { TextLineStream } from '@std/streams/text-line-stream' +import { PORT } from '/api/lib/env.ts' +import { DeploymentsCollection, ProjectsCollection } from '/api/schema.ts' + +const defaultSocketPath = Deno.build.os === 'windows' + ? '\\\\.\\pipe\\01-devtools' + : `${Deno.env.get('XDG_RUNTIME_DIR') || '/tmp'}/01-devtools.sock` + +const encoder = new TextEncoder() + +async function removeSocket(path: string) { + if (Deno.build.os === 'windows') return + try { + await Deno.remove(path) + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error + } +} + +async function sendCommand(socketPath: string, command: string) { + try { + const conn = await Deno.connect({ transport: 'unix', path: socketPath }) + await conn.write(encoder.encode(`${command}\n`)) + const reader = conn.readable + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()) + .getReader() + const { value } = await reader.read() + reader.releaseLock() + conn.close() + return value ? JSON.parse(value) : null + } catch { + return null + } +} + +type JSONPrimitive = string | number | boolean | null +type JSONValue = JSONPrimitive | JSONObject | JSONArray +interface JSONObject { + [member: string]: JSONValue +} +interface JSONArray extends Array {} + +const commands: Record< + string, + (arg: string) => Promise | JSONObject +> = { + info: () => ({ pid: Deno.pid, port: PORT }), + + register: async (arg: string): Promise => { + try { + const { + projectId, + name, + url, + logsEnabled, + databaseEnabled, + sqlEndpoint, + sqlToken, + } = JSON.parse(arg) + + if (!projectId || !url) { + return { + error: 'Usage: register/{"projectId":"...","url":"..."...}', + } + } + + // Create or update project + const projectName = name || projectId + const existingProject = ProjectsCollection.get(projectId) + if (!existingProject) { + await ProjectsCollection.insert({ + slug: projectId, + name: projectName, + teamId: 'local', + isPublic: true, + repositoryUrl: null, + }) + } + + // Create or update deployment + const existingDeployment = DeploymentsCollection.get(url) + if (existingDeployment) { + await DeploymentsCollection.update(url, { + projectId, + logsEnabled: logsEnabled ?? true, + databaseEnabled: databaseEnabled ?? false, + sqlEndpoint: sqlEndpoint || undefined, + sqlToken: sqlToken || 'local', + tokenSalt: crypto.randomUUID(), + }) + } else { + await DeploymentsCollection.insert({ + projectId, + url, + logsEnabled: logsEnabled ?? true, + databaseEnabled: databaseEnabled ?? false, + sqlEndpoint: sqlEndpoint || undefined, + sqlToken: sqlToken || 'local', + tokenSalt: crypto.randomUUID(), + }) + } + + return { pid: Deno.pid, port: PORT } + } catch (err) { + console.error(err) + return { error: (err as Error)?.message || String(err) } + } + }, + + _: () => ({ error: 'Command not found' }), +} + +async function handleConn(conn: Deno.Conn) { + const reader = conn.readable + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()) + .getReader() + const { value = '' } = await reader.read() + reader.releaseLock() + const [name] = value.split('/', 1) + const arg = value.slice((name?.length || 0) + 1) || '' + const cmd = commands[name] || commands._ + await conn.write(encoder.encode(JSON.stringify(await cmd(arg)) + '\n')) + conn.close() +} + +async function acceptLoop(listener: Deno.Listener) { + try { + for await (const conn of listener) void handleConn(conn) + } catch (error) { + if (!(error instanceof Deno.errors.BadResource)) throw error + } +} + +export async function startRegistryServer(socketPath = defaultSocketPath) { + const existing = await sendCommand(socketPath, 'info') + if (existing) { + console.info( + `devtools already started here pid=${existing.pid} port=${existing.port}`, + ) + Deno.exit(0) + } + + await removeSocket(socketPath) + const listener = Deno.listen({ transport: 'unix', path: socketPath }) + void acceptLoop(listener) + return { + close: () => { + listener.close() + return removeSocket(socketPath) + }, + } +} diff --git a/api/lib/local_ipc_test.ts b/api/lib/local_ipc_test.ts new file mode 100644 index 0000000..9f65aa7 --- /dev/null +++ b/api/lib/local_ipc_test.ts @@ -0,0 +1,77 @@ +import { assert, assertEquals } from '@std/assert' +import { TextLineStream } from '@std/streams/text-line-stream' +import { startLocalServer } from './local_ipc.ts' + +const encoder = new TextEncoder() + +async function sendCommand(path: string, command: string) { + const conn = await Deno.connect({ transport: 'unix', path }) + await conn.write(encoder.encode(`${command}\n`)) + const reader = conn.readable + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()) + .getReader() + const { value } = await reader.read() + reader.releaseLock() + conn.close() + return JSON.parse(value!) +} + +Deno.test('local ipc server returns current pid and port', async () => { + const endpoint = Deno.build.os === 'windows' + ? `\\\\.\\pipe\\devtools-test-${crypto.randomUUID()}` + : `${await Deno.makeTempDir()}/devtools.sock` + + const server = await startLocalServer(endpoint) + + try { + assert(server) + assertEquals(await sendCommand(endpoint, 'info'), { + pid: Deno.pid, + port: 3021, + }) + } finally { + await server?.close() + if (Deno.build.os !== 'windows') { + await Deno.remove(endpoint.slice(0, endpoint.lastIndexOf('/')), { + recursive: true, + }) + } + } +}) + +Deno.test('register stores app info from github remote', async () => { + const endpoint = Deno.build.os === 'windows' + ? `\\\\.\\pipe\\devtools-test-${crypto.randomUUID()}` + : `${await Deno.makeTempDir()}/devtools.sock` + const repo = await Deno.makeTempDir() + + const server = await startLocalServer(endpoint) + + try { + assert(server) + await new Deno.Command('git', { + args: ['-C', repo, 'init'], + stdout: 'null', + stderr: 'null', + }).output() + await new Deno.Command('git', { + args: ['-C', repo, 'remote', 'add', 'origin', 'git@github.com:org/repo.git'], + stdout: 'null', + stderr: 'null', + }).output() + + assertEquals( + await sendCommand(endpoint, `register/${JSON.stringify({ pid: 123, path: repo})}`), + { pid: 123, path: repo, name: 'org/repo' }, + ) + } finally { + await server?.close() + if (Deno.build.os !== 'windows') { + await Deno.remove(endpoint.slice(0, endpoint.lastIndexOf('/')), { + recursive: true, + }) + } + await Deno.remove(repo, { recursive: true }) + } +}) diff --git a/api/lib/router.md b/api/lib/router.md deleted file mode 100644 index 83e0731..0000000 --- a/api/lib/router.md +++ /dev/null @@ -1,152 +0,0 @@ -# TypeScript Router - -A type-safe HTTP router with built-in validation and API documentation -generation. - -## Features - -- 🔒 Type-safe request/response handling -- ✅ Built-in input/output validation -- 📝 Automatic API documentation generation -- 🎯 Support for all standard HTTP methods -- 🔄 Async request handling -- 🍪 Cookie and session management -- ⚡ High-precision request timing - -## Basic Usage - -```typescript -import { NUM, OBJ, router, STR } from '@your-package/router' -// Define your routes -const api = router({ - 'GET/users': { - fn: (input, ctx) => ({ id: input.id, name: 'John' }), - description: 'Get user by ID', - input: OBJ({ id: NUM('User ID') }), - output: OBJ({ - id: NUM('User ID'), - name: STR('User name'), - }), - }, -}) -// Use with your server -server.handle('/api/', api) -``` - -## Validation - -The router includes a powerful validation system: - -```typescript -// Define a schema -const UserSchema = OBJ({ - name: STR("User's full name"), - age: NUM("User's age"), - tags: ARR(STR('Tag name'), 'User tags'), - settings: optional( - OBJ({ - theme: STR('UI theme'), - notifications: BOOL('Notification preferences'), - }), - ), -}) -// Use in route definition -const api = router({ - 'POST/users': { - fn: async (input) => { - // input is fully typed and validated - return { id: 1, ...input } - }, - input: UserSchema, - output: OBJ({ - id: NUM('User ID'), - ...UserSchema.properties, - }), - }, -}) -``` - -## API Documentation - -The router automatically generates interactive HTML documentation for your API: - -```typescript -import { generateApiDocs } from '@your-package/router' -const docs = generateApiDocs({ - 'POST/users': { - fn: async (input) => { - // input is fully typed and validated - return { id: 1, ...input } - }, - input: UserSchema, - output: OBJ({ - id: NUM('User ID'), - ...UserSchema.properties, - }), - }, -}) -server.handle('/docs', docs) -``` - -The documentation includes: - -- Method and path information -- Request/response schemas -- Interactive examples -- Search and filtering -- Dark/light mode support - -## Error Handling - -The router provides built-in error handling: - -```typescript -// Validation errors return 400 -// Example response: -{ -error: "Validation Error", -failures: [ -{ -path: ["age"], -type: "number", -value: "not a number" -} -] -} -// Not found returns 404 -{ -error: "Not Found" -} -// Method not allowed returns 405 -{ -error: "Method Not Allowed" -} -``` - -## Context and Sessions - -Each request handler receives a context object: - -```typescript -interface Context { - readonly session: Record // Cookie data - readonly trace: number // Request trace ID - readonly span?: number // Request timing -} -const api = router({ - 'GET/profile': { - fn: (input, ctx) => { - // Access session data - const userId = ctx.session.userId - // Use timing information - const requestTime = ctx.span - ctx.trace - return { userId, requestTime } - }, - input: OBJ({}), - output: OBJ({ - userId: STR(), - requestTime: NUM(), - }), - }, -}) -``` diff --git a/api/routes.ts b/api/routes.ts index 16328c2..8f5d5b0 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -12,7 +12,7 @@ import { TeamDetailDef, User, UserDef, -} from './schema.ts' +} from '/api/schema.ts' import { ARR, BOOL, @@ -40,6 +40,7 @@ import { SQLQueryError, updateTableData, } from '/api/sql.ts' +import { isLocal } from '/api/lib/env.ts' import { get, getOne } from './lmdb-store.ts' import { log } from '/api/lib/logger.ts' import { analyzeQueryWithAI } from '/api/fix-query.ts' @@ -70,15 +71,25 @@ const MetricSchema = OBJ({ }, 'SQLite sqlite3_stmt_status counters'), }) -const withUserSession = async ({ cookies }: RequestContext) => { - const session = await decodeSession(cookies.session) - if (!session) { - log.warn('auth-missing-session') - throw new respond.UnauthorizedError({ message: 'Missing user session' }) +const localUser = { + id: 'local', // this id is for local env, it will ignore permissions + email: 'local@admin.dev', + fullName: 'Local Dev', + picture: '', + isAdmin: true, +} as const + +const withUserSession = isLocal + ? () => localUser + : async ({ cookies }: RequestContext) => { + const session = await decodeSession(cookies.session) + if (!session) { + log.warn('auth-missing-session') + throw new respond.UnauthorizedError({ message: 'Missing user session' }) + } + const admin = AdminsCollection.get(session.id) + return { ...session, isAdmin: !!admin } } - const admin = AdminsCollection.get(session.id) - return { ...session, isAdmin: !!admin } -} const withAdminSession = async (ctx: RequestContext) => { const session = await withUserSession(ctx) @@ -241,6 +252,8 @@ const defs = { 'GET/api/teams': route({ authorize: withUserSession, fn: async () => { + if (isLocal) return [{ id: 'local', name: 'Local', members: [] }] + const groups = await get<{ id: string; name: string }[]>( 'google/group', { @@ -271,6 +284,7 @@ const defs = { 'GET/api/team': route({ authorize: withUserSession, fn: async (_ctx, { id }) => { + if (isLocal) return { id: 'local', name: 'Local', members: [] } const group = await getOne<{ name: string }>('google/group', id) if (!group) throw new respond.NotFoundError({ message: 'Team not found' }) @@ -406,10 +420,7 @@ const defs = { const token = await encryptMessage( JSON.stringify({ url: deployment.url, tokenSalt }), ) - return { - ...deployment, - token, - } + return { ...deployment, token } }, input: OBJ({ url: STR('Deployment URL') }), output: deploymentOutput, @@ -420,10 +431,7 @@ const defs = { fn: async (_ctx, input) => { const tokenSalt = performance.now().toString() const { tokenSalt: _, ...deployment } = await DeploymentsCollection - .insert({ - ...input, - tokenSalt, - }) + .insert({ ...input, tokenSalt }) log.info('deployment-created', { url: deployment.url, projectId: deployment.projectId, @@ -431,10 +439,7 @@ const defs = { const token = await encryptMessage( JSON.stringify({ url: deployment.url, tokenSalt }), ) - return { - ...deployment, - token, - } + return { ...deployment, token } }, input: DeploymentDef, output: deploymentOutput, @@ -449,10 +454,7 @@ const defs = { const token = await encryptMessage( JSON.stringify({ url: deployment.url, tokenSalt }), ) - return { - ...deployment, - token, - } + return { ...deployment, token } }, input: DeploymentDef, output: deploymentOutput, diff --git a/api/server.ts b/api/server.ts index 596cef5..e406c2e 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,20 +1,21 @@ import { serveDir } from '@std/http/file-server' -import { APP_ENV } from '@01edu/api/env' import { server } from '@01edu/api/server' import { routeHandler } from '/api/routes.ts' -import { PORT } from './lib/env.ts' +import { APP_ENV, isLocal, PORT } from '/api/lib/env.ts' import { init } from '/api/lib/functions.ts' -import { startSchemaRefreshLoop } from './sql.ts' +import { initLogTable } from '/api/clickhouse-client.ts' +import { startRegistryServer } from '/api/lib/local_ipc.ts' +import { startSchemaRefreshLoop } from '/api/sql.ts' import { log } from '/api/lib/logger.ts' +await initLogTable() await init() startSchemaRefreshLoop() +isLocal && (await startRegistryServer()) const fetch = server({ log, routeHandler }) export default { - fetch(req: Request) { - return fetch(req, new URL(req.url)) - }, + fetch: (req: Request) => fetch(req, new URL(req.url)), } if (APP_ENV === 'prod') { diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..70bfcb8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,59 @@ +services: + clickhouse: + image: clickhouse/clickhouse-server:latest + restart: unless-stopped + environment: + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-devtools} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-devtools-password} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: ${CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT:-1} + ports: + - '8123:8123' + - '9000:9000' + ulimits: + nofile: + soft: 262144 + hard: 262144 + volumes: + - clickhouse-data:/var/lib/clickhouse + healthcheck: + test: + [ + 'CMD-SHELL', + "clickhouse-client --host 127.0.0.1 --user \"$${CLICKHOUSE_USER}\" --password \"$${CLICKHOUSE_PASSWORD}\" --query 'SELECT 1' >/dev/null 2>&1", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + + app: + build: + context: . + restart: unless-stopped + environment: + BASE_URL: '/' + APP_ENV: 'prod' + PORT: ${PORT:-3021} + PICTURE_DIR: ${PICTURE_DIR:-/app/.picture} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + CLIENT_SECRET: ${CLIENT_SECRET:-} + REDIRECT_URI: ${REDIRECT_URI:-http://localhost:3021/auth/callback} + SECRET: ${SECRET:-iUokBru8WPSMAuMspijlt7F-Cnpqyg84F36b1G681h0} + STORE_URL: ${STORE_URL:-} + STORE_SECRET: ${STORE_SECRET:-} + CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-http://clickhouse:8123} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-devtools} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-devtools-password} + LOCAL_ENV: yes + depends_on: + clickhouse: + condition: service_healthy + ports: + - '${PORT:-3021}:3021' + volumes: + - ./db:/app/db + - app-pictures:/app/.picture + +volumes: + clickhouse-data: + app-pictures: diff --git a/deno.json b/deno.json index 09b72af..dfe61fc 100644 --- a/deno.json +++ b/deno.json @@ -3,9 +3,8 @@ "tasks": { "all": "deno task check && deno task lint && deno task test --parallel", "check": "deno check", - "dev": { "dependencies": ["dev:clickhouse", "dev:api", "dev:vite"] }, + "dev": { "dependencies": ["dev:api", "dev:vite"] }, "dev:api": "deno serve --port 3021 -A --env-file=.env.dev api/server.ts", - "dev:clickhouse": "deno run -A --env-file=.env.dev tasks/clickhouse.ts", "dev:env": "deno run -A tasks/env.ts", "dev:vite": "deno run -A --env-file=.env.dev tasks/vite.ts", "dev:with-seed": "deno task seed && deno task dev", @@ -21,8 +20,8 @@ "fmt": "deno fmt", "lint": "deno lint", "prod": "deno task prod:vite && deno task prod:api", - "prod:api": "deno compile -A --no-check --output dist/api --target x86_64-unknown-linux-gnu --include dist/web api/server.ts --env=prod", - "prod:clickhouse": "APP_ENV=prod deno run -A --env-file tasks/clickhouse.ts", + "prod:api:bundle": "mkdir -p dist && deno bundle api/server.ts -o dist/server.bundle.js", + "prod:api": "deno task prod:api:bundle && deno compile -c deno.json -A --no-check --output dist/api --target x86_64-unknown-linux-gnu --include dist/web --include db/functions dist/server.bundle.js --env=prod", "prod:start": "deno task clickhouse:prod && dist/api", "prod:vite": "BASE_URL=/ APP_ENV=prod deno run -A tasks/vite.ts", "review": "deno run -A https://gistcdn.githack.com/kigiri/7658b4af30bb5eaca3e4cad1fcac7b0c/raw/review.js", @@ -45,6 +44,7 @@ "@std/fs": "jsr:@std/fs@^1.0.23", "@std/http": "jsr:@std/http@^1.1.0", "@std/path": "jsr:@std/path@^1.1.4", + "@std/streams": "jsr:@std/streams@^1.1.0", "@std/testing": "jsr:@std/testing@^1.0.18", "vite": "npm:vite@^8.0.13", "preact": "npm:preact@^10.29.2", @@ -53,7 +53,7 @@ "@clickhouse/client": "npm:@clickhouse/client@^1.18.5", "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.3.0", "tailwindcss": "npm:tailwindcss@^4.3.0", - "daisyui": "npm:daisyui@^5.5.19", + "daisyui": "npm:daisyui@^5.5.20", "lucide-preact": "npm:lucide-preact@^1.16.0", "@deno/gfm": "jsr:@deno/gfm@0.12.0" }, diff --git a/deno.lock b/deno.lock index 3d15a9b..d90f316 100644 --- a/deno.lock +++ b/deno.lock @@ -11,6 +11,8 @@ "jsr:@denosaurs/emoji@~0.3.1": "0.3.1", "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@std/assert@^1.0.19": "1.0.19", + "jsr:@std/async@^1.3.0": "1.3.0", + "jsr:@std/bytes@^1.0.6": "1.0.6", "jsr:@std/cli@^1.0.29": "1.0.29", "jsr:@std/crypto@^1.1.0": "1.1.0", "jsr:@std/data-structures@^1.0.11": "1.0.11", @@ -39,10 +41,10 @@ "npm:@preact/preset-vite@^2.10.5": "2.10.5_vite@8.0.13_preact@10.29.2", "npm:@preact/signals@^2.9.0": "2.9.0_preact@10.29.2", "npm:@tailwindcss/vite@^4.3.0": "4.3.0_vite@8.0.13", - "npm:daisyui@^5.5.19": "5.5.19", + "npm:daisyui@^5.5.20": "5.5.20", "npm:github-slugger@2": "2.0.0", "npm:he@^1.2.0": "1.2.0", - "npm:katex@0.16": "0.16.46", + "npm:katex@0.16": "0.16.47", "npm:lucide-preact@^1.16.0": "1.16.0_preact@10.29.2", "npm:marked-alert@^2.1.2": "2.1.2_marked@17.0.6", "npm:marked-footnote@^1.4.0": "1.4.0_marked@17.0.6", @@ -127,6 +129,12 @@ "jsr:@std/internal@^1.0.12" ] }, + "@std/async@1.3.0": { + "integrity": "80485538a4f7baaa46bfe2246168069e02ed142b9f9079cd164f43bb060ad9e9" + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, "@std/cli@1.0.29": { "integrity": "fa4ef29130baa834d8a13b7d138240c3a2fcfba740bfb7afa646a360a15ec84f" }, @@ -185,12 +193,16 @@ ] }, "@std/streams@1.1.0": { - "integrity": "2f7024d841f343fd478afe0c958a3f0f068ef2a0d2bcc954f550f97ac1fa22e3" + "integrity": "2f7024d841f343fd478afe0c958a3f0f068ef2a0d2bcc954f550f97ac1fa22e3", + "dependencies": [ + "jsr:@std/bytes" + ] }, "@std/testing@1.0.18": { "integrity": "d3152f57b11666bf6358d0e127c7e3488e91178b0c2d8fbf0793e1c53cd13cb1", "dependencies": [ "jsr:@std/assert", + "jsr:@std/async", "jsr:@std/data-structures", "jsr:@std/fs@^1.0.23", "jsr:@std/internal@^1.0.13", @@ -214,8 +226,8 @@ "picocolors" ] }, - "@babel/compat-data@7.29.3": { - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==" + "@babel/compat-data@7.29.0": { + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==" }, "@babel/core@7.29.0": { "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", @@ -301,8 +313,8 @@ "@babel/types" ] }, - "@babel/parser@7.29.3": { - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "@babel/parser@7.29.2": { + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dependencies": [ "@babel/types" ], @@ -431,12 +443,12 @@ "integrity": "sha512-St6yKggjFGhxS52IFLJWvkchRFbAKg2Xh8UxA4S1EGz7GJ2Ui+ssDDldj/w2c8vCxvl6qgR0HaYbKeFJNqujmA==", "tarball": "https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.6.tgz" }, - "@jsr/std__json@1.1.0": { - "integrity": "sha512-a9Eylh7ox33tFn5RxhESnvI4akpefQP5Qn+ePz3Ih4IJ/EPA8p0SKq0aztRfN1sGDhfyWMPWwlUcwNxONt+Ncg==", + "@jsr/std__json@1.0.3": { + "integrity": "sha512-hxHx1j4Wd4une+SHlpsSchKIPwtmP4naETNaQYqqY2iBawZPkcR7oiZBFwKcdhzjUWqMw5wV6ryZSI4d4YNzbQ==", "dependencies": [ "@jsr/std__streams" ], - "tarball": "https://npm.jsr.io/~/11/@jsr/std__json/1.1.0.tgz" + "tarball": "https://npm.jsr.io/~/11/@jsr/std__json/1.0.3.tgz" }, "@jsr/std__jsonc@1.0.2": { "integrity": "sha512-Lva9lY0UPvvgmS8INUHU8Tqijq2SPfdlnSAvofOYUBiB6ALPxo0rTMznRlY5Wt30nMW+rhMTha1x5RGpMBKUoQ==", @@ -445,12 +457,12 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__jsonc/1.0.2.tgz" }, - "@jsr/std__streams@1.1.0": { - "integrity": "sha512-0yP/bIRAgcpdIg1o/XSYnVmCs3a7rtfKfhPPiKo7GFEtvF2qngWKJ80u8xN+D8o3otsrf/Ta90XiNj5WZJfFgg==", + "@jsr/std__streams@1.0.17": { + "integrity": "sha512-LnPlWk20mDIV5/nqoUomAB8umOimfGEyWRApxLgekXFuqKGDsGpUAi58amieVU2XAGNclmUOtQOcQ/qOl3PNFg==", "dependencies": [ "@jsr/std__bytes" ], - "tarball": "https://npm.jsr.io/~/11/@jsr/std__streams/1.1.0.tgz" + "tarball": "https://npm.jsr.io/~/11/@jsr/std__streams/1.0.17.tgz" }, "@napi-rs/wasm-runtime@1.1.4_@emnapi+core@1.10.0_@emnapi+runtime@1.10.0": { "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", @@ -493,8 +505,8 @@ "@prefresh/babel-plugin@0.5.3": { "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==" }, - "@prefresh/core@1.5.10_preact@10.29.2": { - "integrity": "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w==", + "@prefresh/core@1.5.9_preact@10.29.2": { + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", "dependencies": [ "preact" ] @@ -708,14 +720,14 @@ "vite" ] }, - "@tybys/wasm-util@0.10.2": { - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "@tybys/wasm-util@0.10.1": { + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dependencies": [ "tslib" ] }, - "@types/estree@1.0.9": { - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, "babel-plugin-transform-hook-names@1.0.2_@babel+core@7.29.0": { "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", @@ -723,8 +735,8 @@ "@babel/core" ] }, - "baseline-browser-mapping@2.10.29": { - "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "baseline-browser-mapping@2.10.13": { + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", "bin": true }, "boolbase@1.0.0": { @@ -741,8 +753,8 @@ ], "bin": true }, - "caniuse-lite@1.0.30001792": { - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==" + "caniuse-lite@1.0.30001784": { + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==" }, "commander@8.3.0": { "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" @@ -763,8 +775,8 @@ "css-what@6.2.2": { "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" }, - "daisyui@5.5.19": { - "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==" + "daisyui@5.5.20": { + "integrity": "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ==" }, "dayjs@1.11.20": { "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==" @@ -806,11 +818,11 @@ "domhandler" ] }, - "electron-to-chromium@1.5.355": { - "integrity": "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==" + "electron-to-chromium@1.5.330": { + "integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==" }, - "enhanced-resolve@5.21.3": { - "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "enhanced-resolve@5.21.5": { + "integrity": "sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==", "dependencies": [ "graceful-fs", "tapable" @@ -885,8 +897,8 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, - "katex@0.16.46": { - "integrity": "sha512-WHy4Coo+bGZyH7NwJKHkS04YFsFcarWbAEOAC3EMndzdN6VSZqklLLIgfxzyaW9jDoeGYJX9SWbJPKpecox0Uw==", + "katex@0.16.47": { + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "dependencies": [ "commander" ], @@ -1030,8 +1042,8 @@ "he" ] }, - "node-releases@2.0.44": { - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==" + "node-releases@2.0.36": { + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, "nth-check@2.1.1": { "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", @@ -1051,8 +1063,8 @@ "picomatch@4.0.4": { "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==" }, - "postcss@8.5.14": { - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "postcss@8.5.15": { + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dependencies": [ "nanoid", "picocolors", @@ -1118,8 +1130,8 @@ "source-map@0.7.6": { "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==" }, - "stack-trace@1.0.0": { - "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==" + "stack-trace@1.0.0-pre2": { + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==" }, "tailwindcss@4.3.0": { "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==" @@ -1179,9 +1191,6 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" } }, - "remote": { - "https://gistcdn.githack.com/kigiri/21df06d173fcdced5281b86ba6ac1382/raw/crypto.js": "e85976e655898538dbade9d87b05ca0a6bb167b3128cd4098622000a582f5f6d" - }, "workspace": { "dependencies": [ "jsr:@01edu/api-client@~0.2.6", @@ -1196,6 +1205,7 @@ "jsr:@std/fs@^1.0.23", "jsr:@std/http@^1.1.0", "jsr:@std/path@^1.1.4", + "jsr:@std/streams@^1.1.0", "jsr:@std/testing@^1.0.18", "npm:@01edu/signal-router@~0.2.3", "npm:@clickhouse/client@^1.18.5", @@ -1203,7 +1213,7 @@ "npm:@preact/preset-vite@^2.10.5", "npm:@preact/signals@^2.9.0", "npm:@tailwindcss/vite@^4.3.0", - "npm:daisyui@^5.5.19", + "npm:daisyui@^5.5.20", "npm:lucide-preact@^1.16.0", "npm:preact@^10.29.2", "npm:tailwindcss@^4.3.0", diff --git a/tasks/clickhouse.ts b/tasks/clickhouse.ts deleted file mode 100644 index d147dde..0000000 --- a/tasks/clickhouse.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { client } from '/api/clickhouse-client.ts' - -if (import.meta.main) { - try { - await client.ping() - - await client.command({ - query: ` - CREATE TABLE IF NOT EXISTS logs ( - id UUID DEFAULT generateUUIDv4(), - -- Flattened resource fields - service_name LowCardinality(String), - service_version LowCardinality(String), - service_instance_id String, - - timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'), - observed_timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'), - trace_id FixedString(16), - span_id FixedString(16), - severity_number UInt8, - -- derived column, computed by DB from severity_number - severity_text LowCardinality(String) MATERIALIZED CASE - WHEN severity_number > 4 AND severity_number <= 8 THEN 'DEBUG' - WHEN severity_number > 8 AND severity_number <= 12 THEN 'INFO' - WHEN severity_number > 12 AND severity_number <= 16 THEN 'WARN' - WHEN severity_number > 20 AND severity_number <= 24 THEN 'FATAL' - ELSE 'ERROR' - END, - -- Often empty, but kept for OTEL spec compliance - body Nullable(String), - attributes JSON, - event_name LowCardinality(String) - ) - ENGINE = MergeTree - PARTITION BY toYYYYMMDD(timestamp) - ORDER BY (service_name, timestamp, trace_id) - SETTINGS index_granularity = 8192, min_bytes_for_wide_part = 0; - `, - }) - - console.log('logs table is ready') - } catch (error) { - console.error('Error creating ClickHouse table:', { error }) - Deno.exit(1) - } -} diff --git a/tasks/vite.ts b/tasks/vite.ts index 0fb51f7..b7ed947 100644 --- a/tasks/vite.ts +++ b/tasks/vite.ts @@ -1,11 +1,10 @@ // tasks/vite.js import { join } from 'node:path' -import { AliasOptions, build, createServer } from 'vite' +import { build, createServer } from 'vite' import { apiProxy } from '@01edu/api-proxy' import deno from '@deno/vite-plugin' import preact from '@preact/preset-vite' import tailwindcss from '@tailwindcss/vite' -import { APP_ENV } from '@01edu/api/env' const plugins = [ preact({ jsxImportSource: 'preact' }), @@ -13,27 +12,27 @@ const plugins = [ deno(), ] -const alias: AliasOptions = [ +const BASE_URL = Deno.env.get('BASE_URL') || '/' +const preactRuntimeAlias = [ { - find: 'npm:@preact/signals@^2.5.1', - replacement: '@preact/signals', + find: /^npm:preact(?:@[^/]+)?\/jsx-runtime$/, + replacement: 'preact/jsx-runtime', }, { - find: 'npm:preact@^10.27.2', - replacement: 'preact', + find: /^npm:preact(?:@[^/]+)?\/jsx-dev-runtime$/, + replacement: 'preact/jsx-dev-runtime', }, ] + // Production build -if (APP_ENV === 'prod') { +if (Deno.env.get('APP_ENV') === 'prod') { await build({ configFile: false, root: join(import.meta.dirname!, '../web'), plugins, - resolve: { alias }, - build: { - outDir: '../dist/web', - emptyOutDir: true, - }, + base: BASE_URL, + resolve: { alias: preactRuntimeAlias }, + build: { outDir: '../dist/web', emptyOutDir: true }, }) Deno.exit(0) } @@ -43,12 +42,10 @@ const PORT = Number(Deno.env.get('PORT')) || 2119 const server = await createServer({ configFile: false, root: join(import.meta.dirname!, '../web'), + base: BASE_URL, plugins: [...plugins, apiProxy({ port: PORT, prefix: '/api/' })], - resolve: { alias }, - server: { - port: 7737, - host: true, - }, + resolve: { alias: preactRuntimeAlias }, + server: { port: 7737, host: true }, }) await server.listen() server.printUrls() diff --git a/web/components/BrandIcons.tsx b/web/components/BrandIcons.tsx deleted file mode 100644 index f8c410b..0000000 --- a/web/components/BrandIcons.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { SVGAttributes } from 'preact' -// Copy path / name from: https://simpleicons.org/ - -type SVGProps = SVGAttributes -const Icon = (title: string, path: string) => (props: SVGProps) => ( - - {title} - - -) - -export const GitHub = Icon( - 'GibHub', - 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', -) diff --git a/web/components/Layout.tsx b/web/components/Layout.tsx index 32f2307..169c86f 100644 --- a/web/components/Layout.tsx +++ b/web/components/Layout.tsx @@ -1,8 +1,6 @@ -import { JSX } from 'preact' +import { ComponentChildren } from 'preact' -export const PageLayout = ( - { children }: { children: JSX.Element | JSX.Element[] }, -) => ( +export const PageLayout = ({ children }: { children: ComponentChildren }) => (
{children} @@ -13,7 +11,7 @@ export const PageLayout = ( export const PageHeader = ( { children, class: className }: { class?: string - children: JSX.Element | JSX.Element[] + children: ComponentChildren }, ) => (
) -export const PageContent = ( - { children }: { children: JSX.Element | JSX.Element[] }, -) => ( +export const PageContent = ({ children }: { children: ComponentChildren }) => (
{children}
) diff --git a/web/components/SideBar.tsx b/web/components/SideBar.tsx index ba853c7..2e85035 100644 --- a/web/components/SideBar.tsx +++ b/web/components/SideBar.tsx @@ -5,8 +5,8 @@ import { LucideIcon, Settings, } from 'lucide-preact' -import { user } from '../lib/session.ts' import { A, url } from '@01edu/signal-router' +import { sidebarItems } from '../lib/shared.tsx' export type SidebarItem = { label: string @@ -15,10 +15,10 @@ export type SidebarItem = { } export function Sidebar( - { sidebarItems, sbi, title }: { - sidebarItems: Record + { sbi, title, isAdmin }: { sbi?: string title?: string + isAdmin?: boolean }, ) { const sb = url.params.sb @@ -70,7 +70,7 @@ export function Sidebar( params={{ sbi: 'settings' }} replace class={`rounded p-2 w-full flex items-center gap-2 ${ - user.data?.isAdmin + isAdmin ? 'settings' === sbi ? 'bg-primary text-primary-content' : '' : 'opacity-50 pointer-events-none' }`} diff --git a/web/components/forms.tsx b/web/components/forms.tsx index 6e0fc47..c73be30 100644 --- a/web/components/forms.tsx +++ b/web/components/forms.tsx @@ -1,4 +1,8 @@ -import { JSX } from 'preact' +import type { + ButtonHTMLAttributes, + ComponentChildren, + InputHTMLAttributes, +} from 'preact' import { useId } from 'preact/hooks' import { A, LinkProps } from '@01edu/signal-router' @@ -7,7 +11,7 @@ export const Card = ( { children, title, description }: { title: string description?: string - children: JSX.Element | JSX.Element[] + children: ComponentChildren }, ) => (
@@ -27,7 +31,7 @@ export const Card = ( export const Input = ( { label, name, note, ...props }: & { label: string; name: string; note?: string } - & JSX.InputHTMLAttributes, + & InputHTMLAttributes, ) => { const id = useId() return ( @@ -52,7 +56,7 @@ export const Button = ( & { variant?: 'primary' | 'secondary' | 'danger' } - & Omit, 'class' | 'style'> + & Omit, 'class' | 'style'> & Partial, ) => { const baseClasses = @@ -94,7 +98,7 @@ export const Switch = ( label: string note?: string // checked: boolean - } & JSX.InputHTMLAttributes, + } & InputHTMLAttributes, ) => { const id = useId() return ( diff --git a/web/index.tsx b/web/index.tsx index eee60ee..db5b716 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -7,31 +7,25 @@ import { user } from './lib/session.ts' import { url } from '@01edu/signal-router' import { ProjectPage } from './pages/ProjectPage.tsx' -const renderPage = () => { - if (user.pending) return - if (!user.data) { - return - } - if (url.path.startsWith('/projects/')) { - return - } +const Router = () => { + if (user.pending) return null + if (!user.data) return + if (url.path.startsWith('/projects/')) return return } -const App = () => { - return ( -
-
- -
-
-
-
-
- {renderPage()} -
+const App = () => ( +
+
+
- ) -} +
+
+
+
+ +
+
+) const root = document.getElementById('app') if (!root) throw new Error('Unable to find root element #app') diff --git a/web/layout.tsx b/web/layout.tsx index 741a20c..f6fd8d6 100644 --- a/web/layout.tsx +++ b/web/layout.tsx @@ -1,7 +1,6 @@ import { effect, signal } from '@preact/signals' import { A, url } from '@01edu/signal-router' import { Code, LogOut, Moon, Sun } from 'lucide-preact' -import { GitHub } from './components/BrandIcons.tsx' import { user } from './lib/session.ts' const $theme = signal(localStorage.theme || 'dark') @@ -16,7 +15,7 @@ const toggleTheme = () => { } const UserInfo = () => { - if (!user.data) return null + if (!user.data || user.data.id === 'local') return null return (
@@ -58,25 +57,16 @@ export const SwitchTheme = () => ( export const Header = () => (
) } diff --git a/web/pages/ProjectsPage.tsx b/web/pages/ProjectsPage.tsx index 9bc3628..12312b0 100644 --- a/web/pages/ProjectsPage.tsx +++ b/web/pages/ProjectsPage.tsx @@ -24,7 +24,6 @@ type Team = ApiOutput['GET/api/team'] const teams = api['GET/api/teams'].signal() teams.fetch() - const projects = api['GET/api/projects'].signal() projects.fetch()