@parallelworks/ui 0.74.0 → 0.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ import { cx } from "../cx.js";
2
+ import { WarningIcon } from "../icons.js";
3
+ import { toneIcon } from "../components/tone.js";
4
+ import { keyedByContent, withPositionKeys } from "../components/keys.js";
5
+ import { Tooltip } from "../components/Tooltip.js";
6
+ import { useStrings, useWorkflowEngine } from "../components/Provider.js";
7
+ import { Callout } from "../components/Callout.js";
8
+ import { jobLabel } from "./util.js";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/graph/RunWarningsList.tsx
11
+ /** A warning's message, with the text it puts in backticks, such as a keyword, set as code. */
12
+ function WarningText({ message }) {
13
+ const parts = message.split("`");
14
+ if (parts.length < 3 || parts.length % 2 === 0) return message;
15
+ return withPositionKeys(parts).map(({ key, item }, i) => i % 2 === 1 ? /* @__PURE__ */ jsx("code", {
16
+ className: "rounded bg-current/10 px-1 font-mono text-[0.9em]",
17
+ children: item
18
+ }, key) : item);
19
+ }
20
+ /** Flags a job or step that warnings are about, or that holds something they're about; hovering
21
+ * it lists them. */
22
+ function WarningMark({ warnings, className }) {
23
+ const { dag } = useStrings();
24
+ if (warnings.length === 0) return null;
25
+ return /* @__PURE__ */ jsx(Tooltip, {
26
+ content: /* @__PURE__ */ jsx("ul", {
27
+ className: "m-0 flex list-none flex-col gap-1 p-0",
28
+ children: keyedByContent(warnings, (w) => w.message).map(({ key, item }) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(WarningText, { message: item.message }) }, key))
29
+ }),
30
+ children: /* @__PURE__ */ jsx("span", {
31
+ role: "img",
32
+ "aria-label": dag.warningCount(warnings.length),
33
+ "data-warning-mark": "",
34
+ className: cx("inline-flex shrink-0", toneIcon.warning, className),
35
+ children: /* @__PURE__ */ jsx(WarningIcon, { className: "h-[1.1em] w-[1.1em]" })
36
+ })
37
+ });
38
+ }
39
+ /** Where a warning is, as people read it, outermost first: each subworkflow step it's under, its job and
40
+ * its step. Jobs go by the workflow's names, which a matrix's runs share. */
41
+ function placeOf({ warning, targets: [target] }, rootJobs, stepLabel, stepNumber) {
42
+ const parts = [];
43
+ let jobs = rootJobs;
44
+ const path = warning.path ?? [];
45
+ for (let i = 0; i + 1 < path.length; i += 2) {
46
+ const via = target?.via[i / 2];
47
+ const step = via ? jobs?.[via.job]?.steps?.[via.step] : void 0;
48
+ const segment = path[i + 1] ?? "";
49
+ const index = /^step_(\d+)$/.exec(segment)?.[1];
50
+ parts.push(jobLabel(path[i] ?? ""), step && via ? stepLabel(step, via.step) : index === void 0 ? segment : stepNumber(Number(index)));
51
+ jobs = step?.subworkflow?.jobs;
52
+ }
53
+ parts.push(jobLabel(warning.job));
54
+ if (warning.step !== void 0 && warning.step !== null) {
55
+ const step = target ? jobs?.[target.job]?.steps?.[warning.step] : void 0;
56
+ parts.push(step ? stepLabel(step, warning.step) : stepNumber(warning.step));
57
+ }
58
+ return parts;
59
+ }
60
+ /** The run's warnings, each with a link to the job or step it's about. */
61
+ function RunWarningsList({ warnings, rootJobs, onShow }) {
62
+ const { dag } = useStrings();
63
+ const engine = useWorkflowEngine();
64
+ if (warnings.length === 0) return null;
65
+ return /* @__PURE__ */ jsx(Callout, {
66
+ tone: "warning",
67
+ title: dag.warningCount(warnings.length),
68
+ testId: "run-warnings",
69
+ children: /* @__PURE__ */ jsx("ul", {
70
+ className: "m-0 flex max-h-72 list-none flex-col gap-2.5 overflow-y-auto p-0",
71
+ children: keyedByContent(warnings, ({ warning }) => `${(warning.path ?? []).join("/")}/${warning.job}:${warning.step ?? ""}:${warning.message}`).map(({ key, item }) => {
72
+ const where = placeOf(item, rootJobs, (step, index) => engine.stepLabel(step, index), (index) => dag.stepNumber(index)).join(" › ");
73
+ const [target] = item.targets;
74
+ return /* @__PURE__ */ jsxs("li", {
75
+ className: "flex flex-col items-start gap-0.5",
76
+ children: [/* @__PURE__ */ jsx("span", {
77
+ className: "min-w-0 break-words",
78
+ children: /* @__PURE__ */ jsx(WarningText, { message: item.warning.message })
79
+ }), target && onShow ? /* @__PURE__ */ jsx("button", {
80
+ type: "button",
81
+ onClick: () => onShow(target),
82
+ className: "cursor-pointer rounded-sm text-xs font-medium underline underline-offset-2 hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--theme-focus)",
83
+ children: where
84
+ }) : /* @__PURE__ */ jsx("span", {
85
+ className: "text-xs",
86
+ children: where
87
+ })]
88
+ }, key);
89
+ })
90
+ })
91
+ });
92
+ }
93
+ //#endregion
94
+ export { RunWarningsList, WarningMark, WarningText };
@@ -9,6 +9,8 @@ import { Elapsed } from "../components/relativeTime.js";
9
9
  import { RunStatusIcon } from "../components/RunStatusIcon.js";
