@anchrd/intel-ui 0.22.0 → 0.25.0
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/package.json +2 -2
- package/src/access-summary/access-summary.tsx +282 -0
- package/src/app/app-tree/app-tree.tsx +47 -8
- package/src/app/app.tsx +7 -38
- package/src/app/settings-dialog/settings-dialog.tsx +38 -5
- package/src/app/user-footer/user-footer.tsx +31 -13
- package/src/app-root/app-root.tsx +109 -0
- package/src/app-root/app-root.types.ts +27 -0
- package/src/archive/archive.tsx +145 -12
- package/src/data/intel-data-provider/intel-data-provider.ts +33 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +15 -0
- package/src/flow-runs/flow-runs.tsx +3 -1
- package/src/flows/flows.tsx +5 -11
- package/src/folder-contents/folder-contents.tsx +15 -1
- package/src/i18n/de.json +29 -5
- package/src/i18n/en.json +29 -5
- package/src/i18n/es.json +29 -5
- package/src/main.tsx +26 -32
- package/src/node-table/node-table.tsx +10 -50
- package/src/nodes/nodes.tsx +21 -18
- package/src/resource-menu/resource-menu.tsx +79 -75
- package/src/time/time-context.tsx +72 -0
- package/src/time/time.ts +140 -0
- package/src/title-row/title-row.tsx +4 -0
- package/src/components/ui/breadcrumb.tsx +0 -102
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { RouterProvider } from "@tanstack/react-router";
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
5
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
6
|
+
import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
|
|
7
|
+
import type { AppRootDeps, RefusalStore } from "./app-root.types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one decision this application makes before it draws anything: **is there a session?**
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ Without a session there is NO SHELL — not the sidebar, not the header, not the search (#456).
|
|
13
|
+
* It used to paint first and ask afterwards: the `RouterProvider` started, the shell stood, the
|
|
14
|
+
* queries went out, and only when the first of them came back 401 did the redirect happen. Between
|
|
15
|
+
* "painted" and "redirected" lay a full network round trip in which a stranger saw the application.
|
|
16
|
+
* A slow answer stretched that window arbitrarily, and a route that fires no query at all never
|
|
17
|
+
* closed it.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ What is NOT claimed: that data would become visible. The api answers 401 and the lists stay
|
|
20
|
+
* empty. But that one server answer was then the ONLY defence — the client had none. Any surface
|
|
21
|
+
* that later renders something before the first request (a cache, a `localStorage`, an optimistic
|
|
22
|
+
* value) would break it without meaning to. And an application that looks as if one were inside it
|
|
23
|
+
* and contains nothing is the wrong first impression from a product whose business is access
|
|
24
|
+
* control.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **This is the file #130 asked for.** The logic sat in `main.tsx`, `main.tsx` had no test, and
|
|
27
|
+
* therefore none of the eleven tests written for #118 ever touched it: *"the entry point is exactly
|
|
28
|
+
* the place where 'this is only wiring' sounds most plausible and costs most."* The decision now
|
|
29
|
+
* lives somewhere a test can call.
|
|
30
|
+
*/
|
|
31
|
+
export function AppRoot({ data, router, refusal }: AppRootDeps) {
|
|
32
|
+
const refused = useSyncExternalStore(refusal.subscribe, refusal.isRefused, refusal.isRefused);
|
|
33
|
+
if (refused) return <SignInRefused />;
|
|
34
|
+
return <SessionGate data={data} router={router} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ⚠️ Its own component, and not merged into `AppRoot`: the hook below may only run once a
|
|
39
|
+
* `QueryClientProvider` is above it, and `refused` has to be answerable without one — the refusal
|
|
40
|
+
* screen is what stands there when nothing else works.
|
|
41
|
+
*/
|
|
42
|
+
function SessionGate({ data, router }: { data: IntelDataProvider; router: AppRootDeps["router"] }) {
|
|
43
|
+
const i18n = useI18n();
|
|
44
|
+
// ⚠️ `retry: false`, deliberately against the client's default policy: this answer is
|
|
45
|
+
// authoritative in both directions. A 401 is not a hiccup to be retried — the provider's
|
|
46
|
+
// `onUnauthorized` is already taking the reader to Gate while this promise rejects — and a retry
|
|
47
|
+
// here would additionally be PAUSED while the tab is unfocused (`packages/ui/CLAUDE.md`), which
|
|
48
|
+
// is how a guard turns into a permanent "Loading" for somebody looking elsewhere.
|
|
49
|
+
//
|
|
50
|
+
// The key is the one `UserFooter` reads, so the shell's own session query is already answered
|
|
51
|
+
// when it mounts. One question, asked once.
|
|
52
|
+
const session = useQuery({
|
|
53
|
+
queryKey: ["session"],
|
|
54
|
+
queryFn: () => data.getSession(),
|
|
55
|
+
retry: false,
|
|
56
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ⚠️ Both the waiting and the failing case render NO shell. The failing one is not the same as
|
|
60
|
+
// "not signed in": a 401 has already sent the reader away by the time it lands here, so what is
|
|
61
|
+
// left is a surface that could not be reached at all — and saying that is better than a spinner
|
|
62
|
+
// that never ends. Neither of them draws a sidebar.
|
|
63
|
+
if (session.isPending) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="grid min-h-dvh place-items-center bg-background p-8">
|
|
66
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (session.isError) {
|
|
71
|
+
return (
|
|
72
|
+
<div role="alert" className="grid min-h-dvh place-items-center bg-background p-8">
|
|
73
|
+
<div className="max-w-prose text-center">
|
|
74
|
+
<p className="text-sm text-muted-foreground">{i18n.t("signIn.unreachable")}</p>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => window.location.reload()}
|
|
78
|
+
className="mt-6 rounded-md border px-4 py-2 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
79
|
+
>
|
|
80
|
+
{i18n.t("common.retry")}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return <RouterProvider router={router} />;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* ⚠️ Latching, and that is not an optimisation: once the surface has turned down a freshly issued
|
|
91
|
+
* credential, later 401s say nothing new. Without the latch every request the page still had in
|
|
92
|
+
* flight would notify again, and the screen would flicker once per answer.
|
|
93
|
+
*/
|
|
94
|
+
export function createRefusalStore(): RefusalStore {
|
|
95
|
+
let refused = false;
|
|
96
|
+
const listeners = new Set<() => void>();
|
|
97
|
+
return {
|
|
98
|
+
subscribe(listener) {
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
return () => listeners.delete(listener);
|
|
101
|
+
},
|
|
102
|
+
isRefused: () => refused,
|
|
103
|
+
refuse() {
|
|
104
|
+
if (refused) return;
|
|
105
|
+
refused = true;
|
|
106
|
+
for (const listener of listeners) listener();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
2
|
+
import type { createIntelRouter } from "@/router/router.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the surface has turned down a freshly issued credential (#118) — a store rather than a
|
|
6
|
+
* module-level `let`, and that is the whole point of #456.
|
|
7
|
+
*
|
|
8
|
+
* The refusal arrives from inside a `fetch`, not from React: the data provider calls
|
|
9
|
+
* `onUnauthorized`, and something on screen has to change because of it. That used to be a
|
|
10
|
+
* `let refused = false` in `main.tsx` with a hand-written repaint — a decision in bare module body,
|
|
11
|
+
* which is exactly what nothing could call and therefore what no test ever touched (#130).
|
|
12
|
+
*/
|
|
13
|
+
export interface RefusalStore {
|
|
14
|
+
subscribe(listener: () => void): () => void;
|
|
15
|
+
isRefused(): boolean;
|
|
16
|
+
refuse(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type IntelRouter = ReturnType<typeof createIntelRouter>;
|
|
20
|
+
|
|
21
|
+
export interface AppRootDeps {
|
|
22
|
+
// ⚠️ The provider is handed in rather than read from the router context: that context only exists
|
|
23
|
+
// INSIDE `RouterProvider`, and the whole point of the gate is to decide before it mounts.
|
|
24
|
+
data: IntelDataProvider;
|
|
25
|
+
router: IntelRouter;
|
|
26
|
+
refusal: RefusalStore;
|
|
27
|
+
}
|
package/src/archive/archive.tsx
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { Flow } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
3
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import { ArchiveRestore } from "lucide-react";
|
|
4
|
+
import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
5
7
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
6
8
|
import { kindIcons } from "@/kind-icon.ts";
|
|
9
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
7
10
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
11
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
8
12
|
|
|
9
13
|
// One archived thing, whichever side of the tree it came from. The two records stay apart
|
|
10
14
|
// everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
|
|
@@ -16,11 +20,16 @@ interface ArchivedEntry {
|
|
|
16
20
|
archivedAt: string;
|
|
17
21
|
updatedAt: string;
|
|
18
22
|
restore(): Promise<unknown>;
|
|
23
|
+
// Both sides of the tree can be purged (#457) — each through its own door, because node and flow
|
|
24
|
+
// stay separate everywhere else (ADR-0004).
|
|
25
|
+
purge(): Promise<{ purged: true; title: string }>;
|
|
26
|
+
previewPurge?(): Promise<{ inboundLinks: number; totalItems: number }>;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export function Archive() {
|
|
22
30
|
const { data } = useIntelRouterContext();
|
|
23
31
|
const i18n = useI18n();
|
|
32
|
+
const dateTime = useDateTime();
|
|
24
33
|
const queryClient = useQueryClient();
|
|
25
34
|
|
|
26
35
|
const archived = useQuery({
|
|
@@ -31,8 +40,10 @@ export function Archive() {
|
|
|
31
40
|
data.listFlows({ archivedOnly: true }),
|
|
32
41
|
]);
|
|
33
42
|
const entries: ArchivedEntry[] = [
|
|
34
|
-
...nodes.items.flatMap((node) =>
|
|
35
|
-
|
|
43
|
+
...nodes.items.flatMap((node) => {
|
|
44
|
+
// Stable across reloads: after D1 commits, only this key can reach an R2 cleanup receipt.
|
|
45
|
+
const purgeKey = `purge-${node.id}`;
|
|
46
|
+
return node.archivedAt
|
|
36
47
|
? [
|
|
37
48
|
{
|
|
38
49
|
id: node.id,
|
|
@@ -47,10 +58,12 @@ export function Archive() {
|
|
|
47
58
|
archived: false,
|
|
48
59
|
idempotencyKey: crypto.randomUUID(),
|
|
49
60
|
}),
|
|
61
|
+
purge: () => data.purgeNode({ nodeId: node.id, idempotencyKey: purgeKey }),
|
|
62
|
+
previewPurge: () => data.previewNodePurge({ nodeId: node.id }),
|
|
50
63
|
} satisfies ArchivedEntry,
|
|
51
64
|
]
|
|
52
|
-
: []
|
|
53
|
-
),
|
|
65
|
+
: [];
|
|
66
|
+
}),
|
|
54
67
|
...flows.items.flatMap((flow: Flow) =>
|
|
55
68
|
flow.archivedAt
|
|
56
69
|
? [
|
|
@@ -67,6 +80,7 @@ export function Archive() {
|
|
|
67
80
|
archived: false,
|
|
68
81
|
idempotencyKey: crypto.randomUUID(),
|
|
69
82
|
}),
|
|
83
|
+
purge: () => data.purgeFlow({ flowId: flow.id }),
|
|
70
84
|
} satisfies ArchivedEntry,
|
|
71
85
|
]
|
|
72
86
|
: [],
|
|
@@ -95,6 +109,31 @@ export function Archive() {
|
|
|
95
109
|
},
|
|
96
110
|
});
|
|
97
111
|
|
|
112
|
+
// ⚠️ The confirmation is a real dialog, not a `window.confirm` — it has to name the thing and say
|
|
113
|
+
// that it does not come back. The state lives here because the row underneath disappears the
|
|
114
|
+
// moment the purge goes through.
|
|
115
|
+
const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
|
|
116
|
+
const preview = useQuery({
|
|
117
|
+
queryKey: ["purge-preview", confirming?.id],
|
|
118
|
+
queryFn: async () => await confirming?.previewPurge?.(),
|
|
119
|
+
enabled: Boolean(confirming?.previewPurge),
|
|
120
|
+
retry: false,
|
|
121
|
+
});
|
|
122
|
+
const purge = useMutation({
|
|
123
|
+
mutationFn: async (entry: ArchivedEntry) => await entry.purge(),
|
|
124
|
+
onSuccess: async () => {
|
|
125
|
+
setConfirming(null);
|
|
126
|
+
await Promise.all([
|
|
127
|
+
queryClient.invalidateQueries({ queryKey: ["archive"] }),
|
|
128
|
+
// The tree and the graph did not show the node, but its DISAPPEARANCE can be visible
|
|
129
|
+
// elsewhere: a reference to it no longer resolves afterwards.
|
|
130
|
+
queryClient.invalidateQueries({ queryKey: ["tree"] }),
|
|
131
|
+
queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
|
|
132
|
+
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
133
|
+
]);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
98
137
|
return (
|
|
99
138
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
100
139
|
<div className="border-b px-6 py-4">
|
|
@@ -114,6 +153,13 @@ export function Archive() {
|
|
|
114
153
|
{i18n.t("archive.restoreFailed")}
|
|
115
154
|
</p>
|
|
116
155
|
) : null}
|
|
156
|
+
{/* ⚠️ The refusal stands HERE and not in the dialog: it carries the reason — folder not
|
|
157
|
+
empty, a flow still uses it — and it has to stay readable after the dialog is closed. */}
|
|
158
|
+
{purge.isError ? (
|
|
159
|
+
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
160
|
+
{i18n.t("archive.purgeFailed")}
|
|
161
|
+
</p>
|
|
162
|
+
) : null}
|
|
117
163
|
{archived.data?.length === 0 ? (
|
|
118
164
|
<p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
|
|
119
165
|
) : null}
|
|
@@ -135,23 +181,110 @@ export function Archive() {
|
|
|
135
181
|
<span className="grid min-w-0 flex-1 leading-tight">
|
|
136
182
|
<span className="truncate text-sm font-medium">{entry.title}</span>
|
|
137
183
|
<span className="truncate text-xs text-muted-foreground">
|
|
138
|
-
{i18n.t("archive.archivedAt", { when: entry.archivedAt
|
|
184
|
+
{i18n.t("archive.archivedAt", { when: dateTime.at(entry.archivedAt) })}
|
|
139
185
|
</span>
|
|
140
186
|
</span>
|
|
187
|
+
{/* Both actions stay permanently visible: the archive is the one place where getting
|
|
188
|
+
an item back must never depend on discovering a hover-only control. */}
|
|
189
|
+
<TooltipProvider delayDuration={300}>
|
|
190
|
+
<Tooltip>
|
|
191
|
+
{/* The wrapper keeps the tooltip available while the button is disabled. */}
|
|
192
|
+
<TooltipTrigger asChild>
|
|
193
|
+
<span className="inline-flex shrink-0">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
disabled={restore.isPending}
|
|
197
|
+
onClick={() => restore.mutate(entry)}
|
|
198
|
+
aria-label={i18n.t(
|
|
199
|
+
restore.isPending && restore.variables?.id === entry.id
|
|
200
|
+
? "archive.restoring"
|
|
201
|
+
: "archive.restore",
|
|
202
|
+
{ title: entry.title },
|
|
203
|
+
)}
|
|
204
|
+
aria-busy={restore.isPending && restore.variables?.id === entry.id}
|
|
205
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
206
|
+
>
|
|
207
|
+
{restore.isPending && restore.variables?.id === entry.id ? (
|
|
208
|
+
<LoaderCircle aria-hidden="true" className="size-4 animate-spin" />
|
|
209
|
+
) : (
|
|
210
|
+
<ArchiveRestore aria-hidden="true" className="size-4" />
|
|
211
|
+
)}
|
|
212
|
+
</button>
|
|
213
|
+
</span>
|
|
214
|
+
</TooltipTrigger>
|
|
215
|
+
<TooltipContent>
|
|
216
|
+
{i18n.t(
|
|
217
|
+
restore.isPending && restore.variables?.id === entry.id
|
|
218
|
+
? "archive.restoringAction"
|
|
219
|
+
: "archive.restoreAction",
|
|
220
|
+
)}
|
|
221
|
+
</TooltipContent>
|
|
222
|
+
</Tooltip>
|
|
223
|
+
</TooltipProvider>
|
|
141
224
|
<button
|
|
142
225
|
type="button"
|
|
143
|
-
disabled={
|
|
144
|
-
onClick={() =>
|
|
145
|
-
aria-label={i18n.t("archive.
|
|
146
|
-
className="inline-flex
|
|
226
|
+
disabled={purge.isPending}
|
|
227
|
+
onClick={() => setConfirming(entry)}
|
|
228
|
+
aria-label={i18n.t("archive.purge", { title: entry.title })}
|
|
229
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
147
230
|
>
|
|
148
|
-
<
|
|
149
|
-
{i18n.t("archive.restoreAction")}
|
|
231
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
150
232
|
</button>
|
|
151
233
|
</li>
|
|
152
234
|
))}
|
|
153
235
|
</ul>
|
|
154
236
|
</div>
|
|
237
|
+
{confirming ? (
|
|
238
|
+
<Modal title={i18n.t("archive.purge.title")} close={() => setConfirming(null)}>
|
|
239
|
+
<p className="text-sm text-muted-foreground">
|
|
240
|
+
{i18n.t("archive.purge.body", { title: confirming.title })}
|
|
241
|
+
</p>
|
|
242
|
+
{confirming.previewPurge && preview.data ? (
|
|
243
|
+
<div className="mt-2 space-y-2 text-sm text-muted-foreground">
|
|
244
|
+
<p>{i18n.t("archive.purge.items", { count: preview.data.totalItems })}</p>
|
|
245
|
+
<p>
|
|
246
|
+
{preview.data.inboundLinks === 0
|
|
247
|
+
? i18n.t("archive.purge.links.none")
|
|
248
|
+
: preview.data.inboundLinks === 1
|
|
249
|
+
? i18n.t("archive.purge.links.one")
|
|
250
|
+
: i18n.t("archive.purge.links.many", { count: preview.data.inboundLinks })}
|
|
251
|
+
</p>
|
|
252
|
+
</div>
|
|
253
|
+
) : null}
|
|
254
|
+
{confirming.previewPurge && preview.isPending ? (
|
|
255
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
256
|
+
{i18n.t("archive.purge.previewLoading")}
|
|
257
|
+
</p>
|
|
258
|
+
) : null}
|
|
259
|
+
{confirming.previewPurge && preview.isError ? (
|
|
260
|
+
<p role="alert" className="mt-2 text-sm text-destructive">
|
|
261
|
+
{i18n.t("archive.purge.previewFailed")}
|
|
262
|
+
</p>
|
|
263
|
+
) : null}
|
|
264
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
|
|
265
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={() => setConfirming(null)}
|
|
269
|
+
className="inline-flex h-8 items-center rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
270
|
+
>
|
|
271
|
+
{i18n.t("archive.purge.cancel")}
|
|
272
|
+
</button>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
disabled={
|
|
276
|
+
purge.isPending ||
|
|
277
|
+
(Boolean(confirming.previewPurge) && (preview.isPending || preview.isError))
|
|
278
|
+
}
|
|
279
|
+
onClick={() => purge.mutate(confirming)}
|
|
280
|
+
className="inline-flex h-8 items-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
281
|
+
>
|
|
282
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
283
|
+
{i18n.t("archive.purge.confirm")}
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
</Modal>
|
|
287
|
+
) : null}
|
|
155
288
|
</div>
|
|
156
289
|
);
|
|
157
290
|
}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
ListFlowsInput,
|
|
13
13
|
PreviewFlowPublishInput,
|
|
14
14
|
PublishFlowInput,
|
|
15
|
+
PurgeFlowInput,
|
|
16
|
+
PurgeFlowResult,
|
|
15
17
|
RelationGraph,
|
|
16
18
|
RelationGraphInput,
|
|
17
19
|
SaveFlowVersionInput,
|
|
@@ -36,6 +38,10 @@ import {
|
|
|
36
38
|
NodeList,
|
|
37
39
|
NodeTable,
|
|
38
40
|
NodeVersionList,
|
|
41
|
+
PurgeNodeInput,
|
|
42
|
+
PurgeNodePreview,
|
|
43
|
+
PurgeNodePreviewInput,
|
|
44
|
+
PurgeNodeResult,
|
|
39
45
|
ReindexResult,
|
|
40
46
|
ResolveNodeLinksInput,
|
|
41
47
|
ResolveNodeLinksResult,
|
|
@@ -46,6 +52,7 @@ import {
|
|
|
46
52
|
UpdateNodeInput,
|
|
47
53
|
} from "@anchrd/intel-contract/node";
|
|
48
54
|
import {
|
|
55
|
+
ResourceAccessList,
|
|
49
56
|
ResourceGrantList,
|
|
50
57
|
RevokeGrantInput,
|
|
51
58
|
RevokeGrantResult,
|
|
@@ -326,6 +333,26 @@ export function createIntelDataProvider(
|
|
|
326
333
|
body: JSON.stringify(parsed),
|
|
327
334
|
});
|
|
328
335
|
},
|
|
336
|
+
async purgeNode(input) {
|
|
337
|
+
const parsed = PurgeNodeInput.parse(input);
|
|
338
|
+
return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, PurgeNodeResult, {
|
|
339
|
+
method: "DELETE",
|
|
340
|
+
body: JSON.stringify(parsed),
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
async previewNodePurge(input) {
|
|
344
|
+
const parsed = PurgeNodePreviewInput.parse(input);
|
|
345
|
+
return await request(
|
|
346
|
+
`/nodes/${encodeURIComponent(parsed.nodeId)}/purge-preview`,
|
|
347
|
+
PurgeNodePreview,
|
|
348
|
+
);
|
|
349
|
+
},
|
|
350
|
+
async purgeFlow(input) {
|
|
351
|
+
const parsed = PurgeFlowInput.parse(input);
|
|
352
|
+
return await request(`/flows/${encodeURIComponent(parsed.flowId)}`, PurgeFlowResult, {
|
|
353
|
+
method: "DELETE",
|
|
354
|
+
});
|
|
355
|
+
},
|
|
329
356
|
async searchNodes(input) {
|
|
330
357
|
return await request("/nodes/search", SearchResult, {
|
|
331
358
|
method: "POST",
|
|
@@ -335,6 +362,12 @@ export function createIntelDataProvider(
|
|
|
335
362
|
async listGrants(resourceId) {
|
|
336
363
|
return await request(`/nodes/${encodeURIComponent(resourceId)}/grants`, ResourceGrantList);
|
|
337
364
|
},
|
|
365
|
+
async listEffectiveAccess(resourceId) {
|
|
366
|
+
return await request(
|
|
367
|
+
`/nodes/${encodeURIComponent(resourceId)}/effective-access`,
|
|
368
|
+
ResourceAccessList,
|
|
369
|
+
);
|
|
370
|
+
},
|
|
338
371
|
async shareNode(input) {
|
|
339
372
|
const parsed = ShareInput.parse(input);
|
|
340
373
|
return await request(`/nodes/${encodeURIComponent(parsed.resourceId)}/grants`, ShareResult, {
|
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
ListFlowsInput,
|
|
13
13
|
PreviewFlowPublishInput,
|
|
14
14
|
PublishFlowInput,
|
|
15
|
+
PurgeFlowInput,
|
|
16
|
+
PurgeFlowResult,
|
|
15
17
|
RelationGraph,
|
|
16
18
|
RelationGraphScope,
|
|
17
19
|
SaveFlowVersionInput,
|
|
@@ -37,6 +39,9 @@ import type {
|
|
|
37
39
|
NodeList,
|
|
38
40
|
NodeTable,
|
|
39
41
|
NodeVersionList,
|
|
42
|
+
PurgeNodeInput,
|
|
43
|
+
PurgeNodePreviewInput,
|
|
44
|
+
PurgeNodeResult,
|
|
40
45
|
ReindexResult,
|
|
41
46
|
ResolveNodeLinksInput,
|
|
42
47
|
ResolveNodeLinksResult,
|
|
@@ -125,8 +130,18 @@ export interface IntelDataProvider {
|
|
|
125
130
|
listNodeVersions(nodeId: string): Promise<NodeVersionList>;
|
|
126
131
|
updateNode(input: UpdateNodeInput): Promise<Node>;
|
|
127
132
|
archiveNode(input: ArchiveNodeInput): Promise<Node>;
|
|
133
|
+
// ⚠️ The one way across this seam after which nothing is really left (#457). The title comes back
|
|
134
|
+
// because nothing can look it up afterwards.
|
|
135
|
+
purgeNode(input: PurgeNodeInput): Promise<PurgeNodeResult>;
|
|
136
|
+
previewNodePurge(
|
|
137
|
+
input: PurgeNodePreviewInput,
|
|
138
|
+
): Promise<import("@anchrd/intel-contract/node").PurgeNodePreview>;
|
|
139
|
+
purgeFlow(input: PurgeFlowInput): Promise<PurgeFlowResult>;
|
|
128
140
|
searchNodes(input: SearchInput): Promise<SearchResult>;
|
|
129
141
|
listGrants(resourceId: string): Promise<ResourceGrantList>;
|
|
142
|
+
listEffectiveAccess(
|
|
143
|
+
resourceId: string,
|
|
144
|
+
): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
|
|
130
145
|
// The grant, and what the grant does not cover: the documents the flows in this folder read that
|
|
131
146
|
// the new principal still cannot. A warning, never a refusal (ADR-0004 §4).
|
|
132
147
|
shareNode(input: ShareInput): Promise<ShareResult>;
|
|
@@ -5,6 +5,7 @@ import { useState } from "react";
|
|
|
5
5
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
6
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
7
7
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
8
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
8
9
|
|
|
9
10
|
// One page is what a person reads before deciding, not what a database can return. The server caps
|
|
10
11
|
// it at fifty; twenty is what fits on a screen without scrolling past the answer.
|
|
@@ -115,6 +116,7 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
115
116
|
|
|
116
117
|
function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
|
|
117
118
|
const i18n = useI18n();
|
|
119
|
+
const dateTime = useDateTime();
|
|
118
120
|
const failed = run.status === "failed";
|
|
119
121
|
const Chevron = open ? ChevronDown : ChevronRight;
|
|
120
122
|
return (
|
|
@@ -133,7 +135,7 @@ function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; tog
|
|
|
133
135
|
>
|
|
134
136
|
{i18n.t(`runs.status.${run.status}`)}
|
|
135
137
|
</span>
|
|
136
|
-
<span>{
|
|
138
|
+
<span>{dateTime.at(run.startedAt)}</span>
|
|
137
139
|
<span className="text-muted-foreground">
|
|
138
140
|
{run.durationMs === null
|
|
139
141
|
? i18n.t("runs.stillRunning")
|
package/src/flows/flows.tsx
CHANGED
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
} from "@xyflow/react";
|
|
25
25
|
import { FileSearch, Info, Send, Wrench } from "lucide-react";
|
|
26
26
|
import { useEffect, useId, useMemo, useState } from "react";
|
|
27
|
-
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
28
27
|
import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
|
|
29
28
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
30
29
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
@@ -457,16 +456,6 @@ function FlowsEditor() {
|
|
|
457
456
|
|
|
458
457
|
return (
|
|
459
458
|
<div className="flex h-full min-h-0 flex-col">
|
|
460
|
-
{/* Only the view switch belongs to the area: it says how the thing the breadcrumb names is
|
|
461
|
-
being shown, and it is mounted only where there is a flow to draw — a switch with nothing
|
|
462
|
-
to point at is what #19 rules out for the header. A flow is the one level that has runs,
|
|
463
|
-
so it is the one that offers the third view (#35). Everything that acts on the flow itself
|
|
464
|
-
left this bar for the flow's own title line below (#24). */}
|
|
465
|
-
{selectedFlowId ? (
|
|
466
|
-
<ActionSlot>
|
|
467
|
-
<ViewToggle views={["editor", "graph", "runs"]} />
|
|
468
|
-
</ActionSlot>
|
|
469
|
-
) : null}
|
|
470
459
|
{selectedFlowId && document.data ? (
|
|
471
460
|
<FlowTitle
|
|
472
461
|
flow={document.data.flow}
|
|
@@ -736,6 +725,11 @@ function FlowTitle({
|
|
|
736
725
|
// hit without looking is worth more than the primary button being outermost. `TitleRow` is what
|
|
737
726
|
// enforces that — nothing passed in here can get past the menu.
|
|
738
727
|
<TitleRow title={flow.title} description={flow.description} target={{ type: "flow", flow }}>
|
|
728
|
+
{/* ⚠️ #454: the toggle used to stand in the global header, beside the search — where what
|
|
729
|
+
applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
|
|
730
|
+
that names this flow. A flow is the only level with runs, and therefore the only one with
|
|
731
|
+
three views (#35). */}
|
|
732
|
+
<ViewToggle views={["editor", "graph", "runs"]} />
|
|
739
733
|
<TooltipProvider delayDuration={300}>
|
|
740
734
|
<Tooltip>
|
|
741
735
|
<TooltipTrigger asChild>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import { useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { ResourceAccessSummary } from "@/access-summary/access-summary.tsx";
|
|
3
4
|
import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
|
|
4
5
|
import {
|
|
5
6
|
Table,
|
|
@@ -14,6 +15,7 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
|
14
15
|
import { kindIcons } from "@/kind-icon.ts";
|
|
15
16
|
import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
|
|
16
17
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
18
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
17
19
|
|
|
18
20
|
// The same record the menu is handed everywhere else, read off a level row. A re-labelling, never a
|
|
19
21
|
// second source of truth — a rename works on the whole record, not on a summary of it.
|
|
@@ -41,6 +43,7 @@ function targetOf(entry: TreeEntry): ResourceTarget {
|
|
|
41
43
|
export function FolderContents({ folderId }: { folderId: string }) {
|
|
42
44
|
const { data } = useIntelRouterContext();
|
|
43
45
|
const i18n = useI18n();
|
|
46
|
+
const dateTime = useDateTime();
|
|
44
47
|
const navigate = useNavigate();
|
|
45
48
|
const level = useQuery({
|
|
46
49
|
queryKey: treeLevelKey(folderId),
|
|
@@ -82,6 +85,9 @@ export function FolderContents({ folderId }: { folderId: string }) {
|
|
|
82
85
|
<TableHead className="h-9 px-4 text-right text-xs tracking-wide uppercase">
|
|
83
86
|
{i18n.t("node.changed")}
|
|
84
87
|
</TableHead>
|
|
88
|
+
<TableHead className="h-9 px-4 text-xs tracking-wide uppercase">
|
|
89
|
+
{i18n.t("node.access")}
|
|
90
|
+
</TableHead>
|
|
85
91
|
{/* A `<th>` with nothing in it leaves the column unnamed for anyone reading the table
|
|
86
92
|
by its headers. The word is there; only the eye is spared it. */}
|
|
87
93
|
<TableHead className="h-9 w-14">
|
|
@@ -126,7 +132,15 @@ export function FolderContents({ folderId }: { folderId: string }) {
|
|
|
126
132
|
</button>
|
|
127
133
|
</TableCell>
|
|
128
134
|
<TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
|
|
129
|
-
{
|
|
135
|
+
{dateTime.on(changed)}
|
|
136
|
+
</TableCell>
|
|
137
|
+
<TableCell className="px-4">
|
|
138
|
+
<ResourceAccessSummary
|
|
139
|
+
resourceId={
|
|
140
|
+
entry.type === "flow" ? (entry.flow.parentId ?? folderId) : entry.id
|
|
141
|
+
}
|
|
142
|
+
ownerId={entry.type === "flow" ? entry.flow.ownerId : entry.node.ownerId}
|
|
143
|
+
/>
|
|
130
144
|
</TableCell>
|
|
131
145
|
{/* ⚠️ The same menu as the title line of the open thing, not a shorter one built
|
|
132
146
|
for here. #58 collected every action in one place so that place is always the
|