@appscode/design-system 2.17.87 → 2.17.91-alpha.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.
- package/package.json +1 -1
- package/vue-components/index.ts +18 -0
- package/vue-components/styles/base/utilities/_grid.scss +22 -0
- package/vue-components/styles/base/utilities/_typography.scss +30 -0
- package/vue-components/styles/components/_badge-tags.scss +10 -8
- package/vue-components/styles/components/charts/_recoverable-window.scss +15 -0
- package/vue-components/v3/button/SegmentedControl.vue +64 -0
- package/vue-components/v3/cards/ConfirmNameCard.vue +201 -0
- package/vue-components/v3/cards/DetailCard.vue +58 -0
- package/vue-components/v3/cards/RadioCard.vue +162 -0
- package/vue-components/v3/cards/StatCard.vue +50 -0
- package/vue-components/v3/cards/SummaryCard.vue +176 -0
- package/vue-components/v3/cards/TableCard.vue +61 -0
- package/vue-components/v3/charts/RecoverableWindow.vue +240 -0
- package/vue-components/v3/charts/RecoverableWindowTimeline.vue +953 -0
- package/vue-components/v3/charts/ScheduledMarker.vue +18 -0
- package/vue-components/v3/charts/TimelineTrack.vue +405 -0
- package/vue-components/v3/charts/timeline.ts +142 -0
- package/vue-components/v3/charts/types.ts +51 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { useResizeObserver } from "@vueuse/core";
|
|
4
|
+
import HeroiconsArrowPath from "~icons/heroicons/arrow-path";
|
|
5
|
+
|
|
6
|
+
type TimeValue = number | string | Date;
|
|
7
|
+
type StatusType = "success" | "warning" | "danger";
|
|
8
|
+
type LegendKey = "recoverable" | "snapshot" | "failed" | "scheduled" | "selected";
|
|
9
|
+
|
|
10
|
+
interface TimeRange {
|
|
11
|
+
start: TimeValue;
|
|
12
|
+
end: TimeValue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Tick {
|
|
16
|
+
time: TimeValue;
|
|
17
|
+
label?: string;
|
|
18
|
+
highlight?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface LegendLabels {
|
|
22
|
+
recoverable?: string;
|
|
23
|
+
snapshot?: string;
|
|
24
|
+
failed?: string;
|
|
25
|
+
scheduled?: string;
|
|
26
|
+
selected?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TimelineRow {
|
|
30
|
+
// unique key; defaults to the row index
|
|
31
|
+
key?: string;
|
|
32
|
+
label: string;
|
|
33
|
+
// colors the dot in front of the label
|
|
34
|
+
status?: StatusType;
|
|
35
|
+
// "continuous" fills the recoverable ranges (e.g. PITR), "discrete" draws every snapshot as a block;
|
|
36
|
+
// defaults to "continuous" when the row has recoverable ranges
|
|
37
|
+
variant?: "continuous" | "discrete";
|
|
38
|
+
recoverableRanges?: TimeRange[];
|
|
39
|
+
snapshots?: TimeValue[];
|
|
40
|
+
failedCheckpoints?: TimeValue[];
|
|
41
|
+
// upcoming runs, drawn as outlined blocks
|
|
42
|
+
scheduled?: TimeValue[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RangeOption {
|
|
46
|
+
label: string;
|
|
47
|
+
value: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface LegendNote {
|
|
51
|
+
label: string;
|
|
52
|
+
value: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
title?: string;
|
|
57
|
+
subtitle?: string;
|
|
58
|
+
statusLabel?: string;
|
|
59
|
+
statusText?: string;
|
|
60
|
+
statusType?: StatusType;
|
|
61
|
+
// visible time range of the timeline
|
|
62
|
+
rangeStart: TimeValue;
|
|
63
|
+
rangeEnd: TimeValue;
|
|
64
|
+
// current time marker; defaults to rangeEnd
|
|
65
|
+
now?: TimeValue;
|
|
66
|
+
nowLabel?: string;
|
|
67
|
+
// data of the single timeline; ignored when `rows` is set
|
|
68
|
+
recoverableRanges?: TimeRange[];
|
|
69
|
+
snapshots?: TimeValue[];
|
|
70
|
+
failedCheckpoints?: TimeValue[];
|
|
71
|
+
// labeled timelines sharing one time axis, e.g. a database with its config and cache snapshots
|
|
72
|
+
rows?: TimelineRow[];
|
|
73
|
+
// explicit tick marks; when empty, `tickCount` evenly spaced ticks are generated
|
|
74
|
+
ticks?: Tick[];
|
|
75
|
+
tickCount?: number;
|
|
76
|
+
// formats a timestamp (ms) for tick labels and tooltips
|
|
77
|
+
formatTime?: (time: number) => string;
|
|
78
|
+
// range presets shown as a toggle in the header, e.g. 24h / 7d / 30d; the active one is `v-model:range`
|
|
79
|
+
rangeOptions?: RangeOption[];
|
|
80
|
+
// show a refresh button in the header that emits `refresh`
|
|
81
|
+
showRefresh?: boolean;
|
|
82
|
+
legendLabels?: LegendLabels;
|
|
83
|
+
// legend entries in display order; defaults depend on the layout
|
|
84
|
+
legendItems?: LegendKey[];
|
|
85
|
+
// shown at the end of the legend as "label: value", e.g. "etcd interval: 2h"
|
|
86
|
+
legendNote?: LegendNote;
|
|
87
|
+
showLegend?: boolean;
|
|
88
|
+
// allow selecting a timestamp by clicking inside a recoverable range or on a snapshot block
|
|
89
|
+
selectable?: boolean;
|
|
90
|
+
modifierClasses?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
94
|
+
title: "Recoverable Window",
|
|
95
|
+
subtitle: "",
|
|
96
|
+
statusLabel: "Continuous Log Streaming",
|
|
97
|
+
statusText: "",
|
|
98
|
+
statusType: "success",
|
|
99
|
+
now: undefined,
|
|
100
|
+
nowLabel: "now",
|
|
101
|
+
recoverableRanges: () => [],
|
|
102
|
+
snapshots: () => [],
|
|
103
|
+
failedCheckpoints: () => [],
|
|
104
|
+
rows: () => [],
|
|
105
|
+
ticks: () => [],
|
|
106
|
+
tickCount: 6,
|
|
107
|
+
formatTime: (time: number) => {
|
|
108
|
+
const d = new Date(time);
|
|
109
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
110
|
+
},
|
|
111
|
+
rangeOptions: () => [],
|
|
112
|
+
showRefresh: false,
|
|
113
|
+
legendLabels: () => ({}),
|
|
114
|
+
legendItems: undefined,
|
|
115
|
+
legendNote: undefined,
|
|
116
|
+
showLegend: true,
|
|
117
|
+
selectable: true,
|
|
118
|
+
modifierClasses: "",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const emit = defineEmits<{
|
|
122
|
+
// `row` is the entry of `rows` the timestamp was picked from
|
|
123
|
+
select: [time: number, row?: TimelineRow];
|
|
124
|
+
refresh: [];
|
|
125
|
+
}>();
|
|
126
|
+
|
|
127
|
+
// selected timestamp in ms
|
|
128
|
+
const selected = defineModel<number | null>({ default: null });
|
|
129
|
+
// value of the active range option
|
|
130
|
+
const range = defineModel<string>("range", { default: "" });
|
|
131
|
+
|
|
132
|
+
const activeRange = computed(() => range.value || props.rangeOptions[0]?.value);
|
|
133
|
+
|
|
134
|
+
const toMs = (value: TimeValue) => new Date(value).getTime();
|
|
135
|
+
|
|
136
|
+
const startMs = computed(() => toMs(props.rangeStart));
|
|
137
|
+
const endMs = computed(() => toMs(props.rangeEnd));
|
|
138
|
+
const nowMs = computed(() => (props.now !== undefined ? toMs(props.now) : endMs.value));
|
|
139
|
+
const span = computed(() => Math.max(endMs.value - startMs.value, 1));
|
|
140
|
+
|
|
141
|
+
const toPercent = (time: number) => Math.min(Math.max(((time - startMs.value) / span.value) * 100, 0), 100);
|
|
142
|
+
|
|
143
|
+
interface Marker {
|
|
144
|
+
time: number;
|
|
145
|
+
left: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface Lane {
|
|
149
|
+
key: string;
|
|
150
|
+
row: TimelineRow;
|
|
151
|
+
variant: "continuous" | "discrete";
|
|
152
|
+
ranges: { start: number; end: number; left: number; width: number }[];
|
|
153
|
+
snapshots: Marker[];
|
|
154
|
+
failed: Marker[];
|
|
155
|
+
scheduled: Marker[];
|
|
156
|
+
blockWidth?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const toMarkers = (times: TimeValue[] = []): Marker[] =>
|
|
160
|
+
times.map((t) => ({ time: toMs(t), left: toPercent(toMs(t)) }));
|
|
161
|
+
|
|
162
|
+
// narrow the blocks when snapshots sit closer together than a block is wide (long ranges)
|
|
163
|
+
const blockWidth = (markers: Marker[]) => {
|
|
164
|
+
const lefts = markers.map((m) => m.left).sort((a, b) => a - b);
|
|
165
|
+
const gaps = lefts
|
|
166
|
+
.slice(1)
|
|
167
|
+
.map((left, i) => left - lefts[i])
|
|
168
|
+
.filter((gap) => gap > 0);
|
|
169
|
+
return gaps.length ? `min(10px, ${Math.min(...gaps) * 0.6}%)` : undefined;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const isMulti = computed(() => props.rows.length > 0);
|
|
173
|
+
|
|
174
|
+
// without `rows` the component renders one unlabeled continuous lane from the top level props
|
|
175
|
+
const lanes = computed(() => {
|
|
176
|
+
const rows: TimelineRow[] = isMulti.value
|
|
177
|
+
? props.rows
|
|
178
|
+
: [
|
|
179
|
+
{
|
|
180
|
+
label: props.title,
|
|
181
|
+
recoverableRanges: props.recoverableRanges,
|
|
182
|
+
snapshots: props.snapshots,
|
|
183
|
+
failedCheckpoints: props.failedCheckpoints,
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
return rows.map((row, idx): Lane => {
|
|
187
|
+
const snapshots = toMarkers(row.snapshots);
|
|
188
|
+
const failed = toMarkers(row.failedCheckpoints);
|
|
189
|
+
const scheduled = toMarkers(row.scheduled);
|
|
190
|
+
return {
|
|
191
|
+
key: row.key ?? String(idx),
|
|
192
|
+
row,
|
|
193
|
+
variant: row.variant ?? (!isMulti.value || row.recoverableRanges?.length ? "continuous" : "discrete"),
|
|
194
|
+
ranges: (row.recoverableRanges ?? []).map((r) => {
|
|
195
|
+
const start = toMs(r.start);
|
|
196
|
+
const end = toMs(r.end);
|
|
197
|
+
const left = toPercent(start);
|
|
198
|
+
return { start, end, left, width: toPercent(end) - left };
|
|
199
|
+
}),
|
|
200
|
+
snapshots,
|
|
201
|
+
failed,
|
|
202
|
+
scheduled,
|
|
203
|
+
blockWidth: blockWidth([...snapshots, ...failed, ...scheduled]),
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const tickMarks = computed(() => {
|
|
209
|
+
const source: Tick[] = props.ticks.length
|
|
210
|
+
? props.ticks
|
|
211
|
+
: Array.from({ length: props.tickCount }, (_, i) => ({
|
|
212
|
+
time: startMs.value + (span.value / props.tickCount) * i,
|
|
213
|
+
}));
|
|
214
|
+
return source.map((tick) => {
|
|
215
|
+
const time = toMs(tick.time);
|
|
216
|
+
return { time, left: toPercent(time), label: tick.label ?? props.formatTime(time), highlight: !!tick.highlight };
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// grid lines inside the continuous rows
|
|
221
|
+
const gridLines = computed(() => tickMarks.value.filter((tick) => tick.left > 0 && tick.left < 100));
|
|
222
|
+
|
|
223
|
+
const isRecoverable = (lane: Lane, time: number) => lane.ranges.some((r) => time >= r.start && time <= r.end);
|
|
224
|
+
|
|
225
|
+
const legend = computed(() => ({
|
|
226
|
+
recoverable: "Any point recoverable (WAL Replay)",
|
|
227
|
+
snapshot: "Consistent Snapshot",
|
|
228
|
+
failed: "Failed checkpoint",
|
|
229
|
+
scheduled: "Next scheduled",
|
|
230
|
+
selected: "Selected Timestamp",
|
|
231
|
+
...props.legendLabels,
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
const legendKeys = computed<LegendKey[]>(
|
|
235
|
+
() =>
|
|
236
|
+
props.legendItems ??
|
|
237
|
+
(isMulti.value
|
|
238
|
+
? ["recoverable", "snapshot", "failed", "scheduled"]
|
|
239
|
+
: ["recoverable", "snapshot", "failed", "selected"]),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// rows only mark the selection where it is recoverable
|
|
243
|
+
const selectedTime = computed(() => selected.value ?? null);
|
|
244
|
+
|
|
245
|
+
const select = (time: number, lane: Lane) => {
|
|
246
|
+
if (!props.selectable) return;
|
|
247
|
+
selected.value = time;
|
|
248
|
+
emit("select", time, isMulti.value ? lane.row : undefined);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// hover indicator
|
|
252
|
+
const hover = ref<{ lane: string; time: number } | null>(null);
|
|
253
|
+
|
|
254
|
+
const timeFromEvent = (event: MouseEvent) => {
|
|
255
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
256
|
+
const ratio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
|
|
257
|
+
return Math.round(startMs.value + ratio * span.value);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const onMouseMove = (event: MouseEvent, lane: Lane) => {
|
|
261
|
+
if (!props.selectable || lane.variant !== "continuous") return;
|
|
262
|
+
const time = timeFromEvent(event);
|
|
263
|
+
hover.value = isRecoverable(lane, time) ? { lane: lane.key, time } : null;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const onClick = (event: MouseEvent, lane: Lane) => {
|
|
267
|
+
if (!props.selectable || lane.variant !== "continuous") return;
|
|
268
|
+
const time = timeFromEvent(event);
|
|
269
|
+
if (isRecoverable(lane, time)) select(time, lane);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// hide the tick labels the now label would overlap
|
|
273
|
+
const ticksRef = ref<HTMLElement | null>(null);
|
|
274
|
+
const nowLabelRef = ref<HTMLElement | null>(null);
|
|
275
|
+
const coveredTicks = ref<number[]>([]);
|
|
276
|
+
|
|
277
|
+
const updateCoveredTicks = () => {
|
|
278
|
+
if (!ticksRef.value || !nowLabelRef.value?.offsetWidth) {
|
|
279
|
+
coveredTicks.value = [];
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const nowRect = nowLabelRef.value.getBoundingClientRect();
|
|
283
|
+
const labels = Array.from(ticksRef.value.querySelectorAll<HTMLElement>(".rw-tick:not(.is-now)"));
|
|
284
|
+
coveredTicks.value = labels.flatMap((label, idx) => {
|
|
285
|
+
const rect = label.getBoundingClientRect();
|
|
286
|
+
return rect.right + 8 > nowRect.left && rect.left - 8 < nowRect.right ? [idx] : [];
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
useResizeObserver(ticksRef, updateCoveredTicks);
|
|
291
|
+
useResizeObserver(nowLabelRef, updateCoveredTicks);
|
|
292
|
+
watch([tickMarks, nowMs, isMulti], updateCoveredTicks, { flush: "post" });
|
|
293
|
+
</script>
|
|
294
|
+
|
|
295
|
+
<template>
|
|
296
|
+
<div class="recoverable-window" :class="[modifierClasses, { 'is-multi': isMulti }]">
|
|
297
|
+
<div class="rw-header is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap gap-8">
|
|
298
|
+
<div>
|
|
299
|
+
<h6 class="rw-title mb-0">{{ title }}</h6>
|
|
300
|
+
<p v-if="subtitle" class="rw-subtitle mb-0">{{ subtitle }}</p>
|
|
301
|
+
</div>
|
|
302
|
+
<div
|
|
303
|
+
v-if="statusText || rangeOptions.length || showRefresh || $slots.actions"
|
|
304
|
+
class="is-flex is-align-items-center is-flex-wrap-wrap gap-8"
|
|
305
|
+
>
|
|
306
|
+
<p v-if="statusText" class="rw-status mb-0">
|
|
307
|
+
{{ statusLabel }}: <strong :class="`is-${statusType}`">{{ statusText }}</strong>
|
|
308
|
+
</p>
|
|
309
|
+
<slot name="actions">
|
|
310
|
+
<div v-if="rangeOptions.length" class="rw-range-toggle" role="group" aria-label="Time range">
|
|
311
|
+
<button
|
|
312
|
+
v-for="option in rangeOptions"
|
|
313
|
+
:key="option.value"
|
|
314
|
+
type="button"
|
|
315
|
+
class="rw-range-option"
|
|
316
|
+
:class="{ 'is-active': option.value === activeRange }"
|
|
317
|
+
:aria-pressed="option.value === activeRange"
|
|
318
|
+
@click="range = option.value"
|
|
319
|
+
>
|
|
320
|
+
{{ option.label }}
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
<button
|
|
324
|
+
v-if="showRefresh"
|
|
325
|
+
type="button"
|
|
326
|
+
class="rw-refresh"
|
|
327
|
+
title="Refresh"
|
|
328
|
+
aria-label="Refresh"
|
|
329
|
+
@click="emit('refresh')"
|
|
330
|
+
>
|
|
331
|
+
<HeroiconsArrowPath />
|
|
332
|
+
</button>
|
|
333
|
+
</slot>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div class="rw-lanes">
|
|
338
|
+
<template v-for="lane in lanes" :key="lane.key">
|
|
339
|
+
<div v-if="isMulti" class="rw-lane-label" :title="lane.row.label">
|
|
340
|
+
<span v-if="lane.row.status" class="rw-dot" :class="`is-${lane.row.status}`" />
|
|
341
|
+
<span class="rw-lane-name">{{ lane.row.label }}</span>
|
|
342
|
+
</div>
|
|
343
|
+
<div
|
|
344
|
+
class="rw-track"
|
|
345
|
+
:class="[`is-${lane.variant}`, { 'is-selectable': selectable && lane.variant === 'continuous' }]"
|
|
346
|
+
:style="{ '--rw-block-width': lane.blockWidth }"
|
|
347
|
+
@mousemove="onMouseMove($event, lane)"
|
|
348
|
+
@mouseleave="hover = null"
|
|
349
|
+
@click="onClick($event, lane)"
|
|
350
|
+
>
|
|
351
|
+
<template v-if="lane.variant === 'continuous'">
|
|
352
|
+
<span
|
|
353
|
+
v-for="(r, idx) in lane.ranges"
|
|
354
|
+
:key="`range-${idx}`"
|
|
355
|
+
class="rw-range"
|
|
356
|
+
:style="{ left: `${r.left}%`, width: `${r.width}%` }"
|
|
357
|
+
/>
|
|
358
|
+
<template v-if="isMulti">
|
|
359
|
+
<span
|
|
360
|
+
v-for="(tick, idx) in gridLines"
|
|
361
|
+
:key="`grid-${idx}`"
|
|
362
|
+
class="rw-grid"
|
|
363
|
+
:style="{ left: `${tick.left}%` }"
|
|
364
|
+
/>
|
|
365
|
+
</template>
|
|
366
|
+
<span
|
|
367
|
+
v-for="(marker, idx) in lane.snapshots"
|
|
368
|
+
:key="`snap-${idx}`"
|
|
369
|
+
class="rw-marker is-snapshot"
|
|
370
|
+
:style="{ left: `${marker.left}%` }"
|
|
371
|
+
:title="`${legend.snapshot}: ${formatTime(marker.time)}`"
|
|
372
|
+
/>
|
|
373
|
+
<span
|
|
374
|
+
v-for="(marker, idx) in lane.failed"
|
|
375
|
+
:key="`fail-${idx}`"
|
|
376
|
+
class="rw-marker is-failed"
|
|
377
|
+
:style="{ left: `${marker.left}%` }"
|
|
378
|
+
:title="`${legend.failed}: ${formatTime(marker.time)}`"
|
|
379
|
+
/>
|
|
380
|
+
<span v-if="hover?.lane === lane.key" class="rw-hover" :style="{ left: `${toPercent(hover.time)}%` }">
|
|
381
|
+
<span class="rw-hover-label">{{ formatTime(hover.time) }}</span>
|
|
382
|
+
</span>
|
|
383
|
+
<span
|
|
384
|
+
v-if="selectedTime !== null && (!isMulti || isRecoverable(lane, selectedTime))"
|
|
385
|
+
class="rw-marker is-selected"
|
|
386
|
+
:style="{ left: `${toPercent(selectedTime)}%` }"
|
|
387
|
+
:title="`${legend.selected}: ${formatTime(selectedTime)}`"
|
|
388
|
+
/>
|
|
389
|
+
<span v-if="!isMulti" class="rw-marker is-now" :style="{ left: `${toPercent(nowMs)}%` }" />
|
|
390
|
+
</template>
|
|
391
|
+
<template v-else>
|
|
392
|
+
<span
|
|
393
|
+
v-for="(marker, idx) in lane.snapshots"
|
|
394
|
+
:key="`snap-${idx}`"
|
|
395
|
+
class="rw-block is-snapshot"
|
|
396
|
+
:class="{ 'is-clickable': selectable, 'is-selected': marker.time === selectedTime }"
|
|
397
|
+
:style="{ left: `${marker.left}%` }"
|
|
398
|
+
:title="`${legend.snapshot}: ${formatTime(marker.time)}`"
|
|
399
|
+
@click="select(marker.time, lane)"
|
|
400
|
+
/>
|
|
401
|
+
<span
|
|
402
|
+
v-for="(marker, idx) in lane.failed"
|
|
403
|
+
:key="`fail-${idx}`"
|
|
404
|
+
class="rw-block is-failed"
|
|
405
|
+
:style="{ left: `${marker.left}%` }"
|
|
406
|
+
:title="`${legend.failed}: ${formatTime(marker.time)}`"
|
|
407
|
+
/>
|
|
408
|
+
<span
|
|
409
|
+
v-for="(marker, idx) in lane.scheduled"
|
|
410
|
+
:key="`next-${idx}`"
|
|
411
|
+
class="rw-block is-scheduled"
|
|
412
|
+
:style="{ left: `${marker.left}%` }"
|
|
413
|
+
:title="`${legend.scheduled}: ${formatTime(marker.time)}`"
|
|
414
|
+
/>
|
|
415
|
+
</template>
|
|
416
|
+
</div>
|
|
417
|
+
</template>
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
<div class="rw-axis">
|
|
421
|
+
<div ref="ticksRef" class="rw-ticks">
|
|
422
|
+
<span
|
|
423
|
+
v-for="(tick, idx) in tickMarks"
|
|
424
|
+
:key="`tick-${idx}`"
|
|
425
|
+
class="rw-tick"
|
|
426
|
+
:class="{
|
|
427
|
+
'is-highlight': tick.highlight,
|
|
428
|
+
'is-first': tick.left === 0,
|
|
429
|
+
'is-covered': coveredTicks.includes(idx),
|
|
430
|
+
}"
|
|
431
|
+
:style="{ left: `${tick.left}%` }"
|
|
432
|
+
>
|
|
433
|
+
{{ tick.label }}
|
|
434
|
+
</span>
|
|
435
|
+
<span ref="nowLabelRef" class="rw-tick is-now" :style="isMulti ? undefined : { left: `${toPercent(nowMs)}%` }">
|
|
436
|
+
{{ nowLabel }}
|
|
437
|
+
</span>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<div v-if="showLegend" class="rw-legend is-flex is-flex-wrap-wrap is-align-items-center">
|
|
442
|
+
<span v-for="key in legendKeys" :key="key" class="rw-legend-item">
|
|
443
|
+
<span class="rw-swatch" :class="`is-${key}`" />{{ legend[key] }}
|
|
444
|
+
</span>
|
|
445
|
+
<span v-if="legendNote" class="rw-legend-note">
|
|
446
|
+
{{ legendNote.label }}: <strong>{{ legendNote.value }}</strong>
|
|
447
|
+
</span>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</template>
|
|
451
|
+
|
|
452
|
+
<style scoped lang="scss">
|
|
453
|
+
.recoverable-window {
|
|
454
|
+
--rw-recoverable: hsl(158, 59%, 49%);
|
|
455
|
+
--rw-recoverable-start: hsl(161, 94%, 31%);
|
|
456
|
+
--rw-recoverable-end: hsl(158, 63%, 51%);
|
|
457
|
+
--rw-snapshot: hsl(145, 60%, 24%);
|
|
458
|
+
--rw-block: hsl(149, 100%, 30%);
|
|
459
|
+
--rw-failed: hsl(0, 84%, 60%);
|
|
460
|
+
--rw-scheduled: #{$slate-60};
|
|
461
|
+
--rw-selected: #{$color-heading};
|
|
462
|
+
--rw-track-bg: #{$slate-95};
|
|
463
|
+
--rw-border: #{$color-border-light};
|
|
464
|
+
--rw-divider: #{$color-border-light};
|
|
465
|
+
--rw-tick-color: #{$color-label};
|
|
466
|
+
--rw-label-width: 152px;
|
|
467
|
+
--rw-mono: "Inconsolata", monospace;
|
|
468
|
+
|
|
469
|
+
padding: 24px 28px;
|
|
470
|
+
border: 1px solid var(--rw-border);
|
|
471
|
+
border-radius: 12px;
|
|
472
|
+
background-color: $white-100;
|
|
473
|
+
|
|
474
|
+
// labeled rows layout
|
|
475
|
+
&.is-multi {
|
|
476
|
+
--rw-recoverable: hsl(160, 84%, 39%);
|
|
477
|
+
--rw-failed: hsl(0, 72%, 51%);
|
|
478
|
+
--rw-track-bg: #{$slate-90};
|
|
479
|
+
--rw-border: #{$color-border};
|
|
480
|
+
--rw-tick-color: #{$slate-50};
|
|
481
|
+
|
|
482
|
+
padding: 24px;
|
|
483
|
+
|
|
484
|
+
@media (max-width: 576px) {
|
|
485
|
+
--rw-label-width: 108px;
|
|
486
|
+
|
|
487
|
+
padding: 16px;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.rw-header {
|
|
493
|
+
margin-bottom: 8px;
|
|
494
|
+
padding-bottom: 4px;
|
|
495
|
+
|
|
496
|
+
.is-multi & {
|
|
497
|
+
margin-bottom: 24px;
|
|
498
|
+
padding-bottom: 16px;
|
|
499
|
+
border-bottom: 1px solid var(--rw-divider);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.rw-title {
|
|
504
|
+
font-size: 14px;
|
|
505
|
+
font-weight: 600;
|
|
506
|
+
color: $color-heading;
|
|
507
|
+
|
|
508
|
+
.is-multi & {
|
|
509
|
+
font-size: 16px;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.rw-subtitle {
|
|
514
|
+
margin-top: 2px;
|
|
515
|
+
font-size: 13px;
|
|
516
|
+
color: $slate-50;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.rw-status {
|
|
520
|
+
font-family: var(--rw-mono);
|
|
521
|
+
font-size: 13px;
|
|
522
|
+
color: $color-label;
|
|
523
|
+
|
|
524
|
+
strong {
|
|
525
|
+
font-weight: 700;
|
|
526
|
+
|
|
527
|
+
&.is-success {
|
|
528
|
+
color: hsl(145, 60%, 32%);
|
|
529
|
+
}
|
|
530
|
+
&.is-warning {
|
|
531
|
+
color: hsl(36, 90%, 40%);
|
|
532
|
+
}
|
|
533
|
+
&.is-danger {
|
|
534
|
+
color: var(--rw-failed);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.rw-range-toggle {
|
|
540
|
+
display: inline-flex;
|
|
541
|
+
gap: 2px;
|
|
542
|
+
padding: 2px;
|
|
543
|
+
border: 1px solid $color-border;
|
|
544
|
+
border-radius: 8px;
|
|
545
|
+
background-color: $white-100;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.rw-range-option,
|
|
549
|
+
.rw-refresh {
|
|
550
|
+
border: 0;
|
|
551
|
+
font-size: 13px;
|
|
552
|
+
line-height: 18px;
|
|
553
|
+
color: $slate-40;
|
|
554
|
+
background-color: transparent;
|
|
555
|
+
cursor: pointer;
|
|
556
|
+
|
|
557
|
+
&:hover {
|
|
558
|
+
color: $color-heading;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
&:focus-visible {
|
|
562
|
+
outline: 2px solid $color-border-dark;
|
|
563
|
+
outline-offset: 1px;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.rw-range-option {
|
|
568
|
+
padding: 3px 10px;
|
|
569
|
+
border-radius: 6px;
|
|
570
|
+
font-weight: 500;
|
|
571
|
+
|
|
572
|
+
&.is-active {
|
|
573
|
+
font-weight: 700;
|
|
574
|
+
color: $color-heading;
|
|
575
|
+
background-color: $slate-95;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.rw-refresh {
|
|
580
|
+
display: inline-flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
justify-content: center;
|
|
583
|
+
width: 30px;
|
|
584
|
+
height: 30px;
|
|
585
|
+
padding: 0;
|
|
586
|
+
border: 1px solid $color-border;
|
|
587
|
+
border-radius: 8px;
|
|
588
|
+
color: $slate-50;
|
|
589
|
+
background-color: $white-100;
|
|
590
|
+
|
|
591
|
+
svg {
|
|
592
|
+
width: 14px;
|
|
593
|
+
height: 14px;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.rw-lanes {
|
|
598
|
+
.is-multi & {
|
|
599
|
+
display: grid;
|
|
600
|
+
grid-template-columns: var(--rw-label-width) minmax(0, 1fr);
|
|
601
|
+
align-items: center;
|
|
602
|
+
row-gap: 16px;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.rw-lane-label {
|
|
607
|
+
display: flex;
|
|
608
|
+
align-items: center;
|
|
609
|
+
gap: 8px;
|
|
610
|
+
min-width: 0;
|
|
611
|
+
padding-right: 12px;
|
|
612
|
+
font-size: 13px;
|
|
613
|
+
font-weight: 600;
|
|
614
|
+
color: $color-heading;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.rw-lane-name {
|
|
618
|
+
overflow: hidden;
|
|
619
|
+
text-overflow: ellipsis;
|
|
620
|
+
white-space: nowrap;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.rw-dot {
|
|
624
|
+
flex-shrink: 0;
|
|
625
|
+
width: 8px;
|
|
626
|
+
height: 8px;
|
|
627
|
+
border-radius: 50%;
|
|
628
|
+
|
|
629
|
+
&.is-success {
|
|
630
|
+
background-color: hsl(160, 84%, 39%);
|
|
631
|
+
}
|
|
632
|
+
&.is-warning {
|
|
633
|
+
background-color: hsl(25, 95%, 53%);
|
|
634
|
+
}
|
|
635
|
+
&.is-danger {
|
|
636
|
+
background-color: var(--rw-failed);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.rw-track {
|
|
641
|
+
position: relative;
|
|
642
|
+
height: 34px;
|
|
643
|
+
border: 1px solid $color-border;
|
|
644
|
+
background-color: var(--rw-track-bg);
|
|
645
|
+
|
|
646
|
+
&.is-selectable {
|
|
647
|
+
cursor: crosshair;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.is-multi & {
|
|
651
|
+
height: 32px;
|
|
652
|
+
border-radius: 6px;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.is-multi &.is-discrete {
|
|
656
|
+
height: 28px;
|
|
657
|
+
background-color: $white-100;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.rw-range {
|
|
662
|
+
position: absolute;
|
|
663
|
+
top: 0;
|
|
664
|
+
bottom: 0;
|
|
665
|
+
background-color: var(--rw-recoverable);
|
|
666
|
+
|
|
667
|
+
.is-multi & {
|
|
668
|
+
top: 2px;
|
|
669
|
+
bottom: 2px;
|
|
670
|
+
border-radius: 4px;
|
|
671
|
+
background-image: linear-gradient(90deg, var(--rw-recoverable-start), var(--rw-recoverable-end));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.rw-grid {
|
|
676
|
+
position: absolute;
|
|
677
|
+
top: 50%;
|
|
678
|
+
width: 2px;
|
|
679
|
+
height: 12px;
|
|
680
|
+
background-color: rgba(255, 255, 255, 0.25);
|
|
681
|
+
transform: translate(-50%, -50%);
|
|
682
|
+
pointer-events: none;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.rw-marker {
|
|
686
|
+
position: absolute;
|
|
687
|
+
top: 0;
|
|
688
|
+
bottom: 0;
|
|
689
|
+
width: 3px;
|
|
690
|
+
transform: translateX(-50%);
|
|
691
|
+
|
|
692
|
+
&.is-snapshot {
|
|
693
|
+
background-color: var(--rw-snapshot);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
&.is-failed {
|
|
697
|
+
background-color: var(--rw-failed);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.is-multi &.is-snapshot,
|
|
701
|
+
.is-multi &.is-failed {
|
|
702
|
+
top: 2px;
|
|
703
|
+
bottom: 2px;
|
|
704
|
+
width: 2px;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
&.is-selected {
|
|
708
|
+
width: 2px;
|
|
709
|
+
background-color: var(--rw-selected);
|
|
710
|
+
z-index: 2;
|
|
711
|
+
|
|
712
|
+
&::before {
|
|
713
|
+
content: "";
|
|
714
|
+
position: absolute;
|
|
715
|
+
top: -4px;
|
|
716
|
+
left: 50%;
|
|
717
|
+
width: 9px;
|
|
718
|
+
height: 9px;
|
|
719
|
+
border-radius: 50%;
|
|
720
|
+
background-color: var(--rw-selected);
|
|
721
|
+
transform: translateX(-50%);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
&.is-now {
|
|
726
|
+
width: 2px;
|
|
727
|
+
background-color: var(--rw-failed);
|
|
728
|
+
z-index: 3;
|
|
729
|
+
pointer-events: none;
|
|
730
|
+
|
|
731
|
+
&::after {
|
|
732
|
+
content: "";
|
|
733
|
+
position: absolute;
|
|
734
|
+
bottom: -5px;
|
|
735
|
+
left: 50%;
|
|
736
|
+
width: 10px;
|
|
737
|
+
height: 10px;
|
|
738
|
+
border-radius: 50%;
|
|
739
|
+
background-color: var(--rw-failed);
|
|
740
|
+
transform: translateX(-50%);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.rw-block {
|
|
746
|
+
position: absolute;
|
|
747
|
+
top: 50%;
|
|
748
|
+
width: var(--rw-block-width, 10px);
|
|
749
|
+
height: 14px;
|
|
750
|
+
border-radius: 2px;
|
|
751
|
+
transform: translate(-50%, -50%);
|
|
752
|
+
|
|
753
|
+
&.is-snapshot {
|
|
754
|
+
background-color: var(--rw-block);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
&.is-failed {
|
|
758
|
+
background-color: var(--rw-failed);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
&.is-scheduled {
|
|
762
|
+
border: 1px dashed var(--rw-scheduled);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
&.is-clickable {
|
|
766
|
+
cursor: pointer;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
&.is-selected {
|
|
770
|
+
box-shadow:
|
|
771
|
+
0 0 0 2px $white-100,
|
|
772
|
+
0 0 0 3px var(--rw-selected);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
.rw-hover {
|
|
777
|
+
position: absolute;
|
|
778
|
+
top: 0;
|
|
779
|
+
bottom: 0;
|
|
780
|
+
width: 1px;
|
|
781
|
+
background-color: rgba(0, 0, 0, 0.4);
|
|
782
|
+
pointer-events: none;
|
|
783
|
+
z-index: 1;
|
|
784
|
+
|
|
785
|
+
.rw-hover-label {
|
|
786
|
+
position: absolute;
|
|
787
|
+
bottom: calc(100% + 4px);
|
|
788
|
+
left: 50%;
|
|
789
|
+
padding: 2px 6px;
|
|
790
|
+
border-radius: 4px;
|
|
791
|
+
font-family: var(--rw-mono);
|
|
792
|
+
font-size: 11px;
|
|
793
|
+
white-space: nowrap;
|
|
794
|
+
color: $white-100;
|
|
795
|
+
background-color: $color-heading;
|
|
796
|
+
transform: translateX(-50%);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.rw-axis {
|
|
801
|
+
.is-multi & {
|
|
802
|
+
display: grid;
|
|
803
|
+
grid-template-columns: var(--rw-label-width) minmax(0, 1fr);
|
|
804
|
+
margin-top: 16px;
|
|
805
|
+
padding: 8px 0 20px;
|
|
806
|
+
border-top: 1px solid var(--rw-divider);
|
|
807
|
+
border-bottom: 1px solid var(--rw-divider);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.rw-ticks {
|
|
812
|
+
position: relative;
|
|
813
|
+
height: 18px;
|
|
814
|
+
margin: 10px 0 12px;
|
|
815
|
+
padding-bottom: 14px;
|
|
816
|
+
box-sizing: content-box;
|
|
817
|
+
border-bottom: 1px dashed var(--rw-border);
|
|
818
|
+
|
|
819
|
+
.is-multi & {
|
|
820
|
+
grid-column: 2;
|
|
821
|
+
margin: 0;
|
|
822
|
+
padding: 0;
|
|
823
|
+
border: 0;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
.rw-tick {
|
|
828
|
+
position: absolute;
|
|
829
|
+
top: 0;
|
|
830
|
+
font-family: var(--rw-mono);
|
|
831
|
+
font-size: 12px;
|
|
832
|
+
color: var(--rw-tick-color);
|
|
833
|
+
white-space: nowrap;
|
|
834
|
+
transform: translateX(-50%);
|
|
835
|
+
|
|
836
|
+
&.is-first {
|
|
837
|
+
transform: translateX(4px);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
&.is-covered {
|
|
841
|
+
visibility: hidden;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
&.is-highlight {
|
|
845
|
+
font-weight: 700;
|
|
846
|
+
color: hsl(145, 60%, 32%);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
&.is-now {
|
|
850
|
+
font-weight: 700;
|
|
851
|
+
color: var(--rw-failed);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// pinned to the end of the axis
|
|
855
|
+
.is-multi &.is-now {
|
|
856
|
+
right: 0;
|
|
857
|
+
display: inline-flex;
|
|
858
|
+
align-items: center;
|
|
859
|
+
gap: 6px;
|
|
860
|
+
transform: none;
|
|
861
|
+
|
|
862
|
+
&::before {
|
|
863
|
+
content: "";
|
|
864
|
+
width: 6px;
|
|
865
|
+
height: 6px;
|
|
866
|
+
border-radius: 50%;
|
|
867
|
+
background-color: currentColor;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.rw-legend {
|
|
873
|
+
gap: 4px 12px;
|
|
874
|
+
font-size: 13px;
|
|
875
|
+
color: $color-text;
|
|
876
|
+
|
|
877
|
+
.is-multi & {
|
|
878
|
+
gap: 8px 24px;
|
|
879
|
+
padding-top: 12px;
|
|
880
|
+
color: $slate-40;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
.rw-legend-item {
|
|
885
|
+
display: inline-flex;
|
|
886
|
+
align-items: center;
|
|
887
|
+
gap: 6px;
|
|
888
|
+
|
|
889
|
+
.is-multi & {
|
|
890
|
+
gap: 8px;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.rw-legend-note {
|
|
895
|
+
margin-left: auto;
|
|
896
|
+
font-family: var(--rw-mono);
|
|
897
|
+
font-size: 12px;
|
|
898
|
+
color: $slate-60;
|
|
899
|
+
|
|
900
|
+
strong {
|
|
901
|
+
font-weight: 700;
|
|
902
|
+
color: $color-text;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
.rw-swatch {
|
|
907
|
+
display: inline-block;
|
|
908
|
+
width: 14px;
|
|
909
|
+
height: 10px;
|
|
910
|
+
|
|
911
|
+
&.is-recoverable {
|
|
912
|
+
background-color: var(--rw-recoverable);
|
|
913
|
+
}
|
|
914
|
+
&.is-snapshot {
|
|
915
|
+
width: 8px;
|
|
916
|
+
background-color: var(--rw-snapshot);
|
|
917
|
+
}
|
|
918
|
+
&.is-failed {
|
|
919
|
+
width: 10px;
|
|
920
|
+
background-color: var(--rw-failed);
|
|
921
|
+
}
|
|
922
|
+
&.is-scheduled {
|
|
923
|
+
width: 10px;
|
|
924
|
+
border: 1px dashed var(--rw-scheduled);
|
|
925
|
+
}
|
|
926
|
+
&.is-selected {
|
|
927
|
+
width: 3px;
|
|
928
|
+
height: 16px;
|
|
929
|
+
background-color: var(--rw-selected);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// same shapes as the row blocks
|
|
933
|
+
.is-multi & {
|
|
934
|
+
width: 10px;
|
|
935
|
+
height: 12px;
|
|
936
|
+
border-radius: 2px;
|
|
937
|
+
|
|
938
|
+
&.is-recoverable {
|
|
939
|
+
width: 15px;
|
|
940
|
+
height: 15px;
|
|
941
|
+
border-radius: 4px;
|
|
942
|
+
}
|
|
943
|
+
&.is-snapshot {
|
|
944
|
+
background-color: var(--rw-block);
|
|
945
|
+
}
|
|
946
|
+
&.is-selected {
|
|
947
|
+
width: 3px;
|
|
948
|
+
height: 16px;
|
|
949
|
+
border-radius: 0;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
</style>
|