birdclaw 0.8.2 → 0.8.3
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/CHANGELOG.md +16 -0
- package/package.json +2 -1
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +8 -674
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +2 -0
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useTimelineRouteData.ts +105 -235
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -215
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +75 -120
- package/src/lib/backup.ts +335 -424
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +84 -330
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -177
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -181
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +36 -110
- package/src/lib/queries.ts +6 -2381
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +48 -12
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -257
- package/src/lib/timeline-live.ts +86 -236
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -167
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/routes/__root.tsx +11 -8
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -4
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +167 -184
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -394
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/src/lib/client-cache.ts +0 -109
|
@@ -16,13 +16,9 @@ import {
|
|
|
16
16
|
useState,
|
|
17
17
|
} from "react";
|
|
18
18
|
import "mapbox-gl/dist/mapbox-gl.css";
|
|
19
|
-
import type * as GeoJSON from "geojson";
|
|
20
|
-
import Supercluster from "supercluster";
|
|
21
19
|
import type { MapRef } from "react-map-gl/mapbox";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import type { NetworkMapKind, NetworkMapResponse } from "#/lib/network-map";
|
|
25
|
-
import type { QueryEnvelope } from "#/lib/types";
|
|
20
|
+
import { useNetworkMapController } from "#/components/network-map-controller";
|
|
21
|
+
import type { NetworkMapResponse } from "#/lib/network-map";
|
|
26
22
|
import {
|
|
27
23
|
cx,
|
|
28
24
|
errorCopyClass,
|
|
@@ -41,104 +37,32 @@ import {
|
|
|
41
37
|
statusCopyClass,
|
|
42
38
|
} from "#/lib/ui";
|
|
43
39
|
|
|
40
|
+
import {
|
|
41
|
+
CLUSTER_LEAF_SAMPLE_SIZE,
|
|
42
|
+
WORLD_VIEWPORT,
|
|
43
|
+
type ClusterAggregateProperties,
|
|
44
|
+
type ClusterResult,
|
|
45
|
+
type MapFeature,
|
|
46
|
+
type MapViewport,
|
|
47
|
+
type ReactMapboxModule,
|
|
48
|
+
type SelectedOverlay,
|
|
49
|
+
avatarInitial,
|
|
50
|
+
avatarPath,
|
|
51
|
+
buildClusterIndex,
|
|
52
|
+
clusterGradient,
|
|
53
|
+
compareClusterFeatures,
|
|
54
|
+
formatNumber,
|
|
55
|
+
formatRelationship,
|
|
56
|
+
getClusterDisplayAnchor,
|
|
57
|
+
isCluster,
|
|
58
|
+
readViewport,
|
|
59
|
+
relationshipColor,
|
|
60
|
+
} from "#/components/network-map-model";
|
|
61
|
+
|
|
44
62
|
export const Route = createFileRoute("/network-map")({
|
|
45
63
|
component: NetworkMapRoute,
|
|
46
64
|
});
|
|
47
65
|
|
|
48
|
-
type ReactMapboxModule = typeof import("react-map-gl/mapbox");
|
|
49
|
-
type MapFeature = NetworkMapResponse["features"][number];
|
|
50
|
-
type MapBounds = [number, number, number, number];
|
|
51
|
-
|
|
52
|
-
interface ClusterPointProperties {
|
|
53
|
-
featureIndex: number;
|
|
54
|
-
handle: string;
|
|
55
|
-
name: string;
|
|
56
|
-
avatarUrl: string | null;
|
|
57
|
-
relationship: MapFeature["properties"]["relationship"];
|
|
58
|
-
followersCount: number;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface ClusterAggregateProperties {
|
|
62
|
-
followers: number;
|
|
63
|
-
following: number;
|
|
64
|
-
mutual: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface ClusterFeatureProperties extends ClusterAggregateProperties {
|
|
68
|
-
cluster: true;
|
|
69
|
-
cluster_id: number;
|
|
70
|
-
point_count: number;
|
|
71
|
-
point_count_abbreviated: string | number;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
type ClusterPointFeature = GeoJSON.Feature<
|
|
75
|
-
GeoJSON.Point,
|
|
76
|
-
ClusterPointProperties
|
|
77
|
-
>;
|
|
78
|
-
type ClusterFeature = GeoJSON.Feature<GeoJSON.Point, ClusterFeatureProperties>;
|
|
79
|
-
type ClusterResult = ClusterPointFeature | ClusterFeature;
|
|
80
|
-
type MapViewport = { bounds: MapBounds; zoom: number };
|
|
81
|
-
type MapTarget = {
|
|
82
|
-
getBounds: () => {
|
|
83
|
-
getWest: () => number;
|
|
84
|
-
getSouth: () => number;
|
|
85
|
-
getEast: () => number;
|
|
86
|
-
getNorth: () => number;
|
|
87
|
-
};
|
|
88
|
-
getZoom: () => number;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
type SelectedOverlay =
|
|
92
|
-
| { kind: "profile"; feature: MapFeature }
|
|
93
|
-
| {
|
|
94
|
-
kind: "cluster";
|
|
95
|
-
coordinates: [number, number];
|
|
96
|
-
count: number;
|
|
97
|
-
features: MapFeature[];
|
|
98
|
-
stats: ClusterAggregateProperties;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const CLUSTER_LEAF_SAMPLE_SIZE = 48;
|
|
102
|
-
|
|
103
|
-
const MAP_TYPES: Array<{ value: NetworkMapKind; label: string }> = [
|
|
104
|
-
{ value: "all", label: "All" },
|
|
105
|
-
{ value: "followers", label: "Followers" },
|
|
106
|
-
{ value: "following", label: "Following" },
|
|
107
|
-
{ value: "mutual", label: "Mutual" },
|
|
108
|
-
];
|
|
109
|
-
|
|
110
|
-
const WORLD_BOUNDS: MapBounds = [-180, -85, 180, 85];
|
|
111
|
-
const WORLD_VIEWPORT: MapViewport = { bounds: WORLD_BOUNDS, zoom: 1.15 };
|
|
112
|
-
|
|
113
|
-
async function fetchMap(
|
|
114
|
-
type: NetworkMapKind,
|
|
115
|
-
refresh: boolean,
|
|
116
|
-
accountId?: string,
|
|
117
|
-
signal?: AbortSignal,
|
|
118
|
-
) {
|
|
119
|
-
const url = new URL("/api/network-map", window.location.origin);
|
|
120
|
-
url.searchParams.set("type", type);
|
|
121
|
-
url.searchParams.set("limit", "50000");
|
|
122
|
-
url.searchParams.set("geocodeLimit", refresh ? "80" : "12");
|
|
123
|
-
if (accountId) url.searchParams.set("account", accountId);
|
|
124
|
-
if (refresh) url.searchParams.set("refresh", "true");
|
|
125
|
-
const response = await fetch(url, { signal });
|
|
126
|
-
if (!response.ok) {
|
|
127
|
-
throw new Error(`Map request failed (${String(response.status)})`);
|
|
128
|
-
}
|
|
129
|
-
return (await response.json()) as NetworkMapResponse;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function formatNumber(value: number) {
|
|
133
|
-
return new Intl.NumberFormat().format(value);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function formatRelationship(value: MapFeature["properties"]["relationship"]) {
|
|
137
|
-
if (value === "mutual") return "mutual";
|
|
138
|
-
if (value === "following") return "following";
|
|
139
|
-
return "follower";
|
|
140
|
-
}
|
|
141
|
-
|
|
142
66
|
function StatTile({
|
|
143
67
|
icon: Icon,
|
|
144
68
|
label,
|
|
@@ -184,29 +108,6 @@ function useMapboxModule() {
|
|
|
184
108
|
return { module, error };
|
|
185
109
|
}
|
|
186
110
|
|
|
187
|
-
function relationshipColor(
|
|
188
|
-
relationship: MapFeature["properties"]["relationship"],
|
|
189
|
-
) {
|
|
190
|
-
if (relationship === "mutual") return "#22c55e";
|
|
191
|
-
if (relationship === "following") return "#f59e0b";
|
|
192
|
-
return "#1d9bf0";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function avatarInitial(feature: MapFeature) {
|
|
196
|
-
return (feature.properties.name || feature.properties.handle || "?")
|
|
197
|
-
.slice(0, 1)
|
|
198
|
-
.toUpperCase();
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function avatarPath(feature: MapFeature) {
|
|
202
|
-
if (!feature.properties.avatarUrl) return null;
|
|
203
|
-
const query = new URLSearchParams({
|
|
204
|
-
profileId: feature.properties.profileId,
|
|
205
|
-
v: feature.properties.avatarUrl,
|
|
206
|
-
});
|
|
207
|
-
return `/api/avatar?${query.toString()}`;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
111
|
function Avatar({
|
|
211
112
|
feature,
|
|
212
113
|
size = 36,
|
|
@@ -244,136 +145,6 @@ function Avatar({
|
|
|
244
145
|
);
|
|
245
146
|
}
|
|
246
147
|
|
|
247
|
-
function clusterGradient(stats: ClusterAggregateProperties) {
|
|
248
|
-
const total = Math.max(1, stats.followers + stats.following + stats.mutual);
|
|
249
|
-
const mutual = (stats.mutual / total) * 100;
|
|
250
|
-
const following = mutual + (stats.following / total) * 100;
|
|
251
|
-
return `conic-gradient(#22c55e 0 ${mutual}%, #f59e0b ${mutual}% ${following}%, #1d9bf0 ${following}% 100%)`;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function buildClusterIndex(features: MapFeature[]) {
|
|
255
|
-
const points: ClusterPointFeature[] = features.map(
|
|
256
|
-
(feature, featureIndex) => ({
|
|
257
|
-
type: "Feature",
|
|
258
|
-
geometry: feature.geometry,
|
|
259
|
-
properties: {
|
|
260
|
-
featureIndex,
|
|
261
|
-
handle: feature.properties.handle,
|
|
262
|
-
name: feature.properties.name,
|
|
263
|
-
avatarUrl: feature.properties.avatarUrl,
|
|
264
|
-
relationship: feature.properties.relationship,
|
|
265
|
-
followersCount: feature.properties.followersCount,
|
|
266
|
-
},
|
|
267
|
-
}),
|
|
268
|
-
);
|
|
269
|
-
return new Supercluster<ClusterPointProperties, ClusterAggregateProperties>({
|
|
270
|
-
maxZoom: 18,
|
|
271
|
-
minPoints: 2,
|
|
272
|
-
radius: 64,
|
|
273
|
-
map: (props) => ({
|
|
274
|
-
followers: props.relationship === "followers" ? 1 : 0,
|
|
275
|
-
following: props.relationship === "following" ? 1 : 0,
|
|
276
|
-
mutual: props.relationship === "mutual" ? 1 : 0,
|
|
277
|
-
}),
|
|
278
|
-
reduce: (accumulated, props) => {
|
|
279
|
-
accumulated.followers += props.followers;
|
|
280
|
-
accumulated.following += props.following;
|
|
281
|
-
accumulated.mutual += props.mutual;
|
|
282
|
-
},
|
|
283
|
-
}).load(points);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function isCluster(item: ClusterResult): item is ClusterFeature {
|
|
287
|
-
return "cluster" in item.properties && item.properties.cluster === true;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function compareClusterFeatures(a: MapFeature, b: MapFeature) {
|
|
291
|
-
return (
|
|
292
|
-
b.properties.followersCount - a.properties.followersCount ||
|
|
293
|
-
a.properties.handle.localeCompare(b.properties.handle)
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function getClusterDisplayAnchor(
|
|
298
|
-
features: MapFeature[],
|
|
299
|
-
fallback: [number, number],
|
|
300
|
-
): [number, number] {
|
|
301
|
-
const buckets = new Map<
|
|
302
|
-
string,
|
|
303
|
-
{ coordinates: [number, number]; count: number; followers: number }
|
|
304
|
-
>();
|
|
305
|
-
for (const feature of features) {
|
|
306
|
-
const [lng, lat] = feature.geometry.coordinates;
|
|
307
|
-
const key = `${lng.toFixed(4)},${lat.toFixed(4)}`;
|
|
308
|
-
const existing = buckets.get(key);
|
|
309
|
-
if (existing) {
|
|
310
|
-
existing.count += 1;
|
|
311
|
-
existing.followers += feature.properties.followersCount;
|
|
312
|
-
} else {
|
|
313
|
-
buckets.set(key, {
|
|
314
|
-
coordinates: [lng, lat],
|
|
315
|
-
count: 1,
|
|
316
|
-
followers: feature.properties.followersCount,
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
const best = [...buckets.values()].sort(
|
|
321
|
-
(a, b) => b.count - a.count || b.followers - a.followers,
|
|
322
|
-
)[0];
|
|
323
|
-
return best?.coordinates ?? fallback;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function readViewport(target: unknown): MapViewport | null {
|
|
327
|
-
if (!target || typeof target !== "object") return null;
|
|
328
|
-
const map = target as Partial<MapTarget>;
|
|
329
|
-
if (
|
|
330
|
-
typeof map.getBounds !== "function" ||
|
|
331
|
-
typeof map.getZoom !== "function"
|
|
332
|
-
) {
|
|
333
|
-
return null;
|
|
334
|
-
}
|
|
335
|
-
const bounds = map.getBounds();
|
|
336
|
-
return {
|
|
337
|
-
bounds: [
|
|
338
|
-
bounds.getWest(),
|
|
339
|
-
bounds.getSouth(),
|
|
340
|
-
bounds.getEast(),
|
|
341
|
-
bounds.getNorth(),
|
|
342
|
-
],
|
|
343
|
-
zoom: map.getZoom(),
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function boundsContainFeature(bounds: MapBounds, feature: MapFeature) {
|
|
348
|
-
const [west, south, east, north] = bounds;
|
|
349
|
-
const [lng, lat] = feature.geometry.coordinates;
|
|
350
|
-
const normalizedWest = ((west + 540) % 360) - 180;
|
|
351
|
-
const normalizedEast = ((east + 540) % 360) - 180;
|
|
352
|
-
const inLatitude = lat >= Math.max(-85, south) && lat <= Math.min(85, north);
|
|
353
|
-
const inLongitude =
|
|
354
|
-
east - west >= 360
|
|
355
|
-
? true
|
|
356
|
-
: normalizedWest <= normalizedEast
|
|
357
|
-
? lng >= normalizedWest && lng <= normalizedEast
|
|
358
|
-
: lng >= normalizedWest || lng <= normalizedEast;
|
|
359
|
-
return inLatitude && inLongitude;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function featureMatchesSearch(feature: MapFeature, search: string) {
|
|
363
|
-
const needle = search.trim().toLowerCase();
|
|
364
|
-
if (!needle) return true;
|
|
365
|
-
return [
|
|
366
|
-
feature.properties.name,
|
|
367
|
-
feature.properties.handle,
|
|
368
|
-
feature.properties.location,
|
|
369
|
-
feature.properties.resolvedLocation ?? "",
|
|
370
|
-
formatRelationship(feature.properties.relationship),
|
|
371
|
-
]
|
|
372
|
-
.join(" ")
|
|
373
|
-
.toLowerCase()
|
|
374
|
-
.includes(needle);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
148
|
function ProfileMarker({ feature }: { feature: MapFeature }) {
|
|
378
149
|
const color = relationshipColor(feature.properties.relationship);
|
|
379
150
|
return (
|
|
@@ -847,95 +618,21 @@ function VisibleProfilesPanel({
|
|
|
847
618
|
}
|
|
848
619
|
|
|
849
620
|
function NetworkMapRoute() {
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
mapRequestIdRef.current = requestId;
|
|
866
|
-
mapAbortControllerRef.current?.abort();
|
|
867
|
-
const controller = new AbortController();
|
|
868
|
-
mapAbortControllerRef.current = controller;
|
|
869
|
-
setLoading(true);
|
|
870
|
-
setError(null);
|
|
871
|
-
fetchMap(type, refresh, selectedAccountId, controller.signal)
|
|
872
|
-
.then((nextData) => {
|
|
873
|
-
if (mapRequestIdRef.current !== requestId) return;
|
|
874
|
-
setData(nextData);
|
|
875
|
-
})
|
|
876
|
-
.catch((cause: unknown) => {
|
|
877
|
-
if (
|
|
878
|
-
controller.signal.aborted ||
|
|
879
|
-
mapRequestIdRef.current !== requestId
|
|
880
|
-
) {
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
setError(cause instanceof Error ? cause.message : "Map unavailable");
|
|
884
|
-
})
|
|
885
|
-
.finally(() => {
|
|
886
|
-
if (mapRequestIdRef.current === requestId) {
|
|
887
|
-
setLoading(false);
|
|
888
|
-
}
|
|
889
|
-
});
|
|
890
|
-
},
|
|
891
|
-
[selectedAccountId, type],
|
|
892
|
-
);
|
|
893
|
-
|
|
894
|
-
useEffect(() => {
|
|
895
|
-
const controller = new AbortController();
|
|
896
|
-
fetchQueryEnvelope({ signal: controller.signal })
|
|
897
|
-
.then(setMeta)
|
|
898
|
-
.catch(() => {
|
|
899
|
-
// The map can still load against the default account if status is down.
|
|
900
|
-
})
|
|
901
|
-
.finally(() => {
|
|
902
|
-
if (!controller.signal.aborted) setMetaLoaded(true);
|
|
903
|
-
});
|
|
904
|
-
return () => controller.abort();
|
|
905
|
-
}, []);
|
|
906
|
-
|
|
907
|
-
useEffect(() => {
|
|
908
|
-
if (!metaLoaded) return;
|
|
909
|
-
load(false);
|
|
910
|
-
}, [load, metaLoaded]);
|
|
911
|
-
|
|
912
|
-
useEffect(
|
|
913
|
-
() => () => {
|
|
914
|
-
mapAbortControllerRef.current?.abort();
|
|
915
|
-
},
|
|
916
|
-
[],
|
|
917
|
-
);
|
|
918
|
-
|
|
919
|
-
const visibleFeatures = useMemo(
|
|
920
|
-
() =>
|
|
921
|
-
(data?.features ?? [])
|
|
922
|
-
.slice()
|
|
923
|
-
.filter((feature) => boundsContainFeature(viewport.bounds, feature))
|
|
924
|
-
.sort(
|
|
925
|
-
(a, b) =>
|
|
926
|
-
b.properties.followersCount - a.properties.followersCount ||
|
|
927
|
-
a.properties.handle.localeCompare(b.properties.handle),
|
|
928
|
-
),
|
|
929
|
-
[data, viewport],
|
|
930
|
-
);
|
|
931
|
-
|
|
932
|
-
const filteredVisibleFeatures = useMemo(
|
|
933
|
-
() =>
|
|
934
|
-
visibleFeatures
|
|
935
|
-
.filter((feature) => featureMatchesSearch(feature, visibleSearch))
|
|
936
|
-
.slice(0, 160),
|
|
937
|
-
[visibleFeatures, visibleSearch],
|
|
938
|
-
);
|
|
621
|
+
const {
|
|
622
|
+
type,
|
|
623
|
+
setType,
|
|
624
|
+
viewport,
|
|
625
|
+
setViewport,
|
|
626
|
+
visibleSearch,
|
|
627
|
+
setVisibleSearch,
|
|
628
|
+
data,
|
|
629
|
+
loading,
|
|
630
|
+
error,
|
|
631
|
+
refresh,
|
|
632
|
+
visibleFeatures,
|
|
633
|
+
filteredVisibleFeatures,
|
|
634
|
+
mapTypes,
|
|
635
|
+
} = useNetworkMapController();
|
|
939
636
|
|
|
940
637
|
return (
|
|
941
638
|
<section className="flex min-h-screen flex-col min-[1180px]:h-screen min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
|
|
@@ -953,7 +650,7 @@ function NetworkMapRoute() {
|
|
|
953
650
|
<button
|
|
954
651
|
className={secondaryButtonClass}
|
|
955
652
|
type="button"
|
|
956
|
-
onClick={() =>
|
|
653
|
+
onClick={() => refresh()}
|
|
957
654
|
disabled={loading}
|
|
958
655
|
>
|
|
959
656
|
<RefreshCw className={cx("size-4", loading && "animate-spin")} />
|
|
@@ -963,7 +660,7 @@ function NetworkMapRoute() {
|
|
|
963
660
|
</div>
|
|
964
661
|
<div className="border-t border-[var(--line)] px-4 py-3">
|
|
965
662
|
<div className={segmentedClass}>
|
|
966
|
-
{
|
|
663
|
+
{mapTypes.map((item) => (
|
|
967
664
|
<button
|
|
968
665
|
key={item.value}
|
|
969
666
|
type="button"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
3
|
import {
|
|
3
4
|
Activity,
|
|
4
5
|
AlertTriangle,
|
|
@@ -7,7 +8,8 @@ import {
|
|
|
7
8
|
RefreshCw,
|
|
8
9
|
ShieldCheck,
|
|
9
10
|
} from "lucide-react";
|
|
10
|
-
import {
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { queryKeys } from "#/lib/query-client";
|
|
11
13
|
import type {
|
|
12
14
|
XurlRateLimitEndpointSnapshot,
|
|
13
15
|
XurlRateLimitEvent,
|
|
@@ -192,26 +194,16 @@ function EventRow({
|
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
function RateLimitsRoute() {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
const rateLimitsQuery = useQuery({
|
|
198
|
+
queryKey: queryKeys.rateLimits,
|
|
199
|
+
queryFn: fetchRateLimits,
|
|
200
|
+
staleTime: 60_000,
|
|
201
|
+
});
|
|
202
|
+
const snapshot = rateLimitsQuery.data ?? null;
|
|
203
|
+
const loading = rateLimitsQuery.isFetching;
|
|
204
|
+
const error = rateLimitsQuery.error;
|
|
198
205
|
const nowMs = useMemo(() => Date.now(), [snapshot]);
|
|
199
206
|
|
|
200
|
-
const load = useCallback(() => {
|
|
201
|
-
setLoading(true);
|
|
202
|
-
setError(null);
|
|
203
|
-
fetchRateLimits()
|
|
204
|
-
.then(setSnapshot)
|
|
205
|
-
.catch((cause: unknown) => {
|
|
206
|
-
setError(cause instanceof Error ? cause.message : "Rate limits failed");
|
|
207
|
-
})
|
|
208
|
-
.finally(() => setLoading(false));
|
|
209
|
-
}, []);
|
|
210
|
-
|
|
211
|
-
useEffect(() => {
|
|
212
|
-
load();
|
|
213
|
-
}, [load]);
|
|
214
|
-
|
|
215
207
|
return (
|
|
216
208
|
<section className="flex min-h-screen flex-col">
|
|
217
209
|
<header className={pageHeaderClass}>
|
|
@@ -238,7 +230,7 @@ function RateLimitsRoute() {
|
|
|
238
230
|
<button
|
|
239
231
|
className={secondaryButtonClass}
|
|
240
232
|
disabled={loading}
|
|
241
|
-
onClick={
|
|
233
|
+
onClick={() => void rateLimitsQuery.refetch()}
|
|
242
234
|
type="button"
|
|
243
235
|
>
|
|
244
236
|
<RefreshCw
|
|
@@ -251,7 +243,11 @@ function RateLimitsRoute() {
|
|
|
251
243
|
</div>
|
|
252
244
|
</header>
|
|
253
245
|
|
|
254
|
-
{error ?
|
|
246
|
+
{error ? (
|
|
247
|
+
<div className={errorCopyClass}>
|
|
248
|
+
{error instanceof Error ? error.message : "Rate limits failed"}
|
|
249
|
+
</div>
|
|
250
|
+
) : null}
|
|
255
251
|
|
|
256
252
|
{snapshot ? (
|
|
257
253
|
<>
|
package/src/lib/client-cache.ts
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
const DEFAULT_MAX_ENTRIES = 100;
|
|
2
|
-
|
|
3
|
-
interface ClientCacheEntry<T> {
|
|
4
|
-
value: T;
|
|
5
|
-
updatedAt: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface LoadClientCacheOptions {
|
|
9
|
-
force?: boolean;
|
|
10
|
-
maxAgeMs?: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const values = new Map<string, ClientCacheEntry<unknown>>();
|
|
14
|
-
const pending = new Map<string, Promise<unknown>>();
|
|
15
|
-
const revisions = new Map<string, number>();
|
|
16
|
-
let generation = 0;
|
|
17
|
-
|
|
18
|
-
function cacheToken(key: string) {
|
|
19
|
-
return `${String(generation)}:${String(revisions.get(key) ?? 0)}`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function invalidateKey(key: string) {
|
|
23
|
-
revisions.set(key, (revisions.get(key) ?? 0) + 1);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function pruneCache() {
|
|
27
|
-
while (values.size > DEFAULT_MAX_ENTRIES) {
|
|
28
|
-
const oldestKey = values.keys().next().value as string | undefined;
|
|
29
|
-
if (!oldestKey) return;
|
|
30
|
-
values.delete(oldestKey);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function readClientCache<T>(
|
|
35
|
-
key: string,
|
|
36
|
-
maxAgeMs = Number.POSITIVE_INFINITY,
|
|
37
|
-
) {
|
|
38
|
-
const entry = values.get(key) as ClientCacheEntry<T> | undefined;
|
|
39
|
-
if (!entry) return undefined;
|
|
40
|
-
if (Date.now() - entry.updatedAt > maxAgeMs) {
|
|
41
|
-
values.delete(key);
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
return entry.value;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function writeClientCache<T>(key: string, value: T) {
|
|
48
|
-
values.delete(key);
|
|
49
|
-
values.set(key, { value, updatedAt: Date.now() });
|
|
50
|
-
pruneCache();
|
|
51
|
-
return value;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function loadClientCache<T>(
|
|
55
|
-
key: string,
|
|
56
|
-
load: () => Promise<T>,
|
|
57
|
-
{
|
|
58
|
-
force = false,
|
|
59
|
-
maxAgeMs = Number.POSITIVE_INFINITY,
|
|
60
|
-
}: LoadClientCacheOptions = {},
|
|
61
|
-
) {
|
|
62
|
-
if (force) {
|
|
63
|
-
invalidateKey(key);
|
|
64
|
-
values.delete(key);
|
|
65
|
-
pending.delete(key);
|
|
66
|
-
} else {
|
|
67
|
-
const cached = readClientCache<T>(key, maxAgeMs);
|
|
68
|
-
if (cached !== undefined) return Promise.resolve(cached);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const active = pending.get(key) as Promise<T> | undefined;
|
|
72
|
-
if (active) return active;
|
|
73
|
-
|
|
74
|
-
const token = cacheToken(key);
|
|
75
|
-
let request: Promise<T>;
|
|
76
|
-
request = load()
|
|
77
|
-
.then((value) => {
|
|
78
|
-
if (cacheToken(key) === token) writeClientCache(key, value);
|
|
79
|
-
return value;
|
|
80
|
-
})
|
|
81
|
-
.finally(() => {
|
|
82
|
-
if (pending.get(key) === request) pending.delete(key);
|
|
83
|
-
});
|
|
84
|
-
pending.set(key, request);
|
|
85
|
-
return request;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function deleteClientCache(key: string) {
|
|
89
|
-
invalidateKey(key);
|
|
90
|
-
values.delete(key);
|
|
91
|
-
pending.delete(key);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function deleteClientCacheByPrefix(prefix: string) {
|
|
95
|
-
const keys = new Set([...values.keys(), ...pending.keys()]);
|
|
96
|
-
for (const key of keys) {
|
|
97
|
-
if (!key.startsWith(prefix)) continue;
|
|
98
|
-
invalidateKey(key);
|
|
99
|
-
values.delete(key);
|
|
100
|
-
pending.delete(key);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function clearClientCache() {
|
|
105
|
-
generation += 1;
|
|
106
|
-
values.clear();
|
|
107
|
-
pending.clear();
|
|
108
|
-
revisions.clear();
|
|
109
|
-
}
|