release: bump version to 0.68.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.67.0",
|
||||
"version": "0.68.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -333,6 +333,9 @@ const bgpEventIconLayer = createInteractableLayer({
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
}),
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
cluster: {
|
||||
strategy: "stable-spherical",
|
||||
},
|
||||
});
|
||||
|
||||
const bgpCollectorIconLayer = createInteractableLayer({
|
||||
@@ -402,6 +405,9 @@ const bgpCollectorIconLayer = createInteractableLayer({
|
||||
};
|
||||
},
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
cluster: {
|
||||
strategy: "stable-spherical",
|
||||
},
|
||||
});
|
||||
|
||||
function clamp(value, min, max) {
|
||||
|
||||
@@ -247,6 +247,9 @@ const computeCenterIconLayer = createInteractableLayer({
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
}),
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
cluster: {
|
||||
strategy: "stable-spherical",
|
||||
},
|
||||
});
|
||||
|
||||
export function formatComputeCenterTypeLabel(siteType) {
|
||||
|
||||
@@ -93,6 +93,9 @@ const earthInteractableLayer = createInteractableLayer({
|
||||
...item,
|
||||
type: "earth_interactable",
|
||||
}),
|
||||
cluster: {
|
||||
strategy: "stable-spherical",
|
||||
},
|
||||
});
|
||||
|
||||
export async function loadEarthInteractables(earth, { silent = false } = {}) {
|
||||
|
||||
@@ -17,6 +17,12 @@ const CLUSTER_MAX_MARKERS_PER_DOT = 14;
|
||||
const CLUSTER_MAX_POINT_SIZE = 32;
|
||||
const CLUSTER_MAX_SCREEN_DIAMETER_PX = 96;
|
||||
const CLUSTER_SEED_DISTANCE_FACTOR = 1.8;
|
||||
const CLUSTER_TRANSITION_MS = 220;
|
||||
const CLUSTER_BAND_HYSTERESIS = 0.08;
|
||||
const CLUSTER_DISABLE_ABOVE_ZOOM = 2.5;
|
||||
const CLUSTER_STRATEGY_DYNAMIC_SCREEN = "dynamic-screen";
|
||||
const CLUSTER_STRATEGY_STABLE_SPHERICAL = "stable-spherical";
|
||||
const CLUSTER_STRATEGY_NONE = "none";
|
||||
const CLUSTER_ZOOM_BANDS = Object.freeze([
|
||||
Object.freeze({
|
||||
maxZoom: 2.0,
|
||||
@@ -37,6 +43,12 @@ const CLUSTER_ZOOM_BANDS = Object.freeze([
|
||||
maxDiameterPx: 48,
|
||||
}),
|
||||
]);
|
||||
const SPHERICAL_CLUSTER_BANDS = Object.freeze([
|
||||
Object.freeze({ key: "far", maxZoom: 1.7, distance: 15 }),
|
||||
Object.freeze({ key: "mid", maxZoom: 2.6, distance: 8 }),
|
||||
Object.freeze({ key: "near", maxZoom: 3.5, distance: 4 }),
|
||||
Object.freeze({ key: "detail", maxZoom: Number.POSITIVE_INFINITY, distance: 0 }),
|
||||
]);
|
||||
let compactDotsEnabled = true;
|
||||
let screenAvoidanceRevision = 0;
|
||||
|
||||
@@ -90,15 +102,33 @@ function normalizeClusterConfig(cluster, avoidanceConfig) {
|
||||
if (cluster === false || cluster === null) {
|
||||
return {
|
||||
enabled: false,
|
||||
strategy: CLUSTER_STRATEGY_NONE,
|
||||
overlapFactor: CLUSTER_OVERLAP_FACTOR,
|
||||
minCount: CLUSTER_MIN_COUNT,
|
||||
maxMarkersPerDot: CLUSTER_MAX_MARKERS_PER_DOT,
|
||||
bands: SPHERICAL_CLUSTER_BANDS,
|
||||
transitionMs: CLUSTER_TRANSITION_MS,
|
||||
bandHysteresis: CLUSTER_BAND_HYSTERESIS,
|
||||
disableAboveZoom: CLUSTER_DISABLE_ABOVE_ZOOM,
|
||||
};
|
||||
}
|
||||
|
||||
const customConfig = typeof cluster === "object" ? cluster : {};
|
||||
const requestedStrategy = String(customConfig.strategy || "").trim();
|
||||
const enabled = customConfig.enabled ?? avoidanceConfig.enabled;
|
||||
const strategy =
|
||||
enabled === false
|
||||
? CLUSTER_STRATEGY_NONE
|
||||
: [
|
||||
CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||
CLUSTER_STRATEGY_STABLE_SPHERICAL,
|
||||
CLUSTER_STRATEGY_NONE,
|
||||
].includes(requestedStrategy)
|
||||
? requestedStrategy
|
||||
: CLUSTER_STRATEGY_DYNAMIC_SCREEN;
|
||||
return {
|
||||
enabled: customConfig.enabled ?? avoidanceConfig.enabled,
|
||||
enabled: enabled !== false && strategy !== CLUSTER_STRATEGY_NONE,
|
||||
strategy,
|
||||
overlapFactor: Number.isFinite(Number(customConfig.overlapFactor))
|
||||
? Number(customConfig.overlapFactor)
|
||||
: CLUSTER_OVERLAP_FACTOR,
|
||||
@@ -107,9 +137,40 @@ function normalizeClusterConfig(cluster, avoidanceConfig) {
|
||||
2,
|
||||
Math.round(Number(customConfig.maxMarkersPerDot ?? CLUSTER_MAX_MARKERS_PER_DOT)),
|
||||
),
|
||||
bands: normalizeSphericalClusterBands(customConfig.bands),
|
||||
transitionMs: Math.max(
|
||||
0,
|
||||
Math.round(Number(customConfig.transitionMs ?? CLUSTER_TRANSITION_MS)),
|
||||
),
|
||||
bandHysteresis: Math.max(
|
||||
0,
|
||||
Number(customConfig.bandHysteresis ?? CLUSTER_BAND_HYSTERESIS),
|
||||
),
|
||||
disableAboveZoom:
|
||||
customConfig.disableAboveZoom === false || customConfig.disableAboveZoom === null
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Number.isFinite(Number(customConfig.disableAboveZoom))
|
||||
? Number(customConfig.disableAboveZoom)
|
||||
: CLUSTER_DISABLE_ABOVE_ZOOM,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSphericalClusterBands(bands) {
|
||||
if (!Array.isArray(bands) || bands.length === 0) return SPHERICAL_CLUSTER_BANDS;
|
||||
const normalizedBands = bands
|
||||
.map((band, index) => {
|
||||
const maxZoom = Number(band?.maxZoom);
|
||||
const distance = Number(band?.distance);
|
||||
return {
|
||||
key: String(band?.key || `band-${index}`),
|
||||
maxZoom: Number.isFinite(maxZoom) ? maxZoom : Number.POSITIVE_INFINITY,
|
||||
distance: Number.isFinite(distance) ? Math.max(0, distance) : 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.maxZoom - b.maxZoom);
|
||||
return normalizedBands.length > 0 ? normalizedBands : SPHERICAL_CLUSTER_BANDS;
|
||||
}
|
||||
|
||||
function getClusterPointSize(count) {
|
||||
const safeCount = Math.max(2, Number(count) || 2);
|
||||
return Math.min(
|
||||
@@ -125,6 +186,40 @@ function getClusterZoomBand(zoom) {
|
||||
);
|
||||
}
|
||||
|
||||
function getSphericalClusterBand(config, zoom, previousBandKey = null) {
|
||||
const bands = config?.bands || SPHERICAL_CLUSTER_BANDS;
|
||||
const previousIndex = previousBandKey
|
||||
? bands.findIndex((band) => band.key === previousBandKey)
|
||||
: -1;
|
||||
const hysteresis = Number(config?.bandHysteresis) || 0;
|
||||
if (previousIndex >= 0 && hysteresis > 0) {
|
||||
const previousBand = bands[previousIndex];
|
||||
const lowerBoundary =
|
||||
previousIndex > 0 ? bands[previousIndex - 1].maxZoom : Number.NEGATIVE_INFINITY;
|
||||
const upperBoundary = previousBand.maxZoom;
|
||||
if (zoom > lowerBoundary - hysteresis && zoom <= upperBoundary + hysteresis) {
|
||||
return previousBand;
|
||||
}
|
||||
}
|
||||
return bands.find((band) => zoom <= band.maxZoom) || bands[bands.length - 1];
|
||||
}
|
||||
|
||||
function getNowMs() {
|
||||
return typeof performance !== "undefined" && typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
function easeOutCubic(value) {
|
||||
const t = Math.max(0, Math.min(1, value));
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function getTransitionProgress(startedAt, durationMs) {
|
||||
if (!startedAt || !durationMs || durationMs <= 0) return 1;
|
||||
return easeOutCubic((getNowMs() - startedAt) / durationMs);
|
||||
}
|
||||
|
||||
function getNominalGlobePixelsPerWorldUnit(camera, referenceZoom) {
|
||||
if (!camera) return 1;
|
||||
const viewportHeight = window.innerHeight || 1;
|
||||
@@ -150,6 +245,55 @@ function getClusterOverlapFactorForBand(band, baseFactor = CLUSTER_OVERLAP_FACTO
|
||||
return Math.min(baseFactor, band.overlapFactor);
|
||||
}
|
||||
|
||||
function getSphericalBucketKey(latIndex, lonIndex) {
|
||||
return `${latIndex}:${lonIndex}`;
|
||||
}
|
||||
|
||||
function normalizeSphericalLongitudeIndex(lonIndex, lonBucketCount) {
|
||||
if (!Number.isFinite(lonBucketCount) || lonBucketCount <= 0) return lonIndex;
|
||||
return ((lonIndex % lonBucketCount) + lonBucketCount) % lonBucketCount;
|
||||
}
|
||||
|
||||
function getNeighboringSphericalBucketKeys(latIndex, lonIndex, lonBucketCount) {
|
||||
const keys = [];
|
||||
for (let latOffset = -1; latOffset <= 1; latOffset += 1) {
|
||||
for (let lonOffset = -1; lonOffset <= 1; lonOffset += 1) {
|
||||
keys.push(
|
||||
getSphericalBucketKey(
|
||||
latIndex + latOffset,
|
||||
normalizeSphericalLongitudeIndex(lonIndex + lonOffset, lonBucketCount),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function getEntrySphericalCoordinates(entry) {
|
||||
const direction = entry.direction;
|
||||
const lat = Math.asin(Math.max(-1, Math.min(1, direction.y)));
|
||||
const lon = Math.atan2(direction.z, direction.x);
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
function hashStableIds(entries) {
|
||||
let hash = 2166136261;
|
||||
entries.forEach((entry) => {
|
||||
const value = entry.stableId;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
});
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function getClusterRecordId(entries) {
|
||||
const sortedEntries = [...entries].sort((a, b) => a.stableId.localeCompare(b.stableId));
|
||||
const anchorId = sortedEntries[0]?.stableId || "unknown";
|
||||
return `cluster:${anchorId}:${sortedEntries.length}:${hashStableIds(sortedEntries)}`;
|
||||
}
|
||||
|
||||
function disposeGroupChildren(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
@@ -302,13 +446,23 @@ function getMarkerStaticAvoidancePosition(marker) {
|
||||
);
|
||||
}
|
||||
|
||||
function collectScreenAvoidanceEntries(camera) {
|
||||
function collectClusterUpdates(camera) {
|
||||
if (!camera) return [];
|
||||
const entries = [];
|
||||
const updates = [];
|
||||
interactableLayerControllers.forEach((controller) => {
|
||||
entries.push(...(controller.collectClusterEntries?.(camera) || []));
|
||||
const update = controller.collectClusterEntries?.(camera);
|
||||
if (!update) return;
|
||||
if (Array.isArray(update)) {
|
||||
updates.push({
|
||||
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||
entries: update,
|
||||
records: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
updates.push(update);
|
||||
});
|
||||
return entries.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
||||
return updates;
|
||||
}
|
||||
|
||||
function findScreenClusterGroups(entries) {
|
||||
@@ -419,6 +573,77 @@ function splitLargeScreenClusterGroup(groupEntries) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function computeSphericalClusterRecords(entries, config, band) {
|
||||
if (!entries.length || !band || band.distance <= 0) return [];
|
||||
|
||||
const radius =
|
||||
entries.find((entry) => Number.isFinite(entry.radiusWorld))?.radiusWorld ||
|
||||
CONFIG.earthRadius;
|
||||
const angularThreshold = Math.max(0.00001, band.distance / Math.max(1, radius));
|
||||
const lonBucketCount = Math.max(1, Math.ceil((Math.PI * 2) / angularThreshold));
|
||||
const buckets = new Map();
|
||||
const entryState = entries.map((entry) => {
|
||||
const { lat, lon } = getEntrySphericalCoordinates(entry);
|
||||
const latIndex = Math.floor(lat / angularThreshold);
|
||||
const lonIndex = normalizeSphericalLongitudeIndex(
|
||||
Math.floor(lon / angularThreshold),
|
||||
lonBucketCount,
|
||||
);
|
||||
const state = {
|
||||
...entry,
|
||||
latIndex,
|
||||
lonIndex,
|
||||
};
|
||||
const key = getSphericalBucketKey(latIndex, lonIndex);
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(state);
|
||||
return state;
|
||||
});
|
||||
|
||||
const unvisited = new Set(entryState);
|
||||
const groups = [];
|
||||
entryState
|
||||
.slice()
|
||||
.sort((a, b) => a.stableId.localeCompare(b.stableId))
|
||||
.forEach((seed) => {
|
||||
if (!unvisited.has(seed)) return;
|
||||
const groupEntries = [seed];
|
||||
unvisited.delete(seed);
|
||||
const neighborKeys = getNeighboringSphericalBucketKeys(
|
||||
seed.latIndex,
|
||||
seed.lonIndex,
|
||||
lonBucketCount,
|
||||
);
|
||||
const candidates = neighborKeys
|
||||
.flatMap((key) => buckets.get(key) || [])
|
||||
.filter((candidate) => unvisited.has(candidate))
|
||||
.sort((a, b) => getAngularDistance(seed, a) - getAngularDistance(seed, b) || a.stableId.localeCompare(b.stableId));
|
||||
|
||||
candidates.forEach((candidate) => {
|
||||
if (!unvisited.has(candidate)) return;
|
||||
if (getAngularDistance(seed, candidate) > angularThreshold) return;
|
||||
const overlapsGroup = groupEntries.some(
|
||||
(entry) => getAngularDistance(candidate, entry) <= angularThreshold,
|
||||
);
|
||||
if (!overlapsGroup) return;
|
||||
groupEntries.push(candidate);
|
||||
unvisited.delete(candidate);
|
||||
});
|
||||
|
||||
if (groupEntries.length >= config.minCount) {
|
||||
groups.push(groupEntries.sort((a, b) => a.stableId.localeCompare(b.stableId)));
|
||||
return;
|
||||
}
|
||||
|
||||
groupEntries.forEach((entry) => unvisited.add(entry));
|
||||
});
|
||||
|
||||
return groups
|
||||
.flatMap(splitLargeScreenClusterGroup)
|
||||
.map(createScreenClusterRecord)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function compareEntriesByStableGeography(a, b) {
|
||||
const lonA = Math.atan2(a.direction.z, a.direction.x);
|
||||
const lonB = Math.atan2(b.direction.z, b.direction.x);
|
||||
@@ -455,7 +680,7 @@ function createScreenClusterRecord(groupEntries) {
|
||||
});
|
||||
center.x /= sortedEntries.length;
|
||||
center.y /= sortedEntries.length;
|
||||
const clusterId = `cluster:${sortedEntries.map((entry) => entry.stableId).join("+")}`;
|
||||
const clusterId = getClusterRecordId(sortedEntries);
|
||||
return {
|
||||
clusterId,
|
||||
markers: sortedEntries.map((entry) => entry.marker),
|
||||
@@ -463,9 +688,10 @@ function createScreenClusterRecord(groupEntries) {
|
||||
position: clusterPosition,
|
||||
pointSize: getClusterPointSize(sortedEntries.length),
|
||||
anchorStableId: anchorEntry.stableId,
|
||||
clusterZoomBand: anchorEntry.clusterZoomBand,
|
||||
clusterZoomBand: anchorEntry.clusterBandKey || anchorEntry.clusterZoomBand,
|
||||
screenX: center.x,
|
||||
screenY: center.y,
|
||||
transitionStartedAt: getNowMs(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,7 +701,15 @@ function recomputeScreenAvoidance(camera) {
|
||||
interactableLayerControllers.forEach((controller) => {
|
||||
controller.beginClusterUpdate?.();
|
||||
});
|
||||
const entries = collectScreenAvoidanceEntries(camera);
|
||||
const updates = collectClusterUpdates(camera);
|
||||
const dynamicEntries = updates
|
||||
.filter((update) => update.strategy === CLUSTER_STRATEGY_DYNAMIC_SCREEN)
|
||||
.flatMap((update) => update.entries || [])
|
||||
.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
||||
const stableRecords = updates
|
||||
.filter((update) => update.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL)
|
||||
.flatMap((update) => update.records || []);
|
||||
const entries = dynamicEntries;
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const basePosition = getMarkerStaticAvoidancePosition(entry.marker);
|
||||
@@ -491,6 +725,7 @@ function recomputeScreenAvoidance(camera) {
|
||||
.flatMap(splitLargeScreenClusterGroup)
|
||||
.map(createScreenClusterRecord)
|
||||
.filter(Boolean)
|
||||
.concat(stableRecords)
|
||||
.forEach((record) => {
|
||||
record.owner.addOwnedCluster?.(record);
|
||||
record.markers.forEach((marker) => {
|
||||
@@ -589,6 +824,10 @@ export function createInteractableLayer(options = {}) {
|
||||
let pendingClusterSignature = "";
|
||||
let clusterUpdateActive = false;
|
||||
let visualStateVersion = 0;
|
||||
let clusterTopologyRevision = 0;
|
||||
let lastStableClusterKey = "";
|
||||
let lastStableClusterBandKey = "";
|
||||
let stableClusterRecords = [];
|
||||
const ownedClusterRecords = [];
|
||||
const scratchDirection = new THREE.Vector3();
|
||||
const scratchCameraLocal = new THREE.Vector3();
|
||||
@@ -626,6 +865,12 @@ export function createInteractableLayer(options = {}) {
|
||||
lastVisualStateKey = "";
|
||||
}
|
||||
|
||||
function invalidateClusterTopology() {
|
||||
clusterTopologyRevision += 1;
|
||||
lastStableClusterKey = "";
|
||||
lastStableClusterBandKey = "";
|
||||
}
|
||||
|
||||
function refreshViewportSize() {
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
viewportSize.set(
|
||||
@@ -910,10 +1155,29 @@ export function createInteractableLayer(options = {}) {
|
||||
}
|
||||
|
||||
function collectClusterEntries(camera) {
|
||||
if (!visible || !clusterConfig.enabled || !camera || markers.length === 0) return [];
|
||||
if (!visible || !clusterConfig.enabled || !camera || markers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const cameraScale = getCameraScale(camera);
|
||||
const zoom = getCameraZoom(camera);
|
||||
if (zoom > clusterConfig.disableAboveZoom) {
|
||||
lastStableClusterBandKey = "";
|
||||
return {
|
||||
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||
entries: [],
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
const clusterZoomBand = getClusterZoomBand(zoom);
|
||||
const sphericalBand = getSphericalClusterBand(
|
||||
clusterConfig,
|
||||
zoom,
|
||||
lastStableClusterBandKey,
|
||||
);
|
||||
if (clusterConfig.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL && sphericalBand?.key) {
|
||||
lastStableClusterBandKey = sphericalBand.key;
|
||||
}
|
||||
const stableSpherical = clusterConfig.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL;
|
||||
const visualPointSize = shouldUseCompactDots(camera)
|
||||
? COMPACT_DOT_POINT_SIZE
|
||||
: pointSize;
|
||||
@@ -925,27 +1189,29 @@ export function createInteractableLayer(options = {}) {
|
||||
clusterCameraLocalScratch.normalize();
|
||||
group.updateMatrixWorld?.(true);
|
||||
|
||||
return markers
|
||||
const entries = markers
|
||||
.map((marker, index) => {
|
||||
const basePosition = marker.userData?.icon_base_position || marker.position;
|
||||
if (!(basePosition instanceof THREE.Vector3)) return null;
|
||||
const direction = clusterDirectionScratch.copy(basePosition).normalize().clone();
|
||||
if (clusterCameraLocalScratch.dot(clusterDirectionScratch) <= 0) return null;
|
||||
if (!stableSpherical) {
|
||||
if (clusterCameraLocalScratch.dot(clusterDirectionScratch) <= 0) return null;
|
||||
|
||||
clusterWorldScratch.copy(basePosition);
|
||||
group.localToWorld(clusterWorldScratch);
|
||||
clusterProjectedScratch.copy(clusterWorldScratch).project(camera);
|
||||
if (clusterProjectedScratch.z < -1 || clusterProjectedScratch.z > 1) {
|
||||
return null;
|
||||
clusterWorldScratch.copy(basePosition);
|
||||
group.localToWorld(clusterWorldScratch);
|
||||
clusterProjectedScratch.copy(clusterWorldScratch).project(camera);
|
||||
if (clusterProjectedScratch.z < -1 || clusterProjectedScratch.z > 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
|
||||
const sizePx = visualPointSize * pointSizeMultiplier;
|
||||
const screenX =
|
||||
(clusterProjectedScratch.x * 0.5 + 0.5) * width +
|
||||
(stableSpherical ? 0 : (clusterProjectedScratch.x * 0.5 + 0.5) * width) +
|
||||
(0.5 - iconAnchor.x) * sizePx;
|
||||
const screenY =
|
||||
(-clusterProjectedScratch.y * 0.5 + 0.5) * height +
|
||||
(stableSpherical ? 0 : (-clusterProjectedScratch.y * 0.5 + 0.5) * height) +
|
||||
(0.5 - iconAnchor.y) * sizePx;
|
||||
const nominalRadiusPx = Math.max(8, visualPointSize * 0.5);
|
||||
const angularRadius = getAngularRadiusFromPixels(
|
||||
@@ -976,10 +1242,41 @@ export function createInteractableLayer(options = {}) {
|
||||
),
|
||||
maxMarkersPerDot: clusterConfig.maxMarkersPerDot,
|
||||
clusterZoomBand: clusterZoomBand.referenceZoom,
|
||||
clusterBandKey: sphericalBand?.key || String(clusterZoomBand.referenceZoom),
|
||||
radiusWorld: basePosition.length(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
||||
|
||||
if (clusterConfig.strategy !== CLUSTER_STRATEGY_STABLE_SPHERICAL) {
|
||||
return {
|
||||
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||
entries,
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
|
||||
const stableKey = [
|
||||
id,
|
||||
sphericalBand?.key || "unknown",
|
||||
sphericalBand?.distance ?? 0,
|
||||
clusterTopologyRevision,
|
||||
entries.length,
|
||||
].join(":");
|
||||
if (stableKey !== lastStableClusterKey) {
|
||||
stableClusterRecords = computeSphericalClusterRecords(
|
||||
entries,
|
||||
clusterConfig,
|
||||
sphericalBand,
|
||||
);
|
||||
lastStableClusterKey = stableKey;
|
||||
}
|
||||
return {
|
||||
strategy: CLUSTER_STRATEGY_STABLE_SPHERICAL,
|
||||
entries: [],
|
||||
records: stableClusterRecords,
|
||||
};
|
||||
}
|
||||
|
||||
function addOwnedCluster(record) {
|
||||
@@ -1010,6 +1307,17 @@ export function createInteractableLayer(options = {}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasActiveRenderTransitions() {
|
||||
if (clusterConfig.transitionMs <= 0) return false;
|
||||
const now = getNowMs();
|
||||
return pointObjects
|
||||
.concat(clusterPointObjects)
|
||||
.some((points) => {
|
||||
const startedAt = points.userData?.transitionStartedAt;
|
||||
return startedAt && now - startedAt < clusterConfig.transitionMs;
|
||||
});
|
||||
}
|
||||
|
||||
function disposePointsGroup() {
|
||||
if (pointsGroup?.parent) {
|
||||
pointsGroup.parent.remove(pointsGroup);
|
||||
@@ -1131,6 +1439,7 @@ export function createInteractableLayer(options = {}) {
|
||||
bucketKey,
|
||||
markers: bucketMarkers,
|
||||
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
|
||||
transitionStartedAt: getNowMs(),
|
||||
};
|
||||
pointObjects.push(points);
|
||||
pointsGroup.add(points);
|
||||
@@ -1184,6 +1493,7 @@ export function createInteractableLayer(options = {}) {
|
||||
clusterId: record.clusterId,
|
||||
markers: record.markers,
|
||||
clusterPointSize: record.pointSize,
|
||||
transitionStartedAt: record.transitionStartedAt || getNowMs(),
|
||||
};
|
||||
clusterPointObjects.push(points);
|
||||
clusterGroup.add(points);
|
||||
@@ -1290,6 +1600,7 @@ export function createInteractableLayer(options = {}) {
|
||||
function refreshVisuals() {
|
||||
invalidateScreenAvoidance();
|
||||
invalidateVisualState();
|
||||
invalidateClusterTopology();
|
||||
if (!pointsGroup && !clusterGroup) return;
|
||||
rebuildPointLayers();
|
||||
group.visible = visible;
|
||||
@@ -1298,6 +1609,7 @@ export function createInteractableLayer(options = {}) {
|
||||
function setData(items = []) {
|
||||
invalidateScreenAvoidance();
|
||||
invalidateVisualState();
|
||||
invalidateClusterTopology();
|
||||
unregisterLayerAvoidance(id);
|
||||
markers.length = 0;
|
||||
clearRenderObjects();
|
||||
@@ -1407,6 +1719,7 @@ export function createInteractableLayer(options = {}) {
|
||||
function clearData(parent) {
|
||||
invalidateScreenAvoidance();
|
||||
invalidateVisualState();
|
||||
invalidateClusterTopology();
|
||||
unregisterLayerAvoidance(id);
|
||||
markers.length = 0;
|
||||
clearRenderObjects();
|
||||
@@ -1427,6 +1740,7 @@ export function createInteractableLayer(options = {}) {
|
||||
visible = Boolean(nextVisible);
|
||||
invalidateScreenAvoidance();
|
||||
invalidateVisualState();
|
||||
invalidateClusterTopology();
|
||||
group.visible = visible;
|
||||
if (pointsGroup) {
|
||||
pointsGroup.visible = visible;
|
||||
@@ -1478,7 +1792,8 @@ export function createInteractableLayer(options = {}) {
|
||||
if (
|
||||
nextStateKey === lastVisualStateKey &&
|
||||
!(pulse.enabled && hasFocus) &&
|
||||
!dynamicVisuals
|
||||
!dynamicVisuals &&
|
||||
!hasActiveRenderTransitions()
|
||||
) return;
|
||||
lastVisualStateKey = nextStateKey;
|
||||
|
||||
@@ -1493,21 +1808,35 @@ export function createInteractableLayer(options = {}) {
|
||||
}
|
||||
updatePointColors(points, compactDotMode);
|
||||
points.visible = visible;
|
||||
const transitionProgress = getTransitionProgress(
|
||||
points.userData?.transitionStartedAt,
|
||||
clusterConfig.transitionMs,
|
||||
);
|
||||
const transitionScale = 0.82 + transitionProgress * 0.18;
|
||||
points.material.opacity =
|
||||
getPointOpacity?.(sampleMarker) ??
|
||||
(hasFocus ? dimmedOpacity : baseOpacity);
|
||||
(hasFocus ? dimmedOpacity : baseOpacity) * transitionProgress;
|
||||
points.material.size =
|
||||
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
||||
getPointSizeMultiplier(sampleMarker) *
|
||||
cameraScale *
|
||||
(hasFocus ? dimmedScale : 1);
|
||||
(hasFocus ? dimmedScale : 1) *
|
||||
transitionScale;
|
||||
});
|
||||
clusterPointObjects.forEach((points) => {
|
||||
points.visible = visible;
|
||||
points.material.opacity = hasFocus ? dimmedOpacity : baseOpacity;
|
||||
const transitionProgress = getTransitionProgress(
|
||||
points.userData?.transitionStartedAt,
|
||||
clusterConfig.transitionMs,
|
||||
);
|
||||
const transitionScale = 0.68 + transitionProgress * 0.32;
|
||||
const pulseScale = 1 + Math.sin(Date.now() / 260) * 0.018;
|
||||
points.material.opacity = (hasFocus ? dimmedOpacity : baseOpacity) * transitionProgress;
|
||||
points.material.size =
|
||||
(points.userData?.clusterPointSize || COMPACT_DOT_POINT_SIZE) *
|
||||
(hasFocus ? dimmedScale : 1);
|
||||
(hasFocus ? dimmedScale : 1) *
|
||||
transitionScale *
|
||||
pulseScale;
|
||||
});
|
||||
|
||||
const hoverMarker = markers.find(
|
||||
|
||||
@@ -257,6 +257,11 @@ const vesselIconLayer = createInteractableLayer({
|
||||
vessel_kind: item.type,
|
||||
baseScale: VESSEL_CONFIG.marker.baseScale,
|
||||
}),
|
||||
cluster: {
|
||||
strategy: "dynamic-screen",
|
||||
enabled: true,
|
||||
maxMarkersPerDot: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const DEFAULT_VESSEL_VIEWPORT = {
|
||||
|
||||
@@ -122,6 +122,14 @@ type CollectionQueueItem = {
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
type DatasourceMetricBaseline = {
|
||||
taskId: string
|
||||
sourceId: string
|
||||
source: string
|
||||
taskType: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
||||
product: '',
|
||||
module: '',
|
||||
@@ -130,9 +138,12 @@ const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
||||
dataStatus: '',
|
||||
}
|
||||
|
||||
const DATASOURCE_TERMINAL_STATUSES = new Set(['success', 'completed', 'failed', 'cancelled'])
|
||||
const DATASOURCE_FILTER_STORAGE_KEY = 'planet.admin.datasource.filters'
|
||||
const DATASOURCE_FILTER_QUERY_KEYS = ['product', 'module', 'is_active', 'run_status', 'data_status']
|
||||
const DATASOURCE_TERMINAL_STATUSES = new Set(['success', 'completed', 'failed', 'cancelled', 'canceled', 'stopped'])
|
||||
const COLLECTION_QUEUE_ACTIVE_STATUSES = new Set<CollectionQueueStatus>(['queued', 'running', 'cancelling'])
|
||||
const TASK_ACTIVE_STATUSES = new Set(['queued', 'pending', 'running', 'cancelling'])
|
||||
const TASK_INACTIVE_STATUSES = new Set(['success', 'completed', 'failed', 'error', 'cancelled', 'canceled', 'stopped', 'idle'])
|
||||
|
||||
interface PlaygroundApiMessage {
|
||||
id: string
|
||||
@@ -213,15 +224,33 @@ function pick(record: AnyRecord, keys: string[], fallback = '-') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatCountZh(value: number) {
|
||||
if (!Number.isFinite(value)) return '-'
|
||||
const count = Math.max(0, Math.round(value))
|
||||
if (count >= 100000000) return `${(count / 100000000).toFixed(count >= 1000000000 ? 1 : 2).replace(/\.0+$/, '')} 亿条`
|
||||
if (count >= 10000) return `${(count / 10000).toFixed(count >= 100000 ? 1 : 2).replace(/\.0+$/, '')} 万条`
|
||||
return `${count.toLocaleString('zh-CN')} 条`
|
||||
}
|
||||
|
||||
function datasourceRecordCount(record: AnyRecord) {
|
||||
const candidates = [record.__metric_count, record.collected_records, record.record_count, record.records, record.count, record.total]
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function statusTone(value: string): Tone {
|
||||
const lower = value.toLowerCase()
|
||||
if (/(默认|default)/.test(lower)) return 'info'
|
||||
if (/(配置错误|校验失败|连接失败|failed|error|critical|down|danger|unresolved)/.test(lower)) return 'danger'
|
||||
if (/(失败|配置错误|校验失败|连接失败|failed|error|critical|down|danger|unresolved)/.test(lower)) return 'danger'
|
||||
if (/(取消|停止|已停止|cancelled|canceled|stopped)/.test(lower)) return 'neutral'
|
||||
if (/(未配置|未启用|停用|禁用|disabled|false|missing|empty|none|可选|optional|-)/.test(lower)) return 'neutral'
|
||||
if (/(已配置|configured|running|active|enabled|success|ok|healthy|connected|resolved|ack|true|valid|已读取|已上传|已提交|可用|启用)/.test(lower)) return 'success'
|
||||
if (/(运行中|采集中|同步中|加载中|排队中|pending|queued|loading|sync|collect|live|stream|删除中|清缓存中|刷新中|任务中|cancelling)/.test(lower)) return 'running'
|
||||
if (/(成功|完成|已完成|已配置|configured|running|active|enabled|success|completed|done|ok|healthy|connected|resolved|ack|true|valid|已读取|已上传|已提交|可用|启用)/.test(lower)) return 'success'
|
||||
if (/(pending|queued|warning|degraded|partial|waiting|unknown)/.test(lower)) return 'warning'
|
||||
if (/(ai|brief|model|provider|prompt)/.test(lower)) return 'ai'
|
||||
if (/(loading|sync|collect|live|stream|删除中|清缓存中|刷新中|任务中|cancelling)/.test(lower)) return 'running'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
@@ -250,11 +279,15 @@ function recordMetric(record: AnyRecord) {
|
||||
}
|
||||
|
||||
function datasourceMetric(record: AnyRecord) {
|
||||
if (typeof record.collected_records === 'number') return `${record.collected_records} records`
|
||||
if (typeof record.__metric === 'string' && record.__metric) return record.__metric
|
||||
if (typeof record.__metric_count === 'number') return formatCountZh(record.__metric_count)
|
||||
if (typeof record.collected_records === 'number') return formatCountZh(record.collected_records)
|
||||
if (typeof record.records_processed === 'number' && typeof record.total_records === 'number') {
|
||||
return `${record.records_processed}/${record.total_records} records`
|
||||
return `${formatCountZh(record.records_processed)} / ${formatCountZh(record.total_records)}`
|
||||
}
|
||||
if (typeof record.records_processed === 'number') return `${record.records_processed} records`
|
||||
if (typeof record.records_processed === 'number') return formatCountZh(record.records_processed)
|
||||
const count = datasourceRecordCount(record)
|
||||
if (count !== null) return formatCountZh(count)
|
||||
return pick(record, ['record_count', 'count', 'total', 'value', 'records'], '-')
|
||||
}
|
||||
|
||||
@@ -263,11 +296,20 @@ function activeDatasourceTaskType(record: AnyRecord) {
|
||||
}
|
||||
|
||||
function activeDatasourceTaskStatus(record: AnyRecord) {
|
||||
return text(record.task_status || record.status || record.phase, '').toLowerCase()
|
||||
const candidates = [record.task_status, record.phase, record.status]
|
||||
.map((value) => text(value, '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
return candidates.find((status) => TASK_INACTIVE_STATUSES.has(status))
|
||||
|| candidates.find((status) => TASK_ACTIVE_STATUSES.has(status))
|
||||
|| text(record.last_status, '').toLowerCase()
|
||||
|| candidates[0]
|
||||
|| ''
|
||||
}
|
||||
|
||||
function hasActiveDatasourceTask(record: AnyRecord) {
|
||||
return record.is_task_active === true || TASK_ACTIVE_STATUSES.has(activeDatasourceTaskStatus(record))
|
||||
const status = activeDatasourceTaskStatus(record)
|
||||
if (TASK_INACTIVE_STATUSES.has(status)) return false
|
||||
return record.is_task_active === true || TASK_ACTIVE_STATUSES.has(status)
|
||||
}
|
||||
|
||||
function isCollectTaskActive(record: AnyRecord) {
|
||||
@@ -276,24 +318,43 @@ function isCollectTaskActive(record: AnyRecord) {
|
||||
|
||||
function datasourceStatus(record: AnyRecord) {
|
||||
if (isCollectTaskActive(record)) return 'running'
|
||||
const status = [record.task_status, record.phase, record.status, record.last_status]
|
||||
.map((value) => text(value, '').toLowerCase())
|
||||
.find((value) => value && TASK_INACTIVE_STATUSES.has(value))
|
||||
if (status) return status
|
||||
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
|
||||
}
|
||||
|
||||
function taskTerminalDisplayStatus(taskType: string, status: string) {
|
||||
const type = text(taskType, 'collect')
|
||||
const lower = text(status, '').toLowerCase()
|
||||
const noun = taskTypeLabel(type)
|
||||
if (lower === 'success' || lower === 'completed') return `${noun}成功`
|
||||
if (lower === 'failed' || lower === 'error') return `${noun}失败`
|
||||
if (lower === 'cancelled' || lower === 'canceled') return `${noun}已取消`
|
||||
if (lower === 'stopped') return `${noun}已停止`
|
||||
return ''
|
||||
}
|
||||
|
||||
function datasourceDisplayStatus(record: AnyRecord) {
|
||||
if (!hasActiveDatasourceTask(record)) return datasourceStatus(record)
|
||||
const taskType = activeDatasourceTaskType(record)
|
||||
const status = activeDatasourceTaskStatus(record) || text(datasourceStatus(record), '').toLowerCase()
|
||||
if (status === 'queued' || status === 'pending') return `${taskTypeLabel(taskType)}排队中`
|
||||
if (status === 'running' || status === 'collecting') return `${taskTypeLabel(taskType)}中`
|
||||
if (status === 'cancelling') return `停止${taskTypeLabel(taskType)}中`
|
||||
if (!hasActiveDatasourceTask(record)) return datasourceStatus(record)
|
||||
if (taskType === 'clear_data') return '删除中'
|
||||
if (taskType === 'clear_cache') return '清缓存中'
|
||||
if (taskType === 'earth_refresh') return '刷新中'
|
||||
if (taskType === 'collect') return activeDatasourceTaskStatus(record) === 'queued' ? '排队中' : '运行中'
|
||||
return '任务中'
|
||||
if (taskType === 'collect') return '采集中'
|
||||
return `${taskTypeLabel(taskType)}中`
|
||||
}
|
||||
|
||||
function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus {
|
||||
const status = text(statusValue, '').toLowerCase()
|
||||
if (status === 'success' || status === 'completed') return 'success'
|
||||
if (status === 'failed' || status === 'error') return 'failed'
|
||||
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
|
||||
if (status === 'cancelled' || status === 'canceled' || status === 'stopped') return 'cancelled'
|
||||
if (status === 'skipped') return 'skipped'
|
||||
if (status === 'queued' || status === 'pending') return 'queued'
|
||||
if (status === 'cancelling') return 'cancelling'
|
||||
@@ -302,12 +363,17 @@ function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): Collect
|
||||
}
|
||||
|
||||
function queueItemKey(item: AnyRecord) {
|
||||
const taskType = text(item.task_type || item.taskType, 'collect')
|
||||
const taskId = text(item.task_id || item.taskId, '')
|
||||
if (taskId) return `task:${taskId}`
|
||||
if (taskId) return `task:${taskType}:${taskId}`
|
||||
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
|
||||
if (sourceId) return `source:${sourceId}`
|
||||
if (sourceId) return `source:${taskType}:${sourceId}`
|
||||
const source = text(item.collector_name || item.source, '')
|
||||
return source ? `source-name:${source}` : `queue:${Date.now()}`
|
||||
return source ? `source-name:${taskType}:${source}` : `queue:${taskType}:${Date.now()}`
|
||||
}
|
||||
|
||||
function isSameQueueTaskType(left?: string, right?: string) {
|
||||
return text(left, 'collect') === text(right, 'collect')
|
||||
}
|
||||
|
||||
function isActiveQueueStatus(status: CollectionQueueStatus) {
|
||||
@@ -330,21 +396,69 @@ function taskTypeLabel(taskType?: string) {
|
||||
return labels[text(taskType, 'collect')] || '任务'
|
||||
}
|
||||
|
||||
function queueStatusLabel(status: CollectionQueueStatus, taskType?: string) {
|
||||
function queueStatusLabel(status: CollectionQueueStatus | string, taskType?: string) {
|
||||
const noun = taskTypeLabel(taskType)
|
||||
if (status === 'queued') return `${noun}排队中`
|
||||
if (status === 'running') return `${noun}中`
|
||||
if (status === 'cancelling') return `停止${noun}中`
|
||||
const labels: Record<CollectionQueueStatus, string> = {
|
||||
const labels: Record<string, string> = {
|
||||
queued: `${noun}排队中`,
|
||||
running: `${noun}中`,
|
||||
cancelling: `停止${noun}中`,
|
||||
success: '已完成',
|
||||
failed: '失败',
|
||||
success: `${noun}成功`,
|
||||
completed: `${noun}成功`,
|
||||
failed: `${noun}失败`,
|
||||
error: `${noun}失败`,
|
||||
skipped: '跳过',
|
||||
cancelled: '已取消',
|
||||
cancelled: `${noun}已取消`,
|
||||
canceled: `${noun}已取消`,
|
||||
stopped: `${noun}已停止`,
|
||||
idle: '空闲',
|
||||
}
|
||||
return labels[status]
|
||||
return labels[status] || semanticLabel(status)
|
||||
}
|
||||
|
||||
function queuePrimaryMessage(item: CollectionQueueItem) {
|
||||
const type = text(item.taskType, 'collect')
|
||||
if (item.status === 'queued') return queueStatusLabel('queued', type)
|
||||
if (item.status === 'running') {
|
||||
if (type === 'clear_data') return '正在删除数据'
|
||||
if (type === 'clear_cache') return '正在清理缓存'
|
||||
if (type === 'earth_refresh') return '正在刷新图层'
|
||||
return '正在采集'
|
||||
}
|
||||
if (item.status === 'cancelling') {
|
||||
if (type === 'clear_data') return '正在取消删除'
|
||||
if (type === 'collect') return '正在停止采集'
|
||||
return '正在取消任务'
|
||||
}
|
||||
if (item.status === 'success') {
|
||||
if (type === 'clear_data') return '删除完成'
|
||||
if (type === 'clear_cache') return '清缓存完成'
|
||||
if (type === 'earth_refresh') return '刷新完成'
|
||||
return '采集完成'
|
||||
}
|
||||
if (item.status === 'failed') {
|
||||
if (type === 'clear_data') return '删除失败'
|
||||
if (type === 'clear_cache') return '清缓存失败'
|
||||
if (type === 'earth_refresh') return '刷新失败'
|
||||
return '采集失败'
|
||||
}
|
||||
if (item.status === 'cancelled') {
|
||||
if (type === 'clear_data') return '删除已取消'
|
||||
if (type === 'collect') return '采集已取消'
|
||||
return '任务已取消'
|
||||
}
|
||||
if (item.status === 'skipped') return item.reason ? queueReasonLabel(item.reason) : '已跳过'
|
||||
return queueStatusLabel(item.status, type)
|
||||
}
|
||||
|
||||
function snapshotStatus(record: AnyRecord) {
|
||||
const status = text(record.status, '').toLowerCase()
|
||||
if (status === 'running' && text(record.completed_at || record.completedAt, '')) return 'success'
|
||||
if (status) return status
|
||||
if (record.is_current === true) return '当前'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function queueReasonLabel(reason = '') {
|
||||
@@ -365,15 +479,27 @@ function formatDuration(startedAt: number, endedAt = Date.now()) {
|
||||
}
|
||||
|
||||
function datasourceTableRow(row: AnyRecord) {
|
||||
const displayStatus = datasourceDisplayStatus(row)
|
||||
const taskStatus = text(row.task_status || row.phase || row.status || row.last_status, '')
|
||||
const terminalStatus = taskTerminalDisplayStatus(activeDatasourceTaskType(row), taskStatus)
|
||||
return {
|
||||
...row,
|
||||
__module: pick(row, ['module', 'source'], '数据源'),
|
||||
__status: datasourceDisplayStatus(row),
|
||||
__status: terminalStatus || displayStatus,
|
||||
__metric: datasourceMetric(row),
|
||||
__time: pick(row, ['last_run_at', 'last_run'], '-'),
|
||||
}
|
||||
}
|
||||
|
||||
function recordDisplayStatus(record: AnyRecord) {
|
||||
const endpointKey = text(record.__endpointKey, '')
|
||||
if (endpointKey === 'builtin' || record.is_task_active !== undefined || record.task_type || record.task_status) {
|
||||
const taskStatus = text(record.task_status || record.phase || record.status || record.last_status, '')
|
||||
return taskTerminalDisplayStatus(activeDatasourceTaskType(record), taskStatus) || datasourceDisplayStatus(record)
|
||||
}
|
||||
return semanticLabel(recordStatus(record))
|
||||
}
|
||||
|
||||
function makeAction(label: string, icon: ReactNode, to: string) {
|
||||
return { label, icon, to }
|
||||
}
|
||||
@@ -566,7 +692,10 @@ function defaultColumns(onSelect: (record: TableRecord) => void): Array<ColumnDe
|
||||
id: 'status',
|
||||
header: '状态',
|
||||
size: 130,
|
||||
cell: ({ row }) => <StatusText tone={statusTone(recordStatus(row.original))}>{recordStatus(row.original)}</StatusText>,
|
||||
cell: ({ row }) => {
|
||||
const status = recordDisplayStatus(row.original)
|
||||
return <StatusText tone={statusTone(status)}>{status}</StatusText>
|
||||
},
|
||||
},
|
||||
{ id: 'metric', header: '指标', size: 220, cell: ({ row }) => <span className="an-muted-text">{semanticLabel(recordMetric(row.original))}</span> },
|
||||
{ id: 'updated', header: '更新时间', size: 180, cell: ({ row }) => row.original.__time },
|
||||
@@ -824,7 +953,9 @@ function semanticLabel(value: unknown) {
|
||||
const lower = raw.toLowerCase()
|
||||
const labels: Record<string, string> = {
|
||||
success: '成功',
|
||||
completed: '完成',
|
||||
failed: '失败',
|
||||
error: '失败',
|
||||
running: '运行中',
|
||||
pending: '等待中',
|
||||
queued: '排队中',
|
||||
@@ -997,6 +1128,59 @@ function datasourceFiltersFromSearch(search: string): DatasourceFilters {
|
||||
}
|
||||
}
|
||||
|
||||
function hasDatasourceFilterSearch(search: string) {
|
||||
const params = new URLSearchParams(search)
|
||||
return DATASOURCE_FILTER_QUERY_KEYS.some((key) => params.has(key))
|
||||
}
|
||||
|
||||
function normalizeDatasourceFilters(value: Partial<DatasourceFilters> | null | undefined): DatasourceFilters {
|
||||
return {
|
||||
product: text(value?.product, DEFAULT_DATASOURCE_FILTERS.product),
|
||||
module: text(value?.module, DEFAULT_DATASOURCE_FILTERS.module),
|
||||
isActive: ['true', 'false', ''].includes(text(value?.isActive, '')) ? text(value?.isActive, DEFAULT_DATASOURCE_FILTERS.isActive) : DEFAULT_DATASOURCE_FILTERS.isActive,
|
||||
runStatus: text(value?.runStatus, DEFAULT_DATASOURCE_FILTERS.runStatus),
|
||||
dataStatus: text(value?.dataStatus, DEFAULT_DATASOURCE_FILTERS.dataStatus),
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredDatasourceFilters(): DatasourceFilters {
|
||||
if (typeof window === 'undefined') return DEFAULT_DATASOURCE_FILTERS
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DATASOURCE_FILTER_STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_DATASOURCE_FILTERS
|
||||
return normalizeDatasourceFilters(JSON.parse(raw) as Partial<DatasourceFilters>)
|
||||
} catch {
|
||||
return DEFAULT_DATASOURCE_FILTERS
|
||||
}
|
||||
}
|
||||
|
||||
function storeDatasourceFilters(filters: DatasourceFilters) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(DATASOURCE_FILTER_STORAGE_KEY, JSON.stringify(filters))
|
||||
}
|
||||
|
||||
function initialDatasourceFilters(search: string): DatasourceFilters {
|
||||
return hasDatasourceFilterSearch(search) ? datasourceFiltersFromSearch(search) : loadStoredDatasourceFilters()
|
||||
}
|
||||
|
||||
function datasourceFiltersEqual(left: DatasourceFilters, right: DatasourceFilters) {
|
||||
return left.product === right.product &&
|
||||
left.module === right.module &&
|
||||
left.isActive === right.isActive &&
|
||||
left.runStatus === right.runStatus &&
|
||||
left.dataStatus === right.dataStatus
|
||||
}
|
||||
|
||||
function datasourceFiltersSearch(filters: DatasourceFilters) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('product', filters.product)
|
||||
params.set('module', filters.module)
|
||||
params.set('is_active', filters.isActive)
|
||||
params.set('run_status', filters.runStatus)
|
||||
params.set('data_status', filters.dataStatus)
|
||||
return `?${params.toString()}`
|
||||
}
|
||||
|
||||
function datasourceFiltersToParams(filters: DatasourceFilters) {
|
||||
const params: AnyRecord = { include_endpoint: false }
|
||||
if (filters.product) params.product = filters.product
|
||||
@@ -1029,8 +1213,8 @@ function snapshotRows(payload: unknown) {
|
||||
...row,
|
||||
__title: pick(row, ['source', 'datasource_name', 'id'], '采集快照'),
|
||||
__module: '采集快照',
|
||||
__status: pick(row, ['status', 'is_current'], '-'),
|
||||
__metric: typeof row.record_count === 'number' ? `${row.record_count} records` : pick(row, ['record_count'], '-'),
|
||||
__status: snapshotStatus(row),
|
||||
__metric: typeof row.record_count === 'number' ? formatCountZh(row.record_count) : pick(row, ['record_count'], '-'),
|
||||
__time: pick(row, ['completed_at', 'started_at', 'created_at'], '-'),
|
||||
}))
|
||||
const grouped = new Map<string, AnyRecord[]>()
|
||||
@@ -1049,7 +1233,7 @@ function snapshotRows(payload: unknown) {
|
||||
__rowId: `snapshot-source-${source}`,
|
||||
__title: title,
|
||||
__module: '采集快照',
|
||||
__status: pick(current, ['status', 'is_current'], '-'),
|
||||
__status: snapshotStatus(current),
|
||||
__metric: `${ordered.length} 个快照`,
|
||||
__time: pick(current, ['completed_at', 'started_at', 'created_at'], '-'),
|
||||
__snapshots: ordered,
|
||||
@@ -1096,8 +1280,8 @@ function formatSnapshotTime(record: AnyRecord) {
|
||||
|
||||
function snapshotOptionLabel(record: AnyRecord) {
|
||||
const current = record.is_current === true ? '当前 · ' : ''
|
||||
const status = semanticLabel(recordStatus(record))
|
||||
const count = typeof record.record_count === 'number' ? `${record.record_count} records` : pick(record, ['record_count'], '0 records')
|
||||
const status = semanticLabel(snapshotStatus(record))
|
||||
const count = typeof record.record_count === 'number' ? formatCountZh(record.record_count) : pick(record, ['record_count'], '0 条')
|
||||
return `${current}${formatSnapshotTime(record)} · ${status} · ${count}`
|
||||
}
|
||||
|
||||
@@ -2098,7 +2282,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const navigate = useNavigate()
|
||||
const [states, setStates] = useState<SectionState[]>([])
|
||||
const [activeSectionKey, setActiveSectionKey] = useState(config.sections[0]?.key || '')
|
||||
const [datasourceFilters, setDatasourceFilters] = useState<DatasourceFilters>(() => datasourceFiltersFromSearch(location.search))
|
||||
const [datasourceFilters, setDatasourceFilters] = useState<DatasourceFilters>(() => initialDatasourceFilters(location.search))
|
||||
const datasourceFiltersRef = useRef(datasourceFilters)
|
||||
const [activeGroupKey, setActiveGroupKey] = useState('')
|
||||
const [hierarchyDraft, setHierarchyDraft] = useState('')
|
||||
@@ -2114,6 +2298,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const [datasourceSelectedRowIds, setDatasourceSelectedRowIds] = useState<Set<string>>(() => new Set())
|
||||
const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({})
|
||||
const completedDatasourceTasksRef = useRef<Set<string>>(new Set())
|
||||
const datasourceMetricBaselinesRef = useRef<Record<string, DatasourceMetricBaseline>>({})
|
||||
const datasourcePollTimersRef = useRef<Record<string, number>>({})
|
||||
const [selected, setSelected] = useState<TableRecord | null>(null)
|
||||
const [selectedHistory, setSelectedHistory] = useState<TableRecord[]>([])
|
||||
@@ -2205,14 +2390,11 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (config !== configs.datasources) return
|
||||
const next = datasourceFiltersFromSearch(location.search)
|
||||
const next = initialDatasourceFilters(location.search)
|
||||
const current = datasourceFiltersRef.current
|
||||
const unchanged = current.product === next.product &&
|
||||
current.module === next.module &&
|
||||
current.isActive === next.isActive &&
|
||||
current.runStatus === next.runStatus &&
|
||||
current.dataStatus === next.dataStatus
|
||||
const unchanged = datasourceFiltersEqual(current, next)
|
||||
if (!unchanged) setDatasourceSelectedRowIds(new Set())
|
||||
storeDatasourceFilters(next)
|
||||
setDatasourceFilters((filters) => unchanged ? filters : next)
|
||||
}, [config, location.search])
|
||||
|
||||
@@ -2220,25 +2402,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const next = { ...datasourceFiltersRef.current, [key]: value }
|
||||
setDatasourceSelectedRowIds(new Set())
|
||||
setDatasourceFilters(next)
|
||||
const params = new URLSearchParams(location.search)
|
||||
const queryKeyByFilter: Record<keyof DatasourceFilters, string> = {
|
||||
product: 'product',
|
||||
module: 'module',
|
||||
isActive: 'is_active',
|
||||
runStatus: 'run_status',
|
||||
dataStatus: 'data_status',
|
||||
}
|
||||
;(Object.keys(queryKeyByFilter) as Array<keyof DatasourceFilters>).forEach((filterKey) => {
|
||||
const queryKey = queryKeyByFilter[filterKey]
|
||||
const defaultValue = DEFAULT_DATASOURCE_FILTERS[filterKey]
|
||||
const nextValue = next[filterKey]
|
||||
if (!nextValue || nextValue === defaultValue) {
|
||||
params.delete(queryKey)
|
||||
} else {
|
||||
params.set(queryKey, nextValue)
|
||||
}
|
||||
})
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true })
|
||||
storeDatasourceFilters(next)
|
||||
navigate({ pathname: location.pathname, search: datasourceFiltersSearch(next) }, { replace: true })
|
||||
}
|
||||
|
||||
const sectionRequestParams = useCallback((section: SectionConfig, baseParams?: AnyRecord) => {
|
||||
@@ -2350,8 +2515,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const index = current.findIndex((existing) => (
|
||||
existing.key === item.key
|
||||
|| (item.taskId && existing.taskId === item.taskId)
|
||||
|| (isActiveQueueStatus(existing.status) && item.sourceId && existing.sourceId === item.sourceId)
|
||||
|| (isActiveQueueStatus(existing.status) && item.source && existing.source === item.source)
|
||||
|| (isActiveQueueStatus(existing.status) && isSameQueueTaskType(existing.taskType, item.taskType) && item.sourceId && existing.sourceId === item.sourceId)
|
||||
|| (isActiveQueueStatus(existing.status) && isSameQueueTaskType(existing.taskType, item.taskType) && item.source && existing.source === item.source)
|
||||
))
|
||||
if (index < 0) return [item, ...current]
|
||||
const next = [...current]
|
||||
@@ -2365,8 +2530,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const source = text(payload.collector_name || payload.source, '')
|
||||
const taskId = payload.task_id as number | string | null | undefined
|
||||
const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running)
|
||||
const payloadTaskType = text(payload.task_type, '')
|
||||
setCollectionQueue((current) => current.map((item) => {
|
||||
const matched = (taskId && item.taskId === taskId) || (sourceId && item.sourceId === sourceId) || (source && item.source === source)
|
||||
const taskTypeMatched = !payloadTaskType || isSameQueueTaskType(item.taskType, payloadTaskType)
|
||||
const matched = Boolean(taskId && item.taskId === taskId)
|
||||
|| (taskTypeMatched && Boolean(sourceId && item.sourceId === sourceId))
|
||||
|| (taskTypeMatched && Boolean(source && item.source === source))
|
||||
if (!matched) return item
|
||||
const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status)
|
||||
return {
|
||||
@@ -2472,19 +2641,58 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
error_message: payload.error_message,
|
||||
last_run_at: payload.completed_at || payload.started_at,
|
||||
}
|
||||
const metricKey = text(payload.task_id, '') || sourceId || source
|
||||
const recordsProcessed = typeof payload.records_processed === 'number'
|
||||
? payload.records_processed
|
||||
: Number.isFinite(Number(payload.records_processed))
|
||||
? Number(payload.records_processed)
|
||||
: null
|
||||
const applyLiveMetric = (row: AnyRecord, next: AnyRecord) => {
|
||||
if (!metricKey || recordsProcessed === null || recordsProcessed < 0) return next
|
||||
if (taskType === 'clear_data') {
|
||||
let baseline = datasourceMetricBaselinesRef.current[metricKey]
|
||||
if (!baseline) {
|
||||
baseline = {
|
||||
taskId: metricKey,
|
||||
sourceId,
|
||||
source,
|
||||
taskType,
|
||||
count: datasourceRecordCount(row) ?? 0,
|
||||
}
|
||||
datasourceMetricBaselinesRef.current[metricKey] = baseline
|
||||
}
|
||||
const nextCount = Math.max(0, baseline.count - recordsProcessed)
|
||||
return {
|
||||
...next,
|
||||
__metric_count: nextCount,
|
||||
__metric: formatCountZh(nextCount),
|
||||
collected_records: nextCount,
|
||||
has_collected_data: nextCount > 0,
|
||||
}
|
||||
}
|
||||
if (taskType === 'collect' && taskActive) {
|
||||
return {
|
||||
...next,
|
||||
__metric: `已处理 ${formatCountZh(recordsProcessed)}`,
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
setStates((currentStates) => currentStates.map((state) => {
|
||||
if (state.section.key !== 'builtin') return state
|
||||
return {
|
||||
...state,
|
||||
rows: state.rows.map((row) => {
|
||||
if (!isSameDatasourceRow(row, sourceId, source)) return row
|
||||
return normalizeDatasourceTableRecord({ ...row, ...rowPatch, id: row.id, source: row.source })
|
||||
const merged = applyLiveMetric(row, { ...row, ...rowPatch, id: row.id, source: row.source })
|
||||
return normalizeDatasourceTableRecord(merged)
|
||||
}),
|
||||
}
|
||||
}))
|
||||
setSelected((current) => {
|
||||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||||
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
|
||||
const merged = applyLiveMetric(current, { ...current, ...rowPatch, id: current.id, source: current.source })
|
||||
return normalizeDatasourceTableRecord(merged)
|
||||
})
|
||||
patchCollectionQueueFromTask(payload)
|
||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask])
|
||||
@@ -2525,6 +2733,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
toast({ title: `${titleName} 采集已取消` })
|
||||
}
|
||||
}
|
||||
delete datasourceMetricBaselinesRef.current[text(payload.task_id, '') || sourceId || source]
|
||||
delete pendingDatasourceTasksRef.current[pendingEntry?.[0] || pendingKey]
|
||||
}, [isSameDatasourceRow, toast, updateDatasourceRow])
|
||||
|
||||
@@ -2847,7 +3056,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
...item,
|
||||
status: 'cancelling',
|
||||
phase: 'cancelling',
|
||||
phaseMessage: '正在停止任务',
|
||||
phaseMessage: `正在停止${taskTypeLabel(item.taskType)}`,
|
||||
updatedAt: now,
|
||||
})
|
||||
})
|
||||
@@ -2931,6 +3140,61 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900)
|
||||
}
|
||||
|
||||
const restoreActiveCollectionQueueTasks = useCallback(async () => {
|
||||
if (config !== configs.datasources) return
|
||||
try {
|
||||
const response = await axios.get(apiPath('/tasks'), {
|
||||
params: {
|
||||
status: 'queued,running,cancelling',
|
||||
page_size: 200,
|
||||
},
|
||||
})
|
||||
dataArray(response.data).forEach((task) => {
|
||||
const sourceId = text(task.datasource_id || task.source_id, '')
|
||||
if (!sourceId) return
|
||||
const taskId = task.id as number | string | null | undefined
|
||||
const source = text(task.source || task.datasource_source, '')
|
||||
const taskType = text(task.task_type, 'collect')
|
||||
const record = {
|
||||
id: sourceId,
|
||||
source,
|
||||
collector_name: source,
|
||||
name: text(task.datasource_name || task.name || source, '数据源'),
|
||||
task_id: taskId,
|
||||
task_type: taskType,
|
||||
task_status: task.status,
|
||||
status: task.status,
|
||||
phase: task.phase,
|
||||
phase_message: task.phase_message,
|
||||
progress: task.progress,
|
||||
records_processed: task.records_processed,
|
||||
total_records: task.total_records,
|
||||
error_message: task.error_message,
|
||||
__endpointKey: 'builtin',
|
||||
__endpointLabel: '内置源',
|
||||
__rowId: `active-task-${sourceId}-${taskId || taskType}`,
|
||||
__title: text(task.datasource_name || task.name || source, '数据源'),
|
||||
__module: '数据源任务',
|
||||
__status: queueStatusLabel(queueStatusFromTask(task.status || task.phase, true), taskType),
|
||||
__metric: taskId ? `task ${taskId}` : '-',
|
||||
__time: text(task.started_at || task.completed_at, '-'),
|
||||
}
|
||||
upsertCollectionQueueItem(queueItemFromDatasourceRow(record, taskId, {
|
||||
taskType,
|
||||
status: queueStatusFromTask(task.status || task.phase, true),
|
||||
phase: text(task.phase, text(task.status, '')),
|
||||
phaseMessage: text(task.phase_message, ''),
|
||||
progress: typeof task.progress === 'number' ? task.progress : 0,
|
||||
recordsProcessed: typeof task.records_processed === 'number' ? task.records_processed : undefined,
|
||||
totalRecords: typeof task.total_records === 'number' ? task.total_records : undefined,
|
||||
}))
|
||||
scheduleDatasourceTaskPoll(record, taskId)
|
||||
})
|
||||
} catch {
|
||||
// Queue restore is best-effort; the table and explicit refresh still load normally.
|
||||
}
|
||||
}, [config, upsertCollectionQueueItem])
|
||||
|
||||
useEffect(() => {
|
||||
if (config !== configs.datasources) return
|
||||
const builtinState = states.find((state) => state.section.key === 'builtin')
|
||||
@@ -2943,7 +3207,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const taskId = row.task_id as number | string | null | undefined
|
||||
const taskType = text(row.task_type, 'collect')
|
||||
upsertCollectionQueueItem({
|
||||
key: queueItemKey({ id: sourceId, source, task_id: taskId }),
|
||||
key: queueItemKey({ id: sourceId, source, task_id: taskId, task_type: taskType }),
|
||||
sourceId,
|
||||
source,
|
||||
name: recordTitle(row),
|
||||
@@ -2960,6 +3224,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
})
|
||||
}, [config, states, upsertCollectionQueueItem])
|
||||
|
||||
useEffect(() => {
|
||||
void restoreActiveCollectionQueueTasks()
|
||||
}, [restoreActiveCollectionQueueTasks])
|
||||
|
||||
const clearDatasourceData = async (record: TableRecord) => {
|
||||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||
if (!id) return
|
||||
@@ -4263,7 +4531,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
key: row.__rowId,
|
||||
label: recordTitle(row),
|
||||
description: '采集快照',
|
||||
status: normalizeStatusLabel(row.is_current === true ? '当前' : recordStatus(row)),
|
||||
status: normalizeStatusLabel(snapshotStatus(row)),
|
||||
count: Array.isArray(row.__snapshots) ? row.__snapshots.length : 1,
|
||||
record: { ...cleanRecord(row), __sourceEndpoint: row.__endpointKey, __sourceLabel: row.__endpointLabel, __snapshots: row.__snapshots, __snapshotSourceKey: row.__snapshotSourceKey },
|
||||
}))
|
||||
@@ -4796,8 +5064,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
</select>
|
||||
</label>
|
||||
{activeSnapshot ? (
|
||||
<StatusText tone={statusTone(recordStatus(activeSnapshot))}>
|
||||
{semanticLabel(recordStatus(activeSnapshot))}
|
||||
<StatusText tone={statusTone(snapshotStatus(activeSnapshot))}>
|
||||
{semanticLabel(snapshotStatus(activeSnapshot))}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -5280,7 +5548,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<strong>采集任务</strong>
|
||||
<p>{queueItem?.phaseMessage || text(selected.last_status || selected.phase_message, '当前没有运行中的任务。')}</p>
|
||||
</div>
|
||||
<StatusText tone={statusTone(status)}>{queueStatusLabel(status as CollectionQueueStatus, queueItem?.taskType || text(selected.task_type, 'collect'))}</StatusText>
|
||||
<StatusText tone={statusTone(status)}>{queueStatusLabel(status, queueItem?.taskType || text(selected.task_type, 'collect'))}</StatusText>
|
||||
<dl>
|
||||
<dt>数据源</dt><dd>{source || sourceId || '-'}</dd>
|
||||
<dt>任务</dt><dd>{text(taskId, '-')}</dd>
|
||||
@@ -5351,13 +5619,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
overrides: Partial<CollectionQueueItem> = {},
|
||||
): CollectionQueueItem => {
|
||||
const sourceId = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||
const taskType = text(overrides.taskType || record.task_type, 'collect')
|
||||
return {
|
||||
key: queueItemKey({ id: sourceId, source: record.source, task_id: taskId }),
|
||||
key: queueItemKey({ id: sourceId, source: record.source, task_id: taskId, task_type: taskType }),
|
||||
sourceId,
|
||||
source: text(record.source || record.collector_name, ''),
|
||||
name: recordTitle(record),
|
||||
taskId,
|
||||
taskType: text(record.task_type, 'collect'),
|
||||
taskType,
|
||||
status: 'queued',
|
||||
phase: 'queued',
|
||||
phaseMessage: '任务已提交',
|
||||
@@ -5412,8 +5681,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
{group.items.map((item) => (
|
||||
<article key={item.key} className={`an-collection-queue__item is-${item.status}`}>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<p>{item.phaseMessage || item.error || queueStatusLabel(item.status, item.taskType)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}</p>
|
||||
<strong title={item.name}>{item.name}</strong>
|
||||
<p title={`${item.error || queuePrimaryMessage(item)}${item.taskId ? ` · task ${item.taskId}` : ''}${item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}`}>
|
||||
{item.error || queuePrimaryMessage(item)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span>{queueProgress(item)}%</span>
|
||||
<div className="an-collection-queue__item-actions">
|
||||
@@ -5584,7 +5855,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
) : null}
|
||||
<h3>{recordTitle(selected)}</h3>
|
||||
</div>
|
||||
<StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText>
|
||||
<StatusText tone={statusTone(recordDisplayStatus(selected))}>{recordDisplayStatus(selected)}</StatusText>
|
||||
</header>
|
||||
{renderRecordActions()}
|
||||
{renderDatasourceTaskSummary()}
|
||||
|
||||
@@ -444,8 +444,9 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.an-collection-queue__item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
@@ -455,6 +456,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.an-collection-queue__item-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
@@ -470,6 +477,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
|
||||
.an-collection-queue__item strong,
|
||||
.an-collection-queue__item p {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -522,6 +530,10 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.an-collection-queue__item {
|
||||
padding-right: 104px;
|
||||
}
|
||||
}
|
||||
|
||||
.an-task-summary {
|
||||
|
||||
@@ -111,9 +111,13 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
zh: { title: '智能星球可交互图标接入', group: 'Earth', order: 16 },
|
||||
en: { title: 'Intelligent Planet Interactable Usage', group: 'Earth', order: 16 },
|
||||
},
|
||||
'earth-interactable-clustering.md': {
|
||||
zh: { title: '智能星球可交互图标聚类策略', group: 'Earth', order: 17 },
|
||||
en: { title: 'Intelligent Planet Interactable Clustering', group: 'Earth', order: 17 },
|
||||
},
|
||||
'earth-toolbar-overlay-coordination.md': {
|
||||
zh: { title: '智能星球工具栏与浮层协同', group: 'Earth', order: 17 },
|
||||
en: { title: 'Intelligent Planet Toolbar and Overlay Coordination', group: 'Earth', order: 17 },
|
||||
zh: { title: '智能星球工具栏与浮层协同', group: 'Earth', order: 18 },
|
||||
en: { title: 'Intelligent Planet Toolbar and Overlay Coordination', group: 'Earth', order: 18 },
|
||||
},
|
||||
'frontend-admin-frontend-context.md': {
|
||||
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
|
||||
|
||||
Reference in New Issue
Block a user