mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #21932 from guerler/graph.000
This commit is contained in:
@@ -2855,6 +2855,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/histories/{history_id}/graph": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Returns a history-scoped structural graph. */
|
||||
get: operations["graph_api_histories__history_id__graph_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/histories/{history_id}/jobs_summary": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -13106,6 +13123,46 @@ export interface components {
|
||||
*/
|
||||
type: "genomebuild";
|
||||
};
|
||||
/** GraphEdge */
|
||||
GraphEdge: {
|
||||
/** Source */
|
||||
source: string;
|
||||
/** Target */
|
||||
target: string;
|
||||
/**
|
||||
* Type
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "dataset_input" | "dataset_output" | "collection_input" | "collection_output";
|
||||
};
|
||||
/** GraphNode */
|
||||
GraphNode: {
|
||||
/** Collection Type */
|
||||
collection_type?: string | null;
|
||||
/** Deleted */
|
||||
deleted?: boolean | null;
|
||||
/** Extension */
|
||||
extension?: string | null;
|
||||
/** Hid */
|
||||
hid?: number | null;
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Name */
|
||||
name?: string | null;
|
||||
/** State */
|
||||
state?: string | null;
|
||||
/** Tool Id */
|
||||
tool_id?: string | null;
|
||||
/** Tool Name */
|
||||
tool_name?: string | null;
|
||||
/**
|
||||
* Type
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "dataset" | "collection" | "tool_request";
|
||||
/** Visible */
|
||||
visible?: boolean | null;
|
||||
};
|
||||
/**
|
||||
* GroupCreatePayload
|
||||
* @description Payload schema for creating a group.
|
||||
@@ -15376,6 +15433,14 @@ export interface components {
|
||||
*/
|
||||
username_and_slug?: string | null;
|
||||
};
|
||||
/** HistoryGraphResponse */
|
||||
HistoryGraphResponse: {
|
||||
/** Edges */
|
||||
edges: components["schemas"]["GraphEdge"][];
|
||||
/** Nodes */
|
||||
nodes: components["schemas"]["GraphNode"][];
|
||||
truncated: components["schemas"]["TruncationInfo"];
|
||||
};
|
||||
/**
|
||||
* HistorySummary
|
||||
* @description History summary information.
|
||||
@@ -23730,6 +23795,22 @@ export interface components {
|
||||
*/
|
||||
title?: string | null;
|
||||
};
|
||||
/** TruncationInfo */
|
||||
TruncationInfo: {
|
||||
/**
|
||||
* Item Count Capped
|
||||
* @default false
|
||||
*/
|
||||
item_count_capped: boolean;
|
||||
/**
|
||||
* Scope Type
|
||||
* @default recent
|
||||
* @enum {string}
|
||||
*/
|
||||
scope_type: "recent" | "seed_centered";
|
||||
/** Seed In Scope */
|
||||
seed_in_scope?: boolean | null;
|
||||
};
|
||||
/** UndeleteHistoriesPayload */
|
||||
UndeleteHistoriesPayload: {
|
||||
/**
|
||||
@@ -39149,6 +39230,63 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
graph_api_histories__history_id__graph_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Maximum number of nodes. Applied at history scope. */
|
||||
limit?: number;
|
||||
/** @description Include deleted datasets and collections. */
|
||||
include_deleted?: boolean;
|
||||
/** @description Optional: focus on subgraph reachable from this node (e.g. d<encoded_id>). */
|
||||
seed?: string | null;
|
||||
/** @description Direction for seed-based subgraph extraction. */
|
||||
direction?: "backward" | "forward" | "both";
|
||||
/** @description Max depth for seed-based subgraph extraction. */
|
||||
depth?: number;
|
||||
/** @description Center the selection window on this item. Format: d{encoded_id} or c{encoded_id}. */
|
||||
seed_scope?: string | null;
|
||||
};
|
||||
header?: {
|
||||
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
|
||||
"run-as"?: string | null;
|
||||
};
|
||||
path: {
|
||||
/** @description The encoded database identifier of the History. */
|
||||
history_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HistoryGraphResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Request Error */
|
||||
"4XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
/** @description Server Error */
|
||||
"5XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
index_jobs_summary_api_histories__history_id__jobs_summary_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { curveBasisPath, orthogonalPath } from "@/utils/connectionPath";
|
||||
|
||||
import type { EdgeStyle, GraphEdge } from "./types";
|
||||
|
||||
interface Props {
|
||||
edges: GraphEdge[];
|
||||
selectedNodeId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
edgeStyle?: EdgeStyle;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedNodeId: null,
|
||||
edgeStyle: "orthogonal",
|
||||
});
|
||||
|
||||
/** Ribbon margin for collection edges — matches workflow editor's ribbonMargin */
|
||||
const RIBBON_MARGIN = 4;
|
||||
const RIBBON_OFFSETS = [-2 * RIBBON_MARGIN, -1 * RIBBON_MARGIN, 0, 1 * RIBBON_MARGIN, 2 * RIBBON_MARGIN];
|
||||
|
||||
function makePath(points: { x: number; y: number }[]): string {
|
||||
if (props.edgeStyle === "curved") {
|
||||
return curveBasisPath(points.map((p) => [p.x, p.y] as [number, number]));
|
||||
}
|
||||
return orthogonalPath(points);
|
||||
}
|
||||
|
||||
/** For a single (non-collection) edge, return one path string */
|
||||
function edgePaths(edge: GraphEdge): string[] {
|
||||
if (!edge.isCollection || edge.points.length < 2) {
|
||||
return [makePath(edge.points)];
|
||||
}
|
||||
// Collection ribbon: offset each path perpendicular to the edge direction.
|
||||
// For orthogonal/curved layouts the edges run mostly left-to-right,
|
||||
// so vertical offsets produce the ribbon effect.
|
||||
return RIBBON_OFFSETS.map((offset) => {
|
||||
const offsetPoints = edge.points.map((p) => ({ x: p.x, y: p.y + offset }));
|
||||
return makePath(offsetPoints);
|
||||
});
|
||||
}
|
||||
|
||||
function edgeClass(edge: GraphEdge): Record<string, boolean> {
|
||||
const isConnected =
|
||||
!props.selectedNodeId || edge.source === props.selectedNodeId || edge.target === props.selectedNodeId;
|
||||
return {
|
||||
[edge.cssClass ?? "edge-default"]: true,
|
||||
"edge-dimmed": !isConnected,
|
||||
"edge-collection": !!edge.isCollection,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="graph-edges" :width="width" :height="height">
|
||||
<template v-for="edge in edges">
|
||||
<path
|
||||
v-for="(path, idx) in edgePaths(edge)"
|
||||
:key="`${edge.id}-${idx}`"
|
||||
:d="path"
|
||||
:class="edgeClass(edge)"
|
||||
fill="none" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/style/scss/theme/blue.scss";
|
||||
|
||||
.graph-edges {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
path {
|
||||
stroke-width: 2;
|
||||
stroke: $brand-primary;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.edge-dimmed {
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { GraphNode } from "./types";
|
||||
|
||||
interface Props {
|
||||
node: GraphNode;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{ (e: "select", nodeId: string): void }>();
|
||||
|
||||
const nodeStyle = computed(() => ({
|
||||
left: `${props.node.x}px`,
|
||||
top: `${props.node.y}px`,
|
||||
width: `${props.node.width}px`,
|
||||
}));
|
||||
|
||||
const hasInputs = computed(() => props.node.inputs && props.node.inputs.length > 0);
|
||||
const hasOutputs = computed(() => props.node.outputs && props.node.outputs.length > 0);
|
||||
const hasPorts = computed(() => hasInputs.value || hasOutputs.value);
|
||||
const showRule = computed(() => hasInputs.value && hasOutputs.value);
|
||||
const showDataBody = computed(() => !hasPorts.value && (props.node.badge || props.node.data?.stateText));
|
||||
const iconSpin = computed(() => Boolean(props.node.data?.stateSpin));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="graph-node card"
|
||||
:class="[node.cssClass, { 'node-highlight': selected }]"
|
||||
:style="nodeStyle"
|
||||
@click.stop="emit('select', node.id)">
|
||||
<div class="graph-node-header card-header unselectable py-1 px-2" :data-state="node.data?.state ?? undefined">
|
||||
<FontAwesomeIcon :icon="node.icon" class="graph-node-icon mr-1" :spin="iconSpin" />
|
||||
<span class="graph-node-label" :title="node.label">{{ node.label }}</span>
|
||||
<span v-if="hasPorts && node.badge" class="badge badge-light ml-auto">{{ node.badge }}</span>
|
||||
</div>
|
||||
<div v-if="showDataBody" class="card-body p-0 mx-2 my-1">
|
||||
<span v-if="node.badge" class="badge badge-secondary">{{ node.badge }}</span>
|
||||
<div v-if="node.data?.stateText" class="node-state-text">{{ node.data.stateText }}</div>
|
||||
</div>
|
||||
<div v-if="hasPorts" class="node-body card-body p-0 mx-2">
|
||||
<div v-for="input in node.inputs" :key="`in-${input.name}`" class="form-row dataRow input-data-row">
|
||||
<span class="node-port-label">{{ input.label }}</span>
|
||||
</div>
|
||||
<div v-if="showRule" class="rule" />
|
||||
<div v-for="output in node.outputs" :key="`out-${output.name}`" class="form-row dataRow output-data-row">
|
||||
<span class="node-port-label">{{ output.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/style/scss/theme/blue.scss";
|
||||
|
||||
.graph-node {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border: solid $brand-primary 1px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.node-highlight {
|
||||
z-index: 1001;
|
||||
border: solid $white 1px;
|
||||
box-shadow: 0 0 0 3px $brand-primary;
|
||||
}
|
||||
|
||||
.graph-node-header {
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.graph-node-label {
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.node-body {
|
||||
font-size: $h6-font-size;
|
||||
}
|
||||
|
||||
.node-state-text {
|
||||
font-size: $h6-font-size;
|
||||
color: $text-muted;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.output-data-row {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.node-port-label {
|
||||
color: $text-color;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.rule {
|
||||
height: 0;
|
||||
border: none;
|
||||
border-bottom: dotted $brand-primary 1px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding } from "@vueuse/core";
|
||||
import { computed, nextTick, type Ref, ref, watch } from "vue";
|
||||
|
||||
import { useD3Zoom } from "@/composables/d3Zoom";
|
||||
import { useViewportBoundingBox } from "@/composables/viewportBoundingBox";
|
||||
import { maxZoom, minZoom } from "@/utils/zoomLevels";
|
||||
|
||||
import type { EdgeStyle, GraphLayout, GraphNode } from "./types";
|
||||
|
||||
import GraphEdges from "./GraphEdges.vue";
|
||||
import GraphNodeComponent from "./GraphNode.vue";
|
||||
import ZoomControl from "./ZoomControl.vue";
|
||||
|
||||
interface Props {
|
||||
layout: GraphLayout | null;
|
||||
focusNodeId?: string | null;
|
||||
edgeStyle?: EdgeStyle;
|
||||
showZoomControls?: boolean;
|
||||
showMinimap?: boolean;
|
||||
/** Minimap component — injected by consumer to avoid domain coupling */
|
||||
minimapComponent?: object | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
focusNodeId: null,
|
||||
edgeStyle: "orthogonal",
|
||||
showZoomControls: true,
|
||||
showMinimap: true,
|
||||
minimapComponent: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ (e: "nodeSelected", node: GraphNode | null): void }>();
|
||||
|
||||
const selectedNodeId = ref<string | null>(null);
|
||||
|
||||
// Separate ref for the zoom level displayed in ZoomControl.
|
||||
// Set explicitly to snapped values — avoids floating point drift from d3.
|
||||
const scale = ref(1);
|
||||
|
||||
// d3 zoom is attached to the inner canvas-container, NOT the outer wrapper.
|
||||
// This keeps ZoomControl and minimap outside the d3 event target.
|
||||
const canvasContainer: Ref<HTMLElement | null> = ref(null);
|
||||
const { transform, setZoom, panBy, moveTo } = useD3Zoom(1, minZoom, maxZoom, canvasContainer, { x: 50, y: 50 });
|
||||
|
||||
// Element bounding for viewport computation (needed by minimap)
|
||||
const elementBounding = useElementBounding(canvasContainer, { windowResize: false, windowScroll: false });
|
||||
|
||||
// Viewport bounding box in content coordinates
|
||||
const { viewportBoundingBox } = useViewportBoundingBox(elementBounding, scale, transform);
|
||||
|
||||
// Sync scale ref from d3 zoom events (for scroll-wheel zoom)
|
||||
watch(
|
||||
() => transform.value.k,
|
||||
(k) => {
|
||||
scale.value = k;
|
||||
},
|
||||
);
|
||||
|
||||
function onZoom(zoomLevel: number) {
|
||||
setZoom(zoomLevel);
|
||||
}
|
||||
|
||||
const canvasStyle = computed(() => ({
|
||||
transform: `translate(${transform.value.x}px, ${transform.value.y}px) scale(${transform.value.k})`,
|
||||
}));
|
||||
|
||||
function onNodeSelect(nodeId: string) {
|
||||
if (selectedNodeId.value === nodeId) {
|
||||
selectedNodeId.value = null;
|
||||
emit("nodeSelected", null);
|
||||
} else {
|
||||
selectedNodeId.value = nodeId;
|
||||
const node = props.layout?.nodes.find((n) => n.id === nodeId) ?? null;
|
||||
emit("nodeSelected", node);
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas background click deselects
|
||||
const mouseMovementThreshold = 9;
|
||||
let pointerDownPos: { x: number; y: number } | null = null;
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
pointerDownPos = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!pointerDownPos) {
|
||||
return;
|
||||
}
|
||||
const dx = Math.abs(e.clientX - pointerDownPos.x);
|
||||
const dy = Math.abs(e.clientY - pointerDownPos.y);
|
||||
pointerDownPos = null;
|
||||
const clickedOnNode = !!(e.target as HTMLElement).closest(".graph-node");
|
||||
if (dx + dy <= mouseMovementThreshold && !clickedOnNode) {
|
||||
selectedNodeId.value = null;
|
||||
emit("nodeSelected", null);
|
||||
}
|
||||
}
|
||||
|
||||
// Center on focus node or bounding box after layout
|
||||
watch(
|
||||
() => props.layout,
|
||||
async (newLayout) => {
|
||||
if (!newLayout) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
if (props.focusNodeId) {
|
||||
const focusNode = newLayout.nodes.find((n) => n.id === props.focusNodeId);
|
||||
if (focusNode) {
|
||||
moveTo({ x: focusNode.x + focusNode.width / 2, y: focusNode.y + focusNode.height / 2 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (newLayout.nodes.length > 0) {
|
||||
moveTo({ x: newLayout.width / 2, y: newLayout.height / 2 });
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-canvas rounded">
|
||||
<ZoomControl v-if="showZoomControls" :zoom-level="scale" @onZoom="onZoom" />
|
||||
<div ref="canvasContainer" class="canvas-container" @pointerdown="onPointerDown" @pointerup="onPointerUp">
|
||||
<div v-if="layout" class="graph-node-area" :style="canvasStyle">
|
||||
<GraphEdges
|
||||
:edges="layout.edges"
|
||||
:selected-node-id="selectedNodeId"
|
||||
:width="layout.width + 200"
|
||||
:height="layout.height + 200"
|
||||
:edge-style="edgeStyle" />
|
||||
<GraphNodeComponent
|
||||
v-for="node in layout.nodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:selected="node.id === selectedNodeId"
|
||||
@select="onNodeSelect" />
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
:is="minimapComponent"
|
||||
v-if="showMinimap && minimapComponent && layout"
|
||||
:layout="layout"
|
||||
:viewport-bounding-box="viewportBoundingBox"
|
||||
:selected-node-id="selectedNodeId"
|
||||
:parent-right="elementBounding.right.value"
|
||||
:parent-bottom="elementBounding.bottom.value"
|
||||
@panBy="panBy"
|
||||
@moveTo="moveTo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/style/scss/theme/blue.scss";
|
||||
|
||||
.graph-canvas {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.graph-node-area {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import { getZoomInLevel, getZoomOutLevel, isMaxZoom, isMinZoom } from "@/utils/zoomLevels";
|
||||
|
||||
const props = defineProps({
|
||||
zoomLevel: { type: Number, default: 1 },
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "onZoom", zoom: number): void;
|
||||
}>();
|
||||
|
||||
const zoomDefault = 1;
|
||||
const zoomPercentage = computed(() => Math.round(props.zoomLevel * 100));
|
||||
|
||||
function onZoomIn() {
|
||||
emit("onZoom", getZoomInLevel(props.zoomLevel));
|
||||
}
|
||||
|
||||
function onZoomOut() {
|
||||
emit("onZoom", getZoomOutLevel(props.zoomLevel));
|
||||
}
|
||||
|
||||
function onZoomReset() {
|
||||
emit("onZoom", zoomDefault);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="zoom-control float-right btn-group-horizontal">
|
||||
<b-button
|
||||
:disabled="isMinZoom(props.zoomLevel)"
|
||||
role="button"
|
||||
class="fa fa-minus"
|
||||
title="Zoom Out"
|
||||
size="sm"
|
||||
aria-label="Zoom Out"
|
||||
@click="onZoomOut" />
|
||||
<b-button
|
||||
v-b-tooltip.hover
|
||||
role="button"
|
||||
class="zoom-reset"
|
||||
variant="light"
|
||||
title="Reset Zoom Level"
|
||||
size="sm"
|
||||
aria-label="Reset Zoom Level"
|
||||
@click="onZoomReset">
|
||||
{{ zoomPercentage }}%
|
||||
</b-button>
|
||||
<b-button
|
||||
:disabled="isMaxZoom(props.zoomLevel)"
|
||||
role="button"
|
||||
class="fa fa-plus"
|
||||
title="Zoom In"
|
||||
size="sm"
|
||||
aria-label="Zoom In"
|
||||
@click="onZoomIn" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zoom-reset {
|
||||
width: 4rem;
|
||||
}
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
bottom: 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 2000;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
/** A labeled port on a graph node (input or output connector) */
|
||||
export interface GraphNodePort {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** A positioned node after layout */
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
icon: IconDefinition;
|
||||
badge?: string | null;
|
||||
cssClass?: string;
|
||||
/** Input ports displayed in the node body */
|
||||
inputs?: GraphNodePort[];
|
||||
/** Output ports displayed in the node body */
|
||||
outputs?: GraphNodePort[];
|
||||
/** Arbitrary domain data attached by the mapper */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A positioned edge after layout, with routed points */
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
cssClass?: string;
|
||||
/** Render as a collection ribbon (multiple parallel lines) */
|
||||
isCollection?: boolean;
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
/** Edge rendering style */
|
||||
export type EdgeStyle = "orthogonal" | "curved";
|
||||
|
||||
/** Complete layout result ready for rendering */
|
||||
export interface GraphLayout {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export type State =
|
||||
| "new_populated_state"
|
||||
| "inaccessible";
|
||||
|
||||
interface StateRepresentation {
|
||||
export interface StateRepresentation {
|
||||
status: "success" | "warning" | "info" | "danger" | "secondary";
|
||||
text?: string;
|
||||
displayName?: string;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { BAlert } from "bootstrap-vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
import type { GraphNode } from "@/components/Graph/types";
|
||||
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import GenericHistoryItem from "@/components/History/Content/GenericItem.vue";
|
||||
import JobInformation from "@/components/JobInformation/JobInformation.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface Props {
|
||||
node: GraphNode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const nodeType = computed(() => (props.node?.data?.type as string) ?? null);
|
||||
const encodedId = computed(() => (props.node?.data?.encodedId as string) ?? null);
|
||||
|
||||
/** Map graph node type to the item source GenericHistoryItem expects */
|
||||
const itemSrc = computed(() => {
|
||||
if (nodeType.value === "dataset") {
|
||||
return "hda";
|
||||
}
|
||||
if (nodeType.value === "collection") {
|
||||
return "hdca";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Tool request -> job ID resolution
|
||||
const jobId = ref<string | null>(null);
|
||||
const jobLoading = ref(false);
|
||||
const jobError = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
() => [nodeType.value, encodedId.value],
|
||||
async ([type, id]) => {
|
||||
jobId.value = null;
|
||||
jobError.value = null;
|
||||
if (type !== "tool_request" || !id) {
|
||||
return;
|
||||
}
|
||||
jobLoading.value = true;
|
||||
try {
|
||||
const { data, error } = await GalaxyApi().GET("/api/tool_requests/{id}", {
|
||||
params: { path: { id: id as string } },
|
||||
});
|
||||
if (error) {
|
||||
jobError.value = "Failed to load tool request details.";
|
||||
} else if (data.jobs && data.jobs.length > 0) {
|
||||
jobId.value = data.jobs[0]!.id;
|
||||
} else {
|
||||
jobError.value = "No job associated with this tool execution.";
|
||||
}
|
||||
} catch (e) {
|
||||
jobError.value = "Failed to load tool request details.";
|
||||
} finally {
|
||||
jobLoading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-graph-details border-left bg-white">
|
||||
<div class="details-body">
|
||||
<!-- Dataset or Collection -->
|
||||
<div v-if="itemSrc && encodedId" :key="encodedId" class="p-2">
|
||||
<Heading h1 separator inline size="md">
|
||||
{{ nodeType === "dataset" ? "Dataset Information" : "Collection Information" }}
|
||||
</Heading>
|
||||
<GenericHistoryItem :item-id="encodedId" :item-src="itemSrc" />
|
||||
</div>
|
||||
|
||||
<!-- Tool request / Job details -->
|
||||
<div v-else-if="nodeType === 'tool_request'" class="p-2">
|
||||
<LoadingSpan v-if="jobLoading" message="Loading job details" />
|
||||
<BAlert v-else-if="jobError" variant="info" show class="mb-0">{{ jobError }}</BAlert>
|
||||
<JobInformation v-else-if="jobId" :job-id="jobId" :include-times="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.history-graph-details {
|
||||
flex-shrink: 0;
|
||||
width: 320px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from "vue";
|
||||
import { computed, onMounted, ref, toRef, watch } from "vue";
|
||||
|
||||
import type { GraphLayout } from "@/components/Graph/types";
|
||||
import { useAnimationFrame } from "@/composables/sensors/animationFrame";
|
||||
import { useMinimapInteraction } from "@/composables/useMinimapInteraction";
|
||||
import { AxisAlignedBoundingBox } from "@/utils/geometry";
|
||||
|
||||
// Colors resolved once at mount from CSS custom properties on the canvas element.
|
||||
// Uses brand-primary for nodes (set via SCSS in the scoped style block).
|
||||
let nodeColor = "#000";
|
||||
let viewColor = "rgba(0, 0, 0, 0.2)";
|
||||
let viewOutlineColor = "#000";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
layout: GraphLayout | null;
|
||||
viewportBoundingBox: AxisAlignedBoundingBox;
|
||||
selectedNodeId?: string | null;
|
||||
parentRight: number;
|
||||
parentBottom: number;
|
||||
}>(),
|
||||
{
|
||||
selectedNodeId: null,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "panBy", offset: { x: number; y: number }): void;
|
||||
(e: "moveTo", position: { x: number; y: number }): void;
|
||||
}>();
|
||||
|
||||
const canvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||
const minimap: Ref<HTMLElement | null> = ref(null);
|
||||
let redraw = false;
|
||||
|
||||
const MINIMAP_SIZE = 150;
|
||||
const MINIMAP_MIN = 50;
|
||||
const MINIMAP_MAX = 300;
|
||||
const MINIMAP_PADDING = 7;
|
||||
const MINIMAP_BORDER = 1;
|
||||
|
||||
// ── Content bounds from layout ──
|
||||
|
||||
const contentBounds = computed(() => {
|
||||
const aabb = new AxisAlignedBoundingBox();
|
||||
if (props.layout) {
|
||||
for (const node of props.layout.nodes) {
|
||||
aabb.fitRectangle({ x: node.x, y: node.y, width: node.width, height: node.height });
|
||||
}
|
||||
aabb.squareCenter();
|
||||
aabb.expand(60);
|
||||
}
|
||||
return aabb;
|
||||
});
|
||||
|
||||
const viewportBoundsRef = toRef(props, "viewportBoundingBox");
|
||||
const parentRightRef = toRef(props, "parentRight");
|
||||
const parentBottomRef = toRef(props, "parentBottom");
|
||||
|
||||
// ── Shared interaction ──
|
||||
|
||||
const { getCanvasTransform, recomputeTransform, minimapSize } = useMinimapInteraction({
|
||||
canvasRef: canvas,
|
||||
containerRef: minimap,
|
||||
parentRight: parentRightRef,
|
||||
parentBottom: parentBottomRef,
|
||||
contentBounds,
|
||||
viewportBounds: viewportBoundsRef,
|
||||
panBy: (delta) => {
|
||||
if (props.layout && props.layout.nodes.length > 0) {
|
||||
emit("panBy", delta);
|
||||
}
|
||||
},
|
||||
moveTo: (pos) => {
|
||||
if (props.layout && props.layout.nodes.length > 0) {
|
||||
emit("moveTo", pos);
|
||||
}
|
||||
},
|
||||
storageKey: "graph-overview-size",
|
||||
minSize: MINIMAP_MIN,
|
||||
maxSize: MINIMAP_MAX,
|
||||
defaultSize: MINIMAP_SIZE,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (canvas.value) {
|
||||
const style = getComputedStyle(canvas.value);
|
||||
nodeColor = style.getPropertyValue("--node-color").trim() || nodeColor;
|
||||
viewColor = style.getPropertyValue("--view-color").trim() || viewColor;
|
||||
viewOutlineColor = style.getPropertyValue("--view-outline-color").trim() || viewOutlineColor;
|
||||
}
|
||||
recomputeTransform();
|
||||
redraw = true;
|
||||
});
|
||||
|
||||
// ── Redraw triggers ──
|
||||
|
||||
watch(
|
||||
() => props.layout,
|
||||
() => {
|
||||
redraw = true;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
watch(
|
||||
() => props.viewportBoundingBox,
|
||||
() => {
|
||||
redraw = true;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
watch(
|
||||
() => props.selectedNodeId,
|
||||
() => {
|
||||
redraw = true;
|
||||
},
|
||||
);
|
||||
watch(minimapSize, () => {
|
||||
redraw = true;
|
||||
});
|
||||
watch(contentBounds, () => {
|
||||
recomputeTransform();
|
||||
redraw = true;
|
||||
});
|
||||
|
||||
useAnimationFrame(() => {
|
||||
if (redraw && canvas.value) {
|
||||
renderMinimap();
|
||||
redraw = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Rendering ──
|
||||
|
||||
function renderMinimap() {
|
||||
const canvasTransform = getCanvasTransform();
|
||||
const ctx = canvas.value!.getContext("2d") as CanvasRenderingContext2D;
|
||||
ctx.resetTransform();
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
if (!props.layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvasTransform.applyToContext(ctx);
|
||||
|
||||
// Draw nodes
|
||||
ctx.fillStyle = nodeColor;
|
||||
ctx.beginPath();
|
||||
for (const node of props.layout.nodes) {
|
||||
ctx.rect(node.x, node.y, node.width, node.height);
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
// Draw selected node outline
|
||||
if (props.selectedNodeId) {
|
||||
const selected = props.layout.nodes.find((n) => n.id === props.selectedNodeId);
|
||||
if (selected) {
|
||||
const edge = 2 / canvasTransform.scaleX;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = nodeColor;
|
||||
ctx.lineWidth = edge;
|
||||
ctx.rect(selected.x - edge, selected.y - edge, selected.width + edge * 2, selected.height + edge * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw viewport overlay
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = viewOutlineColor;
|
||||
ctx.fillStyle = viewColor;
|
||||
ctx.lineWidth = 1 / canvasTransform.scaleX;
|
||||
ctx.rect(
|
||||
props.viewportBoundingBox.x,
|
||||
props.viewportBoundingBox.y,
|
||||
props.viewportBoundingBox.width,
|
||||
props.viewportBoundingBox.height,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="minimap"
|
||||
class="graph-overview"
|
||||
:style="{ '--graph-overview-size': `${minimapSize + MINIMAP_PADDING + MINIMAP_BORDER}px` }">
|
||||
<canvas ref="canvas" class="graph-overview-body" :width="MINIMAP_MAX" :height="MINIMAP_MAX" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "bootstrap/scss/_functions.scss";
|
||||
@import "@/style/scss/theme/blue.scss";
|
||||
|
||||
.graph-overview {
|
||||
--graph-overview-size: 150px;
|
||||
|
||||
border-top-left-radius: 0.3rem;
|
||||
cursor: nwse-resize;
|
||||
position: absolute;
|
||||
width: var(--graph-overview-size);
|
||||
height: var(--graph-overview-size);
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
border-top: solid $border-color 1px;
|
||||
border-left: solid $border-color 1px;
|
||||
background: $white no-repeat url("@/assets/images/resizable.png");
|
||||
z-index: 2000;
|
||||
overflow: hidden;
|
||||
padding: 7px 0 0 7px;
|
||||
|
||||
max-width: calc(300px + 7px + 1px);
|
||||
max-height: calc(300px + 7px + 1px);
|
||||
min-width: calc(50px + 7px + 1px);
|
||||
min-height: calc(50px + 7px + 1px);
|
||||
|
||||
.graph-overview-body {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
--node-color: #{$brand-primary};
|
||||
--view-color: #{fade-out($brand-dark, 0.8)};
|
||||
--view-outline-color: #{$brand-info};
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { faBezierCurve, faProjectDiagram } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert } from "bootstrap-vue";
|
||||
import { computed, ref, toRef } from "vue";
|
||||
|
||||
import type { EdgeStyle, GraphNode } from "@/components/Graph/types";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
|
||||
import { useHistoryGraphData } from "./useHistoryGraphData";
|
||||
import { useHistoryGraphLayout } from "./useHistoryGraphLayout";
|
||||
|
||||
import HistoryGraphDetails from "./HistoryGraphDetails.vue";
|
||||
import HistoryGraphMinimap from "./HistoryGraphMinimap.vue";
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
import GButtonGroup from "@/components/BaseComponents/GButtonGroup.vue";
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import GraphView from "@/components/Graph/GraphView.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface Props {
|
||||
historyId: string;
|
||||
seedNodeId?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// History name
|
||||
const historyStore = useHistoryStore();
|
||||
const historyName = computed(() => historyStore.getHistoryNameById(props.historyId));
|
||||
|
||||
// Fetch params — product decisions owned here
|
||||
const limit = ref(500);
|
||||
|
||||
const { graphData, loading, error } = useHistoryGraphData(toRef(props, "historyId"), limit, toRef(props, "seedNodeId"));
|
||||
|
||||
// Layout
|
||||
const edgeStyle = ref<EdgeStyle>("orthogonal");
|
||||
const { layout, layoutLoading } = useHistoryGraphLayout(graphData, edgeStyle);
|
||||
|
||||
// Selection
|
||||
const selectedNode = ref<GraphNode | null>(null);
|
||||
|
||||
function onNodeSelected(node: GraphNode | null) {
|
||||
selectedNode.value = node;
|
||||
}
|
||||
|
||||
const isLoading = computed(() => loading.value || layoutLoading.value);
|
||||
const isTruncated = computed(() => graphData.value?.truncated?.item_count_capped ?? false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-graph-view">
|
||||
<BAlert v-if="error" variant="danger" show>{{ error }}</BAlert>
|
||||
<LoadingSpan v-else-if="isLoading" message="Loading history graph" />
|
||||
<template v-else>
|
||||
<div class="ui-form-header-underlay sticky-top" />
|
||||
<div class="tool-header sticky-top bg-secondary px-2 py-1 mb-2 rounded">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="py-1 d-flex flex-gapx-1 align-items-center">
|
||||
<FontAwesomeIcon :icon="faBezierCurve" class="fa-fw" />
|
||||
<Heading h1 inline bold size="text">{{ historyName }}</Heading>
|
||||
</div>
|
||||
<GButtonGroup>
|
||||
<GButton
|
||||
tooltip
|
||||
:title="'Orthogonal edges'"
|
||||
size="small"
|
||||
color="blue"
|
||||
:outline="edgeStyle !== 'orthogonal'"
|
||||
:pressed="edgeStyle === 'orthogonal'"
|
||||
@click="edgeStyle = 'orthogonal'">
|
||||
<FontAwesomeIcon :icon="faProjectDiagram" fixed-width />
|
||||
</GButton>
|
||||
<GButton
|
||||
tooltip
|
||||
:title="'Curved edges'"
|
||||
size="small"
|
||||
color="blue"
|
||||
:outline="edgeStyle !== 'curved'"
|
||||
:pressed="edgeStyle === 'curved'"
|
||||
@click="edgeStyle = 'curved'">
|
||||
<FontAwesomeIcon :icon="faBezierCurve" fixed-width />
|
||||
</GButton>
|
||||
</GButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-graph-content">
|
||||
<GraphView
|
||||
:layout="layout"
|
||||
:focus-node-id="seedNodeId ?? null"
|
||||
:edge-style="edgeStyle"
|
||||
:minimap-component="HistoryGraphMinimap"
|
||||
@nodeSelected="onNodeSelected" />
|
||||
<HistoryGraphDetails v-if="selectedNode" :node="selectedNode" />
|
||||
</div>
|
||||
<div v-if="isTruncated" class="history-graph-truncation">
|
||||
Showing a partial graph. Not all connections are visible.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/style/scss/theme/blue.scss";
|
||||
|
||||
.history-graph-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.history-graph-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.history-graph-truncation {
|
||||
padding: 0.375rem 1rem;
|
||||
background: $state-warning-bg;
|
||||
color: $state-warning-text;
|
||||
font-size: $h6-font-size;
|
||||
text-align: center;
|
||||
border-top: 1px solid $state-warning-border;
|
||||
}
|
||||
|
||||
/* Tool request nodes use primary header (no dataset state) */
|
||||
:deep(.node-tool-request) .graph-node-header {
|
||||
background: $brand-primary;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
/* Dataset/collection nodes use state-driven coloring via data-state attribute */
|
||||
:deep(.node-dataset) .graph-node-header,
|
||||
:deep(.node-collection) .graph-node-header {
|
||||
color: $text-color;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
import { faFile, faLayerGroup, faWrench } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import type { components } from "@/api/schema";
|
||||
import type { GraphEdge, GraphNode, GraphNodePort } from "@/components/Graph/types";
|
||||
import { type StateRepresentation, STATES } from "@/components/History/Content/model/states";
|
||||
|
||||
type ApiGraphNode = components["schemas"]["GraphNode"];
|
||||
type ApiGraphEdge = components["schemas"]["GraphEdge"];
|
||||
export type HistoryGraphResponse = components["schemas"]["HistoryGraphResponse"];
|
||||
|
||||
/** Node width — uniform across all types */
|
||||
const NODE_WIDTH = 200;
|
||||
|
||||
/** Compute node height based on content: header + ports + padding */
|
||||
const HEADER_LINE_HEIGHT = 20;
|
||||
const HEADER_PADDING = 10;
|
||||
const HEADER_ICON_CHARS = 3;
|
||||
const CHARS_PER_LINE = 22;
|
||||
const PORT_ROW_HEIGHT = 18;
|
||||
const RULE_HEIGHT = 5;
|
||||
const BODY_PADDING = 8;
|
||||
const MIN_NODE_HEIGHT = 32;
|
||||
|
||||
function estimateHeaderLines(label: string): number {
|
||||
const effectiveLength = label.length + HEADER_ICON_CHARS;
|
||||
return Math.max(1, Math.ceil(effectiveLength / CHARS_PER_LINE));
|
||||
}
|
||||
|
||||
function computeNodeHeight(
|
||||
label: string,
|
||||
inputCount: number,
|
||||
outputCount: number,
|
||||
hasBadgeBody: boolean = false,
|
||||
): number {
|
||||
const headerLines = estimateHeaderLines(label);
|
||||
const headerHeight = headerLines * HEADER_LINE_HEIGHT + HEADER_PADDING;
|
||||
if (inputCount === 0 && outputCount === 0 && !hasBadgeBody) {
|
||||
return Math.max(MIN_NODE_HEIGHT, headerHeight);
|
||||
}
|
||||
const portRows = inputCount + outputCount;
|
||||
const rule = inputCount > 0 && outputCount > 0 ? RULE_HEIGHT : 0;
|
||||
const badgeRow = hasBadgeBody ? PORT_ROW_HEIGHT + BODY_PADDING : 0;
|
||||
return headerHeight + portRows * PORT_ROW_HEIGHT + rule + BODY_PADDING + badgeRow;
|
||||
}
|
||||
|
||||
/** User-facing type labels */
|
||||
export const NODE_TYPE_LABELS: Record<string, string> = {
|
||||
dataset: "Dataset",
|
||||
collection: "Collection",
|
||||
tool_request: "Tool Execution",
|
||||
};
|
||||
|
||||
const NODE_ICONS: Record<string, typeof faFile> = {
|
||||
dataset: faFile,
|
||||
collection: faLayerGroup,
|
||||
tool_request: faWrench,
|
||||
};
|
||||
|
||||
const NODE_CSS_CLASS: Record<string, string> = {
|
||||
dataset: "node-dataset",
|
||||
collection: "node-collection",
|
||||
tool_request: "node-tool-request",
|
||||
};
|
||||
|
||||
// ── Label resolution ──
|
||||
|
||||
function resolveNodeLabel(node: ApiGraphNode): string {
|
||||
const hid = node.hid ? `${node.hid}: ` : "";
|
||||
switch (node.type) {
|
||||
case "dataset":
|
||||
return `${hid}${node.name ?? node.extension ?? "Dataset"}`;
|
||||
case "collection":
|
||||
return `${hid}${node.name ?? node.collection_type ?? "Collection"}`;
|
||||
case "tool_request":
|
||||
return node.tool_name ?? shortenToolId(node.tool_id);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeBadge(node: ApiGraphNode): string | null {
|
||||
switch (node.type) {
|
||||
case "dataset":
|
||||
return node.extension ?? null;
|
||||
case "collection":
|
||||
return node.collection_type ?? null;
|
||||
case "tool_request":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shortenToolId(toolId: string | null | undefined): string {
|
||||
if (!toolId) {
|
||||
return "Tool";
|
||||
}
|
||||
// Strip toolshed prefix:
|
||||
// "toolshed.g2.bx.psu.edu/repos/iuc/bwa_mem/bwa_mem/1.0" → "bwa_mem"
|
||||
const parts = toolId.split("/");
|
||||
if (parts.length >= 2) {
|
||||
return parts[parts.length - 2] ?? toolId;
|
||||
}
|
||||
return toolId;
|
||||
}
|
||||
|
||||
function isCollectionEdge(edge: ApiGraphEdge): boolean {
|
||||
return edge.type === "collection_input" || edge.type === "collection_output";
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
/**
|
||||
* Map API graph nodes to generic GraphNode[] for the renderer.
|
||||
* Returns nodes with dimensions, labels, icons, badges, and domain data.
|
||||
*/
|
||||
export function mapNodes(apiNodes: ApiGraphNode[], apiEdges: ApiGraphEdge[]): GraphNode[] {
|
||||
// Build a lookup for node labels by ID
|
||||
const nodeLabels = new Map<string, string>();
|
||||
for (const node of apiNodes) {
|
||||
nodeLabels.set(node.id, resolveNodeLabel(node));
|
||||
}
|
||||
|
||||
// Build input/output port lists per node from edges
|
||||
const inputPorts = new Map<string, GraphNodePort[]>();
|
||||
const outputPorts = new Map<string, GraphNodePort[]>();
|
||||
for (const edge of apiEdges) {
|
||||
const sourceLabel = nodeLabels.get(edge.source) ?? edge.source;
|
||||
const targetLabel = nodeLabels.get(edge.target) ?? edge.target;
|
||||
|
||||
// Target node gets an input port labeled by the source
|
||||
if (!inputPorts.has(edge.target)) {
|
||||
inputPorts.set(edge.target, []);
|
||||
}
|
||||
inputPorts.get(edge.target)!.push({ name: edge.source, label: sourceLabel });
|
||||
|
||||
// Source node gets an output port labeled by the target
|
||||
if (!outputPorts.has(edge.source)) {
|
||||
outputPorts.set(edge.source, []);
|
||||
}
|
||||
outputPorts.get(edge.source)!.push({ name: edge.target, label: targetLabel });
|
||||
}
|
||||
|
||||
return apiNodes.map((node) => {
|
||||
// Only tool_request nodes show input/output port lists.
|
||||
// Dataset and collection nodes show state + badge in the body.
|
||||
const isToolRequest = node.type === "tool_request";
|
||||
const inputs = isToolRequest ? (inputPorts.get(node.id) ?? []) : [];
|
||||
const outputs = isToolRequest ? (outputPorts.get(node.id) ?? []) : [];
|
||||
const inputCount = inputPorts.get(node.id)?.length ?? 0;
|
||||
const outputCount = outputPorts.get(node.id)?.length ?? 0;
|
||||
const badge = resolveNodeBadge(node);
|
||||
|
||||
// For datasets/collections, use state to determine icon.
|
||||
// State-based coloring is handled by data-state attribute on the node element,
|
||||
// which triggers the global $galaxy-state-bg / $galaxy-state-border rules from base.scss.
|
||||
// Collections return populated_state ("ok"/"new"/"failed") — map "failed" → "error"
|
||||
// to align with the dataset state vocabulary used by the STATES map and CSS rules.
|
||||
const rawState = node.state;
|
||||
const displayState = rawState === "failed" ? "error" : rawState;
|
||||
const stateKey = displayState as keyof typeof STATES | undefined;
|
||||
const stateRep: StateRepresentation | null =
|
||||
!isToolRequest && stateKey && stateKey in STATES ? STATES[stateKey] : null;
|
||||
const icon = stateRep?.icon ?? NODE_ICONS[node.type] ?? faFile;
|
||||
const cssClass = NODE_CSS_CLASS[node.type];
|
||||
|
||||
// Data nodes always have a body (badge + state text)
|
||||
const hasBody = !isToolRequest;
|
||||
const label = nodeLabels.get(node.id)!;
|
||||
return {
|
||||
id: node.id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: NODE_WIDTH,
|
||||
height: computeNodeHeight(label, inputs.length, outputs.length, hasBody),
|
||||
label,
|
||||
icon,
|
||||
badge,
|
||||
cssClass,
|
||||
inputs,
|
||||
outputs,
|
||||
data: {
|
||||
type: node.type,
|
||||
typeLabel: NODE_TYPE_LABELS[node.type] ?? node.type,
|
||||
/** Encoded ID without the type prefix (d/c/r) */
|
||||
encodedId: node.id.slice(1),
|
||||
toolId: isToolRequest ? node.tool_id : null,
|
||||
inputCount,
|
||||
outputCount,
|
||||
state: displayState,
|
||||
stateText: stateRep?.text ?? null,
|
||||
stateDisplayName: stateRep?.displayName ?? null,
|
||||
stateSpin: stateRep?.spin ?? false,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API graph edges to generic GraphEdge[] for the renderer.
|
||||
* Points are set to empty — the layout composable fills them in.
|
||||
*/
|
||||
export function mapEdges(apiEdges: ApiGraphEdge[]): GraphEdge[] {
|
||||
return apiEdges.map((edge, idx) => ({
|
||||
id: `e${idx}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
cssClass: isCollectionEdge(edge) ? "edge-collection" : "edge-dataset",
|
||||
isCollection: isCollectionEdge(edge),
|
||||
points: [],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type Ref, ref, watch } from "vue";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
|
||||
import type { HistoryGraphResponse } from "./historyGraphMapper";
|
||||
|
||||
/**
|
||||
* Fetch the history-scoped graph from the API.
|
||||
*
|
||||
* The default call returns the full history graph (within bounds).
|
||||
* An optional seed parameter requests a focused subgraph.
|
||||
*/
|
||||
export function useHistoryGraphData(historyId: Ref<string>, limit: Ref<number>, seed?: Ref<string | undefined>) {
|
||||
const graphData = ref<HistoryGraphResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function fetchGraph() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const query: Record<string, unknown> = {
|
||||
limit: limit.value,
|
||||
};
|
||||
if (seed?.value) {
|
||||
query.seed = seed.value;
|
||||
}
|
||||
|
||||
const { data, error: apiError } = await GalaxyApi().GET("/api/histories/{history_id}/graph", {
|
||||
params: {
|
||||
path: { history_id: historyId.value },
|
||||
query: query as any,
|
||||
},
|
||||
});
|
||||
|
||||
if (apiError) {
|
||||
error.value = apiError.err_msg || "Failed to load graph";
|
||||
graphData.value = null;
|
||||
} else {
|
||||
graphData.value = data;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to load graph";
|
||||
graphData.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const watchSources = seed ? [historyId, limit, seed] : [historyId, limit];
|
||||
watch(watchSources, () => fetchGraph(), { immediate: true });
|
||||
|
||||
return { graphData, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled";
|
||||
import { type Ref, ref, watch } from "vue";
|
||||
|
||||
import type { EdgeStyle, GraphEdge, GraphLayout, GraphNode } from "@/components/Graph/types";
|
||||
import { computeControlPoints } from "@/utils/connectionPath";
|
||||
|
||||
import { type HistoryGraphResponse, mapEdges, mapNodes } from "./historyGraphMapper";
|
||||
|
||||
const elk = new ELK();
|
||||
|
||||
/**
|
||||
* Composable that takes API graph data and produces a positioned layout using ELK.js.
|
||||
*
|
||||
* Uses the history graph mapper to convert API types to generic graph types,
|
||||
* then runs ELK layered layout and extracts positioned nodes and edge paths.
|
||||
*
|
||||
* edgeStyle controls how edge points are computed:
|
||||
* - "orthogonal": uses ELK's routed sections (straight segments with bends)
|
||||
* - "curved": computes bezier control points between node ports (workflow editor style)
|
||||
*/
|
||||
export function useHistoryGraphLayout(
|
||||
graphData: Ref<HistoryGraphResponse | null>,
|
||||
edgeStyle: Ref<EdgeStyle> = ref("orthogonal") as Ref<EdgeStyle>,
|
||||
) {
|
||||
const layout = ref<GraphLayout | null>(null);
|
||||
const layoutLoading = ref(false);
|
||||
|
||||
watch(
|
||||
[graphData, edgeStyle],
|
||||
async ([data, style]) => {
|
||||
if (!data || data.nodes.length === 0) {
|
||||
layout.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
layoutLoading.value = true;
|
||||
try {
|
||||
layout.value = await computeLayout(data, style);
|
||||
} catch (e) {
|
||||
console.error("History graph layout failed:", e);
|
||||
layout.value = null;
|
||||
} finally {
|
||||
layoutLoading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { layout, layoutLoading };
|
||||
}
|
||||
|
||||
async function computeLayout(data: HistoryGraphResponse, style: EdgeStyle): Promise<GraphLayout> {
|
||||
// Map API types to generic graph types via the history mapper
|
||||
const graphNodes = mapNodes(data.nodes, data.edges);
|
||||
const graphEdges = mapEdges(data.edges);
|
||||
|
||||
// Build ELK graph
|
||||
const elkChildren: ElkNode[] = graphNodes.map((node) => ({
|
||||
id: node.id,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
}));
|
||||
|
||||
const elkEdges: ElkExtendedEdge[] = graphEdges.map((edge) => ({
|
||||
id: edge.id,
|
||||
sources: [edge.source],
|
||||
targets: [edge.target],
|
||||
}));
|
||||
|
||||
const elkGraph: ElkNode = {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "RIGHT",
|
||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
||||
"elk.layered.spacing.baseValue": "80",
|
||||
"elk.spacing.nodeNode": "40",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
|
||||
"elk.edgeRouting": "ORTHOGONAL",
|
||||
},
|
||||
children: elkChildren,
|
||||
edges: elkEdges,
|
||||
};
|
||||
|
||||
const result = await elk.layout(elkGraph);
|
||||
|
||||
// Apply ELK positions to graph nodes
|
||||
const nodeById = new Map(graphNodes.map((n) => [n.id, n]));
|
||||
const layoutNodes: GraphNode[] = (result.children ?? []).map((elkNode) => {
|
||||
const gn = nodeById.get(elkNode.id)!;
|
||||
return {
|
||||
...gn,
|
||||
x: elkNode.x ?? 0,
|
||||
y: elkNode.y ?? 0,
|
||||
width: elkNode.width ?? gn.width,
|
||||
height: elkNode.height ?? gn.height,
|
||||
};
|
||||
});
|
||||
|
||||
// Build node position lookup for edge routing
|
||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
for (const n of layoutNodes) {
|
||||
nodePositions.set(n.id, { x: n.x, y: n.y, w: n.width, h: n.height });
|
||||
}
|
||||
|
||||
let layoutEdges: GraphEdge[];
|
||||
|
||||
if (style === "curved") {
|
||||
// Compute bezier control points between node ports (workflow editor style)
|
||||
layoutEdges = graphEdges.map((ge) => {
|
||||
const src = nodePositions.get(ge.source);
|
||||
const tgt = nodePositions.get(ge.target);
|
||||
let points: { x: number; y: number }[] = [];
|
||||
if (src && tgt) {
|
||||
const controlPoints = computeControlPoints(src.x + src.w, src.y + src.h / 2, tgt.x, tgt.y + tgt.h / 2);
|
||||
points = controlPoints.map(([x, y]) => ({ x, y }));
|
||||
}
|
||||
return { ...ge, points };
|
||||
});
|
||||
} else {
|
||||
// Extract edge paths from ELK's orthogonal routing sections
|
||||
const edgeById = new Map(graphEdges.map((e) => [e.id, e]));
|
||||
layoutEdges = (result.edges ?? []).map((elkEdge) => {
|
||||
const ge = edgeById.get(elkEdge.id)!;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
|
||||
const sections = (elkEdge as ElkExtendedEdge).sections ?? [];
|
||||
for (const section of sections) {
|
||||
points.push({ x: section.startPoint.x, y: section.startPoint.y });
|
||||
if (section.bendPoints) {
|
||||
for (const bp of section.bendPoints) {
|
||||
points.push({ x: bp.x, y: bp.y });
|
||||
}
|
||||
}
|
||||
points.push({ x: section.endPoint.x, y: section.endPoint.y });
|
||||
}
|
||||
|
||||
// Fallback: straight line between node edges
|
||||
if (points.length === 0) {
|
||||
const src = nodePositions.get(ge.source);
|
||||
const tgt = nodePositions.get(ge.target);
|
||||
if (src && tgt) {
|
||||
points.push({ x: src.x + src.w, y: src.y + src.h / 2 });
|
||||
points.push({ x: tgt.x, y: tgt.y + tgt.h / 2 });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...ge, points };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: layoutNodes,
|
||||
edges: layoutEdges,
|
||||
width: result.width ?? 0,
|
||||
height: result.height ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -25,11 +25,18 @@ const expectedOptions = [
|
||||
"Archive History",
|
||||
"Extract Workflow",
|
||||
"Show Invocations",
|
||||
"Show History Graph",
|
||||
"Share & Manage Access",
|
||||
];
|
||||
|
||||
// options enabled for logged-out users
|
||||
const anonymousOptions = ["Resume Paused Jobs", "Delete History", "Export Tool References", "Export History to File"];
|
||||
const anonymousOptions = [
|
||||
"Resume Paused Jobs",
|
||||
"Delete History",
|
||||
"Export Tool References",
|
||||
"Export History to File",
|
||||
"Show History Graph",
|
||||
];
|
||||
|
||||
// options disabled for logged-out users
|
||||
const anonymousDisabledOptions = expectedOptions.filter((option) => !anonymousOptions.includes(option));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
faArchive,
|
||||
faBars,
|
||||
faBezierCurve,
|
||||
faBurn,
|
||||
faClone,
|
||||
faColumns,
|
||||
@@ -231,6 +232,11 @@ watch(
|
||||
<span v-localize>Show Invocations</span>
|
||||
</BDropdownItem>
|
||||
|
||||
<BDropdownItem :title="localize('View History Graph')" :to="`/histories/${history.id}/graph`">
|
||||
<FontAwesomeIcon fixed-width :icon="faBezierCurve" />
|
||||
<span v-localize>Show History Graph</span>
|
||||
</BDropdownItem>
|
||||
|
||||
<BDropdownDivider />
|
||||
|
||||
<BDropdownItem
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { UseElementBoundingReturn } from "@vueuse/core";
|
||||
import { computed, onMounted, type Ref, ref, watch } from "vue";
|
||||
|
||||
import { useAnimationFrame } from "@/composables/sensors/animationFrame";
|
||||
|
||||
import { type AxisAlignedBoundingBox, Transform, type WorkflowTransform } from "./modules/geometry";
|
||||
import { type AxisAlignedBoundingBox, Transform, type WorkflowTransform } from "@/utils/geometry";
|
||||
|
||||
const lineGap = 10;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import type { Rectangle } from "@/components/Workflow/Editor/modules/geometry";
|
||||
import type { Rectangle } from "@/utils/geometry";
|
||||
import { wait } from "@/utils/utils";
|
||||
|
||||
const bounds = ref<Rectangle>({ x: 0, y: 0, width: 0, height: 0 });
|
||||
|
||||
@@ -7,10 +7,10 @@ import { BButton, BButtonGroup } from "bootstrap-vue";
|
||||
import purify from "dompurify";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
import { AxisAlignedBoundingBox, type Rectangle } from "@/components/Workflow/Editor/modules/geometry";
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import type { FrameWorkflowComment, WorkflowComment, WorkflowCommentColor } from "@/stores/workflowEditorCommentStore";
|
||||
import type { Step } from "@/stores/workflowStepStore";
|
||||
import { AxisAlignedBoundingBox, type Rectangle } from "@/utils/geometry";
|
||||
|
||||
import { LazyMoveMultipleAction } from "../Actions/workflowActions";
|
||||
import { brighterColors, darkenedColors } from "./colors";
|
||||
|
||||
@@ -4,8 +4,8 @@ import { computed } from "vue";
|
||||
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import type { FreehandWorkflowComment } from "@/stores/workflowEditorCommentStore";
|
||||
import { vecSubtract } from "@/utils/geometry";
|
||||
|
||||
import { vecSubtract } from "../modules/geometry";
|
||||
import { colors } from "./colors";
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useEventListener } from "@vueuse/core";
|
||||
import { type Ref, watch } from "vue";
|
||||
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
|
||||
import { vecSnap } from "../modules/geometry";
|
||||
import { vecSnap } from "@/utils/geometry";
|
||||
|
||||
/**
|
||||
* Common functionality required for handling a user resizable element.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { curveBasis, line } from "d3";
|
||||
import { computed, type PropType } from "vue";
|
||||
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import { getConnectionId } from "@/stores/workflowConnectionStore";
|
||||
import type { TerminalPosition } from "@/stores/workflowEditorStateStore";
|
||||
import type { Connection } from "@/stores/workflowStoreTypes";
|
||||
import { curveBasisPath } from "@/utils/connectionPath";
|
||||
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
@@ -25,8 +25,6 @@ const props = defineProps({
|
||||
|
||||
const ribbonMargin = 4;
|
||||
|
||||
const curve = line().curve(curveBasis);
|
||||
|
||||
const { connectionStore, stateStore, stepStore } = useWorkflowStores();
|
||||
|
||||
const outputPos = computed(() => {
|
||||
@@ -165,7 +163,7 @@ const paths = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
return lines.map((l) => curve(l)!);
|
||||
return lines.map((l) => curveBasisPath(l));
|
||||
});
|
||||
|
||||
const lineWidth = computed(() => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useEventListener } from "@vueuse/core";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { Transform, type WorkflowTransform } from "@/components/Workflow/Editor/modules/geometry";
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import { Transform, type WorkflowTransform } from "@/utils/geometry";
|
||||
|
||||
const props = defineProps<{
|
||||
transform: WorkflowTransform;
|
||||
|
||||
@@ -4,10 +4,6 @@ import { ref, watch } from "vue";
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import type { BaseWorkflowComment } from "@/stores/workflowEditorCommentStore";
|
||||
import { assertDefined } from "@/utils/assertions";
|
||||
import { match } from "@/utils/utils";
|
||||
|
||||
import { AddCommentAction } from "../Actions/commentActions";
|
||||
import { AddToSelectionAction, RemoveFromSelectionAction } from "../Actions/workflowActions";
|
||||
import {
|
||||
AxisAlignedBoundingBox,
|
||||
vecMax,
|
||||
@@ -16,7 +12,11 @@ import {
|
||||
vecSnap,
|
||||
vecSubtract,
|
||||
type Vector,
|
||||
} from "../modules/geometry";
|
||||
} from "@/utils/geometry";
|
||||
import { match } from "@/utils/utils";
|
||||
|
||||
import { AddCommentAction } from "../Actions/commentActions";
|
||||
import { AddToSelectionAction, RemoveFromSelectionAction } from "../Actions/workflowActions";
|
||||
|
||||
export function useToolLogic() {
|
||||
const comment = ref<BaseWorkflowComment | null>(null);
|
||||
|
||||
@@ -5,8 +5,8 @@ import { computed, type Ref } from "vue";
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import type { TerminalPosition } from "@/stores/workflowEditorStateStore";
|
||||
import type { Connection, OutputTerminal } from "@/stores/workflowStoreTypes";
|
||||
import type { WorkflowTransform } from "@/utils/geometry";
|
||||
|
||||
import type { WorkflowTransform } from "./modules/geometry";
|
||||
import type { OutputTerminals } from "./modules/terminals";
|
||||
|
||||
import SVGConnection from "./SVGConnection.vue";
|
||||
|
||||
@@ -4,16 +4,16 @@ import { storeToRefs } from "pinia";
|
||||
import { computed, type PropType, provide, reactive, type Ref, ref, watch, watchEffect } from "vue";
|
||||
|
||||
import { DatatypesMapperModel } from "@/components/Datatypes/model";
|
||||
import { useD3Zoom } from "@/composables/d3Zoom";
|
||||
import { useViewportBoundingBox } from "@/composables/viewportBoundingBox";
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import type { TerminalPosition, XYPosition } from "@/stores/workflowEditorStateStore";
|
||||
import type { Step } from "@/stores/workflowStepStore";
|
||||
import { assertDefined } from "@/utils/assertions";
|
||||
import type { Vector } from "@/utils/geometry";
|
||||
|
||||
import { useD3Zoom } from "./composables/d3Zoom";
|
||||
import { useFocusedNodes } from "./composables/useFocusedNodes";
|
||||
import { useViewportBoundingBox } from "./composables/viewportBoundingBox";
|
||||
import { useWorkflowBoundingBox } from "./composables/workflowBoundingBox";
|
||||
import type { Vector } from "./modules/geometry";
|
||||
import type { OutputTerminals } from "./modules/terminals";
|
||||
import { maxZoom, minZoom } from "./modules/zoomLevels";
|
||||
|
||||
@@ -23,11 +23,11 @@ import BoxSelectPreview from "./Tools/BoxSelectPreview.vue";
|
||||
import InputCatcher from "./Tools/InputCatcher.vue";
|
||||
import ToolBar from "./Tools/ToolBar.vue";
|
||||
import LoadingOverlay from "@/components/Common/LoadingOverlay.vue";
|
||||
import ZoomControl from "@/components/Graph/ZoomControl.vue";
|
||||
import AreaHighlight from "@/components/Workflow/Editor/AreaHighlight.vue";
|
||||
import WorkflowNode from "@/components/Workflow/Editor/Node.vue";
|
||||
import WorkflowEdges from "@/components/Workflow/Editor/WorkflowEdges.vue";
|
||||
import WorkflowMinimap from "@/components/Workflow/Editor/WorkflowMinimap.vue";
|
||||
import ZoomControl from "@/components/Workflow/Editor/ZoomControl.vue";
|
||||
|
||||
const emit = defineEmits(["transform", "graph-offset", "onRemove", "scrollTo", "stepClicked"]);
|
||||
const props = defineProps({
|
||||
@@ -59,7 +59,7 @@ const {
|
||||
setZoom,
|
||||
moveTo,
|
||||
setTransform: d3SetTransform,
|
||||
} = useD3Zoom(scale.value, minZoom, maxZoom, canvas, scroll, props.initialPosition);
|
||||
} = useD3Zoom(scale.value, minZoom, maxZoom, canvas, props.initialPosition);
|
||||
|
||||
watch(
|
||||
() => transform.value,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useDraggable, type UseElementBoundingReturn } from "@vueuse/core";
|
||||
import type { UseElementBoundingReturn } from "@vueuse/core";
|
||||
import type { Ref } from "vue";
|
||||
import { computed, nextTick, onMounted, ref, unref, watch } from "vue";
|
||||
|
||||
import { useAnimationFrame } from "@/composables/sensors/animationFrame";
|
||||
import { useAnimationFrameThrottle } from "@/composables/throttle";
|
||||
import { useMinimapInteraction } from "@/composables/useMinimapInteraction";
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
import type {
|
||||
FrameWorkflowComment,
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
WorkflowComment,
|
||||
} from "@/stores/workflowEditorCommentStore";
|
||||
import type { Step, Steps } from "@/stores/workflowStepStore";
|
||||
import { AxisAlignedBoundingBox } from "@/utils/geometry";
|
||||
|
||||
import { useWorkflowBoundingBox } from "./composables/workflowBoundingBox";
|
||||
import {
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
getStepColor,
|
||||
initStateColors,
|
||||
} from "./modules/canvasDraw";
|
||||
import { type AxisAlignedBoundingBox, Transform } from "./modules/geometry";
|
||||
|
||||
const props = defineProps<{
|
||||
steps: Steps;
|
||||
@@ -45,10 +45,6 @@ const { isJustCreated } = commentStore;
|
||||
const canvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||
let redraw = false;
|
||||
|
||||
// it is important these throttles are defined before useAnimationFrame,
|
||||
// so that they are executed first in the frame loop
|
||||
const { throttle: dragThrottle } = useAnimationFrameThrottle();
|
||||
|
||||
watch(
|
||||
() => props.viewportBoundingBox,
|
||||
() => (redraw = true),
|
||||
@@ -59,20 +55,14 @@ const { getWorkflowBoundingBox } = useWorkflowBoundingBox();
|
||||
|
||||
let aabbChanged = false;
|
||||
|
||||
/** transform mapping workflow coordinates to minimap coordinates */
|
||||
let canvasTransform = new Transform();
|
||||
// Workflow-specific: compute padded, squared content bounds
|
||||
const workflowContentBounds = ref(new AxisAlignedBoundingBox());
|
||||
|
||||
function recalculateAABB() {
|
||||
const aabb = getWorkflowBoundingBox();
|
||||
|
||||
aabb.squareCenter();
|
||||
aabb.expand(120);
|
||||
|
||||
// transform canvas to show entire workflow bounding box
|
||||
if (canvas.value) {
|
||||
const scale = canvas.value.width / aabb.width;
|
||||
canvasTransform = new Transform().translate([-aabb.x * scale, -aabb.y * scale]).scale([scale, scale]);
|
||||
}
|
||||
workflowContentBounds.value = aabb;
|
||||
}
|
||||
|
||||
// redraw if any steps or comments change
|
||||
@@ -88,7 +78,6 @@ watch(
|
||||
);
|
||||
|
||||
// these settings are controlled via css, so they can be defined in one common place
|
||||
// this ensures future style changes wont break the minimap's behavior
|
||||
const colors = {
|
||||
node: "#000",
|
||||
error: "#000",
|
||||
@@ -129,10 +118,42 @@ onMounted(async () => {
|
||||
redraw = true;
|
||||
});
|
||||
|
||||
// ── Shared interaction ──
|
||||
|
||||
const minimap: Ref<HTMLElement | null> = ref(null);
|
||||
|
||||
const viewportBoundsRef = computed(() => props.viewportBoundingBox);
|
||||
const parentRight = computed(() => unref(props.viewportBounds.right));
|
||||
const parentBottom = computed(() => unref(props.viewportBounds.bottom));
|
||||
|
||||
const { getCanvasTransform, recomputeTransform, minimapSize } = useMinimapInteraction({
|
||||
canvasRef: canvas,
|
||||
containerRef: minimap,
|
||||
parentRight,
|
||||
parentBottom,
|
||||
contentBounds: workflowContentBounds,
|
||||
viewportBounds: viewportBoundsRef,
|
||||
panBy: (delta) => {
|
||||
if (Object.values(props.steps).length > 0) {
|
||||
emit("panBy", delta);
|
||||
}
|
||||
},
|
||||
moveTo: (pos) => {
|
||||
if (Object.values(props.steps).length > 0) {
|
||||
emit("moveTo", pos);
|
||||
}
|
||||
},
|
||||
storageKey: "overview-size",
|
||||
minSize: size.min,
|
||||
maxSize: size.max,
|
||||
defaultSize: size.default,
|
||||
});
|
||||
|
||||
// for performance reasons, only draw and calculate on animation frames.
|
||||
useAnimationFrame(() => {
|
||||
if (aabbChanged) {
|
||||
recalculateAABB();
|
||||
recomputeTransform();
|
||||
aabbChanged = false;
|
||||
}
|
||||
|
||||
@@ -144,6 +165,7 @@ useAnimationFrame(() => {
|
||||
|
||||
/** Renders the entire minimap to the canvas */
|
||||
function renderMinimap() {
|
||||
const canvasTransform = getCanvasTransform();
|
||||
const ctx = canvas.value!.getContext("2d") as CanvasRenderingContext2D;
|
||||
ctx.resetTransform();
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
@@ -235,80 +257,6 @@ function renderMinimap() {
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// -- Resizing --
|
||||
const minimap: Ref<HTMLCanvasElement | null> = ref(null);
|
||||
const { position: dragHandlePosition, isDragging: isHandleDragging } = useDraggable(minimap, {
|
||||
preventDefault: true,
|
||||
exact: true,
|
||||
});
|
||||
const minimapSize = ref(parseInt(localStorage.getItem("overview-size") || size.default.toString()));
|
||||
|
||||
watch(dragHandlePosition, () => {
|
||||
// resize
|
||||
minimapSize.value = Math.max(
|
||||
unref(props.viewportBounds.right) - dragHandlePosition.value.x,
|
||||
unref(props.viewportBounds.bottom) - dragHandlePosition.value.y,
|
||||
);
|
||||
|
||||
// clamp
|
||||
minimapSize.value = Math.min(Math.max(minimapSize.value, size.min), size.max);
|
||||
});
|
||||
|
||||
watch(isHandleDragging, () => {
|
||||
if (!isHandleDragging.value) {
|
||||
localStorage.setItem("overview-size", minimapSize.value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
// -- Repositioning Viewport --
|
||||
|
||||
/** Scaling factor of the canvas element. Draw size in relation to actual size on screen */
|
||||
const scaleFactor = computed(() => size.max / minimapSize.value);
|
||||
let dragViewport = false;
|
||||
|
||||
useDraggable(canvas, {
|
||||
onStart: (_position, event) => {
|
||||
// minimap coordinates to global coordinates
|
||||
const [x, y] = canvasTransform
|
||||
.inverse()
|
||||
.scale([scaleFactor.value, scaleFactor.value])
|
||||
.apply([event.offsetX, event.offsetY]);
|
||||
|
||||
if (props.viewportBoundingBox.isPointInBounds({ x, y })) {
|
||||
dragViewport = true;
|
||||
}
|
||||
},
|
||||
onMove: (_position, event) => {
|
||||
dragThrottle(() => {
|
||||
if (!dragViewport || Object.values(props.steps).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// minimap coordinates to global coordinates, without translation
|
||||
const [x, y] = canvasTransform
|
||||
.resetTranslation()
|
||||
.inverse()
|
||||
.scale([scaleFactor.value, scaleFactor.value])
|
||||
.apply([-event.movementX, -event.movementY]);
|
||||
|
||||
emit("panBy", { x, y });
|
||||
});
|
||||
},
|
||||
onEnd(_position, event) {
|
||||
// minimap coordinates to global coordinates
|
||||
const [x, y] = canvasTransform
|
||||
.inverse()
|
||||
.scale([scaleFactor.value, scaleFactor.value])
|
||||
.apply([event.offsetX, event.offsetY]);
|
||||
|
||||
if (!dragViewport && Object.values(props.steps).length > 0) {
|
||||
emit("moveTo", { x, y });
|
||||
}
|
||||
|
||||
dragViewport = false;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,76 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import BootstrapVue from "bootstrap-vue";
|
||||
import Vue, { computed } from "vue";
|
||||
|
||||
import { getZoomInLevel, getZoomOutLevel, isMaxZoom, isMinZoom } from "./modules/zoomLevels";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
const props = defineProps({
|
||||
zoomLevel: { type: Number, default: 1 },
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "onZoom", zoom: number): void;
|
||||
}>();
|
||||
|
||||
const zoomDefault = 1;
|
||||
const zoomPercentage = computed(() => Math.round(props.zoomLevel * 100));
|
||||
|
||||
function onZoomIn() {
|
||||
emit("onZoom", getZoomInLevel(props.zoomLevel));
|
||||
}
|
||||
|
||||
function onZoomOut() {
|
||||
emit("onZoom", getZoomOutLevel(props.zoomLevel));
|
||||
}
|
||||
|
||||
function onZoomReset() {
|
||||
emit("onZoom", zoomDefault);
|
||||
}
|
||||
<script lang="ts">
|
||||
// Re-export from shared location for backward compatibility
|
||||
export { default } from "@/components/Graph/ZoomControl.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="zoom-control float-right btn-group-horizontal">
|
||||
<b-button
|
||||
:disabled="isMinZoom(props.zoomLevel)"
|
||||
role="button"
|
||||
class="fa fa-minus"
|
||||
title="Zoom Out"
|
||||
size="sm"
|
||||
aria-label="Zoom Out"
|
||||
@click="onZoomOut" />
|
||||
<b-button
|
||||
v-g-tooltip.hover
|
||||
role="button"
|
||||
class="zoom-reset"
|
||||
variant="light"
|
||||
title="Reset Zoom Level"
|
||||
size="sm"
|
||||
aria-label="Reset Zoom Level"
|
||||
@click="onZoomReset">
|
||||
{{ zoomPercentage }}%
|
||||
</b-button>
|
||||
<b-button
|
||||
:disabled="isMaxZoom(props.zoomLevel)"
|
||||
role="button"
|
||||
class="fa fa-plus"
|
||||
title="Zoom In"
|
||||
size="sm"
|
||||
aria-label="Zoom In"
|
||||
@click="onZoomIn" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zoom-reset {
|
||||
width: 4rem;
|
||||
}
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
bottom: 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 2000;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useWorkflowStores } from "@/composables/workflowStores";
|
||||
|
||||
import { AxisAlignedBoundingBox, type WorkflowTransform } from "../modules/geometry";
|
||||
import { AxisAlignedBoundingBox, type WorkflowTransform } from "@/utils/geometry";
|
||||
|
||||
export interface BoundingBoxBounds {
|
||||
minX: number;
|
||||
|
||||
@@ -10,10 +10,9 @@ import {
|
||||
type WorkflowStepStore,
|
||||
} from "@/stores/workflowStepStore";
|
||||
import { assertDefined } from "@/utils/assertions";
|
||||
import { AxisAlignedBoundingBox, type Rectangle, rectDistance } from "@/utils/geometry";
|
||||
import { match } from "@/utils/utils";
|
||||
|
||||
import { AxisAlignedBoundingBox, type Rectangle, rectDistance } from "./geometry";
|
||||
|
||||
const elk = new ELK();
|
||||
|
||||
export const AUTO_LAYOUT_ORPHAN_EDGE_WARNING_PREFIX = "Auto Layout: skipping edge";
|
||||
|
||||
@@ -1,61 +1,12 @@
|
||||
import type { Last } from "types/utilityTypes";
|
||||
|
||||
export const zoomLevels = [
|
||||
0.1, 0.2, 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.33, 1.5, 2, 2.5, 3, 4, 5,
|
||||
] as const;
|
||||
|
||||
export type ZoomLevel = (typeof zoomLevels)[number];
|
||||
|
||||
export const minZoom = zoomLevels[0];
|
||||
export const maxZoom = zoomLevels[zoomLevels.length - 1] as Last<typeof zoomLevels>;
|
||||
|
||||
/**
|
||||
* Finds the closest snapped zoom level
|
||||
* @param zoom decimal number indicating current zoom multiplier
|
||||
* @returns snapped zoom level
|
||||
*/
|
||||
export function getSnappedZoom(zoom: number): ZoomLevel {
|
||||
return zoomLevels.reduce((a, b) => {
|
||||
return Math.abs(b - zoom) < Math.abs(a - zoom) ? b : a;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next largest zoom level
|
||||
* @param zoom decimal number indicating current zoom multiplier
|
||||
* @returns snapped zoom level
|
||||
*/
|
||||
export function getZoomInLevel(zoom: number): ZoomLevel {
|
||||
const snapped = getSnappedZoom(zoom);
|
||||
const index = zoomLevels.indexOf(snapped);
|
||||
|
||||
if (index === zoomLevels.length - 1) {
|
||||
return snapped;
|
||||
} else {
|
||||
return zoomLevels[index + 1] as ZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next smaller zoom level
|
||||
* @param zoom decimal number indicating current zoom multiplier
|
||||
* @returns snapped zoom level
|
||||
*/
|
||||
export function getZoomOutLevel(zoom: number): ZoomLevel {
|
||||
const snapped = getSnappedZoom(zoom);
|
||||
const index = zoomLevels.indexOf(snapped);
|
||||
|
||||
if (index === 0) {
|
||||
return snapped;
|
||||
} else {
|
||||
return zoomLevels[index - 1] as ZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
export function isMinZoom(zoom: number): boolean {
|
||||
return zoom === minZoom;
|
||||
}
|
||||
|
||||
export function isMaxZoom(zoom: number): boolean {
|
||||
return zoom === maxZoom;
|
||||
}
|
||||
// Re-export from shared location
|
||||
export type { ZoomLevel } from "@/utils/zoomLevels";
|
||||
export {
|
||||
getSnappedZoom,
|
||||
getZoomInLevel,
|
||||
getZoomOutLevel,
|
||||
isMaxZoom,
|
||||
isMinZoom,
|
||||
maxZoom,
|
||||
minZoom,
|
||||
zoomLevels,
|
||||
} from "@/utils/zoomLevels";
|
||||
|
||||
+6
-4
@@ -1,10 +1,13 @@
|
||||
import type { UseScrollReturn } from "@vueuse/core";
|
||||
import { select } from "d3-selection";
|
||||
import { type D3ZoomEvent, zoom, zoomIdentity } from "d3-zoom";
|
||||
import { type Ref, ref, watch } from "vue";
|
||||
|
||||
import type { Vector, WorkflowTransform } from "@/components/Workflow/Editor/modules/geometry";
|
||||
import type { XYPosition } from "@/stores/workflowEditorStateStore";
|
||||
import type { Vector, WorkflowTransform } from "@/utils/geometry";
|
||||
|
||||
export interface XYPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// if element is draggable it may implement its own drag handler,
|
||||
// but d3zoom would call preventDefault
|
||||
@@ -31,7 +34,6 @@ export function useD3Zoom(
|
||||
minZoom: number,
|
||||
maxZoom: number,
|
||||
targetRef: Ref<HTMLElement | null>,
|
||||
scroll: UseScrollReturn,
|
||||
initialPan: XYPosition = { x: 0, y: 0 },
|
||||
) {
|
||||
const transform = ref<WorkflowTransform>({ x: initialPan.x, y: initialPan.y, k: k });
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useDraggable } from "@vueuse/core";
|
||||
import { computed, type Ref, ref, unref, watch } from "vue";
|
||||
|
||||
import { useAnimationFrameThrottle } from "@/composables/throttle";
|
||||
import { type AxisAlignedBoundingBox, Transform } from "@/utils/geometry";
|
||||
|
||||
export interface MinimapInteractionOptions {
|
||||
/** Canvas element for pointer interaction */
|
||||
canvasRef: Ref<HTMLCanvasElement | null>;
|
||||
/** Container element for resize dragging */
|
||||
containerRef: Ref<HTMLElement | null>;
|
||||
/** Reactive right/bottom edges of the parent element (for resize computation) */
|
||||
parentRight: Ref<number>;
|
||||
parentBottom: Ref<number>;
|
||||
/** Final content bounding box (consumer owns padding/squaring policy) */
|
||||
contentBounds: Ref<AxisAlignedBoundingBox>;
|
||||
/** Viewport bounding box in content coordinates */
|
||||
viewportBounds: Ref<AxisAlignedBoundingBox>;
|
||||
/** Called with content-coordinate delta when user drags the viewport */
|
||||
panBy: (delta: { x: number; y: number }) => void;
|
||||
/** Called with content-coordinate position when user clicks outside viewport */
|
||||
moveTo: (pos: { x: number; y: number }) => void;
|
||||
/** localStorage key for resize persistence */
|
||||
storageKey: string;
|
||||
/** Minimum minimap display size in pixels */
|
||||
minSize: number;
|
||||
/** Maximum minimap display size in pixels (also used as canvas internal resolution) */
|
||||
maxSize: number;
|
||||
/** Default minimap display size in pixels */
|
||||
defaultSize: number;
|
||||
}
|
||||
|
||||
export function useMinimapInteraction(options: MinimapInteractionOptions) {
|
||||
const {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
parentRight,
|
||||
parentBottom,
|
||||
contentBounds,
|
||||
viewportBounds,
|
||||
panBy,
|
||||
moveTo,
|
||||
storageKey,
|
||||
minSize,
|
||||
maxSize,
|
||||
defaultSize,
|
||||
} = options;
|
||||
|
||||
// ── Transform: content coordinates → minimap canvas pixels ──
|
||||
|
||||
let canvasTransform = new Transform();
|
||||
|
||||
function recomputeTransform() {
|
||||
const canvas = unref(canvasRef);
|
||||
const bounds = unref(contentBounds);
|
||||
if (!canvas || bounds.width === 0 || bounds.height === 0) {
|
||||
canvasTransform = new Transform();
|
||||
return;
|
||||
}
|
||||
const scaleX = canvas.width / bounds.width;
|
||||
const scaleY = canvas.height / bounds.height;
|
||||
const s = Math.min(scaleX, scaleY);
|
||||
canvasTransform = new Transform().translate([-bounds.x * s, -bounds.y * s]).scale([s, s]);
|
||||
}
|
||||
|
||||
watch(contentBounds, recomputeTransform, { deep: true });
|
||||
|
||||
function getCanvasTransform(): Transform {
|
||||
return canvasTransform;
|
||||
}
|
||||
|
||||
// ── Resize ──
|
||||
|
||||
const minimapSize = ref(parseInt(localStorage.getItem(storageKey) || defaultSize.toString()));
|
||||
|
||||
const { position: dragHandlePosition, isDragging: isHandleDragging } = useDraggable(containerRef, {
|
||||
preventDefault: true,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const scaleFactor = computed(() => maxSize / minimapSize.value);
|
||||
|
||||
watch(dragHandlePosition, () => {
|
||||
const newSize = Math.max(
|
||||
unref(parentRight) - dragHandlePosition.value.x,
|
||||
unref(parentBottom) - dragHandlePosition.value.y,
|
||||
);
|
||||
minimapSize.value = Math.min(Math.max(newSize, minSize), maxSize);
|
||||
});
|
||||
|
||||
watch(isHandleDragging, () => {
|
||||
if (!isHandleDragging.value) {
|
||||
localStorage.setItem(storageKey, minimapSize.value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
// ── Pointer interaction: click-to-center / drag-to-pan ──
|
||||
|
||||
const { throttle: dragThrottle } = useAnimationFrameThrottle();
|
||||
let dragViewport = false;
|
||||
|
||||
function minimapToContent(offsetX: number, offsetY: number): [number, number] {
|
||||
return canvasTransform.inverse().scale([scaleFactor.value, scaleFactor.value]).apply([offsetX, offsetY]);
|
||||
}
|
||||
|
||||
function minimapDeltaToContent(movementX: number, movementY: number): [number, number] {
|
||||
return canvasTransform
|
||||
.resetTranslation()
|
||||
.inverse()
|
||||
.scale([scaleFactor.value, scaleFactor.value])
|
||||
.apply([-movementX, -movementY]);
|
||||
}
|
||||
|
||||
useDraggable(canvasRef, {
|
||||
onStart: (_position, event) => {
|
||||
const [x, y] = minimapToContent(event.offsetX, event.offsetY);
|
||||
if (unref(viewportBounds).isPointInBounds({ x, y })) {
|
||||
dragViewport = true;
|
||||
}
|
||||
},
|
||||
onMove: (_position, event) => {
|
||||
dragThrottle(() => {
|
||||
if (!dragViewport) {
|
||||
return;
|
||||
}
|
||||
const [x, y] = minimapDeltaToContent(event.movementX, event.movementY);
|
||||
panBy({ x, y });
|
||||
});
|
||||
},
|
||||
onEnd: (_position, event) => {
|
||||
if (!dragViewport) {
|
||||
const [x, y] = minimapToContent(event.offsetX, event.offsetY);
|
||||
moveTo({ x, y });
|
||||
}
|
||||
dragViewport = false;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getCanvasTransform,
|
||||
recomputeTransform,
|
||||
minimapSize,
|
||||
};
|
||||
}
|
||||
+1
-2
@@ -2,8 +2,7 @@ import type { UseElementBoundingReturn } from "@vueuse/core";
|
||||
import { type Ref, ref, unref, watch } from "vue";
|
||||
|
||||
import { useAnimationFrameThrottle } from "@/composables/throttle";
|
||||
|
||||
import { AxisAlignedBoundingBox } from "../modules/geometry";
|
||||
import { AxisAlignedBoundingBox } from "@/utils/geometry";
|
||||
|
||||
/**
|
||||
* Constructs a bounding box following the editors pan and zoom in
|
||||
@@ -43,6 +43,7 @@ import GridVisualization from "@/components/Grid/GridVisualization.vue";
|
||||
import HelpTerm from "@/components/Help/HelpTerm.vue";
|
||||
import HistoryArchiveWizard from "@/components/History/Archiving/HistoryArchiveWizard.vue";
|
||||
import HistoryExportTasks from "@/components/History/Export/HistoryExport.vue";
|
||||
import HistoryGraphView from "@/components/History/Graph/HistoryGraphView.vue";
|
||||
import HistoryAccessibility from "@/components/History/HistoryAccessibility.vue";
|
||||
import HistoryDatasetPermissions from "@/components/History/HistoryDatasetPermissions.vue";
|
||||
import HistoryList from "@/components/History/HistoryList.vue";
|
||||
@@ -426,6 +427,14 @@ export function getRouter(Galaxy) {
|
||||
component: WorkflowExtractionForm,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "histories/:historyId/graph",
|
||||
component: HistoryGraphView,
|
||||
props: (route) => ({
|
||||
historyId: route.params.historyId,
|
||||
seedNodeId: route.query.seed || undefined,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "interactivetool_entry_points/list",
|
||||
component: InteractiveTools,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, del, ref, set } from "vue";
|
||||
|
||||
import type { Color } from "@/components/Workflow/Editor/Comments/colors";
|
||||
import { assertDefined } from "@/utils/assertions";
|
||||
import {
|
||||
AxisAlignedBoundingBox,
|
||||
type Rectangle,
|
||||
@@ -10,8 +11,7 @@ import {
|
||||
vecReduceFigures,
|
||||
vecSubtract,
|
||||
type Vector,
|
||||
} from "@/components/Workflow/Editor/modules/geometry";
|
||||
import { assertDefined } from "@/utils/assertions";
|
||||
} from "@/utils/geometry";
|
||||
import { hasKeys, match } from "@/utils/utils";
|
||||
|
||||
import { defineScopedStore } from "./scopedStore";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { UseElementBoundingReturn } from "@vueuse/core";
|
||||
import { computed, reactive, ref, set, type UnwrapRef } from "vue";
|
||||
|
||||
import type { Rectangle } from "@/components/Workflow/Editor/modules/geometry";
|
||||
import type { OutputTerminals } from "@/components/Workflow/Editor/modules/terminals";
|
||||
import reportDefault from "@/components/Workflow/Editor/reportDefault";
|
||||
import type { Rectangle } from "@/utils/geometry";
|
||||
|
||||
import { defineScopedStore } from "./scopedStore";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { computed, getCurrentScope, onScopeDispose, reactive, ref, watch } from "vue";
|
||||
|
||||
import type { Rectangle } from "@/components/Workflow/Editor/modules/geometry";
|
||||
import { useMagicKeys } from "@/composables/useMagicKeys";
|
||||
import { useUserLocalStorage } from "@/composables/userLocalStorage";
|
||||
import type { Rectangle } from "@/utils/geometry";
|
||||
|
||||
import { defineScopedStore } from "./scopedStore";
|
||||
import type { WorkflowCommentColor } from "./workflowEditorCommentStore";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
import { type Rectangle, Transform } from "@/components/Workflow/Editor/modules/geometry";
|
||||
import { defineScopedStore } from "@/stores/scopedStore";
|
||||
import { useUndoRedoStore } from "@/stores/undoRedoStore";
|
||||
import {
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
useWorkflowStepStore,
|
||||
} from "@/stores/workflowStepStore";
|
||||
import { assertDefined } from "@/utils/assertions";
|
||||
import { type Rectangle, Transform } from "@/utils/geometry";
|
||||
|
||||
export type SearchData =
|
||||
| {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Shared SVG path generation utilities for graph edge rendering.
|
||||
*
|
||||
* Two rendering strategies:
|
||||
* - curveBasisPath: smooth bezier curves via d3 curveBasis (used by workflow editor)
|
||||
* - orthogonalPath: straight segments with arc-rounded bends (used for ELK-routed graphs)
|
||||
*
|
||||
* Both accept point arrays and return SVG path `d` attribute strings.
|
||||
*/
|
||||
|
||||
import { curveBasis, line } from "d3";
|
||||
|
||||
/** Point with x/y coordinates */
|
||||
export interface PathPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// ── curveBasis rendering ──
|
||||
|
||||
const _curveBasisLine = line().curve(curveBasis);
|
||||
|
||||
/**
|
||||
* Render points as a smooth bezier curve using d3's curveBasis interpolation.
|
||||
* Used by the workflow editor for connections between nodes.
|
||||
*/
|
||||
export function curveBasisPath(points: [number, number][]): string {
|
||||
return _curveBasisLine(points) ?? "";
|
||||
}
|
||||
|
||||
// ── Orthogonal rendering with arc bends ──
|
||||
|
||||
const DEFAULT_RADIUS = 8;
|
||||
|
||||
/**
|
||||
* Render orthogonal edge points as an SVG path with rounded corners.
|
||||
* Uses arc segments at each bend point to produce smooth turns while
|
||||
* faithfully following the layout engine's routing.
|
||||
*
|
||||
* Used for ELK-routed graph edges where points form orthogonal paths.
|
||||
*/
|
||||
export function orthogonalPath(points: PathPoint[], radius: number = DEFAULT_RADIUS): string {
|
||||
if (points.length < 2) {
|
||||
return "";
|
||||
}
|
||||
if (points.length === 2) {
|
||||
return `M ${points[0]!.x} ${points[0]!.y} L ${points[1]!.x} ${points[1]!.y}`;
|
||||
}
|
||||
|
||||
const parts: string[] = [`M ${points[0]!.x} ${points[0]!.y}`];
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const prev = points[i - 1]!;
|
||||
const curr = points[i]!;
|
||||
const next = points[i + 1]!;
|
||||
|
||||
const dx1 = prev.x - curr.x;
|
||||
const dy1 = prev.y - curr.y;
|
||||
const dx2 = next.x - curr.x;
|
||||
const dy2 = next.y - curr.y;
|
||||
|
||||
const d1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
||||
const d2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
||||
|
||||
const r = Math.min(radius, d1 / 2, d2 / 2);
|
||||
|
||||
const startX = curr.x + (dx1 / d1) * r;
|
||||
const startY = curr.y + (dy1 / d1) * r;
|
||||
const endX = curr.x + (dx2 / d2) * r;
|
||||
const endY = curr.y + (dy2 / d2) * r;
|
||||
|
||||
const cross = dx1 * dy2 - dy1 * dx2;
|
||||
const sweep = cross > 0 ? 0 : 1;
|
||||
|
||||
parts.push(`L ${startX} ${startY}`);
|
||||
parts.push(`A ${r} ${r} 0 0 ${sweep} ${endX} ${endY}`);
|
||||
}
|
||||
|
||||
const last = points[points.length - 1]!;
|
||||
parts.push(`L ${last.x} ${last.y}`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ── Control point computation ──
|
||||
|
||||
const BASE_LINE_SHIFT = 15;
|
||||
const LINE_SHIFT_GROW_X = 0.15;
|
||||
const LINE_SHIFT_GROW_Y = 0.08;
|
||||
|
||||
/**
|
||||
* Compute bezier control points for a connection between two endpoints.
|
||||
* Uses the same algorithm as the Galaxy workflow editor (SVGConnection.vue):
|
||||
* forward connections get 4 control points, backward connections get 6
|
||||
* for smoother curves.
|
||||
*/
|
||||
export function computeControlPoints(startX: number, startY: number, endX: number, endY: number): [number, number][] {
|
||||
const forward = endX >= startX;
|
||||
const distX = Math.abs(endX - startX - BASE_LINE_SHIFT);
|
||||
const distY = Math.abs(endY - startY);
|
||||
|
||||
let shiftX: number;
|
||||
if (forward) {
|
||||
shiftX = BASE_LINE_SHIFT + distX * LINE_SHIFT_GROW_X + distY * LINE_SHIFT_GROW_Y;
|
||||
} else {
|
||||
shiftX = BASE_LINE_SHIFT * 2 + distX * LINE_SHIFT_GROW_X * 0.5 + distY * LINE_SHIFT_GROW_Y * 0.5;
|
||||
}
|
||||
|
||||
if (forward) {
|
||||
return [
|
||||
[startX, startY],
|
||||
[startX + shiftX, startY],
|
||||
[endX - shiftX, endY],
|
||||
[endX, endY],
|
||||
];
|
||||
} else {
|
||||
const shiftY = (endY - startY) / 2;
|
||||
return [
|
||||
[startX, startY],
|
||||
[startX + shiftX, startY],
|
||||
[startX + shiftX, startY + shiftY],
|
||||
[endX - shiftX, endY - shiftY],
|
||||
[endX - shiftX, endY],
|
||||
[endX, endY],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Last } from "types/utilityTypes";
|
||||
|
||||
export const zoomLevels = [
|
||||
0.1, 0.2, 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.33, 1.5, 2, 2.5, 3, 4, 5,
|
||||
] as const;
|
||||
|
||||
export type ZoomLevel = (typeof zoomLevels)[number];
|
||||
|
||||
export const minZoom = zoomLevels[0];
|
||||
export const maxZoom = zoomLevels[zoomLevels.length - 1] as Last<typeof zoomLevels>;
|
||||
|
||||
/**
|
||||
* Finds the closest snapped zoom level
|
||||
* @param zoom decimal number indicating current zoom multiplier
|
||||
* @returns snapped zoom level
|
||||
*/
|
||||
export function getSnappedZoom(zoom: number): ZoomLevel {
|
||||
return zoomLevels.reduce((a, b) => {
|
||||
return Math.abs(b - zoom) < Math.abs(a - zoom) ? b : a;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next largest zoom level
|
||||
* @param zoom decimal number indicating current zoom multiplier
|
||||
* @returns snapped zoom level
|
||||
*/
|
||||
export function getZoomInLevel(zoom: number): ZoomLevel {
|
||||
const snapped = getSnappedZoom(zoom);
|
||||
const index = zoomLevels.indexOf(snapped);
|
||||
|
||||
if (index === zoomLevels.length - 1) {
|
||||
return snapped;
|
||||
} else {
|
||||
return zoomLevels[index + 1] as ZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next smaller zoom level
|
||||
* @param zoom decimal number indicating current zoom multiplier
|
||||
* @returns snapped zoom level
|
||||
*/
|
||||
export function getZoomOutLevel(zoom: number): ZoomLevel {
|
||||
const snapped = getSnappedZoom(zoom);
|
||||
const index = zoomLevels.indexOf(snapped);
|
||||
|
||||
if (index === 0) {
|
||||
return snapped;
|
||||
} else {
|
||||
return zoomLevels[index - 1] as ZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
export function isMinZoom(zoom: number): boolean {
|
||||
return zoom === minZoom;
|
||||
}
|
||||
|
||||
export function isMaxZoom(zoom: number): boolean {
|
||||
return zoom === maxZoom;
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
"""Bounded user-action provenance graph for Galaxy histories.
|
||||
|
||||
For each selected top-level history item, resolves its producing
|
||||
tool_request and that tool_request's declared inputs from the
|
||||
submission payload, one hop out from the seed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import (
|
||||
Literal,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
|
||||
from boltons.iterutils import remap
|
||||
from sqlalchemy import (
|
||||
Select,
|
||||
select,
|
||||
union_all,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.expression import literal_column
|
||||
|
||||
from galaxy.exceptions import (
|
||||
MessageException,
|
||||
RequestParameterInvalidException,
|
||||
)
|
||||
from galaxy.model import (
|
||||
Dataset,
|
||||
DatasetCollection,
|
||||
DatasetCollectionElement,
|
||||
HistoryDatasetAssociation,
|
||||
HistoryDatasetCollectionAssociation,
|
||||
Job,
|
||||
JobToOutputDatasetAssociation,
|
||||
JobToOutputDatasetCollectionAssociation,
|
||||
ToolRequest,
|
||||
)
|
||||
from galaxy.schema.history_graph import (
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
HistoryGraphResponse,
|
||||
TruncationInfo,
|
||||
)
|
||||
from galaxy.schema.schema import DataItemSourceType
|
||||
from galaxy.security.idencoding import IdEncodingHelper
|
||||
from galaxy.structured_app import MinimalManagerApp
|
||||
from galaxy.tool_util.toolbox import AbstractToolBox
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TYPE_RANK = {"dataset": 0, "collection": 1, "tool_request": 2}
|
||||
EDGE_TYPE_RANK = {"dataset_input": 0, "dataset_output": 1, "collection_input": 2, "collection_output": 3}
|
||||
NODE_TYPE_PREFIX = {"dataset": "d", "collection": "c", "tool_request": "r"}
|
||||
MAX_LIMIT = 1000
|
||||
|
||||
|
||||
class HistoryGraphManager:
|
||||
def __init__(self, app: MinimalManagerApp):
|
||||
self.app = app
|
||||
|
||||
def build(
|
||||
self,
|
||||
sa_session: Session,
|
||||
history_id: int,
|
||||
limit: int = 500,
|
||||
include_deleted: bool = False,
|
||||
seed: Optional[str] = None,
|
||||
direction: Literal["backward", "forward", "both"] = "both",
|
||||
depth: int = 5,
|
||||
seed_scope_hid: Optional[int] = None,
|
||||
) -> HistoryGraphResponse:
|
||||
return HistoryGraphBuilder(
|
||||
sa_session=sa_session,
|
||||
security=self.app.security,
|
||||
toolbox=self.app.toolbox,
|
||||
history_id=history_id,
|
||||
limit=limit,
|
||||
include_deleted=include_deleted,
|
||||
seed=seed,
|
||||
direction=direction,
|
||||
depth=depth,
|
||||
seed_scope_hid=seed_scope_hid,
|
||||
).build()
|
||||
|
||||
|
||||
class HistoryGraphBuilder:
|
||||
"""Builds a provenance graph from selected history items.
|
||||
|
||||
Behaviors:
|
||||
- HDA producer edges come from JobToOutputDatasetAssociation joined
|
||||
to Job.
|
||||
- HDCA producer edges come from JobToOutputDatasetCollectionAssociation
|
||||
joined to Job.
|
||||
- Input edges come from the tool_request.request submission payload.
|
||||
- When an item resolves to more than one distinct producing
|
||||
tool_request, the item is kept as a node and the producer edge is
|
||||
skipped with a debug log.
|
||||
- Malformed payloads are logged at debug level and the item becomes
|
||||
a node with no input edges.
|
||||
- Hidden HDAs that belong to a collection are filtered before
|
||||
producer lookup and surfaced via their parent HDCA instead.
|
||||
- ``limit`` is clamped to ``MAX_LIMIT`` to keep per-request work
|
||||
bounded regardless of caller input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sa_session: Session,
|
||||
security: IdEncodingHelper,
|
||||
history_id: int,
|
||||
limit: int = 500,
|
||||
toolbox: Optional[AbstractToolBox] = None,
|
||||
include_deleted: bool = False,
|
||||
seed: Optional[str] = None,
|
||||
direction: Literal["backward", "forward", "both"] = "both",
|
||||
depth: int = 5,
|
||||
seed_scope_hid: Optional[int] = None,
|
||||
):
|
||||
self.sa_session = sa_session
|
||||
self.security = security
|
||||
self.history_id = history_id
|
||||
self.limit = min(limit, MAX_LIMIT)
|
||||
self.toolbox = toolbox
|
||||
self.include_deleted = include_deleted
|
||||
self.seed = seed
|
||||
self.direction = direction
|
||||
self.depth = depth
|
||||
self.seed_scope_hid = seed_scope_hid
|
||||
self._older_than_hid: Optional[int] = None
|
||||
self._newer_than_hid: Optional[int] = None
|
||||
self._sort_keys: dict[str, tuple[int, int]] = {}
|
||||
|
||||
def build(self) -> HistoryGraphResponse:
|
||||
truncation = TruncationInfo()
|
||||
if self.seed_scope_hid is not None:
|
||||
truncation.scope_type = "seed_centered"
|
||||
half = self.limit // 2
|
||||
self._older_than_hid = self.seed_scope_hid + half + (self.limit % 2)
|
||||
self._newer_than_hid = self.seed_scope_hid - half - 1
|
||||
|
||||
# 1. Select top-level items in scope.
|
||||
dataset_ids, collection_ids, item_capped = self._select_items()
|
||||
truncation.item_count_capped = item_capped
|
||||
|
||||
# 2. Remove hidden collection elements.
|
||||
dataset_ids = self._remove_hidden_elements(dataset_ids)
|
||||
|
||||
# 3. Producer lookup + payload input resolution.
|
||||
edges: list[GraphEdge] = []
|
||||
tr_nodes: dict[int, Optional[str]] = {} # tr_id -> tool_id
|
||||
closure_dataset_ids: set[int] = set()
|
||||
closure_collection_ids: set[int] = set()
|
||||
|
||||
hda_producers = self._hda_producers(dataset_ids)
|
||||
hdca_producers = self._hdca_producers(collection_ids)
|
||||
all_producers = {**hda_producers, **hdca_producers}
|
||||
|
||||
# Emit output edges and collect tr_ids.
|
||||
for item_key, (tr_id, tool_id) in all_producers.items():
|
||||
item_type, item_id = item_key
|
||||
tr_nodes[tr_id] = tool_id
|
||||
src = self._encode("tool_request", tr_id)
|
||||
tgt = self._encode(item_type, item_id)
|
||||
etype = "dataset_output" if item_type == "dataset" else "collection_output"
|
||||
edges.append(GraphEdge(source=src, target=tgt, type=etype))
|
||||
|
||||
# Batch-fetch all payloads, parse inputs, emit input edges.
|
||||
payloads = self._fetch_payloads(set(tr_nodes.keys()))
|
||||
for tr_id, payload in payloads.items():
|
||||
input_refs = self._extract_inputs(payload)
|
||||
for ref_type, ref_id in input_refs:
|
||||
src = self._encode(ref_type, ref_id)
|
||||
tgt = self._encode("tool_request", tr_id)
|
||||
etype = "dataset_input" if ref_type == "dataset" else "collection_input"
|
||||
edges.append(GraphEdge(source=src, target=tgt, type=etype))
|
||||
if ref_type == "dataset" and ref_id not in dataset_ids:
|
||||
closure_dataset_ids.add(ref_id)
|
||||
elif ref_type == "collection" and ref_id not in collection_ids:
|
||||
closure_collection_ids.add(ref_id)
|
||||
|
||||
# 4. Filter closure items by the same deleted policy as seed selection.
|
||||
if not self.include_deleted and (closure_dataset_ids or closure_collection_ids):
|
||||
closure_dataset_ids = self._filter_deleted_ids(HistoryDatasetAssociation, closure_dataset_ids)
|
||||
closure_collection_ids = self._filter_deleted_ids(
|
||||
HistoryDatasetCollectionAssociation, closure_collection_ids
|
||||
)
|
||||
|
||||
# Build nodes.
|
||||
all_dataset_ids = dataset_ids | closure_dataset_ids
|
||||
all_collection_ids = collection_ids | closure_collection_ids
|
||||
nodes: list[GraphNode] = []
|
||||
nodes.extend(self._dataset_nodes(all_dataset_ids))
|
||||
nodes.extend(self._collection_nodes(all_collection_ids))
|
||||
nodes.extend(self._tr_nodes(tr_nodes))
|
||||
|
||||
# Invariant: every edge endpoint must be a node. Edges were emitted
|
||||
# before closure deletion filtering, so drop any that now reference
|
||||
# an item that did not survive.
|
||||
node_id_set = {n.id for n in nodes}
|
||||
edges = [e for e in edges if e.source in node_id_set and e.target in node_id_set]
|
||||
|
||||
# 5. Resolve tool names.
|
||||
self._resolve_tool_names(nodes)
|
||||
|
||||
# 6. Optional seed subgraph filter (in-memory only).
|
||||
if self.seed:
|
||||
node_ids = {n.id for n in nodes}
|
||||
truncation.seed_in_scope = self.seed in node_ids
|
||||
nodes, edges = self._seed_filter(self.seed, nodes, edges)
|
||||
|
||||
# 7. Sort.
|
||||
nodes, edges = self._sort(nodes, edges)
|
||||
return HistoryGraphResponse(nodes=nodes, edges=edges, truncated=truncation)
|
||||
|
||||
# ── Item selection ──
|
||||
|
||||
def _select_items(self) -> tuple[set[int], set[int], bool]:
|
||||
hda_q: Select = select(
|
||||
HistoryDatasetAssociation.id,
|
||||
HistoryDatasetAssociation.hid,
|
||||
literal_column("'dataset'").label("item_type"),
|
||||
).where(HistoryDatasetAssociation.history_id == self.history_id)
|
||||
if not self.include_deleted:
|
||||
hda_q = hda_q.where(HistoryDatasetAssociation.deleted == False) # noqa: E712
|
||||
if self._older_than_hid is not None:
|
||||
hda_q = hda_q.where(HistoryDatasetAssociation.hid < self._older_than_hid)
|
||||
if self._newer_than_hid is not None:
|
||||
hda_q = hda_q.where(HistoryDatasetAssociation.hid > self._newer_than_hid)
|
||||
|
||||
hdca_q: Select = select(
|
||||
HistoryDatasetCollectionAssociation.id,
|
||||
HistoryDatasetCollectionAssociation.hid,
|
||||
literal_column("'collection'").label("item_type"),
|
||||
).where(HistoryDatasetCollectionAssociation.history_id == self.history_id)
|
||||
if not self.include_deleted:
|
||||
hdca_q = hdca_q.where(HistoryDatasetCollectionAssociation.deleted == False) # noqa: E712
|
||||
if self._older_than_hid is not None:
|
||||
hdca_q = hdca_q.where(HistoryDatasetCollectionAssociation.hid < self._older_than_hid)
|
||||
if self._newer_than_hid is not None:
|
||||
hdca_q = hdca_q.where(HistoryDatasetCollectionAssociation.hid > self._newer_than_hid)
|
||||
|
||||
combined = union_all(hda_q, hdca_q).subquery()
|
||||
# Deterministic order: hid desc, then id desc as a stable tiebreaker
|
||||
# so duplicate hids (theoretically possible across imported data) do
|
||||
# not affect internal selection stability.
|
||||
stmt = (
|
||||
select(combined.c.id, combined.c.hid, combined.c.item_type)
|
||||
.order_by(combined.c.hid.desc(), combined.c.id.desc())
|
||||
.limit(self.limit + 1)
|
||||
)
|
||||
|
||||
dataset_ids: set[int] = set()
|
||||
collection_ids: set[int] = set()
|
||||
count = 0
|
||||
for row in self.sa_session.execute(stmt):
|
||||
count += 1
|
||||
if count > self.limit:
|
||||
break
|
||||
if row.item_type == "dataset":
|
||||
dataset_ids.add(row.id)
|
||||
else:
|
||||
collection_ids.add(row.id)
|
||||
return dataset_ids, collection_ids, count > self.limit
|
||||
|
||||
def _remove_hidden_elements(self, dataset_ids: set[int]) -> set[int]:
|
||||
if not dataset_ids:
|
||||
return dataset_ids
|
||||
stmt = (
|
||||
select(HistoryDatasetAssociation.id)
|
||||
.join(DatasetCollectionElement, DatasetCollectionElement.hda_id == HistoryDatasetAssociation.id)
|
||||
.where(
|
||||
HistoryDatasetAssociation.id.in_(dataset_ids),
|
||||
HistoryDatasetAssociation.visible == False, # noqa: E712
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
to_remove = {row.id for row in self.sa_session.execute(stmt)}
|
||||
return dataset_ids - to_remove
|
||||
|
||||
def _filter_deleted_ids(
|
||||
self,
|
||||
model_cls: Union[type[HistoryDatasetAssociation], type[HistoryDatasetCollectionAssociation]],
|
||||
ids: set[int],
|
||||
) -> set[int]:
|
||||
"""Return the subset of ``ids`` whose rows are not marked deleted.
|
||||
|
||||
Used to apply the seed-side deleted policy to closure items
|
||||
pulled in via payload refs."""
|
||||
if not ids:
|
||||
return ids
|
||||
stmt = select(model_cls.id).where(
|
||||
model_cls.id.in_(ids),
|
||||
model_cls.deleted == False, # noqa: E712
|
||||
)
|
||||
return {row.id for row in self.sa_session.execute(stmt)}
|
||||
|
||||
# ── Producer lookup ──
|
||||
|
||||
def _hda_producers(self, dataset_ids: set[int]) -> dict[tuple[str, int], tuple[int, str]]:
|
||||
"""Resolve each HDA's producing (tool_request_id, tool_id) by
|
||||
joining JobToOutputDatasetAssociation to Job. Only HDAs with
|
||||
exactly one distinct producing tool_request are returned;
|
||||
ambiguous ones are logged at debug level."""
|
||||
if not dataset_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(
|
||||
JobToOutputDatasetAssociation.dataset_id.label("hda_id"),
|
||||
Job.tool_request_id,
|
||||
Job.tool_id,
|
||||
)
|
||||
.join(Job, Job.id == JobToOutputDatasetAssociation.job_id)
|
||||
.where(
|
||||
JobToOutputDatasetAssociation.dataset_id.in_(dataset_ids),
|
||||
Job.tool_request_id.isnot(None),
|
||||
Job.tool_id.isnot(None),
|
||||
Job.tool_id != "__DATA_FETCH__",
|
||||
)
|
||||
)
|
||||
candidates: dict[int, dict[int, str]] = {} # hda_id -> {tr_id: tool_id}
|
||||
for row in self.sa_session.execute(stmt):
|
||||
candidates.setdefault(row.hda_id, {})[row.tool_request_id] = row.tool_id
|
||||
|
||||
result: dict[tuple[str, int], tuple[int, str]] = {}
|
||||
for hda_id, tr_map in candidates.items():
|
||||
if len(tr_map) == 1:
|
||||
tr_id, tool_id = next(iter(tr_map.items()))
|
||||
result[("dataset", hda_id)] = (tr_id, tool_id)
|
||||
else:
|
||||
log.debug("history_graph: skipping HDA %d — ambiguous producer (%s)", hda_id, set(tr_map.keys()))
|
||||
return result
|
||||
|
||||
def _hdca_producers(self, collection_ids: set[int]) -> dict[tuple[str, int], tuple[int, str]]:
|
||||
"""Resolve each HDCA's producing (tool_request_id, tool_id) by
|
||||
joining JobToOutputDatasetCollectionAssociation to Job. Only
|
||||
HDCAs with exactly one distinct producing tool_request are
|
||||
returned; ambiguous ones are logged at debug level."""
|
||||
if not collection_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(
|
||||
JobToOutputDatasetCollectionAssociation.dataset_collection_id.label("hdca_id"),
|
||||
Job.tool_request_id,
|
||||
Job.tool_id,
|
||||
)
|
||||
.join(Job, Job.id == JobToOutputDatasetCollectionAssociation.job_id)
|
||||
.where(
|
||||
JobToOutputDatasetCollectionAssociation.dataset_collection_id.in_(collection_ids),
|
||||
Job.tool_request_id.isnot(None),
|
||||
Job.tool_id.isnot(None),
|
||||
Job.tool_id != "__DATA_FETCH__",
|
||||
)
|
||||
)
|
||||
candidates: dict[int, dict[int, str]] = {}
|
||||
for row in self.sa_session.execute(stmt):
|
||||
candidates.setdefault(row.hdca_id, {})[row.tool_request_id] = row.tool_id
|
||||
|
||||
result: dict[tuple[str, int], tuple[int, str]] = {}
|
||||
for hdca_id, tr_map in candidates.items():
|
||||
if len(tr_map) == 1:
|
||||
tr_id, tool_id = next(iter(tr_map.items()))
|
||||
result[("collection", hdca_id)] = (tr_id, tool_id)
|
||||
else:
|
||||
log.debug("history_graph: skipping HDCA %d — ambiguous producer (%s)", hdca_id, set(tr_map.keys()))
|
||||
return result
|
||||
|
||||
# ── Payload input resolution ──
|
||||
|
||||
def _fetch_payloads(self, tr_ids: set[int]) -> dict[int, dict]:
|
||||
"""Return raw ``tool_request.request`` payloads keyed by tool
|
||||
request id. Rows whose ``request`` field is not a dict (after
|
||||
decoding a JSON string form) are silently dropped with a debug
|
||||
log, on the assumption that a malformed payload contributes no
|
||||
usable input refs."""
|
||||
if not tr_ids:
|
||||
return {}
|
||||
stmt = select(ToolRequest.id, ToolRequest.request).where(ToolRequest.id.in_(tr_ids))
|
||||
result: dict[int, dict] = {}
|
||||
for row in self.sa_session.execute(stmt):
|
||||
try:
|
||||
payload = json.loads(row.request) if isinstance(row.request, str) else row.request
|
||||
if isinstance(payload, dict):
|
||||
result[row.id] = payload
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
log.debug("history_graph: malformed payload for tool_request %d", row.id)
|
||||
return result
|
||||
|
||||
def _extract_inputs(self, payload: dict) -> set[tuple[str, int]]:
|
||||
"""Sole payload entry point inside the builder. Walks a single
|
||||
``tool_request.request`` payload and returns the deduplicated
|
||||
set of input refs the builder will emit edges for.
|
||||
|
||||
Contract, in order:
|
||||
1. Extract declared ``{src, id}`` refs from the payload, using
|
||||
the same ``boltons.iterutils.remap`` traversal idiom as
|
||||
``jobs.py::populate_input_data_input_id`` — when we hit a
|
||||
leaf keyed ``id``, we peek at its sibling ``src`` on the
|
||||
parent container to decide whether it is an HDA/HDCA ref.
|
||||
2. Normalize refs so that each one points at a top-level
|
||||
history item (see ``_normalize_refs``).
|
||||
3. Return as a set — the caller does not need to dedupe.
|
||||
|
||||
Payload shape is trusted to be Pydantic-validated upstream when
|
||||
the tool_request was accepted; no explicit depth or ref caps are
|
||||
applied here by design."""
|
||||
refs: set[tuple[str, int]] = set()
|
||||
|
||||
def visit(path, key, value):
|
||||
if key == "id" and isinstance(value, int) and not isinstance(value, bool):
|
||||
parent = payload
|
||||
for step in path:
|
||||
parent = parent[step]
|
||||
if isinstance(parent, dict):
|
||||
src = parent.get("src")
|
||||
if src == DataItemSourceType.hda:
|
||||
refs.add(("dataset", value))
|
||||
elif src == DataItemSourceType.hdca:
|
||||
refs.add(("collection", value))
|
||||
return key, value
|
||||
|
||||
remap(payload, visit=visit)
|
||||
return self._normalize_refs(refs)
|
||||
|
||||
def _normalize_refs(self, refs: set[tuple[str, int]]) -> set[tuple[str, int]]:
|
||||
"""Apply the single normalization rule: a hidden-element HDA
|
||||
ref is replaced with its parent HDCA. Single-hop only — the
|
||||
replacement is not re-normalized. No other rules apply here
|
||||
by design; adding more cases should be weighed against the
|
||||
invariant that every emitted ref points at a top-level item."""
|
||||
hda_ids = [item_id for t, item_id in refs if t == "dataset"]
|
||||
hdca_parents: dict[int, int] = {}
|
||||
if hda_ids:
|
||||
stmt = (
|
||||
select(
|
||||
HistoryDatasetAssociation.id.label("hda_id"),
|
||||
HistoryDatasetCollectionAssociation.id.label("hdca_id"),
|
||||
)
|
||||
.join(DatasetCollectionElement, DatasetCollectionElement.hda_id == HistoryDatasetAssociation.id)
|
||||
.join(
|
||||
HistoryDatasetCollectionAssociation,
|
||||
HistoryDatasetCollectionAssociation.collection_id == DatasetCollectionElement.dataset_collection_id,
|
||||
)
|
||||
.where(
|
||||
HistoryDatasetAssociation.id.in_(hda_ids),
|
||||
HistoryDatasetAssociation.visible == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
for row in self.sa_session.execute(stmt):
|
||||
hdca_parents[row.hda_id] = row.hdca_id
|
||||
|
||||
result: set[tuple[str, int]] = set()
|
||||
for ref_type, ref_id in refs:
|
||||
if ref_type == "dataset" and ref_id in hdca_parents:
|
||||
result.add(("collection", hdca_parents[ref_id]))
|
||||
else:
|
||||
result.add((ref_type, ref_id))
|
||||
return result
|
||||
|
||||
# ── Node construction ──
|
||||
|
||||
def _dataset_nodes(self, db_ids: set[int]) -> list[GraphNode]:
|
||||
if not db_ids:
|
||||
return []
|
||||
stmt = (
|
||||
select(
|
||||
HistoryDatasetAssociation.id,
|
||||
HistoryDatasetAssociation.name,
|
||||
HistoryDatasetAssociation.hid,
|
||||
HistoryDatasetAssociation._state,
|
||||
Dataset.state.label("dataset_state"),
|
||||
HistoryDatasetAssociation.extension,
|
||||
HistoryDatasetAssociation.deleted,
|
||||
HistoryDatasetAssociation.visible,
|
||||
)
|
||||
.join(Dataset, HistoryDatasetAssociation.dataset_id == Dataset.id)
|
||||
.where(HistoryDatasetAssociation.id.in_(db_ids))
|
||||
)
|
||||
return [
|
||||
GraphNode(
|
||||
id=self._encode("dataset", row.id),
|
||||
type="dataset",
|
||||
name=row.name,
|
||||
hid=row.hid,
|
||||
state=row._state if row._state else row.dataset_state,
|
||||
extension=row.extension,
|
||||
deleted=row.deleted,
|
||||
visible=row.visible,
|
||||
)
|
||||
for row in self.sa_session.execute(stmt)
|
||||
]
|
||||
|
||||
def _collection_nodes(self, db_ids: set[int]) -> list[GraphNode]:
|
||||
if not db_ids:
|
||||
return []
|
||||
stmt = (
|
||||
select(
|
||||
HistoryDatasetCollectionAssociation.id,
|
||||
HistoryDatasetCollectionAssociation.name,
|
||||
HistoryDatasetCollectionAssociation.hid,
|
||||
HistoryDatasetCollectionAssociation.deleted,
|
||||
HistoryDatasetCollectionAssociation.visible,
|
||||
DatasetCollection.collection_type,
|
||||
DatasetCollection.populated_state,
|
||||
)
|
||||
.join(DatasetCollection, HistoryDatasetCollectionAssociation.collection_id == DatasetCollection.id)
|
||||
.where(HistoryDatasetCollectionAssociation.id.in_(db_ids))
|
||||
)
|
||||
return [
|
||||
GraphNode(
|
||||
id=self._encode("collection", row.id),
|
||||
type="collection",
|
||||
name=row.name,
|
||||
hid=row.hid,
|
||||
state=row.populated_state,
|
||||
collection_type=row.collection_type,
|
||||
deleted=row.deleted,
|
||||
visible=row.visible,
|
||||
)
|
||||
for row in self.sa_session.execute(stmt)
|
||||
]
|
||||
|
||||
def _tr_nodes(self, tr_map: dict[int, Optional[str]]) -> list[GraphNode]:
|
||||
return [
|
||||
GraphNode(
|
||||
id=self._encode("tool_request", tr_id),
|
||||
type="tool_request",
|
||||
tool_id=tool_id,
|
||||
)
|
||||
for tr_id, tool_id in tr_map.items()
|
||||
]
|
||||
|
||||
def _resolve_tool_names(self, nodes: list[GraphNode]) -> None:
|
||||
toolbox = self.toolbox
|
||||
if toolbox is None:
|
||||
return
|
||||
tool_ids = {n.tool_id for n in nodes if n.type == "tool_request" and n.tool_id}
|
||||
name_map: dict[str, str] = {}
|
||||
for tool_id in tool_ids:
|
||||
try:
|
||||
tool = toolbox.get_tool(tool_id)
|
||||
if tool and tool.name:
|
||||
name_map[tool_id] = tool.name
|
||||
except MessageException:
|
||||
pass
|
||||
for node in nodes:
|
||||
if node.type == "tool_request" and node.tool_id and node.tool_id in name_map:
|
||||
node.tool_name = name_map[node.tool_id]
|
||||
|
||||
# ── Seed subgraph filter (in-memory only) ──
|
||||
|
||||
def _seed_filter(
|
||||
self, seed: str, nodes: list[GraphNode], edges: list[GraphEdge]
|
||||
) -> tuple[list[GraphNode], list[GraphEdge]]:
|
||||
node_ids = {n.id for n in nodes}
|
||||
if seed not in node_ids:
|
||||
raise RequestParameterInvalidException(f"Seed {seed} not found in graph.")
|
||||
|
||||
out_map: dict[str, set[str]] = {}
|
||||
in_map: dict[str, set[str]] = {}
|
||||
for e in edges:
|
||||
out_map.setdefault(e.source, set()).add(e.target)
|
||||
in_map.setdefault(e.target, set()).add(e.source)
|
||||
|
||||
reachable: set[str] = {seed}
|
||||
frontier = {seed}
|
||||
for _ in range(self.depth):
|
||||
nxt: set[str] = set()
|
||||
for nid in frontier:
|
||||
if self.direction in ("forward", "both"):
|
||||
nxt |= out_map.get(nid, set()) - reachable
|
||||
if self.direction in ("backward", "both"):
|
||||
nxt |= in_map.get(nid, set()) - reachable
|
||||
if not nxt:
|
||||
break
|
||||
reachable |= nxt
|
||||
frontier = nxt
|
||||
return (
|
||||
[n for n in nodes if n.id in reachable],
|
||||
[e for e in edges if e.source in reachable and e.target in reachable],
|
||||
)
|
||||
|
||||
# ── Sort ──
|
||||
|
||||
def _sort(self, nodes: list[GraphNode], edges: list[GraphEdge]) -> tuple[list[GraphNode], list[GraphEdge]]:
|
||||
fallback = (99, 0)
|
||||
nodes.sort(key=lambda n: self._sort_keys.get(n.id, fallback))
|
||||
edges.sort(
|
||||
key=lambda e: (
|
||||
self._sort_keys.get(e.source, fallback),
|
||||
self._sort_keys.get(e.target, fallback),
|
||||
EDGE_TYPE_RANK.get(e.type, 99),
|
||||
)
|
||||
)
|
||||
return nodes, edges
|
||||
|
||||
# ── Utilities ──
|
||||
|
||||
def _encode(self, node_type: str, db_id: int) -> str:
|
||||
encoded = f"{NODE_TYPE_PREFIX[node_type]}{self.security.encode_id(db_id)}"
|
||||
self._sort_keys[encoded] = (TYPE_RANK[node_type], db_id)
|
||||
return encoded
|
||||
@@ -0,0 +1,38 @@
|
||||
from typing import (
|
||||
Literal,
|
||||
Optional,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
id: str
|
||||
type: Literal["dataset", "collection", "tool_request"]
|
||||
name: Optional[str] = None
|
||||
hid: Optional[int] = None
|
||||
state: Optional[str] = None
|
||||
extension: Optional[str] = None
|
||||
collection_type: Optional[str] = None
|
||||
deleted: Optional[bool] = None
|
||||
visible: Optional[bool] = None
|
||||
tool_id: Optional[str] = None
|
||||
tool_name: Optional[str] = None
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
type: Literal["dataset_input", "dataset_output", "collection_input", "collection_output"]
|
||||
|
||||
|
||||
class TruncationInfo(BaseModel):
|
||||
item_count_capped: bool = False
|
||||
scope_type: Literal["recent", "seed_centered"] = "recent"
|
||||
seed_in_scope: Optional[bool] = None
|
||||
|
||||
|
||||
class HistoryGraphResponse(BaseModel):
|
||||
nodes: list[GraphNode]
|
||||
edges: list[GraphEdge]
|
||||
truncated: TruncationInfo
|
||||
@@ -48,6 +48,7 @@ from galaxy.schema.history import (
|
||||
HistoryIndexQueryPayload,
|
||||
HistorySortByEnum,
|
||||
)
|
||||
from galaxy.schema.history_graph import HistoryGraphResponse
|
||||
from galaxy.schema.schema import (
|
||||
AnyArchivedHistoryView,
|
||||
AnyHistoryView,
|
||||
@@ -357,6 +358,56 @@ class FastAPIHistories:
|
||||
) -> AnyHistoryView:
|
||||
return self.service.show(trans, serialization_params, history_id)
|
||||
|
||||
@router.get(
|
||||
"/api/histories/{history_id}/graph",
|
||||
summary="Returns a history-scoped structural graph.",
|
||||
)
|
||||
def graph(
|
||||
self,
|
||||
history_id: HistoryIDPathParam,
|
||||
limit: int = Query(
|
||||
default=500,
|
||||
description="Maximum number of nodes. Applied at history scope.",
|
||||
ge=1,
|
||||
le=2000,
|
||||
),
|
||||
include_deleted: bool = Query(
|
||||
default=False,
|
||||
description="Include deleted datasets and collections.",
|
||||
),
|
||||
seed: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Optional: focus on subgraph reachable from this node (e.g. d<encoded_id>).",
|
||||
pattern=r"^[dcr].+$",
|
||||
),
|
||||
direction: Literal["backward", "forward", "both"] = Query(
|
||||
default="both",
|
||||
description="Direction for seed-based subgraph extraction.",
|
||||
),
|
||||
depth: int = Query(
|
||||
default=20,
|
||||
description="Max depth for seed-based subgraph extraction.",
|
||||
ge=1,
|
||||
le=20,
|
||||
),
|
||||
seed_scope: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Center the selection window on this item. Format: d{encoded_id} or c{encoded_id}.",
|
||||
pattern=r"^[dc].+$",
|
||||
),
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
) -> HistoryGraphResponse:
|
||||
return self.service.graph(
|
||||
trans,
|
||||
history_id,
|
||||
limit=limit,
|
||||
include_deleted=include_deleted,
|
||||
seed=seed,
|
||||
direction=direction,
|
||||
depth=depth,
|
||||
seed_scope=seed_scope,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/api/histories/{history_id}/prepare_store_download",
|
||||
summary="Return a short term storage token to monitor download of the history.",
|
||||
|
||||
@@ -276,6 +276,7 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs):
|
||||
webapp.add_client_route("/histories/{history_id}/extract_workflow")
|
||||
webapp.add_client_route("/histories/{history_id}/archive")
|
||||
webapp.add_client_route("/histories/{history_id}/invocations")
|
||||
webapp.add_client_route("/histories/{history_id}/graph")
|
||||
webapp.add_client_route("/histories/archived")
|
||||
webapp.add_client_route("/histories/list_published")
|
||||
webapp.add_client_route("/histories/list_shared")
|
||||
|
||||
@@ -40,8 +40,12 @@ from galaxy.managers.histories import (
|
||||
HistoryManager,
|
||||
HistorySerializer,
|
||||
)
|
||||
from galaxy.managers.history_graph import HistoryGraphManager
|
||||
from galaxy.managers.users import UserManager
|
||||
from galaxy.model import HistoryDatasetAssociation
|
||||
from galaxy.model import (
|
||||
HistoryDatasetAssociation,
|
||||
HistoryDatasetCollectionAssociation,
|
||||
)
|
||||
from galaxy.model.scoped_session import galaxy_scoped_session
|
||||
from galaxy.model.store import payload_to_source_uri
|
||||
from galaxy.schema import (
|
||||
@@ -50,6 +54,7 @@ from galaxy.schema import (
|
||||
)
|
||||
from galaxy.schema.fields import DecodedDatabaseIdField
|
||||
from galaxy.schema.history import HistoryIndexQueryPayload
|
||||
from galaxy.schema.history_graph import HistoryGraphResponse
|
||||
from galaxy.schema.schema import (
|
||||
AnyArchivedHistoryView,
|
||||
AnyHistoryView,
|
||||
@@ -126,6 +131,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
deserializer: HistoryDeserializer,
|
||||
citations_manager: CitationsManager,
|
||||
history_export_manager: HistoryExportManager,
|
||||
history_graph_manager: HistoryGraphManager,
|
||||
filters: HistoryFilters,
|
||||
short_term_storage_allocator: ShortTermStorageAllocator,
|
||||
notification_service: NotificationService,
|
||||
@@ -137,6 +143,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
self.deserializer = deserializer
|
||||
self.citations_manager = citations_manager
|
||||
self.history_export_manager = history_export_manager
|
||||
self.history_graph_manager = history_graph_manager
|
||||
self.filters = filters
|
||||
self.shareable_service = ShareableHistoryService(self.manager, self.serializer, notification_service)
|
||||
self.short_term_storage_allocator = short_term_storage_allocator
|
||||
@@ -379,6 +386,40 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
|
||||
return self._serialize_history(trans, history, serialization_params)
|
||||
|
||||
def graph(
|
||||
self,
|
||||
trans: ProvidesHistoryContext,
|
||||
history_id: DecodedDatabaseIdField,
|
||||
limit: int = 500,
|
||||
include_deleted: bool = False,
|
||||
seed: Optional[str] = None,
|
||||
direction: Literal["backward", "forward", "both"] = "both",
|
||||
depth: int = 20,
|
||||
seed_scope: Optional[str] = None,
|
||||
) -> HistoryGraphResponse:
|
||||
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
|
||||
seed_scope_hid = self._resolve_seed_scope_hid(trans, history.id, seed_scope) if seed_scope else None
|
||||
return self.history_graph_manager.build(
|
||||
sa_session=trans.sa_session,
|
||||
history_id=history.id,
|
||||
limit=limit,
|
||||
include_deleted=include_deleted,
|
||||
seed=seed,
|
||||
direction=direction,
|
||||
depth=depth,
|
||||
seed_scope_hid=seed_scope_hid,
|
||||
)
|
||||
|
||||
def _resolve_seed_scope_hid(self, trans: ProvidesHistoryContext, history_id: int, seed_scope: str) -> int:
|
||||
db_id = self.security.decode_id(seed_scope[1:])
|
||||
model_class = HistoryDatasetAssociation if seed_scope[0] == "d" else HistoryDatasetCollectionAssociation
|
||||
row = trans.sa_session.execute(
|
||||
select(model_class.hid).where(model_class.id == db_id, model_class.history_id == history_id)
|
||||
).first()
|
||||
if row is None or row.hid is None:
|
||||
raise glx_exceptions.ObjectNotFound(f"seed_scope {seed_scope} not found in history.")
|
||||
return row.hid
|
||||
|
||||
def prepare_download(
|
||||
self, trans: ProvidesHistoryContext, history_id: DecodedDatabaseIdField, payload: StoreExportPayload
|
||||
) -> AsyncFile:
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import ClassVar
|
||||
from unittest import SkipTest
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from requests import put
|
||||
|
||||
from galaxy.model.unittest_utils.store_fixtures import (
|
||||
@@ -1247,3 +1248,90 @@ class TestArchivingHistoriesWithoutExportRecord(ApiTestCase, BaseHistories):
|
||||
histories = self.dataset_populator.get_histories()
|
||||
for history in histories:
|
||||
assert history["id"] != history_id
|
||||
|
||||
|
||||
class TestHistoryGraphApi(ApiTestCase, BaseHistories):
|
||||
"""API-level tests for ``GET /api/histories/{id}/graph``.
|
||||
|
||||
These cover the surface the endpoint owns: status codes, query
|
||||
parameter validation, auth boundaries, and response shape. Builder
|
||||
logic is exercised separately in ``test_HistoryGraphBuilder``.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
|
||||
# ── response shape ──
|
||||
|
||||
def test_empty_history_returns_empty_graph(self):
|
||||
history_id = self.dataset_populator.new_history()
|
||||
body = self.dataset_populator.get_history_graph(history_id)
|
||||
self._assert_has_keys(body, "nodes", "edges", "truncated")
|
||||
assert body["nodes"] == []
|
||||
assert body["edges"] == []
|
||||
assert body["truncated"]["item_count_capped"] is False
|
||||
assert body["truncated"]["scope_type"] == "recent"
|
||||
|
||||
def test_standalone_datasets_appear_as_dataset_nodes(self):
|
||||
history_id = self.dataset_populator.new_history()
|
||||
self.dataset_populator.new_dataset(history_id, content="a", wait=True)
|
||||
self.dataset_populator.new_dataset(history_id, content="b", wait=True)
|
||||
body = self.dataset_populator.get_history_graph(history_id)
|
||||
assert len(body["nodes"]) == 2
|
||||
assert body["edges"] == []
|
||||
assert all(n["type"] == "dataset" for n in body["nodes"])
|
||||
|
||||
def test_limit_caps_items_and_sets_truncation_flag(self):
|
||||
history_id = self.dataset_populator.new_history()
|
||||
for i in range(5):
|
||||
self.dataset_populator.new_dataset(history_id, content=f"row {i}", wait=True)
|
||||
body = self.dataset_populator.get_history_graph(history_id, limit=3)
|
||||
assert len(body["nodes"]) == 3
|
||||
assert body["truncated"]["item_count_capped"] is True
|
||||
|
||||
def test_seed_scope_returns_seed_centered_window(self):
|
||||
history_id = self.dataset_populator.new_history()
|
||||
dataset = self.dataset_populator.new_dataset(history_id, content="seed", wait=True)
|
||||
seed_scope = f"d{dataset['id']}"
|
||||
body = self.dataset_populator.get_history_graph(history_id, seed_scope=seed_scope, limit=5)
|
||||
assert body["truncated"]["scope_type"] == "seed_centered"
|
||||
assert seed_scope in {n["id"] for n in body["nodes"]}
|
||||
|
||||
# ── query-parameter validation (API-layer regex and bounds) ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param,value",
|
||||
[
|
||||
("seed_scope", "xabc"), # wrong prefix
|
||||
("seed_scope", "d"), # prefix but empty body
|
||||
("seed", "z"), # wrong prefix
|
||||
("limit", 5000), # above max
|
||||
("depth", 21), # above max
|
||||
],
|
||||
)
|
||||
def test_invalid_query_params_return_400(self, param, value):
|
||||
history_id = self.dataset_populator.new_history()
|
||||
response = self.dataset_populator.get_history_graph_raw(history_id, **{param: value})
|
||||
self._assert_status_code_is(response, 400)
|
||||
|
||||
# ── manager-level validation (after API regex passes) ──
|
||||
|
||||
def test_seed_scope_not_in_target_history_is_rejected(self):
|
||||
source_history = self.dataset_populator.new_history()
|
||||
dataset = self.dataset_populator.new_dataset(source_history, content="a", wait=True)
|
||||
target_history = self.dataset_populator.new_history()
|
||||
response = self.dataset_populator.get_history_graph_raw(target_history, seed_scope=f"d{dataset['id']}")
|
||||
self._assert_status_code_is(response, 404)
|
||||
|
||||
# ── auth ──
|
||||
|
||||
def test_other_users_history_is_forbidden(self):
|
||||
with self._different_user():
|
||||
other_history_id = self.dataset_populator.new_history()
|
||||
response = self.dataset_populator.get_history_graph_raw(other_history_id)
|
||||
self._assert_status_code_is(response, 403)
|
||||
|
||||
def test_nonexistent_history_is_rejected(self):
|
||||
response = self.dataset_populator.get_history_graph_raw("0000000000000000")
|
||||
self._assert_status_code_is(response, 400)
|
||||
|
||||
@@ -1842,6 +1842,14 @@ class BaseDatasetPopulator(BasePopulator):
|
||||
api_asserts.assert_status_code_is_ok(response)
|
||||
return response.json()
|
||||
|
||||
def get_history_graph(self, history_id: str, **params: Any) -> dict[str, Any]:
|
||||
response = self.get_history_graph_raw(history_id, **params)
|
||||
api_asserts.assert_status_code_is_ok(response)
|
||||
return response.json()
|
||||
|
||||
def get_history_graph_raw(self, history_id: str, **params: Any) -> Response:
|
||||
return self._get(f"histories/{history_id}/graph", data=params or None)
|
||||
|
||||
def wait_on_task(self, async_task_response: Response):
|
||||
response_json = async_task_response.json()
|
||||
return self.wait_on_task_object(response_json)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user