Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1117580
Extract shared zoom, geometry, and minimap utilities from workflow ed…
guerler Apr 13, 2026
d841378
Add history graph builder manager and pydantic schema.
guerler Apr 13, 2026
3cfad14
Add history graph API endpoint and regenerate client schema.
guerler Apr 13, 2026
2c78e5c
Add unit tests for history graph builder.
guerler Apr 13, 2026
b09a46c
Add generic graph view, node, edge, and zoom components.
guerler Apr 13, 2026
d2374a2
Add history graph views, data loader, layout, and mapper.
guerler Apr 13, 2026
3edbebc
Wire history graph route and menu entry.
guerler Apr 13, 2026
91a5978
Update tests, apply lint
guerler Apr 13, 2026
8e28300
Update comments
guerler Apr 14, 2026
ee0b1a0
Use boltons.iterutils in tool input walker
guerler Apr 14, 2026
efae7c6
Add edge filter
guerler Apr 14, 2026
6fd6d66
Reuse filtering, improve comments
guerler Apr 14, 2026
296d82d
Linting
guerler Apr 14, 2026
e625f0f
Drop chunking mechanism
guerler Apr 15, 2026
87fe6be
Deduplication set for edges is unnecessary
guerler Apr 15, 2026
4fd1cbe
Remove hid window interface from api
guerler Apr 15, 2026
6cbc941
Validate seed on api level
guerler Apr 15, 2026
68fb22e
Add tests
guerler Apr 15, 2026
e36d726
Move test helpers to populator
guerler May 1, 2026
ea9f27b
Move import to top-level
guerler May 1, 2026
55b7e61
Access toolbox directly, no need for get attribute
guerler May 1, 2026
3ce0edd
Move remaining validation to service layer
guerler May 1, 2026
748fef1
Fix typing use AbstractToolBox
guerler May 1, 2026
00e9721
Tighten graph endpoint typing and pin 404 in API tests
guerler May 1, 2026
10a0b8a
Clean up history graph builder test suite
guerler May 1, 2026
0834a34
Fix expected error code in test
guerler May 2, 2026
6e23e9d
Add shim to ensure that manager itself is not reinstantiated, align w…
guerler May 2, 2026
606aa19
Use DataItemSourceType, fix linting
guerler May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: {
/**
Expand Down Expand Up @@ -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?: {
Expand Down
89 changes: 89 additions & 0 deletions client/src/components/Graph/GraphEdges.vue
Original file line number Diff line number Diff line change
@@ -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>
114 changes: 114 additions & 0 deletions client/src/components/Graph/GraphNode.vue
Original file line number Diff line number Diff line change
@@ -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>
Loading
Loading