Fix web push delivery handling and worker console
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled

This commit is contained in:
2026-04-04 04:15:20 +08:00
parent ab18bbaf23
commit 381dc9b854
19 changed files with 1607 additions and 747 deletions

View File

@@ -1,21 +1,6 @@
name: ui-regression name: ui-regression
on: on:
push:
branches:
- main
- master
paths:
- admin/**
- frontend/**
- playwright-smoke/**
- .gitea/workflows/ui-regression.yml
pull_request:
paths:
- admin/**
- frontend/**
- playwright-smoke/**
- .gitea/workflows/ui-regression.yml
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -35,6 +20,11 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
cache-dependency-path: |
frontend/pnpm-lock.yaml
admin/pnpm-lock.yaml
playwright-smoke/pnpm-lock.yaml
- name: Install frontend deps - name: Install frontend deps
working-directory: frontend working-directory: frontend
@@ -48,7 +38,15 @@ jobs:
working-directory: playwright-smoke working-directory: playwright-smoke
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('playwright-smoke/pnpm-lock.yaml') }}
- name: Install Playwright browsers - name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: playwright-smoke working-directory: playwright-smoke
run: pnpm exec playwright install --with-deps chromium run: pnpm exec playwright install --with-deps chromium
@@ -69,71 +67,22 @@ jobs:
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321 VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
run: pnpm build run: pnpm build
- name: Prepare Playwright artifact folders
run: |
rm -rf playwright-smoke/.artifacts
mkdir -p playwright-smoke/.artifacts/frontend
mkdir -p playwright-smoke/.artifacts/admin
- name: Run frontend UI regression suite - name: Run frontend UI regression suite
id: ui_frontend id: ui_frontend
working-directory: playwright-smoke working-directory: playwright-smoke
continue-on-error: true continue-on-error: true
env: env:
PLAYWRIGHT_USE_BUILT_APP: '1' PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:frontend run: pnpm test:frontend
- name: Collect frontend Playwright artifacts
if: always()
run: |
if [ -d playwright-smoke/playwright-report ]; then
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/frontend/playwright-report
fi
if [ -d playwright-smoke/test-results ]; then
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/frontend/test-results
fi
rm -rf playwright-smoke/playwright-report playwright-smoke/test-results
- name: Run admin UI regression suite - name: Run admin UI regression suite
id: ui_admin id: ui_admin
working-directory: playwright-smoke working-directory: playwright-smoke
continue-on-error: true continue-on-error: true
env: env:
PLAYWRIGHT_USE_BUILT_APP: '1' PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:admin run: pnpm test:admin
- name: Collect admin Playwright artifacts
if: always()
run: |
if [ -d playwright-smoke/playwright-report ]; then
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/admin/playwright-report
fi
if [ -d playwright-smoke/test-results ]; then
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
fi
- name: Summarize Playwright artifact paths
if: always()
shell: bash
run: |
set -euo pipefail
echo "Gitea Actions 当前不支持 actions/upload-artifact@v4改为直接输出产物目录"
for path in \
"playwright-smoke/.artifacts/frontend/playwright-report" \
"playwright-smoke/.artifacts/frontend/test-results" \
"playwright-smoke/.artifacts/admin/playwright-report" \
"playwright-smoke/.artifacts/admin/test-results"
do
if [ -d "${path}" ]; then
echo "- ${path}"
find "${path}" -maxdepth 2 -type f | sort | head -n 20
else
echo "- ${path} (missing)"
fi
done
- name: Mark workflow failed when any suite failed - name: Mark workflow failed when any suite failed
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success' if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
run: exit 1 run: exit 1

View File

@@ -89,9 +89,11 @@ pnpm dev
```powershell ```powershell
cd backend cd backend
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development" $env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
cargo loco start 2>&1 cargo loco start --server-and-worker 2>&1
``` ```
如果需要验证浏览器推送、异步通知、失败重试等 Redis 队列任务,本地不要只跑 `server`,要把 `worker` 一起带上;否则任务会停在 `queued`
### Docker生产部署使用 Gitea Package 镜像) ### Docker生产部署使用 Gitea Package 镜像)
补充部署分层与反代说明见: 补充部署分层与反代说明见:

View File

@@ -114,3 +114,14 @@ export function formatWorkerProgress(
return progress.message ?? (details || null); return progress.message ?? (details || null);
} }
export function getWorkerProgressPercent(
job: Pick<WorkerJobRecord, "result">,
): number | null {
const progress = getWorkerProgress(job);
if (typeof progress?.percent !== "number") {
return null;
}
return Math.max(0, Math.min(100, Math.round(progress.percent)));
}

View File

@@ -10,15 +10,22 @@ import {
Star, Star,
Tags, Tags,
Workflow, Workflow,
} from 'lucide-react' } from "lucide-react";
import { startTransition, useCallback, useEffect, useState } from 'react' import { startTransition, useCallback, useEffect, useState } from "react";
import { Link } from 'react-router-dom' import { Link } from "react-router-dom";
import { toast } from 'sonner' import { toast } from "sonner";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import {
import { Skeleton } from '@/components/ui/skeleton' Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/admin-format";
import { import {
Table, Table,
TableBody, TableBody,
@@ -26,9 +33,9 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { adminApi, ApiError } from '@/lib/api' import { adminApi, ApiError } from "@/lib/api";
import { buildFrontendUrl } from '@/lib/frontend-url' import { buildFrontendUrl } from "@/lib/frontend-url";
import { import {
formatCommentScope, formatCommentScope,
formatPostStatus, formatPostStatus,
@@ -37,8 +44,12 @@ import {
formatPostVisibility, formatPostVisibility,
formatReviewStatus, formatReviewStatus,
formatReviewType, formatReviewType,
} from '@/lib/admin-format' } from "@/lib/admin-format";
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types' import type {
AdminAnalyticsResponse,
AdminDashboardResponse,
WorkerOverview,
} from "@/lib/types";
function StatCard({ function StatCard({
label, label,
@@ -46,17 +57,21 @@ function StatCard({
note, note,
icon: Icon, icon: Icon,
}: { }: {
label: string label: string;
value: number value: number;
note: string note: string;
icon: typeof Rss icon: typeof Rss;
}) { }) {
return ( return (
<Card className="bg-gradient-to-br from-card via-card to-background/70"> <Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6"> <CardContent className="flex items-start justify-between pt-6">
<div> <div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p> <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div> {label}
</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">
{value}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p> <p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div> </div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
@@ -64,75 +79,81 @@ function StatCard({
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) );
} }
function formatAiSourceLabel(value: string) { function formatAiSourceLabel(value: string) {
switch (value) { switch (value) {
case 'chatgpt-search': case "chatgpt-search":
return 'ChatGPT Search' return "ChatGPT Search";
case 'perplexity': case "perplexity":
return 'Perplexity' return "Perplexity";
case 'copilot-bing': case "copilot-bing":
return 'Copilot / Bing' return "Copilot / Bing";
case 'gemini': case "gemini":
return 'Gemini' return "Gemini";
case 'claude': case "claude":
return 'Claude' return "Claude";
case 'google': case "google":
return 'Google' return "Google";
case 'duckduckgo': case "duckduckgo":
return 'DuckDuckGo' return "DuckDuckGo";
case 'kagi': case "kagi":
return 'Kagi' return "Kagi";
case 'direct': case "direct":
return 'Direct' return "Direct";
default: default:
return value return value;
} }
} }
export function DashboardPage() { export function DashboardPage() {
const [data, setData] = useState<AdminDashboardResponse | null>(null) const [data, setData] = useState<AdminDashboardResponse | null>(null);
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null) const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null) null,
const [loading, setLoading] = useState(true) );
const [refreshing, setRefreshing] = useState(false) const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(
null,
);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadDashboard = useCallback(async (showToast = false) => { const loadDashboard = useCallback(async (showToast = false) => {
try { try {
if (showToast) { if (showToast) {
setRefreshing(true) setRefreshing(true);
} }
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([ const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
adminApi.dashboard(), adminApi.dashboard(),
adminApi.getWorkersOverview(), adminApi.getWorkersOverview(),
adminApi.analytics(), adminApi.analytics(),
]) ]);
startTransition(() => { startTransition(() => {
setData(next) setData(next);
setWorkerOverview(nextWorkerOverview) setWorkerOverview(nextWorkerOverview);
setAnalytics(nextAnalytics) setAnalytics(nextAnalytics);
}) });
if (showToast) { if (showToast) {
toast.success('仪表盘已刷新。') toast.success("仪表盘已刷新。");
} }
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
return return;
} }
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。') toast.error(
error instanceof ApiError ? error.message : "无法加载仪表盘。",
);
} finally { } finally {
setLoading(false) setLoading(false);
setRefreshing(false) setRefreshing(false);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
void loadDashboard(false) void loadDashboard(false);
}, [loadDashboard]) }, [loadDashboard]);
if (loading || !data || !workerOverview || !analytics) { if (loading || !data || !workerOverview || !analytics) {
return ( return (
@@ -147,24 +168,24 @@ export function DashboardPage() {
<Skeleton className="h-[420px] rounded-3xl" /> <Skeleton className="h-[420px] rounded-3xl" />
</div> </div>
</div> </div>
) );
} }
const statCards = [ const statCards = [
{ {
label: '文章总数', label: "文章总数",
value: data.stats.total_posts, value: data.stats.total_posts,
note: `内容库中共有 ${data.stats.total_comments} 条评论`, note: `内容库中共有 ${data.stats.total_comments} 条评论`,
icon: Rss, icon: Rss,
}, },
{ {
label: '待审核评论', label: "待审核评论",
value: data.stats.pending_comments, value: data.stats.pending_comments,
note: '等待审核处理', note: "等待审核处理",
icon: MessageSquareWarning, icon: MessageSquareWarning,
}, },
{ {
label: '发布待办', label: "发布待办",
value: value:
data.stats.draft_posts + data.stats.draft_posts +
data.stats.scheduled_posts + data.stats.scheduled_posts +
@@ -174,30 +195,32 @@ export function DashboardPage() {
icon: Clock3, icon: Clock3,
}, },
{ {
label: '分类数量', label: "分类数量",
value: data.stats.total_categories, value: data.stats.total_categories,
note: `当前共有 ${data.stats.total_tags} 个标签`, note: `当前共有 ${data.stats.total_tags} 个标签`,
icon: FolderTree, icon: FolderTree,
}, },
{ {
label: 'AI 分块', label: "AI 分块",
value: data.stats.ai_chunks, value: data.stats.ai_chunks,
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭', note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
icon: BrainCircuit, icon: BrainCircuit,
}, },
{ {
label: 'Worker 活动', label: "Worker 活动",
value: workerOverview.active_jobs, value: workerOverview.active_jobs,
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`, note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
icon: Workflow, icon: Workflow,
}, },
] ];
const aiTrafficShare = const aiTrafficShare =
analytics.content_overview.page_views_last_7d > 0 analytics.content_overview.page_views_last_7d > 0
? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100 ? (analytics.ai_discovery_page_views_last_7d /
: 0 analytics.content_overview.page_views_last_7d) *
const topAiSource = analytics.ai_referrers_last_7d[0] 100
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length : 0;
const topAiSource = analytics.ai_referrers_last_7d[0];
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -207,14 +230,15 @@ export function DashboardPage() {
<div> <div>
<h2 className="text-3xl font-semibold tracking-tight"></h2> <h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground"> <p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI AI
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer"> <a href={buildFrontendUrl("/ask")} target="_blank" rel="noreferrer">
<ArrowUpRight className="h-4 w-4" /> <ArrowUpRight className="h-4 w-4" />
AI AI
</a> </a>
@@ -225,7 +249,7 @@ export function DashboardPage() {
disabled={refreshing} disabled={refreshing}
> >
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'} {refreshing ? "刷新中..." : "刷新"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -241,9 +265,7 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</div> </div>
<Badge variant="outline">{data.recent_posts.length} </Badge> <Badge variant="outline">{data.recent_posts.length} </Badge>
</CardHeader> </CardHeader>
@@ -265,9 +287,13 @@ export function DashboardPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{post.title}</span> <span className="font-medium">{post.title}</span>
{post.pinned ? <Badge variant="success"></Badge> : null} {post.pinned ? (
<Badge variant="success"></Badge>
) : null}
</div> </div>
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p> <p className="font-mono text-xs text-muted-foreground">
{post.slug}
</p>
</div> </div>
</TableCell> </TableCell>
<TableCell className="uppercase text-muted-foreground"> <TableCell className="uppercase text-muted-foreground">
@@ -275,12 +301,18 @@ export function DashboardPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">{formatPostStatus(post.status)}</Badge> <Badge variant="outline">
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge> {formatPostStatus(post.status)}
</Badge>
<Badge variant="secondary">
{formatPostVisibility(post.visibility)}
</Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell>{post.category}</TableCell> <TableCell>{post.category}</TableCell>
<TableCell className="text-muted-foreground">{post.created_at}</TableCell> <TableCell className="text-muted-foreground">
{post.created_at}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -291,19 +323,19 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription> AI </CardDescription>
AI
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4"> <div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-medium">{data.site.site_name}</p> <p className="text-sm font-medium">{data.site.site_name}</p>
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p> <p className="mt-1 text-sm text-muted-foreground">
{data.site.site_url}
</p>
</div> </div>
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}> <Badge variant={data.site.ai_enabled ? "success" : "warning"}>
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'} {data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -314,7 +346,9 @@ export function DashboardPage() {
</p> </p>
<div className="mt-3 flex items-end gap-2"> <div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span> <span className="text-3xl font-semibold">
{data.stats.total_reviews}
</span>
<Star className="mb-1 h-4 w-4 text-amber-500" /> <Star className="mb-1 h-4 w-4 text-amber-500" />
</div> </div>
</div> </div>
@@ -323,7 +357,9 @@ export function DashboardPage() {
</p> </p>
<div className="mt-3 flex items-end gap-2"> <div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_links}</span> <span className="text-3xl font-semibold">
{data.stats.total_links}
</span>
<Tags className="mb-1 h-4 w-4 text-primary" /> <Tags className="mb-1 h-4 w-4 text-primary" />
</div> </div>
</div> </div>
@@ -335,25 +371,35 @@ export function DashboardPage() {
</p> </p>
<div className="mt-3 grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-3 sm:grid-cols-2">
<div> <div>
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p> <p className="text-2xl font-semibold">
{data.stats.draft_posts}
</p>
<p className="text-xs text-muted-foreground">稿</p> <p className="text-xs text-muted-foreground">稿</p>
</div> </div>
<div> <div>
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p> <p className="text-2xl font-semibold">
{data.stats.scheduled_posts}
</p>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
<div> <div>
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p> <p className="text-2xl font-semibold">
{data.stats.offline_posts}
</p>
<p className="text-xs text-muted-foreground">线</p> <p className="text-xs text-muted-foreground">线</p>
</div> </div>
<div> <div>
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p> <p className="text-2xl font-semibold">
{data.stats.expired_posts}
</p>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline"> {data.stats.private_posts}</Badge> <Badge variant="outline"> {data.stats.private_posts}</Badge>
<Badge variant="outline"> {data.stats.unlisted_posts}</Badge> <Badge variant="outline">
{data.stats.unlisted_posts}
</Badge>
</div> </div>
</div> </div>
@@ -362,7 +408,9 @@ export function DashboardPage() {
AI AI
</p> </p>
<p className="mt-3 text-sm leading-6 text-muted-foreground"> <p className="mt-3 text-sm leading-6 text-muted-foreground">
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'} {data.site.ai_last_indexed_at
? formatDateTime(data.site.ai_last_indexed_at)
: "站点还没有建立过索引。"}
</p> </p>
</div> </div>
@@ -372,9 +420,13 @@ export function DashboardPage() {
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
GEO / AI GEO / AI
</p> </p>
<p className="mt-3 text-3xl font-semibold">{analytics.ai_discovery_page_views_last_7d}</p> <p className="mt-3 text-3xl font-semibold">
{analytics.ai_discovery_page_views_last_7d}
</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground"> <p className="mt-2 text-sm leading-6 text-muted-foreground">
7 ChatGPT SearchPerplexityCopilot/BingGeminiClaude 访 7 ChatGPT
SearchPerplexityCopilot/BingGeminiClaude
访
</p> </p>
</div> </div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary"> <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
@@ -384,23 +436,41 @@ export function DashboardPage() {
<div className="mt-4 grid gap-3 sm:grid-cols-3"> <div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3"> <div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">访</div> <div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold">{Math.round(aiTrafficShare)}%</div> 访
<div className="mt-1 text-xs text-muted-foreground"> 7 page_view</div> </div>
</div> <div className="mt-2 text-2xl font-semibold">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3"> {Math.round(aiTrafficShare)}%
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground"></div>
<div className="mt-2 text-base font-semibold">
{topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'}
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'} 7 page_view
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3"> <div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground"></div> <div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold">{totalAiSourceBuckets}</div>
<div className="mt-1 text-xs text-muted-foreground"> AI </div> </div>
<div className="mt-2 text-base font-semibold">
{topAiSource
? formatAiSourceLabel(topAiSource.referrer)
: "暂无"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{topAiSource
? `${topAiSource.count} 次访问`
: "等待来源数据"}
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
</div>
<div className="mt-2 text-2xl font-semibold">
{totalAiSourceBuckets}
</div>
<div className="mt-1 text-xs text-muted-foreground">
AI
</div>
</div> </div>
</div> </div>
@@ -408,15 +478,24 @@ export function DashboardPage() {
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => { {analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
const width = `${Math.max( const width = `${Math.max(
(item.count / Math.max(analytics.ai_discovery_page_views_last_7d, 1)) * 100, (item.count /
Math.max(
analytics.ai_discovery_page_views_last_7d,
1,
)) *
100,
8, 8,
)}%` )}%`;
return ( return (
<div key={item.referrer} className="space-y-1.5"> <div key={item.referrer} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{formatAiSourceLabel(item.referrer)}</span> <span className="font-medium">
<span className="text-muted-foreground">{item.count}</span> {formatAiSourceLabel(item.referrer)}
</span>
<span className="text-muted-foreground">
{item.count}
</span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-secondary"> <div className="h-2 overflow-hidden rounded-full bg-secondary">
<div <div
@@ -425,11 +504,13 @@ export function DashboardPage() {
/> />
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
) : ( ) : (
<p className="mt-4 text-sm text-muted-foreground"> 7 AI </p> <p className="mt-4 text-sm text-muted-foreground">
7 AI
</p>
)} )}
</div> </div>
@@ -440,12 +521,17 @@ export function DashboardPage() {
Worker Worker
</p> </p>
<p className="mt-3 text-sm leading-6 text-muted-foreground"> <p className="mt-3 text-sm leading-6 text-muted-foreground">
{workerOverview.queued} {workerOverview.running} {workerOverview.failed} {workerOverview.queued}{" "}
{workerOverview.running} {workerOverview.failed}
</p> </p>
</div> </div>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link <Link
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'} to={
workerOverview.failed > 0
? "/workers?status=failed"
: "/workers"
}
data-testid="dashboard-worker-open" data-testid="dashboard-worker-open"
> >
@@ -459,24 +545,36 @@ export function DashboardPage() {
data-testid="dashboard-worker-card-queued" data-testid="dashboard-worker-card-queued"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5" className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
> >
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Queued</div> <div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div> Queued
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.queued}
</div>
</Link> </Link>
<Link <Link
to="/workers?status=running" to="/workers?status=running"
data-testid="dashboard-worker-card-running" data-testid="dashboard-worker-card-running"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5" className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
> >
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Running</div> <div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div> Running
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.running}
</div>
</Link> </Link>
<Link <Link
to="/workers?status=failed" to="/workers?status=failed"
data-testid="dashboard-worker-card-failed" data-testid="dashboard-worker-card-failed"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5" className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
> >
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Failed</div> <div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div> Failed
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.failed}
</div>
</Link> </Link>
</div> </div>
@@ -489,11 +587,17 @@ export function DashboardPage() {
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5" className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
> >
<div> <div>
<div className="font-medium text-foreground">{item.label}</div> <div className="font-medium text-foreground">
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div> {item.label}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.worker_name}
</div>
</div> </div>
<div className="text-right text-xs text-muted-foreground"> <div className="text-right text-xs text-muted-foreground">
<div>Q {item.queued} · R {item.running}</div> <div>
Q {item.queued} · R {item.running}
</div>
<div>ERR {item.failed}</div> <div>ERR {item.failed}</div>
</div> </div>
</Link> </Link>
@@ -510,11 +614,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</div> </div>
<Badge variant="warning">{data.pending_comments.length} </Badge> <Badge variant="warning">
{data.pending_comments.length}
</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
@@ -543,7 +647,9 @@ export function DashboardPage() {
<TableCell className="font-mono text-xs text-muted-foreground"> <TableCell className="font-mono text-xs text-muted-foreground">
{comment.post_slug} {comment.post_slug}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell> <TableCell className="text-muted-foreground">
{comment.created_at}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -556,11 +662,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</div> </div>
<Badge variant="warning">{data.pending_friend_links.length} </Badge> <Badge variant="warning">
{data.pending_friend_links.length}
</Badge>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{data.pending_friend_links.map((link) => ( {data.pending_friend_links.map((link) => (
@@ -591,9 +697,7 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription></CardDescription>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{data.recent_reviews.map((review) => ( {data.recent_reviews.map((review) => (
@@ -604,11 +708,14 @@ export function DashboardPage() {
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">{review.title}</p> <p className="font-medium">{review.title}</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)} {formatReviewType(review.review_type)} ·{" "}
{formatReviewStatus(review.status)}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-lg font-semibold">{review.rating}/5</div> <div className="text-lg font-semibold">
{review.rating}/5
</div>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{review.review_date} {review.review_date}
</p> </p>
@@ -620,5 +727,5 @@ export function DashboardPage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -26,7 +26,11 @@ import { Select } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { adminApi, ApiError } from "@/lib/api"; import { adminApi, ApiError } from "@/lib/api";
import { formatWorkerProgress } from "@/lib/worker-progress"; import { formatDateTime } from "@/lib/admin-format";
import {
formatWorkerProgress,
getWorkerProgressPercent,
} from "@/lib/worker-progress";
import type { import type {
AdminSiteSettingsResponse, AdminSiteSettingsResponse,
AiProviderConfig, AiProviderConfig,
@@ -2119,7 +2123,9 @@ export function SiteSettingsPage() {
</p> </p>
<p className="mt-3 text-sm leading-6 text-muted-foreground"> <p className="mt-3 text-sm leading-6 text-muted-foreground">
{form.ai_last_indexed_at ?? "索引尚未建立。"} {form.ai_last_indexed_at
? formatDateTime(form.ai_last_indexed_at)
: "索引尚未建立。"}
</p> </p>
{reindexJobId ? ( {reindexJobId ? (
<p className="mt-3 text-xs leading-6 text-muted-foreground"> <p className="mt-3 text-xs leading-6 text-muted-foreground">
@@ -2133,6 +2139,18 @@ export function SiteSettingsPage() {
"任务已经开始,正在等待下一次进度更新。"} "任务已经开始,正在等待下一次进度更新。"}
</p> </p>
) : null} ) : null}
{reindexJobResult &&
getWorkerProgressPercent({ result: reindexJobResult }) !==
null ? (
<div className="mt-3 h-2 overflow-hidden rounded-full bg-border/70">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{
width: `${getWorkerProgressPercent({ result: reindexJobResult })}%`,
}}
/>
</div>
) : null}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,15 @@ Loco.rs backend当前仅保留 API 与后台鉴权相关逻辑,不再提供
## 本地启动 ## 本地启动
```powershell ```powershell
cargo loco start cargo loco start --server-and-worker
``` ```
默认本地监听: 默认本地监听:
- `http://localhost:5150` - `http://localhost:5150`
如果只启动 `cargo loco start` 而没有 `worker`,浏览器推送、异步通知、失败重试这类 Redis 队列任务会入队但没人消费。
## 当前职责 ## 当前职责
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API - 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API

View File

@@ -2537,6 +2537,18 @@ async fn update_reindex_job_progress(
.await .await
} }
async fn stop_reindex_if_cancel_requested(ctx: &AppContext, job_id: Option<i32>) -> Result<()> {
let Some(job_id) = job_id else {
return Ok(());
};
if worker_jobs::cancel_job_if_requested(ctx, job_id, "job cancelled during reindex").await? {
return Err(Error::BadRequest("job cancelled".to_string()));
}
Ok(())
}
async fn load_runtime_settings( async fn load_runtime_settings(
ctx: &AppContext, ctx: &AppContext,
require_enabled: bool, require_enabled: bool,
@@ -2729,6 +2741,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
batch_size, batch_size,
); );
update_reindex_job_progress(ctx, job_id, &preparing_progress).await?; update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let txn = ctx.db.begin().await?; let txn = ctx.db.begin().await?;
txn.execute(Statement::from_string( txn.execute(Statement::from_string(
@@ -2740,6 +2753,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
let mut processed_chunks = 0usize; let mut processed_chunks = 0usize;
for chunk_batch in chunk_drafts.chunks(batch_size) { for chunk_batch in chunk_drafts.chunks(batch_size) {
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let embeddings = embed_texts_locally_with_batch_size( let embeddings = embed_texts_locally_with_batch_size(
chunk_batch chunk_batch
.iter() .iter()
@@ -2799,6 +2813,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
update_reindex_job_progress(ctx, job_id, &embedding_progress).await?; update_reindex_job_progress(ctx, job_id, &embedding_progress).await?;
} }
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?; let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
txn.commit().await?; txn.commit().await?;

View File

@@ -40,7 +40,12 @@ pub const DELIVERY_STATUS_RETRY_PENDING: &str = "retry_pending";
pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted"; pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted";
pub const DELIVERY_STATUS_SKIPPED: &str = "skipped"; pub const DELIVERY_STATUS_SKIPPED: &str = "skipped";
const MAX_DELIVERY_ATTEMPTS: i32 = 5; const WEB_PUSH_TITLE_MAX_CHARS: usize = 72;
const WEB_PUSH_BODY_MAX_CHARS: usize = 160;
const WEB_PUSH_MAX_PAYLOAD_BYTES: usize = 2800;
const WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD: i32 = 2;
const WEB_PUSH_AUTO_PAUSE_NOTE: &str =
"浏览器推送订阅连续投递失败,系统已自动暂停。请在浏览器里重新开启提醒。";
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct DigestDispatchSummary { pub struct DigestDispatchSummary {
@@ -259,6 +264,97 @@ fn merge_browser_push_metadata(
Value::Object(object) Value::Object(object)
} }
fn merge_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
let note = note.trim();
let mut lines = existing
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect::<Vec<_>>();
if !note.is_empty() && !lines.iter().any(|line| line == note) {
lines.push(note.to_string());
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn remove_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
let note = note.trim();
let lines = existing
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && *line != note)
.map(ToString::to_string)
.collect::<Vec<_>>();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn web_push_error_looks_terminal(error_text: &str) -> bool {
let normalized = error_text.trim().to_ascii_lowercase();
normalized.contains("endpoint_host=fcm.googleapis.com")
&& normalized.contains("unspecified error")
|| normalized.contains("410")
|| normalized.contains("404")
|| normalized.contains("gone")
|| normalized.contains("not found")
|| normalized.contains("expired")
|| normalized.contains("unsubscribed")
|| normalized.contains("invalid subscription")
|| normalized.contains("push subscription")
}
fn should_auto_pause_failed_web_push_subscription(
failure_count_after_error: i32,
error_text: &str,
) -> bool {
failure_count_after_error >= WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD
|| web_push_error_looks_terminal(error_text)
}
async fn maybe_pause_failed_web_push_subscription(
ctx: &AppContext,
subscription: Option<&subscriptions::Model>,
error_text: &str,
) -> Result<()> {
let Some(subscription) = subscription else {
return Ok(());
};
if subscription.channel_type != CHANNEL_WEB_PUSH
|| normalize_status(&subscription.status) != STATUS_ACTIVE
{
return Ok(());
}
let failure_count_after_error = subscription.failure_count.unwrap_or(0) + 1;
if !should_auto_pause_failed_web_push_subscription(failure_count_after_error, error_text) {
return Ok(());
}
let mut active = subscription.clone().into_active_model();
active.status = Set(STATUS_PAUSED.to_string());
active.notes = Set(merge_subscription_note(
subscription.notes.as_deref(),
WEB_PUSH_AUTO_PAUSE_NOTE,
));
let _ = active.update(&ctx.db).await?;
Ok(())
}
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> { fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
value value
.and_then(Value::as_object) .and_then(Value::as_object)
@@ -321,16 +417,6 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
values values
} }
fn delivery_retry_delay(attempts: i32) -> Duration {
match attempts {
0 | 1 => Duration::minutes(1),
2 => Duration::minutes(5),
3 => Duration::minutes(15),
4 => Duration::minutes(60),
_ => Duration::hours(6),
}
}
fn effective_period(period: &str) -> (&'static str, i64, &'static str) { fn effective_period(period: &str) -> (&'static str, i64, &'static str) {
match period.trim().to_ascii_lowercase().as_str() { match period.trim().to_ascii_lowercase().as_str() {
"monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY), "monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY),
@@ -680,6 +766,12 @@ pub async fn create_public_web_push_subscription(
active.status = Set(STATUS_ACTIVE.to_string()); active.status = Set(STATUS_ACTIVE.to_string());
active.confirm_token = Set(None); active.confirm_token = Set(None);
active.verified_at = Set(Some(Utc::now().to_rfc3339())); active.verified_at = Set(Some(Utc::now().to_rfc3339()));
active.failure_count = Set(Some(0));
active.last_delivery_status = Set(None);
active.notes = Set(remove_subscription_note(
existing.notes.as_deref(),
WEB_PUSH_AUTO_PAUSE_NOTE,
));
active.metadata = Set(Some(merge_browser_push_metadata( active.metadata = Set(Some(merge_browser_push_metadata(
existing.metadata.as_ref(), existing.metadata.as_ref(),
metadata, metadata,
@@ -1066,26 +1158,47 @@ fn web_push_target_url(message: &QueuedDeliveryPayload) -> Option<String> {
} }
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value { fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
let body = truncate_chars(&collapse_whitespace(&message.text), 220); let title = truncate_chars(
&collapse_whitespace(&message.subject),
WEB_PUSH_TITLE_MAX_CHARS,
);
let body = truncate_chars(&collapse_whitespace(&message.text), WEB_PUSH_BODY_MAX_CHARS);
let url = web_push_target_url(message);
let event_type = message
.payload
.get("event_type")
.and_then(Value::as_str)
.unwrap_or("subscription");
serde_json::json!({ serde_json::json!({
"title": message.subject, "title": title,
"body": body, "body": body,
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"), "icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"), "badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
"url": web_push_target_url(message), "url": url.clone(),
"tag": message "tag": event_type,
.payload
.get("event_type")
.and_then(Value::as_str)
.unwrap_or("subscription"),
"data": { "data": {
"event_type": message.payload.get("event_type").cloned().unwrap_or(Value::Null), "url": url,
"payload": message.payload, "event_type": event_type,
} }
}) })
} }
fn encode_web_push_payload(message: &QueuedDeliveryPayload) -> Result<Vec<u8>> {
let payload = build_web_push_payload(message);
let encoded = serde_json::to_vec(&payload)?;
if encoded.len() > WEB_PUSH_MAX_PAYLOAD_BYTES {
return Err(Error::BadRequest(format!(
"web push payload too large: {} bytes exceeds safe limit {} bytes",
encoded.len(),
WEB_PUSH_MAX_PAYLOAD_BYTES
)));
}
Ok(encoded)
}
async fn deliver_via_channel( async fn deliver_via_channel(
ctx: &AppContext, ctx: &AppContext,
channel_type: &str, channel_type: &str,
@@ -1126,7 +1239,7 @@ async fn deliver_via_channel(
CHANNEL_WEB_PUSH => { CHANNEL_WEB_PUSH => {
let settings = crate::controllers::site_settings::load_current(ctx).await?; let settings = crate::controllers::site_settings::load_current(ctx).await?;
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?; let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
let payload = serde_json::to_vec(&build_web_push_payload(message))?; let payload = encode_web_push_payload(message)?;
web_push_service::send_payload( web_push_service::send_payload(
&settings, &settings,
&subscription_info, &subscription_info,
@@ -1275,24 +1388,25 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
.await?; .await?;
} }
Err(error) => { Err(error) => {
let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS) let error_text = error.to_string();
.then(|| (Utc::now() + delivery_retry_delay(attempts)).to_rfc3339());
let status = if next_retry_at.is_some() {
DELIVERY_STATUS_RETRY_PENDING
} else {
DELIVERY_STATUS_EXHAUSTED
};
let mut active = delivery.into_active_model(); let mut active = delivery.into_active_model();
active.status = Set(status.to_string()); active.status = Set(DELIVERY_STATUS_EXHAUSTED.to_string());
active.provider = Set(Some(provider_name(&delivery_channel_type).to_string())); active.provider = Set(Some(provider_name(&delivery_channel_type).to_string()));
active.response_text = Set(Some(error.to_string())); active.response_text = Set(Some(error_text.clone()));
active.attempts_count = Set(attempts); active.attempts_count = Set(attempts);
active.last_attempt_at = Set(Some(Utc::now().to_rfc3339())); active.last_attempt_at = Set(Some(Utc::now().to_rfc3339()));
active.next_retry_at = Set(next_retry_at); active.next_retry_at = Set(None);
active.delivered_at = Set(Some(Utc::now().to_rfc3339())); active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?; let _ = active.update(&ctx.db).await?;
update_subscription_delivery_state(ctx, subscription_id, status, false).await?; update_subscription_delivery_state(
ctx,
subscription_id,
DELIVERY_STATUS_EXHAUSTED,
false,
)
.await?;
maybe_pause_failed_web_push_subscription(ctx, subscription.as_ref(), &error_text)
.await?;
Err(error)?; Err(error)?;
} }
} }

View File

@@ -1,4 +1,5 @@
use loco_rs::prelude::*; use loco_rs::prelude::*;
use reqwest::Url;
use serde_json::Value; use serde_json::Value;
use web_push::{ use web_push::{
ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder, ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder,
@@ -46,17 +47,30 @@ pub fn vapid_subject(settings: &site_settings::Model) -> Option<String> {
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT)) .or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT))
} }
fn normalize_vapid_subject(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim();
if trimmed.starts_with("mailto:") || trimmed.starts_with("https://") {
Some(trimmed.to_string())
} else {
None
}
})
}
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String { fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
vapid_subject(settings) normalize_vapid_subject(vapid_subject(settings))
.or_else(|| { .or_else(|| normalize_vapid_subject(site_url.map(ToString::to_string)))
site_url
.map(str::trim)
.filter(|value| value.starts_with("http://") || value.starts_with("https://"))
.map(ToString::to_string)
})
.unwrap_or_else(|| "mailto:noreply@example.com".to_string()) .unwrap_or_else(|| "mailto:noreply@example.com".to_string())
} }
fn subscription_endpoint_host(subscription_info: &SubscriptionInfo) -> String {
Url::parse(&subscription_info.endpoint)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string())
}
pub fn public_key_configured(settings: &site_settings::Model) -> bool { pub fn public_key_configured(settings: &site_settings::Model) -> bool {
public_key(settings).is_some() public_key(settings).is_some()
} }
@@ -90,10 +104,12 @@ pub async fn send_payload(
) -> Result<()> { ) -> Result<()> {
let private_key = private_key(settings) let private_key = private_key(settings)
.ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?; .ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?;
let subject = effective_vapid_subject(settings, site_url);
let endpoint_host = subscription_endpoint_host(subscription_info);
let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info) let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info)
.map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?; .map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?;
signature_builder.add_claim("sub", effective_vapid_subject(settings, site_url)); signature_builder.add_claim("sub", subject.clone());
let signature = signature_builder let signature = signature_builder
.build() .build()
.map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?; .map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?;
@@ -111,10 +127,11 @@ pub async fn send_payload(
.build() .build()
.map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?; .map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?;
client client.send(message).await.map_err(|error| {
.send(message) Error::BadRequest(format!(
.await "web push send failed: {error}; vapid subject={subject}; endpoint_host={endpoint_host}"
.map_err(|error| Error::BadRequest(format!("web push send failed: {error}")))?; ))
})?;
Ok(()) Ok(())
} }

View File

@@ -614,6 +614,20 @@ pub async fn update_job_result(ctx: &AppContext, id: i32, result: Value) -> Resu
Ok(()) Ok(())
} }
pub async fn cancel_job_if_requested(ctx: &AppContext, id: i32, reason: &str) -> Result<bool> {
let item = find_job(ctx, id).await?;
if item.status == JOB_STATUS_CANCELLED {
return Ok(true);
}
if item.cancel_requested {
finish_job_cancelled(ctx, id, Some(reason.to_string())).await?;
return Ok(true);
}
Ok(false)
}
pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> { pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
let item = find_job(ctx, id).await?; let item = find_job(ctx, id).await?;
let mut active = item.into_active_model(); let mut active = item.into_active_model();
@@ -708,6 +722,13 @@ pub async fn queue_notification_delivery_job(
.one(&ctx.db) .one(&ctx.db)
.await? .await?
.ok_or(Error::NotFound)?; .ok_or(Error::NotFound)?;
let mut delivery_active = delivery.clone().into_active_model();
delivery_active.status = Set(subscriptions::DELIVERY_STATUS_QUEUED.to_string());
delivery_active.response_text = Set(None);
delivery_active.next_retry_at = Set(None);
delivery_active.delivered_at = Set(None);
delivery_active.attempts_count = Set(0);
let delivery = delivery_active.update(&ctx.db).await?;
let base_args = NotificationDeliveryWorkerArgs { let base_args = NotificationDeliveryWorkerArgs {
delivery_id, delivery_id,

View File

@@ -55,6 +55,16 @@ impl BackgroundWorker<AiReindexWorkerArgs> for AiReindexWorker {
Ok(()) Ok(())
} }
Err(error) => { Err(error) => {
if worker_jobs::cancel_job_if_requested(
&self.ctx,
job_id,
"job cancelled during reindex",
)
.await?
{
return Ok(());
}
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?; worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
Err(error) Err(error)
} }

View File

@@ -20,10 +20,6 @@ impl BackgroundWorker<NotificationDeliveryWorkerArgs> for NotificationDeliveryWo
Self { ctx: ctx.clone() } Self { ctx: ctx.clone() }
} }
fn tags() -> Vec<String> {
vec!["notifications".to_string()]
}
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> { async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
if let Some(job_id) = args.job_id { if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? { if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {

View File

@@ -105,8 +105,8 @@ function Start-Backend {
-Run { -Run {
$env:DATABASE_URL = $DatabaseUrl $env:DATABASE_URL = $DatabaseUrl
Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan
Write-Host "[backend] Starting Loco.rs server..." -ForegroundColor Green Write-Host "[backend] Starting Loco.rs server + worker..." -ForegroundColor Green
cargo loco start 2>&1 cargo loco start --server-and-worker 2>&1
} }
} }

View File

@@ -1,4 +1,4 @@
self.addEventListener('push', (event) => { self.addEventListener("push", (event) => {
const payload = (() => { const payload = (() => {
if (!event.data) { if (!event.data) {
return {}; return {};
@@ -13,39 +13,59 @@ self.addEventListener('push', (event) => {
} }
})(); })();
const title = payload.title || '订阅更新'; const notifyClients = self.clients
const url = payload.url || '/'; .matchAll({ type: "window", includeUncontrolled: true })
.then((clients) =>
Promise.all(
clients.map((client) =>
client.postMessage({
type: "termi:web-push-received",
payload,
}),
),
),
);
const title = payload.title || "订阅更新";
const url = payload.url || "/";
const options = { const options = {
body: payload.body || '', body: payload.body || "",
icon: payload.icon || '/favicon.svg', icon: payload.icon || "/favicon.svg",
badge: payload.badge || '/favicon.ico', badge: payload.badge || "/favicon.ico",
tag: payload.tag || 'termi-subscription', tag: payload.tag || "termi-subscription",
data: { data: {
url, url,
...(payload.data || {}), ...(payload.data || {}),
}, },
}; };
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification?.data?.url || '/';
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { Promise.all([
for (const client of clients) { self.registration.showNotification(title, options),
if ('focus' in client && client.url === targetUrl) { notifyClients,
return client.focus(); ]),
} );
} });
if (self.clients.openWindow) { self.addEventListener("notificationclick", (event) => {
return self.clients.openWindow(targetUrl); event.notification.close();
} const targetUrl = event.notification?.data?.url || "/";
return undefined; event.waitUntil(
}), self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if ("focus" in client && client.url === targetUrl) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
return undefined;
}),
); );
}); });

View File

@@ -360,7 +360,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile'; import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
import { import {
ensureBrowserPushSubscription, ensureBrowserPushSubscription,
getBrowserPushSubscription, getBrowserPushSubscriptionState,
supportsBrowserPush, supportsBrowserPush,
} from '../lib/utils/web-push'; } from '../lib/utils/web-push';
@@ -505,6 +505,14 @@ const webPushAvailable = Boolean(webPushPublicKey);
} }
}; };
const forgetSubmitted = () => {
try {
window.localStorage.removeItem(SUBSCRIBED_KEY);
} catch {
// Ignore storage failures.
}
};
const resetStatus = () => { const resetStatus = () => {
delete status.dataset.state; delete status.dataset.state;
status.textContent = defaultStatus; status.textContent = defaultStatus;
@@ -811,7 +819,19 @@ const webPushAvailable = Boolean(webPushPublicKey);
} }
try { try {
const subscription = await getBrowserPushSubscription(); const { subscription, stale } = await getBrowserPushSubscriptionState(
browserPushPublicKey,
);
if (stale) {
forgetSubmitted();
setBrowserAvailability({
selectable: true,
note: '检测到提醒配置已更新,需要重新开启一次提醒。',
});
return;
}
if (subscription) { if (subscription) {
rememberSubmitted(); rememberSubmitted();
setBrowserAvailability({ setBrowserAvailability({

View File

@@ -1,4 +1,4 @@
const SERVICE_WORKER_URL = '/termi-web-push-sw.js'; const SERVICE_WORKER_URL = "/termi-web-push-sw.js";
export type BrowserPushSubscriptionPayload = { export type BrowserPushSubscriptionPayload = {
endpoint: string; endpoint: string;
@@ -9,21 +9,26 @@ export type BrowserPushSubscriptionPayload = {
}; };
}; };
export type BrowserPushSubscriptionState = {
subscription: PushSubscription | null;
stale: boolean;
};
function ensureBrowserSupport() { function ensureBrowserSupport() {
if ( if (
typeof window === 'undefined' || typeof window === "undefined" ||
!('Notification' in window) || !("Notification" in window) ||
!('serviceWorker' in navigator) || !("serviceWorker" in navigator) ||
!('PushManager' in window) !("PushManager" in window)
) { ) {
throw new Error('当前浏览器不支持 Web Push。'); throw new Error("当前浏览器不支持 Web Push。");
} }
} }
function urlBase64ToUint8Array(base64String: string) { function urlBase64ToUint8Array(base64String: string) {
const normalized = base64String.trim(); const normalized = base64String.trim();
const padding = '='.repeat((4 - (normalized.length % 4)) % 4); const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/'); const base64 = (normalized + padding).replace(/-/g, "+").replace(/_/g, "/");
const binary = window.atob(base64); const binary = window.atob(base64);
const output = new Uint8Array(binary.length); const output = new Uint8Array(binary.length);
@@ -34,22 +39,66 @@ function urlBase64ToUint8Array(base64String: string) {
return output; return output;
} }
function toUint8Array(value: BufferSource | null | undefined) {
if (!value) {
return null;
}
if (value instanceof Uint8Array) {
return value;
}
if (value instanceof ArrayBuffer) {
return new Uint8Array(value);
}
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
function uint8ArraysEqual(left: Uint8Array | null, right: Uint8Array | null) {
if (!left || !right || left.byteLength !== right.byteLength) {
return false;
}
for (let index = 0; index < left.byteLength; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function subscriptionMatchesPublicKey(
subscription: PushSubscription,
publicKeyBytes: Uint8Array | null,
) {
if (!publicKeyBytes) {
return true;
}
const currentKey = toUint8Array(
subscription.options?.applicationServerKey ?? null,
);
return uint8ArraysEqual(currentKey, publicKeyBytes);
}
async function getRegistration() { async function getRegistration() {
ensureBrowserSupport(); ensureBrowserSupport();
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' }); await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: "/" });
return navigator.serviceWorker.ready; return navigator.serviceWorker.ready;
} }
function normalizeSubscription( function normalizeSubscription(
subscription: PushSubscription | PushSubscriptionJSON, subscription: PushSubscription | PushSubscriptionJSON,
): BrowserPushSubscriptionPayload { ): BrowserPushSubscriptionPayload {
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription; const json = "toJSON" in subscription ? subscription.toJSON() : subscription;
const endpoint = json.endpoint?.trim() || ''; const endpoint = json.endpoint?.trim() || "";
const auth = json.keys?.auth?.trim() || ''; const auth = json.keys?.auth?.trim() || "";
const p256dh = json.keys?.p256dh?.trim() || ''; const p256dh = json.keys?.p256dh?.trim() || "";
if (!endpoint || !auth || !p256dh) { if (!endpoint || !auth || !p256dh) {
throw new Error('浏览器返回的 PushSubscription 不完整。'); throw new Error("浏览器返回的 PushSubscription 不完整。");
} }
return { return {
@@ -64,20 +113,53 @@ function normalizeSubscription(
export function supportsBrowserPush() { export function supportsBrowserPush() {
return ( return (
typeof window !== 'undefined' && typeof window !== "undefined" &&
'Notification' in window && "Notification" in window &&
'serviceWorker' in navigator && "serviceWorker" in navigator &&
'PushManager' in window "PushManager" in window
); );
} }
export async function getBrowserPushSubscription() { export async function getBrowserPushSubscriptionState(
publicKey?: string,
): Promise<BrowserPushSubscriptionState> {
if (!supportsBrowserPush()) { if (!supportsBrowserPush()) {
return null; return {
subscription: null,
stale: false,
};
} }
const registration = await getRegistration(); const registration = await getRegistration();
return registration.pushManager.getSubscription(); const subscription = await registration.pushManager.getSubscription();
const normalizedPublicKey = publicKey?.trim() || "";
const publicKeyBytes = normalizedPublicKey
? urlBase64ToUint8Array(normalizedPublicKey)
: null;
if (!subscription) {
return {
subscription: null,
stale: false,
};
}
if (!subscriptionMatchesPublicKey(subscription, publicKeyBytes)) {
return {
subscription: null,
stale: true,
};
}
return {
subscription,
stale: false,
};
}
export async function getBrowserPushSubscription(publicKey?: string) {
const state = await getBrowserPushSubscriptionState(publicKey);
return state.subscription;
} }
export async function ensureBrowserPushSubscription( export async function ensureBrowserPushSubscription(
@@ -86,25 +168,34 @@ export async function ensureBrowserPushSubscription(
ensureBrowserSupport(); ensureBrowserSupport();
if (!publicKey.trim()) { if (!publicKey.trim()) {
throw new Error('Web Push 公钥未配置。'); throw new Error("Web Push 公钥未配置。");
} }
const permission = const permission =
Notification.permission === 'granted' Notification.permission === "granted"
? 'granted' ? "granted"
: await Notification.requestPermission(); : await Notification.requestPermission();
if (permission !== 'granted') { if (permission !== "granted") {
throw new Error('浏览器通知权限未开启。'); throw new Error("浏览器通知权限未开启。");
} }
const registration = await getRegistration(); const registration = await getRegistration();
const publicKeyBytes = urlBase64ToUint8Array(publicKey);
let subscription = await registration.pushManager.getSubscription(); let subscription = await registration.pushManager.getSubscription();
if (
subscription &&
!subscriptionMatchesPublicKey(subscription, publicKeyBytes)
) {
await subscription.unsubscribe().catch(() => undefined);
subscription = null;
}
if (!subscription) { if (!subscription) {
subscription = await registration.pushManager.subscribe({ subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey), applicationServerKey: publicKeyBytes,
}); });
} }

View File

@@ -0,0 +1,68 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, devices } from "@playwright/test";
const backendBaseUrl = "http://127.0.0.1:5150";
const frontendBaseUrl = "http://127.0.0.1:4321";
const isCi = Boolean(process.env.CI);
const webPushChannel =
process.env.PLAYWRIGHT_WEB_PUSH_CHANNEL ??
(process.platform === "win32" ? "msedge" : undefined);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
export default defineConfig({
testDir: "./tests",
testMatch: /web-push\.real\.spec\.ts/,
fullyParallel: false,
workers: 1,
timeout: 180_000,
expect: {
timeout: 20_000,
},
reporter: [["list"]],
use: {
...devices["Desktop Chrome"],
channel: webPushChannel,
baseURL: frontendBaseUrl,
headless: true,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: [
{
command: "cargo loco start --server-and-worker",
cwd: path.resolve(repoRoot, "backend"),
url: `${backendBaseUrl}/api/site_settings`,
reuseExistingServer: false,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
DATABASE_URL:
process.env.DATABASE_URL ??
"postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development",
REDIS_URL: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
TERMI_ADMIN_LOCAL_LOGIN_ENABLED:
process.env.TERMI_ADMIN_LOCAL_LOGIN_ENABLED ?? "true",
},
},
{
command: "pnpm dev --host 127.0.0.1 --port 4321",
cwd: path.resolve(repoRoot, "frontend"),
url: frontendBaseUrl,
reuseExistingServer: false,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
PUBLIC_API_BASE_URL: `${backendBaseUrl}/api`,
INTERNAL_API_BASE_URL: `${backendBaseUrl}/api`,
PUBLIC_IMAGE_ALLOWED_HOSTS: "127.0.0.1,127.0.0.1:5150",
},
},
],
});

View File

@@ -0,0 +1,122 @@
import { chromium, expect, test } from "@playwright/test";
const BACKEND_BASE_URL = "http://127.0.0.1:5150";
const FRONTEND_BASE_URL = "http://127.0.0.1:4321";
type AdminSubscriptionRecord = {
id: number;
channel_type: string;
target: string;
};
type AdminSubscriptionListResponse = {
subscriptions: AdminSubscriptionRecord[];
};
declare global {
interface Window {
__termiPushMessages?: unknown[];
__termiSubscriptionPopupReady?: boolean;
}
}
test("浏览器订阅后可以收到测试推送", async ({ request }, testInfo) => {
const context = await chromium.launchPersistentContext(
testInfo.outputPath("web-push-user-data"),
{
headless: true,
viewport: { width: 1280, height: 800 },
},
);
try {
await context.grantPermissions(["notifications"], {
origin: FRONTEND_BASE_URL,
});
const page = context.pages()[0] ?? (await context.newPage());
await page.addInitScript(() => {
window.__termiPushMessages = [];
navigator.serviceWorker?.addEventListener("message", (event) => {
if (event.data?.type === "termi:web-push-received") {
window.__termiPushMessages?.push(event.data.payload ?? null);
}
});
});
await page.goto(`${FRONTEND_BASE_URL}/maintenance?returnTo=%2F`);
await page.getByLabel("访问口令").fill("termi");
await page.getByRole("button", { name: "进入站点" }).click();
await page.waitForURL(`${FRONTEND_BASE_URL}/`);
await page.waitForFunction(
() => window.__termiSubscriptionPopupReady === true,
);
await page.locator("[data-subscription-popup-open]").click();
const subscribeResponsePromise = page.waitForResponse(
(response) =>
response.url().includes("/api/subscriptions/combined") &&
response.request().method() === "POST",
);
await page.locator("[data-subscription-popup-submit]").click();
const subscribeResponse = await subscribeResponsePromise;
expect(subscribeResponse.ok()).toBeTruthy();
await expect(
page.locator('[data-subscription-popup-status][data-state="success"]'),
).toBeVisible();
const endpoint = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return subscription?.endpoint ?? null;
});
expect(endpoint).toBeTruthy();
const loginResponse = await request.post(
`${BACKEND_BASE_URL}/api/admin/session/login`,
{
data: {
username: "admin",
password: "admin123",
},
},
);
expect(loginResponse.ok()).toBeTruthy();
const subscriptionsResponse = await request.get(
`${BACKEND_BASE_URL}/api/admin/subscriptions`,
);
expect(subscriptionsResponse.ok()).toBeTruthy();
const subscriptionsPayload =
(await subscriptionsResponse.json()) as AdminSubscriptionListResponse;
const subscription = subscriptionsPayload.subscriptions.find(
(item) => item.channel_type === "web_push" && item.target === endpoint,
);
expect(subscription).toBeTruthy();
const testResponse = await request.post(
`${BACKEND_BASE_URL}/api/admin/subscriptions/${subscription?.id}/test`,
);
expect(testResponse.ok()).toBeTruthy();
await page.waitForFunction(
() =>
Array.isArray(window.__termiPushMessages) &&
window.__termiPushMessages.length > 0,
undefined,
{ timeout: 45_000 },
);
const messages = await page.evaluate(() => window.__termiPushMessages ?? []);
expect(messages.length).toBeGreaterThan(0);
} finally {
await context.close();
}
});