Skip to content

Commit 2e70707

Browse files
authored
Merge pull request #21932 from guerler/graph.000
2 parents d1e450a + 606aa19 commit 2e70707

49 files changed

Lines changed: 4317 additions & 268 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

client/src/api/schema/schema.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2855,6 +2855,23 @@ export interface paths {
28552855
patch?: never;
28562856
trace?: never;
28572857
};
2858+
"/api/histories/{history_id}/graph": {
2859+
parameters: {
2860+
query?: never;
2861+
header?: never;
2862+
path?: never;
2863+
cookie?: never;
2864+
};
2865+
/** Returns a history-scoped structural graph. */
2866+
get: operations["graph_api_histories__history_id__graph_get"];
2867+
put?: never;
2868+
post?: never;
2869+
delete?: never;
2870+
options?: never;
2871+
head?: never;
2872+
patch?: never;
2873+
trace?: never;
2874+
};
28582875
"/api/histories/{history_id}/jobs_summary": {
28592876
parameters: {
28602877
query?: never;
@@ -13106,6 +13123,46 @@ export interface components {
1310613123
*/
1310713124
type: "genomebuild";
1310813125
};
13126+
/** GraphEdge */
13127+
GraphEdge: {
13128+
/** Source */
13129+
source: string;
13130+
/** Target */
13131+
target: string;
13132+
/**
13133+
* Type
13134+
* @enum {string}
13135+
*/
13136+
type: "dataset_input" | "dataset_output" | "collection_input" | "collection_output";
13137+
};
13138+
/** GraphNode */
13139+
GraphNode: {
13140+
/** Collection Type */
13141+
collection_type?: string | null;
13142+
/** Deleted */
13143+
deleted?: boolean | null;
13144+
/** Extension */
13145+
extension?: string | null;
13146+
/** Hid */
13147+
hid?: number | null;
13148+
/** Id */
13149+
id: string;
13150+
/** Name */
13151+
name?: string | null;
13152+
/** State */
13153+
state?: string | null;
13154+
/** Tool Id */
13155+
tool_id?: string | null;
13156+
/** Tool Name */
13157+
tool_name?: string | null;
13158+
/**
13159+
* Type
13160+
* @enum {string}
13161+
*/
13162+
type: "dataset" | "collection" | "tool_request";
13163+
/** Visible */
13164+
visible?: boolean | null;
13165+
};
1310913166
/**
1311013167
* GroupCreatePayload
1311113168
* @description Payload schema for creating a group.
@@ -15376,6 +15433,14 @@ export interface components {
1537615433
*/
1537715434
username_and_slug?: string | null;
1537815435
};
15436+
/** HistoryGraphResponse */
15437+
HistoryGraphResponse: {
15438+
/** Edges */
15439+
edges: components["schemas"]["GraphEdge"][];
15440+
/** Nodes */
15441+
nodes: components["schemas"]["GraphNode"][];
15442+
truncated: components["schemas"]["TruncationInfo"];
15443+
};
1537915444
/**
1538015445
* HistorySummary
1538115446
* @description History summary information.
@@ -23730,6 +23795,22 @@ export interface components {
2373023795
*/
2373123796
title?: string | null;
2373223797
};
23798+
/** TruncationInfo */
23799+
TruncationInfo: {
23800+
/**
23801+
* Item Count Capped
23802+
* @default false
23803+
*/
23804+
item_count_capped: boolean;
23805+
/**
23806+
* Scope Type
23807+
* @default recent
23808+
* @enum {string}
23809+
*/
23810+
scope_type: "recent" | "seed_centered";
23811+
/** Seed In Scope */
23812+
seed_in_scope?: boolean | null;
23813+
};
2373323814
/** UndeleteHistoriesPayload */
2373423815
UndeleteHistoriesPayload: {
2373523816
/**
@@ -39149,6 +39230,63 @@ export interface operations {
3914939230
};
3915039231
};
3915139232
};
39233+
graph_api_histories__history_id__graph_get: {
39234+
parameters: {
39235+
query?: {
39236+
/** @description Maximum number of nodes. Applied at history scope. */
39237+
limit?: number;
39238+
/** @description Include deleted datasets and collections. */
39239+
include_deleted?: boolean;
39240+
/** @description Optional: focus on subgraph reachable from this node (e.g. d<encoded_id>). */
39241+
seed?: string | null;
39242+
/** @description Direction for seed-based subgraph extraction. */
39243+
direction?: "backward" | "forward" | "both";
39244+
/** @description Max depth for seed-based subgraph extraction. */
39245+
depth?: number;
39246+
/** @description Center the selection window on this item. Format: d{encoded_id} or c{encoded_id}. */
39247+
seed_scope?: string | null;
39248+
};
39249+
header?: {
39250+
/** @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. */
39251+
"run-as"?: string | null;
39252+
};
39253+
path: {
39254+
/** @description The encoded database identifier of the History. */
39255+
history_id: string;
39256+
};
39257+
cookie?: never;
39258+
};
39259+
requestBody?: never;
39260+
responses: {
39261+
/** @description Successful Response */
39262+
200: {
39263+
headers: {
39264+
[name: string]: unknown;
39265+
};
39266+
content: {
39267+
"application/json": components["schemas"]["HistoryGraphResponse"];
39268+
};
39269+
};
39270+
/** @description Request Error */
39271+
"4XX": {
39272+
headers: {
39273+
[name: string]: unknown;
39274+
};
39275+
content: {
39276+
"application/json": components["schemas"]["MessageExceptionModel"];
39277+
};
39278+
};
39279+
/** @description Server Error */
39280+
"5XX": {
39281+
headers: {
39282+
[name: string]: unknown;
39283+
};
39284+
content: {
39285+
"application/json": components["schemas"]["MessageExceptionModel"];
39286+
};
39287+
};
39288+
};
39289+
};
3915239290
index_jobs_summary_api_histories__history_id__jobs_summary_get: {
3915339291
parameters: {
3915439292
query?: {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<script setup lang="ts">
2+
import { curveBasisPath, orthogonalPath } from "@/utils/connectionPath";
3+
4+
import type { EdgeStyle, GraphEdge } from "./types";
5+
6+
interface Props {
7+
edges: GraphEdge[];
8+
selectedNodeId?: string | null;
9+
width: number;
10+
height: number;
11+
edgeStyle?: EdgeStyle;
12+
}
13+
14+
const props = withDefaults(defineProps<Props>(), {
15+
selectedNodeId: null,
16+
edgeStyle: "orthogonal",
17+
});
18+
19+
/** Ribbon margin for collection edges — matches workflow editor's ribbonMargin */
20+
const RIBBON_MARGIN = 4;
21+
const RIBBON_OFFSETS = [-2 * RIBBON_MARGIN, -1 * RIBBON_MARGIN, 0, 1 * RIBBON_MARGIN, 2 * RIBBON_MARGIN];
22+
23+
function makePath(points: { x: number; y: number }[]): string {
24+
if (props.edgeStyle === "curved") {
25+
return curveBasisPath(points.map((p) => [p.x, p.y] as [number, number]));
26+
}
27+
return orthogonalPath(points);
28+
}
29+
30+
/** For a single (non-collection) edge, return one path string */
31+
function edgePaths(edge: GraphEdge): string[] {
32+
if (!edge.isCollection || edge.points.length < 2) {
33+
return [makePath(edge.points)];
34+
}
35+
// Collection ribbon: offset each path perpendicular to the edge direction.
36+
// For orthogonal/curved layouts the edges run mostly left-to-right,
37+
// so vertical offsets produce the ribbon effect.
38+
return RIBBON_OFFSETS.map((offset) => {
39+
const offsetPoints = edge.points.map((p) => ({ x: p.x, y: p.y + offset }));
40+
return makePath(offsetPoints);
41+
});
42+
}
43+
44+
function edgeClass(edge: GraphEdge): Record<string, boolean> {
45+
const isConnected =
46+
!props.selectedNodeId || edge.source === props.selectedNodeId || edge.target === props.selectedNodeId;
47+
return {
48+
[edge.cssClass ?? "edge-default"]: true,
49+
"edge-dimmed": !isConnected,
50+
"edge-collection": !!edge.isCollection,
51+
};
52+
}
53+
</script>
54+
55+
<template>
56+
<svg class="graph-edges" :width="width" :height="height">
57+
<template v-for="edge in edges">
58+
<path
59+
v-for="(path, idx) in edgePaths(edge)"
60+
:key="`${edge.id}-${idx}`"
61+
:d="path"
62+
:class="edgeClass(edge)"
63+
fill="none" />
64+
</template>
65+
</svg>
66+
</template>
67+
68+
<style lang="scss" scoped>
69+
@import "@/style/scss/theme/blue.scss";
70+
71+
.graph-edges {
72+
position: absolute;
73+
top: 0;
74+
left: 0;
75+
pointer-events: none;
76+
overflow: visible;
77+
z-index: 0;
78+
}
79+
80+
path {
81+
stroke-width: 2;
82+
stroke: $brand-primary;
83+
transition: opacity 0.2s ease;
84+
}
85+
86+
.edge-dimmed {
87+
opacity: 0.3;
88+
}
89+
</style>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<script setup lang="ts">
2+
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
3+
import { computed } from "vue";
4+
5+
import type { GraphNode } from "./types";
6+
7+
interface Props {
8+
node: GraphNode;
9+
selected: boolean;
10+
}
11+
12+
const props = defineProps<Props>();
13+
const emit = defineEmits<{ (e: "select", nodeId: string): void }>();
14+
15+
const nodeStyle = computed(() => ({
16+
left: `${props.node.x}px`,
17+
top: `${props.node.y}px`,
18+
width: `${props.node.width}px`,
19+
}));
20+
21+
const hasInputs = computed(() => props.node.inputs && props.node.inputs.length > 0);
22+
const hasOutputs = computed(() => props.node.outputs && props.node.outputs.length > 0);
23+
const hasPorts = computed(() => hasInputs.value || hasOutputs.value);
24+
const showRule = computed(() => hasInputs.value && hasOutputs.value);
25+
const showDataBody = computed(() => !hasPorts.value && (props.node.badge || props.node.data?.stateText));
26+
const iconSpin = computed(() => Boolean(props.node.data?.stateSpin));
27+
</script>
28+
29+
<template>
30+
<div
31+
class="graph-node card"
32+
:class="[node.cssClass, { 'node-highlight': selected }]"
33+
:style="nodeStyle"
34+
@click.stop="emit('select', node.id)">
35+
<div class="graph-node-header card-header unselectable py-1 px-2" :data-state="node.data?.state ?? undefined">
36+
<FontAwesomeIcon :icon="node.icon" class="graph-node-icon mr-1" :spin="iconSpin" />
37+
<span class="graph-node-label" :title="node.label">{{ node.label }}</span>
38+
<span v-if="hasPorts && node.badge" class="badge badge-light ml-auto">{{ node.badge }}</span>
39+
</div>
40+
<div v-if="showDataBody" class="card-body p-0 mx-2 my-1">
41+
<span v-if="node.badge" class="badge badge-secondary">{{ node.badge }}</span>
42+
<div v-if="node.data?.stateText" class="node-state-text">{{ node.data.stateText }}</div>
43+
</div>
44+
<div v-if="hasPorts" class="node-body card-body p-0 mx-2">
45+
<div v-for="input in node.inputs" :key="`in-${input.name}`" class="form-row dataRow input-data-row">
46+
<span class="node-port-label">{{ input.label }}</span>
47+
</div>
48+
<div v-if="showRule" class="rule" />
49+
<div v-for="output in node.outputs" :key="`out-${output.name}`" class="form-row dataRow output-data-row">
50+
<span class="node-port-label">{{ output.label }}</span>
51+
</div>
52+
</div>
53+
</div>
54+
</template>
55+
56+
<style lang="scss" scoped>
57+
@import "@/style/scss/theme/blue.scss";
58+
59+
.graph-node {
60+
position: absolute;
61+
cursor: pointer;
62+
user-select: none;
63+
border: solid $brand-primary 1px;
64+
transition:
65+
border-color 0.15s,
66+
box-shadow 0.15s,
67+
opacity 0.2s ease;
68+
}
69+
70+
.node-highlight {
71+
z-index: 1001;
72+
border: solid $white 1px;
73+
box-shadow: 0 0 0 3px $brand-primary;
74+
}
75+
76+
.graph-node-header {
77+
font-size: $font-size-base;
78+
}
79+
80+
.graph-node-label {
81+
font-weight: 500;
82+
word-break: break-word;
83+
}
84+
85+
.node-body {
86+
font-size: $h6-font-size;
87+
}
88+
89+
.node-state-text {
90+
font-size: $h6-font-size;
91+
color: $text-muted;
92+
padding: 2px 0;
93+
}
94+
95+
.form-row {
96+
padding: 1px 0;
97+
}
98+
99+
.output-data-row {
100+
text-align: right;
101+
}
102+
103+
.node-port-label {
104+
color: $text-color;
105+
padding: 0 2px;
106+
}
107+
108+
.rule {
109+
height: 0;
110+
border: none;
111+
border-bottom: dotted $brand-primary 1px;
112+
margin: 0;
113+
}
114+
</style>

0 commit comments

Comments
 (0)