From b273c6aa7e66a972552877b4de1ed86945e7bf61 Mon Sep 17 00:00:00 2001 From: Ronny Date: Sat, 11 Apr 2026 20:18:34 +0200 Subject: [PATCH] ci: switch to Gitea Container Registry for reliable deploys Previous pipeline built images locally via Portainer Docker API, but Docker layer caching produced identical images. Now: - Build with nocache=1 - Push to Gitea registry (git.home.rm-warpstation.de) - Compose uses image: from registry instead of build: - Redeploy with pullImage: true forces fresh container Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build.yml | 83 ++++++++++++++++++----- docker-compose.yml | 4 +- frontend/src/components/ConfettiReveal.js | 2 +- frontend/src/components/StatsRing.js | 15 ++-- frontend/src/components/Toast.js | 10 +-- frontend/src/hooks/useRankChange.js | 5 ++ frontend/src/pages/DashboardPage.js | 2 +- 7 files changed, 89 insertions(+), 32 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 9c50b85..013947c 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -4,6 +4,10 @@ on: push: branches: [main] +env: + REGISTRY: git.home.rm-warpstation.de + IMAGE: mwf975_git/tippspiel + jobs: build: runs-on: self-hosted @@ -33,42 +37,89 @@ jobs: - name: Build Docker Image via Portainer run: | - echo "Starting Docker build on NAS..." + IMAGE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE }}:latest" + echo "Building image: $IMAGE_TAG" curl -s -k -X POST \ - "https://192.168.1.60:9444/api/endpoints/2/docker/build?t=wm2026-tippspiel:latest&dockerfile=./Dockerfile" \ + "https://192.168.1.60:9444/api/endpoints/2/docker/build?t=${IMAGE_TAG}&dockerfile=./Dockerfile&nocache=1" \ -H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \ -H "Content-Type: application/x-tar" \ --data-binary @/tmp/tippspiel-ci.tar \ --max-time 600 \ - | grep -E '(Successfully built|Successfully tagged|error|Error)' || true + | grep -E '(Successfully|error|Error)' || true echo "Build completed." + - name: Push to Gitea Registry + run: | + IMAGE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE }}:latest" + + # Login to Gitea registry via Portainer Docker API + LOGIN_PAYLOAD=$(python3 -c "import json; print(json.dumps({'username': 'mwf975_git', 'password': '${{ secrets.DEPLOY_TOKEN }}', 'serveraddress': 'https://${{ env.REGISTRY }}'}))") + curl -s -k -X POST \ + "https://192.168.1.60:9444/api/endpoints/2/docker/auth" \ + -H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "$LOGIN_PAYLOAD" || true + + # Push image to registry + echo "Pushing $IMAGE_TAG..." + AUTH_HEADER=$(python3 -c "import base64,json; print(base64.urlsafe_b64encode(json.dumps({'username':'mwf975_git','password':'${{ secrets.DEPLOY_TOKEN }}'}).encode()).decode())") + curl -s -k -X POST \ + "https://192.168.1.60:9444/api/endpoints/2/docker/images/${IMAGE_TAG}/push" \ + -H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \ + -H "X-Registry-Auth: $AUTH_HEADER" \ + --max-time 300 || true + echo "Push completed." + - name: Redeploy Stack via Portainer run: | echo "Fetching current stack config from Portainer..." - # Aktuelles Compose-File aus Portainer lesen - STACK_FILE=$(curl -s -k \ - "https://192.168.1.60:9444/api/stacks/115/file" \ - -H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \ - | python3 -c "import sys,json; print(json.load(sys.stdin).get('StackFileContent',''))") - - # Aktuelle Env-Vars aus Portainer lesen (enthält die echten Werte) + # Aktuelle Env-Vars aus Portainer lesen ENV_VARS=$(curl -s -k \ "https://192.168.1.60:9444/api/stacks/115" \ -H "X-API-Key: ${{ secrets.PORTAINER_TOKEN }}" \ | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('Env', [])))") - # Stack mit bestehender Konfiguration neu deployen (keine Credentials im Workflow) + # Stack mit Image-Pull neu deployen PAYLOAD=$(python3 -c " - import json, sys - stack_file = '''$STACK_FILE''' + import json env_vars = $ENV_VARS print(json.dumps({ - 'stackFileContent': stack_file, + 'stackFileContent': '''services: + tippspiel: + image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + container_name: wm2026-tippspiel + restart: unless-stopped + ports: + - \"3301:3001\" + environment: + - NODE_ENV=\${NODE_ENV} + - PORT=\${PORT} + - DATABASE_URL=\${DATABASE_URL} + - SUPABASE_URL=\${SUPABASE_URL} + - SUPABASE_SERVICE_ROLE_KEY=\${SUPABASE_SERVICE_ROLE_KEY} + - ANTHROPIC_API_KEY=\${ANTHROPIC_API_KEY} + - FOOTBALL_API_KEY=\${FOOTBALL_API_KEY} + - FOOTBALL_API_BASE_URL=\${FOOTBALL_API_BASE_URL} + - ELEVENLABS_API_KEY=\${ELEVENLABS_API_KEY} + - CORS_ORIGIN=\${CORS_ORIGIN} + - STAFFBASE_PUBLIC_KEY=\${STAFFBASE_PUBLIC_KEY:-} + - STAFFBASE_PLUGIN_ID=\${STAFFBASE_PLUGIN_ID:-} + healthcheck: + test: [\"CMD\", \"wget\", \"-qO-\", \"http://localhost:3001/health\"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + networks: + - main-network + +networks: + main-network: + external: true''', 'env': env_vars, 'prune': True, - 'pullImage': False + 'pullImage': True })) ") @@ -84,7 +135,7 @@ jobs: - name: Verify deployment run: | - sleep 15 + sleep 20 STATUS=$(curl -s http://192.168.1.60:3301/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status'))" 2>/dev/null || echo "unreachable") echo "Health check: $STATUS" if [ "$STATUS" = "ok" ]; then diff --git a/docker-compose.yml b/docker-compose.yml index b68d7a6..f138ce2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ services: tippspiel: - build: - context: . - dockerfile: Dockerfile + image: git.home.rm-warpstation.de/mwf975_git/tippspiel:latest container_name: wm2026-tippspiel restart: unless-stopped ports: diff --git a/frontend/src/components/ConfettiReveal.js b/frontend/src/components/ConfettiReveal.js index 2e7fdb8..ea76484 100644 --- a/frontend/src/components/ConfettiReveal.js +++ b/frontend/src/components/ConfettiReveal.js @@ -14,5 +14,5 @@ export default function ConfettiReveal({ match, onDismiss }) { }, [points]); const resultLabel = points === 3 ? 'EXAKT! 🎉' : points === 1 ? 'Richtige Tendenz! 👏' : 'Knapp daneben... 😅'; const badgeClass = points === 3 ? styles.badgeExact : points === 1 ? styles.badgeTendency : styles.badgeWrong; - return (_jsx("div", { className: styles.overlay, onClick: onDismiss, children: _jsxs("div", { className: styles.card, onClick: e => e.stopPropagation(), children: [_jsxs("div", { className: styles.result, children: [match.homeTeam.shortName, " ", match.score.home, ":", match.score.away, " ", match.awayTeam.shortName] }), _jsxs("div", { className: styles.tipLine, children: ["Dein Tipp: ", tip.home, ":", tip.away] }), _jsxs("div", { className: `${styles.badge} ${badgeClass}`, children: [points, " ", points === 1 ? 'Punkt' : 'Punkte'] }), _jsx("div", { className: styles.label, children: resultLabel }), _jsx("button", { className: styles.dismissBtn, onClick: onDismiss, children: "Weiter" })] }) })); + return (_jsx("div", { className: styles.overlay, onClick: onDismiss, children: _jsxs("div", { className: `card ${styles.card}`, onClick: e => e.stopPropagation(), children: [_jsxs("div", { className: styles.result, children: [match.homeTeam.shortName, " ", match.score.home, ":", match.score.away, " ", match.awayTeam.shortName] }), _jsxs("div", { className: styles.tipLine, children: ["Dein Tipp: ", tip.home, ":", tip.away] }), _jsxs("div", { className: `${styles.badge} ${badgeClass}`, children: [points, " ", points === 1 ? 'Punkt' : 'Punkte'] }), _jsx("div", { className: styles.label, children: resultLabel }), _jsx("button", { className: styles.dismissBtn, onClick: onDismiss, children: "Weiter" })] }) })); } diff --git a/frontend/src/components/StatsRing.js b/frontend/src/components/StatsRing.js index 368a8a6..611e5fe 100644 --- a/frontend/src/components/StatsRing.js +++ b/frontend/src/components/StatsRing.js @@ -3,12 +3,13 @@ import styles from './StatsRing.module.css'; export default function StatsRing({ exact, tendency, wrong, total }) { const radius = 55; const circumference = 2 * Math.PI * radius; - const all = exact + tendency + wrong || 1; - const segments = [ - { value: exact / all, color: 'var(--gold)', label: 'Exakt' }, - { value: tendency / all, color: 'var(--success)', label: 'Tendenz' }, - { value: wrong / all, color: 'var(--error)', label: 'Falsch' }, - ]; + const all = exact + tendency + wrong; + const hasData = all > 0; + const segments = hasData ? [ + { value: exact / all, color: 'var(--gold)', label: 'Exakt', count: exact }, + { value: tendency / all, color: 'var(--success)', label: 'Tendenz', count: tendency }, + { value: wrong / all, color: 'var(--error)', label: 'Falsch', count: wrong }, + ] : []; let offset = 0; return (_jsxs("div", { className: styles.ring, children: [_jsxs("svg", { viewBox: "0 0 140 140", className: styles.svg, children: [_jsx("circle", { cx: "70", cy: "70", r: radius, fill: "none", stroke: "var(--surface-high)", strokeWidth: "12" }), segments.map((seg, i) => { if (seg.value === 0) @@ -17,5 +18,5 @@ export default function StatsRing({ exact, tendency, wrong, total }) { const rotation = offset * 360 - 90; offset += seg.value; return (_jsx("circle", { cx: "70", cy: "70", r: radius, fill: "none", stroke: seg.color, strokeWidth: "12", strokeDasharray: dashArray, transform: `rotate(${rotation} 70 70)`, strokeLinecap: "round" }, i)); - }), _jsx("text", { x: "70", y: "65", textAnchor: "middle", dominantBaseline: "central", fill: "var(--text-primary)", fontSize: "28", fontWeight: "700", children: total }), _jsx("text", { x: "70", y: "85", textAnchor: "middle", fill: "var(--text-secondary)", fontSize: "11", children: "Punkte" })] }), _jsx("div", { className: styles.legend, children: segments.map((seg, i) => (_jsxs("span", { className: styles.legendItem, children: [_jsx("span", { className: styles.dot, style: { background: seg.color } }), seg.label, ": ", Math.round(seg.value * all)] }, i))) })] })); + }), _jsx("text", { x: "70", y: "65", textAnchor: "middle", dominantBaseline: "central", fill: "var(--text-primary)", fontSize: "28", fontWeight: "700", children: total }), _jsx("text", { x: "70", y: "85", textAnchor: "middle", fill: "var(--text-secondary)", fontSize: "11", children: hasData ? 'Punkte' : 'Keine Tipps' })] }), hasData && (_jsx("div", { className: styles.legend, children: segments.map((seg, i) => (_jsxs("span", { className: styles.legendItem, children: [_jsx("span", { className: styles.dot, style: { background: seg.color } }), seg.label, ": ", seg.count] }, i))) }))] })); } diff --git a/frontend/src/components/Toast.js b/frontend/src/components/Toast.js index 262edcb..53787df 100644 --- a/frontend/src/components/Toast.js +++ b/frontend/src/components/Toast.js @@ -1,10 +1,12 @@ import { jsx as _jsx } from "react/jsx-runtime"; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import styles from './Toast.module.css'; export default function Toast({ message, onDismiss, duration = 5000 }) { + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; useEffect(() => { - const timer = setTimeout(onDismiss, duration); + const timer = setTimeout(() => onDismissRef.current(), duration); return () => clearTimeout(timer); - }, [onDismiss, duration]); - return (_jsx("div", { className: styles.toast, onClick: onDismiss, children: message })); + }, [duration]); + return (_jsx("div", { className: `card ${styles.toast}`, onClick: onDismiss, children: message })); } diff --git a/frontend/src/hooks/useRankChange.js b/frontend/src/hooks/useRankChange.js index 6ebbd38..a7ca264 100644 --- a/frontend/src/hooks/useRankChange.js +++ b/frontend/src/hooks/useRankChange.js @@ -1,9 +1,13 @@ import { useState, useEffect } from 'react'; import { api } from '../api/client'; const RANK_KEY = 'tippspiel_last_rank'; +const SHOWN_KEY = 'tippspiel_rank_toast_shown'; export function useRankChange() { const [message, setMessage] = useState(null); useEffect(() => { + // Only show once per session + if (sessionStorage.getItem(SHOWN_KEY)) + return; api.getMyStats().then(stats => { if (!stats.rank) return; @@ -15,6 +19,7 @@ export function useRankChange() { else { setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht — hol dir die Punkte zurück!`); } + sessionStorage.setItem(SHOWN_KEY, '1'); } localStorage.setItem(RANK_KEY, String(stats.rank)); }).catch(() => { }); diff --git a/frontend/src/pages/DashboardPage.js b/frontend/src/pages/DashboardPage.js index 66a3eb6..f07ace7 100644 --- a/frontend/src/pages/DashboardPage.js +++ b/frontend/src/pages/DashboardPage.js @@ -38,7 +38,7 @@ export default function DashboardPage(_props) { if (error || !data) return _jsx("div", { className: styles.error, children: "Dashboard konnte nicht geladen werden." }); const { hero, stats, nudges } = data; - return (_jsxs("div", { className: styles.dashboard, children: [_jsxs("div", { className: styles.hero, onClick: () => navigate('/spiele'), children: [_jsxs("div", { className: styles.heroLabel, children: [_jsx("span", { children: "N\u00E4chstes Spiel" }), hero && (_jsx("span", { className: styles.heroCountdown, children: formatCountdown(hero.match.minutesUntilKickoff) }))] }), hero ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.heroTeams, children: [_jsxs("div", { className: styles.heroTeam, children: [hero.match.homeTeam.crest ? (_jsx("img", { src: hero.match.homeTeam.crest, alt: hero.match.homeTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.homeTeam.shortName })] }), _jsx("span", { className: styles.heroVs, children: "vs" }), _jsxs("div", { className: styles.heroTeam, children: [hero.match.awayTeam.crest ? (_jsx("img", { src: hero.match.awayTeam.crest, alt: hero.match.awayTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.awayTeam.shortName })] })] }), hero.userTip ? (_jsxs("div", { className: styles.heroTip, children: ["Dein Tipp: ", hero.userTip.home, ":", hero.userTip.away, " \u2713"] })) : hero.tippable ? (_jsx("button", { className: styles.heroTipBtn, onClick: e => { e.stopPropagation(); navigate('/spiele'); }, children: "Jetzt tippen" })) : null] })) : (_jsx("p", { style: { textAlign: 'center', color: 'var(--text-muted)', margin: '16px 0' }, children: "Keine anstehenden Spiele" }))] }), _jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.rank !== null ? stats.rank : '—' }), _jsx("span", { className: styles.statLabel, children: "Dein Rang" })] }), _jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.totalPoints }), _jsx("span", { className: styles.statLabel, children: "Punkte" })] }), _jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: formatStreak(stats.streak) }), _jsx("span", { className: styles.statLabel, children: "Streak" })] })] }), nudges.length > 0 && (_jsx("div", { className: styles.nudges, children: nudges.map((nudge, i) => (_jsx("div", { className: styles.nudge, onClick: () => { + return (_jsxs("div", { className: styles.dashboard, children: [_jsxs("div", { className: `card ${styles.hero}`, onClick: () => navigate('/spiele'), children: [_jsxs("div", { className: styles.heroLabel, children: [_jsx("span", { children: "N\u00E4chstes Spiel" }), hero && (_jsx("span", { className: styles.heroCountdown, children: formatCountdown(hero.match.minutesUntilKickoff) }))] }), hero ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.heroTeams, children: [_jsxs("div", { className: styles.heroTeam, children: [hero.match.homeTeam.crest ? (_jsx("img", { src: hero.match.homeTeam.crest, alt: hero.match.homeTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.homeTeam.shortName })] }), _jsx("span", { className: styles.heroVs, children: "vs" }), _jsxs("div", { className: styles.heroTeam, children: [hero.match.awayTeam.crest ? (_jsx("img", { src: hero.match.awayTeam.crest, alt: hero.match.awayTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.awayTeam.shortName })] })] }), hero.userTip ? (_jsxs("div", { className: styles.heroTip, children: ["Dein Tipp: ", hero.userTip.home, ":", hero.userTip.away, " \u2713"] })) : hero.tippable ? (_jsx("button", { className: styles.heroTipBtn, onClick: e => { e.stopPropagation(); navigate('/spiele'); }, children: "Jetzt tippen" })) : null] })) : (_jsx("p", { style: { textAlign: 'center', color: 'var(--text-muted)', margin: '16px 0' }, children: "Keine anstehenden Spiele" }))] }), _jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: `card ${styles.statTile}`, children: [_jsx("span", { className: styles.statValue, children: stats.rank !== null ? stats.rank : '—' }), _jsx("span", { className: styles.statLabel, children: "Dein Rang" })] }), _jsxs("div", { className: `card ${styles.statTile}`, children: [_jsx("span", { className: styles.statValue, children: stats.totalPoints }), _jsx("span", { className: styles.statLabel, children: "Punkte" })] }), _jsxs("div", { className: `card ${styles.statTile}`, children: [_jsx("span", { className: styles.statValue, children: formatStreak(stats.streak) }), _jsx("span", { className: styles.statLabel, children: "Streak" })] })] }), nudges.length > 0 && (_jsx("div", { className: styles.nudges, children: nudges.map((nudge, i) => (_jsx("div", { className: `card ${styles.nudge}`, onClick: () => { if (nudge.type === 'untipped') navigate('/spiele'); else if (nudge.type === 'leader')