@cytario/web 2.1.8 → 2.2.1
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/README.md +1 -1
- package/app/.server/auth/README.md +0 -1
- package/app/.server/auth/authMiddleware.ts +6 -28
- package/app/.server/auth/getSessionCredentials.ts +11 -1
- package/app/.server/auth/sessionPolicy.ts +69 -0
- package/app/.server/corsPreflight.ts +136 -0
- package/app/.server/csp.ts +34 -0
- package/app/components/.client/ImageViewer/README.md +2 -2
- package/app/components/DirectoryView/DirectoryViewGrid.tsx +2 -11
- package/app/components/DirectoryView/DirectoryViewTree.tsx +18 -26
- package/app/components/DirectoryView/buildDirectoryTree.ts +89 -42
- package/app/components/DirectoryView/filterNodes.ts +4 -19
- package/app/components/DirectoryView/useLazyTreeNodes.ts +120 -0
- package/app/components/GlobalSearch/GlobalSearch.tsx +8 -3
- package/app/components/GlobalSearch/Suggestions.tsx +21 -3
- package/app/config.ts +0 -7
- package/app/entry.server.tsx +9 -4
- package/app/hooks/useInitConnections.ts +3 -3
- package/app/root.tsx +11 -6
- package/app/routes/connections/connection.form.tsx +9 -3
- package/app/routes/connections/connection.schema.ts +60 -11
- package/app/routes/connections/connections.clientLoader.ts +91 -0
- package/app/routes/connections/connections.loader.ts +19 -39
- package/app/routes/connections/connections.route.tsx +5 -0
- package/app/routes/connections/createConnection.action.ts +39 -13
- package/app/routes/connections/deleteConnection.action.ts +5 -1
- package/app/routes/connections/updateConnection.action.ts +50 -13
- package/app/routes/home/home.route.tsx +5 -1
- package/app/routes/layouts/protected.layout.tsx +15 -1
- package/app/routes/objects/objects.clientLoader.ts +73 -0
- package/app/routes/objects/objects.loader.ts +58 -92
- package/app/routes/objects/objects.route.tsx +41 -40
- package/app/routes/search.route.tsx +133 -28
- package/app/routes.ts +0 -4
- package/app/utils/connectionsStore/useConnectionsStore.ts +25 -62
- package/app/utils/credentialsRefresh.ts +53 -0
- package/app/utils/db/convertCsvToParquet.ts +24 -16
- package/app/utils/db/createDatabase.ts +25 -33
- package/app/utils/db/duckdbBundles.ts +42 -0
- package/app/utils/db/ensureSpatialLoaded.ts +28 -0
- package/app/utils/db/escapeSqlString.ts +4 -0
- package/app/utils/db/getBlobFromObjectNode.ts +15 -36
- package/app/utils/db/getTileDataWasm.ts +4 -5
- package/app/utils/db/sqlQueries.ts +6 -1
- package/app/utils/filterObjects.ts +2 -18
- package/app/utils/limitConcurrency.ts +28 -0
- package/app/utils/listObjectsClient.ts +318 -0
- package/app/utils/listingLimits.ts +7 -0
- package/app/utils/loadConnectionLevel.ts +50 -0
- package/app/utils/localFilesStore/useFileStore.ts +1 -6
- package/app/utils/pathUtils.ts +65 -0
- package/app/utils/resourceId.ts +14 -29
- package/app/utils/s3HostAllowlist.ts +116 -0
- package/app/utils/signedFetch.ts +159 -24
- package/package.json +3 -3
- package/prisma/seed.ts +3 -3
- package/public/duckdb-extensions/checksums.json +12 -0
- package/public/duckdb-extensions/v1.4.3/wasm_eh/httpfs.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_eh/parquet.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_eh/spatial.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_mvp/httpfs.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_mvp/parquet.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_mvp/spatial.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_threads/httpfs.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_threads/parquet.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_threads/spatial.duckdb_extension.wasm +0 -0
- package/scripts/download-duckdb-extensions.mjs +188 -0
- package/scripts/prebuild.mjs +18 -10
- package/app/.server/auth/getPresignedUrl.ts +0 -21
- package/app/.server/auth/getS3Client.ts +0 -93
- package/app/routes/presign.route.tsx +0 -42
- package/app/utils/getObjects.ts +0 -24
|
@@ -6,42 +6,30 @@ import { isZarrPath } from "~/utils/zarrUtils";
|
|
|
6
6
|
export type TreeNodeType = "bucket" | "directory" | "file";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* A node in the storage directory tree.
|
|
10
|
-
*
|
|
11
|
-
* Structurally extends `@cytario/design` `TreeNode` (`{ id, name, children? }`)
|
|
12
|
-
* so it can be passed directly to the design system `<Tree>` component without
|
|
13
|
-
* conversion. Callbacks like `onActivate` return the full `TreeNode`,
|
|
14
|
-
* eliminating the need for reverse-lookup helpers.
|
|
15
|
-
*
|
|
16
|
-
* Connection-level metadata (`provider`, `bucketName`, etc.) is not stored on
|
|
17
|
-
* every node — look it up from the connections store via `connectionName`.
|
|
9
|
+
* A node in the storage directory tree. Shape matches `@cytario/design`
|
|
10
|
+
* `TreeNode` so it can be passed straight to the design-system `<Tree>`.
|
|
18
11
|
*/
|
|
19
12
|
export interface TreeNode {
|
|
20
13
|
/** Globally unique identifier: `connectionName/pathName`. */
|
|
21
14
|
id: string;
|
|
22
15
|
/** Name of the connection config this node belongs to. */
|
|
23
16
|
connectionName: string;
|
|
24
|
-
/**
|
|
25
|
-
* Display name — the last segment of the path (e.g. `"output.ome.tif"`).
|
|
26
|
-
*
|
|
27
|
-
* For `type: "bucket"` nodes, this equals `connectionName` by convention —
|
|
28
|
-
* loaders set it that way (`connections.loader.ts`, `search.route.tsx`) so
|
|
29
|
-
* `DirectoryViewTableConnection` can use `node.name` as the row's stable
|
|
30
|
-
* id and URL segment without a separate lookup.
|
|
31
|
-
*/
|
|
17
|
+
/** Display name. For `type: "bucket"` this equals `connectionName`. */
|
|
32
18
|
name: string;
|
|
33
19
|
type: TreeNodeType;
|
|
34
|
-
/**
|
|
35
|
-
* Full path relative to the connection root, including trailing `/` for
|
|
36
|
-
* directories. Empty string `""` for bucket root nodes.
|
|
37
|
-
*
|
|
38
|
-
* Built by `buildDirectoryTree` as the concatenation of ancestor `name`
|
|
39
|
-
* segments. Used for routing via `buildConnectionPath()`.
|
|
40
|
-
*/
|
|
20
|
+
/** Full path relative to connection root (trailing `/` for dirs, `""` for bucket root). */
|
|
41
21
|
pathName: string;
|
|
42
|
-
|
|
43
|
-
|
|
22
|
+
/** `undefined` signals a leaf (react-arborist hides the chevron); `[]` is expandable-empty. */
|
|
23
|
+
children?: TreeNode[];
|
|
24
|
+
/** S3 metadata. On directories: first image inside, used for previews. */
|
|
44
25
|
_Object?: _Object;
|
|
26
|
+
/** Hints from `buildLevelTree` for one-level listings. */
|
|
27
|
+
hasChildren?: boolean;
|
|
28
|
+
isLeaf?: boolean;
|
|
29
|
+
loadState?: "idle" | "loading" | "loaded" | "error";
|
|
30
|
+
/** Populated for bucket nodes by the per-connection preview probe. */
|
|
31
|
+
connectionStatus?: "connected" | "error";
|
|
32
|
+
connectionErrorMessage?: string;
|
|
45
33
|
}
|
|
46
34
|
|
|
47
35
|
function buildDirectoryTreeRecursive(
|
|
@@ -56,9 +44,7 @@ function buildDirectoryTreeRecursive(
|
|
|
56
44
|
if (keyParts.length > 1) pathName += "/";
|
|
57
45
|
|
|
58
46
|
if (keyParts.length === 1) {
|
|
59
|
-
// Skip empty-name
|
|
60
|
-
// in "/"). The parent directory was already created by the recursive
|
|
61
|
-
// call, so there is nothing to add.
|
|
47
|
+
// Skip empty-name leaves from S3 folder marker keys (ending in "/").
|
|
62
48
|
if (name === "") return;
|
|
63
49
|
|
|
64
50
|
currentDir.push({
|
|
@@ -71,8 +57,7 @@ function buildDirectoryTreeRecursive(
|
|
|
71
57
|
_Object: obj,
|
|
72
58
|
});
|
|
73
59
|
} else {
|
|
74
|
-
// Zarr directories are images — treat as leaf
|
|
75
|
-
// into the thousands of internal chunk files.
|
|
60
|
+
// Zarr directories are images — treat as leaf, skip the thousands of chunks.
|
|
76
61
|
if (isZarrPath(name)) {
|
|
77
62
|
if (!currentDir.find((child) => child.name === name)) {
|
|
78
63
|
currentDir.push({
|
|
@@ -104,6 +89,7 @@ function buildDirectoryTreeRecursive(
|
|
|
104
89
|
existingDir._Object = obj;
|
|
105
90
|
}
|
|
106
91
|
|
|
92
|
+
if (!existingDir.children) existingDir.children = [];
|
|
107
93
|
buildDirectoryTreeRecursive(
|
|
108
94
|
existingDir.children,
|
|
109
95
|
keyParts.slice(1),
|
|
@@ -114,11 +100,10 @@ function buildDirectoryTreeRecursive(
|
|
|
114
100
|
}
|
|
115
101
|
}
|
|
116
102
|
|
|
117
|
-
/** Depth-first search for a node by its `id`. */
|
|
118
103
|
export function findNodeById(nodes: TreeNode[], id: string): TreeNode | undefined {
|
|
119
104
|
for (const node of nodes) {
|
|
120
105
|
if (node.id === id) return node;
|
|
121
|
-
if (node.children.length > 0) {
|
|
106
|
+
if (node.children && node.children.length > 0) {
|
|
122
107
|
const found = findNodeById(node.children, id);
|
|
123
108
|
if (found) return found;
|
|
124
109
|
}
|
|
@@ -126,19 +111,19 @@ export function findNodeById(nodes: TreeNode[], id: string): TreeNode | undefine
|
|
|
126
111
|
return undefined;
|
|
127
112
|
}
|
|
128
113
|
|
|
129
|
-
/** Total size of all files under a directory node. */
|
|
130
114
|
export function computeDirectorySize(node: TreeNode): number {
|
|
131
115
|
if (node.type === "file") return node._Object?.Size ?? 0;
|
|
132
|
-
if (node.children.length === 0)
|
|
116
|
+
if (!node.children || node.children.length === 0) {
|
|
117
|
+
return node._Object?.Size ?? 0;
|
|
118
|
+
}
|
|
133
119
|
return node.children.reduce((sum, child) => sum + computeDirectorySize(child), 0);
|
|
134
120
|
}
|
|
135
121
|
|
|
136
|
-
/** Latest LastModified timestamp under a directory node. */
|
|
137
122
|
export function computeDirectoryLastModified(node: TreeNode): number {
|
|
138
123
|
if (node.type === "file") {
|
|
139
124
|
return node._Object?.LastModified ? new Date(node._Object.LastModified).getTime() : 0;
|
|
140
125
|
}
|
|
141
|
-
if (node.children.length === 0) {
|
|
126
|
+
if (!node.children || node.children.length === 0) {
|
|
142
127
|
return node._Object?.LastModified ? new Date(node._Object.LastModified).getTime() : 0;
|
|
143
128
|
}
|
|
144
129
|
return node.children.reduce(
|
|
@@ -147,13 +132,75 @@ export function computeDirectoryLastModified(node: TreeNode): number {
|
|
|
147
132
|
);
|
|
148
133
|
}
|
|
149
134
|
|
|
135
|
+
interface BuildLevelTreeArgs {
|
|
136
|
+
contents: _Object[];
|
|
137
|
+
commonPrefixes: string[];
|
|
138
|
+
connectionName: string;
|
|
139
|
+
/** Listing prefix to strip from keys to derive node names. Empty at bucket root. */
|
|
140
|
+
prefix?: string;
|
|
141
|
+
/** Path relative to connection root, prepended to `pathName`. */
|
|
142
|
+
urlPath?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
150
145
|
/**
|
|
151
|
-
* Build a
|
|
152
|
-
*
|
|
153
|
-
* @param prefix S3 listing prefix to strip from object keys
|
|
154
|
-
* @param urlPath Path relative to the connection root (prepended to node
|
|
155
|
-
* pathNames so they stay routable via `/connections/:connectionName/*`)
|
|
146
|
+
* Build a single-level tree from a paginated S3 listing with `Delimiter: "/"`.
|
|
147
|
+
* Zarr `CommonPrefixes` collapse into a single leaf node.
|
|
156
148
|
*/
|
|
149
|
+
export function buildLevelTree({
|
|
150
|
+
contents,
|
|
151
|
+
commonPrefixes,
|
|
152
|
+
connectionName,
|
|
153
|
+
prefix,
|
|
154
|
+
urlPath,
|
|
155
|
+
}: BuildLevelTreeArgs): TreeNode[] {
|
|
156
|
+
const basePath = urlPath ? (urlPath.endsWith("/") ? urlPath : `${urlPath}/`) : "";
|
|
157
|
+
const stripPrefix = prefix ?? "";
|
|
158
|
+
const nodes: TreeNode[] = [];
|
|
159
|
+
|
|
160
|
+
for (const cp of commonPrefixes) {
|
|
161
|
+
const relative = cp.startsWith(stripPrefix) ? cp.slice(stripPrefix.length) : cp;
|
|
162
|
+
const name = relative.replace(/\/$/, "");
|
|
163
|
+
if (!name) continue;
|
|
164
|
+
const pathName = `${basePath}${name}/`;
|
|
165
|
+
const isZarr = isZarrPath(name);
|
|
166
|
+
|
|
167
|
+
nodes.push({
|
|
168
|
+
id: `${connectionName}/${pathName}`,
|
|
169
|
+
connectionName,
|
|
170
|
+
type: isZarr ? "file" : "directory",
|
|
171
|
+
name,
|
|
172
|
+
pathName,
|
|
173
|
+
// Empty array → chevron + lazy expansion; `undefined` → no chevron.
|
|
174
|
+
children: isZarr ? undefined : [],
|
|
175
|
+
hasChildren: !isZarr,
|
|
176
|
+
isLeaf: isZarr,
|
|
177
|
+
loadState: isZarr ? undefined : "idle",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const obj of contents) {
|
|
182
|
+
if (!obj.Key) continue;
|
|
183
|
+
const relative = obj.Key.startsWith(stripPrefix) ? obj.Key.slice(stripPrefix.length) : obj.Key;
|
|
184
|
+
if (!relative || relative.endsWith("/")) continue;
|
|
185
|
+
const name = relative.split("/").pop()!;
|
|
186
|
+
if (!name) continue;
|
|
187
|
+
const pathName = `${basePath}${relative}`;
|
|
188
|
+
|
|
189
|
+
nodes.push({
|
|
190
|
+
id: `${connectionName}/${pathName}`,
|
|
191
|
+
connectionName,
|
|
192
|
+
type: "file",
|
|
193
|
+
name,
|
|
194
|
+
pathName,
|
|
195
|
+
isLeaf: true,
|
|
196
|
+
_Object: obj,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return nodes;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Build a recursive directory tree from a flat S3 object listing. */
|
|
157
204
|
export function buildDirectoryTree(
|
|
158
205
|
objects: _Object[],
|
|
159
206
|
connectionName: string,
|
|
@@ -6,16 +6,6 @@ import type { ColumnConfig } from "~/components/Table/types";
|
|
|
6
6
|
import type { Connection } from "~/utils/connectionsStore/useConnectionsStore";
|
|
7
7
|
import { getFileType } from "~/utils/fileType";
|
|
8
8
|
|
|
9
|
-
// Applies a `ColumnFiltersState` to a `TreeNode[]` so every downstream view
|
|
10
|
-
// (Grid, Table, Tree) receives pre-filtered data. The `ColumnFiltersState`
|
|
11
|
-
// shape is produced by both the Table's column-filter UI and the FilterBar,
|
|
12
|
-
// both writing to the same per-tableId Zustand store.
|
|
13
|
-
//
|
|
14
|
-
// allNodes -> filteredNodes -> DirectoryView -> (Grid | Table | Tree)
|
|
15
|
-
//
|
|
16
|
-
// Filters only the current level (top-level `nodes`). Tree's hierarchical
|
|
17
|
-
// expansion shows descendants unfiltered; name-matching inside the tree
|
|
18
|
-
// relies on the design-system Tree's `searchTerm` + `searchMatch`.
|
|
19
9
|
type NodeAccessor = (node: TreeNode) => string;
|
|
20
10
|
|
|
21
11
|
const fileAccessors: Record<string, NodeAccessor> = {
|
|
@@ -42,27 +32,22 @@ export function getNodeAccessors(
|
|
|
42
32
|
return kind === "connections" ? makeConnectionAccessors(connections) : fileAccessors;
|
|
43
33
|
}
|
|
44
34
|
|
|
45
|
-
/**
|
|
46
|
-
* Filters hidden files (names starting with ".") unless showHidden is true.
|
|
47
|
-
* Filtering is applied recursively so that hidden children inside visible
|
|
48
|
-
* directories are also removed (required for the tree view).
|
|
49
|
-
*/
|
|
35
|
+
/** Filter hidden files (names starting with ".") recursively. */
|
|
50
36
|
export function filterHiddenNodes(nodes: TreeNode[], showHidden: boolean): TreeNode[] {
|
|
51
37
|
if (showHidden) return nodes;
|
|
52
38
|
|
|
53
39
|
return nodes
|
|
54
40
|
.filter((node) => !node.name.startsWith("."))
|
|
55
41
|
.map((node) =>
|
|
56
|
-
node.children.length > 0
|
|
42
|
+
node.children && node.children.length > 0
|
|
57
43
|
? { ...node, children: filterHiddenNodes(node.children, false) }
|
|
58
44
|
: node,
|
|
59
45
|
);
|
|
60
46
|
}
|
|
61
47
|
|
|
62
48
|
/**
|
|
63
|
-
*
|
|
64
|
-
* Text filters use case-insensitive substring
|
|
65
|
-
* exact match. Columns without a matching accessor are skipped.
|
|
49
|
+
* Filter `TreeNode[]` using the same column-filter semantics as the Table.
|
|
50
|
+
* Text filters use case-insensitive substring; select filters use exact match.
|
|
66
51
|
*/
|
|
67
52
|
export function filterNodes(
|
|
68
53
|
nodes: TreeNode[],
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { TreeNode } from "./buildDirectoryTree";
|
|
4
|
+
import { toastBridge, toToastVariant } from "~/toast-bridge";
|
|
5
|
+
import { select } from "~/utils/connectionsStore/selectors";
|
|
6
|
+
import { useConnectionsStore } from "~/utils/connectionsStore/useConnectionsStore";
|
|
7
|
+
import { formatTruncationMessage } from "~/utils/listingLimits";
|
|
8
|
+
import { loadConnectionLevel } from "~/utils/loadConnectionLevel";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Lazily-expanding tree backed by direct browser → S3 listings.
|
|
12
|
+
* `loadChildren` is idempotent and dedupes concurrent calls; each
|
|
13
|
+
* `initialNodes` cohort owns an `AbortController` so stale responses
|
|
14
|
+
* cannot overwrite a fresh tree after navigation.
|
|
15
|
+
*/
|
|
16
|
+
export function useLazyTreeNodes(initialNodes: TreeNode[]) {
|
|
17
|
+
const [nodes, setNodes] = useState<TreeNode[]>(initialNodes);
|
|
18
|
+
const inflightRef = useRef<Map<string, Promise<TreeNode[]>>>(new Map());
|
|
19
|
+
const abortRef = useRef<AbortController>(new AbortController());
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
abortRef.current = controller;
|
|
24
|
+
inflightRef.current = new Map();
|
|
25
|
+
setNodes(initialNodes);
|
|
26
|
+
return () => {
|
|
27
|
+
controller.abort();
|
|
28
|
+
};
|
|
29
|
+
}, [initialNodes]);
|
|
30
|
+
|
|
31
|
+
const loadChildren = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
|
|
32
|
+
if (
|
|
33
|
+
!node.connectionName ||
|
|
34
|
+
node.isLeaf ||
|
|
35
|
+
node.hasChildren === false ||
|
|
36
|
+
node.loadState === "loaded"
|
|
37
|
+
) {
|
|
38
|
+
return node.children ?? [];
|
|
39
|
+
}
|
|
40
|
+
const connectionName = node.connectionName;
|
|
41
|
+
const key = `${connectionName} ${node.pathName}`;
|
|
42
|
+
const existing = inflightRef.current.get(key);
|
|
43
|
+
if (existing) return existing;
|
|
44
|
+
|
|
45
|
+
const connection = select.connection(connectionName)(useConnectionsStore.getState());
|
|
46
|
+
if (!connection) {
|
|
47
|
+
throw new Error(`No connection in store for "${connectionName}"`);
|
|
48
|
+
}
|
|
49
|
+
const { connectionConfig, credentials } = connection;
|
|
50
|
+
|
|
51
|
+
const controller = abortRef.current;
|
|
52
|
+
const { signal } = controller;
|
|
53
|
+
const inflightMap = inflightRef.current;
|
|
54
|
+
|
|
55
|
+
setNodes((prev) =>
|
|
56
|
+
replaceNodeById(prev, node.id, (n) => ({
|
|
57
|
+
...n,
|
|
58
|
+
loadState: "loading",
|
|
59
|
+
})),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const promise = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const { nodes: children, isCapped } = await loadConnectionLevel({
|
|
65
|
+
connectionConfig,
|
|
66
|
+
credentials,
|
|
67
|
+
connectionName,
|
|
68
|
+
urlPath: node.pathName,
|
|
69
|
+
signal,
|
|
70
|
+
});
|
|
71
|
+
if (signal.aborted) return [];
|
|
72
|
+
|
|
73
|
+
if (isCapped) {
|
|
74
|
+
toastBridge.emit({
|
|
75
|
+
variant: toToastVariant("warning"),
|
|
76
|
+
message: formatTruncationMessage(node.name),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
setNodes((prev) =>
|
|
80
|
+
replaceNodeById(prev, node.id, (n) => ({
|
|
81
|
+
...n,
|
|
82
|
+
children,
|
|
83
|
+
loadState: "loaded",
|
|
84
|
+
})),
|
|
85
|
+
);
|
|
86
|
+
return children;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (signal.aborted) return [];
|
|
89
|
+
setNodes((prev) =>
|
|
90
|
+
replaceNodeById(prev, node.id, (n) => ({
|
|
91
|
+
...n,
|
|
92
|
+
loadState: "error",
|
|
93
|
+
})),
|
|
94
|
+
);
|
|
95
|
+
throw error;
|
|
96
|
+
} finally {
|
|
97
|
+
if (inflightMap === inflightRef.current) {
|
|
98
|
+
inflightMap.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
inflightRef.current.set(key, promise);
|
|
104
|
+
return promise;
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
return { nodes, loadChildren };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function replaceNodeById(
|
|
111
|
+
nodes: TreeNode[],
|
|
112
|
+
id: string,
|
|
113
|
+
update: (n: TreeNode) => TreeNode,
|
|
114
|
+
): TreeNode[] {
|
|
115
|
+
return nodes.map((n) => {
|
|
116
|
+
if (n.id === id) return update(n);
|
|
117
|
+
if (!n.children || n.children.length === 0) return n;
|
|
118
|
+
return { ...n, children: replaceNodeById(n.children, id, update) };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -18,12 +18,17 @@ export const GlobalSearch = () => {
|
|
|
18
18
|
const [query, setQuery] = useState(searchQuery);
|
|
19
19
|
const [showResults, setShowResults] = useState(false);
|
|
20
20
|
|
|
21
|
-
// Derive nodes from fetcher data
|
|
22
21
|
const nodes: TreeNode[] = fetcher.data?.nodes ?? [];
|
|
23
22
|
|
|
23
|
+
// Treat the debounce window as "loading" too — otherwise the dropdown
|
|
24
|
+
// flashes "No results" between keystrokes.
|
|
25
|
+
const lastResultsQuery = fetcher.data?.searchQuery ?? "";
|
|
26
|
+
const trimmedQuery = query.trim();
|
|
27
|
+
const isLoading =
|
|
28
|
+
trimmedQuery.length > 0 && (fetcher.state !== "idle" || trimmedQuery !== lastResultsQuery);
|
|
29
|
+
|
|
24
30
|
const handleSubmit = async (value: string) => {
|
|
25
31
|
setSearchQuery(value);
|
|
26
|
-
// Trigger fetcher only if there's input
|
|
27
32
|
if (value.trim()) {
|
|
28
33
|
fetcher.submit({ query: value }, { method: "get", action: "/search" });
|
|
29
34
|
setShowResults(true);
|
|
@@ -63,7 +68,7 @@ export const GlobalSearch = () => {
|
|
|
63
68
|
}}
|
|
64
69
|
/>
|
|
65
70
|
|
|
66
|
-
<Suggestions nodes={nodes} showResults={showResults} />
|
|
71
|
+
<Suggestions nodes={nodes} showResults={showResults} isLoading={isLoading} />
|
|
67
72
|
</div>
|
|
68
73
|
);
|
|
69
74
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EmptyState, H2 } from "@cytario/design";
|
|
2
|
-
import { SearchX } from "lucide-react";
|
|
2
|
+
import { Loader2, SearchX } from "lucide-react";
|
|
3
3
|
import { AnimatePresence, motion } from "motion/react";
|
|
4
4
|
|
|
5
5
|
import { TreeNode } from "../DirectoryView/buildDirectoryTree";
|
|
@@ -8,8 +8,12 @@ import { DirectoryTree } from "../DirectoryView/DirectoryViewTree";
|
|
|
8
8
|
interface SuggestionsProps {
|
|
9
9
|
nodes: TreeNode[];
|
|
10
10
|
showResults: boolean;
|
|
11
|
+
isLoading: boolean;
|
|
11
12
|
}
|
|
12
|
-
|
|
13
|
+
|
|
14
|
+
// Render order: prefer stale nodes over the empty state while a new query is in
|
|
15
|
+
// flight — otherwise the dropdown flashes "No results" on every keystroke.
|
|
16
|
+
export const Suggestions = ({ nodes, showResults, isLoading }: SuggestionsProps) => {
|
|
13
17
|
return (
|
|
14
18
|
<AnimatePresence>
|
|
15
19
|
{showResults && (
|
|
@@ -28,16 +32,30 @@ export const Suggestions = ({ nodes, showResults }: SuggestionsProps) => {
|
|
|
28
32
|
text-black
|
|
29
33
|
shadow-lg rounded-sm border border-slate-200
|
|
30
34
|
`}
|
|
35
|
+
aria-busy={isLoading}
|
|
31
36
|
>
|
|
32
37
|
{nodes.length > 0 ? (
|
|
33
38
|
<>
|
|
34
|
-
<header className="flex-shrink-0 p-4 border-b border-slate-200">
|
|
39
|
+
<header className="flex-shrink-0 p-4 border-b border-slate-200 flex items-center justify-between">
|
|
35
40
|
<H2>All Results</H2>
|
|
41
|
+
{isLoading && (
|
|
42
|
+
<Loader2
|
|
43
|
+
size={16}
|
|
44
|
+
className="animate-spin text-slate-500"
|
|
45
|
+
aria-label="Searching"
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
36
48
|
</header>
|
|
37
49
|
<div className="overflow-y-auto flex-1">
|
|
38
50
|
<DirectoryTree nodes={nodes} />
|
|
39
51
|
</div>
|
|
40
52
|
</>
|
|
53
|
+
) : isLoading ? (
|
|
54
|
+
<EmptyState
|
|
55
|
+
icon={Loader2}
|
|
56
|
+
title="Searching…"
|
|
57
|
+
description="Looking across your connections."
|
|
58
|
+
/>
|
|
41
59
|
) : (
|
|
42
60
|
<EmptyState
|
|
43
61
|
icon={SearchX}
|
package/app/config.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { CookieOptions } from "react-router";
|
|
2
2
|
|
|
3
3
|
interface CytarioConfig {
|
|
4
|
-
setup: {
|
|
5
|
-
allowedFiles: string;
|
|
6
|
-
};
|
|
7
4
|
endpoints: {
|
|
8
5
|
webapp: string;
|
|
9
6
|
};
|
|
@@ -32,7 +29,6 @@ interface CytarioConfig {
|
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
const {
|
|
35
|
-
ALLOWED_FILES,
|
|
36
32
|
BASE_URL,
|
|
37
33
|
CLIENT_ID,
|
|
38
34
|
CLIENT_SECRET,
|
|
@@ -50,9 +46,6 @@ const {
|
|
|
50
46
|
} = process.env;
|
|
51
47
|
|
|
52
48
|
export const cytarioConfig: Readonly<CytarioConfig> = {
|
|
53
|
-
setup: {
|
|
54
|
-
allowedFiles: ALLOWED_FILES ?? ".*",
|
|
55
|
-
},
|
|
56
49
|
endpoints: {
|
|
57
50
|
webapp: WEB_HOST!,
|
|
58
51
|
},
|
package/app/entry.server.tsx
CHANGED
|
@@ -5,14 +5,13 @@ import { renderToPipeableStream } from "react-dom/server";
|
|
|
5
5
|
import type { AppLoadContext, EntryContext } from "react-router";
|
|
6
6
|
import { ServerRouter } from "react-router";
|
|
7
7
|
|
|
8
|
+
import { buildContentSecurityPolicy } from "./.server/csp";
|
|
8
9
|
import { bootstrapPlugins } from "./plugins.generated";
|
|
9
10
|
|
|
10
11
|
const ABORT_DELAY = 5_000;
|
|
11
12
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// client. `handleRequest` awaits this so a request cannot resolve the
|
|
15
|
-
// registry before the plugin's async `register()` has completed.
|
|
13
|
+
// `handleRequest` awaits this so a request cannot resolve the plugin registry
|
|
14
|
+
// before async `register()` calls have completed.
|
|
16
15
|
const bootstrapPromise: Promise<void> = bootstrapPlugins({
|
|
17
16
|
debug: (msg, fields) => console.debug("[plugin-bootstrap]", msg, fields ?? {}),
|
|
18
17
|
info: (msg, fields) => console.info("[plugin-bootstrap]", msg, fields ?? {}),
|
|
@@ -56,6 +55,9 @@ function handleBotRequest(
|
|
|
56
55
|
const stream = createReadableStreamFromReadable(body);
|
|
57
56
|
|
|
58
57
|
responseHeaders.set("Content-Type", "text/html");
|
|
58
|
+
// Only attached to the HTML document; `.data` and action JSON responses
|
|
59
|
+
// inherit the document-level policy from the hydrated page.
|
|
60
|
+
responseHeaders.set("Content-Security-Policy", buildContentSecurityPolicy());
|
|
59
61
|
|
|
60
62
|
resolve(
|
|
61
63
|
new Response(stream, {
|
|
@@ -102,6 +104,9 @@ function handleBrowserRequest(
|
|
|
102
104
|
const stream = createReadableStreamFromReadable(body);
|
|
103
105
|
|
|
104
106
|
responseHeaders.set("Content-Type", "text/html");
|
|
107
|
+
// Only attached to the HTML document; `.data` and action JSON responses
|
|
108
|
+
// inherit the document-level policy from the hydrated page.
|
|
109
|
+
responseHeaders.set("Content-Security-Policy", buildContentSecurityPolicy());
|
|
105
110
|
|
|
106
111
|
resolve(
|
|
107
112
|
new Response(stream, {
|
|
@@ -6,9 +6,9 @@ import { select } from "~/utils/connectionsStore/selectors";
|
|
|
6
6
|
import { useConnectionsStore } from "~/utils/connectionsStore/useConnectionsStore";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Replace the client connections store with the auth-context set. Uses an
|
|
10
|
+
* effect rather than render-time mutation: subscribed descendants would
|
|
11
|
+
* otherwise trip React's "cannot update during render" warning.
|
|
12
12
|
*/
|
|
13
13
|
export function useInitConnections(
|
|
14
14
|
connectionConfigs: ConnectionConfig[],
|
package/app/root.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
useNavigation,
|
|
14
14
|
useRouteError,
|
|
15
15
|
useRouteLoaderData,
|
|
16
|
+
type ClientLoaderFunctionArgs,
|
|
16
17
|
type LinksFunction,
|
|
17
18
|
type MiddlewareFunction,
|
|
18
19
|
type LoaderFunctionArgs,
|
|
@@ -62,7 +63,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
|
|
62
63
|
formAction,
|
|
63
64
|
defaultShouldRevalidate,
|
|
64
65
|
}) => {
|
|
65
|
-
// Only revalidate after form submissions
|
|
66
|
+
// Only revalidate after form submissions so flash notifications surface.
|
|
66
67
|
if (formAction) return defaultShouldRevalidate;
|
|
67
68
|
return false;
|
|
68
69
|
};
|
|
@@ -83,7 +84,6 @@ export const loader = async ({ context }: LoaderFunctionArgs): Promise<RootLoade
|
|
|
83
84
|
await sessionStorage.commitSession(session);
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
// Build Keycloak account settings URL server-side
|
|
87
87
|
const accountSettingsUrl = user
|
|
88
88
|
? `${cytarioConfig.auth.baseUrl}/account?referrer=${cytarioConfig.auth.clientId}&referrer_uri=${cytarioConfig.endpoints.webapp}`
|
|
89
89
|
: undefined;
|
|
@@ -91,6 +91,12 @@ export const loader = async ({ context }: LoaderFunctionArgs): Promise<RootLoade
|
|
|
91
91
|
return { user, notification, accountSettingsUrl };
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
+
// Identity clientLoader. Forces this route off RR's bulk-fetch single-fetch
|
|
95
|
+
// path, which is short-circuited during initial hydrate when a descendant
|
|
96
|
+
// route opts into `clientLoader.hydrate = true` (RR issue #13873).
|
|
97
|
+
export const clientLoader = ({ serverLoader }: ClientLoaderFunctionArgs) =>
|
|
98
|
+
serverLoader<typeof loader>();
|
|
99
|
+
|
|
94
100
|
export function Layout({ children }: { children: React.ReactNode }) {
|
|
95
101
|
const data = useRouteLoaderData<RootLoaderResponse>("root");
|
|
96
102
|
const location = useLocation();
|
|
@@ -105,12 +111,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|
|
105
111
|
}
|
|
106
112
|
}, [data?.notification]);
|
|
107
113
|
|
|
108
|
-
// Hydrate file store from IndexedDB on mount
|
|
109
114
|
useEffect(() => {
|
|
110
115
|
useFileStore.getState().hydrate();
|
|
111
116
|
}, []);
|
|
112
117
|
|
|
113
|
-
// Move focus to main content on route change (skip initial render)
|
|
118
|
+
// Move focus to main content on route change (skip initial render).
|
|
114
119
|
useEffect(() => {
|
|
115
120
|
if (isInitialRender.current) {
|
|
116
121
|
isInitialRender.current = false;
|
|
@@ -185,8 +190,8 @@ export function ErrorBoundary() {
|
|
|
185
190
|
title = `${error.status} ${error.statusText}`;
|
|
186
191
|
message = error.data ?? "An error occurred while processing your request.";
|
|
187
192
|
} else if (error instanceof Error) {
|
|
188
|
-
// Log full error
|
|
189
|
-
//
|
|
193
|
+
// Log full error but show only the generic message to avoid leaking
|
|
194
|
+
// session IDs, endpoints, or stack traces to the client.
|
|
190
195
|
console.error("Unhandled error:", error);
|
|
191
196
|
}
|
|
192
197
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Banner,
|
|
2
3
|
Field,
|
|
3
4
|
Fieldset,
|
|
4
5
|
FormWizard,
|
|
@@ -78,15 +79,15 @@ export const ConnectionForm = ({
|
|
|
78
79
|
const submit = useSubmit();
|
|
79
80
|
const actionData = useActionData<{
|
|
80
81
|
errors?: Record<string, string[]>;
|
|
82
|
+
formError?: string;
|
|
81
83
|
status?: string;
|
|
82
84
|
}>();
|
|
83
85
|
const navigation = useNavigation();
|
|
84
86
|
|
|
85
87
|
const serverErrors = actionData?.status === "error" ? actionData.errors : undefined;
|
|
88
|
+
const formError = actionData?.status === "error" ? actionData.formError : undefined;
|
|
86
89
|
const isSubmitting = navigation.state === "submitting";
|
|
87
90
|
|
|
88
|
-
// Compute the initial step from server errors so we navigate to the
|
|
89
|
-
// correct page without needing setState inside an effect.
|
|
90
91
|
const initialStep = serverErrors
|
|
91
92
|
? Object.keys(serverErrors).reduce<number>((acc, field) => {
|
|
92
93
|
const step = FIELD_TO_STEP[field];
|
|
@@ -115,7 +116,6 @@ export const ConnectionForm = ({
|
|
|
115
116
|
mode: "onTouched",
|
|
116
117
|
});
|
|
117
118
|
|
|
118
|
-
// Surface server-side errors (e.g. unique name constraint) in the form
|
|
119
119
|
useEffect(() => {
|
|
120
120
|
if (!serverErrors) return;
|
|
121
121
|
for (const [field, messages] of Object.entries(serverErrors)) {
|
|
@@ -198,6 +198,12 @@ export const ConnectionForm = ({
|
|
|
198
198
|
<div className="flex flex-col gap-(--spacing-6)">
|
|
199
199
|
<FormWizardProgress labels={STEP_LABELS} />
|
|
200
200
|
|
|
201
|
+
{formError && (
|
|
202
|
+
<Banner variant="danger" title="Could not save the connection">
|
|
203
|
+
{formError}
|
|
204
|
+
</Banner>
|
|
205
|
+
)}
|
|
206
|
+
|
|
201
207
|
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
|
|
202
208
|
<form
|
|
203
209
|
className="flex flex-col gap-(--spacing-6)"
|