Compare commits
@@ -16,8 +16,40 @@ env:
|
|||||||
KUBE_API_SERVER: https://100.112.189.33:6443/
|
KUBE_API_SERVER: https://100.112.189.33:6443/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Verify application and manifests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.14
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Set up kubectl
|
||||||
|
uses: azure/setup-kubectl@v4
|
||||||
|
|
||||||
|
- name: Validate Kubernetes manifests
|
||||||
|
run: kubectl kustomize k8s/ >/dev/null
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
name: Build immutable images and deploy
|
name: Build immutable images and deploy
|
||||||
|
needs: verify
|
||||||
if: >-
|
if: >-
|
||||||
gitea.event_name == 'push' &&
|
gitea.event_name == 'push' &&
|
||||||
gitea.ref == 'refs/heads/main' &&
|
gitea.ref == 'refs/heads/main' &&
|
||||||
@@ -40,6 +72,20 @@ jobs:
|
|||||||
.
|
.
|
||||||
docker push ${{ env.REGISTRY_IMAGE }}:${{ gitea.sha }}
|
docker push ${{ env.REGISTRY_IMAGE }}:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build and push public mirror image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--target app \
|
||||||
|
--build-arg BASE_URL="$BASE_URL" \
|
||||||
|
--build-arg NEXT_PUBLIC_BASE_PATH=/guide \
|
||||||
|
--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID" \
|
||||||
|
--build-arg PUBLIC_MIRROR=true \
|
||||||
|
--build-arg NEXT_DEPLOYMENT_ID=${{ gitea.sha }} \
|
||||||
|
--build-arg VCS_REF=${{ gitea.sha }} \
|
||||||
|
--tag ${{ env.REGISTRY_IMAGE }}:mirror-${{ gitea.sha }} \
|
||||||
|
.
|
||||||
|
docker push ${{ env.REGISTRY_IMAGE }}:mirror-${{ gitea.sha }}
|
||||||
|
|
||||||
- name: Build and push migration image
|
- name: Build and push migration image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
@@ -138,6 +184,9 @@ jobs:
|
|||||||
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
|
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
|
||||||
deployment/buzz-sheet \
|
deployment/buzz-sheet \
|
||||||
app="$REGISTRY_IMAGE:$REVISION"
|
app="$REGISTRY_IMAGE:$REVISION"
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
|
||||||
|
deployment/buzz-sheet-mirror \
|
||||||
|
app="$REGISTRY_IMAGE:mirror-$REVISION"
|
||||||
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
|
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
|
||||||
deployment/buzz-sheet-worker \
|
deployment/buzz-sheet-worker \
|
||||||
worker="$REGISTRY_IMAGE:$REVISION"
|
worker="$REGISTRY_IMAGE:$REVISION"
|
||||||
@@ -147,6 +196,9 @@ jobs:
|
|||||||
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
||||||
deployment/buzz-sheet \
|
deployment/buzz-sheet \
|
||||||
--timeout=10m
|
--timeout=10m
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
||||||
|
deployment/buzz-sheet-mirror \
|
||||||
|
--timeout=10m
|
||||||
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
||||||
deployment/buzz-sheet-worker \
|
deployment/buzz-sheet-worker \
|
||||||
--timeout=10m
|
--timeout=10m
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ FROM node:22-bookworm-slim AS next-builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NEXT_DEPLOYMENT_ID=container-build
|
ARG NEXT_DEPLOYMENT_ID=container-build
|
||||||
ARG BASE_URL
|
ARG BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH
|
||||||
ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID
|
ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID
|
||||||
|
ARG PUBLIC_MIRROR=false
|
||||||
ENV BASE_URL=${BASE_URL}
|
ENV BASE_URL=${BASE_URL}
|
||||||
ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID}
|
ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID}
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||||
ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}
|
ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}
|
||||||
|
ENV PUBLIC_MIRROR=${PUBLIC_MIRROR}
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY --from=dependencies /app/node_modules ./node_modules
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
Buzz Sheet is a Thai-first guide CMS for turning structured character, build,
|
Buzz Sheet is a Thai-first guide CMS for turning structured character, build,
|
||||||
team, rotation, and comparison data into responsive game-guide pages.
|
team, rotation, and comparison data into responsive game-guide pages.
|
||||||
|
|
||||||
Production target: `https://guide.sudloh.com`
|
Production targets: `https://guide.sudloh.com` and the public-only mirror at
|
||||||
|
`https://buzz.sudloh.com/guide`.
|
||||||
|
|
||||||
This project is an independent community tool for Genshin Impact players. It is
|
This project is an independent community tool for Genshin Impact players. It is
|
||||||
not affiliated with or endorsed by HoYoverse.
|
not affiliated with or endorsed by HoYoverse.
|
||||||
@@ -181,7 +182,8 @@ Kustomize resources live in `k8s/` and define:
|
|||||||
|
|
||||||
- Namespace `buzz-sheet`.
|
- Namespace `buzz-sheet`.
|
||||||
- Two rolling web replicas, one outbox worker, and one Discord catalog worker.
|
- Two rolling web replicas, one outbox worker, and one Discord catalog worker.
|
||||||
- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`.
|
- Separate root and `/guide` web workloads with Traefik ingress for
|
||||||
|
`guide.sudloh.com` and `buzz.sudloh.com/guide`.
|
||||||
- Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route.
|
- Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route.
|
||||||
- Startup, liveness, and dependency-aware readiness probes.
|
- Startup, liveness, and dependency-aware readiness probes.
|
||||||
- Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB.
|
- Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB.
|
||||||
@@ -219,12 +221,21 @@ Every page also subscribes to `/api/active`. When a serving pod reports a newer
|
|||||||
deployment ID than the browser's current build, a persistent Thai notification
|
deployment ID than the browser's current build, a persistent Thai notification
|
||||||
offers a full reload into the new release.
|
offers a full reload into the new release.
|
||||||
|
|
||||||
|
The `buzz.sudloh.com/guide` build sets `NEXT_PUBLIC_BASE_PATH=/guide` and
|
||||||
|
`PUBLIC_MIRROR=true`. It shares public data with the canonical site but returns
|
||||||
|
404 for admin, registration, authentication, and admin API routes. The original
|
||||||
|
`guide.sudloh.com` build and its admin routes remain unchanged.
|
||||||
|
|
||||||
The base manifests contain deliberate `replace-me` image and deployment values.
|
The base manifests contain deliberate `replace-me` image and deployment values.
|
||||||
For initial bootstrap, the platform operator applies `k8s/base` through an
|
For initial bootstrap, the platform operator applies `k8s/base` through an
|
||||||
environment overlay that supplies an existing immutable image revision. The
|
environment overlay that supplies an existing immutable image revision. The
|
||||||
aggregate `k8s/` kustomization includes the migration resource for rendering and
|
aggregate `k8s/` kustomization includes the migration resource for rendering and
|
||||||
validation; normal releases create their own revision-named migration Job.
|
validation; normal releases create their own revision-named migration Job.
|
||||||
|
|
||||||
|
The public mirror adds new Deployment, Service, HPA, PDB, and Ingress resources.
|
||||||
|
A cluster administrator must apply those base resources once before the first CI
|
||||||
|
release that attempts to roll out `deployment/buzz-sheet-mirror`.
|
||||||
|
|
||||||
Use this command for local manifest validation only:
|
Use this command for local manifest validation only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import { notFound } from "next/navigation";
|
|||||||
import { GoogleAdsense } from "@/components/public/google-adsense";
|
import { GoogleAdsense } from "@/components/public/google-adsense";
|
||||||
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
|
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
|
||||||
import { getCachedPublicGuide } from "@/lib/cache/public";
|
import { getCachedPublicGuide } from "@/lib/cache/public";
|
||||||
|
import { siteUrl } from "@/lib/site-url";
|
||||||
|
|
||||||
type Params = Promise<{ character: string; page: string }>;
|
type Params = Promise<{ character: string; page: string }>;
|
||||||
|
|
||||||
export const instant = false;
|
export const instant = false;
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||||
const { character } = await params;
|
const { character, page } = await params;
|
||||||
const guide = await getCachedPublicGuide(character);
|
const guide = await getCachedPublicGuide(character);
|
||||||
return guide
|
return guide
|
||||||
? {
|
? {
|
||||||
title: `${guide.name}`,
|
title: `${guide.name}`,
|
||||||
description: guide.overview,
|
description: guide.overview,
|
||||||
|
alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}/${encodeURIComponent(page)}` },
|
||||||
openGraph: { images: [guide.coverUrl] },
|
openGraph: { images: [guide.coverUrl] },
|
||||||
twitter: { images: [guide.coverUrl] },
|
twitter: { images: [guide.coverUrl] },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { GoogleAdsense } from "@/components/public/google-adsense";
|
import { GoogleAdsense } from "@/components/public/google-adsense";
|
||||||
import { StructuredGuideShell } from "@/components/public/structured-guide";
|
import { StructuredGuideShell } from "@/components/public/structured-guide";
|
||||||
import { getCachedPublicGuide } from "@/lib/cache/public";
|
import { getCachedPublicGuide } from "@/lib/cache/public";
|
||||||
|
import { siteUrl } from "@/lib/site-url";
|
||||||
|
|
||||||
type Params = Promise<{ character: string }>;
|
type Params = Promise<{ character: string }>;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export async function generateMetadata({
|
|||||||
? {
|
? {
|
||||||
title: `${guide.name}`,
|
title: `${guide.name}`,
|
||||||
description: guide.overview,
|
description: guide.overview,
|
||||||
|
alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}` },
|
||||||
openGraph: { images: [guide.coverUrl] },
|
openGraph: { images: [guide.coverUrl] },
|
||||||
twitter: { images: [guide.coverUrl] },
|
twitter: { images: [guide.coverUrl] },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ",
|
description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ",
|
||||||
keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"],
|
keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"],
|
||||||
|
robots: process.env.PUBLIC_MIRROR === "true"
|
||||||
|
? { index: false, follow: false }
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { SiteFooter } from "@/components/public/site-footer";
|
|||||||
import { StructuredDirectory } from "@/components/public/structured-directory";
|
import { StructuredDirectory } from "@/components/public/structured-directory";
|
||||||
import { SiteHeader } from "@/components/public/site-header";
|
import { SiteHeader } from "@/components/public/site-header";
|
||||||
import { getCachedPublicGuideCards } from "@/lib/cache/public";
|
import { getCachedPublicGuideCards } from "@/lib/cache/public";
|
||||||
|
import { siteUrl } from "@/lib/site-url";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
alternates: { canonical: siteUrl },
|
||||||
|
};
|
||||||
|
|
||||||
async function DirectoryContent() {
|
async function DirectoryContent() {
|
||||||
await connection();
|
await connection();
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { MetadataRoute } from "next";
|
|||||||
import { siteUrl } from "@/lib/site-url";
|
import { siteUrl } from "@/lib/site-url";
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
if (process.env.PUBLIC_MIRROR === "true") {
|
||||||
|
return { rules: { userAgent: "*", disallow: "/" } };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
|
|||||||
+5
-2
@@ -10,6 +10,8 @@ import {
|
|||||||
} from "@/components/ui/avatar";
|
} from "@/components/ui/avatar";
|
||||||
import { SiteFooter } from "@/components/public/site-footer";
|
import { SiteFooter } from "@/components/public/site-footer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
import { siteUrl } from "@/lib/site-url";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -57,6 +59,7 @@ const specialThanks: ThanksProfile[] = [
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Special Thanks",
|
title: "Special Thanks",
|
||||||
description: "ขอบคุณทุกคนที่มีส่วนช่วยให้ Buzz Guide เกิดขึ้นได้",
|
description: "ขอบคุณทุกคนที่มีส่วนช่วยให้ Buzz Guide เกิดขึ้นได้",
|
||||||
|
alternates: { canonical: `${siteUrl}/thanks` },
|
||||||
};
|
};
|
||||||
|
|
||||||
function getInitials(username: string) {
|
function getInitials(username: string) {
|
||||||
@@ -73,7 +76,7 @@ export default function ThanksPage() {
|
|||||||
className="flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/icon.png"
|
src={withBasePath("/icon.png")}
|
||||||
alt=""
|
alt=""
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
@@ -97,7 +100,7 @@ export default function ThanksPage() {
|
|||||||
<Card className="w-full gap-0 py-0 sm:flex-row sm:items-center">
|
<Card className="w-full gap-0 py-0 sm:flex-row sm:items-center">
|
||||||
<CardHeader className="grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1 py-4 text-left sm:flex-1">
|
<CardHeader className="grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1 py-4 text-left sm:flex-1">
|
||||||
<Image
|
<Image
|
||||||
src="/thanks/astral.png"
|
src={withBasePath("/thanks/astral.png")}
|
||||||
alt="Astral Development
|
alt="Astral Development
|
||||||
"
|
"
|
||||||
width={128}
|
width={128}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
hasDeploymentChanged,
|
hasDeploymentChanged,
|
||||||
normalizeDeploymentId,
|
normalizeDeploymentId,
|
||||||
} from "@/lib/deployment/version";
|
} from "@/lib/deployment/version";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
|
||||||
const UPDATE_TOAST_ID = "deployment-update";
|
const UPDATE_TOAST_ID = "deployment-update";
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export function DeploymentUpdateNotifier({
|
|||||||
const updateAvailable = useRef(false);
|
const updateAvailable = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const source = new EventSource("/api/active");
|
const source = new EventSource(withBasePath("/api/active"));
|
||||||
const receiveDeployment = (event: Event) => {
|
const receiveDeployment = (event: Event) => {
|
||||||
if (
|
if (
|
||||||
updateAvailable.current ||
|
updateAvailable.current ||
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { useEffect } from "react";
|
|||||||
|
|
||||||
import { parseInvalidationEvent } from "@/lib/events/invalidation";
|
import { parseInvalidationEvent } from "@/lib/events/invalidation";
|
||||||
import { createRefreshScheduler } from "@/lib/events/refresh";
|
import { createRefreshScheduler } from "@/lib/events/refresh";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
|
||||||
export function LiveRefresh({ endpoint }: { endpoint: string }) {
|
export function LiveRefresh({ endpoint }: { endpoint: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scheduler = createRefreshScheduler(() => router.refresh());
|
const scheduler = createRefreshScheduler(() => router.refresh());
|
||||||
const source = new EventSource(endpoint);
|
const source = new EventSource(withBasePath(endpoint));
|
||||||
const connected = () => scheduler.connected();
|
const connected = () => scheduler.connected();
|
||||||
const notified = (message: Event) => {
|
const notified = (message: Event) => {
|
||||||
if (!(message instanceof MessageEvent) || typeof message.data !== "string") {
|
if (!(message instanceof MessageEvent) || typeof message.data !== "string") {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
|
||||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||||
import { ExportText } from "@/components/public/export-language";
|
import { ExportText } from "@/components/public/export-language";
|
||||||
@@ -27,7 +28,7 @@ export function ConstellationImage({
|
|||||||
|
|
||||||
<div className="absolute inset-x-20 top-7 z-10 flex items-start justify-center gap-5">
|
<div className="absolute inset-x-20 top-7 z-10 flex items-start justify-center gap-5">
|
||||||
<Image
|
<Image
|
||||||
src="/icon/nav/Constellations.webp"
|
src={withBasePath("/icon/nav/Constellations.webp")}
|
||||||
alt=""
|
alt=""
|
||||||
width={112}
|
width={112}
|
||||||
height={112}
|
height={112}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
|
||||||
export function GuideExportBranding({
|
export function GuideExportBranding({
|
||||||
backgroundUrl,
|
backgroundUrl,
|
||||||
@@ -21,7 +22,7 @@ export function GuideExportBranding({
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-[#050817]/65" />
|
<div className="absolute inset-0 bg-[#050817]/65" />
|
||||||
<Image
|
<Image
|
||||||
src="/icon/Watermark.png"
|
src={withBasePath("/icon/Watermark.png")}
|
||||||
alt=""
|
alt=""
|
||||||
width={constellation ? 280 : 180}
|
width={constellation ? 280 : 180}
|
||||||
height={constellation ? 158 : 101}
|
height={constellation ? 158 : 101}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRef, type PointerEvent } from "react";
|
import { useRef, type PointerEvent } from "react";
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ export function MaterialPoster({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Image
|
<Image
|
||||||
src="/img/mat/fade.png"
|
src={withBasePath("/img/mat/fade.png")}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes={`${POSTER_EXPORT_WIDTH}px`}
|
sizes={`${POSTER_EXPORT_WIDTH}px`}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { Suspense } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { VisitorPresence } from "@/components/public/visitor-presence";
|
import { VisitorPresence } from "@/components/public/visitor-presence";
|
||||||
import { getAdminSession } from "@/lib/auth/server";
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
|
||||||
async function AdminEditLink({ editHref }: { editHref: string }) {
|
async function AdminEditLink({ editHref }: { editHref: string }) {
|
||||||
|
if (process.env.PUBLIC_MIRROR === "true") return null;
|
||||||
const isAdmin = Boolean(await getAdminSession());
|
const isAdmin = Boolean(await getAdminSession());
|
||||||
|
|
||||||
return isAdmin ? (
|
return isAdmin ? (
|
||||||
@@ -29,7 +31,7 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
|
|||||||
href="/"
|
href="/"
|
||||||
className="mr-auto flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="mr-auto flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<Image src="/icon.png" alt="" width={36} height={36} className="size-9 object-contain" />
|
<Image src={withBasePath("/icon.png")} alt="" width={36} height={36} className="size-9 object-contain" />
|
||||||
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav aria-label="Public navigation" className="flex items-center gap-1">
|
<nav aria-label="Public navigation" className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { elementIconFilter } from "@/lib/catalog/element";
|
|||||||
import type { PublicGuide } from "@/lib/guides/queries";
|
import type { PublicGuide } from "@/lib/guides/queries";
|
||||||
import { artifactTierCount } from "@/lib/guides/artifacts";
|
import { artifactTierCount } from "@/lib/guides/artifacts";
|
||||||
import { navigationIconPath } from "@/lib/navigation";
|
import { navigationIconPath } from "@/lib/navigation";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
import {
|
import {
|
||||||
normalizeTargetStat,
|
normalizeTargetStat,
|
||||||
recommendedStatElement,
|
recommendedStatElement,
|
||||||
@@ -419,7 +420,7 @@ function WeaponSectionContent({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<Image
|
<Image
|
||||||
src="/icon/nav/Weapon.png"
|
src={withBasePath("/icon/nav/Weapon.png")}
|
||||||
alt=""
|
alt=""
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
@@ -656,21 +657,21 @@ function ArtifactSectionContent({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-3 p-0">
|
<CardContent className="grid grid-cols-3 p-0">
|
||||||
<ArtifactStat
|
<ArtifactStat
|
||||||
iconSrc="/icon/Icon_Sands_of_Eon.webp"
|
iconSrc={withBasePath("/icon/Icon_Sands_of_Eon.webp")}
|
||||||
label={exportLabel(exporting, "นาฬิกา", "Sands")}
|
label={exportLabel(exporting, "นาฬิกา", "Sands")}
|
||||||
values={condition.sands}
|
values={condition.sands}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
elementIconUrls={guide.elementIconUrls}
|
elementIconUrls={guide.elementIconUrls}
|
||||||
/>
|
/>
|
||||||
<ArtifactStat
|
<ArtifactStat
|
||||||
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
|
iconSrc={withBasePath("/icon/Icon_Goblet_of_Eonothem.webp")}
|
||||||
label={exportLabel(exporting, "ถ้วย", "Goblet")}
|
label={exportLabel(exporting, "ถ้วย", "Goblet")}
|
||||||
values={condition.goblet}
|
values={condition.goblet}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
elementIconUrls={guide.elementIconUrls}
|
elementIconUrls={guide.elementIconUrls}
|
||||||
/>
|
/>
|
||||||
<ArtifactStat
|
<ArtifactStat
|
||||||
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
|
iconSrc={withBasePath("/icon/Icon_Circlet_of_Logos.webp")}
|
||||||
label={exportLabel(exporting, "หัว", "Circlet")}
|
label={exportLabel(exporting, "หัว", "Circlet")}
|
||||||
values={condition.circlet}
|
values={condition.circlet}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
@@ -721,7 +722,7 @@ function ArtifactSectionContent({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<Image
|
<Image
|
||||||
src="/icon/nav/Artifact.webp"
|
src={withBasePath("/icon/nav/Artifact.webp")}
|
||||||
alt=""
|
alt=""
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
@@ -1155,7 +1156,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<Image
|
<Image
|
||||||
src="/icon/nav/Constellations.webp"
|
src={withBasePath("/icon/nav/Constellations.webp")}
|
||||||
alt=""
|
alt=""
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useEffectEvent, useState } from "react";
|
import { useEffect, useEffectEvent, useState } from "react";
|
||||||
|
|
||||||
import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants";
|
import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants";
|
||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
|
||||||
type VisitorCounts = {
|
type VisitorCounts = {
|
||||||
global: number;
|
global: number;
|
||||||
@@ -51,7 +52,7 @@ function useHeartbeat<T>({
|
|||||||
const [value, setValue] = useState<T | null>(null);
|
const [value, setValue] = useState<T | null>(null);
|
||||||
const send = useEffectEvent(async (active = true) => {
|
const send = useEffectEvent(async (active = true) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(withBasePath(endpoint), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Restart all Buzz Sheet workloads after updating the Secret:
|
|||||||
```bash
|
```bash
|
||||||
kubectl rollout restart deployment/buzz-sheet \
|
kubectl rollout restart deployment/buzz-sheet \
|
||||||
--namespace buzz-sheet
|
--namespace buzz-sheet
|
||||||
|
kubectl rollout restart deployment/buzz-sheet-mirror \
|
||||||
|
--namespace buzz-sheet
|
||||||
kubectl rollout restart deployment/buzz-sheet-worker \
|
kubectl rollout restart deployment/buzz-sheet-worker \
|
||||||
--namespace buzz-sheet
|
--namespace buzz-sheet
|
||||||
kubectl rollout restart deployment/buzz-sheet-discord-worker \
|
kubectl rollout restart deployment/buzz-sheet-discord-worker \
|
||||||
@@ -67,6 +69,9 @@ Wait for every rollout to finish:
|
|||||||
kubectl rollout status deployment/buzz-sheet \
|
kubectl rollout status deployment/buzz-sheet \
|
||||||
--namespace buzz-sheet \
|
--namespace buzz-sheet \
|
||||||
--timeout=10m
|
--timeout=10m
|
||||||
|
kubectl rollout status deployment/buzz-sheet-mirror \
|
||||||
|
--namespace buzz-sheet \
|
||||||
|
--timeout=10m
|
||||||
kubectl rollout status deployment/buzz-sheet-worker \
|
kubectl rollout status deployment/buzz-sheet-worker \
|
||||||
--namespace buzz-sheet \
|
--namespace buzz-sheet \
|
||||||
--timeout=10m
|
--timeout=10m
|
||||||
@@ -79,6 +84,7 @@ kubectl rollout status deployment/buzz-sheet-discord-worker \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsS 'https://guide.sudloh.com/api/health?ready=1'
|
curl -fsS 'https://guide.sudloh.com/api/health?ready=1'
|
||||||
|
curl -fsS 'https://buzz.sudloh.com/guide/api/health?ready=1'
|
||||||
bun logs
|
bun logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ rules:
|
|||||||
verbs: ["get"]
|
verbs: ["get"]
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
resourceNames: ["buzz-sheet", "buzz-sheet-worker", "buzz-sheet-discord-worker"]
|
resourceNames: ["buzz-sheet", "buzz-sheet-mirror", "buzz-sheet-worker", "buzz-sheet-discord-worker"]
|
||||||
verbs: ["get", "watch", "patch", "update"]
|
verbs: ["get", "watch", "patch", "update"]
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["replicasets"]
|
resources: ["replicasets"]
|
||||||
|
|||||||
@@ -19,3 +19,13 @@ spec:
|
|||||||
name: buzz-sheet
|
name: buzz-sheet
|
||||||
port:
|
port:
|
||||||
number: 3000
|
number: 3000
|
||||||
|
- host: buzz.sudloh.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /guide
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: buzz-sheet-mirror
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
|
|||||||
@@ -5,14 +5,21 @@ resources:
|
|||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
- configmap.yaml
|
- configmap.yaml
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
|
- mirror-deployment.yaml
|
||||||
- worker-deployment.yaml
|
- worker-deployment.yaml
|
||||||
- discord-worker-deployment.yaml
|
- discord-worker-deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
|
- mirror-service.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
- hpa.yaml
|
- hpa.yaml
|
||||||
|
- mirror-hpa.yaml
|
||||||
- pdb.yaml
|
- pdb.yaml
|
||||||
|
- mirror-pdb.yaml
|
||||||
- ci-rbac.yaml
|
- ci-rbac.yaml
|
||||||
images:
|
images:
|
||||||
- name: buzz-sheet-app
|
- name: buzz-sheet-app
|
||||||
newName: registry.neko-piranha.ts.net/astral/buzz-sheet
|
newName: registry.neko-piranha.ts.net/astral/buzz-sheet
|
||||||
newTag: replace-me
|
newTag: replace-me
|
||||||
|
- name: buzz-sheet-mirror
|
||||||
|
newName: registry.neko-piranha.ts.net/astral/buzz-sheet
|
||||||
|
newTag: mirror-replace-me
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: buzz-sheet-mirror
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
app.kubernetes.io/component: public-mirror
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 1
|
||||||
|
maxUnavailable: 0
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
app.kubernetes.io/component: public-mirror
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
app.kubernetes.io/component: public-mirror
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
terminationGracePeriodSeconds: 30
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/arch: arm64
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: buzz-sheet-mirror
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: buzz-sheet-config
|
||||||
|
- secretRef:
|
||||||
|
name: buzz-sheet-env
|
||||||
|
env:
|
||||||
|
- name: NEXT_DEPLOYMENT_ID
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: buzz-sheet-config
|
||||||
|
key: NEXT_DEPLOYMENT_ID
|
||||||
|
- name: PUBLIC_MIRROR
|
||||||
|
value: "true"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 1Gi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 2Gi
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /guide/api/health
|
||||||
|
port: http
|
||||||
|
failureThreshold: 30
|
||||||
|
periodSeconds: 2
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /guide/api/health?ready=1
|
||||||
|
port: http
|
||||||
|
failureThreshold: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 4
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /guide/api/health
|
||||||
|
port: http
|
||||||
|
failureThreshold: 3
|
||||||
|
periodSeconds: 20
|
||||||
|
timeoutSeconds: 3
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: next-cache
|
||||||
|
mountPath: /app/.next/cache
|
||||||
|
- name: temporary
|
||||||
|
mountPath: /tmp
|
||||||
|
volumes:
|
||||||
|
- name: next-cache
|
||||||
|
emptyDir: {}
|
||||||
|
- name: temporary
|
||||||
|
emptyDir: {}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: buzz-sheet-mirror
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: buzz-sheet-mirror
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 6
|
||||||
|
behavior:
|
||||||
|
scaleDown:
|
||||||
|
stabilizationWindowSeconds: 300
|
||||||
|
scaleUp:
|
||||||
|
stabilizationWindowSeconds: 30
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 70
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: memory
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 75
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: buzz-sheet-mirror
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
spec:
|
||||||
|
minAvailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
app.kubernetes.io/component: public-mirror
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: buzz-sheet-mirror
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
app.kubernetes.io/component: public-mirror
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: buzz-sheet
|
||||||
|
app.kubernetes.io/component: public-mirror
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 3000
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const basePath = process.env.NEXT_PUBLIC_BASE_PATH?.trim() ?? "";
|
||||||
|
|
||||||
|
export function withBasePath(path: string): string {
|
||||||
|
return `${basePath}${path}`;
|
||||||
|
}
|
||||||
+8
-6
@@ -1,9 +1,11 @@
|
|||||||
|
import { withBasePath } from "@/lib/base-path";
|
||||||
|
|
||||||
export function navigationIconPath(slug: string): string | null {
|
export function navigationIconPath(slug: string): string | null {
|
||||||
if (slug === "overview") return "/icon/nav/Overview.webp";
|
if (slug === "overview") return withBasePath("/icon/nav/Overview.webp");
|
||||||
if (slug === "weapon") return "/icon/nav/Weapon.png";
|
if (slug === "weapon") return withBasePath("/icon/nav/Weapon.png");
|
||||||
if (slug === "artifact") return "/icon/nav/Artifact.webp";
|
if (slug === "artifact") return withBasePath("/icon/nav/Artifact.webp");
|
||||||
if (slug === "constellations") return "/icon/nav/Constellations.webp";
|
if (slug === "constellations") return withBasePath("/icon/nav/Constellations.webp");
|
||||||
if (slug === "team") return "/icon/nav/Team-calc.webp";
|
if (slug === "team") return withBasePath("/icon/nav/Team-calc.webp");
|
||||||
if (slug === "extra" || slug.startsWith("extra-")) return "/icon/nav/Extra.webp";
|
if (slug === "extra" || slug.startsWith("extra-")) return withBasePath("/icon/nav/Extra.webp");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
basePath: process.env.NEXT_PUBLIC_BASE_PATH?.trim() || undefined,
|
||||||
cacheComponents: true,
|
cacheComponents: true,
|
||||||
cacheHandlers: {
|
cacheHandlers: {
|
||||||
remote: resolve(process.cwd(), "cache-handlers/redis-handler.cjs"),
|
remote: resolve(process.cwd(), "cache-handlers/redis-handler.cjs"),
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"logs": "kubectl logs --namespace buzz-sheet --selector 'app.kubernetes.io/name=buzz-sheet,app.kubernetes.io/component in (web,worker,discord-worker)' --all-containers=true --prefix=true --tail=100 --follow --max-log-requests=10",
|
"logs": "kubectl logs --namespace buzz-sheet --selector 'app.kubernetes.io/name=buzz-sheet,app.kubernetes.io/component in (web,public-mirror,worker,discord-worker)' --all-containers=true --prefix=true --tail=100 --follow --max-log-requests=10",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "bun scripts/migrate.ts",
|
"db:migrate": "bun scripts/migrate.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export function proxy() {
|
||||||
|
if (process.env.PUBLIC_MIRROR === "true") {
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/admin/:path*",
|
||||||
|
"/register",
|
||||||
|
"/api/admin/:path*",
|
||||||
|
"/api/auth/:path*",
|
||||||
|
"/api/events/admin",
|
||||||
|
"/api/presence/admin",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -37,6 +37,9 @@ describe("production deployment contract", () => {
|
|||||||
expect(service).toContain("targetPort: http");
|
expect(service).toContain("targetPort: http");
|
||||||
expect(ingress).toContain("ingressClassName: traefik");
|
expect(ingress).toContain("ingressClassName: traefik");
|
||||||
expect(ingress).toContain("host: guide.sudloh.com");
|
expect(ingress).toContain("host: guide.sudloh.com");
|
||||||
|
expect(ingress).toContain("host: buzz.sudloh.com");
|
||||||
|
expect(ingress).toContain("path: /guide");
|
||||||
|
expect(ingress).toContain("name: buzz-sheet-mirror");
|
||||||
expect(ingress).toContain(
|
expect(ingress).toContain(
|
||||||
'traefik.ingress.kubernetes.io/read-timeout: "200"',
|
'traefik.ingress.kubernetes.io/read-timeout: "200"',
|
||||||
);
|
);
|
||||||
@@ -44,6 +47,24 @@ describe("production deployment contract", () => {
|
|||||||
expect(ingress).not.toContain("router.tls");
|
expect(ingress).not.toContain("router.tls");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs the public mirror as an independently resilient workload", async () => {
|
||||||
|
const [deployment, service, hpa, pdb] = await Promise.all([
|
||||||
|
repositoryFile("k8s/base/mirror-deployment.yaml"),
|
||||||
|
repositoryFile("k8s/base/mirror-service.yaml"),
|
||||||
|
repositoryFile("k8s/base/mirror-hpa.yaml"),
|
||||||
|
repositoryFile("k8s/base/mirror-pdb.yaml"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(deployment).toContain("name: buzz-sheet-mirror");
|
||||||
|
expect(deployment).toContain("replicas: 2");
|
||||||
|
expect(deployment).toContain("path: /guide/api/health");
|
||||||
|
expect(deployment).toContain('value: "true"');
|
||||||
|
expect(service).toContain("app.kubernetes.io/component: public-mirror");
|
||||||
|
expect(hpa).toContain("name: buzz-sheet-mirror");
|
||||||
|
expect(hpa).toContain("maxReplicas: 6");
|
||||||
|
expect(pdb).toContain("minAvailable: 1");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps availability and scaling bounds explicit", async () => {
|
it("keeps availability and scaling bounds explicit", async () => {
|
||||||
const [hpa, pdb] = await Promise.all([
|
const [hpa, pdb] = await Promise.all([
|
||||||
repositoryFile("k8s/base/hpa.yaml"),
|
repositoryFile("k8s/base/hpa.yaml"),
|
||||||
@@ -63,12 +84,16 @@ describe("production deployment contract", () => {
|
|||||||
"k8s/base/namespace.yaml",
|
"k8s/base/namespace.yaml",
|
||||||
"k8s/base/configmap.yaml",
|
"k8s/base/configmap.yaml",
|
||||||
"k8s/base/deployment.yaml",
|
"k8s/base/deployment.yaml",
|
||||||
|
"k8s/base/mirror-deployment.yaml",
|
||||||
"k8s/base/worker-deployment.yaml",
|
"k8s/base/worker-deployment.yaml",
|
||||||
"k8s/base/discord-worker-deployment.yaml",
|
"k8s/base/discord-worker-deployment.yaml",
|
||||||
"k8s/base/service.yaml",
|
"k8s/base/service.yaml",
|
||||||
|
"k8s/base/mirror-service.yaml",
|
||||||
"k8s/base/ingress.yaml",
|
"k8s/base/ingress.yaml",
|
||||||
"k8s/base/hpa.yaml",
|
"k8s/base/hpa.yaml",
|
||||||
|
"k8s/base/mirror-hpa.yaml",
|
||||||
"k8s/base/pdb.yaml",
|
"k8s/base/pdb.yaml",
|
||||||
|
"k8s/base/mirror-pdb.yaml",
|
||||||
"k8s/base/ci-rbac.yaml",
|
"k8s/base/ci-rbac.yaml",
|
||||||
"k8s/migration/job.yaml",
|
"k8s/migration/job.yaml",
|
||||||
];
|
];
|
||||||
@@ -110,6 +135,9 @@ describe("production deployment contract", () => {
|
|||||||
expect(workflow).toContain("bun run lint");
|
expect(workflow).toContain("bun run lint");
|
||||||
expect(workflow).toContain("kubectl kustomize k8s/");
|
expect(workflow).toContain("kubectl kustomize k8s/");
|
||||||
expect(workflow).toContain("--target app");
|
expect(workflow).toContain("--target app");
|
||||||
|
expect(workflow).toContain("--build-arg NEXT_PUBLIC_BASE_PATH=/guide");
|
||||||
|
expect(workflow).toContain("--build-arg PUBLIC_MIRROR=true");
|
||||||
|
expect(workflow).toContain("mirror-${{ gitea.sha }}");
|
||||||
expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"');
|
expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"');
|
||||||
expect(workflow).toContain(
|
expect(workflow).toContain(
|
||||||
'--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID"',
|
'--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID"',
|
||||||
@@ -123,6 +151,7 @@ describe("production deployment contract", () => {
|
|||||||
);
|
);
|
||||||
expect(workflow).toContain("migrate-${{ gitea.sha }}");
|
expect(workflow).toContain("migrate-${{ gitea.sha }}");
|
||||||
expect(workflow).toContain("deployment/buzz-sheet-discord-worker");
|
expect(workflow).toContain("deployment/buzz-sheet-discord-worker");
|
||||||
|
expect(workflow).toContain("deployment/buzz-sheet-mirror");
|
||||||
const deleteMigrationJob = workflow.indexOf(
|
const deleteMigrationJob = workflow.indexOf(
|
||||||
'delete job buzz-sheet-migrate --ignore-not-found',
|
'delete job buzz-sheet-migrate --ignore-not-found',
|
||||||
);
|
);
|
||||||
@@ -145,11 +174,22 @@ describe("production deployment contract", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(layout).toContain("<DeploymentUpdateNotifier");
|
expect(layout).toContain("<DeploymentUpdateNotifier");
|
||||||
expect(notifier).toContain('new EventSource("/api/active")');
|
expect(notifier).toContain('new EventSource(withBasePath("/api/active"))');
|
||||||
expect(notifier).toContain("window.location.reload()");
|
expect(notifier).toContain("window.location.reload()");
|
||||||
expect(notifier).toContain('label: "รีโหลด"');
|
expect(notifier).toContain('label: "รีโหลด"');
|
||||||
expect(route).toContain('channel.sendNamed("deployment", deploymentId)');
|
expect(route).toContain('channel.sendNamed("deployment", deploymentId)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks private routes only in the public mirror build", async () => {
|
||||||
|
const proxy = await repositoryFile("proxy.ts");
|
||||||
|
|
||||||
|
expect(proxy).toContain('process.env.PUBLIC_MIRROR === "true"');
|
||||||
|
expect(proxy).toContain('"/admin/:path*"');
|
||||||
|
expect(proxy).toContain('"/api/admin/:path*"');
|
||||||
|
expect(proxy).toContain('"/api/auth/:path*"');
|
||||||
|
expect(proxy).toContain("status: 404");
|
||||||
|
expect(proxy).toContain("NextResponse.next()");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("environment template contract", () => {
|
describe("environment template contract", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user