cloudflare-next-intl 0.9.7 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/check_dynamic_pages.mjs +24 -0
- package/dist/src/cloudflare_email/escape_html.d.ts +1 -0
- package/dist/src/cloudflare_email/escape_html.js +7 -0
- package/dist/src/cloudflare_email/index.d.ts +3 -0
- package/dist/src/cloudflare_email/index.js +3 -0
- package/dist/src/cloudflare_email/resolve_email_binding.d.ts +11 -0
- package/dist/src/cloudflare_email/resolve_email_binding.js +8 -0
- package/dist/src/cloudflare_email/send_transactional_email.d.ts +18 -0
- package/dist/src/cloudflare_email/send_transactional_email.js +36 -0
- package/dist/src/cloudflare_fetch/fetch_text.d.ts +5 -0
- package/dist/src/cloudflare_fetch/fetch_text.js +17 -0
- package/dist/src/cloudflare_fetch/fetch_with_fallback.d.ts +2 -0
- package/dist/src/cloudflare_fetch/fetch_with_fallback.js +7 -0
- package/dist/src/cloudflare_fetch/index.d.ts +3 -0
- package/dist/src/cloudflare_fetch/index.js +3 -0
- package/dist/src/cloudflare_fetch/resolve_assets_binding.d.ts +5 -0
- package/dist/src/cloudflare_fetch/resolve_assets_binding.js +9 -0
- package/dist/src/db/connection.js +5 -5
- package/dist/src/db/context.js +2 -2
- package/dist/src/db/resolve_hyperdrive_connection_string.d.ts +5 -0
- package/dist/src/db/resolve_hyperdrive_connection_string.js +10 -0
- package/dist/src/db/resolve_mode.d.ts +2 -2
- package/dist/src/db/resolve_mode.js +7 -1
- package/dist/src/dynamic_pages_check/check_dynamic_pages.d.ts +16 -0
- package/dist/src/dynamic_pages_check/check_dynamic_pages.js +38 -0
- package/dist/src/dynamic_pages_check/detect_dynamic_usage.d.ts +5 -0
- package/dist/src/dynamic_pages_check/detect_dynamic_usage.js +16 -0
- package/dist/src/dynamic_pages_check/find_page_files.d.ts +1 -0
- package/dist/src/dynamic_pages_check/find_page_files.js +24 -0
- package/dist/src/dynamic_pages_check/index.d.ts +3 -0
- package/dist/src/dynamic_pages_check/index.js +3 -0
- package/dist/src/dynamic_pages_check/insert_dynamic_export.d.ts +1 -0
- package/dist/src/dynamic_pages_check/insert_dynamic_export.js +43 -0
- package/dist/src/errors_board/client/error_detail_view.d.ts +7 -0
- package/dist/src/errors_board/client/error_detail_view.js +35 -0
- package/dist/src/errors_board/client/error_row.d.ts +7 -0
- package/dist/src/errors_board/client/error_row.js +9 -0
- package/dist/src/errors_board/client/error_ui_client.d.ts +14 -0
- package/dist/src/errors_board/client/error_ui_client.js +26 -0
- package/dist/src/errors_board/client/errors_filter_form.d.ts +8 -0
- package/dist/src/errors_board/client/errors_filter_form.js +6 -0
- package/dist/src/errors_board/client/errors_list_client.d.ts +15 -0
- package/dist/src/errors_board/client/errors_list_client.js +92 -0
- package/dist/src/errors_board/client/errors_login_form.d.ts +5 -0
- package/dist/src/errors_board/client/errors_login_form.js +21 -0
- package/dist/src/errors_board/client/errors_stat_strip.d.ts +6 -0
- package/dist/src/errors_board/client/errors_stat_strip.js +19 -0
- package/dist/src/errors_board/server/actions_factory.d.ts +24 -0
- package/dist/src/errors_board/server/actions_factory.js +39 -0
- package/dist/src/errors_board/server/errors_repository.d.ts +83 -0
- package/dist/src/errors_board/server/errors_repository.js +199 -0
- package/dist/src/errors_board/server/gate.d.ts +19 -0
- package/dist/src/errors_board/server/gate.js +68 -0
- package/dist/src/errors_board/server/index.d.ts +3 -0
- package/dist/src/errors_board/server/index.js +3 -0
- package/dist/src/errors_board/shared/error_ui_helpers.d.ts +13 -0
- package/dist/src/errors_board/shared/error_ui_helpers.js +60 -0
- package/dist/src/types/types.d.ts +1 -0
- package/package.json +47 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import ErrorRowItem from './error_row.js';
|
|
5
|
+
export default function ErrorsListClient({ initialRows, initialNextCursor, filters, actions, hrefFor, }) {
|
|
6
|
+
const [rows, setRows] = useState(initialRows);
|
|
7
|
+
const [nextCursor, setNextCursor] = useState(initialNextCursor);
|
|
8
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
9
|
+
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
10
|
+
const [isPending, setIsPending] = useState(false);
|
|
11
|
+
const sentinelRef = useRef(null);
|
|
12
|
+
const latestRef = useRef({ isLoadingMore, nextCursor, filters, actions });
|
|
13
|
+
latestRef.current = { isLoadingMore, nextCursor, filters, actions };
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setRows(initialRows);
|
|
16
|
+
setNextCursor(initialNextCursor);
|
|
17
|
+
}, [initialRows, initialNextCursor]);
|
|
18
|
+
const loadMore = useCallback(() => {
|
|
19
|
+
const { isLoadingMore, nextCursor, filters, actions } = latestRef.current;
|
|
20
|
+
if (isLoadingMore || nextCursor === null)
|
|
21
|
+
return;
|
|
22
|
+
setIsLoadingMore(true);
|
|
23
|
+
void actions
|
|
24
|
+
.loadErrors({ ...filters, cursor: nextCursor })
|
|
25
|
+
.then((result) => {
|
|
26
|
+
setRows((previous) => [...previous, ...result.rows]);
|
|
27
|
+
setNextCursor(result.nextCursor);
|
|
28
|
+
})
|
|
29
|
+
.finally(() => setIsLoadingMore(false));
|
|
30
|
+
}, []);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const sentinel = sentinelRef.current;
|
|
33
|
+
if (!sentinel)
|
|
34
|
+
return;
|
|
35
|
+
const observer = new IntersectionObserver((entries) => {
|
|
36
|
+
if (entries[0]?.isIntersecting)
|
|
37
|
+
loadMore();
|
|
38
|
+
}, { rootMargin: '400px' });
|
|
39
|
+
observer.observe(sentinel);
|
|
40
|
+
return () => observer.disconnect();
|
|
41
|
+
}, [loadMore]);
|
|
42
|
+
function toggleSelect(id, checked) {
|
|
43
|
+
setSelectedIds((previous) => {
|
|
44
|
+
const next = new Set(previous);
|
|
45
|
+
if (checked)
|
|
46
|
+
next.add(id);
|
|
47
|
+
else
|
|
48
|
+
next.delete(id);
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function toggleSelectAll(checked) {
|
|
53
|
+
setSelectedIds(checked ? new Set(rows.map((row) => row.id)) : new Set());
|
|
54
|
+
}
|
|
55
|
+
async function handleBulkStatus(status) {
|
|
56
|
+
const ids = Array.from(selectedIds);
|
|
57
|
+
setIsPending(true);
|
|
58
|
+
try {
|
|
59
|
+
await actions.setErrorStatus(ids, status);
|
|
60
|
+
setSelectedIds(new Set());
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
setIsPending(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function handleBulkDelete() {
|
|
67
|
+
const ids = Array.from(selectedIds);
|
|
68
|
+
if (!window.confirm(`Delete ${ids.length} error${ids.length === 1 ? '' : 's'}? This can't be undone.`))
|
|
69
|
+
return;
|
|
70
|
+
setIsPending(true);
|
|
71
|
+
try {
|
|
72
|
+
await actions.deleteErrors(ids);
|
|
73
|
+
setSelectedIds(new Set());
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
setIsPending(false);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function handleDeleteAllResolved() {
|
|
80
|
+
if (!window.confirm("Delete every resolved error (including ones not currently loaded)? This can't be undone."))
|
|
81
|
+
return;
|
|
82
|
+
setIsPending(true);
|
|
83
|
+
try {
|
|
84
|
+
await actions.deleteAllResolved();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
setIsPending(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const hasSelection = selectedIds.size > 0;
|
|
91
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/90 px-3 py-2 shadow-sm backdrop-blur dark:border-gray-800 dark:bg-gray-900/90", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300", children: [_jsx("input", { type: "checkbox", checked: rows.length > 0 && selectedIds.size === rows.length, onChange: (event) => toggleSelectAll(event.target.checked), className: "size-4 accent-blue-600" }), hasSelection ? (_jsxs("span", { className: "font-medium text-gray-900 dark:text-white", children: [selectedIds.size, " selected"] })) : (_jsxs("span", { children: [rows.length, " error", rows.length === 1 ? '' : 's'] }))] }), _jsxs("div", { className: "ml-auto flex flex-wrap gap-2", children: [_jsx("button", { disabled: isPending || !hasSelection, onClick: () => handleBulkStatus('investigating'), className: "rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800", children: "Mark investigating" }), _jsx("button", { disabled: isPending || !hasSelection, onClick: () => handleBulkStatus('resolved'), className: "rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800", children: "Mark resolved" }), filters.status === 'muted' ? (_jsx("button", { disabled: isPending || !hasSelection, onClick: () => handleBulkStatus('new'), title: "Bring these back onto the board as New.", className: "rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800", children: "Unmute" })) : (_jsx("button", { disabled: isPending || !hasSelection, onClick: () => handleBulkStatus('muted'), title: "Hide for good. Repeats stay hidden and never reopen.", className: "rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800", children: "Mute" })), _jsx("button", { disabled: isPending || !hasSelection, onClick: handleBulkDelete, className: "rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-40 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950", children: "Delete selected" }), _jsx("button", { disabled: isPending, onClick: handleDeleteAllResolved, className: "rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-40 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800", children: "Delete all resolved" })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [rows.map((row) => (_jsx(ErrorRowItem, { row: row, selected: selectedIds.has(row.id), onToggleSelect: toggleSelect, hrefFor: hrefFor }, row.id))), rows.length === 0 && (_jsxs("div", { className: "flex flex-col items-center gap-1 rounded-xl border border-dashed border-gray-300 py-14 text-center dark:border-gray-700", children: [_jsx("p", { className: "text-sm font-medium text-gray-600 dark:text-gray-300", children: "No errors here" }), _jsx("p", { className: "text-xs text-gray-400 dark:text-gray-500", children: "Nothing matches the current filters." })] }))] }), nextCursor !== null && (_jsx("div", { ref: sentinelRef, className: "flex justify-center py-4", children: isLoadingMore && (_jsxs("span", { className: "flex items-center gap-2 text-sm text-gray-400 dark:text-gray-500", children: [_jsx("span", { className: "size-3 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-gray-600" }), "Loading more\u2026"] })) })), nextCursor === null && rows.length > 0 && (_jsx("p", { className: "py-4 text-center text-xs text-gray-400 dark:text-gray-500", children: "You've reached the end." }))] }));
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState, useTransition } from 'react';
|
|
4
|
+
export default function ErrorsLoginForm({ login, onSuccess, title = 'Error log', }) {
|
|
5
|
+
const [password, setPassword] = useState('');
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const [isPending, startTransition] = useTransition();
|
|
8
|
+
function handleSubmit(event) {
|
|
9
|
+
event.preventDefault();
|
|
10
|
+
setError(null);
|
|
11
|
+
startTransition(async () => {
|
|
12
|
+
const ok = await login(password);
|
|
13
|
+
if (!ok) {
|
|
14
|
+
setError('Wrong password');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
onSuccess();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return (_jsxs("main", { className: "mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-4 px-4", children: [_jsx("h1", { className: "text-lg font-semibold text-gray-900 dark:text-white", children: title }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-2", children: [_jsx("input", { type: "password", autoFocus: true, value: password, onChange: (event) => setPassword(event.target.value), placeholder: "Password", className: "rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white" }), error && _jsx("p", { className: "text-xs text-red-600 dark:text-red-400", children: error }), _jsx("button", { type: "submit", disabled: isPending, className: "rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:opacity-50", children: "Enter" })] })] }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Link from 'next/link.js';
|
|
3
|
+
const STAT_CONFIG = [
|
|
4
|
+
{ status: 'all', label: 'Total', dotClass: 'bg-blue-500' },
|
|
5
|
+
{ status: 'new', label: 'New', dotClass: 'bg-red-500' },
|
|
6
|
+
{ status: 'investigating', label: 'Investigating', dotClass: 'bg-amber-500' },
|
|
7
|
+
{ status: 'resolved', label: 'Resolved', dotClass: 'bg-emerald-500' },
|
|
8
|
+
{ status: 'muted', label: 'Muted', dotClass: 'bg-gray-400 dark:bg-gray-500' },
|
|
9
|
+
];
|
|
10
|
+
export default function ErrorsStatStrip({ counts, activeStatus, linkFor, }) {
|
|
11
|
+
const total = counts.new + counts.investigating + counts.resolved;
|
|
12
|
+
return (_jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5", children: STAT_CONFIG.map(({ status, label, dotClass }) => {
|
|
13
|
+
const value = status === 'all' ? total : counts[status];
|
|
14
|
+
const isActive = activeStatus === status;
|
|
15
|
+
return (_jsxs(Link, { prefetch: false, href: linkFor(status), className: `flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors ${isActive
|
|
16
|
+
? 'border-blue-400 bg-blue-50 dark:border-blue-500/50 dark:bg-blue-500/10'
|
|
17
|
+
: 'border-gray-200 bg-white hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800/50'}`, children: [_jsxs("span", { className: "flex items-center gap-2 text-xs font-medium text-gray-500 dark:text-gray-400", children: [_jsx("span", { className: `size-2 rounded-full ${dotClass}`, "aria-hidden": true }), label] }), _jsx("span", { className: "text-lg font-semibold tabular-nums text-gray-900 dark:text-white", children: value })] }, status));
|
|
18
|
+
}) }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type D1DatabaseLike, type ErrorsListResult, listErrors as listErrorsImpl, setErrorsStatus as setErrorsStatusImpl, deleteErrorsByIds as deleteErrorsByIdsImpl, deleteAllResolvedErrors as deleteAllResolvedErrorsImpl } from './errors_repository.js';
|
|
2
|
+
export interface ErrorsActions {
|
|
3
|
+
loadErrors(rawParams: {
|
|
4
|
+
flavour?: string;
|
|
5
|
+
status?: string;
|
|
6
|
+
q?: string;
|
|
7
|
+
cursor?: string | null;
|
|
8
|
+
}): Promise<ErrorsListResult>;
|
|
9
|
+
setErrorStatus(ids: number[], status: string): Promise<void>;
|
|
10
|
+
deleteErrors(ids: number[]): Promise<void>;
|
|
11
|
+
deleteAllResolved(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface ErrorsActionsOptions {
|
|
14
|
+
getDb: () => Promise<D1DatabaseLike> | D1DatabaseLike;
|
|
15
|
+
requireAccess: () => Promise<void>;
|
|
16
|
+
listPath?: string;
|
|
17
|
+
repository?: {
|
|
18
|
+
listErrors?: typeof listErrorsImpl;
|
|
19
|
+
setErrorsStatus?: typeof setErrorsStatusImpl;
|
|
20
|
+
deleteErrorsByIds?: typeof deleteErrorsByIdsImpl;
|
|
21
|
+
deleteAllResolvedErrors?: typeof deleteAllResolvedErrorsImpl;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export declare function createErrorsActions(options: ErrorsActionsOptions): ErrorsActions;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { revalidatePath } from 'next/cache.js';
|
|
2
|
+
import { parseErrorsListFilters, boundErrorIds, isErrorStatus, listErrors as listErrorsImpl, setErrorsStatus as setErrorsStatusImpl, deleteErrorsByIds as deleteErrorsByIdsImpl, deleteAllResolvedErrors as deleteAllResolvedErrorsImpl, } from './errors_repository.js';
|
|
3
|
+
export function createErrorsActions(options) {
|
|
4
|
+
const listErrors = options.repository?.listErrors ?? listErrorsImpl;
|
|
5
|
+
const setErrorsStatus = options.repository?.setErrorsStatus ?? setErrorsStatusImpl;
|
|
6
|
+
const deleteErrorsByIds = options.repository?.deleteErrorsByIds ?? deleteErrorsByIdsImpl;
|
|
7
|
+
const deleteAllResolvedErrors = options.repository?.deleteAllResolvedErrors ?? deleteAllResolvedErrorsImpl;
|
|
8
|
+
const listPath = options.listPath ?? '/errors';
|
|
9
|
+
return {
|
|
10
|
+
async loadErrors(rawParams) {
|
|
11
|
+
await options.requireAccess();
|
|
12
|
+
const filters = parseErrorsListFilters(rawParams);
|
|
13
|
+
const db = await options.getDb();
|
|
14
|
+
return listErrors(db, filters);
|
|
15
|
+
},
|
|
16
|
+
async setErrorStatus(ids, status) {
|
|
17
|
+
await options.requireAccess();
|
|
18
|
+
if (!isErrorStatus(status))
|
|
19
|
+
throw new Error(`errors_board: unknown status "${status}"`);
|
|
20
|
+
const boundedIds = boundErrorIds(ids);
|
|
21
|
+
const db = await options.getDb();
|
|
22
|
+
await setErrorsStatus(db, boundedIds, status);
|
|
23
|
+
revalidatePath(listPath);
|
|
24
|
+
},
|
|
25
|
+
async deleteErrors(ids) {
|
|
26
|
+
await options.requireAccess();
|
|
27
|
+
const boundedIds = boundErrorIds(ids);
|
|
28
|
+
const db = await options.getDb();
|
|
29
|
+
await deleteErrorsByIds(db, boundedIds);
|
|
30
|
+
revalidatePath(listPath);
|
|
31
|
+
},
|
|
32
|
+
async deleteAllResolved() {
|
|
33
|
+
await options.requireAccess();
|
|
34
|
+
const db = await options.getDb();
|
|
35
|
+
await deleteAllResolvedErrors(db);
|
|
36
|
+
revalidatePath(listPath);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export declare const ERROR_STATUSES: readonly ["new", "investigating", "resolved", "muted"];
|
|
2
|
+
export type ErrorStatus = typeof ERROR_STATUSES[number];
|
|
3
|
+
export declare const BOARD_STATUSES: readonly ["new", "investigating", "resolved"];
|
|
4
|
+
declare const MAX_MESSAGE_LENGTH = 2000;
|
|
5
|
+
declare const MAX_STACK_LENGTH = 8000;
|
|
6
|
+
declare const MAX_PARAMS_LENGTH = 4000;
|
|
7
|
+
export declare const ERRORS_PAGE_SIZE = 50;
|
|
8
|
+
export declare const MAX_IDS_PER_ACTION = 200;
|
|
9
|
+
export interface ErrorRow {
|
|
10
|
+
id: number;
|
|
11
|
+
fingerprint: string;
|
|
12
|
+
created_at: number;
|
|
13
|
+
updated_at: number;
|
|
14
|
+
flavour: string;
|
|
15
|
+
caller: string;
|
|
16
|
+
message: string;
|
|
17
|
+
stack: string | null;
|
|
18
|
+
params: string | null;
|
|
19
|
+
is_client: number;
|
|
20
|
+
status: ErrorStatus;
|
|
21
|
+
count: number;
|
|
22
|
+
user_email: string | null;
|
|
23
|
+
reopen_count: number;
|
|
24
|
+
resolved_at: number | null;
|
|
25
|
+
}
|
|
26
|
+
export interface D1PreparedStatementLike {
|
|
27
|
+
bind(...values: unknown[]): D1PreparedStatementLike;
|
|
28
|
+
run(): Promise<unknown>;
|
|
29
|
+
all<T = unknown>(): Promise<{
|
|
30
|
+
results?: T[];
|
|
31
|
+
}>;
|
|
32
|
+
first<T = unknown>(): Promise<T | null>;
|
|
33
|
+
}
|
|
34
|
+
export interface D1DatabaseLike {
|
|
35
|
+
prepare(sql: string): D1PreparedStatementLike;
|
|
36
|
+
batch<T = unknown>(statements: D1PreparedStatementLike[]): Promise<{
|
|
37
|
+
results?: T[];
|
|
38
|
+
}[]>;
|
|
39
|
+
}
|
|
40
|
+
export interface RecordErrorInput {
|
|
41
|
+
flavour: string;
|
|
42
|
+
caller: string;
|
|
43
|
+
message: string;
|
|
44
|
+
stack: string | null;
|
|
45
|
+
params: string | null;
|
|
46
|
+
isClient: boolean;
|
|
47
|
+
userEmail: string | null;
|
|
48
|
+
}
|
|
49
|
+
export interface ErrorsListFilters {
|
|
50
|
+
flavour: string;
|
|
51
|
+
status: ErrorStatus | 'all';
|
|
52
|
+
q: string;
|
|
53
|
+
cursor: string | null;
|
|
54
|
+
}
|
|
55
|
+
export interface ErrorsListResult {
|
|
56
|
+
rows: ErrorRow[];
|
|
57
|
+
nextCursor: string | null;
|
|
58
|
+
}
|
|
59
|
+
export interface ErrorsBoardResult extends ErrorsListResult {
|
|
60
|
+
flavours: string[];
|
|
61
|
+
counts: Record<ErrorStatus, number>;
|
|
62
|
+
}
|
|
63
|
+
export declare function isErrorStatus(value: string): value is ErrorStatus;
|
|
64
|
+
export declare function encodeCursor(updatedAt: number, id: number): string;
|
|
65
|
+
export declare function parseErrorsListFilters(raw: {
|
|
66
|
+
flavour?: string;
|
|
67
|
+
status?: string;
|
|
68
|
+
q?: string;
|
|
69
|
+
cursor?: string | null;
|
|
70
|
+
}): ErrorsListFilters;
|
|
71
|
+
export declare function boundErrorIds(ids: number[]): number[];
|
|
72
|
+
declare function truncate(value: string, maxLength: number): string;
|
|
73
|
+
export { MAX_MESSAGE_LENGTH, MAX_STACK_LENGTH, MAX_PARAMS_LENGTH, truncate };
|
|
74
|
+
export declare function ensureSchema(db: D1DatabaseLike): Promise<void>;
|
|
75
|
+
export declare function computeFingerprint(flavour: string, caller: string, message: string): Promise<string>;
|
|
76
|
+
export declare function recordError(db: D1DatabaseLike, input: RecordErrorInput): Promise<void>;
|
|
77
|
+
export declare function listErrors(db: D1DatabaseLike, filters: ErrorsListFilters): Promise<ErrorsListResult>;
|
|
78
|
+
export declare function getErrorById(db: D1DatabaseLike, id: number): Promise<ErrorRow | null>;
|
|
79
|
+
export declare function distinctFlavours(db: D1DatabaseLike): Promise<string[]>;
|
|
80
|
+
export declare function loadErrorsBoard(db: D1DatabaseLike, filters: ErrorsListFilters): Promise<ErrorsBoardResult>;
|
|
81
|
+
export declare function setErrorsStatus(db: D1DatabaseLike, ids: number[], status: ErrorStatus): Promise<void>;
|
|
82
|
+
export declare function deleteErrorsByIds(db: D1DatabaseLike, ids: number[]): Promise<void>;
|
|
83
|
+
export declare function deleteAllResolvedErrors(db: D1DatabaseLike): Promise<void>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
export const ERROR_STATUSES = ['new', 'investigating', 'resolved', 'muted'];
|
|
2
|
+
export const BOARD_STATUSES = ['new', 'investigating', 'resolved'];
|
|
3
|
+
const MAX_MESSAGE_LENGTH = 2000;
|
|
4
|
+
const MAX_STACK_LENGTH = 8000;
|
|
5
|
+
const MAX_PARAMS_LENGTH = 4000;
|
|
6
|
+
export const ERRORS_PAGE_SIZE = 50;
|
|
7
|
+
export const MAX_IDS_PER_ACTION = 200;
|
|
8
|
+
export function isErrorStatus(value) {
|
|
9
|
+
return ERROR_STATUSES.includes(value);
|
|
10
|
+
}
|
|
11
|
+
export function encodeCursor(updatedAt, id) {
|
|
12
|
+
return `${updatedAt}:${id}`;
|
|
13
|
+
}
|
|
14
|
+
function decodeCursor(raw) {
|
|
15
|
+
const [updatedAtPart, idPart] = raw.split(':');
|
|
16
|
+
const updatedAt = Number(updatedAtPart);
|
|
17
|
+
const id = Number(idPart);
|
|
18
|
+
if (!Number.isInteger(updatedAt) || !Number.isInteger(id) || updatedAt < 0 || id < 0)
|
|
19
|
+
return null;
|
|
20
|
+
return { updatedAt, id };
|
|
21
|
+
}
|
|
22
|
+
export function parseErrorsListFilters(raw) {
|
|
23
|
+
const status = raw.status === 'all' || (raw.status && isErrorStatus(raw.status)) ? raw.status : 'all';
|
|
24
|
+
const cursor = raw.cursor && decodeCursor(raw.cursor) ? raw.cursor : null;
|
|
25
|
+
return {
|
|
26
|
+
flavour: raw.flavour ?? 'all',
|
|
27
|
+
status,
|
|
28
|
+
q: (raw.q ?? '').slice(0, 200),
|
|
29
|
+
cursor,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function boundErrorIds(ids) {
|
|
33
|
+
if (!Array.isArray(ids) || ids.length === 0)
|
|
34
|
+
throw new Error('errors_board: id list must not be empty');
|
|
35
|
+
if (!ids.every((id) => Number.isInteger(id) && id > 0)) {
|
|
36
|
+
throw new Error('errors_board: every id must be a positive integer');
|
|
37
|
+
}
|
|
38
|
+
return ids.slice(0, MAX_IDS_PER_ACTION);
|
|
39
|
+
}
|
|
40
|
+
function truncate(value, maxLength) {
|
|
41
|
+
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
|
42
|
+
}
|
|
43
|
+
export { MAX_MESSAGE_LENGTH, MAX_STACK_LENGTH, MAX_PARAMS_LENGTH, truncate };
|
|
44
|
+
const CREATE_TABLE_SQL = `
|
|
45
|
+
CREATE TABLE IF NOT EXISTS errors (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
fingerprint TEXT NOT NULL UNIQUE,
|
|
48
|
+
created_at INTEGER NOT NULL,
|
|
49
|
+
updated_at INTEGER NOT NULL,
|
|
50
|
+
flavour TEXT NOT NULL,
|
|
51
|
+
caller TEXT NOT NULL,
|
|
52
|
+
message TEXT NOT NULL,
|
|
53
|
+
stack TEXT,
|
|
54
|
+
params TEXT,
|
|
55
|
+
is_client INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
status TEXT NOT NULL DEFAULT 'new',
|
|
57
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
58
|
+
user_email TEXT,
|
|
59
|
+
reopen_count INTEGER NOT NULL DEFAULT 0,
|
|
60
|
+
resolved_at INTEGER
|
|
61
|
+
)
|
|
62
|
+
`;
|
|
63
|
+
const CREATE_INDEXES_SQL = [
|
|
64
|
+
'CREATE INDEX IF NOT EXISTS idx_errors_updated_at ON errors (updated_at DESC)',
|
|
65
|
+
'CREATE INDEX IF NOT EXISTS idx_errors_flavour ON errors (flavour)',
|
|
66
|
+
'CREATE INDEX IF NOT EXISTS idx_errors_status ON errors (status)',
|
|
67
|
+
];
|
|
68
|
+
const schemaReadyByDb = new WeakMap();
|
|
69
|
+
export function ensureSchema(db) {
|
|
70
|
+
const existing = schemaReadyByDb.get(db);
|
|
71
|
+
if (existing)
|
|
72
|
+
return existing;
|
|
73
|
+
const ready = db
|
|
74
|
+
.batch([db.prepare(CREATE_TABLE_SQL), ...CREATE_INDEXES_SQL.map((sql) => db.prepare(sql))])
|
|
75
|
+
.then(() => undefined)
|
|
76
|
+
.catch((error) => {
|
|
77
|
+
schemaReadyByDb.delete(db);
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
schemaReadyByDb.set(db, ready);
|
|
81
|
+
return ready;
|
|
82
|
+
}
|
|
83
|
+
export async function computeFingerprint(flavour, caller, message) {
|
|
84
|
+
const data = new TextEncoder().encode(`${flavour}|${caller}|${message}`);
|
|
85
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
86
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
87
|
+
}
|
|
88
|
+
export async function recordError(db, input) {
|
|
89
|
+
await ensureSchema(db);
|
|
90
|
+
const message = truncate(input.message, MAX_MESSAGE_LENGTH);
|
|
91
|
+
const stack = input.stack ? truncate(input.stack, MAX_STACK_LENGTH) : null;
|
|
92
|
+
const params = input.params ? truncate(input.params, MAX_PARAMS_LENGTH) : null;
|
|
93
|
+
const fingerprint = await computeFingerprint(input.flavour, input.caller, message);
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
await db
|
|
96
|
+
.prepare(`INSERT INTO errors (fingerprint, created_at, updated_at, flavour, caller, message, stack, params, is_client, user_email)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
98
|
+
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
99
|
+
updated_at = excluded.updated_at,
|
|
100
|
+
count = errors.count + 1,
|
|
101
|
+
stack = excluded.stack,
|
|
102
|
+
params = excluded.params,
|
|
103
|
+
user_email = excluded.user_email,
|
|
104
|
+
reopen_count = CASE WHEN errors.status = 'resolved' THEN errors.reopen_count + 1 ELSE errors.reopen_count END,
|
|
105
|
+
status = CASE WHEN errors.status = 'resolved' THEN 'new' ELSE errors.status END`)
|
|
106
|
+
.bind(fingerprint, now, now, input.flavour, input.caller, message, stack, params, input.isClient ? 1 : 0, input.userEmail)
|
|
107
|
+
.run();
|
|
108
|
+
}
|
|
109
|
+
function buildListQuery(filters) {
|
|
110
|
+
const conditions = [];
|
|
111
|
+
const bindings = [];
|
|
112
|
+
if (filters.flavour !== 'all') {
|
|
113
|
+
conditions.push('flavour = ?');
|
|
114
|
+
bindings.push(filters.flavour);
|
|
115
|
+
}
|
|
116
|
+
if (filters.status === 'all') {
|
|
117
|
+
conditions.push("status != 'muted'");
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
conditions.push('status = ?');
|
|
121
|
+
bindings.push(filters.status);
|
|
122
|
+
}
|
|
123
|
+
if (filters.q) {
|
|
124
|
+
conditions.push('(message LIKE ? OR caller LIKE ? OR user_email LIKE ?)');
|
|
125
|
+
const like = `%${filters.q}%`;
|
|
126
|
+
bindings.push(like, like, like);
|
|
127
|
+
}
|
|
128
|
+
if (filters.cursor !== null) {
|
|
129
|
+
const decoded = decodeCursor(filters.cursor);
|
|
130
|
+
if (decoded) {
|
|
131
|
+
conditions.push('(updated_at < ? OR (updated_at = ? AND id < ?))');
|
|
132
|
+
bindings.push(decoded.updatedAt, decoded.updatedAt, decoded.id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
bindings.push(ERRORS_PAGE_SIZE + 1);
|
|
136
|
+
return { sql: `SELECT * FROM errors WHERE ${conditions.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ?`, bindings };
|
|
137
|
+
}
|
|
138
|
+
function paginate(rows) {
|
|
139
|
+
const hasMore = rows.length > ERRORS_PAGE_SIZE;
|
|
140
|
+
const page = hasMore ? rows.slice(0, ERRORS_PAGE_SIZE) : rows;
|
|
141
|
+
const last = page[page.length - 1];
|
|
142
|
+
return { rows: page, nextCursor: hasMore && last ? encodeCursor(last.updated_at, last.id) : null };
|
|
143
|
+
}
|
|
144
|
+
export async function listErrors(db, filters) {
|
|
145
|
+
await ensureSchema(db);
|
|
146
|
+
const { sql, bindings } = buildListQuery(filters);
|
|
147
|
+
const result = await db.prepare(sql).bind(...bindings).all();
|
|
148
|
+
return paginate(result.results ?? []);
|
|
149
|
+
}
|
|
150
|
+
export async function getErrorById(db, id) {
|
|
151
|
+
await ensureSchema(db);
|
|
152
|
+
const row = await db.prepare('SELECT * FROM errors WHERE id = ?').bind(id).first();
|
|
153
|
+
return row ?? null;
|
|
154
|
+
}
|
|
155
|
+
export async function distinctFlavours(db) {
|
|
156
|
+
await ensureSchema(db);
|
|
157
|
+
const result = await db.prepare('SELECT DISTINCT flavour FROM errors ORDER BY flavour').all();
|
|
158
|
+
return (result.results ?? []).map((row) => row.flavour);
|
|
159
|
+
}
|
|
160
|
+
export async function loadErrorsBoard(db, filters) {
|
|
161
|
+
await ensureSchema(db);
|
|
162
|
+
const listQuery = buildListQuery(filters);
|
|
163
|
+
const [listResult, flavourResult, countResult] = await db.batch([
|
|
164
|
+
db.prepare(listQuery.sql).bind(...listQuery.bindings),
|
|
165
|
+
db.prepare('SELECT DISTINCT flavour FROM errors ORDER BY flavour'),
|
|
166
|
+
db.prepare('SELECT status, COUNT(*) as count FROM errors GROUP BY status'),
|
|
167
|
+
]);
|
|
168
|
+
const counts = { new: 0, investigating: 0, resolved: 0, muted: 0 };
|
|
169
|
+
for (const row of (countResult.results ?? [])) {
|
|
170
|
+
counts[row.status] = row.count;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
...paginate((listResult.results ?? [])),
|
|
174
|
+
flavours: (flavourResult.results ?? []).map((row) => row.flavour),
|
|
175
|
+
counts,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
export async function setErrorsStatus(db, ids, status) {
|
|
179
|
+
await ensureSchema(db);
|
|
180
|
+
const boundedIds = boundErrorIds(ids);
|
|
181
|
+
const placeholders = boundedIds.map(() => '?').join(', ');
|
|
182
|
+
await db
|
|
183
|
+
.prepare(`UPDATE errors
|
|
184
|
+
SET status = ?,
|
|
185
|
+
resolved_at = CASE WHEN ? = 'resolved' THEN ? ELSE resolved_at END
|
|
186
|
+
WHERE id IN (${placeholders})`)
|
|
187
|
+
.bind(status, status, Date.now(), ...boundedIds)
|
|
188
|
+
.run();
|
|
189
|
+
}
|
|
190
|
+
export async function deleteErrorsByIds(db, ids) {
|
|
191
|
+
await ensureSchema(db);
|
|
192
|
+
const boundedIds = boundErrorIds(ids);
|
|
193
|
+
const placeholders = boundedIds.map(() => '?').join(', ');
|
|
194
|
+
await db.prepare(`DELETE FROM errors WHERE id IN (${placeholders})`).bind(...boundedIds).run();
|
|
195
|
+
}
|
|
196
|
+
export async function deleteAllResolvedErrors(db) {
|
|
197
|
+
await ensureSchema(db);
|
|
198
|
+
await db.prepare("DELETE FROM errors WHERE status = 'resolved'").run();
|
|
199
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface ErrorsAccessOptions {
|
|
2
|
+
allowedEmails: readonly string[] | ((email: string | null) => boolean);
|
|
3
|
+
onDenied?: () => void | Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export declare function createRequireErrorsAccess(options: ErrorsAccessOptions): () => Promise<void>;
|
|
6
|
+
export interface PasswordErrorsAccessOptions {
|
|
7
|
+
password: string;
|
|
8
|
+
cookieName?: string;
|
|
9
|
+
cookiePath?: string;
|
|
10
|
+
maxAgeSeconds?: number;
|
|
11
|
+
onDenied?: () => void | Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface PasswordErrorsAccess {
|
|
14
|
+
hasAccess(): Promise<boolean>;
|
|
15
|
+
requireAccess(): Promise<void>;
|
|
16
|
+
verifyPassword(password: string): Promise<boolean>;
|
|
17
|
+
setAuthCookie(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare function createPasswordErrorsAccess(options: PasswordErrorsAccessOptions): PasswordErrorsAccess;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getAuthUser } from '../../firebase_auth/server/use_auth_user_server.js';
|
|
2
|
+
export function createRequireErrorsAccess(options) {
|
|
3
|
+
const allowedEmailsLower = Array.isArray(options.allowedEmails)
|
|
4
|
+
? new Set(options.allowedEmails.map((email) => email.toLowerCase()))
|
|
5
|
+
: null;
|
|
6
|
+
const isAllowed = allowedEmailsLower
|
|
7
|
+
? (email) => allowedEmailsLower.has((email ?? '').toLowerCase())
|
|
8
|
+
: options.allowedEmails;
|
|
9
|
+
return async function requireErrorsAccess() {
|
|
10
|
+
const { user } = await getAuthUser();
|
|
11
|
+
if (isAllowed(user?.email ?? null))
|
|
12
|
+
return;
|
|
13
|
+
if (options.onDenied) {
|
|
14
|
+
await options.onDenied();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const { notFound } = await import('next/navigation.js');
|
|
18
|
+
notFound();
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async function sha256Hex(input) {
|
|
22
|
+
const data = new TextEncoder().encode(input);
|
|
23
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
24
|
+
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
25
|
+
}
|
|
26
|
+
export function createPasswordErrorsAccess(options) {
|
|
27
|
+
const cookieName = options.cookieName ?? 'errors_auth';
|
|
28
|
+
const cookiePath = options.cookiePath ?? '/errors';
|
|
29
|
+
const maxAge = options.maxAgeSeconds ?? 60 * 60 * 24 * 30;
|
|
30
|
+
async function expectedCookieValue() {
|
|
31
|
+
return sha256Hex(options.password);
|
|
32
|
+
}
|
|
33
|
+
async function hasAccess() {
|
|
34
|
+
const { cookies } = await import('next/headers.js');
|
|
35
|
+
const store = await cookies();
|
|
36
|
+
const value = store.get(cookieName)?.value;
|
|
37
|
+
if (!value)
|
|
38
|
+
return false;
|
|
39
|
+
return value === (await expectedCookieValue());
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
hasAccess,
|
|
43
|
+
async requireAccess() {
|
|
44
|
+
if (await hasAccess())
|
|
45
|
+
return;
|
|
46
|
+
if (options.onDenied) {
|
|
47
|
+
await options.onDenied();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const { notFound } = await import('next/navigation.js');
|
|
51
|
+
notFound();
|
|
52
|
+
},
|
|
53
|
+
async verifyPassword(password) {
|
|
54
|
+
return password === options.password;
|
|
55
|
+
},
|
|
56
|
+
async setAuthCookie() {
|
|
57
|
+
const { cookies } = await import('next/headers.js');
|
|
58
|
+
const store = await cookies();
|
|
59
|
+
store.set(cookieName, await expectedCookieValue(), {
|
|
60
|
+
httpOnly: true,
|
|
61
|
+
secure: true,
|
|
62
|
+
sameSite: 'lax',
|
|
63
|
+
path: cookiePath,
|
|
64
|
+
maxAge,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ERROR_STATUSES, BOARD_STATUSES, ERRORS_PAGE_SIZE, MAX_IDS_PER_ACTION, isErrorStatus, parseErrorsListFilters, boundErrorIds, encodeCursor, ensureSchema, computeFingerprint, recordError, listErrors, getErrorById, distinctFlavours, loadErrorsBoard, setErrorsStatus, deleteErrorsByIds, deleteAllResolvedErrors, type ErrorStatus, type ErrorRow, type D1DatabaseLike, type D1PreparedStatementLike, type RecordErrorInput, type ErrorsListFilters, type ErrorsListResult, type ErrorsBoardResult, } from './errors_repository.js';
|
|
2
|
+
export { createRequireErrorsAccess, createPasswordErrorsAccess, type ErrorsAccessOptions, type PasswordErrorsAccessOptions, type PasswordErrorsAccess, } from './gate.js';
|
|
3
|
+
export { createErrorsActions, type ErrorsActions, type ErrorsActionsOptions } from './actions_factory.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ERROR_STATUSES, BOARD_STATUSES, ERRORS_PAGE_SIZE, MAX_IDS_PER_ACTION, isErrorStatus, parseErrorsListFilters, boundErrorIds, encodeCursor, ensureSchema, computeFingerprint, recordError, listErrors, getErrorById, distinctFlavours, loadErrorsBoard, setErrorsStatus, deleteErrorsByIds, deleteAllResolvedErrors, } from './errors_repository.js';
|
|
2
|
+
export { createRequireErrorsAccess, createPasswordErrorsAccess, } from './gate.js';
|
|
3
|
+
export { createErrorsActions } from './actions_factory.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ErrorStatus } from '../server/errors_repository.js';
|
|
2
|
+
export declare const STATUS_LABELS: Record<ErrorStatus, string>;
|
|
3
|
+
export declare const STATUS_HINTS: Record<ErrorStatus, string>;
|
|
4
|
+
export declare const STATUS_DOT_CLASS: Record<ErrorStatus, string>;
|
|
5
|
+
export declare const STATUS_BADGE_CLASS: Record<ErrorStatus, string>;
|
|
6
|
+
export declare function formatRelativeTime(timestampMs: number): string;
|
|
7
|
+
export declare function formatLocalTimestamp(timestampMs: number): string;
|
|
8
|
+
export interface ParsedRequestContext {
|
|
9
|
+
path?: string;
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
referer?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseRequestContext(paramsJson: string | null): ParsedRequestContext | null;
|