refactor(host): migrate from docker swarm to kubernetes
This commit is contained in:
@@ -57,13 +57,34 @@ jobs:
|
|||||||
docker push mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG &
|
docker push mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG &
|
||||||
wait
|
wait
|
||||||
|
|
||||||
- name: Deploy to production
|
- name: Install kubectl
|
||||||
|
run: |
|
||||||
|
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||||
|
curl -sLO "https://dl.k8s.io/release/v1.35.0/bin/linux/${ARCH}/kubectl"
|
||||||
|
chmod +x kubectl
|
||||||
|
mv kubectl /usr/local/bin/kubectl
|
||||||
|
|
||||||
|
- name: Configure kubectl
|
||||||
|
run: |
|
||||||
|
kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true
|
||||||
|
kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }}
|
||||||
|
kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz
|
||||||
|
kubectl config use-context buzz
|
||||||
|
|
||||||
|
- name: Deploy to k3s
|
||||||
run: |
|
run: |
|
||||||
TAG=${{ gitea.sha }}
|
TAG=${{ gitea.sha }}
|
||||||
|
|
||||||
docker service update \
|
# DB migration
|
||||||
--image mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG \
|
kubectl delete job db-migrate -n buzz --ignore-not-found
|
||||||
buzz_app
|
kubectl create job db-migrate \
|
||||||
docker service update \
|
--image=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG \
|
||||||
--image mts.dgnr.us:5000/${{ gitea.repository }}:backend-$TAG \
|
-n buzz \
|
||||||
buzz_backend
|
-- bun dr push
|
||||||
|
kubectl wait --for=condition=complete job/db-migrate -n buzz --timeout=180s
|
||||||
|
|
||||||
|
# Roll app & backend
|
||||||
|
kubectl set image deployment/app app=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG -n buzz
|
||||||
|
kubectl set image deployment/backend backend=mts.dgnr.us:5000/${{ gitea.repository }}:backend-$TAG -n buzz
|
||||||
|
kubectl rollout status deployment/app -n buzz --timeout=180s
|
||||||
|
kubectl rollout status deployment/backend -n buzz --timeout=180s
|
||||||
|
|||||||
+1
-15
@@ -21,21 +21,6 @@ COPY patches ./patches
|
|||||||
# Install into cache, then persist into real node_modules
|
# Install into cache, then persist into real node_modules
|
||||||
RUN --mount=type=cache,target=/root/.bun bun install --frozen-lockfile
|
RUN --mount=type=cache,target=/root/.bun bun install --frozen-lockfile
|
||||||
|
|
||||||
### Stage N: drizzle ###
|
|
||||||
FROM oven/bun:canary-alpine as drizzle
|
|
||||||
|
|
||||||
# Isolation
|
|
||||||
RUN adduser -Du 1001 container
|
|
||||||
USER container
|
|
||||||
WORKDIR /home/container
|
|
||||||
COPY --from=deps /home/container/node_modules ./node_modules
|
|
||||||
COPY --from=deps /home/container/package*.json ./
|
|
||||||
COPY --from=deps /home/container/bun.lock ./
|
|
||||||
COPY --chown=1001 ./drizzle.config.ts ./.env ./
|
|
||||||
COPY --chown=1001 ./lib/db ./lib/db
|
|
||||||
|
|
||||||
RUN bun dr push
|
|
||||||
|
|
||||||
### Stage 2: builder ###
|
### Stage 2: builder ###
|
||||||
FROM oven/bun:canary-alpine AS builder
|
FROM oven/bun:canary-alpine AS builder
|
||||||
|
|
||||||
@@ -86,5 +71,6 @@ COPY --from=builder /home/container/util ./util
|
|||||||
COPY --from=builder /home/container/lib ./lib
|
COPY --from=builder /home/container/lib ./lib
|
||||||
COPY --from=builder /home/container/tsconfig.json ./tsconfig.json
|
COPY --from=builder /home/container/tsconfig.json ./tsconfig.json
|
||||||
COPY --from=builder /home/container/.version ./.version
|
COPY --from=builder /home/container/.version ./.version
|
||||||
|
COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts
|
||||||
|
|
||||||
CMD ["bun", "start"]
|
CMD ["bun", "start"]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { MaybeWrap } from "@/components/action-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,11 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
@@ -40,12 +46,6 @@ import { setLimit, toggleCheck, toggleLock } from "@/lib/api";
|
|||||||
import { shared } from "@/lib/comms";
|
import { shared } from "@/lib/comms";
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MaybeWrap } from "@/components/action-button";
|
|
||||||
import {
|
|
||||||
HoverCard,
|
|
||||||
HoverCardContent,
|
|
||||||
HoverCardTrigger,
|
|
||||||
} from "@/components/ui/hover-card";
|
|
||||||
|
|
||||||
export function SidebarLink({
|
export function SidebarLink({
|
||||||
submission,
|
submission,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isNotNull, sql } from "drizzle-orm";
|
||||||
import { Dice3, Trash2 } from "lucide-react";
|
import { Dice3, Trash2 } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -27,9 +28,8 @@ import {
|
|||||||
import { getArtifactConfig, random, wipe } from "@/lib/api";
|
import { getArtifactConfig, random, wipe } from "@/lib/api";
|
||||||
import { adminCheck } from "@/lib/auth";
|
import { adminCheck } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { LimitManager, SubmissionList, Watcher } from "./client";
|
|
||||||
import { isNotNull, sql } from "drizzle-orm";
|
|
||||||
import { submissions } from "@/lib/db/schema";
|
import { submissions } from "@/lib/db/schema";
|
||||||
|
import { LimitManager, SubmissionList, Watcher } from "./client";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "เสือกไอดีชาวบ้าน (แอดมิน)",
|
title: "เสือกไอดีชาวบ้าน (แอดมิน)",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { PlusIcon } from "lucide-react";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { ActionButton } from "@/components/action-button";
|
||||||
import { FormAction, FormInput, FormProvider } from "@/components/form";
|
import { FormAction, FormInput, FormProvider } from "@/components/form";
|
||||||
|
import { formParse } from "@/components/form-submit";
|
||||||
import { ModalBase } from "@/components/modal";
|
import { ModalBase } from "@/components/modal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
|
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
|
||||||
@@ -12,9 +14,7 @@ import { actionLog } from "@/lib/api";
|
|||||||
import { adminCheck } from "@/lib/auth";
|
import { adminCheck } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { settings } from "@/lib/db/schema";
|
import { settings } from "@/lib/db/schema";
|
||||||
import { formParse } from "@/components/form-submit";
|
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { ActionButton } from "@/components/action-button";
|
|
||||||
import { resetGoal } from "../../api";
|
import { resetGoal } from "../../api";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { adminCheck } from "@/lib/auth";
|
import { adminCheck } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { donations, settings } from "@/lib/db/schema";
|
import { donations, settings } from "@/lib/db/schema";
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { fileToDataUrl } from "@/lib/utils";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { getPostHogClient } from "@/lib/posthog-server";
|
import { getPostHogClient } from "@/lib/posthog-server";
|
||||||
|
import { fileToDataUrl } from "@/lib/utils";
|
||||||
|
|
||||||
export async function resetGoal() {
|
export async function resetGoal() {
|
||||||
if (!(await adminCheck())) throw new Error("Unauthorized");
|
if (!(await adminCheck())) throw new Error("Unauthorized");
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DataTable } from "@/components/tantable";
|
|
||||||
import { SimpleTooltip } from "@/components/tooltip";
|
|
||||||
import {
|
|
||||||
HoverCard,
|
|
||||||
HoverCardContent,
|
|
||||||
HoverCardTrigger,
|
|
||||||
} from "@/components/ui/hover-card";
|
|
||||||
import type { donations } from "@/lib/db/schema";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
BitcoinIcon,
|
BitcoinIcon,
|
||||||
@@ -18,15 +10,23 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
MessageCircleWarning,
|
MessageCircleWarning,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useEllipsisVisible } from "react-hook-text-overflow";
|
import { useEllipsisVisible } from "react-hook-text-overflow";
|
||||||
|
import { DataTable } from "@/components/tantable";
|
||||||
|
import { SimpleTooltip } from "@/components/tooltip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import type { donations } from "@/lib/db/schema";
|
||||||
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { ActionButton } from "../../../../components/action-button";
|
import { ActionButton } from "../../../../components/action-button";
|
||||||
import { getImage, reloadWidget, resendPopup, testPopup } from "./api";
|
import { getImage, reloadWidget, resendPopup, testPopup } from "./api";
|
||||||
import { useEffect } from "react";
|
|
||||||
import posthog from "posthog-js";
|
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
||||||
{ accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } },
|
{ accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ErrorModal } from "@/components/error";
|
|
||||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { ErrorModal } from "@/components/error";
|
||||||
|
|
||||||
export default async function DonateAdminLayout({
|
export default async function DonateAdminLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { db } from "@/lib/db";
|
|
||||||
import { DonateAdminPage } from "./client";
|
|
||||||
import { donations } from "@/lib/db/schema";
|
|
||||||
import { desc, getTableColumns, sql } from "drizzle-orm";
|
import { desc, getTableColumns, sql } from "drizzle-orm";
|
||||||
import { adminCheck } from "@/lib/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { adminCheck } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { donations } from "@/lib/db/schema";
|
||||||
|
import { DonateAdminPage } from "./client";
|
||||||
|
|
||||||
export default async function () {
|
export default async function () {
|
||||||
if (!(await adminCheck()))
|
if (!(await adminCheck()))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { QrCodeIcon, SendIcon } from "lucide-react";
|
import { QrCodeIcon, SendIcon } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { th } from "zod/v4/locales";
|
import { th } from "zod/v4/locales";
|
||||||
import PromptpayImage from "#/assets/promptpay.jpg";
|
import PromptpayImage from "#/assets/promptpay.jpg";
|
||||||
import TruemoneyIcon from "#/assets/tmn.webp";
|
import TruemoneyIcon from "#/assets/tmn.webp";
|
||||||
|
import DonateLogo from "#/logos/donate.webp";
|
||||||
import Cropper from "@/components/cropper";
|
import Cropper from "@/components/cropper";
|
||||||
import {
|
import {
|
||||||
FormAction,
|
FormAction,
|
||||||
@@ -27,15 +29,13 @@ import { db } from "@/lib/db";
|
|||||||
import { donations, endgameSlips, submissions } from "@/lib/db/schema";
|
import { donations, endgameSlips, submissions } from "@/lib/db/schema";
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { checkSlip } from "@/lib/payment";
|
import { checkSlip } from "@/lib/payment";
|
||||||
|
import { getPostHogClient } from "@/lib/posthog-server";
|
||||||
import { fileToDataUrl } from "@/lib/utils";
|
import { fileToDataUrl } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
CurrencyInput,
|
CurrencyInput,
|
||||||
SlipUpload,
|
SlipUpload,
|
||||||
} from "../rubgram/admin/@modal/manual/client";
|
} from "../rubgram/admin/@modal/manual/client";
|
||||||
import { DownloadButton } from "../rubgram/client";
|
import { DownloadButton } from "../rubgram/client";
|
||||||
import { getPostHogClient } from "@/lib/posthog-server";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import DonateLogo from "#/logos/donate.webp";
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record<
|
const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record<
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SQL } from "drizzle-orm";
|
import type { SQL } from "drizzle-orm";
|
||||||
import { asc, desc, max, sql, sum } from "drizzle-orm";
|
import { asc, desc, max, sql, sum } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@@ -7,7 +8,6 @@ import { donations } from "@/lib/db/schema";
|
|||||||
import { fileToDataUrl } from "@/lib/utils";
|
import { fileToDataUrl } from "@/lib/utils";
|
||||||
import { Podium } from "./podium";
|
import { Podium } from "./podium";
|
||||||
import { TopDonateTable } from "./table";
|
import { TopDonateTable } from "./table";
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
type NumberString = `${number}`;
|
type NumberString = `${number}`;
|
||||||
type NumberLike = number | NumberString;
|
type NumberLike = number | NumberString;
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { Anuphan } from "next/font/google";
|
||||||
import { ViewTransition } from "react";
|
import { ViewTransition } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import Background from "#/bg.webp";
|
import Background from "#/bg.webp";
|
||||||
@@ -5,7 +6,6 @@ import Image from "@/components/image";
|
|||||||
import { StarsRenderer } from "@/components/stars";
|
import { StarsRenderer } from "@/components/stars";
|
||||||
import Providers, { VersionCheck } from "../client";
|
import Providers, { VersionCheck } from "../client";
|
||||||
import { Navbar } from "./navbar";
|
import { Navbar } from "./navbar";
|
||||||
import { Anuphan } from "next/font/google";
|
|
||||||
|
|
||||||
const anuphan = Anuphan({
|
const anuphan = Anuphan({
|
||||||
variable: "--font-anuphan",
|
variable: "--font-anuphan",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2, UserLock } from "lucide-react";
|
import { Loader2, UserLock } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { type FormEvent, use, useEffect, useState } from "react";
|
import { type FormEvent, use, useEffect, useState } from "react";
|
||||||
@@ -11,7 +12,6 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { Brash } from "./brash";
|
import { Brash } from "./brash";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function LoginPage({
|
export default function LoginPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
+1
-1
@@ -3,10 +3,10 @@ import Link from "next/link";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Banner from "#/banner_t.webp";
|
import Banner from "#/banner_t.webp";
|
||||||
import ArtifactLogo from "#/logos/artifact.webp";
|
import ArtifactLogo from "#/logos/artifact.webp";
|
||||||
|
import DonateLogo from "#/logos/donate.webp";
|
||||||
import GuideLogo from "#/logos/guide.webp";
|
import GuideLogo from "#/logos/guide.webp";
|
||||||
import RubgramLogo from "#/logos/rubgram.webp";
|
import RubgramLogo from "#/logos/rubgram.webp";
|
||||||
import TierlistLogo from "#/logos/tierlist.webp";
|
import TierlistLogo from "#/logos/tierlist.webp";
|
||||||
import DonateLogo from "#/logos/donate.webp";
|
|
||||||
import Image from "@/components/image";
|
import Image from "@/components/image";
|
||||||
import { SimpleTooltip } from "@/components/tooltip";
|
import { SimpleTooltip } from "@/components/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|||||||
@@ -4,15 +4,16 @@
|
|||||||
import { FileSearch2, RefreshCw, X } from "lucide-react";
|
import { FileSearch2, RefreshCw, X } from "lucide-react";
|
||||||
import { type ComponentProps, useEffect, useRef, useState } from "react";
|
import { type ComponentProps, useEffect, useRef, useState } from "react";
|
||||||
import { b2sClient } from "@/app/(ui)/admin/cdn/table";
|
import { b2sClient } from "@/app/(ui)/admin/cdn/table";
|
||||||
|
import { VirtualizedComboBox } from "@/components/combobox";
|
||||||
import { useFormContext } from "@/components/form";
|
import { useFormContext } from "@/components/form";
|
||||||
import { SimpleTooltip } from "@/components/tooltip";
|
import { SimpleTooltip } from "@/components/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from "@/components/ui/input-group";
|
} from "@/components/ui/input-group";
|
||||||
import type { Input } from "@/components/ui/input";
|
|
||||||
import { Kbd } from "@/components/ui/kbd";
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +24,8 @@ import {
|
|||||||
MultiSelectTrigger,
|
MultiSelectTrigger,
|
||||||
MultiSelectValue,
|
MultiSelectValue,
|
||||||
} from "@/components/ui/multi-select";
|
} from "@/components/ui/multi-select";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { VirtualizedComboBox } from "@/components/combobox";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function CurrencyInput(props: ComponentProps<typeof Input>) {
|
export function CurrencyInput(props: ComponentProps<typeof Input>) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { PlusIcon } from "lucide-react";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { th } from "zod/v4/locales";
|
||||||
|
import { getDiscordUsers } from "@/app/api/discord/users/api";
|
||||||
import {
|
import {
|
||||||
FormAction,
|
FormAction,
|
||||||
FormInput,
|
FormInput,
|
||||||
@@ -12,6 +14,15 @@ import { ModalBase } from "@/components/modal";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
|
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { actionLog } from "@/lib/api";
|
import { actionLog } from "@/lib/api";
|
||||||
import { adminCheck } from "@/lib/auth";
|
import { adminCheck } from "@/lib/auth";
|
||||||
@@ -22,19 +33,8 @@ import {
|
|||||||
endgameSubmissions,
|
endgameSubmissions,
|
||||||
endgameTypes,
|
endgameTypes,
|
||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
import { CurrencyInput, ServiceSelect, SlipUpload, UserSelect } from "./client";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import type { SlipokResponse } from "../../../api";
|
import type { SlipokResponse } from "../../../api";
|
||||||
import { getDiscordUsers } from "@/app/api/discord/users/api";
|
import { CurrencyInput, ServiceSelect, SlipUpload, UserSelect } from "./client";
|
||||||
import { th } from "zod/v4/locales";
|
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import Link from "next/link";
|
|||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SimpleTooltip } from "@/components/tooltip";
|
import { SimpleTooltip } from "@/components/tooltip";
|
||||||
import { toggleMonth } from "../../api";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +33,8 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toggleMonth } from "../../api";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { endOfMonth, startOfMonth } from "date-fns";
|
||||||
import { and, between, eq, getTableColumns } from "drizzle-orm";
|
import { and, between, eq, getTableColumns } from "drizzle-orm";
|
||||||
import { startOfMonth, endOfMonth } from "date-fns";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { adminCheck } from "@/lib/auth";
|
import { adminCheck } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { Dice5, PlusIcon, Trash2 } from "lucide-react";
|
import { Dice5, PlusIcon, Trash2 } from "lucide-react";
|
||||||
|
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { ErrorModal } from "@/components/error";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -34,10 +38,6 @@ import {
|
|||||||
SubmissionList,
|
SubmissionList,
|
||||||
Watcher,
|
Watcher,
|
||||||
} from "./client";
|
} from "./client";
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
|
||||||
import { ErrorModal } from "@/components/error";
|
|
||||||
|
|
||||||
export default async function AdminLayout({
|
export default async function AdminLayout({
|
||||||
modal,
|
modal,
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
settings,
|
settings,
|
||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { getPostHogClient } from "@/lib/posthog-server";
|
|
||||||
import { checkSlip } from "@/lib/payment";
|
import { checkSlip } from "@/lib/payment";
|
||||||
|
import { getPostHogClient } from "@/lib/posthog-server";
|
||||||
|
|
||||||
const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } =
|
const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } =
|
||||||
process.env as Record<string, string>;
|
process.env as Record<string, string>;
|
||||||
|
|
||||||
|
|||||||
@@ -449,8 +449,7 @@ export function TierList({
|
|||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{type.name} เวอร์ชั่น {version.name} ระดับ
|
{type.name} เวอร์ชั่น {version.name} ระดับ
|
||||||
<span className="text-yellow-400">{type.mode}</span>{" "}
|
<span className="text-yellow-400">{type.mode}</span> ใช้ได้ถึง{" "}
|
||||||
ใช้ได้ถึง{" "}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-green-400">{version.deprecates}</span>
|
<span className="text-green-400">{version.deprecates}</span>
|
||||||
{deleteMode && (
|
{deleteMode && (
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { getAmberVh } from "@/lib/api";
|
import { getAmberVh } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return new Response(
|
return new Response(
|
||||||
await fetch(
|
await fetch(
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { getAmberVh } from "@/lib/api";
|
import { getAmberVh } from "@/lib/api";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return new Response(
|
return new Response(
|
||||||
await fetch(
|
await fetch(
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { env } from "node:process";
|
import { env } from "node:process";
|
||||||
import Snowflake, { GatewayIntents } from "@sfjs/snowflake";
|
import Snowflake, { GatewayIntents } from "@sfjs/snowflake";
|
||||||
import { redis } from "@/lib/db/redis";
|
|
||||||
import { adminCheck } from "@/lib/auth";
|
|
||||||
import { forbidden } from "next/navigation";
|
import { forbidden } from "next/navigation";
|
||||||
|
import { adminCheck } from "@/lib/auth";
|
||||||
|
import { redis } from "@/lib/db/redis";
|
||||||
|
|
||||||
const bot = new Snowflake({
|
const bot = new Snowflake({
|
||||||
token: env.DISCORD_BOT_TOKEN || "",
|
token: env.DISCORD_BOT_TOKEN || "",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { and, asc, not, sql } from "drizzle-orm";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { donations } from "@/lib/db/schema";
|
import { donations } from "@/lib/db/schema";
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { fileToDataUrl } from "@/lib/utils";
|
|
||||||
import { and, asc, not, sql } from "drizzle-orm";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { getPostHogClient } from "@/lib/posthog-server";
|
import { getPostHogClient } from "@/lib/posthog-server";
|
||||||
|
import { fileToDataUrl } from "@/lib/utils";
|
||||||
|
|
||||||
async function runResume() {
|
async function runResume() {
|
||||||
const [havent] = await db
|
const [havent] = await db
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { GoogleGenAI } from "@google/genai";
|
import { GoogleGenAI } from "@google/genai";
|
||||||
|
import { hash } from "bun";
|
||||||
|
import { PassThrough, Readable } from "stream";
|
||||||
import wav from "wav";
|
import wav from "wav";
|
||||||
import { Readable, PassThrough } from "stream";
|
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { redis } from "@/lib/db/redis";
|
import { redis } from "@/lib/db/redis";
|
||||||
import { hash } from "bun";
|
|
||||||
const { DONATE_WIDGET_KEY, GEMINI_TTS_API_KEY } = process.env as Record<
|
const { DONATE_WIDGET_KEY, GEMINI_TTS_API_KEY } = process.env as Record<
|
||||||
string,
|
string,
|
||||||
string
|
string
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { cdn } from "@/lib/db/schema";
|
import { cdn } from "@/lib/db/schema";
|
||||||
|
|
||||||
export const revalidate = 86400; // 24 hours
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
|
||||||
return await db.select({ id: cdn.id }).from(cdn);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// oxlint-disable tailwindcss/no-unknown-classes
|
// oxlint-disable tailwindcss/no-unknown-classes
|
||||||
|
|
||||||
|
import { isNotNull, sql } from "drizzle-orm";
|
||||||
import { Kanit } from "next/font/google";
|
import { Kanit } from "next/font/google";
|
||||||
import { Watcher } from "@/app/(ui)/artifact/admin/client";
|
import { Watcher } from "@/app/(ui)/artifact/admin/client";
|
||||||
import { VersionCheck } from "@/app/client";
|
import { VersionCheck } from "@/app/client";
|
||||||
@@ -6,7 +8,6 @@ import AutoFitText from "@/components/fit";
|
|||||||
import { getArtifactConfig } from "@/lib/api";
|
import { getArtifactConfig } from "@/lib/api";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { submissions } from "@/lib/db/schema";
|
import { submissions } from "@/lib/db/schema";
|
||||||
import { isNotNull, sql } from "drizzle-orm";
|
|
||||||
|
|
||||||
const kanit = Kanit({
|
const kanit = Kanit({
|
||||||
variable: "--font-kanit",
|
variable: "--font-kanit",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { donations } from "@/lib/db/schema";
|
import { donations } from "@/lib/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { getPostHogClient } from "@/lib/posthog-server";
|
import { getPostHogClient } from "@/lib/posthog-server";
|
||||||
|
|
||||||
export async function markRunning(id: string) {
|
export async function markRunning(id: string) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { gt, sum } from "drizzle-orm";
|
||||||
import { getArtifactConfig } from "@/lib/api";
|
import { getArtifactConfig } from "@/lib/api";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { donations, settings } from "@/lib/db/schema";
|
import { donations, settings } from "@/lib/db/schema";
|
||||||
import { gt, sum } from "drizzle-orm";
|
|
||||||
|
|
||||||
export async function getDonateBar() {
|
export async function getDonateBar() {
|
||||||
const { donateGoalStarting } = await getArtifactConfig();
|
const { donateGoalStarting } = await getArtifactConfig();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { getDonateBar } from "./api";
|
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { pausePass } from "@/lib/utils";
|
import { pausePass } from "@/lib/utils";
|
||||||
|
import { getDonateBar } from "./api";
|
||||||
|
|
||||||
type Data = Awaited<ReturnType<typeof getDonateBar>>;
|
type Data = Awaited<ReturnType<typeof getDonateBar>>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import Image from "next/image";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { VersionCheck } from "@/app/client";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
|
||||||
import DefaultImage from "#/favicon.webp";
|
import DefaultImage from "#/favicon.webp";
|
||||||
|
import { VersionCheck } from "@/app/client";
|
||||||
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { markDone, markRunning } from "./api";
|
import { markDone, markRunning } from "./api";
|
||||||
|
|
||||||
type DonateData = {
|
type DonateData = {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { asc, desc, max, sum } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { donations } from "@/lib/db/schema";
|
import { donations } from "@/lib/db/schema";
|
||||||
import { asc, desc, max, sum } from "drizzle-orm";
|
|
||||||
|
|
||||||
export async function getTopDonate() {
|
export async function getTopDonate() {
|
||||||
const [top] = await db
|
const [top] = await db
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { getTopDonate } from "./api";
|
import { getTopDonate } from "./api";
|
||||||
|
|
||||||
type Data = Awaited<ReturnType<typeof getTopDonate>>;
|
type Data = Awaited<ReturnType<typeof getTopDonate>>;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import type { MaybePromise } from "bun";
|
import type { MaybePromise } from "bun";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
export function ActionButton({
|
export function ActionButton({
|
||||||
action,
|
action,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
ZoomOutIcon,
|
ZoomOutIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Cropper,
|
Cropper,
|
||||||
@@ -27,6 +25,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||||
|
|
||||||
// Define type for pixel crop area
|
// Define type for pixel crop area
|
||||||
type Area = { x: number; y: number; width: number; height: number };
|
type Area = { x: number; y: number; width: number; height: number };
|
||||||
|
|||||||
+2
-2
@@ -1,14 +1,14 @@
|
|||||||
// oxlint-disable react-hooks/exhaustive-deps
|
// oxlint-disable react-hooks/exhaustive-deps
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Divide } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { FormSubmitResult } from "./form-submit";
|
||||||
import { DialogClose } from "./ui/dialog";
|
import { DialogClose } from "./ui/dialog";
|
||||||
import { Label } from "./ui/label";
|
import { Label } from "./ui/label";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||||
import { Divide } from "lucide-react";
|
|
||||||
import type { FormSubmitResult } from "./form-submit";
|
|
||||||
|
|
||||||
const AUTOSAVE_TTL_MS = 10 * 60 * 1000;
|
const AUTOSAVE_TTL_MS = 10 * 60 * 1000;
|
||||||
const STORAGE_PREFIX = "form:autosave:";
|
const STORAGE_PREFIX = "form:autosave:";
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# Buzz Events — k3s Deployment
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
### Ingress
|
||||||
|
- **k3s built-in Traefik**, not nginx.
|
||||||
|
- Annotations `traefik.ingress.kubernetes.io/response-buffering: "false"` + `read-timeout: "200"` preserve SSE (Server-Sent Events).
|
||||||
|
- Traefik Middleware `sse-optimizer` caps `maxRequestBodyBytes` at 100 MB (for slip image uploads).
|
||||||
|
- Standard `networking.k8s.io/v1` Ingress resource (not Traefik CRD IngressRoute) for portability.
|
||||||
|
- External port: **80/443** (standard). `m.dgnr.us` DNS should point at k3s node IP(s). Old port 8604 is retired.
|
||||||
|
|
||||||
|
### Namespace
|
||||||
|
- Everything lives in namespace `buzz`.
|
||||||
|
- The namespace is created once manually (`kubectl create ns buzz`) and listed in kustomization.yaml.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
- **`buzz-env`** Secret created once from `.env`:
|
||||||
|
```
|
||||||
|
kubectl create secret generic buzz-env --from-env-file=.env -n buzz
|
||||||
|
```
|
||||||
|
- All Deployments and the migration Job reference it via `envFrom.secretRef`.
|
||||||
|
- Update by deleting and recreating (or editing in place):
|
||||||
|
```
|
||||||
|
kubectl delete secret buzz-env -n buzz && kubectl create secret generic buzz-env --from-env-file=.env -n buzz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migration
|
||||||
|
- **k8s Job** (deploy-time), not in Dockerfile.
|
||||||
|
- The `drizzle` stage was removed from `Dockerfile`. The build no longer runs `bun dr push`.
|
||||||
|
- CI creates the `db-migrate` Job imperatively, waits for completion, then updates deployments.
|
||||||
|
- The Job uses the frontend image (has drizzle config) and `command: ["bun", "dr", "push"]`.
|
||||||
|
- Old job is deleted before creating the new one (`kubectl delete job ... --ignore-not-found`).
|
||||||
|
|
||||||
|
### Service Discovery
|
||||||
|
- Frontend Service: `app` (ClusterIP, port 3000).
|
||||||
|
- Backend reaches frontend at `http://app:3000` (same namespace, k8s DNS resolves `<service>`).
|
||||||
|
- Backend references in `backend/index.ts:231,279` using `http://app:3000` remain unchanged.
|
||||||
|
- `lib/auth.ts` `trustedOrigins` includes `http://app.buzz.svc.cluster.local:3000` for completeness.
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- Gitea Actions builds and pushes images as before (same tags: `:frontend`, `:backend`, `:frontend-$SHA`, `:backend-$SHA`).
|
||||||
|
- Deploy step in `build.yml`:
|
||||||
|
1. Delete old migration Job, create fresh one with `:frontend-$SHA`, wait for completion.
|
||||||
|
2. `kubectl set image deployment/app` and `deployment/backend` with the new SHA tags.
|
||||||
|
- Kustomize is used for initial cluster bootstrap (`kubectl apply -k k8s/`). Subsequent updates use imperative `kubectl set image` because Kustomize's `images` field cannot differentiate two images that share the same image name (different tags). If image naming changes to separate repos (e.g., `buzz-frontend:tag` / `buzz-backend:tag`), switch to `kustomize edit set image` + `kubectl apply -k .`.
|
||||||
|
|
||||||
|
### CI Authentication
|
||||||
|
- **ServiceAccount `ci-deployer`** in namespace `buzz`.
|
||||||
|
- RBAC Role grants: Deployments (get/list/watch/update/patch), Pods (get/list/watch), Jobs (full CRUD), Services/Ingresses/Middlewares (get/list/watch/create/update/patch).
|
||||||
|
- Token for the runner: `kubectl create token ci-deployer -n buzz` (or use a long-lived token from a Secret tied to the SA).
|
||||||
|
- The runner also needs `docker` login access to `mts.dgnr.us:5000` (same as today).
|
||||||
|
|
||||||
|
### Replicated Stateful Services (external to buzz namespace)
|
||||||
|
- **PostgreSQL**: CloudNativePG operator. `DATABASE_URL` in Secret points to the CNPG cluster's read-write service (e.g. `postgres://...@<cluster>-rw.<cnpg-ns>.svc:5432/buzz`).
|
||||||
|
- **Redis**: redis-operator. `REDIS_URL` in Secret points to the Redis operator's endpoint.
|
||||||
|
|
||||||
|
### Container Registry
|
||||||
|
- **Zot** running in-cluster (namespace `registry`), exposed via ServiceLB on host port 5000.
|
||||||
|
- All k3s nodes have `/etc/rancher/k3s/registries.yaml` mirroring `mts.dgnr.us:5000` → `http://localhost:5000` (svclb binds host port 5000). k3s must be restarted after writing this file.
|
||||||
|
- CI runner pushes to `mts.dgnr.us:5000` (Tailscale → `100.75.220.33` → svclb → zot pod).
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
- **app**: 2 replicas, limits 1 CPU / 1 Gi memory, requests 500m / 512 Mi. Liveness probe on `/api/health` (60s interval). Readiness probe same.
|
||||||
|
- **backend**: 1 replica, limits 1 CPU / 512 Mi memory, requests 250m / 256 Mi. No probes (no HTTP endpoint).
|
||||||
|
- **db-migrate Job**: limits 1 CPU / 512 Mi, requests 250m / 256 Mi.
|
||||||
|
|
||||||
|
### HPA / PDB
|
||||||
|
- HPA scales `app` on CPU > 70%, min 2 / max 6 replicas (replaces `swarm.autoscale` label).
|
||||||
|
- PDB ensures min 1 pod always available during voluntary disruptions (node drains, rollouts).
|
||||||
|
|
||||||
|
### TLS
|
||||||
|
- Not configured in manifests. Currently HTTP-only behind Traefik. Add `spec.tls` + cert-manager or k3s Traefik's built-in Let's Encrypt when ready.
|
||||||
|
|
||||||
|
## First Deploy (manual bootstrap)
|
||||||
|
```bash
|
||||||
|
kubectl create ns buzz
|
||||||
|
kubectl create secret generic buzz-env --from-env-file=.env -n buzz
|
||||||
|
kubectl apply -k k8s/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subsequent Deploys (CI)
|
||||||
|
```bash
|
||||||
|
# In .github/workflows/build.yml
|
||||||
|
TAG=${{ gitea.sha }}
|
||||||
|
|
||||||
|
# Install kubectl (arch-detected)
|
||||||
|
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||||
|
curl -sLO "https://dl.k8s.io/release/v1.35.0/bin/linux/${ARCH}/kubectl"
|
||||||
|
chmod +x kubectl && mv kubectl /usr/local/bin/kubectl
|
||||||
|
|
||||||
|
# Wire up kubeconfig
|
||||||
|
kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true
|
||||||
|
kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }}
|
||||||
|
kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz
|
||||||
|
kubectl config use-context buzz
|
||||||
|
|
||||||
|
# DB migration
|
||||||
|
kubectl delete job db-migrate -n buzz --ignore-not-found
|
||||||
|
kubectl create job db-migrate \
|
||||||
|
--image=mts.dgnr.us:5000/astral/buzz:frontend-$TAG \
|
||||||
|
-n buzz \
|
||||||
|
--command -- bun dr push
|
||||||
|
kubectl wait --for=condition=complete job/db-migrate -n buzz --timeout=120s
|
||||||
|
|
||||||
|
# Roll app & backend
|
||||||
|
kubectl set image deployment/app app=mts.dgnr.us:5000/astral/buzz:frontend-$TAG -n buzz
|
||||||
|
kubectl set image deployment/backend backend=mts.dgnr.us:5000/astral/buzz:backend-$TAG -n buzz
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runner needs Gitea secret `KUBE_TOKEN` containing the `ci-deployer` SA token. Rotate via:
|
||||||
|
```bash
|
||||||
|
kubectl delete secret ci-deployer-token -n buzz && \
|
||||||
|
kubectl apply -f k8s/service-account.yaml && \
|
||||||
|
kubectl create secret generic ci-deployer-token \
|
||||||
|
--namespace buzz \
|
||||||
|
--type=kubernetes.io/service-account-token \
|
||||||
|
--annotation=kubernetes.io/service-account.name=ci-deployer
|
||||||
|
# then get new token and update Gitea secret
|
||||||
|
TOKEN=$(kubectl get secret ci-deployer-token -n buzz -o jsonpath='{.data.token}' | base64 -d)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Changed from Swarm
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `Dockerfile` | Removed `drizzle` stage (migration moved to k8s Job) |
|
||||||
|
| `lib/auth.ts` | Added `app.buzz.svc.cluster.local:3000` to `trustedOrigins` |
|
||||||
|
| `.github/workflows/build.yml` | Replaced `docker service update` with k8s deploy commands |
|
||||||
|
| `nginx/nginx.conf` | Archived — replaced by Traefik Ingress |
|
||||||
|
| `docker-compose.yml` | Archived — replaced by k8s manifests |
|
||||||
|
|
||||||
|
## Key Distinctions from Swarm
|
||||||
|
- No `overlay` network (k8s CNI handles multi-node networking).
|
||||||
|
- No host port `8604` (Traefik on 80/443).
|
||||||
|
- Service discovery via k8s DNS, not Swarm DNS.
|
||||||
|
- Rolling updates via `kubectl set image`, not `docker service update`.
|
||||||
|
- Health checks via k8s probes, not Docker HEALTHCHECK.
|
||||||
|
- Autoscaling via HPA, not `swarm.autoscale` label.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: app
|
||||||
|
namespace: buzz
|
||||||
|
labels:
|
||||||
|
app: buzz
|
||||||
|
component: app
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxUnavailable: 1
|
||||||
|
maxSurge: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: buzz
|
||||||
|
component: app
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: buzz
|
||||||
|
component: app
|
||||||
|
spec:
|
||||||
|
terminationGracePeriodSeconds: 5
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: mts.dgnr.us:5000/astral/buzz:frontend
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: buzz-env
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 60
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: buzz
|
||||||
|
labels:
|
||||||
|
app: buzz
|
||||||
|
component: backend
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxUnavailable: 0
|
||||||
|
maxSurge: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: buzz
|
||||||
|
component: backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: buzz
|
||||||
|
component: backend
|
||||||
|
spec:
|
||||||
|
terminationGracePeriodSeconds: 5
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: mts.dgnr.us:5000/astral/buzz:backend
|
||||||
|
env:
|
||||||
|
- name: NO_AUTH_CHECK
|
||||||
|
value: "true"
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: buzz-env
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 256Mi
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: app
|
||||||
|
namespace: buzz
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: app
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 6
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 70
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: app
|
||||||
|
namespace: buzz
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/read-timeout: "200"
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: m.dgnr.us
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: app
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
|
- host: buzz.sudloh.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: app
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: buzz
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- deployment-app.yaml
|
||||||
|
- service-app.yaml
|
||||||
|
- deployment-backend.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
- middleware.yaml
|
||||||
|
- hpa.yaml
|
||||||
|
- pdb.yaml
|
||||||
|
- service-account.yaml
|
||||||
|
- role.yaml
|
||||||
|
- role-binding.yaml
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: sse-optimizer
|
||||||
|
namespace: buzz
|
||||||
|
spec:
|
||||||
|
buffering:
|
||||||
|
maxRequestBodyBytes: 104857600
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: buzz
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: app
|
||||||
|
namespace: buzz
|
||||||
|
spec:
|
||||||
|
minAvailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: buzz
|
||||||
|
component: app
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: ci-deployer
|
||||||
|
namespace: buzz
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: ci-deployer
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: ci-deployer
|
||||||
|
namespace: buzz
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: ci-deployer
|
||||||
|
namespace: buzz
|
||||||
|
rules:
|
||||||
|
- apiGroups: ["apps"]
|
||||||
|
resources: ["deployments"]
|
||||||
|
verbs: ["get", "list", "watch", "update", "patch"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["services"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||||
|
- apiGroups: ["batch"]
|
||||||
|
resources: ["jobs"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||||
|
- apiGroups: ["networking.k8s.io"]
|
||||||
|
resources: ["ingresses"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||||
|
- apiGroups: ["traefik.io"]
|
||||||
|
resources: ["middlewares"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||||
|
- apiGroups: ["autoscaling"]
|
||||||
|
resources: ["horizontalpodautoscalers"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||||
|
- apiGroups: ["policy"]
|
||||||
|
resources: ["poddisruptionbudgets"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: ci-deployer
|
||||||
|
namespace: buzz
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: app
|
||||||
|
namespace: buzz
|
||||||
|
labels:
|
||||||
|
app: buzz
|
||||||
|
component: app
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: buzz
|
||||||
|
component: app
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
+8
-6
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUIDv7 } from "bun";
|
import { fetch, randomUUIDv7 } from "bun";
|
||||||
import { and, eq, inArray, isNotNull, lt, not, or, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNotNull, lt, not, or, sql } from "drizzle-orm";
|
||||||
import type { PgDatabase } from "drizzle-orm/pg-core";
|
import type { PgDatabase } from "drizzle-orm/pg-core";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
@@ -9,6 +9,7 @@ import z from "zod";
|
|||||||
import { adminCheck } from "./auth";
|
import { adminCheck } from "./auth";
|
||||||
import { uidRegex } from "./const";
|
import { uidRegex } from "./const";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
|
import { redis } from "./db/redis";
|
||||||
import { cdnReferences } from "./db/references";
|
import { cdnReferences } from "./db/references";
|
||||||
import {
|
import {
|
||||||
artifactSettings,
|
artifactSettings,
|
||||||
@@ -23,8 +24,6 @@ import {
|
|||||||
} from "./db/schema";
|
} from "./db/schema";
|
||||||
import { sse, tlSse } from "./db/sse-endpoints";
|
import { sse, tlSse } from "./db/sse-endpoints";
|
||||||
import { b2s } from "./utils";
|
import { b2s } from "./utils";
|
||||||
import { redis } from "./db/redis";
|
|
||||||
import { fetch } from "bun";
|
|
||||||
|
|
||||||
export async function getCharacters(chars: string[]) {
|
export async function getCharacters(chars: string[]) {
|
||||||
return await db
|
return await db
|
||||||
@@ -410,8 +409,11 @@ export async function actionLog(text: string, details?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAmberVh() {
|
export async function getAmberVh() {
|
||||||
const cached = await redis!.get("amber:vh");
|
try {
|
||||||
if (cached) return cached;
|
const cached = await redis?.get("amber:vh");
|
||||||
|
if (cached) return cached;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
response: z.number().positive(),
|
response: z.number().positive(),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
@@ -425,6 +427,6 @@ export async function getAmberVh() {
|
|||||||
e.json(),
|
e.json(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
queueMicrotask(() => redis!.setex("amber:vh", 86400, vh));
|
queueMicrotask(() => redis?.setex("amber:vh", 86400, vh));
|
||||||
return vh;
|
return vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const auth = betterAuth({
|
|||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://dev3000.astrxl.dev",
|
"https://dev3000.astrxl.dev",
|
||||||
"http://m.dgnr.us",
|
"http://m.dgnr.us",
|
||||||
|
"http://buzz.sudloh.com",
|
||||||
|
"http://app.buzz.svc.cluster.local:3000",
|
||||||
],
|
],
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg", // or "mysql", "sqlite"
|
provider: "pg", // or "mysql", "sqlite"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user