@anthusai/papyrus 2.0.0-next.2 → 2.0.0-next.21
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/amplify/data/resource.js +15 -0
- package/amplify/data/resource.ts +17 -0
- package/amplify/data/schema.js +20 -0
- package/amplify/data/schema.ts +22 -0
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/content-actions/handler.py +22 -0
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +92 -0
- package/amplify/functions/editorial-checks/schema.ts +94 -0
- package/amplify/functions/editorial-checks/wire.js +49 -0
- package/amplify/functions/editorial-checks/wire.ts +57 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/publication/redirect-and-index.js +83 -0
- package/amplify/publication/redirect-rules.js +61 -0
- package/amplify/publication/redirect-rules.ts +79 -0
- package/amplify/publication/s3-projection.js +5 -1
- package/amplify/publication/s3-projection.ts +7 -1
- package/amplify/site-backend-config.ts +10 -0
- package/amplify/site-backend.js +31 -0
- package/amplify/site-backend.ts +38 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +347 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/monitoring-overview-view.tsx +240 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +74 -61
- package/components/newsroom-references-view.tsx +14 -14
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/publication-cyclotron-panel.tsx +10 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +30 -21
- package/components/reference-cyclotron-priming.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +27 -2
- package/components/use-editorial-checks.ts +133 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +342 -0
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +194 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +171 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +144 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +23 -3
- package/package.json +47 -3
- package/routes.manifest.json +34 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
|
2
|
+
import { CITATION_TOKEN, planPreview, selectionToSource, splitByMarks, type BlockAnnotation, type PreviewMark, type PreviewPlan } from "../lib/editorial-preview";
|
|
3
|
+
import type { EditorialCheckResult, EditorialFinding, Span } from "../lib/editorial-checks";
|
|
2
4
|
import {
|
|
3
5
|
parseMarkusDocument,
|
|
4
6
|
type MarkusBlock,
|
|
@@ -17,7 +19,20 @@ type PreviewSidecar = {
|
|
|
17
19
|
bibliography: string[];
|
|
18
20
|
};
|
|
19
21
|
|
|
22
|
+
/** Editorial findings drawn on the preview (see lib/editorial-preview.ts). */
|
|
23
|
+
export type PreviewEditorial = {
|
|
24
|
+
/** A check result computed for the same body the preview shows, and the findings to draw from it. */
|
|
25
|
+
result: EditorialCheckResult;
|
|
26
|
+
findings: EditorialFinding[];
|
|
27
|
+
activeFindingIds?: readonly string[];
|
|
28
|
+
/** A marked passage was clicked. */
|
|
29
|
+
onOpenMark: (findingIds: string[], anchor: HTMLElement) => void;
|
|
30
|
+
/** Text was selected inside one prose block: the source span of the sentences it touches. */
|
|
31
|
+
onSelectSource?: (source: Span, anchor: DOMRect) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
20
34
|
type PreviewContext = {
|
|
35
|
+
editorial?: PreviewEditorial & { plan: PreviewPlan; blocks: Map<string, BlockAnnotation> };
|
|
21
36
|
sidecar: PreviewSidecar;
|
|
22
37
|
citationNumbers: Map<string, number>;
|
|
23
38
|
imageUrls: Record<string, string>;
|
|
@@ -91,7 +106,7 @@ function renderText(text: string, context: PreviewContext): ReactNode {
|
|
|
91
106
|
if (!keys) continue;
|
|
92
107
|
if (index > cursor) parts.push(text.slice(cursor, index));
|
|
93
108
|
parts.push(
|
|
94
|
-
<sup className="text-primary" key={`${index}-${token}`}>
|
|
109
|
+
<sup className="text-primary" data-editorial-skip key={`${index}-${token}`}>
|
|
95
110
|
{keys.map((key) => `[${context.citationNumbers.get(key)}]`).join("")}
|
|
96
111
|
</sup>,
|
|
97
112
|
);
|
|
@@ -127,7 +142,7 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
|
|
|
127
142
|
}
|
|
128
143
|
case "image":
|
|
129
144
|
return (
|
|
130
|
-
<span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" key={index}>
|
|
145
|
+
<span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" data-editorial-skip key={index}>
|
|
131
146
|
{`Image: ${node.src ?? ""} (${node.alt})`}
|
|
132
147
|
</span>
|
|
133
148
|
);
|
|
@@ -141,6 +156,92 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
|
|
|
141
156
|
});
|
|
142
157
|
}
|
|
143
158
|
|
|
159
|
+
const MARK_CLASSES: Record<PreviewMark["severity"], string> = {
|
|
160
|
+
error: "decoration-destructive",
|
|
161
|
+
warn: "decoration-amber-500",
|
|
162
|
+
info: "decoration-sky-500",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function markElement(text: string, mark: PreviewMark, key: string, context: PreviewContext): ReactNode {
|
|
166
|
+
const active = mark.findingIds.some((id) => context.editorial?.activeFindingIds?.includes(id));
|
|
167
|
+
return (
|
|
168
|
+
<mark
|
|
169
|
+
className={`cursor-pointer bg-transparent text-inherit underline underline-offset-4 ${mark.status === "review" ? "decoration-dotted" : "decoration-wavy"} ${MARK_CLASSES[mark.severity]} ${active ? "rounded-sm bg-ring/25" : ""}`}
|
|
170
|
+
data-editorial-mark={mark.findingIds.join(" ")}
|
|
171
|
+
key={key}
|
|
172
|
+
onClick={(event) => {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
context.editorial?.onOpenMark(mark.findingIds, event.currentTarget);
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{text}
|
|
178
|
+
</mark>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Like renderInlines, but splits rendered text at editorial marks (offsets as in inlineLeaves). */
|
|
183
|
+
function renderMarkedInlines(inlines: MarkusInline[], context: PreviewContext, marks: PreviewMark[], cursor: { at: number }): ReactNode[] {
|
|
184
|
+
const marked = (text: string, keyPrefix: string): ReactNode[] => {
|
|
185
|
+
const start = cursor.at;
|
|
186
|
+
cursor.at += text.length;
|
|
187
|
+
return splitByMarks(start, start + text.length, marks).map((piece, pieceIndex) => {
|
|
188
|
+
const slice = text.slice(piece.start - start, piece.end - start);
|
|
189
|
+
return piece.mark ? markElement(slice, piece.mark, `${keyPrefix}-${pieceIndex}`, context) : slice;
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
return inlines.map((node, index) => {
|
|
193
|
+
switch (node.type) {
|
|
194
|
+
case "text": {
|
|
195
|
+
const parts: ReactNode[] = [];
|
|
196
|
+
let last = 0;
|
|
197
|
+
for (const match of node.text.matchAll(CITATION_TOKEN)) {
|
|
198
|
+
const at = match.index ?? 0;
|
|
199
|
+
if (at > last) parts.push(...marked(node.text.slice(last, at), `${index}-${last}`));
|
|
200
|
+
parts.push(<span key={`${index}-t${at}`}>{renderText(match[0], context)}</span>);
|
|
201
|
+
last = at + match[0].length;
|
|
202
|
+
}
|
|
203
|
+
if (last < node.text.length) parts.push(...marked(node.text.slice(last), `${index}-${last}`));
|
|
204
|
+
return <span key={index}>{parts}</span>;
|
|
205
|
+
}
|
|
206
|
+
case "emphasis":
|
|
207
|
+
return <em key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</em>;
|
|
208
|
+
case "strong":
|
|
209
|
+
return <strong key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</strong>;
|
|
210
|
+
case "strikethrough":
|
|
211
|
+
return <s key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</s>;
|
|
212
|
+
case "code_span":
|
|
213
|
+
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" key={index}>{marked(node.code, `${index}`)}</code>;
|
|
214
|
+
case "link": {
|
|
215
|
+
const children = renderMarkedInlines(node.children, context, marks, cursor);
|
|
216
|
+
const custom = node.href ? context.renderLink?.(node.href, children) : null;
|
|
217
|
+
if (custom) return <span key={index}>{custom}</span>;
|
|
218
|
+
return node.href ? (
|
|
219
|
+
<a className="text-primary underline" href={node.href} key={index} rel="noopener noreferrer" target="_blank">{children}</a>
|
|
220
|
+
) : (
|
|
221
|
+
<span key={index}>{children}</span>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
case "soft_break":
|
|
225
|
+
cursor.at += 1;
|
|
226
|
+
return <span key={index}>{" "}</span>;
|
|
227
|
+
case "hard_break":
|
|
228
|
+
cursor.at += 1;
|
|
229
|
+
return <br key={index} />;
|
|
230
|
+
default:
|
|
231
|
+
return renderInlines([node], context)[0];
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Render a prose block's inlines, with editorial marks when the block has an annotation. */
|
|
237
|
+
function proseInlines(block: { inline: MarkusInline[] }, context: PreviewContext): { children: ReactNode[]; blockKey?: string } {
|
|
238
|
+
const annotation = context.editorial?.plan.get(block);
|
|
239
|
+
if (!context.editorial || !annotation) return { children: renderInlines(block.inline, context) };
|
|
240
|
+
const blockKey = String(context.editorial.blocks.size);
|
|
241
|
+
context.editorial.blocks.set(blockKey, annotation);
|
|
242
|
+
return { children: renderMarkedInlines(block.inline, context, annotation.marks, { at: 0 }), blockKey };
|
|
243
|
+
}
|
|
244
|
+
|
|
144
245
|
function soleToken(inlines: MarkusInline[]): string | null {
|
|
145
246
|
if (inlines.length !== 1 || inlines[0].type !== "text") return null;
|
|
146
247
|
const text = inlines[0].text.trim();
|
|
@@ -170,12 +271,14 @@ function renderBlock(block: MarkusBlock, index: number, context: PreviewContext)
|
|
|
170
271
|
</ol>
|
|
171
272
|
);
|
|
172
273
|
}
|
|
173
|
-
|
|
274
|
+
const prose = proseInlines(block, context);
|
|
275
|
+
return <p className="my-3 leading-relaxed" data-editorial-block={prose.blockKey} key={index}>{prose.children}</p>;
|
|
174
276
|
}
|
|
175
277
|
case "heading": {
|
|
176
278
|
const level = Math.min(Math.max(block.level, 1), 6);
|
|
177
279
|
const Tag = `h${level}` as "h1";
|
|
178
|
-
|
|
280
|
+
const prose = proseInlines(block, context);
|
|
281
|
+
return <Tag className={HEADING_CLASSES[level - 1]} data-editorial-block={prose.blockKey} key={index}>{prose.children}</Tag>;
|
|
179
282
|
}
|
|
180
283
|
case "list": {
|
|
181
284
|
const items = block.items.map((item, itemIndex) => (
|
|
@@ -310,12 +413,45 @@ function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[]
|
|
|
310
413
|
);
|
|
311
414
|
}
|
|
312
415
|
|
|
416
|
+
/** Characters of rendered text from the start of `block` to (node, offset), skipping citation and image furniture. */
|
|
417
|
+
function renderedOffset(block: HTMLElement, node: Node, offset: number): number {
|
|
418
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
419
|
+
let total = 0;
|
|
420
|
+
for (let current = walker.nextNode(); current; current = walker.nextNode()) {
|
|
421
|
+
const skipped = current.parentElement?.closest("[data-editorial-skip]");
|
|
422
|
+
if (current === node) return total + (skipped ? 0 : offset);
|
|
423
|
+
if (!skipped) total += current.textContent?.length ?? 0;
|
|
424
|
+
}
|
|
425
|
+
return total;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function previewSelectionHandler(context: PreviewContext) {
|
|
429
|
+
return (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
430
|
+
const editorial = context.editorial;
|
|
431
|
+
if (!editorial?.onSelectSource) return;
|
|
432
|
+
const selection = window.getSelection();
|
|
433
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
|
|
434
|
+
const range = selection.getRangeAt(0);
|
|
435
|
+
const startBlock = (range.startContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
|
|
436
|
+
const endBlock = (range.endContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
|
|
437
|
+
if (!startBlock || startBlock !== endBlock || !event.currentTarget.contains(startBlock)) return;
|
|
438
|
+
const annotation = editorial.blocks.get(startBlock.dataset.editorialBlock ?? "");
|
|
439
|
+
const source = selectionToSource(annotation, {
|
|
440
|
+
start: renderedOffset(startBlock, range.startContainer, range.startOffset),
|
|
441
|
+
end: renderedOffset(startBlock, range.endContainer, range.endOffset),
|
|
442
|
+
});
|
|
443
|
+
if (source) editorial.onSelectSource(source, range.getBoundingClientRect());
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
313
447
|
export function MarkusIrPreview({
|
|
314
448
|
bodyIr,
|
|
315
449
|
imageUrls = {},
|
|
316
450
|
dynamicDirectives = {},
|
|
317
451
|
renderLink,
|
|
452
|
+
editorial,
|
|
318
453
|
}: {
|
|
454
|
+
editorial?: PreviewEditorial;
|
|
319
455
|
bodyIr: unknown;
|
|
320
456
|
imageUrls?: Record<string, string>;
|
|
321
457
|
/** Live components for leaf directives whose content the host app computes, keyed by directive name. */
|
|
@@ -333,8 +469,13 @@ export function MarkusIrPreview({
|
|
|
333
469
|
imageUrls,
|
|
334
470
|
dynamicDirectives,
|
|
335
471
|
renderLink,
|
|
472
|
+
editorial: editorial ? { ...editorial, plan: planPreview(document, editorial.result, editorial.findings), blocks: new Map() } : undefined,
|
|
336
473
|
};
|
|
337
|
-
return
|
|
474
|
+
return (
|
|
475
|
+
<div data-markus-preview onMouseUp={editorial?.onSelectSource ? previewSelectionHandler(context) : undefined}>
|
|
476
|
+
{renderBlocks(document.children, context)}
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
338
479
|
} catch (error) {
|
|
339
480
|
return (
|
|
340
481
|
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" data-markus-preview-error role="alert">
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import {
|
|
3
|
+
confidenceText,
|
|
4
|
+
describeDelivery,
|
|
5
|
+
describeRelevance,
|
|
6
|
+
everyText,
|
|
7
|
+
formatUtc,
|
|
8
|
+
referenceHref,
|
|
9
|
+
routingLabel,
|
|
10
|
+
usageText,
|
|
11
|
+
type MonitoringItem,
|
|
12
|
+
type MonitoringOverview,
|
|
13
|
+
type MonitoringRelevance,
|
|
14
|
+
type MonitoringResult,
|
|
15
|
+
} from "../lib/monitoring-overview";
|
|
16
|
+
|
|
17
|
+
export type MonitoringMode = "publication" | "all";
|
|
18
|
+
|
|
19
|
+
const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
|
|
20
|
+
const status = (value: string) => STATUS_WORDS[value] ?? value;
|
|
21
|
+
const STOP_WORDS: Record<string, string> = {
|
|
22
|
+
request_ceiling_reached: "stopped at the request ceiling",
|
|
23
|
+
standing_capacity_reached: "stopped: the standing run is full",
|
|
24
|
+
provider_key_unavailable: "stopped: the Cyclotron has no model key",
|
|
25
|
+
run_busy: "stopped: the Cyclotron run was busy",
|
|
26
|
+
time_budget: "stopped for time",
|
|
27
|
+
};
|
|
28
|
+
const MARKS: Record<string, string> = { delivered: "✓", failed: "✕", pending: "…", not_applicable: "–" };
|
|
29
|
+
|
|
30
|
+
function feedHost(url: string | null): string {
|
|
31
|
+
try {
|
|
32
|
+
return url ? new URL(url).host : "unknown source";
|
|
33
|
+
} catch {
|
|
34
|
+
return "unknown source";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Message({ title, children, kind }: { title: string; children?: React.ReactNode; kind: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="category-steering-empty" role={kind === "loading" ? "status" : "alert"} data-monitoring-state={kind}>
|
|
41
|
+
<strong>{title}</strong>
|
|
42
|
+
{children ? <p>{children}</p> : null}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function RelevanceLine({ entry, linkable, demo }: { entry: MonitoringRelevance; linkable: boolean; demo: boolean }) {
|
|
48
|
+
const lineage = entry.delivery.referenceLineageId ?? entry.delivery.referenceId ?? null;
|
|
49
|
+
const delivery = describeDelivery(entry);
|
|
50
|
+
return (
|
|
51
|
+
<li data-relevance={entry.relevant === null ? "unknown" : entry.relevant ? "yes" : "no"} data-delivery={entry.delivery.state ?? "none"}>
|
|
52
|
+
<strong>{entry.publicationId}</strong>
|
|
53
|
+
<span>{describeRelevance(entry)}{entry.relevant !== null ? `, ${confidenceText(entry.confidence)}` : ""}</span>
|
|
54
|
+
{delivery ? (
|
|
55
|
+
<span>
|
|
56
|
+
<span aria-hidden="true">{MARKS[entry.delivery.state ?? ""] ?? ""} </span>
|
|
57
|
+
{delivery}
|
|
58
|
+
{entry.delivery.state === "delivered" && lineage && linkable ? (
|
|
59
|
+
<> <Link href={referenceHref(lineage, demo)} data-monitoring-reference-link>Open in References</Link></>
|
|
60
|
+
) : null}
|
|
61
|
+
{entry.delivery.state === "failed" && entry.delivery.error ? <> ({entry.delivery.error})</> : null}
|
|
62
|
+
</span>
|
|
63
|
+
) : null}
|
|
64
|
+
</li>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string | null; demo: boolean }) {
|
|
69
|
+
return (
|
|
70
|
+
<li data-monitoring-item={item.sourceId} data-routing={item.routing}>
|
|
71
|
+
<div>
|
|
72
|
+
<strong>{item.url ? <a href={item.url} target="_blank" rel="noreferrer noopener">{item.title}</a> : item.title}</strong>
|
|
73
|
+
<span>
|
|
74
|
+
{formatUtc(item.observedAt)}
|
|
75
|
+
{item.discussionUrl ? <> · <a href={item.discussionUrl} target="_blank" rel="noreferrer noopener">Hacker News thread</a></> : null}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<p className="news-desk-monitoring__routing">
|
|
79
|
+
{routingLabel(item.routing)}
|
|
80
|
+
{item.routingNote ? <>: {item.routingNote}</> : null}
|
|
81
|
+
</p>
|
|
82
|
+
{item.relevance.length ? (
|
|
83
|
+
<ul className="news-desk-monitoring__relevance">
|
|
84
|
+
{item.relevance.map((entry) => <RelevanceLine key={entry.publicationId} entry={entry} linkable={entry.publicationId === ownId} demo={demo} />)}
|
|
85
|
+
</ul>
|
|
86
|
+
) : null}
|
|
87
|
+
</li>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
|
|
92
|
+
const ceilings = data.usage?.ceilings ?? null;
|
|
93
|
+
const router = data.router;
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
{data.notice === "publication_not_routed" ? (
|
|
97
|
+
<p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router's outputs, so no relevance is shown for it here.</p>
|
|
98
|
+
) : null}
|
|
99
|
+
|
|
100
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
101
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
|
|
102
|
+
{router ? (
|
|
103
|
+
<p data-monitoring-router>
|
|
104
|
+
<strong>{router.mode === "live" ? "Live" : "Dry run"}</strong>
|
|
105
|
+
{router.mode === "live" ? " · decisions are made and delivered" : " · requests are checked but nothing is sent"}
|
|
106
|
+
{" · "}{router.feedback ? "Feedback on" : "Feedback off"}
|
|
107
|
+
{router.enabled ? "" : " · Paused"}
|
|
108
|
+
</p>
|
|
109
|
+
) : <p>The router has not been configured.</p>}
|
|
110
|
+
</article>
|
|
111
|
+
|
|
112
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="usage">
|
|
113
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Usage</h3><span>Decisions the router recorded, against the shared Cyclotron's ceilings (UTC days)</span></div></div>
|
|
114
|
+
{data.usage ? (
|
|
115
|
+
<>
|
|
116
|
+
<dl className="news-desk-monitoring__usage">
|
|
117
|
+
<div><dt>Decisions today ({data.usage.day})</dt><dd>{usageText(data.usage.decisionsToday, ceilings?.decisionsPerDay)}</dd></div>
|
|
118
|
+
<div><dt>Decisions in total</dt><dd>{usageText(data.usage.decisionsTotal, ceilings?.decisionsTotal)}</dd></div>
|
|
119
|
+
{ceilings?.items !== undefined ? <div><dt>Items judged (ceiling)</dt><dd>{usageText(data.usage.decisionsTotal, ceilings.items)}</dd></div> : null}
|
|
120
|
+
</dl>
|
|
121
|
+
{ceilings === null ? <p className="news-desk-delegation-note">No ceilings are configured here, so only usage is shown.</p> : null}
|
|
122
|
+
</>
|
|
123
|
+
) : <p>No usage recorded yet.</p>}
|
|
124
|
+
</article>
|
|
125
|
+
|
|
126
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="feeds">
|
|
127
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Feeds watched</h3><span>What the collector reads</span></div></div>
|
|
128
|
+
{data.feeds.length ? (
|
|
129
|
+
<ul className="news-desk-monitoring__list">
|
|
130
|
+
{data.feeds.map((feed) => (
|
|
131
|
+
<li key={feed.id} data-monitoring-feed={feed.id}>
|
|
132
|
+
<div>
|
|
133
|
+
<strong>{feed.id}</strong>
|
|
134
|
+
<span>{feedHost(feed.sourceUrl)}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<span>{feed.enabled ? "Enabled" : "Disabled"} · {everyText(feed.intervalSeconds)}{feed.limit ? ` · up to ${feed.limit} items` : ""}</span>
|
|
137
|
+
<span>Last run {formatUtc(feed.lastFinishedAt)}{feed.lastStatus ? ` (${status(feed.lastStatus)})` : ""} · next {formatUtc(feed.nextDueAt)}</span>
|
|
138
|
+
</li>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
) : <p>No feed is configured.</p>}
|
|
142
|
+
</article>
|
|
143
|
+
|
|
144
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="runs">
|
|
145
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Recent runs</h3><span>Collector receipts, newest first</span></div></div>
|
|
146
|
+
{data.runs.length ? (
|
|
147
|
+
<ul className="news-desk-monitoring__list">
|
|
148
|
+
{data.runs.map((run) => (
|
|
149
|
+
<li key={run.runId} data-monitoring-run={run.runId}>
|
|
150
|
+
<strong>{formatUtc(run.startedAt)}</strong>
|
|
151
|
+
<span>{status(run.status)}</span>
|
|
152
|
+
<span>{run.new} new · {run.revised} revised · {run.unchanged} unchanged · {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}{run.errors.length ? ` (${run.errors.join("; ")})` : ""}</span>
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
) : <p>No collector runs recorded yet.</p>}
|
|
157
|
+
{data.routerRuns.length ? (
|
|
158
|
+
<ul className="news-desk-monitoring__list" data-monitoring-router-runs>
|
|
159
|
+
{data.routerRuns.map((run) => (
|
|
160
|
+
<li key={run.runId} data-monitoring-router-run={run.runId}>
|
|
161
|
+
<strong>{formatUtc(run.startedAt)}</strong>
|
|
162
|
+
<span>Router, {run.mode === "live" ? "live" : "dry run"}: {status(run.status)}{run.stoppedBy ? `, ${STOP_WORDS[run.stoppedBy] ?? run.stoppedBy}` : ""}</span>
|
|
163
|
+
<span>{run.counts.examined ?? 0} examined · {run.counts.decided ?? 0} judged · {run.counts.delivered ?? 0} delivered · {run.counts.deliveryFailed ?? 0} {(run.counts.deliveryFailed ?? 0) === 1 ? "delivery failure" : "delivery failures"} · {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}</span>
|
|
164
|
+
</li>
|
|
165
|
+
))}
|
|
166
|
+
</ul>
|
|
167
|
+
) : null}
|
|
168
|
+
</article>
|
|
169
|
+
|
|
170
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
171
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
|
|
172
|
+
{data.items.length ? (
|
|
173
|
+
<ul className="news-desk-monitoring__queue">
|
|
174
|
+
{data.items.map((item) => <QueueItem key={`${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
175
|
+
</ul>
|
|
176
|
+
) : <p>No items in the queue.</p>}
|
|
177
|
+
{data.hiddenNotRelevant > 0 ? (
|
|
178
|
+
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
179
|
+
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
180
|
+
</p>
|
|
181
|
+
) : null}
|
|
182
|
+
</article>
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Presentation only: the panel fetches, this renders any result, so every state is testable from fixtures. */
|
|
188
|
+
export function MonitoringOverviewView({
|
|
189
|
+
result,
|
|
190
|
+
canViewAll,
|
|
191
|
+
mode,
|
|
192
|
+
onModeChange,
|
|
193
|
+
demo = false,
|
|
194
|
+
}: {
|
|
195
|
+
result: MonitoringResult | null;
|
|
196
|
+
canViewAll: boolean;
|
|
197
|
+
mode: MonitoringMode;
|
|
198
|
+
onModeChange: (mode: MonitoringMode) => void;
|
|
199
|
+
demo?: boolean;
|
|
200
|
+
}) {
|
|
201
|
+
let body: React.ReactNode;
|
|
202
|
+
if (result === null) {
|
|
203
|
+
body = <Message kind="loading" title="Loading monitoring" />;
|
|
204
|
+
} else if ("error" in result) {
|
|
205
|
+
body = result.error === "forbidden"
|
|
206
|
+
? <Message kind="forbidden" title="Monitoring is for editors and admins">Your account does not have access to this page. Ask an admin for the editor role.</Message>
|
|
207
|
+
: <Message kind="error" title="Monitoring could not be read">The request was not understood. Reload the page.</Message>;
|
|
208
|
+
} else if (result.state === "not_configured") {
|
|
209
|
+
body = <Message kind="not-configured" title="Monitoring is not set up for this publication">The monitoring pipeline is not connected to this site's account, so there is nothing to show.</Message>;
|
|
210
|
+
} else if (result.state === "unavailable") {
|
|
211
|
+
body = <Message kind="unavailable" title="Monitoring could not be read right now">The pipeline is not affected. Try again in a minute.</Message>;
|
|
212
|
+
} else if (result.state === "empty") {
|
|
213
|
+
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
214
|
+
} else {
|
|
215
|
+
body = <Ready data={result} demo={demo} />;
|
|
216
|
+
}
|
|
217
|
+
const scopeLine = result && "scope" in result
|
|
218
|
+
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
219
|
+
: null;
|
|
220
|
+
return (
|
|
221
|
+
<div data-news-desk-admin-panel="monitoring">
|
|
222
|
+
<article className="news-desk-doctrine-card">
|
|
223
|
+
<div className="news-desk-doctrine-card__header">
|
|
224
|
+
<div>
|
|
225
|
+
<h3>Monitoring</h3>
|
|
226
|
+
<span>The automated research pipeline: collection, judging and delivery to References. Read only.</span>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
{canViewAll ? (
|
|
230
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Scope">
|
|
231
|
+
<button type="button" aria-pressed={mode === "publication"} data-monitoring-scope="publication" onClick={() => onModeChange("publication")}>This publication</button>
|
|
232
|
+
<button type="button" aria-pressed={mode === "all"} data-monitoring-scope="all" onClick={() => onModeChange("all")}>Whole shared pipeline</button>
|
|
233
|
+
</div>
|
|
234
|
+
) : null}
|
|
235
|
+
{scopeLine ? <p className="news-desk-delegation-note" data-monitoring-scope-line>{scopeLine} {result && "asOf" in result ? `As of ${formatUtc(result.asOf)}.` : ""}</p> : null}
|
|
236
|
+
</article>
|
|
237
|
+
{body}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { loadMonitoringOverview } from "../lib/monitoring-client";
|
|
5
|
+
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
6
|
+
import type { MonitoringResult } from "../lib/monitoring-overview";
|
|
7
|
+
import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
8
|
+
|
|
9
|
+
const REFRESH_MILLISECONDS = 60_000;
|
|
10
|
+
|
|
11
|
+
/** Administration, Monitoring: loads the overview, refreshes it each minute, and lets an admin widen it to the whole pipeline. */
|
|
12
|
+
export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
13
|
+
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
14
|
+
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (isDemo) {
|
|
18
|
+
setResult(mode === "all" ? FIXTURE_ALL_PIPELINE_OVERVIEW : FIXTURE_MONITORING_OVERVIEW);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let live = true;
|
|
22
|
+
const refresh = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const next = await loadMonitoringOverview(mode);
|
|
25
|
+
if (live) setResult(next);
|
|
26
|
+
} catch {
|
|
27
|
+
if (live) setResult({ state: "unavailable" });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
setResult(null);
|
|
31
|
+
void refresh();
|
|
32
|
+
const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
|
|
33
|
+
return () => {
|
|
34
|
+
live = false;
|
|
35
|
+
window.clearInterval(timer);
|
|
36
|
+
};
|
|
37
|
+
}, [mode, isDemo]);
|
|
38
|
+
|
|
39
|
+
return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo} />;
|
|
40
|
+
}
|