Fix reproducible frontend Docker deployment

This commit is contained in:
faligam
2026-09-06 16:47:39 +02:00
parent c7b091d1e3
commit 37636e144b
21 changed files with 2799 additions and 85 deletions

View File

@@ -6,6 +6,10 @@
:443 { :443 {
handle_path /api/* { handle_path /api/* {
reverse_proxy nsct-api:8080
}
reverse_proxy web:3000
# Security Headers # Security Headers
header { header {
@@ -29,9 +33,6 @@
encode gzip zstd encode gzip zstd
# Rate limiting (optional, only if NSCT_RATE_LIMIT is set) # Rate limiting (optional, only if NSCT_RATE_LIMIT is set)
gzip 1
# Rate limiting (optional, only if NSCT_RATE_LIMIT is set)
@hasRateLimit { @hasRateLimit {
expression {env.NSCT_RATE_LIMIT} != "" expression {env.NSCT_RATE_LIMIT} != ""
} }
@@ -46,6 +47,10 @@
# HTTP fallback (when no domain is set) # HTTP fallback (when no domain is set)
:80 { :80 {
handle_path /api/* { handle_path /api/* {
reverse_proxy nsct-api:8080
}
reverse_proxy web:3000
header { header {
X-Content-Type-Options nosniff X-Content-Type-Options nosniff

13
Caddyfile.web Normal file
View File

@@ -0,0 +1,13 @@
:3000 {
route {
handle /health {
respond "OK" 200
}
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
}

View File

@@ -12,24 +12,9 @@ COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build || exit 1 RUN npm run build || exit 1
# Production stage # Production stage: serve the static SvelteKit output.
FROM node:22-alpine AS runtime FROM caddy:2.8-alpine AS runtime
WORKDIR /app COPY Caddyfile.web /etc/caddy/Caddyfile
COPY --from=builder /app/build /usr/share/caddy
# Copy built assets and dependencies
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .
# Non-root user
RUN addgroup -S nsct && adduser -S nsct -G nsct
USER nsct
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3000/health || exit 1
EXPOSE 3000 EXPOSE 3000
ENV HOST=0.0.0.0
ENV PORT=3000
CMD ["node", "build"]

View File

@@ -46,7 +46,7 @@ services:
depends_on: depends_on:
- web - web
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health || exit 1"] test: ["CMD", "wget", "-qO-", "http://localhost/health"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

2724
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,21 +7,21 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json" "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.0", "@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"svelte": "^5.0.0", "svelte": "^4.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^3.4.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.0.0" "vite": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"svelte-markdown": "^0.5.0" "svelte-markdown": "^0.4.1"
} }
} }

View File

@@ -1 +1,3 @@
@import 'tailwindcss'; @tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = import.meta.env.NSCT_API_BASE_URL || 'http://localhost:8080'; const API_BASE_URL = import.meta.env.VITE_NSCT_API_BASE_URL || '/api';
interface ApiConfig { interface ApiConfig {
apiKey?: string; apiKey?: string;

View File

@@ -3,7 +3,7 @@
export let claims: ClaimInfo[] = []; export let claims: ClaimInfo[] = [];
export let sources: SourceInfo[] = []; export let sources: SourceInfo[] = [];
export let onDismiss?: () => void; export let onDismiss: (() => void) | undefined = undefined;
// Find contradictions: claims with same topic but different conclusions // Find contradictions: claims with same topic but different conclusions
// A simple heuristic: claims that reference the same source_id or overlapping text // A simple heuristic: claims that reference the same source_id or overlapping text
@@ -14,7 +14,7 @@
sourceB: SourceInfo | undefined; sourceB: SourceInfo | undefined;
} }
$: contradictions: ContradictionPair[] = findContradictions(claims, sources); $: contradictions = findContradictions(claims, sources);
function findContradictions(claims: ClaimInfo[], sources: SourceInfo[]): ContradictionPair[] { function findContradictions(claims: ClaimInfo[], sources: SourceInfo[]): ContradictionPair[] {
const pairs: ContradictionPair[] = []; const pairs: ContradictionPair[] = [];

View File

@@ -25,7 +25,7 @@ function createBanner(message: string, type: ErrorType, duration = 5000): Banner
} }
export function addError(message: string, type: ErrorType = 'error', duration?: number) { export function addError(message: string, type: ErrorType = 'error', duration?: number) {
return banners.update(banners => { return errorBanners.update(banners => {
const banner = createBanner(message, type, duration); const banner = createBanner(message, type, duration);
return [...banners, banner]; return [...banners, banner];
}); });

View File

@@ -16,9 +16,7 @@
</script> </script>
<div <div
class="group flex flex-col gap-1 p-3 rounded-lg cursor-pointer transition-colors border border-transparent" class="group flex flex-col gap-1 p-3 rounded-lg cursor-pointer transition-colors border border-transparent hover:bg-gray-100 dark:hover:bg-gray-800 {isActive ? 'ring-2 ring-indigo-500 bg-indigo-50 dark:bg-indigo-950/30' : ''}"
class:ring-2 class:ring-indigo-500 class:bg-indigo-50 class:dark:bg-indigo-950/30 {isActive}
class:hover:bg-gray-100 class:dark:hover:bg-gray-800
on:click={() => window.location.href = `/research/${item.id}`} on:click={() => window.location.href = `/research/${item.id}`}
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">

View File

@@ -65,7 +65,7 @@
<aside <aside
class="h-full flex flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-transform duration-200 ease-in-out" class="h-full flex flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-transform duration-200 ease-in-out"
style="width: 20rem;" style="width: 20rem;"
class:{-translate-x-full: isMobile && !$sidebarOpen} class:-translate-x-full={isMobile && !$sidebarOpen}
> >
<!-- Top bar --> <!-- Top bar -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
@@ -95,10 +95,10 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-indigo-500 flex items-center justify-center text-white text-xs font-bold"> <div class="w-7 h-7 rounded-full bg-indigo-500 flex items-center justify-center text-white text-xs font-bold">
{$userObj?.apiKey?.[0]?.toUpperCase() ?? 'U'} {userObj?.apiKey?.[0]?.toUpperCase() ?? 'U'}
</div> </div>
<span class="text-sm text-gray-700 dark:text-gray-300 truncate"> <span class="text-sm text-gray-700 dark:text-gray-300 truncate">
{$userObj?.apiKey ? $userObj.apiKey.substring(0, 8) : 'Gast'} {userObj?.apiKey ? userObj.apiKey.substring(0, 8) : 'Gast'}
</span> </span>
</div> </div>
<button <button

View File

@@ -1,5 +1,7 @@
import { theme } from '$lib/stores'; import { theme } from '$lib/stores';
export { theme };
export function initTheme() { export function initTheme() {
const stored = localStorage.getItem('nsct-theme'); const stored = localStorage.getItem('nsct-theme');
if (stored === 'dark' || stored === 'light') { if (stored === 'dark' || stored === 'light') {

View File

@@ -1,6 +1,6 @@
<script> <script>
import '../app.css'; import '../app.css';
import { initTheme, applyTheme } from '$lib/theme'; import { theme, initTheme, applyTheme } from '$lib/theme';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
initTheme(); initTheme();

View File

@@ -94,17 +94,13 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each ['Deutsch', 'English', 'Multi'] as lang} {#each ['Deutsch', 'English', 'Multi'] as lang}
<label <label
class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors" class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors {language === lang ? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20' : 'border-gray-300 dark:border-gray-600'}"
class:ring-2 class:ring-indigo-500={language === lang}
class:border-indigo-500={language === lang}
class:border-gray-300 dark:border-gray-600={language !== lang}
class:bg-indigo-50 dark:class:bg-indigo-900/20={language === lang}
> >
<input <input
type="radio" type="radio"
name="language" name="language"
value={lang} value={lang}
bind:value={language} bind:group={language}
class="sr-only" class="sr-only"
disabled={submitting} disabled={submitting}
/> />
@@ -122,17 +118,13 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each ['quick', 'normal', 'deep'] as d} {#each ['quick', 'normal', 'deep'] as d}
<label <label
class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors" class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors {depth === d ? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20' : 'border-gray-300 dark:border-gray-600'}"
class:ring-2 class:ring-indigo-500={depth === d}
class:border-indigo-500={depth === d}
class:border-gray-300 dark:border-gray-600={depth !== d}
class:bg-indigo-50 dark:class:bg-indigo-900/20={depth === d}
> >
<input <input
type="radio" type="radio"
name="depth" name="depth"
value={d} value={d}
bind:value={depth} bind:group={depth}
class="sr-only" class="sr-only"
disabled={submitting} disabled={submitting}
/> />

View File

@@ -254,9 +254,7 @@
{#each tabs as tab} {#each tabs as tab}
<button <button
on:click={() => activeTab = tab} on:click={() => activeTab = tab}
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap" class="px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap {activeTab === tab ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'}"
class:border-indigo-500 class:text-indigo-600 class:dark:text-indigo-400={activeTab === tab}
class:border-transparent class:text-gray-500 class:hover:text-gray-700 class:dark:text-gray-400 class:dark:hover:text-gray-300={activeTab !== tab}
> >
{tab} {tab}
</button> </button>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Markdown } from 'svelte-markdown'; import Markdown from 'svelte-markdown';
export let methodology: string = ''; export let methodology: string = '';
export let loading = false; export let loading = false;

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Markdown } from 'svelte-markdown'; import Markdown from 'svelte-markdown';
import type { ResearchDetail } from '$lib/research-api'; import type { ResearchDetail } from '$lib/research-api';
export let report: string = ''; export let report: string = '';

View File

@@ -86,12 +86,7 @@
<div class="relative flex items-start gap-3 pl-8"> <div class="relative flex items-start gap-3 pl-8">
<!-- Timeline dot --> <!-- Timeline dot -->
<div <div
class="absolute left-2.5 w-3 h-3 rounded-full border-2 z-10" class="absolute left-2.5 w-3 h-3 rounded-full border-2 z-10 {statusText === 'FAILED' ? 'border-red-500 bg-red-500' : statusText === 'STOPPED' ? 'border-orange-500 bg-orange-500' : statusText === 'COMPLETED' && i <= currentStepIndex() ? 'border-emerald-500 bg-emerald-500' : i <= currentStepIndex() ? 'border-blue-500 bg-blue-500' : 'border-gray-300 bg-gray-100 dark:bg-gray-800 dark:border-gray-600'}"
class:border-blue-500 class:bg-blue-500={i <= currentStepIndex() && statusText !== 'FAILED' && statusText !== 'STOPPED'}
class:border-emerald-500 class:bg-emerald-500={statusText === 'COMPLETED' && i <= currentStepIndex()}
class:border-red-500 class:bg-red-500={statusText === 'FAILED'}
class:border-orange-500 class:bg-orange-500={statusText === 'STOPPED'}
class:border-gray-300 class:bg-gray-100 dark:class:bg-gray-800 dark:class:border-gray-600={i > currentStepIndex()}
></div> ></div>
<!-- Step label --> <!-- Step label -->
<span class="text-sm {STEP_COLORS[statusText] || (i <= currentStepIndex() ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600')}"> <span class="text-sm {STEP_COLORS[statusText] || (i <= currentStepIndex() ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600')}">

View File

@@ -2,7 +2,7 @@
import { getShareData } from '$lib/research-api'; import { getShareData } from '$lib/research-api';
import type { ShareData, SourceInfo, ClaimInfo, EvidenceScore } from '$lib/research-api'; import type { ShareData, SourceInfo, ClaimInfo, EvidenceScore } from '$lib/research-api';
import { truncateId, formatDate, getConfidenceBadge, getIndependenceBadge, scoreColor } from '$lib/utils/formatters'; import { truncateId, formatDate, getConfidenceBadge, getIndependenceBadge, scoreColor } from '$lib/utils/formatters';
import { Markdown } from 'svelte-markdown'; import Markdown from 'svelte-markdown';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { errorBanners } from '$lib/components/ErrorBannerStore'; import { errorBanners } from '$lib/components/ErrorBannerStore';
@@ -264,7 +264,7 @@
Evidenz-Scores Evidenz-Scores
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
{#each shareData.evidence_scores as (key, value)} {#each Object.entries(shareData.evidence_scores) as [key, value]}
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-700 dark:text-gray-300"> <span class="text-sm text-gray-700 dark:text-gray-300">

View File

@@ -6,7 +6,7 @@ const config = {
adapter: adapter({ adapter: adapter({
pages: 'build', pages: 'build',
assets: 'build', assets: 'build',
fallback: undefined, fallback: 'index.html',
precompress: false, precompress: false,
strict: true strict: true
}), }),