● Zion Boggan zionboggan.com ↗

fix: get Phase 1 stack running live on CT250 (Docker)

Deploy-hardening from the first real bring-up on a dedicated container:

migrations:
- drops: remove non-immutable generated column (EXTRACT(EPOCH FROM timestamptz)
  is only STABLE); nothing read it.
- drop TimescaleDB hypertable conversions, the continuous-aggregate matview, and
  retention policies (they conflict with PKs/FKs and can't run in sqlx's migration
  transaction). Tables are plain Postgres; the backend's time_bucket() calls still
  work via the loaded extension. Documented to re-enable in Phase 7.

docker-compose:
- NATS: drop server-side user/pass (async-nats does not forward URL userinfo;
  the bus is internal-only on drophawk-net). Fixes auth-violation crash loop.
- ntfy healthcheck: match {"healthy":true} (was grepping "ok" -> false unhealthy).
- loki: run as root so it can create its rules dir.
- frontend/caddy depends_on backend+frontend relaxed to service_started (the
  distroless/slim images have no shell+wget, so their healthchecks false-negative).

Caddyfile:
- drop brotli from `encode` (not in stock caddy image); serve plain HTTP on :80
  for LAN-by-IP access (named-host labels forced an auto-HTTPS redirect).

Plus frontend compile fixes from the build pass (pnpm pin, Button asChild,
api qs overload, Radix version bumps, motion typing). Verified live:
http://REDACTED-IP serves the dashboard with real Ticketmaster data;
/api/health ok; migrations applied; scheduler polling Ticketmaster.
bb9a385   Zion Boggan committed on Jun 22, 2026 (1 month ago)
backend/Cargo.toml +2 -3
@@ -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]
backend/Dockerfile +6 -1
@@ -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 ----
backend/migrations/20260101000004_drops.sql +4 -2
@@ -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');
backend/migrations/20260101000006_notifications.sql +2 -1
@@ -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');
backend/migrations/20260101000007_timeseries.sql +7 -19
@@ -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.
backend/migrations/20260101000011_retention.sql +8 -4
@@ -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;
backend/src/store/drop.rs +18 -27
@@ -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 {
docker-compose.yml +11 -7
@@ -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
frontend/Dockerfile +1 -1
@@ -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 ---
frontend/components/ui/button.tsx +8 -2
@@ -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>
);
});
frontend/lib/api.ts +3 -1
@@ -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));
frontend/lib/motion.ts +2 -2
@@ -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 = {
frontend/package.json +6 -6
@@ -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"
},
infra/Caddyfile +3 -3
@@ -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 {
infra/ntfy.server.yml +1 -1
@@ -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