includio-cms 0.36.7 → 0.36.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +8 -2
- package/CHANGELOG.md +19 -0
- package/DOCS.md +1 -1
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/media-jobs.d.ts +5 -0
- package/dist/admin/api/media-jobs.js +33 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +9 -0
- package/dist/admin/client/maintenance/media-jobs-panel.svelte +310 -0
- package/dist/admin/client/maintenance/media-jobs-panel.svelte.d.ts +3 -0
- package/dist/core/cms.js +7 -0
- package/dist/core/server/fields/utils/imageStyles.js +41 -4
- package/dist/core/server/index.d.ts +3 -0
- package/dist/core/server/index.js +3 -0
- package/dist/core/server/media/jobs/enqueue.d.ts +6 -0
- package/dist/core/server/media/jobs/enqueue.js +20 -0
- package/dist/core/server/media/jobs/processImageStyleJob.d.ts +19 -0
- package/dist/core/server/media/jobs/processImageStyleJob.js +23 -0
- package/dist/core/server/media/jobs/startWorker.d.ts +2 -0
- package/dist/core/server/media/jobs/startWorker.js +29 -0
- package/dist/core/server/media/jobs/worker.d.ts +29 -0
- package/dist/core/server/media/jobs/worker.js +54 -0
- package/dist/core/server/media/operations/replaceFile.js +2 -2
- package/dist/core/server/media/operations/uploadFile.js +10 -3
- package/dist/core/server/media/styles/buildStyleSpecs.d.ts +12 -0
- package/dist/core/server/media/styles/buildStyleSpecs.js +36 -0
- package/dist/core/server/media/styles/imagesConfig.d.ts +3 -0
- package/dist/core/server/media/styles/imagesConfig.js +16 -0
- package/dist/core/server/media/styles/operations/batchGenerateStyles.js +19 -41
- package/dist/core/server/media/styles/operations/generateFallbackVariant.d.ts +10 -0
- package/dist/core/server/media/styles/operations/generateFallbackVariant.js +21 -0
- package/dist/core/server/media/styles/operations/getImageStyle.js +12 -8
- package/dist/core/server/media/styles/sharp/sharpConfig.d.ts +7 -0
- package/dist/core/server/media/styles/sharp/sharpConfig.js +12 -0
- package/dist/db-postgres/index.js +113 -1
- package/dist/db-postgres/schema/imageStyle.js +1 -1
- package/dist/db-postgres/schema/index.d.ts +1 -0
- package/dist/db-postgres/schema/index.js +1 -0
- package/dist/db-postgres/schema/mediaFile.d.ts +17 -0
- package/dist/db-postgres/schema/mediaFile.js +1 -0
- package/dist/db-postgres/schema/mediaJob.d.ts +279 -0
- package/dist/db-postgres/schema/mediaJob.js +23 -0
- package/dist/sveltekit/components/image.svelte +1 -1
- package/dist/types/adapters/db.d.ts +51 -0
- package/dist/types/cms.d.ts +46 -0
- package/dist/types/media.d.ts +1 -0
- package/dist/updates/0.36.8/index.d.ts +2 -0
- package/dist/updates/0.36.8/index.js +20 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# includio-cms — Public API v0.36.
|
|
1
|
+
# includio-cms — Public API v0.36.8
|
|
2
2
|
|
|
3
3
|
> Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
|
|
4
4
|
|
|
5
|
-
Entry points: **19** · Stable: **
|
|
5
|
+
Entry points: **19** · Stable: **501** · Experimental: **4**
|
|
6
6
|
|
|
7
7
|
Tags:
|
|
8
8
|
- `@public` — frozen for v1.0; semver-protected.
|
|
@@ -28,10 +28,15 @@ Tags:
|
|
|
28
28
|
### `includio-cms/core/server`
|
|
29
29
|
|
|
30
30
|
- `createEntityAPI(cms: CMS, opts?: EntityAPIOptions): <inferred>` — Creates a high-level Entity API (CRUD + publish/archive) bound to a CMS
|
|
31
|
+
- `enqueueStyleSpecs(mediaFile: Pick<MediaFile, 'id' | 'width' | 'height'>, fieldStyles: ImageFieldStyle[] | undefined, opts: { priority?:...): Promise<void>` — Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey.
|
|
31
32
|
- `getAuth(): <inferred>` — Returns the underlying `better-auth` instance from the initialized CMS.
|
|
32
33
|
- `getCMS(): CMS` — Returns the singleton CMS instance. Must be called after `includioCMS()`
|
|
34
|
+
- `interface MediaJob`
|
|
35
|
+
- `type MediaJobStatus = 'pending' | 'processing' | 'done' | 'failed' | 'skipped'`
|
|
33
36
|
- `interface ResolvedMedia` — Resolved media file plus its image styles + blur placeholder. Returned by
|
|
34
37
|
- `resolveMediaWithStyles(mediaIds: string[], styles?: ImageFieldStyle[]): Promise<Record<string, ResolvedMedia>>` — Resolve media files by IDs and generate image styles. Useful for plugins or
|
|
38
|
+
- `startMediaWorker(cfg: WorkerConfig, deps: RunOnceDeps): void` — Startuje ciągłą pętlę workera (idempotentne). Concurrency=1: jeden job naraz.
|
|
39
|
+
- `stopMediaWorker(): void`
|
|
35
40
|
|
|
36
41
|
### `includio-cms/types`
|
|
37
42
|
|
|
@@ -356,6 +361,7 @@ Tags:
|
|
|
356
361
|
- `const invitation: <inferred>`
|
|
357
362
|
- `const mediaFilesTable: <inferred>`
|
|
358
363
|
- `const mediaFileTagsTable: <inferred>`
|
|
364
|
+
- `const mediaJobTable: <inferred>`
|
|
359
365
|
- `const mediaTagsTable: <inferred>`
|
|
360
366
|
- `pg(config: Config): DatabaseAdapterWithDrizzle` — Postgres database adapter (drizzle + `postgres`).
|
|
361
367
|
- `const session: <inferred>`
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.36.8 — 2026-07-02
|
|
7
|
+
|
|
8
|
+
Optymalizacja obrazów przeniesiona do tła: render nigdy nie odpala sharpa. Warianty generuje in-process worker zasilany trwałą kolejką media_job (concurrency=1, twarde limity sharp, retry/backoff, circuit-breaker). Lżejsza, konfigurowalna matryca (avif+webp, bez pełno-res bazy, cap 2560) tnie ~połowę plików/jobów i eliminuje bombę CPU pełno-res AVIF, która OOM-owała VPS.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Kolejka `media_job` + in-process worker: claim `FOR UPDATE SKIP LOCKED`, backoff wykładniczy, circuit-breaker po N próbach, poison-guard `limitInputPixels`, reset stale-locków. Start w `initCMS`, wyłączalny `media.optimization.worker=false`.
|
|
12
|
+
- Render kolejkuje brakujące style zamiast generować synchronicznie — odpowiedź HTTP nigdy nie czeka na sharpa; frontend serwuje lekki fallback + blur do czasu gotowości wariantów.
|
|
13
|
+
- Konfigurowalna matryca `mediaConfig.images`: `formats` (domyślnie avif+webp), `widths` [640,1024,1536,2560], `quality`, `maxDimension` (2560), `fallback` (jpeg ~1280). Bez wariantu bazowego pełno-res i bez martwej drabiny w formacie oryginalnym (~15 → ~8 wariantów/obraz).
|
|
14
|
+
- Upload generuje synchronicznie lekki fallback (`media_file.fallback_url`) i kolejkuje warianty; sweep oraz `replaceFile` też kolejkują (nie generują inline).
|
|
15
|
+
- Admin `/admin/maintenance`: sekcja „Kolejka mediów / Logi" — liczniki pending/processing/failed, lista failed z akcjami Ponów/Pomiń.
|
|
16
|
+
- Globalne limity sharp (`sharp.concurrency`/`cache`) ustawiane raz przy init. Deduplikacja zadań po efektywnej specyfikacji (format+wymiary+crop+quality).
|
|
17
|
+
- Public API: `enqueueStyleSpecs`, `startMediaWorker`, `stopMediaWorker`, typy `MediaJob`/`MediaJobStatus`.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `image_styles`: dodany `crop` do klucza unikalnego (koniec cichego nadpisywania wariantów różniących się tylko cropem) + spójny `ON CONFLICT`.
|
|
21
|
+
|
|
22
|
+
### Breaking
|
|
23
|
+
- Nowa tabela `media_job` i kolumna `media_file.fallback_url` — wymagana migracja (`drizzle-kit push`). Render nie generuje styli synchronicznie (dogeneruje worker w tle). Konsumenci, którzy wyłączyli auto-maintenance z powodu OOM, mogą usunąć `media.maintenance.autoRun=false` — nowy worker jest bezpieczny by-design.
|
|
24
|
+
|
|
6
25
|
## 0.36.7 — 2026-06-24
|
|
7
26
|
|
|
8
27
|
Faktura uwzględnia rabat z kuponu i koszt wysyłki. Pozycje są wystawiane w cenach po rabacie (finalna kwota = kwota zapłacona), a użyty kod rabatowy trafia do uwag faktury. Wcześniej faktura zawierała pełne ceny katalogowe i pomijała rabat.
|
package/DOCS.md
CHANGED
|
@@ -11,6 +11,7 @@ import * as transcodeVideosHandlers from './transcode-videos.js';
|
|
|
11
11
|
import * as uploadLimitHandlers from './upload-limit.js';
|
|
12
12
|
import * as systemInfoHandlers from './system-info.js';
|
|
13
13
|
import * as maintenanceStatusHandlers from './maintenance-status.js';
|
|
14
|
+
import * as mediaJobsHandlers from './media-jobs.js';
|
|
14
15
|
import * as integrationsHandlers from './integrations.js';
|
|
15
16
|
import * as testEmailHandlers from './test-email.js';
|
|
16
17
|
import { requireAuth } from '../remote/middleware/auth.js';
|
|
@@ -30,6 +31,7 @@ export function createAdminApiHandler(options) {
|
|
|
30
31
|
'upload-limit': uploadLimitHandlers,
|
|
31
32
|
'system-info': systemInfoHandlers,
|
|
32
33
|
'maintenance-status': maintenanceStatusHandlers,
|
|
34
|
+
'media-jobs': mediaJobsHandlers,
|
|
33
35
|
integrations: integrationsHandlers,
|
|
34
36
|
'test-email': testEmailHandlers,
|
|
35
37
|
...options?.extraRoutes
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
/** GET /admin/api/media-jobs?status=&type=&limit=&offset= → { counts, jobs } */
|
|
3
|
+
export declare const GET: RequestHandler;
|
|
4
|
+
/** POST /admin/api/media-jobs { id, action: 'retry' | 'skip' } */
|
|
5
|
+
export declare const POST: RequestHandler;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { requireRole } from '../remote/middleware/auth.js';
|
|
2
|
+
import { getCMS } from '../../core/cms.js';
|
|
3
|
+
import { json } from '@sveltejs/kit';
|
|
4
|
+
/** GET /admin/api/media-jobs?status=&type=&limit=&offset= → { counts, jobs } */
|
|
5
|
+
export const GET = async ({ url }) => {
|
|
6
|
+
requireRole('admin');
|
|
7
|
+
const status = url.searchParams.get('status') ?? undefined;
|
|
8
|
+
const type = url.searchParams.get('type') ?? undefined;
|
|
9
|
+
const limit = Number(url.searchParams.get('limit') ?? 50);
|
|
10
|
+
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
11
|
+
const db = getCMS().databaseAdapter;
|
|
12
|
+
const [counts, jobs] = await Promise.all([
|
|
13
|
+
db.countMediaJobs(),
|
|
14
|
+
db.listMediaJobs({ status, type, limit, offset })
|
|
15
|
+
]);
|
|
16
|
+
return json({ counts, jobs });
|
|
17
|
+
};
|
|
18
|
+
/** POST /admin/api/media-jobs { id, action: 'retry' | 'skip' } */
|
|
19
|
+
export const POST = async ({ request }) => {
|
|
20
|
+
requireRole('admin');
|
|
21
|
+
const body = (await request.json());
|
|
22
|
+
if (!body.id || (body.action !== 'retry' && body.action !== 'skip')) {
|
|
23
|
+
return json({ error: 'Invalid request' }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
const db = getCMS().databaseAdapter;
|
|
26
|
+
if (body.action === 'retry') {
|
|
27
|
+
await db.retryMediaJob(body.id);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
await db.failMediaJob(body.id, 'skipped by admin', { retry: false });
|
|
31
|
+
}
|
|
32
|
+
return json({ ok: true });
|
|
33
|
+
};
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
|
|
27
27
|
import { sidebarLang } from '../../components/layout/lang.js';
|
|
28
28
|
import PageHeader from '../../components/layout/page-header.svelte';
|
|
29
|
+
import MediaJobsPanel from './media-jobs-panel.svelte';
|
|
29
30
|
|
|
30
31
|
const breadcrumbs = getBreadcrumbs();
|
|
31
32
|
const interfaceLanguage = useInterfaceLanguage();
|
|
@@ -695,6 +696,14 @@
|
|
|
695
696
|
</div>
|
|
696
697
|
</div>
|
|
697
698
|
|
|
699
|
+
<!-- Section: Media jobs / Logs -->
|
|
700
|
+
<div class="mb-8">
|
|
701
|
+
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--muted-foreground);">
|
|
702
|
+
Kolejka mediów / Logi
|
|
703
|
+
</h2>
|
|
704
|
+
<MediaJobsPanel />
|
|
705
|
+
</div>
|
|
706
|
+
|
|
698
707
|
<!-- Section: File Integrity -->
|
|
699
708
|
<div class="mb-8">
|
|
700
709
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--muted-foreground);">
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import Loader2 from '@tabler/icons-svelte/icons/loader-2';
|
|
4
|
+
import Refresh from '@tabler/icons-svelte/icons/refresh';
|
|
5
|
+
import PlayerSkipForward from '@tabler/icons-svelte/icons/player-skip-forward';
|
|
6
|
+
import ListDetails from '@tabler/icons-svelte/icons/list-details';
|
|
7
|
+
import AlertTriangle from '@tabler/icons-svelte/icons/alert-triangle';
|
|
8
|
+
import Button from '../../../components/ui/button/button.svelte';
|
|
9
|
+
import * as Card from '../../../components/ui/card/index.js';
|
|
10
|
+
import { toast } from 'svelte-sonner';
|
|
11
|
+
|
|
12
|
+
type JobStatus = 'pending' | 'processing' | 'done' | 'failed' | 'skipped';
|
|
13
|
+
type StatusFilter = 'all' | 'failed' | 'pending' | 'processing';
|
|
14
|
+
|
|
15
|
+
interface JobCounts {
|
|
16
|
+
pending: number;
|
|
17
|
+
processing: number;
|
|
18
|
+
done: number;
|
|
19
|
+
failed: number;
|
|
20
|
+
skipped: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MediaJob {
|
|
24
|
+
id: string;
|
|
25
|
+
type: string;
|
|
26
|
+
mediaFileId: string;
|
|
27
|
+
payload: string;
|
|
28
|
+
status: JobStatus;
|
|
29
|
+
priority: number;
|
|
30
|
+
attempts: number;
|
|
31
|
+
maxAttempts: number;
|
|
32
|
+
lastError: string | null;
|
|
33
|
+
runAfter: string;
|
|
34
|
+
updatedAt: string;
|
|
35
|
+
completedAt: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MediaJobsResponse {
|
|
39
|
+
counts: JobCounts;
|
|
40
|
+
jobs: MediaJob[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const filters: { value: StatusFilter; label: string }[] = [
|
|
44
|
+
{ value: 'all', label: 'Wszystkie' },
|
|
45
|
+
{ value: 'failed', label: 'Błędy' },
|
|
46
|
+
{ value: 'pending', label: 'Oczekujące' },
|
|
47
|
+
{ value: 'processing', label: 'W trakcie' }
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const statusLabels: Record<JobStatus, string> = {
|
|
51
|
+
pending: 'Oczekuje',
|
|
52
|
+
processing: 'W trakcie',
|
|
53
|
+
done: 'Gotowe',
|
|
54
|
+
failed: 'Błąd',
|
|
55
|
+
skipped: 'Pominięte'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function statusColor(status: JobStatus): string {
|
|
59
|
+
switch (status) {
|
|
60
|
+
case 'done':
|
|
61
|
+
return 'var(--success, #3A8A5C)';
|
|
62
|
+
case 'failed':
|
|
63
|
+
return 'var(--error, #C44B4B)';
|
|
64
|
+
case 'processing':
|
|
65
|
+
return 'var(--primary)';
|
|
66
|
+
case 'pending':
|
|
67
|
+
return 'var(--warning, #C4893A)';
|
|
68
|
+
default:
|
|
69
|
+
return 'var(--muted-foreground)';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function styleLabel(payload: string): string {
|
|
74
|
+
try {
|
|
75
|
+
const p = JSON.parse(payload) as { name?: string; format?: string; width?: number };
|
|
76
|
+
if (p.name) return p.name;
|
|
77
|
+
if (p.format && p.width) return `${p.format} · ${p.width}w`;
|
|
78
|
+
if (p.format) return p.format;
|
|
79
|
+
} catch {
|
|
80
|
+
// fall through to raw payload
|
|
81
|
+
}
|
|
82
|
+
return payload;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncate(text: string, max = 60): string {
|
|
86
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function timeAgo(dateStr: string): string {
|
|
90
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
91
|
+
const mins = Math.floor(diff / 60000);
|
|
92
|
+
if (mins < 1) return 'przed chwilą';
|
|
93
|
+
if (mins < 60) return `${mins} min temu`;
|
|
94
|
+
const hours = Math.floor(mins / 60);
|
|
95
|
+
if (hours < 24) return `${hours}h temu`;
|
|
96
|
+
return `${Math.floor(hours / 24)}d temu`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let counts = $state<JobCounts>({ pending: 0, processing: 0, done: 0, failed: 0, skipped: 0 });
|
|
100
|
+
let jobs = $state<MediaJob[]>([]);
|
|
101
|
+
let statusFilter = $state<StatusFilter>('all');
|
|
102
|
+
let loading = $state(true);
|
|
103
|
+
let error = $state<string | null>(null);
|
|
104
|
+
let actioningId = $state<string | null>(null);
|
|
105
|
+
|
|
106
|
+
let counterItems = $derived([
|
|
107
|
+
{ label: 'Oczekujące', value: counts.pending, color: 'var(--warning, #C4893A)' },
|
|
108
|
+
{ label: 'W trakcie', value: counts.processing, color: 'var(--primary)' },
|
|
109
|
+
{ label: 'Gotowe', value: counts.done, color: 'var(--success, #3A8A5C)' },
|
|
110
|
+
{ label: 'Błędy', value: counts.failed, color: 'var(--error, #C44B4B)' },
|
|
111
|
+
{ label: 'Pominięte', value: counts.skipped, color: 'var(--muted-foreground)' }
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
async function loadJobs(filter: StatusFilter, showLoading = false) {
|
|
115
|
+
if (showLoading) loading = true;
|
|
116
|
+
try {
|
|
117
|
+
const params = new URLSearchParams({ limit: '50' });
|
|
118
|
+
if (filter !== 'all') params.set('status', filter);
|
|
119
|
+
const res = await fetch(`/admin/api/media-jobs?${params.toString()}`);
|
|
120
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
121
|
+
const data = (await res.json()) as MediaJobsResponse;
|
|
122
|
+
counts = data.counts;
|
|
123
|
+
jobs = data.jobs;
|
|
124
|
+
error = null;
|
|
125
|
+
} catch {
|
|
126
|
+
error = 'Nie udało się załadować kolejki mediów';
|
|
127
|
+
} finally {
|
|
128
|
+
loading = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function doAction(id: string, action: 'retry' | 'skip') {
|
|
133
|
+
actioningId = id;
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch('/admin/api/media-jobs', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ id, action })
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
141
|
+
toast.success(action === 'retry' ? 'Zadanie ponowione' : 'Zadanie pominięte');
|
|
142
|
+
await loadJobs(statusFilter);
|
|
143
|
+
} catch {
|
|
144
|
+
toast.error('Nie udało się wykonać akcji');
|
|
145
|
+
} finally {
|
|
146
|
+
actioningId = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Refetch on mount and whenever the status filter changes.
|
|
151
|
+
$effect(() => {
|
|
152
|
+
loadJobs(statusFilter, true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Poll every 5s; interval cleaned up on unmount.
|
|
156
|
+
onMount(() => {
|
|
157
|
+
const id = setInterval(() => loadJobs(statusFilter), 5000);
|
|
158
|
+
return () => clearInterval(id);
|
|
159
|
+
});
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<Card.Root>
|
|
163
|
+
<Card.Header class="pb-3">
|
|
164
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
165
|
+
<div class="flex items-center gap-2">
|
|
166
|
+
<ListDetails class="size-5" style="color: var(--primary);" />
|
|
167
|
+
<Card.Title class="text-base">Kolejka zadań</Card.Title>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="flex flex-wrap gap-1.5">
|
|
170
|
+
{#each filters as f (f.value)}
|
|
171
|
+
<Button
|
|
172
|
+
variant={statusFilter === f.value ? 'default' : 'outline'}
|
|
173
|
+
size="sm"
|
|
174
|
+
onclick={() => (statusFilter = f.value)}
|
|
175
|
+
>
|
|
176
|
+
{f.label}
|
|
177
|
+
</Button>
|
|
178
|
+
{/each}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div class="mt-3 flex flex-wrap gap-2">
|
|
183
|
+
{#each counterItems as c (c.label)}
|
|
184
|
+
<div
|
|
185
|
+
class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs"
|
|
186
|
+
style="background: color-mix(in srgb, {c.color} 12%, transparent);"
|
|
187
|
+
>
|
|
188
|
+
<span class="font-semibold" style="color: {c.color};">{c.value}</span>
|
|
189
|
+
<span style="color: var(--muted-foreground);">{c.label}</span>
|
|
190
|
+
</div>
|
|
191
|
+
{/each}
|
|
192
|
+
</div>
|
|
193
|
+
</Card.Header>
|
|
194
|
+
|
|
195
|
+
<Card.Content>
|
|
196
|
+
{#if loading && jobs.length === 0}
|
|
197
|
+
<div class="flex items-center justify-center py-12">
|
|
198
|
+
<Loader2 class="size-6 animate-spin" style="color: var(--muted-foreground);" />
|
|
199
|
+
</div>
|
|
200
|
+
{:else if error && jobs.length === 0}
|
|
201
|
+
<div
|
|
202
|
+
class="flex items-center gap-2 rounded-md px-3 py-3 text-sm"
|
|
203
|
+
style="background: color-mix(in srgb, var(--error, #C44B4B) 12%, transparent); color: var(--error, #C44B4B);"
|
|
204
|
+
>
|
|
205
|
+
<AlertTriangle class="size-4 shrink-0" />
|
|
206
|
+
{error}
|
|
207
|
+
</div>
|
|
208
|
+
{:else if jobs.length === 0}
|
|
209
|
+
<p class="py-8 text-center text-sm" style="color: var(--muted-foreground);">Brak zadań</p>
|
|
210
|
+
{:else}
|
|
211
|
+
{#if error}
|
|
212
|
+
<p class="mb-3 flex items-center gap-1.5 text-xs" style="color: var(--error, #C44B4B);">
|
|
213
|
+
<AlertTriangle class="size-3.5 shrink-0" />
|
|
214
|
+
{error} — pokazano ostatnie dane
|
|
215
|
+
</p>
|
|
216
|
+
{/if}
|
|
217
|
+
<div class="overflow-x-auto">
|
|
218
|
+
<table class="w-full text-sm">
|
|
219
|
+
<thead>
|
|
220
|
+
<tr class="border-b text-left" style="border-color: var(--border);">
|
|
221
|
+
<th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Media</th>
|
|
222
|
+
<th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Styl</th>
|
|
223
|
+
<th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Status</th>
|
|
224
|
+
<th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Prób</th>
|
|
225
|
+
<th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Błąd</th>
|
|
226
|
+
<th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Zaktualizowano</th>
|
|
227
|
+
<th class="px-3 py-2"></th>
|
|
228
|
+
</tr>
|
|
229
|
+
</thead>
|
|
230
|
+
<tbody>
|
|
231
|
+
{#each jobs as job (job.id)}
|
|
232
|
+
<tr class="border-b" style="border-color: var(--border);">
|
|
233
|
+
<td class="px-3 py-2">
|
|
234
|
+
<span class="font-mono text-xs" title={job.mediaFileId}
|
|
235
|
+
>{job.mediaFileId.slice(0, 8)}</span
|
|
236
|
+
>
|
|
237
|
+
</td>
|
|
238
|
+
<td class="px-3 py-2">
|
|
239
|
+
<span title={job.payload}>{styleLabel(job.payload)}</span>
|
|
240
|
+
</td>
|
|
241
|
+
<td class="px-3 py-2">
|
|
242
|
+
<span
|
|
243
|
+
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
|
244
|
+
style="background: color-mix(in srgb, {statusColor(
|
|
245
|
+
job.status
|
|
246
|
+
)} 15%, transparent); color: {statusColor(job.status)};"
|
|
247
|
+
>
|
|
248
|
+
{#if job.status === 'processing'}
|
|
249
|
+
<Loader2 class="size-3 animate-spin" />
|
|
250
|
+
{/if}
|
|
251
|
+
{statusLabels[job.status]}
|
|
252
|
+
</span>
|
|
253
|
+
</td>
|
|
254
|
+
<td class="px-3 py-2 whitespace-nowrap" style="color: var(--muted-foreground);">
|
|
255
|
+
{job.attempts}/{job.maxAttempts}
|
|
256
|
+
</td>
|
|
257
|
+
<td class="px-3 py-2">
|
|
258
|
+
{#if job.lastError}
|
|
259
|
+
<span
|
|
260
|
+
class="text-xs"
|
|
261
|
+
style="color: var(--error, #C44B4B);"
|
|
262
|
+
title={job.lastError}>{truncate(job.lastError)}</span
|
|
263
|
+
>
|
|
264
|
+
{:else}
|
|
265
|
+
<span style="color: var(--muted-foreground);">—</span>
|
|
266
|
+
{/if}
|
|
267
|
+
</td>
|
|
268
|
+
<td
|
|
269
|
+
class="px-3 py-2 whitespace-nowrap"
|
|
270
|
+
style="color: var(--muted-foreground);"
|
|
271
|
+
title={new Date(job.updatedAt).toLocaleString('pl-PL')}
|
|
272
|
+
>
|
|
273
|
+
{timeAgo(job.updatedAt)}
|
|
274
|
+
</td>
|
|
275
|
+
<td class="px-3 py-2">
|
|
276
|
+
{#if job.status === 'failed'}
|
|
277
|
+
<div class="flex justify-end gap-1.5">
|
|
278
|
+
<Button
|
|
279
|
+
variant="outline"
|
|
280
|
+
size="sm"
|
|
281
|
+
onclick={() => doAction(job.id, 'retry')}
|
|
282
|
+
disabled={actioningId === job.id}
|
|
283
|
+
>
|
|
284
|
+
{#if actioningId === job.id}
|
|
285
|
+
<Loader2 class="size-3.5 animate-spin" />
|
|
286
|
+
{:else}
|
|
287
|
+
<Refresh class="size-3.5" />
|
|
288
|
+
{/if}
|
|
289
|
+
Ponów
|
|
290
|
+
</Button>
|
|
291
|
+
<Button
|
|
292
|
+
variant="ghost"
|
|
293
|
+
size="sm"
|
|
294
|
+
onclick={() => doAction(job.id, 'skip')}
|
|
295
|
+
disabled={actioningId === job.id}
|
|
296
|
+
>
|
|
297
|
+
<PlayerSkipForward class="size-3.5" />
|
|
298
|
+
Pomiń
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
{/if}
|
|
302
|
+
</td>
|
|
303
|
+
</tr>
|
|
304
|
+
{/each}
|
|
305
|
+
</tbody>
|
|
306
|
+
</table>
|
|
307
|
+
</div>
|
|
308
|
+
{/if}
|
|
309
|
+
</Card.Content>
|
|
310
|
+
</Card.Root>
|
package/dist/core/cms.js
CHANGED
|
@@ -178,6 +178,13 @@ export function initCMS(config) {
|
|
|
178
178
|
import('./server/media/operations/backgroundMaintenance.js')
|
|
179
179
|
.then((m) => m.startBackgroundMaintenance())
|
|
180
180
|
.catch((e) => console.warn('[cms] Failed to start background maintenance:', e));
|
|
181
|
+
// Start in-process media optimization worker (generuje warianty poza ścieżką renderu).
|
|
182
|
+
// Wyłączalny przez media.optimization.worker = false.
|
|
183
|
+
if (cms.mediaConfig.optimization?.worker !== false) {
|
|
184
|
+
import('./server/media/jobs/startWorker.js')
|
|
185
|
+
.then((m) => m.startMediaOptimizationWorker())
|
|
186
|
+
.catch((e) => console.warn('[cms] Failed to start media worker:', e));
|
|
187
|
+
}
|
|
181
188
|
// Apply shop variantAttribute GIN indexes (idempotent CREATE INDEX IF NOT EXISTS).
|
|
182
189
|
// Pass shop + drizzle explicitly so the dynamic import doesn't depend on a
|
|
183
190
|
// shared CMS singleton (vitest can give the dynamic module a fresh instance).
|
|
@@ -2,6 +2,8 @@ import { getImageStylesBatch } from '../../media/styles/operations/getImageStyle
|
|
|
2
2
|
import { imageStyleKey } from '../../../../types/adapters/db.js';
|
|
3
3
|
import { getCMS } from '../../../cms.js';
|
|
4
4
|
import { generateBlurDataUrl } from '../../media/utils/generateBlurDataUrl.js';
|
|
5
|
+
import { buildStyleSpecs } from '../../media/styles/buildStyleSpecs.js';
|
|
6
|
+
import { resolveImagesConfig } from '../../media/styles/imagesConfig.js';
|
|
5
7
|
export const defaultStyles = [
|
|
6
8
|
{ name: 'default', srcset: [640, 1024, 1920, 2560] }
|
|
7
9
|
];
|
|
@@ -72,10 +74,45 @@ function variantStyleFor(style, w) {
|
|
|
72
74
|
}
|
|
73
75
|
export async function getImageStyles(field, val) {
|
|
74
76
|
const hasCustomStyles = field.styles && field.styles.length > 0;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Custom field.styles: zachowaj dotychczasową semantykę (expand formatów + baza pełno-res + srcset).
|
|
78
|
+
if (hasCustomStyles) {
|
|
79
|
+
return resolveLegacyStyles(expandStyleFormats(field.styles, getOriginalFormat(val)), val);
|
|
80
|
+
}
|
|
81
|
+
// Domyślnie: nowa lekka matryca z configu (avif+webp × szerokości, BEZ bazy pełno-res).
|
|
82
|
+
// Grupujemy warianty po formacie → jeden <source> per format ze srcset (bez bramki bazy).
|
|
83
|
+
const specs = isProcessableImage(val)
|
|
84
|
+
? buildStyleSpecs(val, undefined, resolveImagesConfig(getCMS().mediaConfig.images))
|
|
85
|
+
: [];
|
|
86
|
+
const requests = specs.map((style) => ({ mediaFileId: val.id, style }));
|
|
87
|
+
const [styleMap, blurDataUrl] = await Promise.all([
|
|
88
|
+
getImageStylesBatch(requests),
|
|
89
|
+
ensureBlurDataUrl(val)
|
|
90
|
+
]);
|
|
91
|
+
const byFormat = new Map();
|
|
92
|
+
for (const style of specs) {
|
|
93
|
+
const data = styleMap.get(imageStyleKey(val.id, style));
|
|
94
|
+
if (!data || typeof style.width !== 'number')
|
|
95
|
+
continue;
|
|
96
|
+
const fmt = style.format ?? 'orig';
|
|
97
|
+
const group = byFormat.get(fmt) ?? { media: data.media, mimeType: data.mimeType, variants: [] };
|
|
98
|
+
group.variants.push({ url: data.url, w: style.width });
|
|
99
|
+
byFormat.set(fmt, group);
|
|
100
|
+
}
|
|
101
|
+
const styles = {};
|
|
102
|
+
for (const [fmt, group] of byFormat) {
|
|
103
|
+
group.variants.sort((a, b) => a.w - b.w);
|
|
104
|
+
const largest = group.variants[group.variants.length - 1];
|
|
105
|
+
styles[`default_${fmt}`] = {
|
|
106
|
+
url: largest.url,
|
|
107
|
+
media: group.media,
|
|
108
|
+
mimeType: group.mimeType,
|
|
109
|
+
srcset: group.variants.map((v) => `${v.url} ${v.w}w`).join(', ')
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { styles, blurDataUrl };
|
|
113
|
+
}
|
|
114
|
+
/** Stara ścieżka dla custom field.styles: styl bazowy pełno-res (bramka istnienia) + srcset wariantów. */
|
|
115
|
+
async function resolveLegacyStyles(stylesArr, val) {
|
|
79
116
|
const requests = [];
|
|
80
117
|
for (const style of stylesArr) {
|
|
81
118
|
requests.push({ mediaFileId: val.id, style });
|
|
@@ -2,3 +2,6 @@ export { getCMS } from '../cms.js';
|
|
|
2
2
|
export { resolveMediaWithStyles, type ResolvedMedia } from './fields/utils/resolveMedia.js';
|
|
3
3
|
export { createEntityAPI } from '../../entity/index.js';
|
|
4
4
|
export { getAuth } from '../../server/auth.js';
|
|
5
|
+
export { enqueueStyleSpecs } from './media/jobs/enqueue.js';
|
|
6
|
+
export { startMediaWorker, stopMediaWorker } from './media/jobs/worker.js';
|
|
7
|
+
export type { MediaJob, MediaJobStatus } from '../../types/adapters/db.js';
|
|
@@ -2,3 +2,6 @@ export { getCMS } from '../cms.js';
|
|
|
2
2
|
export { resolveMediaWithStyles } from './fields/utils/resolveMedia.js';
|
|
3
3
|
export { createEntityAPI } from '../../entity/index.js';
|
|
4
4
|
export { getAuth } from '../../server/auth.js';
|
|
5
|
+
// Optymalizacja obrazów w tle (media_job)
|
|
6
|
+
export { enqueueStyleSpecs } from './media/jobs/enqueue.js';
|
|
7
|
+
export { startMediaWorker, stopMediaWorker } from './media/jobs/worker.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ImageFieldStyle } from '../../../../types/fields.js';
|
|
2
|
+
import type { MediaFile } from '../../../../types/media.js';
|
|
3
|
+
/** Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey. */
|
|
4
|
+
export declare function enqueueStyleSpecs(mediaFile: Pick<MediaFile, 'id' | 'width' | 'height'>, fieldStyles: ImageFieldStyle[] | undefined, opts?: {
|
|
5
|
+
priority?: number;
|
|
6
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getCMS } from '../../../cms.js';
|
|
2
|
+
import { resolveImagesConfig } from '../styles/imagesConfig.js';
|
|
3
|
+
import { buildStyleSpecs, styleDedupeKey } from '../styles/buildStyleSpecs.js';
|
|
4
|
+
/** Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey. */
|
|
5
|
+
export async function enqueueStyleSpecs(mediaFile, fieldStyles, opts = {}) {
|
|
6
|
+
const cms = getCMS();
|
|
7
|
+
const cfg = resolveImagesConfig(cms.mediaConfig.images);
|
|
8
|
+
const specs = buildStyleSpecs(mediaFile, fieldStyles, cfg);
|
|
9
|
+
for (const style of specs) {
|
|
10
|
+
await cms.databaseAdapter
|
|
11
|
+
.enqueueMediaJob({
|
|
12
|
+
type: 'image_style',
|
|
13
|
+
mediaFileId: mediaFile.id,
|
|
14
|
+
payload: JSON.stringify(style),
|
|
15
|
+
dedupeKey: styleDedupeKey(mediaFile.id, style),
|
|
16
|
+
priority: opts.priority ?? 0
|
|
17
|
+
})
|
|
18
|
+
.catch((e) => console.warn('[media] enqueue failed:', e));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MediaJob } from '../../../../types/adapters/db.js';
|
|
2
|
+
import type { MediaFile, ImageStyle } from '../../../../types/media.js';
|
|
3
|
+
import type { ImageFieldStyle } from '../../../../types/fields.js';
|
|
4
|
+
export type JobResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
retry: boolean;
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
type CreateImageStyleFn = (mediaFileId: string, style: ImageFieldStyle) => Promise<ImageStyle>;
|
|
12
|
+
export interface ProcessImageStyleDeps {
|
|
13
|
+
createImageStyle: CreateImageStyleFn;
|
|
14
|
+
getMediaFile: (id: string) => Promise<MediaFile | null>;
|
|
15
|
+
limitPixels: number;
|
|
16
|
+
}
|
|
17
|
+
/** Przetwarza pojedynczy job typu image_style. Klasyfikuje błędy: poison/not-found → retry:false. */
|
|
18
|
+
export declare function processImageStyleJob(job: MediaJob, deps: ProcessImageStyleDeps): Promise<JobResult>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { assertWithinPixelLimit } from '../styles/sharp/sharpConfig.js';
|
|
2
|
+
/** Przetwarza pojedynczy job typu image_style. Klasyfikuje błędy: poison/not-found → retry:false. */
|
|
3
|
+
export async function processImageStyleJob(job, deps) {
|
|
4
|
+
const style = JSON.parse(job.payload);
|
|
5
|
+
const media = await deps.getMediaFile(job.mediaFileId);
|
|
6
|
+
if (!media)
|
|
7
|
+
return { ok: false, retry: false, error: 'media file not found' };
|
|
8
|
+
try {
|
|
9
|
+
if (media.width && media.height) {
|
|
10
|
+
assertWithinPixelLimit(media.width, media.height, deps.limitPixels);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
return { ok: false, retry: false, error: e.message };
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
await deps.createImageStyle(job.mediaFileId, style);
|
|
18
|
+
return { ok: true };
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
return { ok: false, retry: true, error: e.message };
|
|
22
|
+
}
|
|
23
|
+
}
|