feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -33,23 +33,24 @@ export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SU
layer.sourceLayer !== undefined && layer.availability === "required"
).map((layer) => layer.id);
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId) {
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId, workspace = GEOSERVER_WORKSPACE) {
const params = new URLSearchParams({
service: "WFS",
version: "2.0.0",
request: "GetFeature",
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[sourceId]}`,
typeNames: `${workspace}:${SOURCE_LAYERS[sourceId]}`,
resultType: "hits"
});
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
return new URL(`${MAP_URL}/${workspace}/ows?${params.toString()}`);
}
export async function fetchGeoServerLayerFeatureCount(
sourceId: DataDependentGeoServerSourceId,
signal?: AbortSignal
signal?: AbortSignal,
workspace = GEOSERVER_WORKSPACE
) {
const response = await fetch(createGeoServerLayerHitsUrl(sourceId), {
const response = await fetch(createGeoServerLayerHitsUrl(sourceId, workspace), {
headers: { Accept: "application/xml, text/xml" },
signal
});
@@ -74,12 +75,13 @@ export async function fetchGeoServerLayerFeatureCount(
}
export async function resolveGeoServerLayerAvailability(
signal?: AbortSignal
signal?: AbortSignal,
workspace = GEOSERVER_WORKSPACE
): Promise<GeoServerLayerAvailability> {
const results = await Promise.allSettled(
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map(async (sourceId) => ({
sourceId,
count: await fetchGeoServerLayerFeatureCount(sourceId, signal)
count: await fetchGeoServerLayerFeatureCount(sourceId, signal, workspace)
}))
);
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
@@ -63,13 +63,13 @@ export function escapeCqlLiteral(value: string) {
return `'${value.replaceAll("'", "''")}'`;
}
export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
export function createMapFeatureWfsUrl(query: MapFeatureQuery, workspace = GEOSERVER_WORKSPACE) {
const idField = MAP_FEATURE_ID_FIELDS[query.sourceId];
const params = new URLSearchParams({
service: "WFS",
version: "2.0.0",
request: "GetFeature",
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
typeNames: `${workspace}:${SOURCE_LAYERS[query.sourceId]}`,
outputFormat: "application/json",
srsName: "EPSG:4326",
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES)
@@ -79,7 +79,7 @@ export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
params.set("cql_filter", `"${idField}" IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
}
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
return new URL(`${MAP_URL}/${workspace}/ows?${params.toString()}`);
}
export function normalizeMapFeatureCollection(value: unknown, maxFeatures: number): FeatureCollection | null {
+13
View File
@@ -131,3 +131,16 @@ function createImageMap(loadImage: (path: string) => Promise<{ data: { width: nu
addImage: ReturnType<typeof vi.fn>;
};
}
it("does not access a disposed map after SCADA image loading completes", async () => {
const controller = new AbortController();
let resolve!: (value: { data: { width: number; height: number } }) => void;
const map = createImageMap(() => new Promise((done) => { resolve = done; }));
const pending = registerScadaImages(map, controller.signal);
controller.abort();
resolve({ data: { width: 64, height: 64 } });
await expect(pending).resolves.toMatchObject({ status: "cancelled" });
expect(map.addImage).not.toHaveBeenCalled();
expect(map.loadImage).toHaveBeenCalledTimes(1);
});
+7 -2
View File
@@ -61,7 +61,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export type ScadaImageRegistrationResult = {
status: "ready" | "degraded" | "unavailable";
status: "ready" | "degraded" | "unavailable" | "cancelled";
failedImageIds: string[];
};
@@ -226,12 +226,15 @@ export const scadaFallbackLayers = [
];
export async function registerScadaImages(
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">,
signal?: AbortSignal
): Promise<ScadaImageRegistrationResult> {
if (signal?.aborted) return { status: "cancelled", failedImageIds: [] };
let pressure: Awaited<ReturnType<typeof loadImage>>;
try {
pressure = await loadImage(map, SCADA_MAP_ICON_PATHS.pressure);
if (signal?.aborted) return { status: "cancelled", failedImageIds: [] };
addImageIfMissing(map, SCADA_IMAGE_IDS.pressure, pressure);
} catch {
return { status: "unavailable", failedImageIds: Object.values(SCADA_IMAGE_IDS) };
@@ -245,8 +248,10 @@ export async function registerScadaImages(
try {
const image = await loadImage(map, path);
if (signal?.aborted) return { status: "cancelled", failedImageIds };
addImageIfMissing(map, imageId, image);
} catch {
if (signal?.aborted) return { status: "cancelled", failedImageIds };
failedImageIds.push(imageId);
try {
addImageIfMissing(map, imageId, pressure);
+8 -5
View File
@@ -282,30 +282,33 @@ export function createWaterNetworkSources(): Record<
VectorSourceSpecification
>;
export function createWaterNetworkSources(
sourceIds: readonly GeoServerWaterNetworkSourceId[]
sourceIds: readonly GeoServerWaterNetworkSourceId[],
workspace?: string
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
export function createWaterNetworkSources(
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS,
workspace = GEOSERVER_WORKSPACE
) {
return Object.fromEntries(
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
layer.available &&
layer.sourceLayer !== undefined &&
sourceIds.includes(layer.id as GeoServerWaterNetworkSourceId)
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer, workspace)] as const]
: []
)
) as Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
}
function createGeoServerVectorSource(
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS]
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
workspace: string
): VectorSourceSpecification {
return {
type: "vector",
promoteId: "id",
tiles: [
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
`${MAP_URL}/gwc/service/wmts/rest/${workspace}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
minzoom: 0,
maxzoom: 24
@@ -51,3 +51,17 @@ describe("supply facility map icons", () => {
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
});
});
it("does not register images after the owning map is cancelled during loading", async () => {
const controller = new AbortController();
let resolve!: (value: { data: { width: number; height: number } }) => void;
const map = { hasImage: vi.fn(() => false), addImage: vi.fn(),
loadImage: vi.fn(() => new Promise<{ data: { width: number; height: number } }>((done) => { resolve = done; })) };
const pending = registerSupplyAssetImages(map as unknown as Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">, controller.signal);
controller.abort();
resolve({ data: { width: 64, height: 64 } });
await expect(pending).resolves.toMatchObject({ status: "cancelled" });
expect(map.addImage).not.toHaveBeenCalled();
expect(map.loadImage).toHaveBeenCalledTimes(1);
});
+5 -2
View File
@@ -43,7 +43,7 @@ export const SUPPLY_ASSET_MAP_ICON_PATHS = {
} as const;
export type SupplyAssetImageRegistrationResult = {
status: "ready" | "degraded";
status: "ready" | "degraded" | "cancelled";
failedImageIds: string[];
};
@@ -63,16 +63,19 @@ const ACTIVE_SUPPLY_ASSET_ICON_KEYS = [
] as const;
export async function registerSupplyAssetImages(
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">,
signal?: AbortSignal
): Promise<SupplyAssetImageRegistrationResult> {
const failedImageIds: string[] = [];
for (const key of ACTIVE_SUPPLY_ASSET_ICON_KEYS) {
if (signal?.aborted) return { status: "cancelled", failedImageIds };
const imageId = SUPPLY_ASSET_IMAGE_IDS[key];
if (map.hasImage(imageId)) continue;
try {
const image = await loadImage(map, SUPPLY_ASSET_MAP_ICON_PATHS[key]);
if (signal?.aborted) return { status: "cancelled", failedImageIds };
addImageIfMissing(map, imageId, image);
} catch {
failedImageIds.push(imageId);
@@ -86,6 +86,7 @@ export type WorkbenchMapCommands = {
};
export type WorkbenchMapControllerOptions = {
geoserverWorkspace?: string;
getMap: () => MapLibreMap | null;
isReady: () => boolean;
getPadding: () => PaddingOptions;
@@ -619,7 +620,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
if (!parsed.ok) throw new Error(parsed.code);
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
const response = await fetch(createMapFeatureWfsUrl(parsed.value, this.options.geoserverWorkspace), {
headers: { Accept: "application/geo+json, application/json" },
signal
});