diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 5ba0f06..cc6d706 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -115,8 +115,16 @@ jobs: docker image prune -af docker compose ps - # Annotate deploy in Grafana - GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null) + # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives + # in secrets.infra.env (decrypted by cd-infra.yml into the + # merged /opt/trails-cool/.env on the server). cd-apps's + # own app.env intentionally does NOT carry it — apps don't + # need it at runtime. So we read from the merged `.env` + # that cd-infra populated. If cd-infra has never run on + # this host, .env may not exist; the `2>/dev/null` and + # the `if -n` guard make the annotation a silent no-op + # rather than a deploy failure in that case. + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then docker compose exec -T grafana curl -sf -X POST \ -H "Authorization: Bearer $GRAFANA_TOKEN" \ diff --git a/apps/journal/app/lib/explore.integration.test.ts b/apps/journal/app/lib/explore.integration.test.ts index f8ad466..93f916f 100644 --- a/apps/journal/app/lib/explore.integration.test.ts +++ b/apps/journal/app/lib/explore.integration.test.ts @@ -80,12 +80,14 @@ describe.skipIf(!runIntegration)("explore.server integration", () => { expect(rows.find((r) => r.id === id)).toBeUndefined(); }); - it("demo persona is excluded from the directory", async () => { + it("demo persona is INCLUDED in the directory", async () => { const persona = loadPersona(); - // Insert a user with the persona's username — should still be filtered out. + // Insert a user with the persona's username — should appear like any + // other public user. The /explore loader is responsible for the + // demo-badge tagging at render time, not the directory query. const id = await makeUser({ username: persona.username }); const { rows } = await listDirectory({ page: 1, perPage: 50 }); - expect(rows.find((r) => r.id === id)).toBeUndefined(); + expect(rows.find((r) => r.id === id)).toBeDefined(); }); it("orders by most-recent public activity, NULLS LAST", async () => { diff --git a/apps/journal/app/lib/explore.server.ts b/apps/journal/app/lib/explore.server.ts index 69fb1a2..a604075 100644 --- a/apps/journal/app/lib/explore.server.ts +++ b/apps/journal/app/lib/explore.server.ts @@ -1,7 +1,6 @@ -import { and, count, desc, eq, gte, inArray, isNotNull, ne, sql } from "drizzle-orm"; +import { and, count, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, follows, users } from "@trails-cool/db/schema/journal"; -import { loadPersona } from "./demo-bot.server.ts"; import { localActorIri } from "./actor-iri.ts"; const DEFAULT_PAGE_SIZE = 20; @@ -42,13 +41,11 @@ function clampPage(raw: number | undefined): number { } function exclusionFilters() { - // Public-only and not the demo persona. Banned/suspended users would + // Public-only. The demo persona IS included on /explore — its whole + // purpose is to give new users a follow target, and the per-row demo + // badge in the UI signals what it is. Banned/suspended users would // be filtered here too once such a status column exists — see design.md. - const persona = loadPersona(); - return and( - eq(users.profileVisibility, "public"), - ne(users.username, persona.username), - ); + return eq(users.profileVisibility, "public"); } /** diff --git a/apps/journal/app/routes/explore.tsx b/apps/journal/app/routes/explore.tsx index a773005..1479acb 100644 --- a/apps/journal/app/routes/explore.tsx +++ b/apps/journal/app/routes/explore.tsx @@ -10,6 +10,7 @@ import { listActiveRecently, listDirectory, } from "~/lib/explore.server"; +import { loadPersona } from "~/lib/demo-bot.server"; import { FollowButton } from "~/components/FollowButton"; const BIO_TRUNCATE = 120; @@ -44,6 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) { : new Map(); const isSelf = (rowId: string) => viewer?.id === rowId; + const personaUsername = loadPersona().username; const decorate = (row: typeof allRows[number]) => ({ id: row.id, @@ -53,6 +55,7 @@ export async function loader({ request }: Route.LoaderArgs) { followerCount: followerCounts.get(row.id) ?? 0, followState: followStates.get(row.id) ?? null, isSelf: isSelf(row.id), + isDemoUser: row.username === personaUsername, }); // Resolved page size (after loader-side clamping inside listDirectory) @@ -85,6 +88,7 @@ interface DecoratedRow { followerCount: number; followState: { following: boolean; pending: boolean } | null; isSelf: boolean; + isDemoUser: boolean; } function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: boolean }) { @@ -92,12 +96,22 @@ function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: bool return (
  • - - {row.displayName ?? row.username} - +
    + + {row.displayName ?? row.username} + + {row.isDemoUser && ( + + {t("demo.badge")} + + )} +

    @{row.username} · {t("social.followers.count", { count: row.followerCount })}

    diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile index f2a61b6..6d80f0a 100644 --- a/infrastructure/Caddyfile +++ b/infrastructure/Caddyfile @@ -28,7 +28,17 @@ output stdout format json } - reverse_proxy journal:3000 + reverse_proxy journal:3000 { + # During an `apps` deploy the journal container is briefly down + # (~10–30s) while compose swaps containers. Without these, + # Caddy returns 502 immediately and the `caddy-502-rate` alert + # trips on every deploy. With them, Caddy holds and retries + # against the upstream for up to 30s — restart becomes + # invisible to clients. A real outage longer than 30s still + # 502s and correctly trips the alert. + lb_try_duration 30s + lb_try_interval 250ms + } } www.{$DOMAIN:trails.cool} { @@ -53,5 +63,9 @@ planner.{$DOMAIN:trails.cool} { output stdout format json } - reverse_proxy planner:3001 + reverse_proxy planner:3001 { + # Same rationale as the journal block — see the comment there. + lb_try_duration 30s + lb_try_interval 250ms + } } diff --git a/infrastructure/grafana/provisioning/alerting/alerts.yml b/infrastructure/grafana/provisioning/alerting/alerts.yml index aaae096..1ce2546 100644 --- a/infrastructure/grafana/provisioning/alerting/alerts.yml +++ b/infrastructure/grafana/provisioning/alerting/alerts.yml @@ -206,6 +206,12 @@ groups: annotations: summary: "BRouter host metrics scrape has been failing for 2+ minutes — the dedicated host, vSwitch, or cAdvisor may be down" + # The threshold here is intentionally `> 0` for 2m — *any* + # sustained 502 stream is real. Deploy-time restarts no longer + # produce 502s thanks to `lb_try_duration` on Caddy's reverse + # proxy (see `infrastructure/Caddyfile`); if 502s appear here + # it means the upstream has been unreachable for longer than + # Caddy's retry window, which is a genuine outage. - uid: caddy-502-rate title: Caddy 502 errors detected condition: B diff --git a/openspec/specs/explore/spec.md b/openspec/specs/explore/spec.md index 5c61c68..635f704 100644 --- a/openspec/specs/explore/spec.md +++ b/openspec/specs/explore/spec.md @@ -43,25 +43,26 @@ The directory SHALL be ordered by `MAX(activities.created_at) DESC` per user, co The directory SHALL exclude: 1. Users with `profile_visibility = 'private'` — they have explicitly opted out of public discovery (Mastodon-style locked accounts). -2. The instance's demo persona, identified by the username returned by `loadPersona()` — the demo bot is not a real user and should not appear in real discovery. -3. (Forward-compat) Users in any future banned/suspended state — when such a status column exists, it SHALL be added to the exclusion filter. +2. (Forward-compat) Users in any future banned/suspended state — when such a status column exists, it SHALL be added to the exclusion filter. Excluded users SHALL NOT appear on `/explore` even if they have public activities and would otherwise sort to the top of the directory. +The demo persona (identified by `loadPersona().username`) is **not** excluded — its purpose is to give new users a follow target, so it appears in the directory like any other public user. The directory row SHALL render a "demo account" badge next to the display name so viewers know what they're following. + #### Scenario: Private profile is excluded from the directory - **WHEN** user A has `profile_visibility = 'private'` and any activity history - **THEN** A does not appear in the `/explore` directory regardless of which page is requested -#### Scenario: Demo persona is excluded -- **WHEN** the demo persona username (per `loadPersona()`) matches a row that would otherwise appear -- **THEN** that row is filtered out of the directory +#### Scenario: Demo persona appears with a demo badge +- **WHEN** the demo persona username (per `loadPersona()`) matches a row in the directory +- **THEN** the row is rendered like any other public user, with an additional small "demo account" badge next to the display name #### Scenario: Public user with no activities is included - **WHEN** user A has `profile_visibility = 'public'` but has never created an activity - **THEN** A still appears in the directory (sorted toward the end by the recency rule) ### Requirement: "Active recently" sub-section -The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, demo persona, future banned/suspended users). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header). +The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, future banned/suspended users — the demo persona is included like any other public user, with the same demo-badge treatment). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header). #### Scenario: Active-recently strip rendered with qualifying users - **WHEN** at least one public local user (excluding private/demo) has a public activity within the last 30 days @@ -71,9 +72,13 @@ The `/explore` page SHALL render an "Active recently" sub-section at the top of - **WHEN** no public local user (excluding private/demo) has any public activity within the last 30 days - **THEN** the "Active recently" sub-section is not rendered at all; the main directory is the only listing on the page -#### Scenario: Strip respects exclusion rules -- **WHEN** a private profile or the demo persona has a recent public activity -- **THEN** they are not included in the "Active recently" strip — the same exclusion rules apply as for the main directory +#### Scenario: Strip excludes private profiles +- **WHEN** a private profile has a recent public activity +- **THEN** they are not included in the "Active recently" strip — the same private-profile exclusion as the main directory + +#### Scenario: Strip includes the demo persona +- **WHEN** the demo persona has a recent public activity +- **THEN** it appears in the "Active recently" strip like any other public user, carrying the same demo-account badge as in the main directory ### Requirement: Pagination The directory SHALL paginate via `?page=N` (1-indexed) and `?perPage=K` query parameters. Page size SHALL default to 20 per page and SHALL be capped at 100; `perPage` values outside `[1, 100]` SHALL be clamped to that range without raising an error. The response SHALL surface a "Next page" link when more rows exist past the current page, and a "Previous page" link when `page > 1`.