feat(analysis): live SSE updates in the admin coverage dashboard
The Analysis section now reflects worker progress in real time: - Opens an EventSource on the live stream while mounted; a "Live" pill reflects connection state and reconnects on drop (probes after repeated failures so a lost session logs out). - Applies events incrementally: enqueued marks in-scope loaded pages queued; started flips a chip to a pulsing "analyzing"; completed flips it green and live-bumps the manga + chapter coverage badges (guarded so no double count); failed flips it red. A compact activity ticker shows the last few events. - Server numbers stay authoritative: re-loading the overview or a page grid clears the matching live overrides. API client: analysisStatusStreamUrl() + AnalysisEvent type. Tests: vitest for the stream URL; Playwright asserts SSE frames flow into the ticker (existing coverage/drill/queue specs get a default quiet stream). svelte-check + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,14 @@ async function mockAdmin(page: Page, cap: Captured) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Default: keep the SSE connection pending (no events) so tests that
|
||||||
|
// don't care about live updates don't trigger reconnect churn. The
|
||||||
|
// live-updates test overrides this with a fulfilling stream.
|
||||||
|
await page.route(
|
||||||
|
'**/api/v1/admin/analysis/status/stream',
|
||||||
|
() => new Promise(() => {})
|
||||||
|
);
|
||||||
|
|
||||||
await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
|
await page.route('**/api/v1/admin/analysis/reenqueue', (r) => {
|
||||||
cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
|
cap.reenqueue = JSON.parse(r.request().postData() ?? '{}');
|
||||||
return r.fulfill({
|
return r.fulfill({
|
||||||
@@ -221,6 +229,49 @@ test.describe('/admin/analysis', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('live SSE events drive the indicator and activity ticker', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||||
|
await mockAdmin(page, cap);
|
||||||
|
// Override the default hanging stream with one that emits two frames.
|
||||||
|
const frames =
|
||||||
|
`event: analysis\ndata: ${JSON.stringify({
|
||||||
|
kind: 'started',
|
||||||
|
page_id: pageNone,
|
||||||
|
manga_id: mangaId,
|
||||||
|
chapter_id: chapterId,
|
||||||
|
page_number: 2
|
||||||
|
})}\n\n` +
|
||||||
|
`event: analysis\ndata: ${JSON.stringify({
|
||||||
|
kind: 'completed',
|
||||||
|
page_id: pageNone,
|
||||||
|
manga_id: mangaId,
|
||||||
|
chapter_id: chapterId,
|
||||||
|
page_number: 2
|
||||||
|
})}\n\n`;
|
||||||
|
let served = false;
|
||||||
|
await page.route('**/api/v1/admin/analysis/status/stream', (r) => {
|
||||||
|
if (served) return new Promise(() => {}); // hang on reconnect
|
||||||
|
served = true;
|
||||||
|
return r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream' },
|
||||||
|
body: frames
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await page.goto('/admin/analysis');
|
||||||
|
|
||||||
|
// The two SSE frames are parsed and applied to the activity ticker.
|
||||||
|
// (The mocked stream closes after its body, so the live pill flips
|
||||||
|
// back to "Reconnecting…" — the ticker is the durable signal.)
|
||||||
|
const tick = page.getByTestId('admin-analysis-ticker');
|
||||||
|
await expect(tick).toContainText('Analyzing page 2');
|
||||||
|
await expect(tick).toContainText('Analyzed page 2');
|
||||||
|
});
|
||||||
|
|
||||||
test('queue an unanalyzed page from its detail modal', async ({ page }) => {
|
test('queue an unanalyzed page from its detail modal', async ({ page }) => {
|
||||||
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
const cap: Captured = { reenqueue: null, analyzeCalls: 0 };
|
||||||
await mockAdmin(page, cap);
|
await mockAdmin(page, cap);
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import {
|
|||||||
getAnalysisMangaCoverage,
|
getAnalysisMangaCoverage,
|
||||||
getAnalysisChapterCoverage,
|
getAnalysisChapterCoverage,
|
||||||
getAnalysisChapterPages,
|
getAnalysisChapterPages,
|
||||||
getAnalysisPageDetail
|
getAnalysisPageDetail,
|
||||||
|
analysisStatusStreamUrl
|
||||||
} from './admin';
|
} from './admin';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
function ok(body: unknown, status = 200): Response {
|
||||||
@@ -580,4 +581,10 @@ describe('admin crawler api client', () => {
|
|||||||
expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
|
expect(url).toMatch(/\/v1\/admin\/analysis\/pages\/p1$/);
|
||||||
expect(d.status).toBe('none');
|
expect(d.status).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('analysisStatusStreamUrl points at the SSE endpoint', () => {
|
||||||
|
expect(analysisStatusStreamUrl()).toMatch(
|
||||||
|
/\/v1\/admin\/analysis\/status\/stream$/
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -532,3 +532,27 @@ export async function getAnalysisPageDetail(
|
|||||||
`/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
|
`/v1/admin/analysis/pages/${encodeURIComponent(pageId)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One live analysis event from the SSE stream. */
|
||||||
|
export type AnalysisEvent =
|
||||||
|
| {
|
||||||
|
kind: 'enqueued';
|
||||||
|
count: number;
|
||||||
|
manga_id: string | null;
|
||||||
|
chapter_id: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'started' | 'completed' | 'failed';
|
||||||
|
page_id: string;
|
||||||
|
manga_id: string;
|
||||||
|
chapter_id: string;
|
||||||
|
page_number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** URL of the live analysis SSE stream. Open with `new EventSource(...)`
|
||||||
|
* while the admin Analysis page is mounted and close it on navigate-away.
|
||||||
|
* Each message is a named `analysis` event whose `data` is an
|
||||||
|
* {@link AnalysisEvent}; a `lagged` event signals dropped frames. */
|
||||||
|
export function analysisStatusStreamUrl(): string {
|
||||||
|
return apiUrl('/v1/admin/analysis/status/stream');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
import {
|
import {
|
||||||
reenqueueAnalysis,
|
reenqueueAnalysis,
|
||||||
@@ -8,11 +8,13 @@
|
|||||||
getAnalysisChapterCoverage,
|
getAnalysisChapterCoverage,
|
||||||
getAnalysisChapterPages,
|
getAnalysisChapterPages,
|
||||||
getAnalysisPageDetail,
|
getAnalysisPageDetail,
|
||||||
|
analysisStatusStreamUrl,
|
||||||
type MangaCoverage,
|
type MangaCoverage,
|
||||||
type ChapterCoverage,
|
type ChapterCoverage,
|
||||||
type PageStatusItem,
|
type PageStatusItem,
|
||||||
type PageAnalysisDetail,
|
type PageAnalysisDetail,
|
||||||
type ReenqueueAnalysisOptions
|
type ReenqueueAnalysisOptions,
|
||||||
|
type AnalysisEvent
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { chapterLabel } from '$lib/api/chapters';
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
|
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
|
||||||
@@ -50,10 +52,154 @@
|
|||||||
let notice = $state<string | null>(null);
|
let notice = $state<string | null>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// --- Live status (SSE) ---
|
||||||
|
type LiveStatus = 'analyzing' | 'done' | 'failed' | 'queued';
|
||||||
|
// Per-page override applied on top of the fetched grid status.
|
||||||
|
let liveStatus = $state<Record<string, LiveStatus>>({});
|
||||||
|
// Pages we've already live-counted into coverage, so a repeat
|
||||||
|
// `completed` can't double-bump a badge.
|
||||||
|
let counted = new Set<string>();
|
||||||
|
let ticker = $state<{ id: number; text: string }[]>([]);
|
||||||
|
let tickerSeq = 0;
|
||||||
|
let live = $state(false);
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
let sseErrors = 0;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void loadCoverage(true);
|
void loadCoverage(true);
|
||||||
|
openStream();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
source?.close();
|
||||||
|
source = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function pushTicker(text: string) {
|
||||||
|
ticker = [{ id: ++tickerSeq, text }, ...ticker].slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective chip status: live override wins over the fetched value. */
|
||||||
|
function chipStatus(p: PageStatusItem): string {
|
||||||
|
return liveStatus[p.page_id] ?? p.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mangaId that owns a loaded chapter, via the chapters cache. */
|
||||||
|
function mangaOfChapter(chapterId: string): string | null {
|
||||||
|
for (const [mid, val] of Object.entries(chapters)) {
|
||||||
|
if (Array.isArray(val) && val.some((c) => c.chapter_id === chapterId)) {
|
||||||
|
return mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpCoverage(mangaId: string, chapterId: string) {
|
||||||
|
mangas = mangas.map((m) =>
|
||||||
|
m.manga_id === mangaId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
analyzed_pages: Math.min(m.total_pages, m.analyzed_pages + 1)
|
||||||
|
}
|
||||||
|
: m
|
||||||
|
);
|
||||||
|
const chs = chapters[mangaId];
|
||||||
|
if (Array.isArray(chs)) {
|
||||||
|
chapters = {
|
||||||
|
...chapters,
|
||||||
|
[mangaId]: chs.map((c) =>
|
||||||
|
c.chapter_id === chapterId
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
analyzed_pages: Math.min(
|
||||||
|
c.total_pages,
|
||||||
|
c.analyzed_pages + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark loaded, in-scope, not-yet-done pages as queued. */
|
||||||
|
function markQueued(ev: Extract<AnalysisEvent, { kind: 'enqueued' }>) {
|
||||||
|
const next = { ...liveStatus };
|
||||||
|
for (const [chapterId, val] of Object.entries(pages)) {
|
||||||
|
if (!Array.isArray(val)) continue;
|
||||||
|
const inScope =
|
||||||
|
ev.chapter_id != null
|
||||||
|
? chapterId === ev.chapter_id
|
||||||
|
: ev.manga_id != null
|
||||||
|
? mangaOfChapter(chapterId) === ev.manga_id
|
||||||
|
: true; // whole library
|
||||||
|
if (!inScope) continue;
|
||||||
|
for (const p of val) {
|
||||||
|
const cur: string = next[p.page_id] ?? p.status;
|
||||||
|
if (cur === 'none' || cur === 'failed') next[p.page_id] = 'queued';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liveStatus = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEvent(ev: AnalysisEvent) {
|
||||||
|
if (ev.kind === 'enqueued') {
|
||||||
|
const scope = ev.chapter_id
|
||||||
|
? ' (chapter)'
|
||||||
|
: ev.manga_id
|
||||||
|
? ' (manga)'
|
||||||
|
: ' (library)';
|
||||||
|
pushTicker(`Queued ${ev.count} page${ev.count === 1 ? '' : 's'}${scope}`);
|
||||||
|
markQueued(ev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'started') {
|
||||||
|
liveStatus = { ...liveStatus, [ev.page_id]: 'analyzing' };
|
||||||
|
pushTicker(`Analyzing page ${ev.page_number}`);
|
||||||
|
} else if (ev.kind === 'completed') {
|
||||||
|
liveStatus = { ...liveStatus, [ev.page_id]: 'done' };
|
||||||
|
if (!counted.has(ev.page_id)) {
|
||||||
|
counted.add(ev.page_id);
|
||||||
|
bumpCoverage(ev.manga_id, ev.chapter_id);
|
||||||
|
}
|
||||||
|
pushTicker(`✓ Analyzed page ${ev.page_number}`);
|
||||||
|
} else if (ev.kind === 'failed') {
|
||||||
|
liveStatus = { ...liveStatus, [ev.page_id]: 'failed' };
|
||||||
|
pushTicker(`✗ Failed page ${ev.page_number}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStream() {
|
||||||
|
if (source) return;
|
||||||
|
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
|
||||||
|
es.addEventListener('analysis', (e) => {
|
||||||
|
try {
|
||||||
|
applyEvent(JSON.parse((e as MessageEvent).data) as AnalysisEvent);
|
||||||
|
live = true;
|
||||||
|
sseErrors = 0;
|
||||||
|
} catch {
|
||||||
|
// ignore a malformed frame
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Dropped frames — resync the overview from the server.
|
||||||
|
es.addEventListener('lagged', () => void loadCoverage(true));
|
||||||
|
es.onopen = () => {
|
||||||
|
live = true;
|
||||||
|
sseErrors = 0;
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
live = false;
|
||||||
|
sseErrors += 1;
|
||||||
|
// EventSource hides the status code; after several failures,
|
||||||
|
// probe a normal admin call so the global on401 hook can fire.
|
||||||
|
if (sseErrors >= 4) {
|
||||||
|
sseErrors = 0;
|
||||||
|
getAnalysisMangaCoverage({ limit: 1 }).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source = es;
|
||||||
|
}
|
||||||
|
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(() => loadCoverage(true), 300);
|
searchTimer = setTimeout(() => loadCoverage(true), 300);
|
||||||
@@ -66,6 +212,9 @@
|
|||||||
// Collapse open rows so stale chapter/page state doesn't linger.
|
// Collapse open rows so stale chapter/page state doesn't linger.
|
||||||
expandedManga = null;
|
expandedManga = null;
|
||||||
expandedChapter = null;
|
expandedChapter = null;
|
||||||
|
// Server numbers are authoritative again — drop live overrides.
|
||||||
|
liveStatus = {};
|
||||||
|
counted = new Set();
|
||||||
} else {
|
} else {
|
||||||
loadingMore = true;
|
loadingMore = true;
|
||||||
}
|
}
|
||||||
@@ -122,7 +271,13 @@
|
|||||||
async function loadPages(id: string) {
|
async function loadPages(id: string) {
|
||||||
pages[id] = 'loading';
|
pages[id] = 'loading';
|
||||||
try {
|
try {
|
||||||
pages[id] = await getAnalysisChapterPages(id);
|
const fresh = await getAnalysisChapterPages(id);
|
||||||
|
// Backend status is authoritative for just-fetched pages; drop
|
||||||
|
// any live overrides for them so the grid reflects the server.
|
||||||
|
const next = { ...liveStatus };
|
||||||
|
for (const p of fresh) delete next[p.page_id];
|
||||||
|
liveStatus = next;
|
||||||
|
pages[id] = fresh;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
delete pages[id];
|
delete pages[id];
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load pages.';
|
error = e instanceof ApiError ? e.message : 'Failed to load pages.';
|
||||||
@@ -220,13 +375,32 @@
|
|||||||
<title>Mangalord | Admin — Analysis</title>
|
<title>Mangalord | Admin — Analysis</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1 class="heading">Content analysis</h1>
|
<div class="title-row">
|
||||||
|
<h1 class="heading">Content analysis</h1>
|
||||||
|
<span
|
||||||
|
class="live-pill"
|
||||||
|
class:on={live}
|
||||||
|
data-testid="admin-analysis-live"
|
||||||
|
title={live ? 'Live updates connected' : 'Reconnecting…'}
|
||||||
|
>
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
{live ? 'Live' : 'Reconnecting…'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
|
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
|
||||||
NSFW moderation). Browse what's analyzed, queue more, and inspect any
|
NSFW moderation). Browse what's analyzed, queue more, and inspect any
|
||||||
page's result.
|
page's result. Updates stream live as pages are queued and processed.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{#if ticker.length > 0}
|
||||||
|
<ul class="ticker" data-testid="admin-analysis-ticker" aria-live="polite">
|
||||||
|
{#each ticker as t (t.id)}
|
||||||
|
<li>{t.text}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -365,11 +539,11 @@
|
|||||||
{#each ps as p (p.page_id)}
|
{#each ps as p (p.page_id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="page-chip {p.status}"
|
class="page-chip {chipStatus(p)}"
|
||||||
title={`Page ${p.page_number} — ${p.status}`}
|
title={`Page ${p.page_number} — ${chipStatus(p)}`}
|
||||||
onclick={() => openDetail(p.page_id)}
|
onclick={() => openDetail(p.page_id)}
|
||||||
data-testid={`admin-analysis-page-${p.page_id}`}
|
data-testid={`admin-analysis-page-${p.page_id}`}
|
||||||
data-status={p.status}
|
data-status={chipStatus(p)}
|
||||||
>
|
>
|
||||||
{p.page_number}
|
{p.page_number}
|
||||||
</button>
|
</button>
|
||||||
@@ -377,6 +551,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="legend">
|
<p class="legend">
|
||||||
<span class="dot done"></span> analyzed
|
<span class="dot done"></span> analyzed
|
||||||
|
<span class="dot analyzing"></span> analyzing
|
||||||
<span class="dot queued"></span> queued
|
<span class="dot queued"></span> queued
|
||||||
<span class="dot failed"></span> failed
|
<span class="dot failed"></span> failed
|
||||||
<span class="dot none"></span> not analyzed
|
<span class="dot none"></span> not analyzed
|
||||||
@@ -496,9 +671,68 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
.heading {
|
.heading {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
.title-row .heading {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.live-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.live-pill.on {
|
||||||
|
color: #2e7d32;
|
||||||
|
border-color: color-mix(in srgb, #2e7d32 40%, transparent);
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.live-pill.on .live-dot {
|
||||||
|
animation: pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.ticker {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
max-width: 44rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
.lede {
|
.lede {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
@@ -620,6 +854,12 @@
|
|||||||
border-color: color-mix(in srgb, #2563eb 45%, transparent);
|
border-color: color-mix(in srgb, #2563eb 45%, transparent);
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
}
|
}
|
||||||
|
.page-chip.analyzing {
|
||||||
|
background: color-mix(in srgb, #d4762a 22%, transparent);
|
||||||
|
border-color: color-mix(in srgb, #d4762a 55%, transparent);
|
||||||
|
color: #b85e1a;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
.page-chip.failed {
|
.page-chip.failed {
|
||||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
|
||||||
@@ -647,6 +887,9 @@
|
|||||||
.dot.queued {
|
.dot.queued {
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
}
|
}
|
||||||
|
.dot.analyzing {
|
||||||
|
background: #d4762a;
|
||||||
|
}
|
||||||
.dot.failed {
|
.dot.failed {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user