mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Make the production compose stack deployable and disclose the AI-generated catalogue (#329)
This commit is contained in:
32
web/docker-compose.prod.seed.yaml
Normal file
32
web/docker-compose.prod.seed.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed
|
||||
name: biergarten-prod-seed
|
||||
|
||||
services:
|
||||
database.seed:
|
||||
env_file: ".env.prod"
|
||||
image: database.seed
|
||||
container_name: prod-env-database-seed
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Database/Database.Seed/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
APP_UID: 1000
|
||||
environment:
|
||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||
DB_SERVER: "${DB_SERVER}"
|
||||
DB_NAME: "${DB_NAME}"
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
restart: "no"
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
networks:
|
||||
prodnet:
|
||||
name: biergarten-prodnet
|
||||
external: true
|
||||
@@ -6,10 +6,10 @@ services:
|
||||
container_name: prod-env-sqlserver
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
SA_PASSWORD: "${DB_PASSWORD}"
|
||||
MSSQL_SA_PASSWORD: "${DB_PASSWORD}"
|
||||
MSSQL_PID: "Express"
|
||||
volumes:
|
||||
- sqlserverdata-prod:/var/opt/mssql
|
||||
- biergartendata-prod:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -20,29 +20,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
database.migrations:
|
||||
env_file: ".env.prod"
|
||||
image: database.migrations
|
||||
container_name: prod-env-database-migrations
|
||||
depends_on:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Database/Database.Migrations/Dockerfile
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
APP_UID: 1000
|
||||
environment:
|
||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||
DB_SERVER: "${DB_SERVER}"
|
||||
DB_NAME: "${DB_NAME}"
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
restart: "no"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 60s
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
@@ -51,7 +30,7 @@ services:
|
||||
image: api.core
|
||||
container_name: prod-env-api-core
|
||||
depends_on:
|
||||
sqlserver:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend
|
||||
@@ -59,8 +38,6 @@ services:
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
APP_UID: 1000
|
||||
# No published ports: only the frontend service is reachable from outside
|
||||
# the Docker network in production. api.core stays internal to prodnet.
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "Production"
|
||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||
@@ -73,11 +50,45 @@ services:
|
||||
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"bash",
|
||||
"-c",
|
||||
'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; head -1 <&3 | grep -q " 200 "',
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
seaweedfs:
|
||||
env_file: ".env.prod"
|
||||
image: chrislusf/seaweedfs
|
||||
container_name: prod-env-seaweedfs
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
volumes:
|
||||
- nuget-cache-prod:/root/.nuget/packages
|
||||
- seaweedfsdata-prod:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
frontend:
|
||||
env_file: ".env.prod"
|
||||
@@ -85,27 +96,34 @@ services:
|
||||
container_name: prod-env-frontend
|
||||
depends_on:
|
||||
api.core:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:5656:5656"
|
||||
environment:
|
||||
NODE_ENV: "production"
|
||||
PORT: "3000"
|
||||
PORT: "5656"
|
||||
API_BASE_URL: "http://api.core:8080"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
volumes:
|
||||
sqlserverdata-prod:
|
||||
driver: local
|
||||
nuget-cache-prod:
|
||||
driver: local
|
||||
biergartendata-prod:
|
||||
external: true
|
||||
seaweedfsdata-prod:
|
||||
external: true
|
||||
|
||||
networks:
|
||||
prodnet:
|
||||
name: biergarten-prodnet
|
||||
driver: bridge
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import AiDisclosureDialog from './AiDisclosureDialog';
|
||||
|
||||
const aiDisclosureDescription = `Tells visitors that the catalogue is AI-generated fixture data before they engage with it, as the pipeline ethics documentation requires. It opens on every full page load and closes for the rest of that load once acknowledged.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Disclosure/AiDisclosureDialog',
|
||||
component: AiDisclosureDialog,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: aiDisclosureDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AiDisclosureDialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Open: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await waitFor(async () => {
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: /this content is ai-generated/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
await expect(
|
||||
canvas.getByRole('link', { name: /how this data was generated/i }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const Acknowledged: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /i understand/i }));
|
||||
await expect(
|
||||
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
|
||||
).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const InitiallyDismissed: Story = {
|
||||
args: {
|
||||
defaultOpen: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
|
||||
).toBeNull();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Sparks } from 'iconoir-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const ethicsDocUrl =
|
||||
'https://github.com/aaronpo97/the-biergarten-app/blob/main/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md';
|
||||
|
||||
const AiDisclosureDialog = ({ defaultOpen = true }: { defaultOpen?: boolean }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal modal-open"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ai-disclosure-title"
|
||||
>
|
||||
<div className="modal-box max-w-md">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="text-primary">
|
||||
<Sparks className="size-10" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 id="ai-disclosure-title" className="text-lg font-bold">
|
||||
This content is AI-generated
|
||||
</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
The Biergarten App is a proof of concept. Every brewery, beer, and user in
|
||||
this catalogue is fixture data produced by a language model — none of it
|
||||
describes a real business, person, or beer.
|
||||
</p>
|
||||
<div role="alert" className="alert alert-warning alert-soft text-left">
|
||||
<span className="text-sm">
|
||||
Brewing details, local-language text, and coordinates are known to
|
||||
contain fabrications. Treat nothing here as factual.
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={ethicsDocUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link link-primary text-sm"
|
||||
>
|
||||
How this data was generated
|
||||
</a>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
autoFocus
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
I understand
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiDisclosureDialog;
|
||||
@@ -13,6 +13,7 @@ import './app.css';
|
||||
import Navbar from './components/ui/navbar/Navbar';
|
||||
import ToastProvider from './components/ui/toast/ToastProvider';
|
||||
import { getOptionalAuth } from './features/auth/auth.server';
|
||||
import AiDisclosureDialog from './features/home/components/AiDisclosureDialog';
|
||||
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
@@ -60,6 +61,7 @@ const App = ({ loaderData }: Route.ComponentProps) => {
|
||||
<>
|
||||
<Navbar auth={auth} />
|
||||
<ToastProvider />
|
||||
<AiDisclosureDialog />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,12 @@ const app = express();
|
||||
app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' }));
|
||||
app.use(express.static('build/client', { maxAge: '1h' }));
|
||||
|
||||
// Answers from Express alone so the container health check reports on this
|
||||
// process only, independent of whether the API is reachable yet.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.status(200).type('text/plain').send('ok');
|
||||
});
|
||||
|
||||
const requestHandler = createRequestHandler({
|
||||
build: () => import('./build/server/index.js'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user