feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8028
-594
No files matched your search
+7
-1
@@ -18,7 +18,8 @@ prod.log
|
||||
db_backup_*.sql
|
||||
|
||||
# Local project documentation
|
||||
/docs/
|
||||
/docs/*
|
||||
!/docs/cms-upgrade-2026-09.md
|
||||
|
||||
# Local gitea binary symlink
|
||||
gitea
|
||||
@@ -39,3 +40,8 @@ coverage/
|
||||
|
||||
# Shared deployment lock (never application source)
|
||||
.deploy.lock
|
||||
|
||||
# Browser verification artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
@@ -54,6 +54,7 @@ RUN apk add --no-cache tini curl \
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migrations
|
||||
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||
USER nextjs
|
||||
EXPOSE 3002
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# CMS upgrade — September 2026
|
||||
|
||||
## Operator changes
|
||||
|
||||
| Area | Behavior and location |
|
||||
| --- | --- |
|
||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
||||
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
||||
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
||||
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
||||
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
||||
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
||||
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
||||
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
||||
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
||||
|
||||
## Deployment requirements
|
||||
|
||||
- Apply migration `0026_article_editor_recovery.sql` through `pnpm db:migrate` before enabling the new news editor. It adds private drafts and revision tables without modifying existing articles.
|
||||
- Keep `storage` persistent and writable. Error assignments and import cancellation markers use the existing shared storage.
|
||||
- Run the existing `pnpm jobs:worker` process with the installation configuration. It now publishes a heartbeat to Redis every minute. A web process alone does not establish that scheduled-news jobs are running.
|
||||
- The deploy runner installs Chromium before cutover. Browser checks visit only public login/news/staff pages; they do not create production content or authenticate staff. The host must satisfy Chromium system-library requirements.
|
||||
- Use the existing Gitea registry secrets. The CMS does not read or display those credentials.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Operations is a bounded operational summary: errors use at most 1,000 retained events and imports use the latest 30 owned jobs. Empty checked records do not prove that all historical work is resolved.
|
||||
- Import history bounds payloads and concurrent file reads. Directory metadata scanning and worker enumeration still scale with stored history.
|
||||
- Redis lease checks and file saves are separate operations. They reduce stale writes but do not provide atomic fencing across Redis, SQL and filesystem operations. An import interrupted after SQL may require local-data inspection.
|
||||
- Revision history displays the latest 20 saved versions; revisions are retained in the database. New-article recovery has one private slot per staff account.
|
||||
- The database connection probe is a measurement, not a performance benchmark. No throughput or latency improvement is claimed without production measurements.
|
||||
- A rollback restores an application image; it does not reverse database migrations. The new tables are additive.
|
||||
|
||||
## Verification
|
||||
|
||||
Local verification on 2026-09-09:
|
||||
|
||||
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
||||
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
||||
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
||||
- Translation audit: no invalid ICU messages, variable mismatches or missing static references. Existing locale gaps and 1,560 hardcoded-text candidates still need editorial work; they are not silently marked translated.
|
||||
- Headless Edge verification of actual shared components with compiled CSS and the CMS theme at widths 1,280 and 390 pixels: the switch changes state and thumb position, the dialog stays within the 720px viewport, the final button is reachable, and the background page does not scroll. This isolated fixture does not establish full authenticated-page parity.
|
||||
- Deployment rollback, verified-digest publication, ownership/cancellation and concurrent news edit behavior have focused regression tests.
|
||||
|
||||
Docker runtime, database migration execution and authenticated browser flows still require an integration environment. Check the Gitea pipeline and live release identifier after publication. A successful local build is not production verification.
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS website_article_drafts (
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
article_key VARCHAR(32) NOT NULL,
|
||||
version INT UNSIGNED NOT NULL,
|
||||
payload LONGTEXT NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, article_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS website_article_revisions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
article_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
payload LONGTEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX article_history (article_id, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Read-only production checks. Content mutation tests belong to a seeded staging DB.
|
||||
test("login remains usable on a narrow viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 720 });
|
||||
const response = await page.goto("/login");
|
||||
expect(response?.ok()).toBe(true);
|
||||
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(page.locator("body")).not.toContainText("Application error");
|
||||
});
|
||||
test("public news is rendered without a server error", async ({ page }) => {
|
||||
const response = await page.goto("/news");
|
||||
expect(response?.ok()).toBe(true);
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText("Application error");
|
||||
});
|
||||
test("staff page is available", async ({ page }) => {
|
||||
const response = await page.goto("/staff");
|
||||
expect(response?.ok()).toBe(true);
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
});
|
||||
+3
-1
@@ -35,7 +35,8 @@
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "next experimental-analyze",
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs"
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
@@ -80,6 +81,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.29.8",
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
timeout: 30000,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
Generated
+47
-8
@@ -84,13 +84,13 @@ importers:
|
||||
version: 3.24.3(@types/[email protected])
|
||||
next:
|
||||
specifier: 16.3.4
|
||||
version: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
version: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.32
|
||||
version: 5.0.0-beta.32([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
version: 5.0.0-beta.32([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
next-intl:
|
||||
specifier: 4.14.2
|
||||
version: 4.14.2(@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
version: 4.14.2(@swc/[email protected])([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
otplib:
|
||||
specifier: 13.5.0
|
||||
version: 13.5.0
|
||||
@@ -134,6 +134,9 @@ importers:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.12
|
||||
version: 2.5.12
|
||||
'@playwright/test':
|
||||
specifier: 1.62.1
|
||||
version: 1.62.1
|
||||
'@tailwindcss/forms':
|
||||
specifier: 0.5.11
|
||||
version: 0.5.11([email protected])
|
||||
@@ -1177,6 +1180,11 @@ packages:
|
||||
'@pinojs/[email protected]':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||
|
||||
@@ -2114,6 +2122,11 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -2548,6 +2561,16 @@ packages:
|
||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==}
|
||||
|
||||
@@ -3651,6 +3674,10 @@ snapshots:
|
||||
|
||||
'@pinojs/[email protected]': {}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
dependencies:
|
||||
playwright: 1.62.1
|
||||
|
||||
'@radix-ui/[email protected]': {}
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||
@@ -4325,6 +4352,9 @@ snapshots:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8([email protected])
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -4633,15 +4663,15 @@ snapshots:
|
||||
dependencies:
|
||||
content-type: 2.1.0
|
||||
|
||||
[email protected]([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
[email protected]([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@auth/core': 0.41.3
|
||||
next: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.2.8
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
[email protected](@swc/[email protected])([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@eloqnt/config': 0.1.0
|
||||
'@eloqnt/format-json': 0.1.0
|
||||
@@ -4651,14 +4681,14 @@ snapshots:
|
||||
'@swc/core': 1.16.2(@swc/[email protected])
|
||||
icu-minify: 4.14.2
|
||||
negotiator: 1.1.0
|
||||
next: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next-intl-swc-plugin-extractor: 4.14.2
|
||||
react: 19.2.8
|
||||
use-intl: 4.14.2([email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
|
||||
[email protected](@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
[email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@next/env': 16.3.4
|
||||
'@swc/helpers': 0.5.23
|
||||
@@ -4677,6 +4707,7 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 16.3.4
|
||||
'@next/swc-win32-arm64-msvc': 16.3.4
|
||||
'@next/swc-win32-x64-msvc': 16.3.4
|
||||
'@playwright/test': 1.62.1
|
||||
sharp: 0.35.4(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -4750,6 +4781,14 @@ snapshots:
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 4.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
playwright-core: 1.62.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -93,6 +93,7 @@ for managed_name in epicnext-cms epicnext-cms-app; do
|
||||
done
|
||||
cp "$deploy_dir/.env" .env
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install chromium
|
||||
|
||||
echo "Building $image"
|
||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
||||
@@ -159,8 +160,11 @@ candidate_attempted=1
|
||||
)
|
||||
healthy
|
||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
||||
# Publish the latest alias only after health and release checks pass.
|
||||
docker tag "$image" epicnext-cms:latest
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||
# Publish the latest alias only after HTTP and browser checks pass.
|
||||
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
||||
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
||||
docker tag "$verified_image" epicnext-cms:latest
|
||||
cutover_started=0
|
||||
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
||||
if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi
|
||||
@@ -168,7 +172,7 @@ if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" ||
|
||||
echo "Deployment verified: $sha"
|
||||
# Retain the current and previous releases; do not remove arbitrary named tags.
|
||||
while IFS= read -r tag; do
|
||||
if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then
|
||||
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
|
||||
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
|
||||
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
|
||||
fi
|
||||
|
||||
@@ -260,6 +260,13 @@ async function publishScheduledArticles(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function reportWorkerHeartbeat(): Promise<void> {
|
||||
if (!redis) return;
|
||||
await redis
|
||||
.set("cms:jobs-worker:heartbeat", new Date().toISOString(), "EX", 600)
|
||||
.catch((error) => captureWorkerError(error, "WorkerHeartbeat"));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
new Cron("* * * * *", () => {
|
||||
runCatalogExport().catch((e) =>
|
||||
@@ -307,6 +314,10 @@ async function main() {
|
||||
module: "jobs",
|
||||
});
|
||||
|
||||
new Cron("* * * * *", () => {
|
||||
void reportWorkerHeartbeat();
|
||||
});
|
||||
await reportWorkerHeartbeat();
|
||||
await Promise.all([
|
||||
backupEmulatorJar(),
|
||||
cleanupOldLogs(),
|
||||
|
||||
@@ -21,7 +21,17 @@ trap 'rm -rf -- "$DOCKER_CONFIG" "$context"' EXIT
|
||||
git archive HEAD | tar -x -C "$context"
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
|
||||
unset REGISTRY_TOKEN
|
||||
# On the shared runner, publish the exact image already verified by deployment.
|
||||
local_image="epicnext-cms:$sha"
|
||||
local_revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$local_image" 2>/dev/null || true)"
|
||||
local_id="$(docker image inspect --format '{{.Id}}' "$local_image" 2>/dev/null || true)"
|
||||
verified_id="$(docker image inspect --format '{{.Id}}' "epicnext-cms:verified-$sha" 2>/dev/null || true)"
|
||||
if [[ "$local_revision" = "$sha" && -n "$local_id" && "$local_id" = "$verified_id" ]]; then
|
||||
docker tag "$verified_id" "$image"
|
||||
echo "Reusing verified release image $local_image"
|
||||
else
|
||||
docker build --network=host --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" "$context"
|
||||
fi
|
||||
docker build --network=host --target migrations -t "$image-migrations" "$context"
|
||||
node scripts/verify-portable-image.mjs "$image" "$sha"
|
||||
# Publish only after the same application image passed both runtime configurations.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { verifyRuntimeMetadata } from "./verify-runtime-metadata.mjs";
|
||||
|
||||
const [image, release] = process.argv.slice(2);
|
||||
if (!image || !/^[0-9a-f]{40}$/.test(release ?? ""))
|
||||
@@ -95,30 +96,7 @@ try {
|
||||
await delay(1000);
|
||||
}
|
||||
if (!ready) throw new Error(`${hotel}: expected HTTP release not served`);
|
||||
const page = await fetch(`${base}/login`, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const html = await page.text();
|
||||
if (
|
||||
!page.ok ||
|
||||
!html.includes(`Fixture ${hotel}`) ||
|
||||
!html.includes(base)
|
||||
)
|
||||
throw new Error(
|
||||
`${hotel}: hotel name/domain were not resolved at runtime`,
|
||||
);
|
||||
const manifest = await fetch(`${base}/manifest.webmanifest`, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if ((await manifest.json()).name !== `Fixture ${hotel}`)
|
||||
throw new Error(`${hotel}: manifest contains build-time settings`);
|
||||
for (const path of ["/robots.txt", "/sitemap.xml"]) {
|
||||
const response = await fetch(base + path, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok || !(await response.text()).includes(base))
|
||||
throw new Error(`${hotel}: ${path} contains build-time domain`);
|
||||
}
|
||||
await verifyRuntimeMetadata(base, hotel);
|
||||
const avatar = await fetch(
|
||||
`${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`,
|
||||
{ signal: AbortSignal.timeout(15000) },
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Metadata handlers can resolve runtime settings without a working application DB.
|
||||
export async function verifyRuntimeMetadata(base, hotel) {
|
||||
const manifest = await fetch(`${base}/manifest.webmanifest`, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!manifest.ok)
|
||||
throw new Error(`${hotel}: manifest HTTP ${manifest.status}`);
|
||||
if ((await manifest.json()).name !== `Fixture ${hotel}`)
|
||||
throw new Error(`${hotel}: manifest contains build-time settings`);
|
||||
for (const path of ["/robots.txt", "/sitemap.xml"]) {
|
||||
const response = await fetch(base + path, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`${hotel}: ${path} HTTP ${response.status}`);
|
||||
if (!(await response.text()).includes(base))
|
||||
throw new Error(`${hotel}: ${path} contains build-time domain`);
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,16 @@ vi.mock("next/navigation", () => ({
|
||||
throw Error(`redirect ${url}`);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const database = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||
where: () => ({
|
||||
limit: () => {
|
||||
const result = Promise.resolve(state.rows.shift() ?? []);
|
||||
return Object.assign(result, { for: () => result });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: state.insert }),
|
||||
@@ -42,9 +46,13 @@ vi.mock("@/lib/db", async () => ({
|
||||
return { where: async () => [{ affectedRows: 1 }] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
|
||||
fn(database),
|
||||
};
|
||||
return { ...(await import("@/db/schema")), db: database };
|
||||
});
|
||||
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import { createArticle, updateArticle } from "./admin-articles";
|
||||
|
||||
function form(extra: Record<string, string> = {}) {
|
||||
@@ -89,11 +97,25 @@ describe("news saves", () => {
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
||||
const existing = {
|
||||
title: "Old",
|
||||
slug: "test-news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "",
|
||||
status: "draft",
|
||||
publishAt: null,
|
||||
publishedAt: null,
|
||||
};
|
||||
state.rows = [[existing]];
|
||||
expect(
|
||||
(
|
||||
await updateArticle(
|
||||
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||
form({
|
||||
id: "1",
|
||||
baseToken: articleEditToken(existing),
|
||||
publishAt: "2099-01-01T00:00:00Z",
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
@@ -106,6 +128,27 @@ describe("news saves", () => {
|
||||
);
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects an outdated editor without inserting a revision or overwriting the article", async () => {
|
||||
state.rows = [
|
||||
[
|
||||
{
|
||||
title: "Changed by another editor",
|
||||
slug: "test-news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "",
|
||||
status: "published",
|
||||
publishAt: null,
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(
|
||||
await updateArticle(form({ id: "1", baseToken: "outdated" })),
|
||||
).toEqual({ ok: false, error: "editConflict" });
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
expect(state.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects invalid scheduled dates before writing", async () => {
|
||||
expect(
|
||||
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||
|
||||
@@ -4,7 +4,9 @@ import { and, eq, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import {
|
||||
ArticleInputError,
|
||||
type ArticleSaveResult,
|
||||
@@ -23,11 +25,15 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
||||
async function uniqueSlug(
|
||||
title: string,
|
||||
excludeId?: bigint,
|
||||
connection: Pick<typeof db, "select"> = db,
|
||||
): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
for (let n = 2; ; n++) {
|
||||
const [existing] = await db
|
||||
const [existing] = await connection
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
@@ -105,28 +111,42 @@ export async function updateArticle(
|
||||
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let becamePublished = false;
|
||||
let slug: string;
|
||||
let slug = "";
|
||||
try {
|
||||
input = readArticleInput(formData);
|
||||
const [existing] = await db
|
||||
.select({
|
||||
slug: WebsiteArticles.slug,
|
||||
status: WebsiteArticles.status,
|
||||
publishedAt: WebsiteArticles.publishedAt,
|
||||
})
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1);
|
||||
if (!existing) return { ok: false, error: t("articleNotFound") };
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||
throw new ArticleInputError("editConflict");
|
||||
await tx.insert(ArticleRevisions).values({
|
||||
articleId: id,
|
||||
userId: staff.id,
|
||||
payload: JSON.stringify({
|
||||
title: existing.title,
|
||||
slug: existing.slug,
|
||||
image: existing.image,
|
||||
shortStory: existing.shortStory,
|
||||
fullStory: existing.fullStory,
|
||||
status: existing.status,
|
||||
publishAt: existing.publishAt?.toISOString() ?? "",
|
||||
baseToken: "",
|
||||
}),
|
||||
});
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id);
|
||||
: await uniqueSlug(requestedSlug, id, tx);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await db
|
||||
await tx
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
...fields,
|
||||
@@ -138,6 +158,7 @@ export async function updateArticle(
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
}
|
||||
@@ -170,6 +191,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await tx
|
||||
.delete(WebsiteArticleComments)
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx
|
||||
.delete(ArticleRevisions)
|
||||
.where(eq(ArticleRevisions.articleId, id));
|
||||
await tx
|
||||
.delete(ArticleDrafts)
|
||||
.where(eq(ArticleDrafts.articleKey, String(id)));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
version: 0,
|
||||
update: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
permission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const tx = {
|
||||
insert: (table: unknown) => ({
|
||||
values: (value: unknown) => {
|
||||
state.insert(table, value);
|
||||
return { onDuplicateKeyUpdate: async () => {} };
|
||||
},
|
||||
}),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ for: async () => [{ version: state.version }] }),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (value: unknown) => {
|
||||
state.update(table, value);
|
||||
return { where: async () => {} };
|
||||
},
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...(await import("@/db/schema")),
|
||||
db: { transaction: async (fn: (value: typeof tx) => unknown) => fn(tx) },
|
||||
};
|
||||
});
|
||||
|
||||
import { ArticleDrafts } from "@/db/article-editor";
|
||||
import { autosaveArticle } from "./article-recovery";
|
||||
|
||||
function draft() {
|
||||
const form = new FormData();
|
||||
form.set("title", "Incomplete title");
|
||||
form.set("status", "published");
|
||||
form.set("fullStory", "Work in progress");
|
||||
return form;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.version = 0;
|
||||
state.permission.mockResolvedValue({ id: 17 });
|
||||
});
|
||||
it("stores incomplete editor content only in a private draft table even when publication is selected", async () => {
|
||||
expect(await autosaveArticle("new", 0, draft())).toEqual({
|
||||
ok: true,
|
||||
version: 1,
|
||||
});
|
||||
expect(state.permission).toHaveBeenCalledWith("news.edit");
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
ArticleDrafts,
|
||||
expect.objectContaining({ userId: 17, articleKey: "new" }),
|
||||
);
|
||||
expect(state.update).toHaveBeenCalledOnce();
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
ArticleDrafts,
|
||||
expect.objectContaining({ version: 1 }),
|
||||
);
|
||||
});
|
||||
it("does not replace a more recent draft from another tab", async () => {
|
||||
state.version = 4;
|
||||
expect(await autosaveArticle("new", 3, draft())).toEqual({ ok: false });
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not access storage when the editor permission is denied", async () => {
|
||||
state.permission.mockRejectedValue(new Error("denied"));
|
||||
await expect(autosaveArticle("new", 0, draft())).rejects.toThrow("denied");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects excessive article content before writing", async () => {
|
||||
const form = draft();
|
||||
form.set("fullStory", "x".repeat(500_001));
|
||||
await expect(autosaveArticle("new", 0, form)).rejects.toThrow("draftInvalid");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
type ArticleDraft,
|
||||
articleKey,
|
||||
readArticleDraft,
|
||||
} from "@/lib/article-draft";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function loadArticleRecovery(key: string) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(ArticleDrafts)
|
||||
.where(
|
||||
and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const revisions =
|
||||
key === "new"
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(ArticleRevisions)
|
||||
.where(eq(ArticleRevisions.articleId, BigInt(key)))
|
||||
.orderBy(desc(ArticleRevisions.id))
|
||||
.limit(20);
|
||||
return {
|
||||
version: draft?.version ?? 0,
|
||||
draft:
|
||||
draft && draft.payload !== "{}"
|
||||
? {
|
||||
version: draft.version,
|
||||
payload: JSON.parse(draft.payload) as ArticleDraft,
|
||||
}
|
||||
: null,
|
||||
revisions: revisions.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
payload: JSON.parse(r.payload) as ArticleDraft,
|
||||
})),
|
||||
};
|
||||
}
|
||||
export async function autosaveArticle(
|
||||
key: string,
|
||||
expectedVersion: number,
|
||||
form: FormData,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
const payload = JSON.stringify(readArticleDraft(form));
|
||||
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0)
|
||||
throw new Error("draftInvalid");
|
||||
return db.transaction(async (tx) => {
|
||||
if (key !== "new") {
|
||||
const [article] = await tx
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, BigInt(key)))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!article) return { ok: false as const };
|
||||
}
|
||||
await tx
|
||||
.insert(ArticleDrafts)
|
||||
.values({ userId: staff.id, articleKey: key, version: 0, payload: "{}" })
|
||||
.onDuplicateKeyUpdate({ set: { articleKey: key } });
|
||||
const where = and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
);
|
||||
const [draft] = await tx
|
||||
.select()
|
||||
.from(ArticleDrafts)
|
||||
.where(where)
|
||||
.for("update");
|
||||
if (!draft || draft.version !== expectedVersion)
|
||||
return { ok: false as const };
|
||||
await tx
|
||||
.update(ArticleDrafts)
|
||||
.set({ payload, version: expectedVersion + 1, updatedAt: new Date() })
|
||||
.where(where);
|
||||
return { ok: true as const, version: expectedVersion + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearArticleRecovery(
|
||||
key: string,
|
||||
expectedVersion: number,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
await db
|
||||
.update(ArticleDrafts)
|
||||
.set({ payload: "{}", version: expectedVersion + 1, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
eq(ArticleDrafts.version, expectedVersion),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -247,3 +247,81 @@
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.shortcutHint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.shortcut {
|
||||
min-height: 64px;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.shortcut:focus-visible,
|
||||
.friend:focus-visible,
|
||||
.room:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.eventDetails {
|
||||
min-width: 0;
|
||||
flex: 1 1 220px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.eventDetails h2 {
|
||||
margin: 8px 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.eventDetails p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.eventPhase {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
color: var(--color-text-readable);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 14%,
|
||||
var(--color-surface)
|
||||
);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.hero {
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 100px;
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
}
|
||||
.identity h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.wallet {
|
||||
gap: 8px;
|
||||
}
|
||||
.currency {
|
||||
padding: 10px;
|
||||
}
|
||||
.event > a {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
import styles from "./dashboard.module.css";
|
||||
@@ -75,10 +75,11 @@ export default async function MePage({
|
||||
referralsRows,
|
||||
friends,
|
||||
currencyRows,
|
||||
dashboardEvent,
|
||||
} = data;
|
||||
const userSettings = userSettingsRows[0];
|
||||
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
||||
const unreadCount = Number(unreadCountRows[0]?.value ?? 0);
|
||||
const unreadCount = Number(unreadCountRows?.[0]?.value ?? 0);
|
||||
const needed = Math.max(1, Number.parseInt(neededRaw ?? "5", 10) || 5);
|
||||
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
||||
const rewardCurrency = (
|
||||
@@ -173,12 +174,20 @@ export default async function MePage({
|
||||
{ href: "/shop", label: t("shop"), count: 0 },
|
||||
].map((link) => (
|
||||
<Link key={link.href} href={link.href} className={styles.shortcut}>
|
||||
<span>
|
||||
{link.label}
|
||||
{link.href === "/messages" && (
|
||||
<span className={styles.shortcutHint}>
|
||||
{unreadCountRows === null
|
||||
? t("messagesUnavailable")
|
||||
: unreadCount > 0
|
||||
? t("unread", { count: unreadCount })
|
||||
: t("messagesCaughtUp")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{link.count > 0 && (
|
||||
<span
|
||||
className={styles.count}
|
||||
title={t("unread", { count: link.count })}
|
||||
>
|
||||
<span className={styles.count} aria-hidden="true">
|
||||
{link.count > 99 ? "99+" : link.count}
|
||||
</span>
|
||||
)}
|
||||
@@ -199,6 +208,48 @@ export default async function MePage({
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</section>
|
||||
<SurfaceCard
|
||||
title={t("eventTitle")}
|
||||
actionHref="/events"
|
||||
actionLabel={t("allEvents")}
|
||||
bodyClassName="p-5"
|
||||
>
|
||||
{dashboardEvent.unavailable ? (
|
||||
<p className="muted" role="status">
|
||||
{t("eventsUnavailable")}
|
||||
</p>
|
||||
) : dashboardEvent.event ? (
|
||||
<div className={styles.event}>
|
||||
<div className={styles.eventDetails}>
|
||||
<span className={styles.eventPhase}>
|
||||
{t(
|
||||
dashboardEvent.event.phase === "ongoing"
|
||||
? "eventOngoing"
|
||||
: "eventUpcoming",
|
||||
)}
|
||||
</span>
|
||||
<h2>{dashboardEvent.event.title}</h2>
|
||||
<p className="muted">
|
||||
{dashboardEvent.event.typeName} ·{" "}
|
||||
<time dateTime={dashboardEvent.event.startsAt.toISOString()}>
|
||||
{format.dateTime(dashboardEvent.event.startsAt, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</time>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/events/${slugify(dashboardEvent.event.title)}-${dashboardEvent.event.id}`}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("eventDetails")} →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">{t("noUpcomingEvents")}</p>
|
||||
)}
|
||||
</SurfaceCard>
|
||||
<div className={styles.columns}>
|
||||
<div className={styles.stack}>
|
||||
<SurfaceCard
|
||||
@@ -236,7 +287,11 @@ export default async function MePage({
|
||||
bodyClassName="p-5"
|
||||
>
|
||||
<p className="muted">{t("roomsHint")}</p>
|
||||
{recentRooms.length === 0 ? (
|
||||
{recentRooms === null ? (
|
||||
<p className="muted" role="status">
|
||||
{t("roomsUnavailable")}
|
||||
</p>
|
||||
) : recentRooms.length === 0 ? (
|
||||
<p className="muted">{t("noRooms")}</p>
|
||||
) : (
|
||||
<div className={styles.rooms}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -62,6 +63,8 @@ export default async function EditArticle({
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
articleKey={String(article.id)}
|
||||
baseToken={articleEditToken(article)}
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
fd.set("id", String(article.id));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export function ArticleListSubmit() {
|
||||
const { pending } = useFormStatus();
|
||||
const t = useTranslations("pages.admin.articles");
|
||||
return (
|
||||
<Button type="submit" disabled={pending} aria-busy={pending}>
|
||||
{pending ? t("listLoading") : t("listApply")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+134
-70
@@ -1,107 +1,171 @@
|
||||
import { desc, inArray } from "drizzle-orm";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Form from "next/form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { ArticleCard } from "@/components/admin/article-card";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { loadArticleList } from "@/lib/services/article-list";
|
||||
import { ArticleListSubmit } from "./list-submit";
|
||||
|
||||
export default async function AdminArticles({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
searchParams: Promise<{
|
||||
error?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
|
||||
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const { error } = await searchParams;
|
||||
const params = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const articles = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
slug: WebsiteArticles.slug,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
userId: WebsiteArticles.userId,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(50)
|
||||
.catch(() => []);
|
||||
|
||||
const authorMap = new Map<number, string>();
|
||||
const authorIds = articles
|
||||
.map((a) => a.userId)
|
||||
.filter((id): id is number => Boolean(id));
|
||||
if (authorIds.length > 0) {
|
||||
const authors = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(inArray(User.id, authorIds))
|
||||
.catch(() => []);
|
||||
for (const u of authors) authorMap.set(u.id, u.username);
|
||||
let result: Awaited<ReturnType<typeof loadArticleList>> | null = null;
|
||||
try {
|
||||
result = await loadArticleList({
|
||||
status: params.status,
|
||||
search: params.search,
|
||||
page: Number(params.page ?? 1),
|
||||
});
|
||||
} catch {
|
||||
/* Render a retry state instead of an empty article list. */
|
||||
}
|
||||
|
||||
const latest = articles[0]?.createdAt
|
||||
? formatDate(articles[0].createdAt, "date")
|
||||
: "—";
|
||||
|
||||
const status =
|
||||
result?.status ??
|
||||
(["draft", "published", "scheduled"].includes(params.status ?? "")
|
||||
? params.status
|
||||
: "all");
|
||||
const search = result?.search ?? (params.search ?? "").slice(0, 191);
|
||||
const href = (page: number) =>
|
||||
`/admin/articles?${new URLSearchParams({ status: status ?? "all", search, page: String(page) })}`;
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={Newspaper}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
actions={
|
||||
<Button variant="default" asChild>
|
||||
canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank) ? (
|
||||
<Button asChild>
|
||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
|
||||
{params.error ? (
|
||||
<p role="alert" className="text-sm text-[var(--admin-error)] mb-4">
|
||||
{t("listActionError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("articlesShown")}
|
||||
value={articles.length}
|
||||
icon="📰"
|
||||
<Form
|
||||
action="/admin/articles"
|
||||
className="admin-card mb-5 flex flex-wrap items-end gap-3"
|
||||
key={`${status}:${search}`}
|
||||
>
|
||||
<label htmlFor="article-status" className="grid gap-1 text-sm">
|
||||
<span>{t("listStatus")}</span>
|
||||
<select
|
||||
id="article-status"
|
||||
name="status"
|
||||
defaultValue={status}
|
||||
className="h-9 rounded-md border bg-[var(--admin-surface)] px-3"
|
||||
>
|
||||
{["all", "draft", "published", "scheduled"].map((value) => (
|
||||
<option value={value} key={value}>
|
||||
{t(`listStatus_${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="article-search"
|
||||
className="grid flex-1 min-w-0 gap-1 text-sm"
|
||||
>
|
||||
<span>{t("listSearch")}</span>
|
||||
<Input
|
||||
id="article-search"
|
||||
name="search"
|
||||
defaultValue={search}
|
||||
maxLength={191}
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentPost")}
|
||||
icon="🕒"
|
||||
</label>
|
||||
<ArticleListSubmit />
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/articles">{t("listReset")}</Link>
|
||||
</Button>
|
||||
</Form>
|
||||
{!result ? (
|
||||
<div className="admin-card space-y-3" role="alert">
|
||||
<p>{t("listUnavailable")}</p>
|
||||
<Link
|
||||
className="btn btn-outline"
|
||||
href={href(Number(params.page) || 1)}
|
||||
>
|
||||
{t("listRetry")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{t("listTotal", { count: result.total })}
|
||||
</p>
|
||||
{result.rows.length === 0 ? (
|
||||
<div className="admin-empty">
|
||||
{search || status !== "all"
|
||||
? t("listNoMatches")
|
||||
: t("noArticles")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,260px),1fr))] gap-4">
|
||||
{result.rows.map((article) => (
|
||||
<div key={String(article.id)} className="min-w-0 space-y-2">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{["draft", "published", "scheduled"].includes(
|
||||
article.status,
|
||||
)
|
||||
? t(`listStatus_${article.status}`)
|
||||
: article.status}
|
||||
</span>
|
||||
<ArticleCard
|
||||
id={String(article.id)}
|
||||
title={article.title}
|
||||
image={article.image}
|
||||
createdAt={article.createdAt?.toISOString() ?? null}
|
||||
author={article.author ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-2">
|
||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||
{articles.length === 0 ? (
|
||||
<div className="admin-empty">{t("noArticles")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
||||
{articles.map((a) => (
|
||||
<ArticleCard
|
||||
key={String(a.id)}
|
||||
id={String(a.id)}
|
||||
title={a.title}
|
||||
image={a.image}
|
||||
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
|
||||
author={authorMap.get(a.userId ?? -1)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<nav
|
||||
aria-label={t("listPagination")}
|
||||
className="mt-5 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
{result.page > 1 ? (
|
||||
<Link className="btn btn-outline" href={href(result.page - 1)}>
|
||||
{t("listPrevious")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{t("listPage", { page: result.page, total: result.lastPage })}
|
||||
</span>
|
||||
{result.page < result.lastPage ? (
|
||||
<Link className="btn btn-outline" href={href(result.page + 1)}>
|
||||
{t("listNext")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
OnlineUsersWidget,
|
||||
StatusCard,
|
||||
} from "@/components/admin/dashboard";
|
||||
import { OperationsInbox } from "@/components/admin/operations-inbox";
|
||||
import Link from "@/components/link";
|
||||
import { env } from "@/env";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
@@ -126,6 +127,7 @@ export default async function CommandoCentrum() {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<OperationsInbox />
|
||||
{/* ── Live status ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
assign: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard }));
|
||||
vi.mock("@/lib/error-monitor", () => ({
|
||||
ErrorStore: class {
|
||||
resolve = mocks.resolve;
|
||||
assign = mocks.assign;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
@@ -17,7 +19,7 @@ vi.mock("@/lib/permissions", () => ({
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { resolveCmsError } from "./actions";
|
||||
import { assignCmsError, resolveCmsError } from "./actions";
|
||||
|
||||
it("requires edit permission before resolving or auditing", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
@@ -37,3 +39,33 @@ it("records who resolved the problem", async () => {
|
||||
expect.objectContaining({ userId: 42, action: "cms.error.resolve" }),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it("requires edit permission before assigning", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
await expect(assignCmsError(new FormData())).rejects.toThrow("denied");
|
||||
expect(mocks.assign).not.toHaveBeenCalled();
|
||||
});
|
||||
it("assigns only the authenticated staff member, ignoring submitted user IDs", async () => {
|
||||
mocks.guard.mockResolvedValue({ id: 42 });
|
||||
const data = new FormData();
|
||||
data.set("fingerprint", "aabbccddeeff0011");
|
||||
data.set("assignment", "self");
|
||||
data.set("userId", "999");
|
||||
await assignCmsError(data);
|
||||
expect(mocks.guard).toHaveBeenCalledWith("admin.devops.edit");
|
||||
expect(mocks.assign).toHaveBeenCalledWith("aabbccddeeff0011", 42);
|
||||
expect(mocks.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, action: "cms.error.assign" }),
|
||||
);
|
||||
data.set("assignment", "clear");
|
||||
await assignCmsError(data);
|
||||
expect(mocks.assign).toHaveBeenLastCalledWith("aabbccddeeff0011", null);
|
||||
});
|
||||
it("rejects unsupported assignment commands", async () => {
|
||||
mocks.guard.mockResolvedValue({ id: 42 });
|
||||
const data = new FormData();
|
||||
data.set("assignment", "other");
|
||||
await expect(assignCmsError(data)).rejects.toThrow("Invalid assignment");
|
||||
expect(mocks.assign).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -15,3 +15,20 @@ export async function resolveCmsError(data: FormData) {
|
||||
});
|
||||
revalidatePath("/admin/devops/cms-errors");
|
||||
}
|
||||
|
||||
export async function assignCmsError(data: FormData) {
|
||||
const staff = await requirePermission(PERMS.DEVOPS_EDIT);
|
||||
const mode = data.get("assignment");
|
||||
if (mode !== "self" && mode !== "clear")
|
||||
throw new Error("Invalid assignment operation");
|
||||
const fingerprint = String(data.get("fingerprint") ?? "");
|
||||
const userId = mode === "self" ? staff.id : null;
|
||||
await new ErrorStore().assign(fingerprint, userId);
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "cms.error.assign",
|
||||
target: "cms-error",
|
||||
after: { fingerprint, assignedUserId: userId },
|
||||
});
|
||||
revalidatePath("/admin/devops/cms-errors");
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import Link from "@/components/link";
|
||||
|
||||
import { summarizeErrorGroup } from "@/lib/error-groups";
|
||||
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { resolveCmsError } from "./actions";
|
||||
|
||||
import { assignCmsError, resolveCmsError } from "./actions";
|
||||
|
||||
export default async function CmsErrorsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
|
||||
const t = await getTranslations("pages.admin.cmsErrors");
|
||||
|
||||
const params = await searchParams;
|
||||
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
|
||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||
|
||||
try {
|
||||
result = await new ErrorStore().read();
|
||||
} catch {
|
||||
@@ -25,28 +39,48 @@ export default async function CmsErrorsPage({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const filtered = result.records.filter(
|
||||
(r) =>
|
||||
(!params.source || r.source === params.source) &&
|
||||
(params.status !== "open" || !r.resolved) &&
|
||||
(params.status !== "resolved" || r.resolved) &&
|
||||
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||
);
|
||||
const groups = new Map<string, typeof filtered>();
|
||||
for (const r of filtered) {
|
||||
const list = groups.get(r.fingerprint) ?? [];
|
||||
list.push(r);
|
||||
groups.set(r.fingerprint, list);
|
||||
|
||||
const allGroups = new Map<string, typeof result.records>();
|
||||
|
||||
for (const record of result.records) {
|
||||
const events = allGroups.get(record.fingerprint) ?? [];
|
||||
|
||||
events.push(record);
|
||||
allGroups.set(record.fingerprint, events);
|
||||
}
|
||||
|
||||
const groups = new Map(
|
||||
[...allGroups.entries()].filter(([, events]) => {
|
||||
const latest = summarizeErrorGroup(events).latest;
|
||||
|
||||
return (
|
||||
latest &&
|
||||
(!params.source || latest.source === params.source) &&
|
||||
(params.status !== "open" || !latest.resolved) &&
|
||||
(params.status !== "resolved" || latest.resolved) &&
|
||||
(!query ||
|
||||
events.some((record) =>
|
||||
JSON.stringify(record).toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = [...groups.values()].flat();
|
||||
|
||||
const page = Math.max(
|
||||
1,
|
||||
|
||||
Math.min(
|
||||
Number(params.page) || 1,
|
||||
|
||||
Math.max(1, Math.ceil(groups.size / 25)),
|
||||
),
|
||||
);
|
||||
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
@@ -104,9 +138,16 @@ export default async function CmsErrorsPage({
|
||||
<div className="admin-card">{t("empty")}</div>
|
||||
) : null}
|
||||
{[...groups.entries()]
|
||||
|
||||
.slice((page - 1) * 25, page * 25)
|
||||
|
||||
.map(([fingerprint, events]) => {
|
||||
const r = events[0];
|
||||
const metrics = summarizeErrorGroup(events);
|
||||
|
||||
const r = metrics.latest;
|
||||
|
||||
if (!r) return null;
|
||||
|
||||
return (
|
||||
<details key={fingerprint} className="admin-card overflow-hidden">
|
||||
<summary className="cursor-pointer">
|
||||
@@ -118,6 +159,105 @@ export default async function CmsErrorsPage({
|
||||
<p className="text-sm break-words">{r.message}</p>
|
||||
</summary>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("firstSeen")}</dt>
|
||||
<dd>
|
||||
<time dateTime={metrics.firstAt ?? ""}>
|
||||
{metrics.firstAt}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("lastSeen")}</dt>
|
||||
<dd>
|
||||
<time dateTime={metrics.lastAt ?? ""}>
|
||||
{metrics.lastAt}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("affectedUsers")}
|
||||
</dt>
|
||||
<dd>{metrics.affectedUsers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("unidentifiedEvents")}
|
||||
</dt>
|
||||
<dd>{metrics.unidentified}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("metricsScope")}
|
||||
</p>
|
||||
<p>
|
||||
{r.assignment
|
||||
? t("assignedStaff", { id: r.assignment.userId })
|
||||
: t("unassigned")}
|
||||
</p>
|
||||
{canAccess(
|
||||
permissions,
|
||||
PERMS.DEVOPS_EDIT,
|
||||
session.user.rank,
|
||||
) && (
|
||||
<form
|
||||
action={assignCmsError}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="fingerprint"
|
||||
value={fingerprint}
|
||||
/>
|
||||
{r.assignment?.userId !== session.user.id && (
|
||||
<button
|
||||
type="submit"
|
||||
name="assignment"
|
||||
value="self"
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("assignSelf")}
|
||||
</button>
|
||||
)}
|
||||
{r.assignment && (
|
||||
<button
|
||||
type="submit"
|
||||
name="assignment"
|
||||
value="clear"
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("clearAssignment")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{metrics.operationLink && (
|
||||
<Link
|
||||
href={metrics.operationLink}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("openOperation")}
|
||||
</Link>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
{t("releaseOccurrences")}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{metrics.releases.map((release) => (
|
||||
<li key={release.release} className="break-all">
|
||||
<code>{release.release}</code> ·{" "}
|
||||
{t("occurrences", { count: release.count })} ·{" "}
|
||||
{release.firstAt} — {release.lastAt}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("releaseScope")}
|
||||
</p>
|
||||
</details>
|
||||
<p>
|
||||
{t("reference")}: <code className="break-all">{r.id}</code>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ inspect: vi.fn(), access: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 1 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: mocks.access,
|
||||
PERMS: { DEVOPS_VIEW: "devops.view" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/admin/installation", () => ({
|
||||
inspectInstallation: mocks.inspect,
|
||||
}));
|
||||
vi.mock("../../commandocentrum/refresh-status", () => ({
|
||||
RefreshStatus: () => null,
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
it("rejects unauthorized access before checking database, filesystem or worker", async () => {
|
||||
mocks.access.mockReturnValue(false);
|
||||
await expect(Page()).rejects.toThrow("redirect");
|
||||
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { inspectInstallation } from "@/lib/admin/installation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { RefreshStatus } from "../../commandocentrum/refresh-status";
|
||||
export default async function InstallationPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.installation");
|
||||
const result = await inspectInstallation();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p>{t("hint")}</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{t("checked", {
|
||||
time: formatDate(new Date(result.checkedAt), "datetime-seconds"),
|
||||
ms: result.durationMs,
|
||||
})}
|
||||
</p>
|
||||
<RefreshStatus label={t("recheck")} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{result.rows.map((row) => (
|
||||
<section key={row.key} className="admin-card space-y-2 min-w-0">
|
||||
<h2 className="font-semibold">{t(row.key)}</h2>
|
||||
<p
|
||||
className={
|
||||
row.state === "failed"
|
||||
? "text-destructive"
|
||||
: row.state === "ok"
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-text-muted)]"
|
||||
}
|
||||
>
|
||||
{t(row.state)}
|
||||
</p>
|
||||
{row.detail ? (
|
||||
<code className="block break-all text-xs">{row.detail}</code>
|
||||
) : null}
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{t(`${row.key}Hint`)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,12 +53,16 @@ export default async function DevOpsPage() {
|
||||
|
||||
const t = await getTranslations("pages.admin.devops");
|
||||
const data = await getDevOpsData();
|
||||
const installation = await getTranslations("pages.admin.installation");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
{t("cmsErrors")}
|
||||
</a>
|
||||
<Link href="/admin/devops/installation" className="btn btn-outline">
|
||||
{installation("title")}
|
||||
</Link>
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const keys = ["actor", "action", "from", "to"] as const;
|
||||
export function AuditFilters() {
|
||||
const t = useTranslations("pages.admin.logs.audit");
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
function apply(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const values = new FormData(event.currentTarget);
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) {
|
||||
const value = String(values.get(key) ?? "").trim();
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
function clear() {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) next.delete(key);
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
return (
|
||||
<form onSubmit={apply} key={params.toString()} aria-busy={pending}>
|
||||
<fieldset disabled={pending} className="space-y-3 rounded-xl border p-4">
|
||||
<legend className="px-1 text-sm font-medium">
|
||||
{t("filtersTitle")}
|
||||
</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{keys.map((key) => (
|
||||
<label
|
||||
htmlFor={`audit-${key}`}
|
||||
key={key}
|
||||
className="space-y-1 text-sm"
|
||||
>
|
||||
<span>{t(`filter${key[0].toUpperCase()}${key.slice(1)}`)}</span>
|
||||
<Input
|
||||
id={`audit-${key}`}
|
||||
name={key}
|
||||
type={key === "from" || key === "to" ? "date" : "text"}
|
||||
maxLength={191}
|
||||
defaultValue={params.get(key) ?? ""}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("filtersHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
{t("applyFilters")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={clear}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
import { AuditFilters } from "./audit-filters";
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
@@ -14,75 +15,73 @@ interface AuditRow {
|
||||
target: string;
|
||||
targetId: number | null;
|
||||
diff: string | null;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function DiffCell({ diff }: { diff: string | null }) {
|
||||
function DiffCell({ row }: { row: AuditRow }) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!diff)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||
try {
|
||||
parsed = JSON.parse(diff);
|
||||
} catch {
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
const summary =
|
||||
keys.length === 1
|
||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
|
||||
: t("audit.changesCount", { count: keys.length });
|
||||
|
||||
const { changes, invalid } = readAuditChanges(
|
||||
row.diff,
|
||||
row.before,
|
||||
row.after,
|
||||
);
|
||||
if (invalid)
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("audit.invalidDetails")}
|
||||
</span>
|
||||
);
|
||||
if (!changes.length)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
return (
|
||||
<details className="max-w-xl text-xs">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("audit.changesCount", { count: changes.length })}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3">
|
||||
{changes.map((change) => (
|
||||
<div key={change.key} className="space-y-1">
|
||||
<p className="font-medium break-all">{change.key}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs font-normal"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{summary}
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5 text-xs">
|
||||
{keys.map((k) => (
|
||||
<div key={k} className="flex gap-1">
|
||||
<span className="font-medium">{k}:</span>
|
||||
<span className="text-[var(--admin-error)] line-through">
|
||||
{String(parsed[k].from ?? "∅")}
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.beforeValue")}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{String(parsed[k].to ?? "∅")}
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.from)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.afterValue")}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.to)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuditTableProps {
|
||||
data: PaginatedResult<AuditRow>;
|
||||
}
|
||||
|
||||
export function AuditTable({ data }: AuditTableProps) {
|
||||
const params = useSearchParams();
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
|
||||
const columns: DataTableColumn<AuditRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{ key: "username", label: t("colUser"), sortable: true },
|
||||
{ key: "id", label: t("colId") },
|
||||
{ key: "username", label: t("colUser") },
|
||||
{
|
||||
key: "action",
|
||||
label: t("colAction"),
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const v = String(value);
|
||||
let variant: "default" | "secondary" | "destructive" = "secondary";
|
||||
@@ -92,7 +91,7 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
return <Badge variant={variant}>{v}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: "target", label: t("colTarget"), sortable: true },
|
||||
{ key: "target", label: t("colTarget") },
|
||||
{
|
||||
key: "targetId",
|
||||
label: t("audit.colTargetId"),
|
||||
@@ -103,12 +102,11 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell diff={row.diff} />,
|
||||
render: (_value, row) => <DiffCell row={row} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: t("audit.colDate"),
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span>{String(value) || t("audit.notAvailable")}</span>
|
||||
),
|
||||
@@ -116,11 +114,15 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AuditFilters />
|
||||
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl="/api/admin/export?type=audit"
|
||||
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,15 @@ export default async function AuditLogsPage({
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page } = parseListParams(sp);
|
||||
|
||||
const result = await getAuditLogs({ search, page, perPage });
|
||||
const result = await getAuditLogs({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
actor: sp.get("actor") ?? undefined,
|
||||
action: sp.get("action") ?? undefined,
|
||||
from: sp.get("from") ?? undefined,
|
||||
to: sp.get("to") ?? undefined,
|
||||
});
|
||||
|
||||
const pagination = calcPagination(result.total, result.page, result.perPage);
|
||||
|
||||
@@ -32,6 +40,8 @@ export default async function AuditLogsPage({
|
||||
target: r.target,
|
||||
targetId: r.targetId,
|
||||
diff: r.diff,
|
||||
before: r.before,
|
||||
after: r.after,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
options: {} as Record<string, unknown>,
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 2,
|
||||
username: '=HYPERLINK("bad")',
|
||||
action: "update",
|
||||
target: "user",
|
||||
targetId: 3,
|
||||
createdAt: "2026-09-09",
|
||||
diff: '{"password":{"from":"oldsecret","to":"newsecret"}}',
|
||||
before: null,
|
||||
after: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (options: Record<string, unknown>, handler: unknown) => {
|
||||
state.options = options;
|
||||
return handler;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { LOGS_VIEW: "logs.view" } }));
|
||||
vi.mock("@/lib/services/audit", () => ({
|
||||
getAuditLogs: vi.fn(async () => ({ rows: state.rows })),
|
||||
}));
|
||||
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("audit CSV", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuditLogs).mockClear();
|
||||
});
|
||||
it("requires the audit viewing permission and passes the same filters and page", async () => {
|
||||
expect(state.options).toMatchObject({ permission: "logs.view" });
|
||||
const response = await GET(
|
||||
new Request(
|
||||
"https://example.test/api/admin/logs/audit/export?actor=Alice&action=update&from=2026-09-09&to=2026-09-10&search=user&page=2&perPage=10",
|
||||
) as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(getAuditLogs).toHaveBeenCalledWith({
|
||||
actor: "Alice",
|
||||
action: "update",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-10",
|
||||
search: "user",
|
||||
page: 2,
|
||||
perPage: 10,
|
||||
});
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
});
|
||||
it("escapes CSV formula cells and redacts historical secrets", async () => {
|
||||
const response = await GET(
|
||||
new Request("https://example.test/api/admin/logs/audit/export") as never,
|
||||
{} as never,
|
||||
);
|
||||
const csv = await response.text();
|
||||
expect(csv).toContain("'=HYPERLINK");
|
||||
expect(csv).not.toContain("oldsecret");
|
||||
expect(csv).not.toContain("newsecret");
|
||||
expect(csv).toContain("[Redacted]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { parseListParams } from "@/lib/admin-helpers";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
|
||||
function cell(value: unknown) {
|
||||
const raw = value == null ? "" : String(value);
|
||||
const safe = /^[\s]*[=+@-]/.test(raw) ? `'${raw}` : raw;
|
||||
return `"${safe.replace(/"/g, '""')}"`;
|
||||
}
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.LOGS_VIEW },
|
||||
async (request) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const { search, page, perPage } = parseListParams(params);
|
||||
const result = await getAuditLogs({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
actor: params.get("actor") ?? undefined,
|
||||
action: params.get("action") ?? undefined,
|
||||
from: params.get("from") ?? undefined,
|
||||
to: params.get("to") ?? undefined,
|
||||
});
|
||||
const rows: unknown[][] = [
|
||||
[
|
||||
"id",
|
||||
"user_id",
|
||||
"username",
|
||||
"action",
|
||||
"target",
|
||||
"target_id",
|
||||
"created_at",
|
||||
"changes",
|
||||
],
|
||||
];
|
||||
for (const row of result.rows) {
|
||||
const details = readAuditChanges(row.diff, row.before, row.after);
|
||||
rows.push([
|
||||
row.id,
|
||||
row.userId,
|
||||
row.username,
|
||||
row.action,
|
||||
row.target,
|
||||
row.targetId,
|
||||
row.createdAt,
|
||||
details.invalid
|
||||
? "[Unavailable legacy details]"
|
||||
: details.changes
|
||||
.map(
|
||||
(change) =>
|
||||
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
|
||||
)
|
||||
.join("\n"),
|
||||
]);
|
||||
}
|
||||
return new Response(
|
||||
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="audit-page.csv"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
after: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
source: vi.fn(),
|
||||
@@ -25,15 +26,17 @@ vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } }));
|
||||
vi.mock("@/lib/services/furni-job-worker", () => ({
|
||||
drainFurnitureImports: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-job-store", () => ({
|
||||
vi.mock("@/lib/services/furni-job-store", async (original) => ({
|
||||
...(await original<typeof import("@/lib/services/furni-job-store")>()),
|
||||
ImportJobStore: class {
|
||||
create = mocks.create;
|
||||
list = mocks.list;
|
||||
requestCancellation = mocks.cancel;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
import { GET, PATCH, POST } from "./route";
|
||||
|
||||
const item = {
|
||||
id: 1,
|
||||
@@ -106,11 +109,23 @@ it("rejects a missing configured source", async () => {
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
it("only returns the current operators history", async () => {
|
||||
mocks.list.mockResolvedValue([
|
||||
{ id: "a", userId: 7 },
|
||||
{ id: "b", userId: 9 },
|
||||
]);
|
||||
it("requests bounded owner-scoped history from the store", async () => {
|
||||
mocks.list.mockResolvedValue([{ id: "a", userId: 7 }]);
|
||||
const response = await GET(request({}), ctx);
|
||||
expect(mocks.list).toHaveBeenCalledWith({ userId: 7, limit: 30 });
|
||||
expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]);
|
||||
});
|
||||
it("requests cancellation with the authenticated owner", async () => {
|
||||
const id = randomUUID();
|
||||
mocks.cancel.mockResolvedValue({ id, cancelRequested: true });
|
||||
const response = await PATCH(request({ id }), ctx);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.cancel).toHaveBeenCalledWith(id, 7);
|
||||
});
|
||||
it("does not reveal other owners' jobs", async () => {
|
||||
mocks.cancel.mockResolvedValue(null);
|
||||
expect((await PATCH(request({ id: randomUUID() }), ctx)).status).toBe(404);
|
||||
});
|
||||
it("rejects unsafe cancellation IDs", async () => {
|
||||
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
|
||||
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -38,10 +38,10 @@ export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (_request, ctx) => {
|
||||
after(drainFurnitureImports);
|
||||
const jobs = (await new ImportJobStore().list())
|
||||
.filter((job) => job.userId === ctx.session.user.id)
|
||||
.reverse()
|
||||
.slice(0, 30);
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: ctx.session.user.id,
|
||||
limit: 30,
|
||||
});
|
||||
return apiOk({ jobs });
|
||||
},
|
||||
);
|
||||
@@ -82,3 +82,18 @@ export const POST = withAdmin(
|
||||
return apiOk({ job });
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
|
||||
const job = await new ImportJobStore().requestCancellation(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
if (!job) return apiError("Import job not found", 404);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk({ job });
|
||||
},
|
||||
);
|
||||
@@ -4,18 +4,24 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
export function ArticleForm({
|
||||
action,
|
||||
defaultValues,
|
||||
articleKey = "new",
|
||||
baseToken = "",
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
|
||||
edit?: boolean;
|
||||
articleKey?: string;
|
||||
baseToken?: string;
|
||||
defaultValues?: {
|
||||
title?: string;
|
||||
slug?: string;
|
||||
@@ -55,6 +61,31 @@ export function ArticleForm({
|
||||
}, [defaultValues?.publishAt]);
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [token, setToken] = useState(baseToken);
|
||||
const [summary, setSummary] = useState(defaultValues?.shortStory ?? "");
|
||||
const [body, setBody] = useState(defaultValues?.fullStory ?? "");
|
||||
const [bodyVersion, setBodyVersion] = useState(0);
|
||||
function restoreDraft(draft: ArticleDraft, revision: boolean) {
|
||||
setTitle(draft.title);
|
||||
setSlug(draft.slug);
|
||||
setSlugTouched(true);
|
||||
setImage(draft.image);
|
||||
setImageError(false);
|
||||
setSummary(draft.shortStory);
|
||||
setBody(draft.fullStory);
|
||||
setBodyVersion((v) => v + 1);
|
||||
setStatus("draft");
|
||||
setPublishAt("");
|
||||
setToken(revision ? baseToken : draft.baseToken);
|
||||
markDirty();
|
||||
}
|
||||
const recovery = useArticleRecovery(
|
||||
articleKey,
|
||||
formRef,
|
||||
saving,
|
||||
dirty,
|
||||
restoreDraft,
|
||||
);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
@@ -76,12 +107,14 @@ export function ArticleForm({
|
||||
setSaveError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await recovery.wait();
|
||||
const result = await action(data);
|
||||
if (result && !result.ok) {
|
||||
setSaveError(result.error);
|
||||
return;
|
||||
}
|
||||
if (editVersion.current === submittedVersion) setDirty(false);
|
||||
if (result?.ok) await recovery.clear().catch(() => {});
|
||||
if (result?.ok && result.data?.redirectTo)
|
||||
router.push(result.data.redirectTo);
|
||||
} catch (error) {
|
||||
@@ -104,6 +137,8 @@ export function ArticleForm({
|
||||
style={{ display: "grid", gap: "1rem" }}
|
||||
>
|
||||
<fieldset disabled={pending} className="contents">
|
||||
<input type="hidden" name="baseToken" value={token} />
|
||||
{recovery.panel}
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm">
|
||||
{saveError}
|
||||
@@ -193,7 +228,8 @@ export function ArticleForm({
|
||||
<textarea
|
||||
name="shortStory"
|
||||
maxLength={255}
|
||||
defaultValue={defaultValues?.shortStory ?? ""}
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
@@ -205,9 +241,10 @@ export function ArticleForm({
|
||||
{t("body")}
|
||||
</span>
|
||||
<RichText
|
||||
key={bodyVersion}
|
||||
onChange={markDirty}
|
||||
name="fullStory"
|
||||
defaultValue={defaultValues?.fullStory ?? ""}
|
||||
defaultValue={body}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={12}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
autosaveArticle,
|
||||
clearArticleRecovery,
|
||||
loadArticleRecovery,
|
||||
} from "@/actions/article-recovery";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
|
||||
export function useArticleRecovery(
|
||||
key: string,
|
||||
form: RefObject<HTMLFormElement | null>,
|
||||
saving: RefObject<boolean>,
|
||||
dirty: boolean,
|
||||
restore: (draft: ArticleDraft, revision: boolean) => void,
|
||||
) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
const [recovery, setRecovery] = useState<Awaited<
|
||||
ReturnType<typeof loadArticleRecovery>
|
||||
> | null>(null);
|
||||
const [message, setMessage] = useState("autosaveLoading");
|
||||
const [reload, setReload] = useState(0);
|
||||
const version = useRef(0);
|
||||
const request = useRef<Promise<void> | null>(null);
|
||||
const last = useRef("");
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
const blocked = useRef(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
blocked.current = true;
|
||||
last.current = "";
|
||||
setMessage("autosaveLoading");
|
||||
loadArticleRecovery(key)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
version.current = data.version;
|
||||
setRecovery(data);
|
||||
blocked.current = Boolean(data.draft);
|
||||
setMessage(data.draft ? "recoveryFound" : "autosaveReady");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMessage("autosaveFailed");
|
||||
});
|
||||
const timer = setInterval(() => {
|
||||
if (
|
||||
blocked.current ||
|
||||
!dirtyRef.current ||
|
||||
saving.current ||
|
||||
request.current ||
|
||||
!form.current
|
||||
)
|
||||
return;
|
||||
const data = new FormData(form.current);
|
||||
const snapshot = JSON.stringify(Array.from(data.entries()));
|
||||
if (snapshot === last.current) return;
|
||||
setMessage("autosaveSaving");
|
||||
request.current = autosaveArticle(key, version.current, data)
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
if (!result.ok) {
|
||||
blocked.current = true;
|
||||
setMessage("autosaveConflict");
|
||||
return;
|
||||
}
|
||||
version.current = result.version;
|
||||
last.current = snapshot;
|
||||
setMessage("autosaveSaved");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMessage("autosaveFailed");
|
||||
})
|
||||
.finally(() => {
|
||||
request.current = null;
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [key, form, saving, reload]);
|
||||
return {
|
||||
wait: () => request.current ?? Promise.resolve(),
|
||||
clear: () => clearArticleRecovery(key, version.current),
|
||||
panel: (
|
||||
<div className="grid gap-2 text-xs" aria-live="polite">
|
||||
<p>{t(message)}</p>
|
||||
{(message === "autosaveFailed" || message === "autosaveConflict") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={async () => {
|
||||
if (saving.current) return;
|
||||
// Reconcile server versions without replacing any current form contents.
|
||||
blocked.current = true;
|
||||
await request.current;
|
||||
setReload((current) => current + 1);
|
||||
}}
|
||||
>
|
||||
{t("retryRecovery")}
|
||||
</button>
|
||||
)}
|
||||
{recovery?.draft && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
if (!recovery.draft || !window.confirm(t("confirmRecovery")))
|
||||
return;
|
||||
restore(recovery.draft.payload, false);
|
||||
setRecovery({ ...recovery, draft: null });
|
||||
blocked.current = false;
|
||||
setMessage("autosaveReady");
|
||||
}}
|
||||
>
|
||||
{t("recoverDraft")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t("confirmDiscardDraft"))) return;
|
||||
try {
|
||||
await clearArticleRecovery(key, version.current);
|
||||
// Reload the version in case another tab changed it before discarding.
|
||||
const data = await loadArticleRecovery(key);
|
||||
version.current = data.version;
|
||||
setRecovery(data);
|
||||
blocked.current = Boolean(data.draft);
|
||||
setMessage(data.draft ? "autosaveConflict" : "autosaveReady");
|
||||
} catch {
|
||||
setMessage("autosaveFailed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("discardDraft")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!recovery?.revisions.length && (
|
||||
<details>
|
||||
<summary>{t("revisionHistory")}</summary>
|
||||
<ul className="grid gap-2 mt-2">
|
||||
{recovery.revisions.map((revision) => (
|
||||
<li key={revision.id} className="flex gap-2 items-center">
|
||||
<time dateTime={revision.createdAt}>
|
||||
{new Date(revision.createdAt).toLocaleString()}
|
||||
</time>
|
||||
<span>{revision.payload.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
if (
|
||||
blocked.current ||
|
||||
!window.confirm(t("confirmRevision"))
|
||||
)
|
||||
return;
|
||||
restore(revision.payload, true);
|
||||
}}
|
||||
disabled={Boolean(recovery.draft)}
|
||||
>
|
||||
{t("restoreRevision")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -106,7 +106,42 @@ export function DataTable<T extends { id: number | string }>({
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const bulkLock = useRef(false);
|
||||
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const [columnPreference, setColumnPreference] = useState<{
|
||||
path: string;
|
||||
hidden: Set<string>;
|
||||
}>(() => ({ path: pathname, hidden: new Set() }));
|
||||
const hiddenColumns =
|
||||
columnPreference.path === pathname &&
|
||||
!columns.every((column) => columnPreference.hidden.has(column.key))
|
||||
? columnPreference.hidden
|
||||
: new Set<string>();
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(
|
||||
sessionStorage.getItem(`cms-table-columns:${pathname}`) ?? "[]",
|
||||
);
|
||||
const hidden = Array.isArray(raw)
|
||||
? raw.filter((key): key is string => typeof key === "string")
|
||||
: [];
|
||||
setColumnPreference({ path: pathname, hidden: new Set(hidden) });
|
||||
} catch {
|
||||
setColumnPreference({ path: pathname, hidden: new Set() });
|
||||
}
|
||||
}, [pathname]);
|
||||
function setHiddenColumns(update: (previous: Set<string>) => Set<string>) {
|
||||
const next = update(hiddenColumns);
|
||||
// Leave at least one data column available, even after a stale saved preference.
|
||||
if (columns.every((column) => next.has(column.key))) return;
|
||||
setColumnPreference({ path: pathname, hidden: next });
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`cms-table-columns:${pathname}`,
|
||||
JSON.stringify([...next]),
|
||||
);
|
||||
} catch {
|
||||
/* Storage can be disabled by the browser. */
|
||||
}
|
||||
}
|
||||
|
||||
const currentSort = searchParams.get("sort") || "";
|
||||
const currentOrder = searchParams.get("order") || "desc";
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { count, eq, ne } from "drizzle-orm";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { collectOperations } from "@/lib/admin/operations-inbox";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticles,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||
|
||||
export async function OperationsInbox() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const allowed = (permission: string) =>
|
||||
canAccess(permissions, permission, session.user.rank);
|
||||
const t = await getTranslations("pages.admin.operations");
|
||||
const result = await collectOperations([
|
||||
{
|
||||
source: "errors",
|
||||
allowed: allowed(PERMS.DEVOPS_VIEW),
|
||||
load: async () => {
|
||||
const { records } = await new ErrorStore().read();
|
||||
const total = new Set(
|
||||
records
|
||||
.filter((record) => !record.resolved)
|
||||
.map((record) => record.fingerprint),
|
||||
).size;
|
||||
return [
|
||||
{
|
||||
id: "unresolved",
|
||||
source: "errors",
|
||||
count: total,
|
||||
severity: 3,
|
||||
href: "/admin/devops/cms-errors?status=open",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "imports",
|
||||
allowed: allowed(PERMS.ASSETS_IMPORT),
|
||||
load: async () => {
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: Number(session.user.id),
|
||||
limit: 30,
|
||||
});
|
||||
const total = jobs.filter(
|
||||
(job) =>
|
||||
job.state === "interrupted" ||
|
||||
job.items.some((item) => item.state === "failed"),
|
||||
).length;
|
||||
return [
|
||||
{
|
||||
id: "attention",
|
||||
source: "imports",
|
||||
count: total,
|
||||
severity: 2,
|
||||
href: "/admin/studio/furni",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "tickets",
|
||||
allowed: allowed(PERMS.TICKETS_VIEW),
|
||||
load: async () => {
|
||||
const [rows, helpRows] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed")),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true)),
|
||||
]);
|
||||
return [
|
||||
{
|
||||
id: "open",
|
||||
source: "tickets",
|
||||
count:
|
||||
Number(rows[0]?.total ?? 0) + Number(helpRows[0]?.total ?? 0),
|
||||
severity: 1,
|
||||
href: "/admin/tickets",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "publications",
|
||||
allowed: allowed(PERMS.NEWS_VIEW),
|
||||
load: async () => {
|
||||
const rows = await db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.status, "draft"));
|
||||
return [
|
||||
{
|
||||
id: "drafts",
|
||||
source: "publications",
|
||||
count: Number(rows[0]?.total ?? 0),
|
||||
severity: 0,
|
||||
href: "/admin/articles?status=draft",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
]);
|
||||
return (
|
||||
<section
|
||||
className="admin-card mb-6 space-y-3"
|
||||
aria-labelledby="operations-title"
|
||||
>
|
||||
<h2 id="operations-title" className="text-lg font-semibold">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
{result.items.length === 0 && result.unavailable.length === 0 ? (
|
||||
<p>{t("empty")}</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{result.items.map((item) => (
|
||||
<Link
|
||||
key={`${item.source}:${item.id}`}
|
||||
href={item.href}
|
||||
className="rounded-lg border border-[var(--admin-border)] p-3 focus-visible:outline-2"
|
||||
>
|
||||
<span>{t(item.source)}</span>
|
||||
<strong className="float-right">{item.count}</strong>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{result.unavailable.map((source) => (
|
||||
<p
|
||||
key={source}
|
||||
role="status"
|
||||
className="text-sm text-[var(--admin-warning)]"
|
||||
>
|
||||
{t("unavailable", { source: t(source) })}
|
||||
</p>
|
||||
))}
|
||||
{allowed(PERMS.DEVOPS_VIEW) ? (
|
||||
<Link href="/admin/devops/installation" className="btn btn-outline">
|
||||
{t("installation")}
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||
import { FurnitureCompleteness } from "./furniture-completeness";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
const empty: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [],
|
||||
catalog: [],
|
||||
furnidata: [],
|
||||
furnidataReadable: true,
|
||||
nitro: { exists: false, bytes: 0 },
|
||||
icon: { exists: false, bytes: 0 },
|
||||
};
|
||||
describe("completeness panel", () => {
|
||||
it("renders five separately named parts with useful missing-data guidance", () => {
|
||||
const html = renderToStaticMarkup(<FurnitureCompleteness local={empty} />);
|
||||
for (const part of ["sql", "catalog", "furnidata", "icon", "nitro"]) {
|
||||
expect(html).toContain(`parts.${part}`);
|
||||
expect(html).toContain(`actions.${part}`);
|
||||
}
|
||||
expect(html).not.toContain('href="/admin/studio/audit"');
|
||||
});
|
||||
it("gives conflicts an accessible action to open the real audit page", () => {
|
||||
const local = {
|
||||
...empty,
|
||||
sql: [
|
||||
{ id: 1, spriteId: 1, type: "s", name: "Chair" },
|
||||
{ id: 2, spriteId: 2, type: "s", name: "Chair" },
|
||||
],
|
||||
};
|
||||
const html = renderToStaticMarkup(<FurnitureCompleteness local={local} />);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/admin/studio/audit"');
|
||||
expect(html).toContain("actions.audit");
|
||||
});
|
||||
it("renders unavailable checks as unverified with a recheck instruction", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<FurnitureCompleteness
|
||||
local={{
|
||||
...empty,
|
||||
furnidataReadable: false,
|
||||
icon: { exists: null, bytes: 0 },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/statuses.unknown/g)).toHaveLength(2);
|
||||
expect(html.match(/actions.recheck/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { furnitureCompleteness } from "@/lib/furni/studio-completeness";
|
||||
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
|
||||
export function FurnitureCompleteness({
|
||||
local,
|
||||
}: {
|
||||
local: FurnitureInspection;
|
||||
}) {
|
||||
const t = useTranslations("admin.studio.completeness");
|
||||
const parts = furnitureCompleteness(local);
|
||||
return (
|
||||
<section aria-label={t("title")} className="space-y-3">
|
||||
<h3 className="font-semibold">{t("title")}</h3>
|
||||
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{parts.map((part) => (
|
||||
<div
|
||||
key={part.id}
|
||||
className="rounded-md border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
<dt className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(`parts.${part.id}`)}
|
||||
</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
<p
|
||||
className={
|
||||
part.status === "present"
|
||||
? "font-medium text-[var(--admin-success)]"
|
||||
: part.status === "unknown"
|
||||
? "font-medium text-[var(--admin-text-muted)]"
|
||||
: "font-medium text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
{t(`statuses.${part.status}`)}
|
||||
</p>
|
||||
{part.action && (
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(`actions.${part.action}`)}
|
||||
</p>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{parts.some((part) => part.status === "conflict") && (
|
||||
<p role="alert" className="text-[var(--admin-warning)]">
|
||||
<Link
|
||||
href="/admin/studio/audit"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("openAudit")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
import {
|
||||
Check,
|
||||
CloudDownload,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
||||
import { FurnitureInspector } from "./furniture-inspector";
|
||||
import {
|
||||
getFurniImageUrl,
|
||||
handleImgError,
|
||||
TYPE_LABELS,
|
||||
} from "./studio-furni-icons";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
|
||||
interface FurnitureDetailDrawerProps {
|
||||
detail: FurniItem;
|
||||
importingId: string | null;
|
||||
regenerating: boolean;
|
||||
busy: boolean;
|
||||
inspectionKey: string;
|
||||
onClose: () => void;
|
||||
onImport: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRegenerate: () => void;
|
||||
onRepair: () => Promise<unknown>;
|
||||
}
|
||||
export function FurnitureDetailDrawer({
|
||||
detail,
|
||||
importingId,
|
||||
regenerating,
|
||||
busy,
|
||||
inspectionKey,
|
||||
onClose,
|
||||
onImport,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onRepair,
|
||||
}: FurnitureDetailDrawerProps) {
|
||||
const t = useTranslations("admin.studio.completeness");
|
||||
const detailPreview = previewAutoCatalog(
|
||||
detail.classname,
|
||||
detail.type === "wallitem" ? "i" : "s",
|
||||
);
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Furni detail
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close furniture details"
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div
|
||||
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
|
||||
backgroundSize: "14px 14px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFurniImageUrl(detail)}
|
||||
alt={detail.name}
|
||||
className="h-28 w-28 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => handleImgError(e, detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
|
||||
{detail.name}
|
||||
</h2>
|
||||
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
|
||||
{detail.classname}
|
||||
</p>
|
||||
|
||||
{detail.description ? (
|
||||
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
|
||||
{detail.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">
|
||||
{TYPE_LABELS[detail.type] ?? detail.type}
|
||||
</Badge>
|
||||
<Badge variant="secondary">rev {detail.revision}</Badge>
|
||||
{detail.alreadyImported ? (
|
||||
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
|
||||
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="text-[var(--admin-accent-foreground)]">
|
||||
<Sparkles size={10} /> Auto-import ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FurnitureInspector
|
||||
key={inspectionKey}
|
||||
item={detail}
|
||||
busy={busy}
|
||||
onRepair={onRepair}
|
||||
/>
|
||||
{/* Auto-catalog preview */}
|
||||
{detailPreview && (
|
||||
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<WandSparkles size={12} className="text-[var(--admin-accent)]" />
|
||||
Automatic placement
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">Category</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.categoryLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">Price</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.credits} credits
|
||||
{detailPreview.points > 0
|
||||
? ` + ${detailPreview.points} points`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
||||
<div className="space-y-2">
|
||||
{!detail.alreadyImported ? (
|
||||
<Button
|
||||
className="w-full gap-1.5"
|
||||
disabled={importingId !== null}
|
||||
onClick={onImport}
|
||||
>
|
||||
{importingId === detail.classname ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CloudDownload size={14} />
|
||||
)}
|
||||
Import & add to catalog
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!detail.nitroExists && (
|
||||
<>
|
||||
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
||||
This furni is in the DB but the .nitro file is missing.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
disabled={regenerating}
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Regenerate .nitro
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full gap-1.5"
|
||||
onClick={onEdit}
|
||||
disabled={!detail.nitroExists}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Edit nitro
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.alreadyImported && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete imported furni
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
type FurnitureInspection,
|
||||
furnitureHealth,
|
||||
} from "@/lib/furni/studio-inspection";
|
||||
import { FurnitureCompleteness } from "./furniture-completeness";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
import { useFurnitureInspection } from "./use-furniture-inspection";
|
||||
|
||||
const status = (value: boolean | null | undefined) =>
|
||||
value == null ? "Not verified" : value ? "Present" : "Missing";
|
||||
export function FurnitureComparison({
|
||||
item,
|
||||
local,
|
||||
@@ -20,42 +19,7 @@ export function FurnitureComparison({
|
||||
const comparison = compareFurniture(item, local);
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
[".nitro", local.nitro.exists],
|
||||
["Local icon", local.icon.exists],
|
||||
[
|
||||
"Furnidata",
|
||||
local.furnidataReadable ? local.furnidata.length > 0 : null,
|
||||
],
|
||||
["SQL item", local.sql.length > 0],
|
||||
["Catalog offer", local.catalog.length > 0],
|
||||
] as const
|
||||
).map(([label, present]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded-md border border-[var(--admin-border)] p-2"
|
||||
>
|
||||
<dt className="text-xs text-[var(--admin-text-muted)]">{label}</dt>
|
||||
<dd
|
||||
className={
|
||||
present
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
{status(present)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{comparison.state === "conflict" && (
|
||||
<p role="alert" className="text-[var(--admin-warning)]">
|
||||
Multiple local records use this classname. Review them in Catalog
|
||||
audit before importing.
|
||||
</p>
|
||||
)}
|
||||
<FurnitureCompleteness local={local} />
|
||||
{!local.furnidataReadable && (
|
||||
<p className="text-[var(--admin-warning)]">
|
||||
Local furnidata could not be read. Its values are not verified.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
@@ -10,6 +12,7 @@ export function FurnitureJobHistory({
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const format = useFormatter();
|
||||
const [limits, setLimits] = useState<Record<string, number>>({});
|
||||
return (
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
@@ -21,6 +24,12 @@ export function FurnitureJobHistory({
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("historyLimit")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void state.refresh()}>
|
||||
{t("refreshHistory")}
|
||||
</Button>
|
||||
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||
{state.jobs.map((job) => {
|
||||
const retryItems = retryableJobItems(job);
|
||||
@@ -50,7 +59,7 @@ export function FurnitureJobHistory({
|
||||
aria-label={t("progress")}
|
||||
/>
|
||||
<ul className="space-y-1 py-2">
|
||||
{job.items.map((item) => (
|
||||
{job.items.slice(0, limits[job.id] ?? 50).map((item) => (
|
||||
<li key={item.classname}>
|
||||
<strong>{item.classname}</strong> · {t(item.state)}
|
||||
{item.itemId ? ` · ID ${item.itemId}` : ""}
|
||||
@@ -70,6 +79,46 @@ export function FurnitureJobHistory({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{job.items.length > (limits[job.id] ?? 50) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setLimits((current) => ({
|
||||
...current,
|
||||
[job.id]: (current[job.id] ?? 50) + 50,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t("showMoreItems")}
|
||||
</Button>
|
||||
)}
|
||||
{(job.state === "queued" || job.state === "running") && (
|
||||
<div className="space-y-2 py-2">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(job.cancelRequested ? "cancelRequested" : "cancelHint")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.cancelling !== null || job.cancelRequested}
|
||||
onClick={() => void state.cancel(job.id)}
|
||||
>
|
||||
{t("cancelRemaining")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{job.items.some(
|
||||
(item) =>
|
||||
item.state === "interrupted" ||
|
||||
item.error?.startsWith("Server restarted during import.") ||
|
||||
(item.state === "running" && job.state === "interrupted"),
|
||||
) && (
|
||||
<p role="alert" className="py-2 text-[var(--admin-warning)]">
|
||||
{t("uncertainOutcome")}{" "}
|
||||
<Link href="/admin/studio/audit" className="underline">
|
||||
{t("reviewLocalData")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{retryItems.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -84,7 +133,7 @@ export function FurnitureJobHistory({
|
||||
}
|
||||
>
|
||||
{t(
|
||||
job.state === "interrupted"
|
||||
job.state === "interrupted" || job.state === "cancelled"
|
||||
? "retryUnfinished"
|
||||
: "retryFailed",
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +38,7 @@ export function ImportReview({
|
||||
busy?: boolean;
|
||||
onConfirm: (items: FurniItem[]) => void;
|
||||
}) {
|
||||
const completenessT = useTranslations("admin.studio.completeness");
|
||||
const [attachments, setAttachments] = useState<Record<string, string>>({});
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
async function attach(item: FurniItem, file: File) {
|
||||
@@ -186,6 +189,22 @@ export function ImportReview({
|
||||
(complete missing data) · {blocked} conflicts ·{" "}
|
||||
{unavailable.length} source file warnings
|
||||
</p>
|
||||
{blocked > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-3 text-sm"
|
||||
>
|
||||
<p>
|
||||
{completenessT("excludedConflicts", { count: blocked })}
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/studio/audit"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
{completenessT("openAudit")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{inspection.items.some(
|
||||
(item) =>
|
||||
!item.furnidataReadable ||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -53,10 +52,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import {
|
||||
type AutoCatalogPreview,
|
||||
previewAutoCatalog,
|
||||
} from "@/lib/furni/auto-catalog";
|
||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -64,7 +59,7 @@ import type { TreeNode } from "@/types/catalog";
|
||||
import { BatchProgress } from "./batch-progress";
|
||||
import { CatalogRail } from "./catalog-rail";
|
||||
import { CheckboxDot } from "./checkbox-dot";
|
||||
import { FurnitureInspector } from "./furniture-inspector";
|
||||
import { FurnitureDetailDrawer } from "./furniture-detail-drawer";
|
||||
import { FurnitureJobHistory } from "./furniture-jobs";
|
||||
import { ImportReview } from "./import-review";
|
||||
import { filterFurniture } from "./studio-filters";
|
||||
@@ -883,14 +878,6 @@ export function StudioClient({
|
||||
}
|
||||
}
|
||||
|
||||
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
|
||||
if (!detail) return null;
|
||||
return previewAutoCatalog(
|
||||
detail.classname,
|
||||
detail.type === "wallitem" ? "i" : "s",
|
||||
);
|
||||
}, [detail]);
|
||||
|
||||
const selectedNonImported = useMemo(
|
||||
() =>
|
||||
filteredItems.filter(
|
||||
@@ -1930,74 +1917,19 @@ export function StudioClient({
|
||||
|
||||
{/* ── Detail drawer ─────────────────────────────── */}
|
||||
{detail ? (
|
||||
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Furni detail
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close furniture details"
|
||||
onClick={() => setDetail(null)}
|
||||
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div
|
||||
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
|
||||
backgroundSize: "14px 14px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFurniImageUrl(detail)}
|
||||
alt={detail.name}
|
||||
className="h-28 w-28 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => handleImgError(e, detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
|
||||
{detail.name}
|
||||
</h2>
|
||||
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
|
||||
{detail.classname}
|
||||
</p>
|
||||
|
||||
{detail.description ? (
|
||||
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
|
||||
{detail.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">
|
||||
{TYPE_LABELS[detail.type] ?? detail.type}
|
||||
</Badge>
|
||||
<Badge variant="secondary">rev {detail.revision}</Badge>
|
||||
{detail.alreadyImported ? (
|
||||
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
|
||||
<Check size={10} /> In catalog
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="text-[var(--admin-accent-foreground)]">
|
||||
<Sparkles size={10} /> Auto-import ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FurnitureInspector
|
||||
key={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
|
||||
.filter((j) => j.state === "completed")
|
||||
<FurnitureDetailDrawer
|
||||
detail={detail}
|
||||
importingId={importingId}
|
||||
regenerating={regeneratingNitro.has(detail.classname)}
|
||||
inspectionKey={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
|
||||
.filter(
|
||||
(j) =>
|
||||
j.state === "completed" ||
|
||||
j.state === "cancelled" ||
|
||||
j.state === "interrupted",
|
||||
)
|
||||
.map((j) => j.id)
|
||||
.join(",")}`}
|
||||
item={detail}
|
||||
busy={
|
||||
jobs.busy ||
|
||||
jobs.jobs.some(
|
||||
@@ -2006,6 +1938,14 @@ export function StudioClient({
|
||||
j.items.some((i) => i.classname === detail.classname),
|
||||
)
|
||||
}
|
||||
onClose={() => setDetail(null)}
|
||||
onImport={() => setReview({ items: [detail], single: true })}
|
||||
onEdit={() => setEditingClassname(detail.classname)}
|
||||
onRegenerate={() => void regenNitro(detail)}
|
||||
onDelete={() => {
|
||||
setDeleting(detail.classname);
|
||||
setConfirmDelete(true);
|
||||
}}
|
||||
onRepair={() =>
|
||||
jobs.submit([detail], {
|
||||
sourceId: activeSource || undefined,
|
||||
@@ -2014,106 +1954,6 @@ export function StudioClient({
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* Auto-catalog preview */}
|
||||
{detailPreview && (
|
||||
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<WandSparkles
|
||||
size={12}
|
||||
className="text-[var(--admin-accent)]"
|
||||
/>
|
||||
Automatic placement
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
Category
|
||||
</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.categoryLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
Price
|
||||
</span>
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{detailPreview.credits} credits
|
||||
{detailPreview.points > 0
|
||||
? ` + ${detailPreview.points} points`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
||||
<div className="space-y-2">
|
||||
{!detail.alreadyImported ? (
|
||||
<Button
|
||||
className="w-full gap-1.5"
|
||||
disabled={importingId !== null}
|
||||
onClick={() => setReview({ items: [detail], single: true })}
|
||||
>
|
||||
{importingId === detail.classname ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CloudDownload size={14} />
|
||||
)}
|
||||
Import & add to catalog
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!detail.nitroExists && (
|
||||
<>
|
||||
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
||||
This furni is in the DB but the .nitro file is
|
||||
missing.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
disabled={regeneratingNitro.has(detail.classname)}
|
||||
onClick={() => regenNitro(detail)}
|
||||
>
|
||||
{regeneratingNitro.has(detail.classname) ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Regenerate .nitro
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full gap-1.5"
|
||||
onClick={() => setEditingClassname(detail.classname)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Edit nitro
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.alreadyImported && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
onClick={() => {
|
||||
setDeleting(detail.classname);
|
||||
setConfirmDelete(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete imported furni
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
export function useFurnitureJobs(onComplete: () => void) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
@@ -29,7 +33,9 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
if (
|
||||
(job.state === "completed" || job.state === "interrupted") &&
|
||||
(job.state === "completed" ||
|
||||
job.state === "interrupted" ||
|
||||
job.state === "cancelled") &&
|
||||
!completed.current.has(job.id)
|
||||
) {
|
||||
completed.current.add(job.id);
|
||||
@@ -44,7 +50,33 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, []);
|
||||
useVisiblePolling(refresh, 5000);
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
useVisiblePolling(refresh, active ? 5000 : 30000);
|
||||
const cancel = async (id: string) => {
|
||||
if (cancelInFlight.current) return;
|
||||
cancelInFlight.current = true;
|
||||
setCancelling(id);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
setJobs((current) =>
|
||||
current.map((job) => (job.id === id ? data.job : job)),
|
||||
);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
} finally {
|
||||
cancelInFlight.current = false;
|
||||
setCancelling(null);
|
||||
}
|
||||
};
|
||||
const submit = async (
|
||||
items: ImportJobItem[],
|
||||
options: {
|
||||
@@ -88,5 +120,5 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { jobs, busy, error, submit, refresh };
|
||||
return { jobs, busy, error, submit, refresh, cancel, cancelling };
|
||||
}
|
||||
@@ -66,7 +66,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
"bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
bigint,
|
||||
int,
|
||||
longtext,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const ArticleDrafts = mysqlTable(
|
||||
"website_article_drafts",
|
||||
{
|
||||
userId: bigint("user_id", { mode: "number", unsigned: true }).notNull(),
|
||||
articleKey: varchar("article_key", { length: 32 }).notNull(),
|
||||
version: int("version", { unsigned: true }).notNull(),
|
||||
payload: longtext("payload").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.userId, table.articleKey] })],
|
||||
);
|
||||
export const ArticleRevisions = mysqlTable("website_article_revisions", {
|
||||
id: bigint("id", { mode: "number", unsigned: true })
|
||||
.autoincrement()
|
||||
.primaryKey(),
|
||||
articleId: bigint("article_id", { mode: "bigint", unsigned: true }).notNull(),
|
||||
userId: bigint("user_id", { mode: "number", unsigned: true }).notNull(),
|
||||
payload: longtext("payload").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
});
|
||||
|
||||
it("validates the complete 137-row migration matrix without issues", () => {
|
||||
it("validates the complete 139-row migration matrix without issues", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
||||
expect(discovered).toHaveLength(138);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(discovered).toHaveLength(139);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
||||
it("discovers the exact legacy administration inventory", () => {
|
||||
const pages = discoverLegacyPages();
|
||||
|
||||
expect(pages).toHaveLength(138);
|
||||
expect(pages).toHaveLength(139);
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/users/:id/edit",
|
||||
|
||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||
it("covers all 138 legacy pages exactly once", () => {
|
||||
it("covers all 139 legacy pages exactly once", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(
|
||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
).toEqual([]);
|
||||
|
||||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
|
||||
] as const;
|
||||
|
||||
describe("systemMigrationEntries", () => {
|
||||
it("covers all 19 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(19);
|
||||
it("covers all 20 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(20);
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||
|
||||
@@ -21,6 +21,21 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
|
||||
}
|
||||
|
||||
export const systemMigrationEntries: readonly MigrationEntry[] = [
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/devops/installation",
|
||||
sourceFile: "src/app/admin/devops/installation/page.tsx",
|
||||
targetPath: "/admin/system/observability/installation",
|
||||
decision: "REBUILD",
|
||||
capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
|
||||
dependencies: {
|
||||
queries: ["inspectInstallation", "cms_migrations", "worker heartbeat"],
|
||||
mutations: [],
|
||||
},
|
||||
auditRequirement: "NONE",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
}),
|
||||
// Operator alerts and read-only analytics.
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { migrationState, workerState } from "./installation-state";
|
||||
|
||||
it("distinguishes missing, malformed and stale worker evidence", () => {
|
||||
const now = Date.parse("2026-09-09T12:00:00Z");
|
||||
expect(workerState(null, now)).toBe("unknown");
|
||||
expect(workerState("bad", now)).toBe("unknown");
|
||||
expect(workerState("2026-09-09T11:59:30Z", now)).toBe("ok");
|
||||
expect(workerState("2026-09-09T11:55:00Z", now)).toBe("failed");
|
||||
});
|
||||
it("never treats missing migration history as up to date", () => {
|
||||
expect(migrationState(["0026"], null)).toEqual({
|
||||
state: "unknown",
|
||||
pending: null,
|
||||
});
|
||||
expect(migrationState(["0025", "0026"], ["0025"])).toEqual({
|
||||
state: "failed",
|
||||
pending: 1,
|
||||
});
|
||||
expect(migrationState(["0026"], ["0026"])).toEqual({
|
||||
state: "ok",
|
||||
pending: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type DiagnosticState = "ok" | "failed" | "unknown" | "missing";
|
||||
export function workerState(
|
||||
value: string | null,
|
||||
now = Date.now(),
|
||||
): DiagnosticState {
|
||||
if (!value) return "unknown";
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time) || time > now + 60000) return "unknown";
|
||||
return now - time <= 120000 ? "ok" : "failed";
|
||||
}
|
||||
export function migrationState(
|
||||
expected: string[],
|
||||
applied: string[] | null,
|
||||
): { state: DiagnosticState; pending: number | null } {
|
||||
if (!expected.length || applied === null)
|
||||
return { state: "unknown", pending: null };
|
||||
const done = new Set(applied);
|
||||
const pending = expected.filter((name) => !done.has(name)).length;
|
||||
return { state: pending ? "failed" : "ok", pending };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import "server-only";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
type DiagnosticState,
|
||||
migrationState,
|
||||
workerState,
|
||||
} from "@/lib/admin/installation-state";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { db } from "@/lib/db";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function inspectInstallation() {
|
||||
const started = performance.now();
|
||||
const [health, storage, expected, applied, heartbeat, renderer] =
|
||||
await Promise.all([
|
||||
fetchOpsHealth(),
|
||||
Promise.all(
|
||||
["storage", "public/nitro-assets", "public/swf"].map(async (name) => ({
|
||||
name,
|
||||
state: await access(
|
||||
path.join(process.cwd(), name),
|
||||
constants.R_OK | constants.W_OK,
|
||||
)
|
||||
.then(() => "ok" as const)
|
||||
.catch(() => "failed" as const),
|
||||
})),
|
||||
),
|
||||
readdir(path.join(process.cwd(), "drizzle/migrations"))
|
||||
.then((files) =>
|
||||
files
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.map((name) => name.slice(0, -4)),
|
||||
)
|
||||
.catch(() => []),
|
||||
db
|
||||
.execute(sql`SELECT migration FROM cms_migrations`)
|
||||
.then(([rows]) =>
|
||||
(rows as unknown as { migration: string }[]).map(
|
||||
(row) => row.migration,
|
||||
),
|
||||
)
|
||||
.catch(() => null),
|
||||
redis
|
||||
? redis.get("cms:jobs-worker:heartbeat").catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
siteSettings.get("nitro_client_url", "").catch(() => ""),
|
||||
]);
|
||||
const migration = migrationState(expected, applied);
|
||||
const release = process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown";
|
||||
const rows: Array<{ key: string; state: DiagnosticState; detail?: string }> =
|
||||
[
|
||||
{
|
||||
key: "release",
|
||||
state: /^[a-f0-9]{40}$/i.test(release) ? "ok" : "unknown",
|
||||
detail: /^[a-f0-9]{40}$/i.test(release) ? release : undefined,
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
state: health.dbOk ? "ok" : "failed",
|
||||
detail: health.dbOk ? `${health.dbLatencyMs} ms` : undefined,
|
||||
},
|
||||
{
|
||||
key: "redis",
|
||||
state:
|
||||
health.redisOk === null
|
||||
? "missing"
|
||||
: health.redisOk
|
||||
? "ok"
|
||||
: "failed",
|
||||
},
|
||||
{ key: "emulator", state: health.emulatorOk ? "ok" : "failed" },
|
||||
{
|
||||
key: "storage",
|
||||
state: storage.every((item) => item.state === "ok") ? "ok" : "failed",
|
||||
detail:
|
||||
storage
|
||||
.filter((item) => item.state !== "ok")
|
||||
.map((item) => item.name)
|
||||
.join(", ") || undefined,
|
||||
},
|
||||
{
|
||||
key: "migrations",
|
||||
state: migration.state,
|
||||
detail:
|
||||
migration.pending === null ? undefined : String(migration.pending),
|
||||
},
|
||||
{
|
||||
key: "worker",
|
||||
state: workerState(heartbeat),
|
||||
detail:
|
||||
heartbeat && Number.isFinite(Date.parse(heartbeat))
|
||||
? new Date(heartbeat).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "renderer",
|
||||
state: health.dbOk ? "ok" : "unknown",
|
||||
detail: health.dbOk && !renderer ? "/nitro-client/" : undefined,
|
||||
},
|
||||
{ key: "registry", state: "unknown" },
|
||||
];
|
||||
return {
|
||||
rows,
|
||||
checkedAt: new Date().toISOString(),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { collectOperations } from "./operations-inbox";
|
||||
|
||||
it("does not load unauthorized sources and reports failed sources separately", async () => {
|
||||
const denied = vi.fn();
|
||||
const result = await collectOperations([
|
||||
{ source: "errors", allowed: false, load: denied },
|
||||
{
|
||||
source: "imports",
|
||||
allowed: true,
|
||||
load: async () => {
|
||||
throw Error("offline");
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(denied).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ items: [], unavailable: ["imports"] });
|
||||
});
|
||||
it("deduplicates, prioritizes and excludes empty work", async () => {
|
||||
const item = {
|
||||
id: "one",
|
||||
source: "errors" as const,
|
||||
href: "/admin/devops/cms-errors",
|
||||
count: 2,
|
||||
severity: 3,
|
||||
};
|
||||
const result = await collectOperations([
|
||||
{
|
||||
source: "errors",
|
||||
allowed: true,
|
||||
load: async () => [item, item, { ...item, id: "empty", count: 0 }],
|
||||
},
|
||||
]);
|
||||
expect(result.items).toEqual([item]);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type OperationsSource =
|
||||
| "errors"
|
||||
| "imports"
|
||||
| "tickets"
|
||||
| "publications";
|
||||
export interface OperationsItem {
|
||||
id: string;
|
||||
source: OperationsSource;
|
||||
href: string;
|
||||
count: number;
|
||||
severity: number;
|
||||
}
|
||||
export interface OperationsLoader {
|
||||
source: OperationsSource;
|
||||
allowed: boolean;
|
||||
load: () => Promise<OperationsItem[]>;
|
||||
}
|
||||
/** Authorization precedes loading; a failed source is never reported as empty. */
|
||||
export async function collectOperations(loaders: OperationsLoader[]) {
|
||||
const available = loaders.filter((loader) => loader.allowed);
|
||||
const results = await Promise.allSettled(
|
||||
available.map((loader) => loader.load()),
|
||||
);
|
||||
const unique = new Map<string, OperationsItem>();
|
||||
const unavailable: OperationsSource[] = [];
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "rejected") {
|
||||
unavailable.push(available[index].source);
|
||||
continue;
|
||||
}
|
||||
for (const item of result.value) {
|
||||
const key = `${item.source}:${item.id}`;
|
||||
if (item.count > 0 && !unique.has(key)) unique.set(key, item);
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: [...unique.values()]
|
||||
.sort((a, b) => b.severity - a.severity || a.id.localeCompare(b.id))
|
||||
.slice(0, 12),
|
||||
unavailable,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
import { env } from "@/env";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { redis } from "@/lib/redis";
|
||||
@@ -19,7 +20,8 @@ export type OpsHealth = {
|
||||
* Shared DB + Redis + emulator health probe for CommandoCentrum, DevOps, and APIs.
|
||||
* Semantics match `/api/health` for Redis (null when not configured).
|
||||
*/
|
||||
export async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
export const fetchOpsHealth = cache(
|
||||
async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
@@ -55,4 +57,5 @@ export async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
emulatorOk: Boolean(emulatorOk),
|
||||
onlineUsers,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { articleKey, readArticleDraft } from "./article-draft";
|
||||
import { articleEditToken } from "./article-edit-token";
|
||||
|
||||
it("allows incomplete drafts without requiring publication-ready fields", () => {
|
||||
const form = new FormData();
|
||||
form.set("status", "scheduled");
|
||||
expect(readArticleDraft(form)).toMatchObject({
|
||||
title: "",
|
||||
fullStory: "",
|
||||
publishAt: "",
|
||||
status: "scheduled",
|
||||
});
|
||||
});
|
||||
it("rejects unbounded or malformed recovery keys", () => {
|
||||
for (const key of ["", "../1", "0", "-1", "1e5", "1".repeat(21)])
|
||||
expect(() => articleKey(key)).toThrow();
|
||||
expect(articleKey("new")).toBe("new");
|
||||
expect(articleKey("123")).toBe("123");
|
||||
});
|
||||
it("detects body and scheduled instant changes even within the same clock second", () => {
|
||||
const row = {
|
||||
title: "News",
|
||||
slug: "news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "old",
|
||||
status: "scheduled",
|
||||
publishAt: new Date("2030-01-01T00:00:00Z"),
|
||||
};
|
||||
expect(articleEditToken(row)).not.toBe(
|
||||
articleEditToken({ ...row, fullStory: "new" }),
|
||||
);
|
||||
expect(articleEditToken(row)).not.toBe(
|
||||
articleEditToken({ ...row, publishAt: new Date("2030-01-01T01:00:00Z") }),
|
||||
);
|
||||
expect(articleEditToken(row)).toBe(articleEditToken({ ...row }));
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ArticleDraft = {
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
shortStory: string;
|
||||
fullStory: string;
|
||||
status: string;
|
||||
publishAt: string;
|
||||
baseToken: string;
|
||||
};
|
||||
export function readArticleDraft(form: FormData): ArticleDraft {
|
||||
const keys = [
|
||||
"title",
|
||||
"slug",
|
||||
"image",
|
||||
"shortStory",
|
||||
"fullStory",
|
||||
"status",
|
||||
"publishAt",
|
||||
"baseToken",
|
||||
] as const;
|
||||
const result = {} as ArticleDraft;
|
||||
for (const key of keys) {
|
||||
const value = form.get(key) ?? "";
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > (key === "fullStory" ? 500_000 : 255)
|
||||
)
|
||||
throw new Error("draftInvalid");
|
||||
result[key] = value;
|
||||
}
|
||||
if (!["draft", "scheduled", "published"].includes(result.status))
|
||||
throw new Error("draftInvalid");
|
||||
return result;
|
||||
}
|
||||
export function articleKey(value: string): string {
|
||||
if (value === "new" || /^[1-9]\d{0,19}$/.test(value)) return value;
|
||||
throw new Error("draftInvalid");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createHash } from "node:crypto";
|
||||
export function articleEditToken(article: {
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
shortStory: string;
|
||||
fullStory: string;
|
||||
status: string;
|
||||
publishAt: Date | null;
|
||||
}) {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
article.title,
|
||||
article.slug,
|
||||
article.image,
|
||||
article.shortStory,
|
||||
article.fullStory,
|
||||
article.status,
|
||||
article.publishAt?.toISOString() ?? "",
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
@@ -3,6 +3,8 @@ export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
constructor(
|
||||
public readonly code:
|
||||
| "articleNotFound"
|
||||
| "editConflict"
|
||||
| "titleRequired"
|
||||
| "titleTooLong"
|
||||
| "summaryTooLong"
|
||||
|
||||
@@ -78,10 +78,22 @@ describe("deployment transaction", () => {
|
||||
result.calls.indexOf("docker stop"),
|
||||
);
|
||||
expect(result.calls.indexOf("verify-deployed-release.mjs")).toBeLessThan(
|
||||
result.calls.indexOf("docker tag sha256:new epicnext-cms:latest"),
|
||||
);
|
||||
expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan(
|
||||
result.calls.indexOf(
|
||||
`docker tag epicnext-cms:${sha} epicnext-cms:latest`,
|
||||
`docker tag sha256:new epicnext-cms:verified-${sha}`,
|
||||
),
|
||||
);
|
||||
expect(result.calls).toContain(
|
||||
`docker image rm epicnext-cms:verified-${"c".repeat(40)}`,
|
||||
);
|
||||
expect(result.calls).not.toContain(
|
||||
`docker image rm epicnext-cms:verified-${"b".repeat(40)}`,
|
||||
);
|
||||
expect(result.calls).not.toContain(
|
||||
`docker image rm epicnext-cms:verified-${sha}`,
|
||||
);
|
||||
expect(result.calls).toContain(
|
||||
"docker tag sha256:old epicnext-cms:previous",
|
||||
);
|
||||
@@ -97,9 +109,12 @@ describe("deployment transaction", () => {
|
||||
const result = simulate(scenario);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.app).toBe("old");
|
||||
expect(result.calls).not.toContain(
|
||||
`docker tag sha256:new epicnext-cms:verified-${sha}`,
|
||||
);
|
||||
expect(result.output).toContain("Rollback verified: sha256:old");
|
||||
expect(result.calls).not.toContain(
|
||||
`docker tag epicnext-cms:${sha} epicnext-cms:latest`,
|
||||
"docker tag sha256:new epicnext-cms:latest",
|
||||
);
|
||||
},
|
||||
30000,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { errorOperationLink, summarizeErrorGroup } from "./error-groups";
|
||||
import { createErrorRecord } from "./error-monitor";
|
||||
|
||||
it("counts retained occurrences and identified users across releases without trusting browser identities", () => {
|
||||
const base = createErrorRecord("server", "test", "failed", { userId: 12 });
|
||||
const first = { ...base, at: "2026-09-08T10:00:00Z", release: "a" };
|
||||
const latest = { ...base, at: "2026-09-09T10:00:00Z", release: "b" };
|
||||
const browser = {
|
||||
...latest,
|
||||
source: "browser" as const,
|
||||
context: { userId: 99 },
|
||||
};
|
||||
const summary = summarizeErrorGroup([
|
||||
latest,
|
||||
browser,
|
||||
first,
|
||||
{ ...latest, context: { userId: "12" } },
|
||||
{ ...latest, context: { email: "redacted", userId: "[REDACTED]" } },
|
||||
]);
|
||||
expect(summary.count).toBe(5);
|
||||
expect(summary.firstAt).toBe(first.at);
|
||||
expect(summary.lastAt).toBe(latest.at);
|
||||
expect(summary.affectedUsers).toBe(1);
|
||||
expect(summary.unidentified).toBe(2);
|
||||
expect(summary.releases.map((r) => [r.release, r.count])).toEqual([
|
||||
["a", 1],
|
||||
["b", 4],
|
||||
]);
|
||||
});
|
||||
it("creates only recognized local operation links", () => {
|
||||
const base = createErrorRecord("server", "test", "failed", {
|
||||
module: "news",
|
||||
articleId: 7,
|
||||
});
|
||||
expect(errorOperationLink(base)).toBe("/admin/articles/7");
|
||||
expect(
|
||||
errorOperationLink({
|
||||
...base,
|
||||
context: { module: "news", articleId: "../settings" },
|
||||
}),
|
||||
).toBe("/admin/articles");
|
||||
expect(
|
||||
errorOperationLink({
|
||||
...base,
|
||||
source: "browser",
|
||||
context: { path: "/admin/media" },
|
||||
}),
|
||||
).toBe("/admin/media");
|
||||
for (const path of [
|
||||
"https://evil.invalid",
|
||||
"//evil.invalid",
|
||||
"/admin/articles/1?token=x",
|
||||
"/admin/articles/../settings",
|
||||
"/admin/users/42",
|
||||
"javascript:alert(1)",
|
||||
"/admin/%61rticles",
|
||||
])
|
||||
expect(errorOperationLink({ ...base, context: { path } })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CmsErrorRecord } from "./error-monitor";
|
||||
|
||||
export function errorOperationLink(record: CmsErrorRecord): string | null {
|
||||
const context = record.context;
|
||||
if (record.source === "server") {
|
||||
if (context.module === "news") {
|
||||
const id = String(context.articleId ?? "");
|
||||
return /^[1-9]\d{0,18}$/.test(id)
|
||||
? `/admin/articles/${id}`
|
||||
: "/admin/articles";
|
||||
}
|
||||
const routes: Record<string, string> = {
|
||||
catalog: "/admin/catalog",
|
||||
media: "/admin/media",
|
||||
users: "/admin/users",
|
||||
settings: "/admin/settings",
|
||||
studio: "/admin/studio",
|
||||
};
|
||||
if (
|
||||
typeof context.module === "string" &&
|
||||
Object.hasOwn(routes, context.module)
|
||||
)
|
||||
return routes[context.module];
|
||||
}
|
||||
// Never use arbitrary error-provided URLs as navigation targets.
|
||||
const candidate = context.path;
|
||||
if (typeof candidate !== "string") return null;
|
||||
if (
|
||||
/^\/admin\/(articles|users|catalog|media|settings|studio)$/.test(candidate)
|
||||
)
|
||||
return candidate;
|
||||
if (/^\/admin\/articles\/[1-9]\d{0,18}$/.test(candidate)) return candidate;
|
||||
return null;
|
||||
}
|
||||
export function summarizeErrorGroup(events: CmsErrorRecord[]) {
|
||||
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
|
||||
const first = sorted[0];
|
||||
const latest = sorted.at(-1);
|
||||
const users = new Set<string>();
|
||||
let unidentified = 0;
|
||||
const releases = new Map<
|
||||
string,
|
||||
{ release: string; count: number; firstAt: string; lastAt: string }
|
||||
>();
|
||||
for (const event of sorted) {
|
||||
const raw = event.source === "server" ? event.context.userId : null;
|
||||
const id =
|
||||
typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0
|
||||
? String(raw)
|
||||
: typeof raw === "string" && /^[1-9]\d{0,14}$/.test(raw)
|
||||
? raw
|
||||
: null;
|
||||
if (id) users.add(id);
|
||||
else unidentified++;
|
||||
const prior = releases.get(event.release);
|
||||
releases.set(event.release, {
|
||||
release: event.release,
|
||||
count: (prior?.count ?? 0) + 1,
|
||||
firstAt: prior?.firstAt ?? event.at,
|
||||
lastAt: event.at,
|
||||
});
|
||||
}
|
||||
return {
|
||||
count: events.length,
|
||||
firstAt: first?.at ?? null,
|
||||
lastAt: latest?.at ?? null,
|
||||
affectedUsers: users.size,
|
||||
unidentified,
|
||||
releases: [...releases.values()],
|
||||
latest,
|
||||
operationLink: latest ? errorOperationLink(latest) : null,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
@@ -54,3 +54,50 @@ it("resolves a group and reopens it when a later occurrence arrives", async () =
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists assignments across instances and clears them without modifying resolution", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||
try {
|
||||
const store = new ErrorStore(dir);
|
||||
const record = createErrorRecord("server", "assign", "failed");
|
||||
await store.append(record);
|
||||
await store.resolve(record.fingerprint);
|
||||
await store.assign(record.fingerprint, 42);
|
||||
const persisted = (await new ErrorStore(dir).read()).records[0];
|
||||
expect(persisted.assignment?.userId).toBe(42);
|
||||
expect(persisted.resolved).toBe(true);
|
||||
await new ErrorStore(dir).assign(record.fingerprint, null);
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
await expect(store.assign("../escape", 42)).rejects.toThrow(
|
||||
"Invalid fingerprint",
|
||||
);
|
||||
await expect(store.assign(record.fingerprint, -1)).rejects.toThrow(
|
||||
"Invalid assignee",
|
||||
);
|
||||
await expect(store.assign("0000000000000000", 42)).rejects.toThrow(
|
||||
"no longer available",
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("ignores expired assignment metadata and prunes it with error retention", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||
try {
|
||||
const record = createErrorRecord("server", "retention", "failed");
|
||||
const expired = `2000-01-01.${record.fingerprint}.assignment`;
|
||||
await writeFile(
|
||||
path.join(dir, expired),
|
||||
JSON.stringify({ userId: 99, at: "2000-01-01T00:00:00Z" }),
|
||||
);
|
||||
const store = new ErrorStore(dir);
|
||||
await store.append(record);
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
expect(await readdir(dir)).not.toContain(expired);
|
||||
const current = `${record.at.slice(0, 10)}.${record.fingerprint}.assignment`;
|
||||
await writeFile(path.join(dir, current), "x".repeat(513));
|
||||
expect((await store.read()).records[0].assignment).toBeNull();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
@@ -27,6 +28,7 @@ export function redactErrorText(value: string): string {
|
||||
export interface CmsErrorRecord {
|
||||
id: string;
|
||||
resolved?: boolean;
|
||||
assignment?: { userId: number; at: string } | null;
|
||||
at: string;
|
||||
source: "server" | "browser";
|
||||
event: string;
|
||||
@@ -111,7 +113,9 @@ export class ErrorStore {
|
||||
.slice(0, 10);
|
||||
for (const name of files)
|
||||
if (
|
||||
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) &&
|
||||
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.(?:resolved|assignment)(?:\.[a-f0-9-]+\.tmp)?)$/.test(
|
||||
name,
|
||||
) &&
|
||||
name.slice(0, 10) < cutoff
|
||||
)
|
||||
await unlink(path.join(this.directory, name)).catch(() => {});
|
||||
@@ -135,6 +139,43 @@ export class ErrorStore {
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
async assign(fingerprint: string, userId: number | null) {
|
||||
if (!/^[a-f0-9]{16}$/.test(fingerprint))
|
||||
throw new Error("Invalid fingerprint");
|
||||
if (userId !== null && (!Number.isSafeInteger(userId) || userId <= 0))
|
||||
throw new Error("Invalid assignee");
|
||||
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
|
||||
ErrorStore.pending++;
|
||||
const write = ErrorStore.queue.then(async () => {
|
||||
const { records } = await this.read();
|
||||
if (!records.some((record) => record.fingerprint === fingerprint))
|
||||
throw new Error("Error group no longer available");
|
||||
const at = new Date().toISOString();
|
||||
const file = path.join(
|
||||
this.directory,
|
||||
`${at.slice(0, 10)}.${fingerprint}.assignment`,
|
||||
);
|
||||
const metadata = (await readdir(this.directory)).filter(
|
||||
(name) =>
|
||||
name.startsWith(at.slice(0, 10)) && name.endsWith(".assignment"),
|
||||
);
|
||||
if (metadata.length >= 1000 && !metadata.includes(path.basename(file)))
|
||||
throw new Error("Daily assignment storage limit reached");
|
||||
const temp = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(temp, JSON.stringify({ userId, at }), { mode: 0o600 });
|
||||
await rename(temp, file);
|
||||
} finally {
|
||||
await unlink(temp).catch(() => {});
|
||||
}
|
||||
});
|
||||
ErrorStore.queue = write.catch(() => {});
|
||||
try {
|
||||
await write;
|
||||
} finally {
|
||||
ErrorStore.pending--;
|
||||
}
|
||||
}
|
||||
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
|
||||
const files = await readdir(this.directory).catch(
|
||||
(e: NodeJS.ErrnoException) => {
|
||||
@@ -142,17 +183,48 @@ export class ErrorStore {
|
||||
throw e;
|
||||
},
|
||||
);
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const assignments = new Map<string, CmsErrorRecord["assignment"]>();
|
||||
for (const name of files
|
||||
.filter(
|
||||
(f) =>
|
||||
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.assignment$/.test(f) &&
|
||||
f.slice(0, 10) >= cutoff,
|
||||
)
|
||||
.sort()) {
|
||||
const file = path.join(this.directory, name);
|
||||
if ((await stat(file)).size > 512) continue;
|
||||
try {
|
||||
const value = JSON.parse(await readFile(file, "utf8"));
|
||||
if (
|
||||
typeof value.at !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.at))
|
||||
)
|
||||
continue;
|
||||
if (value.userId === null) assignments.set(name.split(".")[1], null);
|
||||
else if (Number.isSafeInteger(value.userId) && value.userId > 0)
|
||||
assignments.set(name.split(".")[1], {
|
||||
userId: value.userId,
|
||||
at: value.at,
|
||||
});
|
||||
} catch {
|
||||
/* Ignore interrupted or malformed metadata. */
|
||||
}
|
||||
}
|
||||
const resolved = new Map<string, string>();
|
||||
for (const name of files
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f))
|
||||
.filter(
|
||||
(f) =>
|
||||
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f) &&
|
||||
f.slice(0, 10) >= cutoff,
|
||||
)
|
||||
.sort()) {
|
||||
const at = await readFile(path.join(this.directory, name), "utf8");
|
||||
resolved.set(name.split(".")[1], at);
|
||||
}
|
||||
const records: CmsErrorRecord[] = [];
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
for (const name of files
|
||||
.filter(
|
||||
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
|
||||
@@ -169,6 +241,7 @@ export class ErrorStore {
|
||||
records.push({
|
||||
...r,
|
||||
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
|
||||
assignment: assignments.get(r.fingerprint) ?? null,
|
||||
});
|
||||
} catch {
|
||||
/* Ignore an interrupted final write. */
|
||||
|
||||
@@ -5,7 +5,14 @@ import { retryableJobItems } from "./import-job-retry";
|
||||
const job = (state: ImportJob["state"]) =>
|
||||
({
|
||||
state,
|
||||
items: ["done", "failed", "pending", "running"].map((state) => ({
|
||||
items: [
|
||||
"done",
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
].map((state) => ({
|
||||
state,
|
||||
classname: state,
|
||||
})),
|
||||
@@ -14,12 +21,33 @@ it("only retries failures after a completed job", () =>
|
||||
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
]));
|
||||
it("recovers all unfinished items from an interrupted job", () =>
|
||||
it("recovers only safe unfinished items from an interrupted job", () =>
|
||||
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
]));
|
||||
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
|
||||
expect(retryableJobItems(job(state))).toEqual([]),
|
||||
);
|
||||
|
||||
it("retries cancelled remaining work without redoing completed or uncertain items", () => {
|
||||
expect(retryableJobItems(job("cancelled")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"cancelled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not blindly retry legacy interrupted mutations stored as failures", () => {
|
||||
const interrupted = job("interrupted");
|
||||
interrupted.items = [
|
||||
{
|
||||
...interrupted.items[0],
|
||||
state: "failed",
|
||||
error:
|
||||
"Server restarted during import. Review local data and retry to complete missing parts.",
|
||||
},
|
||||
];
|
||||
expect(retryableJobItems(interrupted)).toEqual([]);
|
||||
});
|
||||
@@ -4,7 +4,9 @@ export function retryableJobItems(job: ImportJob): ImportJob["items"] {
|
||||
if (job.state === "running" || job.state === "queued") return [];
|
||||
return job.items.filter(
|
||||
(item) =>
|
||||
item.state === "failed" ||
|
||||
(job.state === "interrupted" && item.state !== "done"),
|
||||
(item.state === "failed" &&
|
||||
!item.error?.startsWith("Server restarted during import.")) ||
|
||||
((job.state === "interrupted" || job.state === "cancelled") &&
|
||||
(item.state === "pending" || item.state === "cancelled")),
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,20 @@ export interface ImportJob {
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
state: "queued" | "running" | "completed" | "interrupted";
|
||||
state: "queued" | "running" | "completed" | "interrupted" | "cancelled";
|
||||
cancelRequested?: boolean;
|
||||
sourceId?: string;
|
||||
translate: boolean;
|
||||
langs?: string[];
|
||||
items: Array<
|
||||
ImportJobItem & {
|
||||
state: "pending" | "running" | "done" | "failed";
|
||||
state:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "interrupted";
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
itemId?: number;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { furnitureCompleteness } from "./studio-completeness";
|
||||
import {
|
||||
compareFurniture,
|
||||
type FurnitureInspection,
|
||||
} from "./studio-inspection";
|
||||
|
||||
const complete: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [{ id: 100, spriteId: 200, type: "s", name: "Chair" }],
|
||||
catalog: [{ id: 50, pageId: 1, credits: 3, points: 0 }],
|
||||
furnidata: [
|
||||
{ id: 200, type: "flooritem", name: "Chair", description: "", revision: 1 },
|
||||
],
|
||||
furnidataReadable: true,
|
||||
nitro: { exists: true, bytes: 200 },
|
||||
icon: { exists: true, bytes: 30 },
|
||||
};
|
||||
describe("furniture completeness", () => {
|
||||
it("checks all five independent parts and accepts locally remapped IDs", () => {
|
||||
expect(furnitureCompleteness(complete).map((p) => p.status)).toEqual(
|
||||
Array(5).fill("present"),
|
||||
);
|
||||
});
|
||||
it("does not infer a catalog offer or icon from SQL and Nitro", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [],
|
||||
icon: { exists: false, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "catalog")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "catalog",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "icon",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "sql")?.status).toBe("present");
|
||||
});
|
||||
it("keeps unreadable assets unknown and directs operators to recheck", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
furnidataReadable: false,
|
||||
icon: { exists: null, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "furnidata")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ id: 201, type: "flooritem" },
|
||||
{ id: 200, type: "wallitem" },
|
||||
])(
|
||||
"flags ID or type mismatches in both review and completeness: %j",
|
||||
(change) => {
|
||||
const local = {
|
||||
...complete,
|
||||
furnidata: [{ ...complete.furnidata[0], ...change }],
|
||||
};
|
||||
expect(
|
||||
furnitureCompleteness(local)
|
||||
.filter((p) => p.status === "conflict")
|
||||
.map((p) => p.id),
|
||||
).toEqual(["sql", "furnidata"]);
|
||||
expect(
|
||||
compareFurniture(
|
||||
{ name: "Chair", description: "", revision: 1, type: "flooritem" },
|
||||
local,
|
||||
).state,
|
||||
).toBe("conflict");
|
||||
},
|
||||
);
|
||||
it("allows multiple legitimate catalog offers but surfaces duplicate SQL records", () => {
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [...complete.catalog, { ...complete.catalog[0], id: 51 }],
|
||||
}).find((p) => p.id === "catalog")?.status,
|
||||
).toBe("present");
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
sql: [...complete.sql, { ...complete.sql[0], id: 101 }],
|
||||
}).find((p) => p.id === "sql"),
|
||||
).toMatchObject({ status: "conflict", action: "audit" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
type FurnitureInspection,
|
||||
hasFurnitureConflict,
|
||||
} from "./studio-inspection";
|
||||
export type FurniturePartId =
|
||||
| "sql"
|
||||
| "catalog"
|
||||
| "furnidata"
|
||||
| "icon"
|
||||
| "nitro";
|
||||
export interface FurniturePart {
|
||||
id: FurniturePartId;
|
||||
status: "present" | "missing" | "unknown" | "conflict";
|
||||
action: FurniturePartId | "recheck" | "audit" | null;
|
||||
}
|
||||
/** Report each required part without treating unavailable checks as missing data. */
|
||||
export function furnitureCompleteness(
|
||||
local: FurnitureInspection,
|
||||
): FurniturePart[] {
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const evidence: Array<[FurniturePartId, boolean | null]> = [
|
||||
["sql", local.sql.length > 0],
|
||||
["catalog", local.catalog.length > 0],
|
||||
["furnidata", local.furnidataReadable ? local.furnidata.length > 0 : null],
|
||||
["icon", local.icon.exists],
|
||||
["nitro", local.nitro.exists],
|
||||
];
|
||||
return evidence.map(([id, present]) => {
|
||||
if (conflict && (id === "sql" || id === "furnidata"))
|
||||
return { id, status: "conflict", action: "audit" };
|
||||
if (present === null) return { id, status: "unknown", action: "recheck" };
|
||||
return {
|
||||
id,
|
||||
status: present ? "present" : "missing",
|
||||
action: present ? null : id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -56,8 +56,7 @@ export function compareFurniture(
|
||||
after !== undefined &&
|
||||
String(before) !== String(after),
|
||||
}));
|
||||
const state =
|
||||
local.sql.length > 1 || local.furnidata.length > 1
|
||||
const state = hasFurnitureConflict(local)
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
@@ -76,17 +75,7 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
missing.push("Furnidata");
|
||||
if (!local.sql.length) missing.push("SQL item");
|
||||
if (!local.catalog.length) missing.push("Catalog offer");
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
const conflict =
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
);
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const unknown =
|
||||
!local.furnidataReadable ||
|
||||
local.nitro.exists === null ||
|
||||
@@ -102,3 +91,18 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
: "incomplete";
|
||||
return { state, missing } as const;
|
||||
}
|
||||
|
||||
export function hasFurnitureConflict(local: FurnitureInspection): boolean {
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
return (
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const bash =
|
||||
process.platform === "win32"
|
||||
? ((process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.flatMap((dir) => [
|
||||
join(dir, "bash.exe"),
|
||||
join(dirname(dir), "bin", "bash.exe"),
|
||||
join(dirname(dirname(dir)), "bin", "bash.exe"),
|
||||
])
|
||||
.find((path) => existsSync(path)) ?? "bash")
|
||||
: "bash";
|
||||
const sha = "a".repeat(40);
|
||||
function simulate(scenario: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-publish-test-"));
|
||||
try {
|
||||
const result = spawnSync(
|
||||
bash,
|
||||
[resolve(root, "scripts/publish-container.sh")],
|
||||
{
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASH_ENV: resolve(root, "src/test/publish-container-harness.sh"),
|
||||
TEST_DIR: dir.replaceAll("\\", "/"),
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
REGISTRY_SERVER: "https://registry.invalid",
|
||||
REGISTRY_REPOSITORY: "owner/cms",
|
||||
REGISTRY_USER: "fixture",
|
||||
REGISTRY_TOKEN: "fixture-only",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
return readFileSync(join(dir, "calls"), "utf8");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
describe("verified application image reuse", () => {
|
||||
it("reuses only the exact image digest that passed deployment checks", () => {
|
||||
const calls = simulate("verified");
|
||||
expect(calls).toContain(
|
||||
`docker tag sha256:candidate registry.invalid/owner/cms:${sha}`,
|
||||
);
|
||||
expect(calls).not.toContain("docker build --network=host --build-arg");
|
||||
expect(
|
||||
calls.indexOf("verify scripts/verify-portable-image.mjs"),
|
||||
).toBeLessThan(calls.indexOf("docker push"));
|
||||
});
|
||||
it.each(["missing", "mismatch"])(
|
||||
"builds committed source when verification marker is %s",
|
||||
(scenario) => {
|
||||
const calls = simulate(scenario);
|
||||
expect(calls).toContain("docker build --network=host --build-arg");
|
||||
expect(calls).not.toContain("docker tag sha256:candidate");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
total: 55,
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (sql, params) => {
|
||||
state.queries.push({ sql, params });
|
||||
return { rows: sql.includes("count(*)") ? [[state.total]] : [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadArticleList } from "./article-list";
|
||||
|
||||
describe("article listing", () => {
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
state.total = 55;
|
||||
});
|
||||
it("filters drafts before pagination and binds search text", async () => {
|
||||
const result = await loadArticleList({
|
||||
status: "draft",
|
||||
search: "needle'",
|
||||
page: 2,
|
||||
});
|
||||
expect(result.page).toBe(2);
|
||||
for (const query of state.queries) {
|
||||
expect(query.sql).toContain("`website_articles`.`status` = ?");
|
||||
expect(query.sql).not.toContain("needle");
|
||||
expect(query.params.slice(0, 3)).toEqual([
|
||||
"draft",
|
||||
"%needle'%",
|
||||
"%needle'%",
|
||||
]);
|
||||
}
|
||||
expect(state.queries[1].params.slice(-2)).toEqual([24, 24]);
|
||||
});
|
||||
it("clamps an out-of-range page to the last filtered page", async () => {
|
||||
const result = await loadArticleList({ status: "scheduled", page: 999 });
|
||||
expect(result).toMatchObject({ page: 3, lastPage: 3, total: 55 });
|
||||
expect(state.queries[1].params.slice(-2)).toEqual([24, 48]);
|
||||
});
|
||||
it("bounds invalid input and keeps an empty collection on page one", async () => {
|
||||
state.total = 0;
|
||||
const result = await loadArticleList({ status: "unexpected", page: NaN });
|
||||
expect(result).toMatchObject({
|
||||
status: "all",
|
||||
page: 1,
|
||||
lastPage: 1,
|
||||
total: 0,
|
||||
});
|
||||
expect(state.queries[1].sql).not.toContain(
|
||||
"`website_articles`.`status` = ?",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { and, count, desc, eq, like, or } from "drizzle-orm";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
export async function loadArticleList(
|
||||
options: { status?: string; search?: string; page?: number } = {},
|
||||
) {
|
||||
const status = ["draft", "published", "scheduled"].includes(
|
||||
options.status ?? "",
|
||||
)
|
||||
? options.status
|
||||
: "all";
|
||||
const search = (options.search ?? "").trim().slice(0, 191);
|
||||
const where = and(
|
||||
status !== "all" ? eq(WebsiteArticles.status, status ?? "all") : undefined,
|
||||
search
|
||||
? or(
|
||||
like(WebsiteArticles.title, `%${search}%`),
|
||||
like(WebsiteArticles.shortStory, `%${search}%`),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
const totals = await db
|
||||
.select({ value: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(where);
|
||||
const total = Number(totals[0]?.value ?? 0);
|
||||
const perPage = 24;
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||
const page = Math.min(
|
||||
lastPage,
|
||||
Number.isFinite(options.page)
|
||||
? Math.max(1, Math.trunc(options.page ?? 1))
|
||||
: 1,
|
||||
);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
status: WebsiteArticles.status,
|
||||
author: User.username,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.leftJoin(User, eq(User.id, WebsiteArticles.userId))
|
||||
.where(where)
|
||||
.orderBy(desc(WebsiteArticles.createdAt), desc(WebsiteArticles.id))
|
||||
.limit(perPage)
|
||||
.offset((page - 1) * perPage);
|
||||
return { rows, page, perPage, total, lastPage, status, search };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
type Change = { key: string; from: unknown; to: unknown };
|
||||
const sensitive =
|
||||
/password|secret|token|otp|recovery|authTicket|two_factor|api_key/i;
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 10) return "[…]";
|
||||
if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
|
||||
if (!record(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
sensitive.test(key) ? "[Redacted]" : redact(item, depth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
function parse(value: string) {
|
||||
if (value.length > 100000) throw new Error("oversize");
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!record(parsed)) throw new Error("invalid");
|
||||
return parsed;
|
||||
}
|
||||
export function readAuditChanges(
|
||||
diff: string | null,
|
||||
before?: string | null,
|
||||
after?: string | null,
|
||||
) {
|
||||
try {
|
||||
let changes: Change[];
|
||||
if (diff) {
|
||||
const parsed = parse(diff);
|
||||
changes = Object.entries(parsed).map(([key, value]) => {
|
||||
if (!record(value) || !("from" in value || "to" in value))
|
||||
throw new Error("invalid");
|
||||
return { key, from: value.from, to: value.to };
|
||||
});
|
||||
} else {
|
||||
const previous = before ? parse(before) : {};
|
||||
const next = after ? parse(after) : {};
|
||||
changes = [...new Set([...Object.keys(previous), ...Object.keys(next)])]
|
||||
.filter(
|
||||
(key) => JSON.stringify(previous[key]) !== JSON.stringify(next[key]),
|
||||
)
|
||||
.map((key) => ({ key, from: previous[key], to: next[key] }));
|
||||
}
|
||||
return {
|
||||
invalid: false,
|
||||
changes: changes.map(({ key, from, to }) => ({
|
||||
key,
|
||||
from: sensitive.test(key) ? "[Redacted]" : redact(from),
|
||||
to: sensitive.test(key) ? "[Redacted]" : redact(to),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { invalid: true, changes: [] as Change[] };
|
||||
}
|
||||
}
|
||||
export function formatAuditValue(value: unknown) {
|
||||
const text =
|
||||
value === undefined
|
||||
? "∅"
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2);
|
||||
return text.length > 4000 ? `${text.slice(0, 4000)}…` : text;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface AuditFilters {
|
||||
search?: string;
|
||||
actor?: string;
|
||||
action?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
function day(value?: string) {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return "";
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isFinite(date.getTime()) &&
|
||||
date.toISOString().slice(0, 10) === value
|
||||
? value
|
||||
: "";
|
||||
}
|
||||
export function normalizeAuditFilters(options: AuditFilters = {}) {
|
||||
const from = day(options.from);
|
||||
const to = day(options.to);
|
||||
return {
|
||||
search: (options.search ?? "").trim().slice(0, 191),
|
||||
actor: (options.actor ?? "").trim().slice(0, 191),
|
||||
action: (options.action ?? "").trim().slice(0, 191),
|
||||
from,
|
||||
to,
|
||||
until: to
|
||||
? new Date(new Date(`${to}T00:00:00Z`).getTime() + 86400000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
: "",
|
||||
page: Number.isFinite(options.page)
|
||||
? Math.min(1000000, Math.max(1, Math.trunc(options.page ?? 1)))
|
||||
: 1,
|
||||
perPage: Number.isFinite(options.perPage)
|
||||
? Math.min(100, Math.max(1, Math.trunc(options.perPage ?? 20)))
|
||||
: 20,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (sql, params) => {
|
||||
state.queries.push({ sql, params });
|
||||
return { rows: [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { getAuditLogs } from "./audit";
|
||||
|
||||
describe("audit query filters", () => {
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
});
|
||||
it("binds actor/action/date/search values in both list and count queries", async () => {
|
||||
await getAuditLogs({
|
||||
actor: "Alice' OR 1=1 --",
|
||||
action: "update",
|
||||
search: "user",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-09",
|
||||
perPage: 10,
|
||||
page: 2,
|
||||
});
|
||||
expect(state.queries).toHaveLength(2);
|
||||
for (const query of state.queries) {
|
||||
expect(query.sql).not.toContain("Alice");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`user_id` in (select");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`created_at` >= ?");
|
||||
expect(query.sql).toContain("`admin_audit_log`.`created_at` < ?");
|
||||
expect(query.params.slice(0, 6)).toEqual([
|
||||
"%user%",
|
||||
"%user%",
|
||||
"update",
|
||||
"Alice' OR 1=1 --",
|
||||
"2026-09-09",
|
||||
"2026-09-10",
|
||||
]);
|
||||
}
|
||||
expect(state.queries[0].params.slice(-2)).toEqual([10, 10]);
|
||||
});
|
||||
it("uses an exact numeric actor ID and caps row count", async () => {
|
||||
await getAuditLogs({ actor: "42", perPage: 1000, page: -2 });
|
||||
expect(state.queries[0].params).toEqual([42, 100]);
|
||||
expect(state.queries[0].sql).not.toContain("in (select");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditValue, readAuditChanges } from "./audit-diff";
|
||||
import { normalizeAuditFilters } from "./audit-filters";
|
||||
|
||||
describe("audit filter bounds", () => {
|
||||
it("normalizes pagination and rejects invalid calendar dates", () => {
|
||||
expect(
|
||||
normalizeAuditFilters({
|
||||
page: NaN,
|
||||
perPage: Infinity,
|
||||
from: "2026-02-30",
|
||||
to: "x",
|
||||
}),
|
||||
).toMatchObject({ page: 1, perPage: 20, from: "", to: "" });
|
||||
expect(normalizeAuditFilters({ page: -3, perPage: 1000 })).toMatchObject({
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
});
|
||||
it("caps strings and preserves exact actor/action and UTC day boundaries", () => {
|
||||
expect(
|
||||
normalizeAuditFilters({
|
||||
actor: " Alice ",
|
||||
action: " update ",
|
||||
from: "2026-09-09",
|
||||
to: "2026-09-09",
|
||||
}),
|
||||
).toMatchObject({
|
||||
actor: "Alice",
|
||||
action: "update",
|
||||
from: "2026-09-09",
|
||||
until: "2026-09-10",
|
||||
});
|
||||
expect(
|
||||
normalizeAuditFilters({ search: "x".repeat(500) }).search,
|
||||
).toHaveLength(191);
|
||||
});
|
||||
});
|
||||
describe("legacy audit diffs", () => {
|
||||
it.each(["null", "[]", "1", "bad JSON", '{"name":null}', '{"name":"value"}'])(
|
||||
"handles %s safely",
|
||||
(diff) => {
|
||||
expect(readAuditChanges(diff).invalid).toBe(true);
|
||||
},
|
||||
);
|
||||
it("keeps objects and explicit null distinct from absent values", () => {
|
||||
const result = readAuditChanges(
|
||||
'{"settings":{"from":{"enabled":false},"to":null},"added":{"to":3}}',
|
||||
);
|
||||
expect(result.changes).toHaveLength(2);
|
||||
expect(formatAuditValue(result.changes[0].from)).toContain(
|
||||
'"enabled": false',
|
||||
);
|
||||
expect(formatAuditValue(result.changes[0].to)).toBe("null");
|
||||
expect(formatAuditValue(result.changes[1].from)).toBe("∅");
|
||||
});
|
||||
it("shows create/delete snapshots when a legacy record has no diff", () => {
|
||||
expect(readAuditChanges(null, null, '{"name":"New"}').changes).toEqual([
|
||||
{ key: "name", from: undefined, to: "New" },
|
||||
]);
|
||||
});
|
||||
it("redacts historical sensitive nested fields before rendering", () => {
|
||||
expect(
|
||||
formatAuditValue(
|
||||
readAuditChanges('{"settings":{"from":{"password":"secret"},"to":{}}}')
|
||||
.changes[0].from,
|
||||
),
|
||||
).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -187,3 +187,24 @@ describe("getAuditLogs", () => {
|
||||
expect(result.rows[0].username).toBe("User #99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit response privacy", () => {
|
||||
it("does not serialize legacy snapshot secrets to the client", async () => {
|
||||
selectRows.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
diff: null,
|
||||
before: null,
|
||||
after: '{"name":"Alice","password":"secret-value"}',
|
||||
},
|
||||
]);
|
||||
selectCount.mockResolvedValue([{ value: 1 }]);
|
||||
selectUsers.mockResolvedValue([]);
|
||||
const result = await getAuditLogs();
|
||||
expect(JSON.stringify(result.rows)).not.toContain("secret-value");
|
||||
expect(result.rows[0].before).toBeNull();
|
||||
expect(result.rows[0].after).toBeNull();
|
||||
expect(result.rows[0].diff).toContain("Alice");
|
||||
});
|
||||
});
|
||||
+43
-15
@@ -1,5 +1,7 @@
|
||||
import { count, desc, inArray, like, or } from "drizzle-orm";
|
||||
import { and, count, desc, eq, gte, inArray, like, lt, or } from "drizzle-orm";
|
||||
import { AdminAuditLog, db, User } from "@/lib/db";
|
||||
import { readAuditChanges } from "./audit-diff";
|
||||
import { type AuditFilters, normalizeAuditFilters } from "./audit-filters";
|
||||
|
||||
interface AuditEntry {
|
||||
userId: number;
|
||||
@@ -68,23 +70,32 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
interface GetLogsOptions {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
const { search, page = 1, perPage = 20 } = options;
|
||||
export async function getAuditLogs(options: AuditFilters = {}) {
|
||||
const { search, actor, action, from, until, page, perPage } =
|
||||
normalizeAuditFilters(options);
|
||||
const skip = (page - 1) * perPage;
|
||||
|
||||
const where = search
|
||||
const where = and(
|
||||
search
|
||||
? or(
|
||||
like(AdminAuditLog.action, `%${search}%`),
|
||||
like(AdminAuditLog.target, `%${search}%`),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
: undefined,
|
||||
action ? eq(AdminAuditLog.action, action) : undefined,
|
||||
actor
|
||||
? /^[1-9]\d*$/.test(actor) && Number.isSafeInteger(Number(actor))
|
||||
? eq(AdminAuditLog.userId, Number(actor))
|
||||
: inArray(
|
||||
AdminAuditLog.userId,
|
||||
db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.username, actor)),
|
||||
)
|
||||
: undefined,
|
||||
from ? gte(AdminAuditLog.createdAt, from) : undefined,
|
||||
until ? lt(AdminAuditLog.createdAt, until) : undefined,
|
||||
);
|
||||
const [rows, totalResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
@@ -94,6 +105,8 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
target: AdminAuditLog.target,
|
||||
targetId: AdminAuditLog.targetId,
|
||||
diff: AdminAuditLog.diff,
|
||||
before: AdminAuditLog.before,
|
||||
after: AdminAuditLog.after,
|
||||
createdAt: AdminAuditLog.createdAt,
|
||||
})
|
||||
.from(AdminAuditLog)
|
||||
@@ -116,10 +129,25 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
|
||||
: [];
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const enrichedRows = rows.map((r) => ({
|
||||
const enrichedRows = rows.map((r) => {
|
||||
const details = readAuditChanges(r.diff, r.before, r.after);
|
||||
return {
|
||||
...r,
|
||||
// Only sanitized changes may cross the server/client boundary.
|
||||
before: null,
|
||||
after: null,
|
||||
diff: details.invalid
|
||||
? "[Unavailable legacy details]"
|
||||
: details.changes.length
|
||||
? JSON.stringify(
|
||||
Object.fromEntries(
|
||||
details.changes.map(({ key, from, to }) => [key, { from, to }]),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
username: userMap.get(r.userId) ?? `User #${r.userId}`,
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: enrichedRows,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDashboardEventPhase } from "./dashboard-event-phase";
|
||||
|
||||
const now = new Date("2026-09-09T12:00:00Z");
|
||||
const event = (
|
||||
startsAt: string,
|
||||
endsAt: string | null = null,
|
||||
status = "published",
|
||||
) => ({
|
||||
startsAt: new Date(startsAt),
|
||||
endsAt: endsAt ? new Date(endsAt) : null,
|
||||
status,
|
||||
});
|
||||
|
||||
describe("dashboard event timing", () => {
|
||||
it("shows a future published event without requiring an end", () => {
|
||||
expect(getDashboardEventPhase(event("2026-09-09T13:00:00Z"), now)).toBe(
|
||||
"upcoming",
|
||||
);
|
||||
});
|
||||
it("marks the start boundary and known duration as ongoing", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T12:00:00Z", "2026-09-09T13:00:00Z"),
|
||||
now,
|
||||
),
|
||||
).toBe("ongoing");
|
||||
});
|
||||
it("excludes ended events including the exact end boundary", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T11:00:00Z", "2026-09-09T12:00:00Z"),
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
it("does not invent a duration for old events without an end", () => {
|
||||
expect(
|
||||
getDashboardEventPhase(event("2026-09-08T12:00:00Z"), now),
|
||||
).toBeNull();
|
||||
});
|
||||
it.each(["draft", "cancelled", "completed"])(
|
||||
"excludes %s events",
|
||||
(status) => {
|
||||
expect(
|
||||
getDashboardEventPhase(
|
||||
event("2026-09-09T13:00:00Z", null, status),
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export function getDashboardEventPhase(
|
||||
event: { status: string; startsAt: Date; endsAt: Date | null },
|
||||
now: Date,
|
||||
): "upcoming" | "ongoing" | null {
|
||||
if (event.status !== "published") return null;
|
||||
if (event.startsAt > now) return "upcoming";
|
||||
return event.endsAt && event.endsAt > now ? "ongoing" : null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [] as unknown[],
|
||||
fail: false,
|
||||
query: "",
|
||||
params: [] as unknown[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (query, params) => {
|
||||
state.query = query;
|
||||
state.params = params;
|
||||
if (state.fail) throw new Error("offline");
|
||||
return { rows: state.rows };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadDashboardEvent } from "./dashboard-event";
|
||||
|
||||
describe("dashboard event loading", () => {
|
||||
beforeEach(() => {
|
||||
state.rows = [];
|
||||
state.fail = false;
|
||||
});
|
||||
it("limits to one published ongoing or future event in date order", async () => {
|
||||
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z"));
|
||||
expect(state.query).toContain("`website_events`.`status` = ?");
|
||||
expect(state.query).toContain(
|
||||
"(`website_events`.`starts_at` > ? or `website_events`.`ends_at` > ?)",
|
||||
);
|
||||
expect(state.query).toContain(
|
||||
"order by `website_events`.`starts_at` asc, `website_events`.`id` asc limit ?",
|
||||
);
|
||||
expect(state.params).toEqual([
|
||||
"published",
|
||||
"2026-09-09 12:00:00.000",
|
||||
"2026-09-09 12:00:00.000",
|
||||
1,
|
||||
]);
|
||||
});
|
||||
it("distinguishes an empty calendar from a failed query", async () => {
|
||||
expect(await loadDashboardEvent()).toEqual({
|
||||
event: null,
|
||||
unavailable: false,
|
||||
});
|
||||
state.fail = true;
|
||||
expect(await loadDashboardEvent()).toEqual({
|
||||
event: null,
|
||||
unavailable: true,
|
||||
});
|
||||
});
|
||||
it("returns the next event with the phase derived from its real dates", async () => {
|
||||
state.rows = [
|
||||
[3, "Quiz", "published", "2026-09-09 13:00:00.000", null, "Games"],
|
||||
];
|
||||
expect(
|
||||
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z")),
|
||||
).toMatchObject({
|
||||
unavailable: false,
|
||||
event: { id: 3, title: "Quiz", phase: "upcoming" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { and, asc, eq, gt, or } from "drizzle-orm";
|
||||
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
|
||||
import { getDashboardEventPhase } from "./dashboard-event-phase";
|
||||
|
||||
export async function loadDashboardEvent(now = new Date()) {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
status: WebsiteEvent.status,
|
||||
startsAt: WebsiteEvent.startsAt,
|
||||
endsAt: WebsiteEvent.endsAt,
|
||||
typeName: WebsiteEventType.name,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteEvent.status, "published"),
|
||||
or(gt(WebsiteEvent.startsAt, now), gt(WebsiteEvent.endsAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
|
||||
.limit(1);
|
||||
const event = rows[0];
|
||||
const phase = event ? getDashboardEventPhase(event, now) : null;
|
||||
return {
|
||||
event: event && phase ? { ...event, phase } : null,
|
||||
unavailable: false,
|
||||
};
|
||||
} catch {
|
||||
return { event: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
@@ -59,3 +59,44 @@ it("rejects unsafe job paths", async () => {
|
||||
);
|
||||
await expect(s.read("../escape")).rejects.toThrow("Invalid import ID");
|
||||
});
|
||||
|
||||
it("cancellation survives a stale worker save", async () => {
|
||||
const s = await store(),
|
||||
j = job();
|
||||
await s.create(j);
|
||||
expect((await s.requestCancellation(j.id, j.userId))?.cancelRequested).toBe(
|
||||
true,
|
||||
);
|
||||
await s.save({ ...j, state: "running" });
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(true);
|
||||
expect((await s.read(j.id)).cancelRequested).toBe(true);
|
||||
});
|
||||
it("rejects cancellation by a different operator and is idempotent for the owner", async () => {
|
||||
const s = await store(),
|
||||
j = job();
|
||||
await s.create(j);
|
||||
expect(await s.requestCancellation(j.id, 99)).toBeNull();
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(false);
|
||||
await Promise.all([
|
||||
s.requestCancellation(j.id, j.userId),
|
||||
s.requestCancellation(j.id, j.userId),
|
||||
]);
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(true);
|
||||
});
|
||||
it("does not alter completed jobs when cancellation arrives late", async () => {
|
||||
const s = await store(),
|
||||
j = { ...job(), state: "completed" as const };
|
||||
await s.create(j);
|
||||
expect((await s.requestCancellation(j.id, j.userId))?.state).toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(await s.isCancellationRequested(j.id)).toBe(false);
|
||||
});
|
||||
it("loads bounded history for the requesting owner", async () => {
|
||||
const s = await store();
|
||||
for (let i = 0; i < 5; i++)
|
||||
await s.create({ ...job(), userId: i % 2 === 0 ? 4 : 8 });
|
||||
const result = await s.list({ userId: 4, limit: 2 });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((job) => job.userId === 4)).toBe(true);
|
||||
});
|
||||
@@ -39,20 +39,68 @@ export class ImportJobStore {
|
||||
}
|
||||
async read(id: string): Promise<ImportJob> {
|
||||
if (!validJobId(id)) throw Error("Invalid import ID");
|
||||
return JSON.parse(
|
||||
const job: ImportJob = JSON.parse(
|
||||
await fs.readFile(path.join(this.root, `${id}.json`), "utf8"),
|
||||
);
|
||||
if (await this.isCancellationRequested(id)) job.cancelRequested = true;
|
||||
return job;
|
||||
}
|
||||
async list(): Promise<ImportJob[]> {
|
||||
async isCancellationRequested(id: string): Promise<boolean> {
|
||||
if (!validJobId(id)) throw Error("Invalid import ID");
|
||||
try {
|
||||
await fs.access(path.join(this.root, `${id}.cancel`));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Cancellation has its own durable marker: a worker save cannot overwrite it. */
|
||||
async requestCancellation(
|
||||
id: string,
|
||||
userId: number,
|
||||
): Promise<ImportJob | null> {
|
||||
const job = await this.read(id).catch((error) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!job || job.userId !== userId) return null;
|
||||
if (job.state !== "queued" && job.state !== "running") return job;
|
||||
await fs
|
||||
.writeFile(path.join(this.root, `${id}.cancel`), "cancel", { flag: "wx" })
|
||||
.catch((error) => {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
});
|
||||
return this.read(id);
|
||||
}
|
||||
async list(options?: {
|
||||
userId: number;
|
||||
limit: number;
|
||||
}): Promise<ImportJob[]> {
|
||||
const files = await fs.readdir(this.root).catch((error) => {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
});
|
||||
const jobs = await Promise.all(
|
||||
files
|
||||
.filter((f) => f.endsWith(".json") && validJobId(f.slice(0, -5)))
|
||||
.map((f) => this.read(f.slice(0, -5))),
|
||||
const candidates: Array<{ id: string; modified: number }> = [];
|
||||
// Metadata and payload reads are sequential, avoiding thousands of concurrent opens.
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json") || !validJobId(file.slice(0, -5))) continue;
|
||||
const stat = await fs.stat(path.join(this.root, file));
|
||||
candidates.push({ id: file.slice(0, -5), modified: stat.mtimeMs });
|
||||
}
|
||||
candidates.sort((a, b) =>
|
||||
options ? b.modified - a.modified : a.modified - b.modified,
|
||||
);
|
||||
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
const jobs: ImportJob[] = [];
|
||||
const limit = options ? Math.min(30, Math.max(1, options.limit)) : Infinity;
|
||||
for (const candidate of candidates) {
|
||||
const job = await this.read(candidate.id);
|
||||
if (options && job.userId !== options.userId) continue;
|
||||
jobs.push(job);
|
||||
if (jobs.length >= limit) break;
|
||||
}
|
||||
return options
|
||||
? jobs
|
||||
: jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ const mocks = vi.hoisted(() => ({
|
||||
set: vi.fn(),
|
||||
eval: vi.fn(),
|
||||
list: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
save: vi.fn(),
|
||||
import: vi.fn(),
|
||||
attachment: vi.fn(),
|
||||
export: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
updateCatalog: vi.fn(),
|
||||
updateItems: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({ redis: { set: mocks.set, eval: mocks.eval } }));
|
||||
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
|
||||
@@ -19,6 +22,7 @@ vi.mock("./clone-sources", () => ({ getSource: async () => null }));
|
||||
vi.mock("./furni-job-store", () => ({
|
||||
ImportJobStore: class {
|
||||
list = mocks.list;
|
||||
isCancellationRequested = mocks.cancel;
|
||||
save = mocks.save;
|
||||
},
|
||||
}));
|
||||
@@ -33,7 +37,7 @@ vi.mock("./furni-attachment", () => ({
|
||||
readFurnitureAttachment: mocks.attachment,
|
||||
}));
|
||||
vi.mock("./rcon", () => ({
|
||||
rcon: { updateCatalog: async () => {}, updateItems: async () => {} },
|
||||
rcon: { updateCatalog: mocks.updateCatalog, updateItems: mocks.updateItems },
|
||||
}));
|
||||
|
||||
vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() }));
|
||||
@@ -44,10 +48,14 @@ let job: ImportJob;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.set.mockResolvedValue("OK");
|
||||
mocks.cancel.mockResolvedValue(false);
|
||||
mocks.eval.mockResolvedValue(1);
|
||||
mocks.export.mockImplementation((fn) => fn());
|
||||
mocks.import.mockResolvedValue({ ok: true, itemId: 900, warnings: [] });
|
||||
mocks.save.mockResolvedValue(undefined);
|
||||
mocks.updateCatalog.mockResolvedValue(undefined);
|
||||
mocks.updateItems.mockResolvedValue(undefined);
|
||||
mocks.translate.mockResolvedValue(undefined);
|
||||
job = {
|
||||
id: "job",
|
||||
userId: 5,
|
||||
@@ -89,7 +97,7 @@ it("marks abandoned work interrupted without repeating an uncertain import", asy
|
||||
job.items[0].state = "running";
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("interrupted");
|
||||
expect(job.items[0].state).toBe("failed");
|
||||
expect(job.items[0].state).toBe("interrupted");
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps errors for failed items and continues the batch", async () => {
|
||||
@@ -112,3 +120,106 @@ it("loads attachments using the job owner and exact classname", async () => {
|
||||
expect.objectContaining({ providedNitro: Buffer.from("verified") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels queued work without importing any items", async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("cancelled");
|
||||
expect(job.items[0].state).toBe("cancelled");
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
it("finishes the in-flight item and cancels only the remaining items", async () => {
|
||||
job.items.push({ ...job.items[0], classname: "table" });
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("cancelled");
|
||||
expect(job.items.map((item) => item.state)).toEqual(["done", "cancelled"]);
|
||||
expect(mocks.import).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("reports completion if cancellation arrives during the final item", async () => {
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.cancel.mockResolvedValue(true);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(job.state).toBe("completed");
|
||||
});
|
||||
it("preserves pending items when recovery finds an uncertain in-flight import", async () => {
|
||||
job.state = "running";
|
||||
job.items[0].state = "running";
|
||||
job.items.push({ ...job.items[0], classname: "table", state: "pending" });
|
||||
await drainFurnitureImports();
|
||||
expect(job.items.map((item) => item.state)).toEqual([
|
||||
"interrupted",
|
||||
"pending",
|
||||
]);
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not overwrite another worker's interruption after losing ownership during the final import", async () => {
|
||||
let persisted: ImportJob | undefined;
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
persisted = structuredClone(value);
|
||||
});
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
persisted = {
|
||||
...structuredClone(job),
|
||||
state: "interrupted",
|
||||
items: job.items.map((item) => ({ ...item, state: "interrupted" })),
|
||||
};
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
return { ok: true, itemId: 900, warnings: [] };
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(mocks.import).toHaveBeenCalledOnce();
|
||||
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||
expect(persisted?.state).toBe("interrupted");
|
||||
expect(persisted?.items[0].state).toBe("interrupted");
|
||||
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps an uncertain import running on disk when lease verification fails", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.import.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockRejectedValue(new Error("Redis unavailable"));
|
||||
throw new Error("SQL outcome unknown");
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
||||
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
||||
});
|
||||
it("does not persist final completion when ownership changes during cache refresh", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.updateCatalog.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(snapshots.at(-1)?.state).toBe("running");
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("done");
|
||||
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
||||
expect(mocks.updateItems).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not save a translated outcome after ownership is lost during translation", async () => {
|
||||
const snapshots: ImportJob[] = [];
|
||||
job.translate = true;
|
||||
mocks.save.mockImplementation(async (value: ImportJob) => {
|
||||
snapshots.push(structuredClone(value));
|
||||
});
|
||||
mocks.translate.mockImplementationOnce(async () => {
|
||||
mocks.eval.mockResolvedValue(0);
|
||||
});
|
||||
await drainFurnitureImports();
|
||||
expect(mocks.translate).toHaveBeenCalledOnce();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
||||
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ImportJob } from "@/lib/furni/import-job";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logAudit } from "./audit";
|
||||
@@ -13,6 +14,7 @@ import { repairFurniture } from "./furniture-repair";
|
||||
import { rcon } from "./rcon";
|
||||
|
||||
const LOCK = "furniture-import-worker:v1";
|
||||
class ImportLeaseLostError extends Error {}
|
||||
let running: Promise<void> | undefined;
|
||||
export function drainFurnitureImports(): Promise<void> {
|
||||
if (running) return running;
|
||||
@@ -30,6 +32,28 @@ async function drain() {
|
||||
const token = randomUUID();
|
||||
if ((await redis.set(LOCK, token, "EX", 600, "NX")) !== "OK") return;
|
||||
let lease = true;
|
||||
async function requireLease() {
|
||||
if (!lease) throw new ImportLeaseLostError("Import worker lost its lease");
|
||||
try {
|
||||
const owned = await redis?.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],600) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
);
|
||||
if (owned !== 1 || !lease) {
|
||||
lease = false;
|
||||
throw new ImportLeaseLostError("Import worker lost its lease");
|
||||
}
|
||||
} catch (error) {
|
||||
lease = false;
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
throw new ImportLeaseLostError(
|
||||
"Import worker could not verify its lease",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void redis
|
||||
?.eval(
|
||||
@@ -47,29 +71,48 @@ async function drain() {
|
||||
}, 30000);
|
||||
try {
|
||||
const store = new ImportJobStore();
|
||||
// Redis ownership checks and file writes are separate operations. These checks
|
||||
// prevent known stale writes; they do not provide atomic filesystem fencing.
|
||||
async function saveOwned(job: ImportJob) {
|
||||
await requireLease();
|
||||
await store.save(job);
|
||||
}
|
||||
for (const job of await store.list()) {
|
||||
if (!lease) break;
|
||||
await requireLease();
|
||||
if (job.state === "running") {
|
||||
job.state = "interrupted";
|
||||
for (const item of job.items)
|
||||
if (item.state === "running" || item.state === "pending") {
|
||||
item.state = "failed";
|
||||
if (item.state === "running") {
|
||||
item.state = "interrupted";
|
||||
item.error =
|
||||
"Server restarted during import. Review local data and retry to complete missing parts.";
|
||||
"Server restarted during import. Its outcome is uncertain. Inspect local data before starting a new repair.";
|
||||
}
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
continue;
|
||||
}
|
||||
if (job.state !== "queued") continue;
|
||||
if (await store.isCancellationRequested(job.id)) {
|
||||
job.state = "cancelled";
|
||||
for (const item of job.items)
|
||||
if (item.state === "pending") item.state = "cancelled";
|
||||
await saveOwned(job);
|
||||
continue;
|
||||
}
|
||||
job.state = "running";
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
await withCatalogExport(async () => {
|
||||
await ensureDirectories();
|
||||
const source = job.sourceId ? await getSource(job.sourceId) : null;
|
||||
for (const item of job.items) {
|
||||
if (!lease) throw Error("Import worker lost its lease");
|
||||
await requireLease();
|
||||
if (item.state !== "pending") continue;
|
||||
if (await store.isCancellationRequested(job.id)) {
|
||||
for (const remaining of job.items)
|
||||
if (remaining.state === "pending") remaining.state = "cancelled";
|
||||
break;
|
||||
}
|
||||
item.state = "running";
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
try {
|
||||
if (job.sourceId && !source)
|
||||
throw Error("Import source no longer exists");
|
||||
@@ -80,6 +123,7 @@ async function drain() {
|
||||
job.userId,
|
||||
)
|
||||
: undefined;
|
||||
await requireLease();
|
||||
const result = await (job.mode === "repair"
|
||||
? repairFurniture
|
||||
: importSingleFurni)({
|
||||
@@ -90,6 +134,7 @@ async function drain() {
|
||||
nitroBaseUrl: source?.nitroBaseUrl,
|
||||
iconBaseUrl: source?.iconBaseUrl,
|
||||
});
|
||||
await requireLease();
|
||||
item.warnings = result.warnings;
|
||||
item.itemId = result.itemId;
|
||||
if (!result.ok) throw Error(result.error || "Import failed");
|
||||
@@ -114,16 +159,21 @@ async function drain() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
await requireLease();
|
||||
item.state = "failed";
|
||||
item.error =
|
||||
error instanceof Error ? error.message : "Import failed";
|
||||
}
|
||||
await store.save(job);
|
||||
await saveOwned(job);
|
||||
}
|
||||
await requireLease();
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
await requireLease();
|
||||
await rcon.updateItems();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof ImportLeaseLostError) throw error;
|
||||
for (const item of job.items)
|
||||
if (item.state === "done") {
|
||||
item.warnings ??= [];
|
||||
@@ -131,17 +181,25 @@ async function drain() {
|
||||
}
|
||||
}
|
||||
});
|
||||
job.state = "completed";
|
||||
await store.save(job);
|
||||
await requireLease();
|
||||
job.state = job.items.some((item) => item.state === "cancelled")
|
||||
? "cancelled"
|
||||
: "completed";
|
||||
await saveOwned(job);
|
||||
}
|
||||
await requireLease();
|
||||
await runCatalogExport();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
await redis.eval(
|
||||
await redis
|
||||
.eval(
|
||||
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
|
||||
1,
|
||||
LOCK,
|
||||
token,
|
||||
);
|
||||
)
|
||||
.catch((error) => {
|
||||
logServerError("furni.worker_release_failed", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
UsersSettings,
|
||||
} from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { loadDashboardEvent } from "./dashboard-event";
|
||||
import { siteSettings } from "./site-settings";
|
||||
export async function loadUserDashboard(userId: number) {
|
||||
const [
|
||||
dashboardEvent,
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
@@ -29,6 +31,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
friends,
|
||||
currencyRows,
|
||||
] = await Promise.all([
|
||||
loadDashboardEvent(),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
@@ -62,7 +65,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.from(Rooms)
|
||||
.orderBy(desc(Rooms.id))
|
||||
.limit(6)
|
||||
.catch(() => []),
|
||||
.catch(() => null),
|
||||
db
|
||||
.select({
|
||||
badgeCode: UsersBadges.badgeCode,
|
||||
@@ -85,7 +88,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.select({ value: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, userId))
|
||||
.catch(() => [{ value: 0 }]),
|
||||
.catch(() => null),
|
||||
db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
@@ -144,6 +147,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
];
|
||||
|
||||
return {
|
||||
dashboardEvent,
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRaw,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { verifyRuntimeMetadata } from "../../scripts/verify-runtime-metadata.mjs";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it("verifies runtime names and domains without requesting DB-dependent login HTML", async () => {
|
||||
const fetcher = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/login"))
|
||||
return new Response("Database unavailable", { status: 500 });
|
||||
return url.endsWith("webmanifest")
|
||||
? Response.json({ name: "Fixture alpha" })
|
||||
: new Response("https://alpha.test/sitemap.xml");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
await expect(
|
||||
verifyRuntimeMetadata("https://alpha.test", "alpha"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fetcher.mock.calls.map((c) => c[0])).not.toContain(
|
||||
"https://alpha.test/login",
|
||||
);
|
||||
});
|
||||
it.each(["manifest", "domain", "http"])("rejects wrong %s", async (mode) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) =>
|
||||
url.endsWith("webmanifest")
|
||||
? Response.json(
|
||||
{ name: mode === "manifest" ? "Build fixture" : "Fixture alpha" },
|
||||
{ status: mode === "http" ? 500 : 200 },
|
||||
)
|
||||
: new Response("https://wrong.test"),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
verifyRuntimeMetadata("https://alpha.test", "alpha"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
@@ -7,7 +7,138 @@
|
||||
"admin": {
|
||||
"nav": {
|
||||
"studio": "Studio"
|
||||
},
|
||||
"logs": {
|
||||
"audit": {
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
}
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"articles": {
|
||||
"form": {
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -23,5 +154,36 @@
|
||||
"fullscreenExit": "الخروج من ملء الشاشة",
|
||||
"show": "إظهار الشريط",
|
||||
"emulatorUnknown": "حالة المحاكي غير معروفة"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Запазва се...",
|
||||
"deleteArticle": "Изтриване на статия",
|
||||
"deleteConfirm": "Това действие не може да бъде отменено. Статията ще бъде окончателно премахната.",
|
||||
"cancel": "Отказ"
|
||||
}
|
||||
"cancel": "Отказ",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Етикети",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Изход от цял екран",
|
||||
"show": "Покажи лентата",
|
||||
"emulatorUnknown": "Състоянието на емулатора е неизвестно"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Ukládání…",
|
||||
"deleteArticle": "Smazat článek",
|
||||
"deleteConfirm": "Tuto akci nelze vrátit zpět. Článek bude trvale odstraněn.",
|
||||
"cancel": "Zrušit"
|
||||
}
|
||||
"cancel": "Zrušit",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tagy",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Ukončit celou obrazovku",
|
||||
"show": "Zobrazit lištu",
|
||||
"emulatorUnknown": "Stav emulátoru neznámý"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -967,8 +967,42 @@
|
||||
"saving": "Gemmer...",
|
||||
"deleteArticle": "Slet artikel",
|
||||
"deleteConfirm": "Denne handling kan ikke fortrydes. Artiklen fjernes permanent.",
|
||||
"cancel": "Annuller"
|
||||
}
|
||||
"cancel": "Annuller",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
@@ -2071,7 +2105,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2973,6 +3019,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -3255,6 +3366,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3279,5 +3402,36 @@
|
||||
"fullscreenExit": "Afslut fuldskærm",
|
||||
"show": "Vis værktøjslinje",
|
||||
"emulatorUnknown": "Emulatorstatus ukendt"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
-3
@@ -938,8 +938,42 @@
|
||||
"saving": "Wird gespeichert…",
|
||||
"deleteArticle": "Artikel löschen",
|
||||
"deleteConfirm": "Dieser Vorgang kann nicht rückgängig gemacht werden. Der Artikel wird dauerhaft entfernt.",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
"cancel": "Abbrechen",
|
||||
"autosaveLoading": "Loading saved drafts…",
|
||||
"autosaveReady": "Private draft autosave is ready.",
|
||||
"autosaveSaving": "Saving private draft…",
|
||||
"autosaveSaved": "Private draft saved. Publication changes require Save.",
|
||||
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
|
||||
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
|
||||
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
|
||||
"recoverDraft": "Recover draft",
|
||||
"discardDraft": "Discard saved draft",
|
||||
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
|
||||
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
|
||||
"revisionHistory": "Previous saved versions (latest 20)",
|
||||
"restoreRevision": "Restore into editor",
|
||||
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
|
||||
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
|
||||
"retryRecovery": "Reload recovery without changing this form"
|
||||
},
|
||||
"listActionError": "The article action could not be completed.",
|
||||
"listStatus": "Publication status",
|
||||
"listStatus_all": "All articles",
|
||||
"listStatus_draft": "Draft",
|
||||
"listStatus_published": "Published",
|
||||
"listStatus_scheduled": "Scheduled",
|
||||
"listSearch": "Search title or summary",
|
||||
"listApply": "Apply",
|
||||
"listLoading": "Loading…",
|
||||
"listReset": "Reset filters",
|
||||
"listUnavailable": "Articles are temporarily unavailable. Try again.",
|
||||
"listRetry": "Try again",
|
||||
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
|
||||
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
|
||||
"listPagination": "Article pages",
|
||||
"listPrevious": "Previous",
|
||||
"listNext": "Next",
|
||||
"listPage": "Page {page} of {total}"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
@@ -2042,7 +2076,19 @@
|
||||
"colDate": "Date",
|
||||
"searchPlaceholder": "Search by action or target…",
|
||||
"changesCount": "{count} changes",
|
||||
"notAvailable": "N/A"
|
||||
"notAvailable": "N/A",
|
||||
"filtersTitle": "Filter audit records",
|
||||
"filterActor": "Actor (exact username or ID)",
|
||||
"filterAction": "Exact action",
|
||||
"filterFrom": "From date (UTC)",
|
||||
"filterTo": "Through date (UTC)",
|
||||
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
|
||||
"applyFilters": "Apply filters",
|
||||
"resetFilters": "Reset filters",
|
||||
"invalidDetails": "These legacy details cannot be displayed.",
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"chat": {
|
||||
"colMessage": "Message",
|
||||
@@ -2944,6 +2990,71 @@
|
||||
"hideItem": "Hide item",
|
||||
"showItem": "Show item",
|
||||
"pinned": "Pinned"
|
||||
},
|
||||
"cmsErrors": {
|
||||
"firstSeen": "First occurrence in retained records",
|
||||
"lastSeen": "Latest occurrence",
|
||||
"affectedUsers": "Distinct identified users",
|
||||
"unidentifiedEvents": "Events without a verified user ID",
|
||||
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
|
||||
"assignedStaff": "Assigned to staff #{id}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignSelf": "Assign to me",
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
|
||||
"checked": "Checked at {time} · probes completed in {ms} ms",
|
||||
"recheck": "Check again",
|
||||
"ok": "Check passed",
|
||||
"failed": "Needs attention",
|
||||
"unknown": "Not verified",
|
||||
"missing": "Not configured",
|
||||
"release": "Running release",
|
||||
"database": "Database",
|
||||
"redis": "Redis",
|
||||
"emulator": "Emulator",
|
||||
"storage": "Writable storage",
|
||||
"migrations": "Database migrations",
|
||||
"worker": "Background worker",
|
||||
"renderer": "Renderer configuration",
|
||||
"registry": "Container registry",
|
||||
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
|
||||
"databaseHint": "Connection check and measured SELECT 1 response time.",
|
||||
"redisHint": "Optional for some pages; required for queued import work.",
|
||||
"emulatorHint": "Response to the configured emulator ping.",
|
||||
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
|
||||
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
|
||||
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
|
||||
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
|
||||
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
|
||||
},
|
||||
"importHistory": {
|
||||
"cancelled": "Cancelled",
|
||||
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
|
||||
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
|
||||
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
|
||||
"cancelRemaining": "Cancel remaining items",
|
||||
"historyLimit": "Showing up to 30 most recently updated jobs.",
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
|
||||
"empty": "No pending items found in the checked records.",
|
||||
"errors": "Unresolved CMS error groups",
|
||||
"imports": "Your imports needing review",
|
||||
"tickets": "Open support tickets",
|
||||
"publications": "News drafts",
|
||||
"unavailable": "{source}: data is temporarily unavailable.",
|
||||
"installation": "Installation diagnostics"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
@@ -3256,6 +3367,18 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
"messagesCaughtUp": "No unread messages",
|
||||
"messagesUnavailable": "Open your inbox to check",
|
||||
"eventTitle": "On the calendar",
|
||||
"allEvents": "All events",
|
||||
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
|
||||
"eventOngoing": "Happening now",
|
||||
"eventUpcoming": "Coming up",
|
||||
"eventDetails": "View event",
|
||||
"noUpcomingEvents": "No upcoming events have been announced yet.",
|
||||
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -3280,5 +3403,36 @@
|
||||
"fullscreenExit": "Vollbild beenden",
|
||||
"show": "Leiste anzeigen",
|
||||
"emulatorUnknown": "Emulatorstatus unbekannt"
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
"sql": "SQL item",
|
||||
"catalog": "Catalog offer",
|
||||
"furnidata": "Furnidata",
|
||||
"icon": "Local icon",
|
||||
"nitro": "Nitro file"
|
||||
},
|
||||
"statuses": {
|
||||
"present": "Present",
|
||||
"missing": "Missing",
|
||||
"unknown": "Not verified",
|
||||
"conflict": "Needs review"
|
||||
},
|
||||
"actions": {
|
||||
"sql": "Import or repair this furni to create the missing item record.",
|
||||
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
|
||||
"furnidata": "Import or repair this furni to add the missing furniture data.",
|
||||
"icon": "Import or repair this furni to retrieve the missing local icon.",
|
||||
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
|
||||
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
|
||||
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
|
||||
},
|
||||
"openAudit": "Open Catalog audit to resolve conflicts",
|
||||
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user