@anthusai/papyrus 2.0.0-next.7 → 2.0.0-next.71
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 +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -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/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +180 -0
- package/amplify/plugins/reader-steering/config.ts +280 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +184 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +210 -0
- package/amplify/plugins/reader-steering/schema.js +204 -0
- package/amplify/plugins/reader-steering/schema.ts +206 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +38 -1
- package/amplify/site-backend.ts +46 -1
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -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/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- 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 +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +86 -6
- package/infra/site-config.ts +129 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -69
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -85
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
draftQuestions,
|
|
13
13
|
importStarterPack,
|
|
14
14
|
listPolicies,
|
|
15
|
+
listStarterPacks,
|
|
15
16
|
loadSettings,
|
|
16
17
|
recalibrate,
|
|
17
18
|
savePolicy,
|
|
@@ -19,9 +20,9 @@ import {
|
|
|
19
20
|
tryPolicy,
|
|
20
21
|
type EditorialPolicyRecord,
|
|
21
22
|
type EditorialSettings,
|
|
23
|
+
type StarterPackSummary,
|
|
22
24
|
} from "../lib/editorial-checks-client";
|
|
23
25
|
|
|
24
|
-
const STARTER_PACK = "ai-writing-tells";
|
|
25
26
|
const POLICY_KEY = /^[a-z0-9][a-z0-9._-]*$/;
|
|
26
27
|
|
|
27
28
|
type Draft = {
|
|
@@ -63,8 +64,8 @@ function lines(text: string): string[] {
|
|
|
63
64
|
return text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/** The policy as
|
|
67
|
-
function
|
|
67
|
+
/** The policy as Humanus sees it (its key is the policy id). Throws on invalid detector JSON. */
|
|
68
|
+
function humanusPolicy(draft: Draft): Record<string, unknown> {
|
|
68
69
|
const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
|
|
69
70
|
if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
|
|
70
71
|
const violates = lines(draft.violatesText);
|
|
@@ -87,6 +88,8 @@ export function EditorialChecksAdminPanel() {
|
|
|
87
88
|
const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
|
|
88
89
|
const [settingsExist, setSettingsExist] = useState(false);
|
|
89
90
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
91
|
+
const [starterPacks, setStarterPacks] = useState<StarterPackSummary[]>([]);
|
|
92
|
+
const [starterPack, setStarterPack] = useState<string>("");
|
|
90
93
|
const [message, setMessage] = useState<string | null>(null);
|
|
91
94
|
const [working, setWorking] = useState<string | null>(null);
|
|
92
95
|
const [tryText, setTryText] = useState("");
|
|
@@ -94,7 +97,9 @@ export function EditorialChecksAdminPanel() {
|
|
|
94
97
|
|
|
95
98
|
const refresh = useCallback(async () => {
|
|
96
99
|
try {
|
|
97
|
-
const [loadedPolicies, loadedSettings] = await Promise.all([listPolicies(), loadSettings()]);
|
|
100
|
+
const [loadedPolicies, loadedSettings, catalog] = await Promise.all([listPolicies(), loadSettings(), listStarterPacks()]);
|
|
101
|
+
setStarterPacks(catalog.packs);
|
|
102
|
+
setStarterPack((current) => current || catalog.packs[0]?.name || "");
|
|
98
103
|
setPolicies(loadedPolicies);
|
|
99
104
|
setSettings(loadedSettings.settings);
|
|
100
105
|
setSettingsExist(loadedSettings.exists);
|
|
@@ -128,7 +133,7 @@ export function EditorialChecksAdminPanel() {
|
|
|
128
133
|
const key = draft.record.policyKey.trim();
|
|
129
134
|
if (!POLICY_KEY.test(key)) throw new Error("The key must be lowercase letters, digits, dots, hyphens or underscores.");
|
|
130
135
|
if (!draft.record.title.trim() || !draft.record.guidance.trim()) throw new Error("A policy needs a title and guidance.");
|
|
131
|
-
const policy =
|
|
136
|
+
const policy = humanusPolicy(draft);
|
|
132
137
|
const record: EditorialPolicyRecord = {
|
|
133
138
|
...draft.record,
|
|
134
139
|
id: draft.isNew ? `${draft.record.scope}#${key}` : draft.record.id,
|
|
@@ -157,19 +162,38 @@ export function EditorialChecksAdminPanel() {
|
|
|
157
162
|
<h4>Policies</h4>
|
|
158
163
|
<div className="flex flex-wrap gap-2">
|
|
159
164
|
<Button onClick={() => { setDraft(draftFrom(emptyPolicy(), true)); setTryResult(null); }} size="sm" type="button">New policy</Button>
|
|
165
|
+
<select aria-label="Starter pack" onChange={(event) => setStarterPack(event.target.value)} value={starterPack}>
|
|
166
|
+
{starterPacks.map((pack) => (
|
|
167
|
+
<option key={pack.name} value={pack.name}>
|
|
168
|
+
{pack.title} ({pack.importedCount} of {pack.policyCount} added)
|
|
169
|
+
</option>
|
|
170
|
+
))}
|
|
171
|
+
</select>
|
|
160
172
|
<Button
|
|
161
|
-
disabled={working !== null}
|
|
173
|
+
disabled={working !== null || !starterPack}
|
|
162
174
|
onClick={() => run("import", async () => {
|
|
163
|
-
const outcome = await importStarterPack(
|
|
175
|
+
const outcome = await importStarterPack(starterPack);
|
|
164
176
|
await refresh();
|
|
165
|
-
|
|
177
|
+
const label = starterPacks.find((pack) => pack.name === starterPack)?.title ?? starterPack;
|
|
178
|
+
return `${label}: ${outcome.created} policies added, ${outcome.skipped} already present.`;
|
|
166
179
|
})}
|
|
167
180
|
size="sm"
|
|
168
181
|
type="button"
|
|
169
182
|
variant="outline"
|
|
170
183
|
>
|
|
171
|
-
Add
|
|
184
|
+
Add starter pack
|
|
172
185
|
</Button>
|
|
186
|
+
</div>
|
|
187
|
+
{(() => {
|
|
188
|
+
const selected = starterPacks.find((pack) => pack.name === starterPack);
|
|
189
|
+
return selected ? (
|
|
190
|
+
<p className="text-sm text-muted-foreground">
|
|
191
|
+
{selected.title}{selected.version ? ` ${selected.version}` : ""}: {selected.policyCount} policies
|
|
192
|
+
{selected.isDefault ? " (added to every new site)" : ""}. {selected.guidance}
|
|
193
|
+
</p>
|
|
194
|
+
) : null;
|
|
195
|
+
})()}
|
|
196
|
+
<div className="flex flex-wrap gap-2">
|
|
173
197
|
<Button
|
|
174
198
|
disabled={working !== null}
|
|
175
199
|
onClick={() => run("recalibrate", async () => {
|
|
@@ -243,7 +267,7 @@ export function EditorialChecksAdminPanel() {
|
|
|
243
267
|
<label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
|
|
244
268
|
<label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
|
|
245
269
|
<label>
|
|
246
|
-
Detectors (JSON; see <code>
|
|
270
|
+
Detectors (JSON; see <code>humanus schema policy-pack-v1</code>)
|
|
247
271
|
<Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
|
|
248
272
|
</label>
|
|
249
273
|
<div className="flex flex-wrap gap-2">
|
|
@@ -284,7 +308,7 @@ export function EditorialChecksAdminPanel() {
|
|
|
284
308
|
<Button
|
|
285
309
|
disabled={working !== null || !tryText.trim()}
|
|
286
310
|
onClick={() => run("try", async () => {
|
|
287
|
-
setTryResult(await tryPolicy(
|
|
311
|
+
setTryResult(await tryPolicy(humanusPolicy(draft), tryText));
|
|
288
312
|
})}
|
|
289
313
|
size="sm"
|
|
290
314
|
type="button"
|
|
@@ -320,6 +344,7 @@ export function EditorialChecksAdminPanel() {
|
|
|
320
344
|
});
|
|
321
345
|
}}
|
|
322
346
|
>
|
|
347
|
+
<label><input checked={settings.enabled} data-editorial-enabled onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} type="checkbox" /> Run editorial checks for this publication (turning this off stops checks and rewrites; policies are kept)</label>
|
|
323
348
|
<label><input checked={settings.lanes.rules} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, rules: event.target.checked } })} type="checkbox" /> Rule checks</label>
|
|
324
349
|
<label><input checked={settings.lanes.classifier} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, classifier: event.target.checked } })} type="checkbox" /> Judgment checks (decision model)</label>
|
|
325
350
|
<label><input checked={settings.checkWhileTyping} onChange={(event) => setSettings({ ...settings, checkWhileTyping: event.target.checked })} type="checkbox" /> Check while editors type (after a short pause)</label>
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
7
|
+
import {
|
|
8
|
+
buildActionLog,
|
|
9
|
+
buildHidden,
|
|
10
|
+
buildQueue,
|
|
11
|
+
moderationModeText,
|
|
12
|
+
receiptMessage,
|
|
13
|
+
shortReader,
|
|
14
|
+
type ActionEntry,
|
|
15
|
+
type ActionRow,
|
|
16
|
+
type CommentRow,
|
|
17
|
+
type ModerationActionName,
|
|
18
|
+
type ModerationRow,
|
|
19
|
+
type ModerationTarget,
|
|
20
|
+
type Receipt,
|
|
21
|
+
type ReportRow,
|
|
22
|
+
type SubmissionRow,
|
|
23
|
+
} from "../lib/reader-moderation";
|
|
24
|
+
|
|
25
|
+
const SUBMISSION_FIELDS = "id canonicalUrl domain title kind note readerId state moderation reportCount signals itemId createdAt";
|
|
26
|
+
const COMMENT_FIELDS = "id subjectKey readerId body parentId state moderation reportCount signals createdAt";
|
|
27
|
+
|
|
28
|
+
const QUEUE_SUBMISSIONS = `query Queue($nextToken: String) { listReaderSubmissionsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
|
|
29
|
+
const QUEUE_COMMENTS = `query Queue($nextToken: String) { listReaderCommentsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
|
|
30
|
+
const HIDDEN_SUBMISSIONS = `query Hidden($nextToken: String) { listReaderSubmissions(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
|
|
31
|
+
const HIDDEN_COMMENTS = `query Hidden($nextToken: String) { listReaderComments(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
|
|
32
|
+
const OPEN_REPORTS = `query Reports($nextToken: String) { listReaderReports(filter: { state: { eq: "open" } }, limit: 500, nextToken: $nextToken) { items { id targetType target reporterReaderId reason state createdAt } nextToken } }`;
|
|
33
|
+
const ACTIONS = `query Actions($nextToken: String) { listModerationActions(limit: 500, nextToken: $nextToken) { items { id by actorSub action targetType target reason fromState toState undoOf undoneBy at } nextToken } }`;
|
|
34
|
+
const HANDLES = `query Handles($nextToken: String) { listReaderHandles(limit: 1000, nextToken: $nextToken) { items { id readerId } nextToken } }`;
|
|
35
|
+
const MODERATE = `mutation Moderate($action: String!, $targetType: String!, $target: String!, $reason: String, $undoOf: String) { moderateReader(action: $action, targetType: $targetType, target: $target, reason: $reason, undoOf: $undoOf) { status id error message } }`;
|
|
36
|
+
|
|
37
|
+
type GraphResult = { data?: Record<string, { items: unknown[]; nextToken?: string | null } | Receipt | null>; errors?: { message?: string }[] };
|
|
38
|
+
const MAXIMUM_PAGES = 20;
|
|
39
|
+
|
|
40
|
+
async function loadAll<T>(query: string, field: string): Promise<T[]> {
|
|
41
|
+
const client = generateClient({ authMode: "userPool" });
|
|
42
|
+
const rows: T[] = [];
|
|
43
|
+
let nextToken: string | null | undefined;
|
|
44
|
+
for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
|
|
45
|
+
const result = (await client.graphql({ query, variables: { nextToken } })) as GraphResult;
|
|
46
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load moderation data.");
|
|
47
|
+
const connection = result.data?.[field] as { items: T[]; nextToken?: string | null } | undefined;
|
|
48
|
+
rows.push(...(connection?.items ?? []).filter(Boolean));
|
|
49
|
+
nextToken = connection?.nextToken;
|
|
50
|
+
if (!nextToken) break;
|
|
51
|
+
}
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function moderate(variables: Record<string, string | undefined>): Promise<Receipt> {
|
|
56
|
+
const client = generateClient({ authMode: "userPool" });
|
|
57
|
+
const result = (await client.graphql({ query: MODERATE, variables })) as GraphResult;
|
|
58
|
+
if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
|
|
59
|
+
return (result.data?.moderateReader as Receipt | null) ?? { status: "refused", message: "The action did not answer." };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ModerationViewProps = {
|
|
63
|
+
mode: unknown;
|
|
64
|
+
queue: ModerationRow[];
|
|
65
|
+
hidden: ModerationRow[];
|
|
66
|
+
log: ActionEntry[];
|
|
67
|
+
tab: "queue" | "hidden" | "log";
|
|
68
|
+
busy: boolean;
|
|
69
|
+
status: string;
|
|
70
|
+
reason: string;
|
|
71
|
+
onTab: (tab: "queue" | "hidden" | "log") => void;
|
|
72
|
+
onReason: (reason: string) => void;
|
|
73
|
+
onAction: (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => void;
|
|
74
|
+
onRefresh: () => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function Row({ row, busy, onAction, listed }: { row: ModerationRow; busy: boolean; onAction: ModerationViewProps["onAction"]; listed: "queue" | "hidden" }) {
|
|
78
|
+
const kind = row.kind === "submission" ? "Submission" : "Comment";
|
|
79
|
+
return (
|
|
80
|
+
<li data-moderation-row={row.id}>
|
|
81
|
+
<article aria-label={`${kind} by ${shortReader(row.readerId, row.handle)}`}>
|
|
82
|
+
<p>
|
|
83
|
+
<strong>{kind}</strong> by {shortReader(row.readerId, row.handle)} <time dateTime={row.createdAt}>{row.createdAt}</time> ({row.state}
|
|
84
|
+
{row.reports.length > 0 ? `, ${row.reports.length} ${row.reports.length === 1 ? "report" : "reports"}` : ""})
|
|
85
|
+
</p>
|
|
86
|
+
{row.kind === "submission" ? (
|
|
87
|
+
<p>
|
|
88
|
+
{row.url ? (
|
|
89
|
+
<a href={row.url} target="_blank" rel="noopener noreferrer nofollow">{row.title}</a>
|
|
90
|
+
) : (
|
|
91
|
+
<span>{row.title}</span>
|
|
92
|
+
)}
|
|
93
|
+
</p>
|
|
94
|
+
) : null}
|
|
95
|
+
{row.text ? <p style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.text}</p> : null}
|
|
96
|
+
{row.signals.length > 0 ? (
|
|
97
|
+
<ul aria-label="Automatic signals">
|
|
98
|
+
{row.signals.map((signal, index) => (
|
|
99
|
+
<li key={`${signal.detector}-${index}`}>Signal ({signal.severity}): {signal.policy} / {signal.detector}{signal.excerpt ? ` — “${signal.excerpt}”` : ""}</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
) : null}
|
|
103
|
+
{row.reports.length > 0 ? (
|
|
104
|
+
<ul aria-label="Reports">
|
|
105
|
+
{row.reports.map((report) => (
|
|
106
|
+
<li key={report.id}>Reported: {report.reason}</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
) : null}
|
|
110
|
+
<p>
|
|
111
|
+
{listed === "queue" ? (
|
|
112
|
+
<button type="button" disabled={busy} onClick={() => onAction("approve", row.kind, row.id)}>Approve</button>
|
|
113
|
+
) : (
|
|
114
|
+
<button type="button" disabled={busy} onClick={() => onAction("unhide", row.kind, row.id)}>Show again</button>
|
|
115
|
+
)}{" "}
|
|
116
|
+
{row.state !== "hidden" ? <button type="button" disabled={busy} onClick={() => onAction("hide", row.kind, row.id)}>Hide</button> : null}{" "}
|
|
117
|
+
{row.canPromote ? <button type="button" disabled={busy} onClick={() => onAction("promote", row.kind, row.id)}>Promote to a draft</button> : null}{" "}
|
|
118
|
+
<button type="button" disabled={busy} onClick={() => onAction("ban", "reader", row.readerId)}>Ban reader</button>{" "}
|
|
119
|
+
<button type="button" disabled={busy} onClick={() => onAction("limit", "reader", row.readerId)}>Limit reader</button>
|
|
120
|
+
</p>
|
|
121
|
+
</article>
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ModerationView(props: ModerationViewProps) {
|
|
127
|
+
const { queue, hidden, log, tab, busy } = props;
|
|
128
|
+
return (
|
|
129
|
+
<section aria-labelledby="newsroom-moderation-title" data-news-desk-admin-panel="moderation">
|
|
130
|
+
<h3 id="newsroom-moderation-title">Moderation</h3>
|
|
131
|
+
<p>{moderationModeText(props.mode)} Only editors and admins can see this.</p>
|
|
132
|
+
<p role="tablist" aria-label="Moderation views">
|
|
133
|
+
<button type="button" role="tab" aria-selected={tab === "queue"} onClick={() => props.onTab("queue")}>Queue ({queue.length})</button>{" "}
|
|
134
|
+
<button type="button" role="tab" aria-selected={tab === "hidden"} onClick={() => props.onTab("hidden")}>Hidden ({hidden.length})</button>{" "}
|
|
135
|
+
<button type="button" role="tab" aria-selected={tab === "log"} onClick={() => props.onTab("log")}>Recent actions</button>{" "}
|
|
136
|
+
<button type="button" onClick={props.onRefresh} disabled={busy}>Refresh</button>
|
|
137
|
+
</p>
|
|
138
|
+
<label>
|
|
139
|
+
Reason for the next action (optional){" "}
|
|
140
|
+
<input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
|
|
141
|
+
</label>
|
|
142
|
+
<p role="status" aria-live="polite">{props.status}</p>
|
|
143
|
+
{tab === "queue" ? (
|
|
144
|
+
queue.length === 0 ? <p>Nothing is waiting for a decision.</p> : <ul>{queue.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="queue" />)}</ul>
|
|
145
|
+
) : null}
|
|
146
|
+
{tab === "hidden" ? (
|
|
147
|
+
hidden.length === 0 ? <p>Nothing is hidden.</p> : <ul>{hidden.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="hidden" />)}</ul>
|
|
148
|
+
) : null}
|
|
149
|
+
{tab === "log" ? (
|
|
150
|
+
log.length === 0 ? <p>No moderation actions yet.</p> : (
|
|
151
|
+
<table>
|
|
152
|
+
<thead>
|
|
153
|
+
<tr><th scope="col">When</th><th scope="col">Action</th><th scope="col">Target</th><th scope="col">By</th><th scope="col">Reason</th><th scope="col"><span>Undo</span></th></tr>
|
|
154
|
+
</thead>
|
|
155
|
+
<tbody>
|
|
156
|
+
{log.map((entry) => (
|
|
157
|
+
<tr key={entry.id}>
|
|
158
|
+
<td>{entry.at}</td>
|
|
159
|
+
<td>{entry.label} ({entry.fromState} to {entry.toState}){entry.undoneBy ? ", undone" : ""}</td>
|
|
160
|
+
<td>{entry.targetType} {entry.target.slice(0, 12)}</td>
|
|
161
|
+
<td>{entry.byLabel}</td>
|
|
162
|
+
<td>{entry.reason}</td>
|
|
163
|
+
<td>
|
|
164
|
+
{entry.undoable ? (
|
|
165
|
+
<button type="button" disabled={busy} onClick={() => props.onAction("undo", entry.targetType as ModerationTarget, entry.target, entry.id)}>Undo</button>
|
|
166
|
+
) : null}
|
|
167
|
+
</td>
|
|
168
|
+
</tr>
|
|
169
|
+
))}
|
|
170
|
+
</tbody>
|
|
171
|
+
</table>
|
|
172
|
+
)
|
|
173
|
+
) : null}
|
|
174
|
+
</section>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function ModerationPanel() {
|
|
179
|
+
const mode = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering")?.moderation, []);
|
|
180
|
+
const [tab, setTab] = useState<"queue" | "hidden" | "log">("queue");
|
|
181
|
+
const [data, setData] = useState<{ queue: ModerationRow[]; hidden: ModerationRow[]; log: ActionEntry[] } | null>(null);
|
|
182
|
+
const [status, setStatus] = useState("Loading...");
|
|
183
|
+
const [busy, setBusy] = useState(false);
|
|
184
|
+
const [reason, setReason] = useState("");
|
|
185
|
+
|
|
186
|
+
const refresh = useCallback(async (quiet = false) => {
|
|
187
|
+
setBusy(true);
|
|
188
|
+
try {
|
|
189
|
+
const [newSubmissions, newComments, hiddenSubmissions, hiddenComments, reports, actions, handleRows] = await Promise.all([
|
|
190
|
+
loadAll<SubmissionRow>(QUEUE_SUBMISSIONS, "listReaderSubmissionsByModerationAndCreatedAt"),
|
|
191
|
+
loadAll<CommentRow>(QUEUE_COMMENTS, "listReaderCommentsByModerationAndCreatedAt"),
|
|
192
|
+
loadAll<SubmissionRow>(HIDDEN_SUBMISSIONS, "listReaderSubmissions"),
|
|
193
|
+
loadAll<CommentRow>(HIDDEN_COMMENTS, "listReaderComments"),
|
|
194
|
+
loadAll<ReportRow>(OPEN_REPORTS, "listReaderReports"),
|
|
195
|
+
loadAll<ActionRow>(ACTIONS, "listModerationActions"),
|
|
196
|
+
loadAll<{ id: string; readerId: string }>(HANDLES, "listReaderHandles"),
|
|
197
|
+
]);
|
|
198
|
+
const handles = Object.fromEntries(handleRows.map((row) => [row.readerId, row.id]));
|
|
199
|
+
setData({
|
|
200
|
+
queue: buildQueue({ submissions: newSubmissions, comments: newComments, reports, handles }),
|
|
201
|
+
hidden: buildHidden({ submissions: hiddenSubmissions, comments: hiddenComments, reports, handles }),
|
|
202
|
+
log: buildActionLog(actions),
|
|
203
|
+
});
|
|
204
|
+
if (!quiet) setStatus("");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "Could not load moderation data."}`);
|
|
207
|
+
} finally {
|
|
208
|
+
setBusy(false);
|
|
209
|
+
}
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
void refresh();
|
|
214
|
+
}, [refresh]);
|
|
215
|
+
|
|
216
|
+
const onAction = useCallback(
|
|
217
|
+
async (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => {
|
|
218
|
+
setBusy(true);
|
|
219
|
+
setStatus("Working...");
|
|
220
|
+
try {
|
|
221
|
+
const receipt = await moderate({ action, targetType, target, ...(reason.trim() ? { reason: reason.trim() } : {}), ...(undoOf ? { undoOf } : {}) });
|
|
222
|
+
setStatus(receiptMessage(receipt));
|
|
223
|
+
if (receipt.status === "ok") setReason("");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
|
|
226
|
+
}
|
|
227
|
+
await refresh(true);
|
|
228
|
+
},
|
|
229
|
+
[reason, refresh],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<ModerationView
|
|
234
|
+
mode={mode}
|
|
235
|
+
queue={data?.queue ?? []}
|
|
236
|
+
hidden={data?.hidden ?? []}
|
|
237
|
+
log={data?.log ?? []}
|
|
238
|
+
tab={tab}
|
|
239
|
+
busy={busy}
|
|
240
|
+
status={status}
|
|
241
|
+
reason={reason}
|
|
242
|
+
onTab={setTab}
|
|
243
|
+
onReason={setReason}
|
|
244
|
+
onAction={(action, targetType, target, undoOf) => void onAction(action, targetType, target, undoOf)}
|
|
245
|
+
onRefresh={() => void refresh()}
|
|
246
|
+
/>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
{run.waitingForClassifier?.length ? <span data-monitoring-waiting-for-classifier>Waiting for classifier: {run.waitingForClassifier.join(", ")} (skipped, not an error; probed on a 30 minute to 6 hour backoff)</span> : null}
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ul>
|
|
168
|
+
) : null}
|
|
169
|
+
</article>
|
|
170
|
+
|
|
171
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
172
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
|
|
173
|
+
{data.items.length ? (
|
|
174
|
+
<ul className="news-desk-monitoring__queue">
|
|
175
|
+
{data.items.map((item) => <QueueItem key={`${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
176
|
+
</ul>
|
|
177
|
+
) : <p>No items in the queue.</p>}
|
|
178
|
+
{data.hiddenNotRelevant > 0 ? (
|
|
179
|
+
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
180
|
+
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
181
|
+
</p>
|
|
182
|
+
) : null}
|
|
183
|
+
</article>
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Presentation only: the panel fetches, this renders any result, so every state is testable from fixtures. */
|
|
189
|
+
export function MonitoringOverviewView({
|
|
190
|
+
result,
|
|
191
|
+
canViewAll,
|
|
192
|
+
mode,
|
|
193
|
+
onModeChange,
|
|
194
|
+
demo = false,
|
|
195
|
+
}: {
|
|
196
|
+
result: MonitoringResult | null;
|
|
197
|
+
canViewAll: boolean;
|
|
198
|
+
mode: MonitoringMode;
|
|
199
|
+
onModeChange: (mode: MonitoringMode) => void;
|
|
200
|
+
demo?: boolean;
|
|
201
|
+
}) {
|
|
202
|
+
let body: React.ReactNode;
|
|
203
|
+
if (result === null) {
|
|
204
|
+
body = <Message kind="loading" title="Loading monitoring" />;
|
|
205
|
+
} else if ("error" in result) {
|
|
206
|
+
body = result.error === "forbidden"
|
|
207
|
+
? <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>
|
|
208
|
+
: <Message kind="error" title="Monitoring could not be read">The request was not understood. Reload the page.</Message>;
|
|
209
|
+
} else if (result.state === "not_configured") {
|
|
210
|
+
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>;
|
|
211
|
+
} else if (result.state === "unavailable") {
|
|
212
|
+
body = <Message kind="unavailable" title="Monitoring could not be read right now">The pipeline is not affected. Try again in a minute.</Message>;
|
|
213
|
+
} else if (result.state === "empty") {
|
|
214
|
+
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>;
|
|
215
|
+
} else {
|
|
216
|
+
body = <Ready data={result} demo={demo} />;
|
|
217
|
+
}
|
|
218
|
+
const scopeLine = result && "scope" in result
|
|
219
|
+
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
220
|
+
: null;
|
|
221
|
+
return (
|
|
222
|
+
<div data-news-desk-admin-panel="monitoring">
|
|
223
|
+
<article className="news-desk-doctrine-card">
|
|
224
|
+
<div className="news-desk-doctrine-card__header">
|
|
225
|
+
<div>
|
|
226
|
+
<h3>Monitoring</h3>
|
|
227
|
+
<span>The automated research pipeline: collection, judging and delivery to References. Read only.</span>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
{canViewAll ? (
|
|
231
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Scope">
|
|
232
|
+
<button type="button" aria-pressed={mode === "publication"} data-monitoring-scope="publication" onClick={() => onModeChange("publication")}>This publication</button>
|
|
233
|
+
<button type="button" aria-pressed={mode === "all"} data-monitoring-scope="all" onClick={() => onModeChange("all")}>Whole shared pipeline</button>
|
|
234
|
+
</div>
|
|
235
|
+
) : null}
|
|
236
|
+
{scopeLine ? <p className="news-desk-delegation-note" data-monitoring-scope-line>{scopeLine} {result && "asOf" in result ? `As of ${formatUtc(result.asOf)}.` : ""}</p> : null}
|
|
237
|
+
</article>
|
|
238
|
+
{body}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -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
|
+
}
|