10
10
  import { LogViewer } from "../logviewer/LogViewer.js";
11
11
  import { jobLabel, toggled } from "./util.js";
12
+ import { WarningMark } from "./RunWarningsList.js";
13
+ import { rowKey, useWarningMarks } from "./runWarnings.js";
12
14
  import { jsx, jsxs } from "react/jsx-runtime";
13
15
  import { useCallback, useId, useMemo, useState } from "react";
14
16
  //#region src/graph/TreeView.tsx
@@ -52,6 +54,7 @@ function JobRow({ jobName, job, isExpanded, onToggle, prefix, state }) {
52
54
  const dependencies = Array.isArray(job.needs) ? job.needs : [];
53
55
  const panelId = useId();
54
56
  const { dag: t } = useStrings();
57
+ const marks = useWarningMarks();
55
58
  const skipped = job.status === "skipped" || job.status === "skipped-failed";
56
59
  return /* @__PURE__ */ jsxs("div", {
57
60
  className: "border-b border-[var(--theme-border)] last:border-b-0",
@@ -70,9 +73,12 @@ function JobRow({ jobName, job, isExpanded, onToggle, prefix, state }) {
70
73
  /* @__PURE__ */ jsx(RunStatusIcon, { status: job.status }),
71
74
  /* @__PURE__ */ jsxs("div", {
72
75
  className: "flex flex-col gap-1 min-w-0 flex-1",
73
- children: [/* @__PURE__ */ jsx("span", {
74
- className: "font-mono text-sm text-[var(--theme-app)] truncate",
75
- children: jobLabel(jobName, job)
76
+ children: [/* @__PURE__ */ jsxs("span", {
77
+ className: "flex min-w-0 items-center gap-1.5",
78
+ children: [/* @__PURE__ */ jsx("span", {
79
+ className: "font-mono text-sm text-[var(--theme-app)] truncate",
80
+ children: jobLabel(jobName, job)
81
+ }), /* @__PURE__ */ jsx(WarningMark, { warnings: marks.under(rowKey(prefix, jobName)) })]
76
82
  }), dependencies.length > 0 && /* @__PURE__ */ jsxs("div", {
77
83
  className: "flex items-center gap-1.5",
78
84
  children: [/* @__PURE__ */ jsx("span", {
@@ -120,6 +126,7 @@ function StepRow({ step, index, isExpanded, onToggle, prefix, jobName, state })
120
126
  const panelId = useId();
121
127
  const subworkflowPanelId = useId();
122
128
  const { dag: t } = useStrings();
129
+ const marks = useWarningMarks();
123
130
  const subworkflowJobs = subworkflow?.jobs ?? {};
124
131
  const subworkflowJobNames = Object.keys(subworkflowJobs).filter((name) => subworkflowJobs[name]?.if !== false);
125
132
  const subworkflowPrefix = `${prefix}subworkflows/${jobName}/step_${index}/`;
@@ -139,12 +146,14 @@ function StepRow({ step, index, isExpanded, onToggle, prefix, jobName, state })
139
146
  className: "h-4 w-4 flex-shrink-0"
140
147
  }),
141
148
  /* @__PURE__ */ jsxs("span", {
142
- className: "font-mono text-sm text-[var(--theme-app)] truncate flex-1",
149
+ className: "font-mono text-sm text-[var(--theme-app)] truncate min-w-0",
143
150
  children: [label, subworkflow && /* @__PURE__ */ jsx("span", {
144
151
  className: "ml-2 text-xs text-[var(--theme-muted-text-color)]",
145
152
  children: t.subworkflow
146
153
  })]
147
154
  }),
155
+ /* @__PURE__ */ jsx(WarningMark, { warnings: marks.under(rowKey(prefix, jobName, index)) }),
156
+ /* @__PURE__ */ jsx("div", { className: "flex-1" }),
148
157
  /* @__PURE__ */ jsx(Elapsed, {
149
158
  start: step.startedAt,
150
159
  end: step.completedAt,
@@ -189,6 +198,7 @@ function TreeView({ jobs, slug, expandedJobs, setExpandedJobs }) {
189
198
  const engine = useWorkflowEngine();
190
199
  const matrixPanelId = useId();
191
200
  const { common, dag } = useStrings();
201
+ const marks = useWarningMarks();
192
202
  const visibleJobNames = useMemo(() => {
193
203
  return Object.keys(jobs).filter((jobName) => jobs[jobName]?.if !== false);
194
204
  }, [jobs]);
@@ -293,7 +303,8 @@ function TreeView({ jobs, slug, expandedJobs, setExpandedJobs }) {
293
303
  dag.jobCount(group.members.length),
294
304
  ")"
295
305
  ]
296
- })
306
+ }),
307
+ !isGroupExpanded && /* @__PURE__ */ jsx(WarningMark, { warnings: marks.underAny(group.members.map((m) => rowKey("", m))) })
297
308
  ]
298
309
  }), /* @__PURE__ */ jsx(DisclosurePanel, {
299
310
  open: isGroupExpanded,
@@ -6,6 +6,7 @@ export { JOB_FIELDS, RETRY_FIELDS, RUNS_ON_FIELDS, SSH_KEYS, STEP_FIELDS, STRATE
6
6
  export { inputTypes, offeredInputKeys } from './InputDialog.js';
7
7
  export { InputsFormEditor } from './InputsEditor.js';
8
8
  export { type ListedProblem, ProblemsButton } from './ProblemsButton.js';
9
+ export type { RunWarning } from './runWarnings.js';
9
10
  export type { SettingsView } from './settingsViews.js';
10
11
  export { type WorkflowActions, withFields } from './stepWith.js';
11
12
  export type { RunLink } from './types.js';
@@ -0,0 +1,58 @@
1
+ import type { WorkflowJob } from './types.js';
2
+ /** Something probably wrong with a run's workflow, found when the run was created, about one of its jobs or steps. */
3
+ export interface RunWarning {
4
+ /** The steps that lead to the subworkflow the job is in, as `[job, 'step_<n>', …]`, outermost
5
+ * first; absent for the run's own workflow. */
6
+ path?: readonly string[] | null | undefined;
7
+ /** The job, by the name the workflow gives it, which every run of its matrix shares. */
8
+ job: string;
9
+ /** The step's index among the job's steps as the run lists them; absent for the job's own. */
10
+ step?: number | null | undefined;
11
+ /** What is wrong and what to write instead. Text in backticks shows as code. */
12
+ message: string;
13
+ }
14
+ /** A subworkflow step on the way to a warning: its level's prefix, its job and its index. */
15
+ interface Via {
16
+ prefix: string;
17
+ job: string;
18
+ step: number;
19
+ }
20
+ /** One place in the graph a warning is about, by the keys the graph opens and finds rows by. */
21
+ export interface WarningTarget {
22
+ /** The subworkflow steps it sits under, outermost first, each of which opens to show it. */
23
+ via: Via[];
24
+ /** `subworkflows/<job>/step_<n>/` for each of them, as the graph prefixes the level it is on. */
25
+ prefix: string;
26
+ /** The job as the run lists it, such as one run of a matrix. */
27
+ job: string;
28
+ step?: number | undefined;
29
+ /** `prefix + job`, and `:step` for a step's. */
30
+ key: string;
31
+ }
32
+ /** A warning and where it is: one place, or one for each run of its job's matrix; none while the
33
+ * run lists no such job, such as before a subworkflow's jobs are known. */
34
+ export interface LocatedWarning {
35
+ warning: RunWarning;
36
+ targets: WarningTarget[];
37
+ }
38
+ /** The key a job's row, or with `step` one of its step's rows, is found and marked by. */
39
+ export declare const rowKey: (prefix: string, job: string, step?: number) => string;
40
+ /** Finds each warning's job or step among the jobs the run lists, through the subworkflows its path names. */
41
+ export declare function locateWarnings(warnings: readonly RunWarning[], rootJobs: Record<string, WorkflowJob>): LocatedWarning[];
42
+ /** Which warnings each job and step row shows a mark for: its own, and those of anything inside it. */
43
+ export declare class WarningMarks {
44
+ static readonly EMPTY: WarningMarks;
45
+ private readonly byKey;
46
+ /** Equal for two sets of marks that mark the same rows with the same warnings. */
47
+ readonly signature: string;
48
+ constructor(located: readonly LocatedWarning[]);
49
+ /** The warnings about the job or step at `key`, or about anything in it, such as its steps or a
50
+ * subworkflow it runs. */
51
+ under(key: string): RunWarning[];
52
+ /** The warnings under any of `keys`, each once, such as a matrix's runs. */
53
+ underAny(keys: readonly string[]): RunWarning[];
54
+ }
55
+ export declare const WarningMarksContext: import("react").Context<WarningMarks>;
56
+ /** The run's warning marks, which every job list and tree row in a run's graph reads. */
57
+ export declare const useWarningMarks: () => WarningMarks;
58
+ export {};
@@ -0,0 +1,84 @@
1
+ import { createContext, use } from "react";
2
+ //#region src/graph/runWarnings.ts
3
+ /** The key a job's row, or with `step` one of its step's rows, is found and marked by. */
4
+ var rowKey = (prefix, job, step) => step === void 0 ? `${prefix}${job}` : `${prefix}${job}:${step}`;
5
+ function jobsNamed(jobs, name) {
6
+ if (Object.hasOwn(jobs, name)) return [name];
7
+ return Object.keys(jobs).filter((key) => jobs[key]?._matrix?.originaljob === name);
8
+ }
9
+ var STEP_SEGMENT = /^step_(\d+)$/;
10
+ /** Finds each warning's job or step among the jobs the run lists, through the subworkflows its path names. */
11
+ function locateWarnings(warnings, rootJobs) {
12
+ return warnings.map((warning) => {
13
+ let levels = [{
14
+ prefix: "",
15
+ jobs: rootJobs,
16
+ via: []
17
+ }];
18
+ const path = warning.path ?? [];
19
+ for (let i = 0; i + 1 < path.length; i += 2) {
20
+ const index = Number(STEP_SEGMENT.exec(path[i + 1] ?? "")?.[1] ?? NaN);
21
+ levels = levels.flatMap((level) => Number.isNaN(index) ? [] : jobsNamed(level.jobs, path[i] ?? "").flatMap((job) => {
22
+ const jobs = level.jobs[job]?.steps?.[index]?.subworkflow?.jobs;
23
+ return jobs ? [{
24
+ prefix: `${level.prefix}subworkflows/${job}/step_${index}/`,
25
+ jobs,
26
+ via: [...level.via, {
27
+ prefix: level.prefix,
28
+ job,
29
+ step: index
30
+ }]
31
+ }] : [];
32
+ }));
33
+ }
34
+ const step = warning.step ?? void 0;
35
+ return {
36
+ warning,
37
+ targets: levels.flatMap(({ prefix, jobs, via }) => jobsNamed(jobs, warning.job).map((job) => ({
38
+ via,
39
+ prefix,
40
+ job,
41
+ step,
42
+ key: rowKey(prefix, job, step)
43
+ })))
44
+ };
45
+ });
46
+ }
47
+ /** Which warnings each job and step row shows a mark for: its own, and those of anything inside it. */
48
+ var WarningMarks = class WarningMarks {
49
+ static EMPTY = new WarningMarks([]);
50
+ byKey = /* @__PURE__ */ new Map();
51
+ /** Equal for two sets of marks that mark the same rows with the same warnings. */
52
+ signature;
53
+ constructor(located) {
54
+ for (const { warning, targets } of located) for (const target of targets) {
55
+ const keys = target.via.flatMap((v) => [rowKey(v.prefix, v.job), rowKey(v.prefix, v.job, v.step)]);
56
+ keys.push(rowKey(target.prefix, target.job));
57
+ if (target.step !== void 0) keys.push(target.key);
58
+ for (const key of keys) {
59
+ const marked = this.byKey.get(key);
60
+ if (!marked) this.byKey.set(key, [warning]);
61
+ else if (!marked.includes(warning)) marked.push(warning);
62
+ }
63
+ }
64
+ this.signature = JSON.stringify([...this.byKey].map(([key, marked]) => [key, marked.map((w) => [
65
+ w.message,
66
+ w.job,
67
+ w.step
68
+ ])]));
69
+ }
70
+ /** The warnings about the job or step at `key`, or about anything in it, such as its steps or a
71
+ * subworkflow it runs. */
72
+ under(key) {
73
+ return this.byKey.get(key) ?? [];
74
+ }
75
+ /** The warnings under any of `keys`, each once, such as a matrix's runs. */
76
+ underAny(keys) {
77
+ return [...new Set(keys.flatMap((key) => this.under(key)))];
78
+ }
79
+ };
80
+ var WarningMarksContext = createContext(WarningMarks.EMPTY);
81
+ /** The run's warning marks, which every job list and tree row in a run's graph reads. */
82
+ var useWarningMarks = () => use(WarningMarksContext);
83
+ //#endregion
84
+ export { WarningMarks, WarningMarksContext, locateWarnings, rowKey, useWarningMarks };
@@ -38,3 +38,22 @@ export declare function fitView(bounds: Bounds, panel: {
38
38
  }): View | null;
39
39
  /** The least shift that brings [start, end] inside [low, high]; none when it can't all fit, so the view stays. */
40
40
  export declare function intoView(start: number, end: number, low: number, high: number): number;
41
+ /** A box on screen, as `getBoundingClientRect` gives it. */
42
+ export interface Rect {
43
+ left: number;
44
+ top: number;
45
+ width: number;
46
+ height: number;
47
+ }
48
+ /** The smallest box around all of `rects`, which must not be empty. */
49
+ export declare function enclosing(rects: readonly Rect[]): Rect;
50
+ /**
51
+ * The view that shows `region`, a job or step and what it is in, in `visible`, zoomed in to `readable`
52
+ * if it is further out: all of it in the middle where it fits, or else `target`, a part of it, with as
53
+ * much of the rest before it as fits. None while all of it already shows, readable. Boxes are on screen,
54
+ * and a view's x and y count from `origin`, where the pan and zoom place the graph.
55
+ */
56
+ export declare function viewShowing(region: Rect, target: Rect, visible: Rect, origin: {
57
+ left: number;
58
+ top: number;
59
+ }, view: View, readable: number): View | null;
@@ -52,5 +52,66 @@ function intoView(start, end, low, high) {
52
52
  if (start < low) return low - start;
53
53
  return end > high ? high - end : 0;
54
54
  }
55
+ /** The smallest box around all of `rects`, which must not be empty. */
56
+ function enclosing(rects) {
57
+ const left = Math.min(...rects.map((r) => r.left));
58
+ const top = Math.min(...rects.map((r) => r.top));
59
+ const right = Math.max(...rects.map((r) => r.left + r.width));
60
+ const bottom = Math.max(...rects.map((r) => r.top + r.height));
61
+ return {
62
+ left,
63
+ top,
64
+ width: right - left,
65
+ height: bottom - top
66
+ };
67
+ }
68
+ var within = (inner, outer, margin) => inner.left >= outer.left + margin && inner.top >= outer.top + margin && inner.left + inner.width <= outer.left + outer.width - margin && inner.top + inner.height <= outer.top + outer.height - margin;
69
+ function placeOnAxis(region, target, visible, margin) {
70
+ const low = visible.start + margin;
71
+ const high = visible.start + visible.size - margin;
72
+ if (region.size <= high - low) return visible.start + (visible.size - region.size) / 2;
73
+ const before = target.start - region.start;
74
+ if (target.size > high - low) return visible.start + (visible.size - target.size) / 2 - before;
75
+ return Math.min(low, high - target.size - before);
76
+ }
77
+ /**
78
+ * The view that shows `region`, a job or step and what it is in, in `visible`, zoomed in to `readable`
79
+ * if it is further out: all of it in the middle where it fits, or else `target`, a part of it, with as
80
+ * much of the rest before it as fits. None while all of it already shows, readable. Boxes are on screen,
81
+ * and a view's x and y count from `origin`, where the pan and zoom place the graph.
82
+ */
83
+ function viewShowing(region, target, visible, origin, view, readable) {
84
+ const margin = fitMargin(visible);
85
+ if (view.scale >= readable && within(region, visible, margin)) return null;
86
+ const scale = Math.max(view.scale, readable);
87
+ const grow = scale / view.scale;
88
+ const x = (left) => (left - origin.left - view.x) / view.scale * scale;
89
+ const y = (top) => (top - origin.top - view.y) / view.scale * scale;
90
+ const left = placeOnAxis({
91
+ start: x(region.left),
92
+ size: region.width * grow
93
+ }, {
94
+ start: x(target.left),
95
+ size: target.width * grow
96
+ }, {
97
+ start: visible.left,
98
+ size: visible.width
99
+ }, margin);
100
+ const top = placeOnAxis({
101
+ start: y(region.top),
102
+ size: region.height * grow
103
+ }, {
104
+ start: y(target.top),
105
+ size: target.height * grow
106
+ }, {
107
+ start: visible.top,
108
+ size: visible.height
109
+ }, margin);
110
+ return {
111
+ x: left - origin.left - x(region.left),
112
+ y: top - origin.top - y(region.top),
113
+ scale
114
+ };
115
+ }
55
116
  //#endregion
56
- export { FIT_MAX_SCALE, MIN_SCALE, ZOOM_STEP, fitMargin, fitView, intoView, wheelFactor, zoomAround, zoomLimit };
117
+ export { FIT_MAX_SCALE, MIN_SCALE, ZOOM_STEP, enclosing, fitMargin, fitView, intoView, viewShowing, wheelFactor, zoomAround, zoomLimit };