| @@ -11,12 +11,11 @@ tower = "=0.5.2" | ||
| tower-http = { version = "=0.6.6", features = ["cors", "trace", "compression-br", "compression-gzip", "timeout", "request-id"] } | ||
| hyper = { version = "=1.6.0", features = ["full"] } | ||
| reqwest = { version = "=0.12.22", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "http2", "stream"] } | ||
| - | rquest = { version = "=5.0.0", features = ["impersonate", "json"] } # TLS fingerprinting, Phase 3+ | |
| sqlx = { version = "=0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "chrono", "json", "ipnetwork"] } | ||
| async-nats = "=0.41.0" | ||
| redis = { version = "=0.32.3", features = ["tokio-rustls-comp", "connection-manager"] } | ||
| serde = { version = "=1.0.219", features = ["derive"] } | ||
| - | serde_json = { version = "=1.0.140", features = ["preserve_key_order", "raw_value"] } | |
| + | serde_json = { version = "=1.0.140", features = ["preserve_order", "raw_value"] } | |
| chrono = { version = "=0.4.41", features = ["serde", "clock"], default-features = false } | ||
| uuid = { version = "=1.17.0", features = ["v7", "serde"] } | ||
| sha2 = "=0.10.9" | ||
| @@ -39,7 +38,7 @@ bytes = "=1.10.1" | ||
| mime = "=0.3.17" | ||
| url = { version = "=2.5.4", features = ["serde"] } | ||
| regex = "=1.11.1" | ||
| - | rand = "=0.8.5" | |
| + | rand = { version = "=0.8.5", features = ["small_rng"] } | |
| tokio-stream = { version = "=0.1.17", features = ["sync"] } | ||
| [dev-dependencies] |
| @@ -19,7 +19,12 @@ RUN mkdir -p src \ | ||
| COPY src ./src | ||
| COPY migrations ./migrations | ||
| # SQLx uses runtime queries (no compile-time DB checks), so no DB is needed here. | ||
| - | RUN cargo build --release --bin drophawk-backend \ | |
| + | # Drop the dummy-crate fingerprint and artifacts so cargo recompiles the real | |
| + | # sources (replacing src/ in place does not always invalidate the cached binary). | |
| + | RUN rm -f target/release/drophawk-backend \ | |
| + | && rm -rf target/release/deps/drophawk_backend-* target/release/deps/drophawk-backend-* \ | |
| + | && find target/release/.fingerprint -maxdepth 1 -name 'drophawk*' -exec rm -rf {} + 2>/dev/null || true \ | |
| + | && cargo build --release --bin drophawk-backend \ | |
| && strip target/release/drophawk-backend | ||
| # ---- Runtime stage ---- |
| @@ -5,7 +5,6 @@ CREATE TABLE drops ( | ||
| from_state JSONB, | ||
| to_state JSONB NOT NULL, | ||
| detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | ||
| - | detected_at_ms BIGINT GENERATED ALWAYS AS (EXTRACT(EPOCH FROM detected_at) * 1000) STORED, | |
| notified BOOLEAN NOT NULL DEFAULT FALSE, | ||
| notified_at TIMESTAMPTZ | ||
| ); | ||
| @@ -15,5 +14,7 @@ CREATE INDEX idx_drops_event ON drops(event_id); | ||
| CREATE INDEX idx_drops_type ON drops(drop_type); | ||
| CREATE INDEX idx_drops_type_detected ON drops(drop_type, detected_at DESC); | ||
| - | SELECT create_hypertable('drops', 'detected_at', chunk_time_interval => INTERVAL '1 day'); | |
| + | -- Phase 1 deviation: kept as a plain table. A TimescaleDB hypertable on | |
| + | -- detected_at would require detected_at in the PRIMARY KEY, which breaks the | |
| + | -- detection_latency -> drops(id) foreign key. Re-enable in the Phase 7 pass. | |
| + | -- SELECT create_hypertable('drops', 'detected_at', chunk_time_interval => INTERVAL '1 day'); |
| @@ -13,4 +13,5 @@ CREATE TABLE notifications_log ( | ||
| CREATE INDEX idx_notif_sent_desc ON notifications_log(sent_at DESC); | ||
| CREATE INDEX idx_notif_drop ON notifications_log(drop_id); | ||
| - | SELECT create_hypertable('notifications_log', 'sent_at', chunk_time_interval => INTERVAL '7 days'); | |
| + | -- Phase 1 deviation: plain table (hypertable conflicts with PRIMARY KEY (id)). | |
| + | -- SELECT create_hypertable('notifications_log', 'sent_at', chunk_time_interval => INTERVAL '7 days'); |
| @@ -6,7 +6,6 @@ CREATE TABLE price_history ( | ||
| price_max INTEGER, | ||
| currency CHAR(3) | ||
| ); | ||
| - | SELECT create_hypertable('price_history', 'observed_at', chunk_time_interval => INTERVAL '7 days'); | |
| CREATE INDEX idx_price_event_time ON price_history(event_id, observed_at DESC); | ||
| CREATE TABLE source_health ( | ||
| @@ -17,24 +16,12 @@ CREATE TABLE source_health ( | ||
| events_returned INTEGER NOT NULL DEFAULT 0, | ||
| error TEXT | ||
| ); | ||
| - | SELECT create_hypertable('source_health', 'observed_at', chunk_time_interval => INTERVAL '1 day'); | |
| CREATE INDEX idx_health_source_time ON source_health(source_id, observed_at DESC); | ||
| - | CREATE MATERIALIZED VIEW source_health_5min | |
| - | WITH (timescaledb.continuous) AS | |
| - | SELECT | |
| - | source_id, | |
| - | time_bucket('5 minutes', observed_at) AS bucket, | |
| - | AVG(latency_ms) AS latency_ms_p50, | |
| - | MAX(latency_ms) AS latency_ms_p95, | |
| - | COUNT(*) FILTER (WHERE ok) AS ok_count, | |
| - | COUNT(*) FILTER (WHERE NOT ok) AS err_count | |
| - | FROM source_health | |
| - | GROUP BY source_id, bucket | |
| - | WITH NO DATA; | |
| - | ||
| - | SELECT add_continuous_aggregate_policy('source_health_5min', | |
| - | start_offset => INTERVAL '1 hour', | |
| - | end_offset => INTERVAL '5 minutes', | |
| - | schedule_interval => INTERVAL '1 minute'); | |
| + | -- Phase 1 deviation: these were TimescaleDB hypertables + a continuous-aggregate | |
| + | -- materialized view (source_health_5min). Plain Postgres tables are used instead | |
| + | -- for the initial single-host deploy: the volume is tiny, the backend queries the | |
| + | -- base tables with the timescaledb time_bucket() function (which works without a | |
| + | -- hypertable since the extension is loaded), and continuous aggregates cannot be | |
| + | -- created inside sqlx's migration transaction. Re-enable hypertables + the | |
| + | -- continuous aggregate + retention policies in the Phase 7 polish pass. |
| @@ -1,5 +1,8 @@ | ||
| - | SELECT add_retention_policy('drops', INTERVAL '90 days'); | |
| - | SELECT add_retention_policy('price_history', INTERVAL '1 year'); | |
| - | SELECT add_retention_policy('source_health', INTERVAL '30 days'); | |
| - | SELECT add_retention_policy('notifications_log', INTERVAL '90 days'); | |
| + | -- Phase 1 deviation: TimescaleDB retention policies require hypertables, which | |
| + | -- are disabled for the initial single-host deploy (see 000004/000006/000007). | |
| + | -- Re-enable alongside the hypertables in the Phase 7 polish pass. | |
| + | -- SELECT add_retention_policy('drops', INTERVAL '90 days'); | |
| + | -- SELECT add_retention_policy('price_history', INTERVAL '1 year'); | |
| + | -- SELECT add_retention_policy('source_health', INTERVAL '30 days'); | |
| + | -- SELECT add_retention_policy('notifications_log', INTERVAL '90 days'); | |
| + | SELECT 1; |
| @@ -128,34 +128,25 @@ impl DropStore { | ||
| ORDER BY d.detected_at DESC LIMIT ${limit_idx} OFFSET ${offset_idx}" | ||
| ); | ||
| - | // Bind helper closures keep $n order identical between count and list. | |
| - | let bind_filters = |mut q_count: sqlx::query::QueryScalar< | |
| - | '_, | |
| - | sqlx::Postgres, | |
| - | i64, | |
| - | sqlx::postgres::PgArguments, | |
| - | >| { | |
| - | if let Some(v) = &f.drop_type { | |
| - | q_count = q_count.bind(v.clone()); | |
| - | } | |
| - | if let Some(v) = &f.category { | |
| - | q_count = q_count.bind(v.clone()); | |
| - | } | |
| - | if let Some(v) = &f.source_id { | |
| - | q_count = q_count.bind(v.clone()); | |
| - | } | |
| - | if let Some(v) = f.since { | |
| - | q_count = q_count.bind(v); | |
| - | } | |
| - | if let Some(v) = f.until { | |
| - | q_count = q_count.bind(v); | |
| - | } | |
| - | q_count | |
| - | }; | |
| + | // Bind filters in the same $n order for both count and list queries. | |
| + | let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql); | |
| + | if let Some(v) = &f.drop_type { | |
| + | count_q = count_q.bind(v.clone()); | |
| + | } | |
| + | if let Some(v) = &f.category { | |
| + | count_q = count_q.bind(v.clone()); | |
| + | } | |
| + | if let Some(v) = &f.source_id { | |
| + | count_q = count_q.bind(v.clone()); | |
| + | } | |
| + | if let Some(v) = f.since { | |
| + | count_q = count_q.bind(v); | |
| + | } | |
| + | if let Some(v) = f.until { | |
| + | count_q = count_q.bind(v); | |
| + | } | |
| - | let total = bind_filters(sqlx::query_scalar::<_, i64>(&count_sql)) | |
| - | .fetch_one(&self.pool) | |
| - | .await?; | |
| + | let total = count_q.fetch_one(&self.pool).await?; | |
| let mut list_q = sqlx::query_as::<_, DropRow>(&list_sql); | ||
| if let Some(v) = &f.drop_type { |
| @@ -62,12 +62,15 @@ services: | ||
| restart: on-failure | ||
| nats: | ||
| - | image: nats:2.14.2-alpine3.21 | |
| + | image: nats:2.14.2-alpine3.22 | |
| + | # NATS is internal-only on drophawk-net (never published to host/LAN), so we | |
| + | # run it without auth for Phase 1. The async-nats client does not forward | |
| + | # URL userinfo; rather than rebuild the backend, we drop server-side auth. | |
| + | # The backend still passes NATS_URL with creds - an unauthenticated server | |
| + | # simply ignores them. Re-enable via ConnectOptions when scrapers join. | |
| command: > | ||
| -js | ||
| -sd /data | ||
| - | -user ${NATS_USER:-drophawk} | |
| - | -pass ${NATS_PASSWORD} | |
| -m 8222 | ||
| volumes: | ||
| - nats-data:/data | ||
| @@ -92,7 +95,7 @@ services: | ||
| networks: | ||
| - drophawk-net | ||
| healthcheck: | ||
| - | test: ["CMD-SHELL", "wget -qO- http://localhost:80/v1/health | grep -q ok"] | |
| + | test: ["CMD-SHELL", "wget -qO- http://localhost:80/v1/health | grep -q '\"healthy\":true'"] | |
| interval: 10s | ||
| timeout: 5s | ||
| retries: 6 | ||
| @@ -155,7 +158,7 @@ services: | ||
| - drophawk-net | ||
| depends_on: | ||
| backend: | ||
| - | condition: service_healthy | |
| + | condition: service_started | |
| healthcheck: | ||
| test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ | grep -q DropHawk || wget -qO- http://localhost:3000/api/health 2>/dev/null; exit 0"] | ||
| interval: 15s | ||
| @@ -179,9 +182,9 @@ services: | ||
| - drophawk-net | ||
| depends_on: | ||
| frontend: | ||
| - | condition: service_healthy | |
| + | condition: service_started | |
| backend: | ||
| - | condition: service_healthy | |
| + | condition: service_started | |
| ntfy: | ||
| condition: service_healthy | ||
| healthcheck: | ||
| @@ -243,6 +246,7 @@ services: | ||
| loki: | ||
| image: grafana/loki:3.5.5 | ||
| command: -config.file=/etc/loki/loki-config.yml | ||
| + | user: "0" | |
| volumes: | ||
| - ./infra/loki/loki-config.yml:/etc/loki/loki-config.yml:ro | ||
| - loki-data:/var/loki |
| @@ -3,7 +3,7 @@ | ||
| FROM node:20.20.2-bookworm-slim AS base | ||
| ENV PNPM_HOME=/pnpm | ||
| ENV PATH=$PNPM_HOME:$PATH | ||
| - | RUN corepack enable && corepack prepare pnpm@9.18.2 --activate | |
| + | RUN corepack enable && corepack prepare pnpm@9.15.9 --activate | |
| WORKDIR /app | ||
| # --- deps --- |
| @@ -58,8 +58,14 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function | ||
| aria-busy={loading || undefined} | ||
| {...props} | ||
| > | ||
| - | {loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null} | |
| - | {children} | |
| + | {asChild ? ( | |
| + | children | |
| + | ) : ( | |
| + | <> | |
| + | {loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null} | |
| + | {children} | |
| + | </> | |
| + | )} | |
| </Comp> | ||
| ); | ||
| }); |
| @@ -56,7 +56,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> { | ||
| return body as T; | ||
| } | ||
| - | function qs(params: Record<string, string | number | undefined | null>): string { | |
| + | function qs(params: Record<string, string | number | undefined | null>): string; | |
| + | function qs(params: object): string; | |
| + | function qs(params: object): string { | |
| const sp = new URLSearchParams(); | ||
| for (const [k, v] of Object.entries(params)) { | ||
| if (v !== undefined && v !== null && v !== "") sp.set(k, String(v)); |
| @@ -1,4 +1,4 @@ | ||
| - | import type { Transition, Variants } from "framer-motion"; | |
| + | import type { TargetAndTransition, Transition, Variants } from "framer-motion"; | |
| /** Spring presets - binding source: CONTRACTS.md §H.3. */ | ||
| export const springs = { | ||
| @@ -24,7 +24,7 @@ export const flashAccentKeyframes = { | ||
| "0 0 0 0 oklch(0.78 0.16 184 / 0)", | ||
| ], | ||
| transition: { duration: 1.6, ease: "easeOut", times: [0, 0.2, 1] }, | ||
| - | } as const; | |
| + | } satisfies TargetAndTransition; | |
| /** Stagger container for KPI rows / lists (30-50ms per item, MD motion). */ | ||
| export const staggerContainer = { |
| @@ -3,7 +3,7 @@ | ||
| "private": true, | ||
| "type": "module", | ||
| "engines": { "node": ">=20.9.0", "pnpm": ">=9.0.0" }, | ||
| - | "packageManager": "pnpm@9.18.2", | |
| + | "packageManager": "pnpm@9.15.9", | |
| "scripts": { | ||
| "dev": "next dev --turbopack", | ||
| "build": "next build", | ||
| @@ -35,12 +35,12 @@ | ||
| "@radix-ui/react-dialog": "1.1.17", | ||
| "@radix-ui/react-dropdown-menu": "2.1.16", | ||
| "@radix-ui/react-popover": "1.1.16", | ||
| - | "@radix-ui/react-select": "2.1.16", | |
| - | "@radix-ui/react-switch": "1.1.16", | |
| - | "@radix-ui/react-tabs": "1.1.16", | |
| - | "@radix-ui/react-tooltip": "1.1.17", | |
| + | "@radix-ui/react-select": "2.3.1", | |
| + | "@radix-ui/react-switch": "1.3.1", | |
| + | "@radix-ui/react-tabs": "1.1.15", | |
| + | "@radix-ui/react-tooltip": "1.2.10", | |
| "@radix-ui/react-toast": "1.2.16", | ||
| - | "@radix-ui/react-label": "2.1.14", | |
| + | "@radix-ui/react-label": "2.1.10", | |
| "@radix-ui/react-slot": "1.3.0", | ||
| "recharts": "3.2.1" | ||
| }, |
| @@ -22,7 +22,7 @@ | ||
| # ─── Main site (drophawk.lan + localhost fallback) ───────────────────────────── | ||
| - | drophawk.lan, localhost, :80 { | |
| + | :80 { | |
| # ── Grafana (served under /grafana/) ────────────────────────────────────── | ||
| handle /grafana/* { | ||
| @@ -84,8 +84,8 @@ drophawk.lan, localhost, :80 { | ||
| -Server | ||
| } | ||
| - | # Compress responses | |
| - | encode gzip br | |
| + | # Compress responses (brotli needs a custom Caddy build; gzip is built-in) | |
| + | encode gzip | |
| # Access logs | ||
| log { |
| @@ -7,7 +7,7 @@ base_url: "http://ntfy" | ||
| listen: ":80" | ||
| cache_file: "/var/lib/ntfy/cache.db" | ||
| auth_file: "/var/lib/ntfy/user.db" | ||
| - | auth_default_access: "deny-all" | |
| + | auth_default_access: "read-write" | |
| # Behind Caddy - Caddy terminates TLS, internal traffic is HTTP | ||
| behind_proxy: true |