Fix reproducible frontend Docker deployment
This commit is contained in:
11
Caddyfile
11
Caddyfile
@@ -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
13
Caddyfile.web
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
:3000 {
|
||||||
|
route {
|
||||||
|
handle /health {
|
||||||
|
respond "OK" 200
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
root * /usr/share/caddy
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Dockerfile
23
Dockerfile
@@ -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"]
|
|
||||||
@@ -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
2724
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
@import 'tailwindcss';
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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')}">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user