@appscode/design-system 2.17.86 → 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.
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts"></script>
2
+
3
+ <template>
4
+ <svg viewBox="0 0 10 16" fill="none" xmlns="http://www.w3.org/2000/svg">
5
+ <path d="M1 5.2V1.6h3.1" />
6
+ <path d="M9 5.2V1.6H5.9" />
7
+ <path d="M1 10.8v3.6h3.1" />
8
+ <path d="M9 10.8v3.6H5.9" />
9
+ </svg>
10
+ </template>
11
+
12
+ <style lang="scss" scoped>
13
+ svg {
14
+ stroke: currentColor;
15
+ stroke-width: 1.2;
16
+ stroke-linecap: square;
17
+ }
18
+ </style>
@@ -0,0 +1,405 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { useElementSize } from "@vueuse/core";
4
+
5
+ import ScheduledMarker from "./ScheduledMarker.vue";
6
+ import type { RecoveryPoint, RecoveryTrack, TimelineHover } from "./types";
7
+ import {
8
+ clipRange,
9
+ formatTimestamp,
10
+ isRecoverableAt,
11
+ offsetOf,
12
+ pointState,
13
+ subTicks,
14
+ timeAt,
15
+ toMs,
16
+ type PointState,
17
+ type TimeDomain,
18
+ } from "./timeline";
19
+
20
+ interface Props {
21
+ track: RecoveryTrack;
22
+ domain: TimeDomain;
23
+ }
24
+
25
+ const props = defineProps<Props>();
26
+
27
+ const emit = defineEmits<{
28
+ (e: "hover", payload: TimelineHover): void;
29
+ (e: "leave"): void;
30
+ (e: "select", point: RecoveryPoint): void;
31
+ }>();
32
+
33
+ const rail = ref<HTMLElement | null>(null);
34
+ const cursorOffset = ref<number | null>(null);
35
+ const cursorTime = ref<number | null>(null);
36
+ const hoveredMark = ref<Mark | null>(null);
37
+
38
+ const { width: railWidth } = useElementSize(rail);
39
+
40
+ const MARK_SLOT_PX = 14;
41
+ const STATE_PRIORITY: PointState[] = ["failed", "running", "succeeded", "verified", "scheduled"];
42
+
43
+ const isContinuous = computed(() => props.track.kind === "continuous");
44
+
45
+ interface Box {
46
+ left: number;
47
+ width: number;
48
+ }
49
+
50
+ const spans = computed<Box[]>(() =>
51
+ (props.track.spans ?? []).reduce<Box[]>((boxes, span) => {
52
+ const box = clipRange(span, props.domain);
53
+ if (box) boxes.push({ left: box.left, width: box.width });
54
+ return boxes;
55
+ }, []),
56
+ );
57
+
58
+ const gaps = computed<(Box & { error?: string })[]>(() =>
59
+ (props.track.gaps ?? []).reduce<(Box & { error?: string })[]>((boxes, gap) => {
60
+ const box = clipRange(gap, props.domain);
61
+ if (box) boxes.push({ left: box.left, width: box.width, error: gap.error });
62
+ return boxes;
63
+ }, []),
64
+ );
65
+
66
+ interface Mark {
67
+ points: RecoveryPoint[];
68
+ time: number;
69
+ endTime: number;
70
+ offset: number;
71
+ state: PointState;
72
+ failed: number;
73
+ }
74
+
75
+ const rawMarks = computed<Mark[]>(() =>
76
+ (props.track.points ?? []).reduce<Mark[]>((list, point) => {
77
+ const time = toMs(point.snapshotTime);
78
+ if (time === undefined || time < props.domain.start || time > props.domain.end) return list;
79
+ const state = pointState(point);
80
+ list.push({
81
+ points: [point],
82
+ time,
83
+ endTime: time,
84
+ offset: offsetOf(time, props.domain),
85
+ state,
86
+ failed: state === "failed" ? 1 : 0,
87
+ });
88
+ return list;
89
+ }, []),
90
+ );
91
+
92
+ const marks = computed<Mark[]>(() => {
93
+ const list = rawMarks.value;
94
+ const capacity = railWidth.value ? Math.floor(railWidth.value / MARK_SLOT_PX) : list.length;
95
+ if (list.length <= capacity || capacity < 1) return list;
96
+
97
+ const bucketMs = props.domain.durationMs / capacity;
98
+ const buckets = new Map<number, Mark>();
99
+ list.forEach((mark) => {
100
+ const index = Math.min(capacity - 1, Math.floor((mark.time - props.domain.start) / bucketMs));
101
+ const bucket = buckets.get(index);
102
+ if (!bucket) {
103
+ const start = props.domain.start + index * bucketMs;
104
+ buckets.set(index, {
105
+ points: [...mark.points],
106
+ time: start,
107
+ endTime: start + bucketMs,
108
+ offset: offsetOf(start + bucketMs / 2, props.domain),
109
+ state: mark.state,
110
+ failed: mark.failed,
111
+ });
112
+ return;
113
+ }
114
+ bucket.points.push(...mark.points);
115
+ bucket.failed += mark.failed;
116
+ if (STATE_PRIORITY.indexOf(mark.state) < STATE_PRIORITY.indexOf(bucket.state)) bucket.state = mark.state;
117
+ });
118
+ return [...buckets.values()];
119
+ });
120
+
121
+ const nextScheduledOffset = computed(() => {
122
+ const time = toMs(props.track.nextScheduled);
123
+ if (time === undefined) return null;
124
+ return time > props.domain.end ? 100 : offsetOf(time, props.domain);
125
+ });
126
+
127
+ const guides = computed(() => (isContinuous.value ? subTicks(props.domain) : []));
128
+
129
+ const cursorState = computed(() => {
130
+ if (cursorTime.value === null) return null;
131
+ return isRecoverableAt(cursorTime.value, props.track.spans, props.track.gaps);
132
+ });
133
+
134
+ const tooltip = computed(() => {
135
+ const mark = hoveredMark.value;
136
+ if (mark && mark.points.length > 1) {
137
+ const lines = [`${mark.points.length} runs`];
138
+ if (mark.failed) lines.push(`${mark.failed} failed`);
139
+ return { title: `${formatTimestamp(mark.time)} — ${formatTimestamp(mark.endTime)}`, lines };
140
+ }
141
+ if (mark) {
142
+ const point = mark.points[0];
143
+ const lines = [
144
+ point.phase === "Failed"
145
+ ? "Failed run"
146
+ : `${point.phase}${point.verificationStatus ? ` · ${point.verificationStatus}` : ""}`,
147
+ ];
148
+ if (point.size) lines.push(point.size);
149
+ if (point.error) lines.push(point.error);
150
+ return { title: formatTimestamp(mark.time), lines };
151
+ }
152
+ if (cursorTime.value === null) return null;
153
+ if (!isContinuous.value) return null;
154
+ const state = cursorState.value;
155
+ const lines = state?.recoverable
156
+ ? ["Any point recoverable"]
157
+ : [state?.gap ? "Gap — archive push failed" : "Outside recoverable window"];
158
+ if (state?.gap?.error) lines.push(state.gap.error);
159
+ return { title: formatTimestamp(cursorTime.value), lines };
160
+ });
161
+
162
+ function onMove(event: MouseEvent) {
163
+ const el = rail.value;
164
+ if (!el) return;
165
+ const rect = el.getBoundingClientRect();
166
+ const ratio = (event.clientX - rect.left) / rect.width;
167
+ const time = timeAt(ratio, props.domain);
168
+ cursorOffset.value = Math.min(100, Math.max(0, ratio * 100));
169
+ cursorTime.value = time;
170
+ const state = isRecoverableAt(time, props.track.spans, props.track.gaps);
171
+ const mark = hoveredMark.value;
172
+ const point = mark?.points.length === 1 ? mark.points[0] : undefined;
173
+ const recoverable = isContinuous.value ? state.recoverable : !!point && point.phase === "Succeeded";
174
+ emit("hover", { trackKey: props.track.key, time, recoverable, gap: state.gap, point: point ?? undefined });
175
+ }
176
+
177
+ function onLeave() {
178
+ cursorOffset.value = null;
179
+ cursorTime.value = null;
180
+ hoveredMark.value = null;
181
+ emit("leave");
182
+ }
183
+ </script>
184
+
185
+ <template>
186
+ <div
187
+ ref="rail"
188
+ class="rw-track"
189
+ :class="isContinuous ? 'rw-track--continuous' : 'rw-track--discrete'"
190
+ @mousemove="onMove"
191
+ @mouseleave="onLeave"
192
+ >
193
+ <div v-if="isContinuous" class="rw-clip">
194
+ <span v-for="(guide, index) in guides" :key="`guide-${index}`" class="rw-guide" :style="{ left: `${guide}%` }" />
195
+
196
+ <span
197
+ v-for="(item, index) in spans"
198
+ :key="`span-${index}`"
199
+ class="rw-span"
200
+ :style="{ left: `${item.left}%`, width: `${item.width}%` }"
201
+ >
202
+ <span
203
+ class="rw-span-fill"
204
+ :style="{ left: `${(-item.left / item.width) * 100}%`, width: `${(100 / item.width) * 100}%` }"
205
+ />
206
+ </span>
207
+
208
+ <span
209
+ v-for="(item, index) in gaps"
210
+ :key="`gap-${index}`"
211
+ class="rw-gap"
212
+ :style="{ left: `${item.left}%`, width: `${item.width}%` }"
213
+ />
214
+
215
+ <span
216
+ v-for="item in rawMarks"
217
+ :key="`base-${item.time}`"
218
+ class="rw-base"
219
+ :class="`rw-base--${item.state}`"
220
+ :style="{ left: `${item.offset}%` }"
221
+ @mouseenter="hoveredMark = item"
222
+ @mouseleave="hoveredMark = null"
223
+ />
224
+
225
+ <span v-if="cursorOffset !== null" class="rw-cursor" :style="{ left: `${cursorOffset}%` }" />
226
+ </div>
227
+
228
+ <template v-else>
229
+ <span
230
+ v-for="item in marks"
231
+ :key="item.time"
232
+ class="rw-mark"
233
+ :class="[`rw-mark--${item.state}`, { 'rw-mark--grouped': item.points.length > 1 }]"
234
+ :style="{ left: `${item.offset}%` }"
235
+ @mouseenter="hoveredMark = item"
236
+ @mouseleave="hoveredMark = null"
237
+ @click="emit('select', item.points[0])"
238
+ />
239
+ </template>
240
+
241
+ <span
242
+ v-if="nextScheduledOffset !== null"
243
+ class="rw-mark rw-mark--scheduled"
244
+ :class="{ 'rw-mark--edge': nextScheduledOffset >= 99 }"
245
+ :style="nextScheduledOffset >= 99 ? undefined : { left: `${nextScheduledOffset}%` }"
246
+ :title="`Next scheduled${track.interval ? ` · every ${track.interval}` : ''}`"
247
+ >
248
+ <scheduled-marker />
249
+ </span>
250
+
251
+ <div
252
+ v-if="tooltip && cursorOffset !== null"
253
+ class="rw-tooltip"
254
+ :style="{ left: `${Math.min(92, Math.max(8, cursorOffset))}%` }"
255
+ >
256
+ <strong class="text-xs">{{ tooltip.title }}</strong>
257
+ <span v-for="(line, index) in tooltip.lines" :key="index" class="text-xs has-text-grey">{{ line }}</span>
258
+ </div>
259
+ </div>
260
+ </template>
261
+
262
+ <style lang="scss" scoped>
263
+ @import "../../styles/components/charts/recoverable-window";
264
+
265
+ .rw-track {
266
+ position: relative;
267
+ height: 26px;
268
+ border-radius: 4px;
269
+ cursor: crosshair;
270
+
271
+ &--continuous {
272
+ background-color: $rw-rail;
273
+ }
274
+
275
+ &--discrete {
276
+ background-color: $white-100;
277
+ border: 1px solid $rw-rail-border;
278
+ }
279
+ }
280
+
281
+ .rw-clip {
282
+ position: absolute;
283
+ inset: 0;
284
+ border-radius: 4px;
285
+ overflow: hidden;
286
+ }
287
+
288
+ .rw-guide {
289
+ position: absolute;
290
+ top: 4px;
291
+ bottom: 4px;
292
+ width: 1px;
293
+ background-color: $rw-guide;
294
+ z-index: 2;
295
+ pointer-events: none;
296
+ }
297
+
298
+ .rw-span {
299
+ position: absolute;
300
+ top: 0;
301
+ bottom: 0;
302
+ border-radius: 4px;
303
+ overflow: hidden;
304
+ z-index: 1;
305
+ }
306
+
307
+ .rw-span-fill {
308
+ position: absolute;
309
+ top: 0;
310
+ bottom: 0;
311
+ background: linear-gradient(to right, $rw-pitr-start, $rw-pitr-end);
312
+ }
313
+
314
+ .rw-gap {
315
+ position: absolute;
316
+ top: 0;
317
+ bottom: 0;
318
+ z-index: 3;
319
+ background-color: $rw-failed;
320
+ }
321
+
322
+ .rw-base {
323
+ position: absolute;
324
+ top: 0;
325
+ bottom: 0;
326
+ width: 2px;
327
+ margin-left: -1px;
328
+ z-index: 4;
329
+ background-color: $rw-base-divider;
330
+
331
+ &--failed {
332
+ background-color: $rw-failed;
333
+ }
334
+ }
335
+
336
+ .rw-mark {
337
+ position: absolute;
338
+ top: 50%;
339
+ width: 10px;
340
+ height: 14px;
341
+ margin-left: -5px;
342
+ transform: translateY(-50%);
343
+ border-radius: 2px;
344
+ background-color: $rw-pitr-legend;
345
+
346
+ &--verified {
347
+ background-color: $rw-snapshot;
348
+ }
349
+
350
+ &--failed {
351
+ background-color: $rw-failed;
352
+ }
353
+
354
+ &--running {
355
+ background-color: $rw-running;
356
+ }
357
+
358
+ &--scheduled {
359
+ display: flex;
360
+ width: 10px;
361
+ height: 16px;
362
+ margin-left: -5px;
363
+ background-color: transparent;
364
+ color: $rw-scheduled;
365
+ }
366
+
367
+ &--grouped {
368
+ width: 6px;
369
+ margin-left: -3px;
370
+ }
371
+
372
+ &--edge {
373
+ left: auto;
374
+ right: 6px;
375
+ margin-left: 0;
376
+ }
377
+ }
378
+
379
+ .rw-cursor {
380
+ position: absolute;
381
+ top: 0;
382
+ bottom: 0;
383
+ width: 1px;
384
+ background-color: rgba($rw-base-divider, 0.8);
385
+ z-index: 5;
386
+ pointer-events: none;
387
+ }
388
+
389
+ .rw-tooltip {
390
+ position: absolute;
391
+ bottom: calc(100% + 6px);
392
+ transform: translateX(-50%);
393
+ z-index: 10;
394
+ display: flex;
395
+ flex-direction: column;
396
+ gap: 2px;
397
+ padding: 6px 8px;
398
+ white-space: nowrap;
399
+ border-radius: 4px;
400
+ border: 1px solid $rw-rail-border;
401
+ background-color: $white-100;
402
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
403
+ pointer-events: none;
404
+ }
405
+ </style>
@@ -0,0 +1,142 @@
1
+ import type { RecoveryGap, RecoveryPoint, RecoverySpan, TimeRangeOption } from "./types";
2
+
3
+ export const HOUR_MS = 60 * 60 * 1000;
4
+ export const DAY_MS = 24 * HOUR_MS;
5
+
6
+ export const defaultRangeOptions: TimeRangeOption[] = [
7
+ { label: "24h", durationMs: DAY_MS },
8
+ { label: "7d", durationMs: 7 * DAY_MS },
9
+ { label: "30d", durationMs: 30 * DAY_MS },
10
+ ];
11
+
12
+ const DURATION_UNITS: Record<string, number> = {
13
+ m: 60 * 1000,
14
+ h: HOUR_MS,
15
+ d: DAY_MS,
16
+ w: 7 * DAY_MS,
17
+ };
18
+
19
+ export function parseDuration(label: string): number | undefined {
20
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(m|h|d|w)\s*$/i.exec(label);
21
+ if (!match) return undefined;
22
+ const unit = DURATION_UNITS[match[2].toLowerCase()];
23
+ const value = Number(match[1]);
24
+ if (!unit || !value) return undefined;
25
+ return value * unit;
26
+ }
27
+
28
+ export interface TimeDomain {
29
+ start: number;
30
+ end: number;
31
+ durationMs: number;
32
+ }
33
+
34
+ export interface AxisTick {
35
+ time: number;
36
+ label: string;
37
+ offset: number;
38
+ }
39
+
40
+ export function toMs(value?: string | number | null): number | undefined {
41
+ if (value === undefined || value === null || value === "") return undefined;
42
+ const ms = typeof value === "number" ? value : Date.parse(value);
43
+ return Number.isNaN(ms) ? undefined : ms;
44
+ }
45
+
46
+ export function offsetOf(time: number, domain: TimeDomain): number {
47
+ const ratio = (time - domain.start) / domain.durationMs;
48
+ return Math.min(100, Math.max(0, ratio * 100));
49
+ }
50
+
51
+ export function timeAt(ratio: number, domain: TimeDomain): number {
52
+ const clamped = Math.min(1, Math.max(0, ratio));
53
+ return Math.round(domain.start + clamped * domain.durationMs);
54
+ }
55
+
56
+ export function clipRange(
57
+ range: { start?: string; end?: string },
58
+ domain: TimeDomain,
59
+ ): { left: number; width: number; start: number; end: number } | null {
60
+ const start = toMs(range.start) ?? domain.start;
61
+ const end = toMs(range.end) ?? domain.end;
62
+ if (end <= domain.start || start >= domain.end) return null;
63
+ const left = offsetOf(start, domain);
64
+ const width = offsetOf(end, domain) - left;
65
+ return { left, width: Math.max(width, 0.15), start, end };
66
+ }
67
+
68
+ function tickStep(durationMs: number): { step: number; withDate: boolean } {
69
+ if (durationMs <= DAY_MS) return { step: 4 * HOUR_MS, withDate: false };
70
+ if (durationMs <= 7 * DAY_MS) return { step: DAY_MS, withDate: true };
71
+ return { step: 5 * DAY_MS, withDate: true };
72
+ }
73
+
74
+ export function formatTime(time: number, withDate = false): string {
75
+ const date = new Date(time);
76
+ if (withDate) return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
77
+ return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
78
+ }
79
+
80
+ export function formatTimestamp(time: number): string {
81
+ return new Date(time).toLocaleString(undefined, {
82
+ month: "short",
83
+ day: "numeric",
84
+ hour: "2-digit",
85
+ minute: "2-digit",
86
+ second: "2-digit",
87
+ hour12: false,
88
+ });
89
+ }
90
+
91
+ export function axisTicks(domain: TimeDomain): AxisTick[] {
92
+ const { step, withDate } = tickStep(domain.durationMs);
93
+ const ticks: AxisTick[] = [];
94
+ const offset = new Date(domain.start).getTimezoneOffset() * 60 * 1000;
95
+ let time = Math.ceil((domain.start - offset) / step) * step + offset;
96
+ while (time <= domain.end) {
97
+ ticks.push({ time, label: formatTime(time, withDate), offset: offsetOf(time, domain) });
98
+ time += step;
99
+ }
100
+ return ticks.filter((tick) => tick.offset <= 74);
101
+ }
102
+
103
+ export function subTicks(domain: TimeDomain): number[] {
104
+ const { step } = tickStep(domain.durationMs);
105
+ const sub = step / 2;
106
+ const offsetTz = new Date(domain.start).getTimezoneOffset() * 60 * 1000;
107
+ const marks: number[] = [];
108
+ let time = Math.ceil((domain.start - offsetTz) / sub) * sub + offsetTz;
109
+ while (time < domain.end) {
110
+ marks.push(offsetOf(time, domain));
111
+ time += sub;
112
+ }
113
+ return marks;
114
+ }
115
+
116
+ export function isRecoverableAt(
117
+ time: number,
118
+ spans: RecoverySpan[] = [],
119
+ gaps: RecoveryGap[] = [],
120
+ ): { recoverable: boolean; gap?: RecoveryGap } {
121
+ const inSpan = spans.some((span) => {
122
+ const start = toMs(span.start);
123
+ const end = toMs(span.end);
124
+ return start !== undefined && end !== undefined && time >= start && time <= end;
125
+ });
126
+ if (!inSpan) return { recoverable: false };
127
+ const gap = gaps.find((item) => {
128
+ const start = toMs(item.start);
129
+ const end = toMs(item.end);
130
+ return start !== undefined && end !== undefined && time >= start && time <= end;
131
+ });
132
+ return { recoverable: !gap, gap };
133
+ }
134
+
135
+ export type PointState = "verified" | "succeeded" | "failed" | "running" | "scheduled";
136
+
137
+ export function pointState(point: RecoveryPoint): PointState {
138
+ if (point.phase === "Failed" || point.verificationStatus === "VerificationFailed") return "failed";
139
+ if (point.phase === "Pending" || point.phase === "Running") return "running";
140
+ if (point.verificationStatus === "Verified") return "verified";
141
+ return "succeeded";
142
+ }
@@ -0,0 +1,51 @@
1
+ export type SnapshotPhase = "Pending" | "Running" | "Succeeded" | "Failed";
2
+
3
+ export type VerificationStatus = "Verified" | "NotVerified" | "VerificationFailed";
4
+
5
+ export type TrackKind = "continuous" | "discrete";
6
+
7
+ export interface RecoveryPoint {
8
+ snapshotTime: string;
9
+ phase: SnapshotPhase;
10
+ verificationStatus?: VerificationStatus;
11
+ snapshotID?: string;
12
+ size?: string;
13
+ error?: string;
14
+ }
15
+
16
+ export interface RecoverySpan {
17
+ start: string;
18
+ end: string;
19
+ lsn?: string;
20
+ }
21
+
22
+ export interface RecoveryGap {
23
+ start: string;
24
+ end: string;
25
+ error?: string;
26
+ }
27
+
28
+ export interface RecoveryTrack {
29
+ key: string;
30
+ label: string;
31
+ kind: TrackKind;
32
+ status?: "success" | "warning" | "danger";
33
+ spans?: RecoverySpan[];
34
+ gaps?: RecoveryGap[];
35
+ points?: RecoveryPoint[];
36
+ nextScheduled?: string;
37
+ interval?: string;
38
+ }
39
+
40
+ export interface TimelineHover {
41
+ trackKey: string;
42
+ time: number;
43
+ recoverable: boolean;
44
+ point?: RecoveryPoint;
45
+ gap?: RecoveryGap;
46
+ }
47
+
48
+ export interface TimeRangeOption {
49
+ label: string;
50
+ durationMs: number;
51
+ }
@@ -29,9 +29,30 @@ const themeModes = ref<ThemeModes>({
29
29
 
30
30
  const emit = defineEmits(["set:theme"]);
31
31
 
32
+ // localStorage throws when the origin's quota is full, and when storage is
33
+ // unreachable altogether (private browsing, sandboxed iframe, blocked site
34
+ // data, server-side render). Remembering the theme is a convenience, so a
35
+ // failure must never stop the theme from being read or applied.
36
+ const readThemeMode = (): string | null => {
37
+ try {
38
+ return localStorage.getItem("themeMode");
39
+ } catch {
40
+ // no stored preference available
41
+ return null;
42
+ }
43
+ };
44
+
45
+ const persistThemeMode = (mode: string) => {
46
+ try {
47
+ localStorage.setItem("themeMode", mode);
48
+ } catch {
49
+ // not remembered across reloads, but it still applies for this session
50
+ }
51
+ };
52
+
32
53
  onMounted(() => {
33
54
  // get theme mode from localStorage or set default one
34
- themeMode.value = localStorage.getItem("themeMode") || "light";
55
+ themeMode.value = readThemeMode() || "light";
35
56
  });
36
57
 
37
58
  onUnmounted(() => {
@@ -39,7 +60,7 @@ onUnmounted(() => {
39
60
  });
40
61
 
41
62
  const onThemeModeChange = (n: string) => {
42
- localStorage.setItem("themeMode", n);
63
+ persistThemeMode(n);
43
64
 
44
65
  let theme = n;
45
66
  if (n === "system") {