@runwisp/ui 0.4.0

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.
Files changed (108) hide show
  1. package/LICENSE +215 -0
  2. package/README.md +20 -0
  3. package/package.json +128 -0
  4. package/src/app.d.ts +8 -0
  5. package/src/app.html +12 -0
  6. package/src/astro/CTABand.astro +32 -0
  7. package/src/astro/Hero.astro +58 -0
  8. package/src/astro/SiteFooter.astro +47 -0
  9. package/src/astro/SiteHeader.astro +46 -0
  10. package/src/lib/actions/portal.ts +16 -0
  11. package/src/lib/assets/runwisp-logo.svg +16 -0
  12. package/src/lib/components/Accordion.svelte +20 -0
  13. package/src/lib/components/AccordionItem.svelte +74 -0
  14. package/src/lib/components/Alert.svelte +107 -0
  15. package/src/lib/components/AlertDialog.svelte +63 -0
  16. package/src/lib/components/Avatar.svelte +88 -0
  17. package/src/lib/components/Badge.svelte +131 -0
  18. package/src/lib/components/Breadcrumb.svelte +58 -0
  19. package/src/lib/components/Button.svelte +69 -0
  20. package/src/lib/components/Card.svelte +70 -0
  21. package/src/lib/components/Checkbox.svelte +90 -0
  22. package/src/lib/components/CodeBlock.svelte +66 -0
  23. package/src/lib/components/CronInput.svelte +130 -0
  24. package/src/lib/components/DataGrid.svelte +368 -0
  25. package/src/lib/components/Drawer.svelte +156 -0
  26. package/src/lib/components/Dropdown.svelte +195 -0
  27. package/src/lib/components/DurationInput.svelte +170 -0
  28. package/src/lib/components/EmptyState.svelte +46 -0
  29. package/src/lib/components/ErrorState.svelte +73 -0
  30. package/src/lib/components/FeatureCard.svelte +35 -0
  31. package/src/lib/components/FeatureGrid.svelte +37 -0
  32. package/src/lib/components/FormField.svelte +53 -0
  33. package/src/lib/components/Heading.svelte +46 -0
  34. package/src/lib/components/Input.svelte +114 -0
  35. package/src/lib/components/Kbd.svelte +31 -0
  36. package/src/lib/components/Link.svelte +56 -0
  37. package/src/lib/components/LinkButton.svelte +55 -0
  38. package/src/lib/components/LogConsole.svelte +424 -0
  39. package/src/lib/components/Logo.svelte +34 -0
  40. package/src/lib/components/Modal.svelte +138 -0
  41. package/src/lib/components/PageContainer.svelte +38 -0
  42. package/src/lib/components/Pagination.svelte +96 -0
  43. package/src/lib/components/Popover.svelte +130 -0
  44. package/src/lib/components/Progress.svelte +68 -0
  45. package/src/lib/components/Prose.svelte +43 -0
  46. package/src/lib/components/Radio.svelte +82 -0
  47. package/src/lib/components/RadioGroup.svelte +53 -0
  48. package/src/lib/components/Select.svelte +454 -0
  49. package/src/lib/components/Separator.svelte +30 -0
  50. package/src/lib/components/Skeleton.svelte +75 -0
  51. package/src/lib/components/Sparkline.svelte +58 -0
  52. package/src/lib/components/Spinner.svelte +44 -0
  53. package/src/lib/components/StatusIndicator.svelte +203 -0
  54. package/src/lib/components/Table.svelte +19 -0
  55. package/src/lib/components/TableBody.svelte +17 -0
  56. package/src/lib/components/TableCell.svelte +34 -0
  57. package/src/lib/components/TableHead.svelte +19 -0
  58. package/src/lib/components/TableRow.svelte +22 -0
  59. package/src/lib/components/Tabs.svelte +148 -0
  60. package/src/lib/components/Text.svelte +57 -0
  61. package/src/lib/components/Textarea.svelte +81 -0
  62. package/src/lib/components/ThemeToggle.svelte +47 -0
  63. package/src/lib/components/TimezoneSelect.svelte +127 -0
  64. package/src/lib/components/ToastContainer.svelte +84 -0
  65. package/src/lib/components/Toggle.svelte +84 -0
  66. package/src/lib/components/Tooltip.svelte +56 -0
  67. package/src/lib/components/button-styles.ts +51 -0
  68. package/src/lib/components/dashboard/RunDetailPanel.svelte +252 -0
  69. package/src/lib/components/dashboard/RunsList.svelte +331 -0
  70. package/src/lib/components/dashboard/run-helpers.ts +12 -0
  71. package/src/lib/components/dashboard/status-config.ts +141 -0
  72. package/src/lib/components/dashboard/types.ts +35 -0
  73. package/src/lib/index.ts +122 -0
  74. package/src/lib/log-console/LogCache.svelte.ts +93 -0
  75. package/src/lib/log-console/LogFetcher.svelte.ts +179 -0
  76. package/src/lib/log-console/types.ts +24 -0
  77. package/src/lib/theme-tokens.css +235 -0
  78. package/src/lib/theme.css +276 -0
  79. package/src/lib/utils/error.ts +18 -0
  80. package/src/lib/utils/format.ts +76 -0
  81. package/src/lib/utils/id.ts +6 -0
  82. package/src/lib/utils/toast.svelte.ts +87 -0
  83. package/src/lib/utils/toast.ts +60 -0
  84. package/src/routes/+layout.svelte +10 -0
  85. package/src/routes/+page.svelte +5 -0
  86. package/src/routes/layout.css +3 -0
  87. package/src/stories/Alert.stories.svelte +64 -0
  88. package/src/stories/Avatar.stories.svelte +76 -0
  89. package/src/stories/Badge.stories.svelte +76 -0
  90. package/src/stories/Breadcrumb.stories.svelte +64 -0
  91. package/src/stories/Button.stories.svelte +101 -0
  92. package/src/stories/Card.stories.svelte +106 -0
  93. package/src/stories/Checkbox.stories.svelte +77 -0
  94. package/src/stories/DataGrid.stories.svelte +130 -0
  95. package/src/stories/Dropdown.stories.svelte +81 -0
  96. package/src/stories/EmptyState.stories.svelte +74 -0
  97. package/src/stories/Input.stories.svelte +88 -0
  98. package/src/stories/Logo.stories.svelte +82 -0
  99. package/src/stories/Modal.stories.svelte +101 -0
  100. package/src/stories/Progress.stories.svelte +65 -0
  101. package/src/stories/Select.stories.svelte +158 -0
  102. package/src/stories/Tabs.stories.svelte +103 -0
  103. package/src/stories/Textarea.stories.svelte +45 -0
  104. package/src/stories/Toggle.stories.svelte +74 -0
  105. package/src/stories/Tooltip.stories.svelte +77 -0
  106. package/src/stories/Welcome.mdx +241 -0
  107. package/src/stories/assets/runwisp_c.svg +16 -0
  108. package/src/svelte-shims.d.ts +18 -0
@@ -0,0 +1,127 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import Select from "./Select.svelte";
6
+ import { onMount } from "svelte";
7
+ import { type Component } from "svelte";
8
+
9
+ let {
10
+ value = $bindable(),
11
+ label = "Timezone",
12
+ disabled = false,
13
+ hint,
14
+ } = $props<{
15
+ value: string;
16
+ label?: string;
17
+ disabled?: boolean;
18
+ hint?: string;
19
+ }>();
20
+
21
+ type ZoneOption = {
22
+ value: string;
23
+ label: string;
24
+ group?: string | undefined;
25
+ description?: string | undefined;
26
+ icon?: Component;
27
+ };
28
+ let options = $state<ZoneOption[]>([]);
29
+
30
+ function getOffset(zone: string): string {
31
+ try {
32
+ const formatter = new Intl.DateTimeFormat("en-US", {
33
+ timeZone: zone,
34
+ timeZoneName: "shortOffset",
35
+ });
36
+ const parts = formatter.formatToParts(new Date());
37
+ const offset = parts.find((p) => p.type === "timeZoneName")?.value;
38
+ return offset?.replace("GMT", "UTC") || "";
39
+ } catch {
40
+ return "";
41
+ }
42
+ }
43
+
44
+ function toRegion(zone: string): string {
45
+ const separatorIndex = zone.indexOf("/");
46
+ return separatorIndex === -1 ? "Other" : zone.slice(0, separatorIndex);
47
+ }
48
+
49
+ function toPrettyLabel(zone: string): string {
50
+ return zone.split("/").slice(1).join(" / ").replace(/_/g, " ");
51
+ }
52
+
53
+ function toZoneOption(zone: string): ZoneOption {
54
+ const offset = getOffset(zone);
55
+ return {
56
+ value: zone,
57
+ label: toPrettyLabel(zone),
58
+ group: toRegion(zone),
59
+ description: offset ? `Timezone offset: ${offset}` : undefined,
60
+ };
61
+ }
62
+
63
+ function buildFixedOffsetOptions(): ZoneOption[] {
64
+ const fixedOffsets: ZoneOption[] = [];
65
+
66
+ for (let offsetHours = -12; offsetHours <= 14; offsetHours++) {
67
+ const signDisplay = offsetHours >= 0 ? "+" : "-";
68
+ const abs = Math.abs(offsetHours).toString().padStart(2, "0");
69
+ const label = `UTC${signDisplay}${abs}:00`;
70
+ const value =
71
+ offsetHours === 0
72
+ ? "UTC"
73
+ : `Etc/GMT${offsetHours > 0 ? "-" : "+"}${Math.abs(offsetHours)}`;
74
+
75
+ fixedOffsets.push({
76
+ value,
77
+ label,
78
+ group: "Fixed Offsets",
79
+ description: "Fixed constant time offset",
80
+ });
81
+ }
82
+
83
+ return fixedOffsets;
84
+ }
85
+
86
+ function buildTimezoneOptions(): ZoneOption[] {
87
+ const regions: Record<string, ZoneOption[]> = {};
88
+
89
+ for (const zone of Intl.supportedValuesOf("timeZone")) {
90
+ if (zone.startsWith("Etc/")) continue;
91
+
92
+ const option = toZoneOption(zone);
93
+ const region = option.group ?? "Other";
94
+ regions[region] ??= [];
95
+ regions[region].push(option);
96
+ }
97
+
98
+ const regionalOptions = Object.keys(regions)
99
+ .sort()
100
+ .flatMap((region) => regions[region]!.sort((a, b) => a.label.localeCompare(b.label)));
101
+
102
+ return [...buildFixedOffsetOptions(), ...regionalOptions];
103
+ }
104
+
105
+ onMount(() => {
106
+ try {
107
+ options = buildTimezoneOptions();
108
+
109
+ if (!value) {
110
+ value = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
111
+ }
112
+ } catch (e) {
113
+ console.warn("Timezone load failed", e);
114
+ options = [{ value: "UTC", label: "UTC", description: "Universal Coordinated Time" }];
115
+ }
116
+ });
117
+ </script>
118
+
119
+ <Select
120
+ {label}
121
+ bind:value
122
+ {disabled}
123
+ {hint}
124
+ placeholder="Select timezone..."
125
+ {options}
126
+ searchable={true}
127
+ ></Select>
@@ -0,0 +1,84 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ /**
6
+ * Toast notification component
7
+ * Displays toast notifications for user feedback
8
+ */
9
+ import { fly, fade } from "svelte/transition";
10
+ import { CircleCheck, CircleX, Info, TriangleAlert, X } from "@lucide/svelte";
11
+ import { toast, type Toast } from "../utils/toast.svelte.js";
12
+
13
+ function getIcon(type: Toast["type"]) {
14
+ switch (type) {
15
+ case "success":
16
+ return CircleCheck;
17
+ case "error":
18
+ return CircleX;
19
+ case "warning":
20
+ return TriangleAlert;
21
+ case "info":
22
+ return Info;
23
+ }
24
+ }
25
+
26
+ function getColorClasses(type: Toast["type"]) {
27
+ switch (type) {
28
+ case "success":
29
+ return "border-success-200 text-success-700";
30
+ case "error":
31
+ return "border-danger-200 text-danger-700";
32
+ case "warning":
33
+ return "border-warning-200 text-warning-700";
34
+ case "info":
35
+ return "border-wisp-200 text-wisp-700";
36
+ }
37
+ }
38
+
39
+ function getIconColor(type: Toast["type"]) {
40
+ switch (type) {
41
+ case "success":
42
+ return "text-success-surface";
43
+ case "error":
44
+ return "text-danger-surface";
45
+ case "warning":
46
+ return "text-warning-surface";
47
+ case "info":
48
+ return "text-info";
49
+ }
50
+ }
51
+ </script>
52
+
53
+ <!-- Toast Container -->
54
+ <div
55
+ class="pointer-events-none fixed bottom-4 left-1/2 z-[100] flex -translate-x-1/2 flex-col gap-2"
56
+ role="region"
57
+ aria-label="Notifications"
58
+ aria-live="polite"
59
+ >
60
+ {#each toast.items as toastItem (toastItem.id)}
61
+ {@const Icon = getIcon(toastItem.type)}
62
+ <div
63
+ in:fly={{ y: 20, duration: 300 }}
64
+ out:fade={{ duration: 200 }}
65
+ class="pointer-events-auto isolate flex max-w-md min-w-[320px] items-start gap-3 rounded-xl border bg-surface-overlay/95 px-4 py-3 shadow-lg ring-1 ring-on-surface/5 backdrop-blur-md {getColorClasses(
66
+ toastItem.type,
67
+ )}"
68
+ role="alert"
69
+ >
70
+ <Icon
71
+ class="mt-0.5 h-5 w-5 flex-shrink-0 {getIconColor(toastItem.type)}"
72
+ aria-hidden="true"
73
+ />
74
+ <p class="flex-1 text-sm font-medium">{toastItem.message}</p>
75
+ <button
76
+ onclick={() => toast.remove(toastItem.id)}
77
+ class="flex-shrink-0 rounded-lg p-1 text-on-surface-muted transition-colors hover:bg-surface-sunken hover:text-on-surface"
78
+ aria-label="Close notification"
79
+ >
80
+ <X class="h-4 w-4" aria-hidden="true" />
81
+ </button>
82
+ </div>
83
+ {/each}
84
+ </div>
@@ -0,0 +1,84 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ interface Props {
6
+ checked?: boolean;
7
+ disabled?: boolean;
8
+ size?: "sm" | "md" | "lg";
9
+ label?: string;
10
+ description?: string;
11
+ onchange?: (checked: boolean) => void;
12
+ class?: string;
13
+ }
14
+
15
+ let {
16
+ checked = $bindable(),
17
+ disabled = false,
18
+ size = "md",
19
+ label,
20
+ description,
21
+ onchange,
22
+ class: className = "",
23
+ }: Props = $props();
24
+
25
+ function toggle() {
26
+ if (disabled) return;
27
+ checked = !checked;
28
+ onchange?.(checked);
29
+ }
30
+
31
+ const sizeClasses: Record<
32
+ "sm" | "md" | "lg",
33
+ { track: string; thumb: string; translate: string; text: string }
34
+ > = {
35
+ sm: { track: "h-5 w-9", thumb: "h-4 w-4", translate: "translate-x-4", text: "text-sm" },
36
+ md: { track: "h-6 w-11", thumb: "h-5 w-5", translate: "translate-x-5", text: "text-sm" },
37
+ lg: { track: "h-7 w-14", thumb: "h-6 w-6", translate: "translate-x-7", text: "text-base" },
38
+ };
39
+
40
+ const sizeConfig = $derived(sizeClasses[size]);
41
+ </script>
42
+
43
+ <label
44
+ class="inline-flex items-start gap-3 {disabled
45
+ ? 'cursor-not-allowed opacity-50'
46
+ : 'cursor-pointer'} {className}"
47
+ >
48
+ <button
49
+ type="button"
50
+ role="switch"
51
+ aria-checked={checked}
52
+ aria-label={label ?? description ?? "Toggle"}
53
+ {disabled}
54
+ onclick={toggle}
55
+ class="
56
+ relative inline-flex shrink-0 items-center rounded-full
57
+ {sizeConfig.track}
58
+ transition-colors duration-200
59
+ focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
60
+ {checked ? 'bg-primary' : 'bg-outline-hover'}
61
+ {disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
62
+ "
63
+ >
64
+ <span
65
+ class="
66
+ inline-block rounded-full bg-surface-raised shadow-sm
67
+ {sizeConfig.thumb}
68
+ transform transition-transform duration-200
69
+ {checked ? sizeConfig.translate : 'translate-x-0.5'}
70
+ "
71
+ ></span>
72
+ </button>
73
+
74
+ {#if label || description}
75
+ <div class="flex flex-col gap-0.5">
76
+ {#if label}
77
+ <span class="{sizeConfig.text} font-medium text-on-surface">{label}</span>
78
+ {/if}
79
+ {#if description}
80
+ <span class="text-sm text-on-surface-muted">{description}</span>
81
+ {/if}
82
+ </div>
83
+ {/if}
84
+ </label>
@@ -0,0 +1,56 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+
7
+ type TooltipPosition = "top" | "bottom" | "left" | "right";
8
+
9
+ interface Props {
10
+ content: string;
11
+ position?: TooltipPosition;
12
+ children: Snippet;
13
+ class?: string;
14
+ }
15
+
16
+ let { content, position = "top", children, class: className = "" }: Props = $props();
17
+
18
+ const positionClasses: Record<TooltipPosition, string> = {
19
+ top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
20
+ bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
21
+ left: "right-full top-1/2 -translate-y-1/2 mr-2",
22
+ right: "left-full top-1/2 -translate-y-1/2 ml-2",
23
+ };
24
+
25
+ const arrowClasses: Record<TooltipPosition, string> = {
26
+ top: "top-full left-1/2 -translate-x-1/2 border-t-mist-800 border-x-transparent border-b-transparent",
27
+ bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-mist-800 border-x-transparent border-t-transparent",
28
+ left: "left-full top-1/2 -translate-y-1/2 border-l-mist-800 border-y-transparent border-r-transparent",
29
+ right: "right-full top-1/2 -translate-y-1/2 border-r-mist-800 border-y-transparent border-l-transparent",
30
+ };
31
+ </script>
32
+
33
+ <div class="group relative inline-block {className}">
34
+ {@render children()}
35
+
36
+ <div
37
+ class="
38
+ absolute z-50 {positionClasses[position]}
39
+ pointer-events-none invisible rounded-lg
40
+ bg-mist-800 px-2.5 py-1.5 text-xs
41
+ font-medium
42
+ whitespace-nowrap text-white opacity-0 transition-opacity
43
+ duration-150 group-hover:visible
44
+ group-hover:opacity-100
45
+ "
46
+ role="tooltip"
47
+ >
48
+ {content}
49
+ <span
50
+ class="
51
+ absolute h-0 w-0
52
+ border-4 {arrowClasses[position]}
53
+ "
54
+ ></span>
55
+ </div>
56
+ </div>
@@ -0,0 +1,51 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success";
5
+ export type ButtonSize = "xs" | "sm" | "md" | "lg";
6
+
7
+ export const BUTTON_BASE = `
8
+ group
9
+ inline-flex items-center justify-center gap-2
10
+ font-medium cursor-pointer select-none
11
+ border border-transparent
12
+ focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 focus-visible:border-ring
13
+ disabled:opacity-50 disabled:cursor-not-allowed
14
+ transition-all duration-normal ease-out
15
+ active:scale-[0.98]
16
+ `;
17
+
18
+ export const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
19
+ primary: `
20
+ bg-primary text-on-primary shadow-sm shadow-primary/20
21
+ hover:bg-primary-hover hover:shadow-md hover:shadow-primary/30
22
+ active:bg-primary-active
23
+ `,
24
+ secondary: `
25
+ bg-surface-raised text-on-surface-muted border-outline shadow-sm
26
+ hover:bg-surface-sunken hover:text-on-surface hover:border-outline-hover
27
+ active:bg-surface-sunken
28
+ `,
29
+ ghost: `
30
+ bg-transparent text-on-surface-muted
31
+ hover:bg-surface-sunken hover:text-on-surface
32
+ active:bg-surface-sunken
33
+ `,
34
+ danger: `
35
+ bg-danger-surface text-on-danger shadow-sm shadow-danger-surface/20
36
+ hover:bg-danger-hover hover:shadow-md hover:shadow-danger-surface/30
37
+ active:bg-danger-active
38
+ `,
39
+ success: `
40
+ bg-success-surface text-on-success shadow-sm shadow-success-surface/20
41
+ hover:bg-success-hover hover:shadow-md hover:shadow-success-surface/30
42
+ active:bg-success-surface
43
+ `,
44
+ };
45
+
46
+ export const BUTTON_SIZES: Record<ButtonSize, string> = {
47
+ xs: "text-xs px-2 py-1 rounded-md",
48
+ sm: "text-sm px-3 py-1.5 rounded-lg",
49
+ md: "text-sm px-4 py-2 rounded-lg",
50
+ lg: "text-base px-6 py-3 rounded-xl",
51
+ };
@@ -0,0 +1,252 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { untrack } from "svelte";
6
+ import { Server, Hash, Terminal as TerminalIcon, Download } from "@lucide/svelte";
7
+ import Badge from "../Badge.svelte";
8
+ import LogConsole from "../LogConsole.svelte";
9
+ import type { Run } from "./types.js";
10
+ import type { LogEvent, LogSlice } from "../../log-console/types.js";
11
+ import { formatDateTime } from "../../utils/format.js";
12
+ import { formatShortId } from "../../utils/id.js";
13
+ import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
14
+ import { runDuration } from "./run-helpers.js";
15
+
16
+ let {
17
+ run,
18
+ fetchLogs,
19
+ streamLogs,
20
+ showTaskName = false,
21
+ }: {
22
+ run: Run | undefined;
23
+ fetchLogs: (
24
+ runId: string,
25
+ from: number,
26
+ to: number,
27
+ ) => Promise<LogSlice | LogEvent | undefined> | LogSlice | LogEvent | undefined;
28
+ streamLogs?: (
29
+ runId: string,
30
+ onEvent: (event: LogEvent) => void,
31
+ initialState?: { fromLine: number },
32
+ ) => () => void;
33
+ showTaskName?: boolean;
34
+ } = $props();
35
+
36
+ const TAIL_LINES = 1000;
37
+
38
+ let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
39
+
40
+ // Derive a stable scalar so the effect only re-runs when the id actually
41
+ // changes, not on every run object reference swap from SSE array updates.
42
+ let runId = $derived(run?.id);
43
+
44
+ $effect(() => {
45
+ const id = runId;
46
+ if (!id) return;
47
+
48
+ const stream = streamLogs;
49
+ if (!stream) return;
50
+
51
+ let cleanup: (() => void) | undefined;
52
+
53
+ untrack(() => {
54
+ // Single SSE call seeds the viewport via backfill (negative
55
+ // fromLine = tail-from-end) and continues with live events on
56
+ // the same connection. No tail+stream handoff, no duplicate
57
+ // replay, native Last-Event-ID resume on reconnect.
58
+ cleanup = stream(
59
+ id,
60
+ (event: LogEvent) => {
61
+ if (logConsole) logConsole.onStream(event);
62
+ },
63
+ { fromLine: -TAIL_LINES },
64
+ );
65
+ });
66
+
67
+ return () => {
68
+ if (cleanup) cleanup();
69
+ };
70
+ });
71
+ </script>
72
+
73
+ {#if run}
74
+ {@const config = getRunStatusConfig(runDisplayStatus(run))}
75
+ {@const DetailIcon = config.icon}
76
+ {@const duration = runDuration(run)}
77
+ <!-- Detailed Header -->
78
+ <div class="shrink-0 border-b border-outline-faint bg-surface-raised p-6">
79
+ <div class="flex flex-col justify-between gap-6 lg:flex-row lg:items-start">
80
+ <div class="flex items-start gap-4">
81
+ <!-- Large Status Icon -->
82
+ <div
83
+ class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl {config.bg} {config.color}"
84
+ >
85
+ <DetailIcon size={32} class={run.status === "running" ? "animate-spin" : ""} />
86
+ </div>
87
+
88
+ <div>
89
+ <div class="mb-1 flex items-center gap-3">
90
+ <h2 class="text-xl font-bold text-on-surface">
91
+ {#if showTaskName}
92
+ {run.task_name}{#if run.instance_index > 0}<span
93
+ class="text-on-surface-muted">#{run.instance_index}</span
94
+ >{/if}
95
+ {:else}
96
+ Run #{formatShortId(run.id)}{#if run.instance_index > 0}
97
+ <span class="text-on-surface-muted"
98
+ >· instance #{run.instance_index}</span
99
+ >
100
+ {/if}
101
+ {/if}
102
+ </h2>
103
+ <Badge
104
+ variant={runDisplayStatus(run) === "success"
105
+ ? "success"
106
+ : run.status === "running"
107
+ ? "info"
108
+ : "danger"}
109
+ >
110
+ {runDisplayStatus(run).toUpperCase()}
111
+ </Badge>
112
+ </div>
113
+ <div
114
+ class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-on-surface-muted"
115
+ >
116
+ {#if showTaskName}
117
+ <div
118
+ class="flex items-center gap-1.5 font-mono text-xs text-on-surface-faint"
119
+ >
120
+ <Hash size={12} />
121
+ Run ID: <span class="text-on-surface-muted">{run.id}</span>
122
+ </div>
123
+ <div
124
+ class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
125
+ ></div>
126
+ <div class="flex items-center gap-1.5">
127
+ <Server size={14} />
128
+ <span
129
+ >Triggered by <span class="font-medium text-on-surface"
130
+ >{run.triggered_by}</span
131
+ ></span
132
+ >
133
+ </div>
134
+ {:else}
135
+ <div class="flex items-center gap-1.5">
136
+ <Server size={14} />
137
+ <span
138
+ >Triggered by <span class="font-medium text-on-surface"
139
+ >{run.triggered_by}</span
140
+ ></span
141
+ >
142
+ </div>
143
+ <div
144
+ class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
145
+ ></div>
146
+ <div
147
+ class="flex items-center gap-1.5 font-mono text-xs text-on-surface-faint"
148
+ >
149
+ <Hash size={12} />
150
+ {run.id}
151
+ </div>
152
+ {/if}
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Stats Grid -->
158
+ <div
159
+ class="grid grid-cols-3 gap-6 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3"
160
+ >
161
+ <div class="flex flex-col gap-1">
162
+ <span
163
+ class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
164
+ >Started</span
165
+ >
166
+ <span class="font-mono text-sm font-medium whitespace-nowrap text-on-surface">
167
+ {run.start_at ? formatDateTime(run.start_at) : "--:--"}
168
+ </span>
169
+ </div>
170
+
171
+ <div class="flex flex-col gap-1 border-l border-outline pl-6">
172
+ <span
173
+ class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
174
+ >Duration</span
175
+ >
176
+ <span class="font-mono text-sm font-medium text-on-surface">
177
+ {duration ?? "--"}
178
+ </span>
179
+ </div>
180
+
181
+ <div class="flex flex-col gap-1 border-l border-outline pl-6">
182
+ <span
183
+ class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
184
+ >Exited</span
185
+ >
186
+ <span
187
+ class="text-sm font-medium {run.exit_code === 0
188
+ ? 'text-success-surface'
189
+ : 'text-on-surface'} font-mono"
190
+ >
191
+ {typeof run.exit_code === "number" ? `Code ${run.exit_code}` : "-"}
192
+ </span>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- Console View -->
199
+ <div class="flex min-h-0 flex-1 flex-col bg-slate-950">
200
+ <div
201
+ class="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900/50 px-4 py-2 font-mono text-xs text-slate-400"
202
+ >
203
+ <div class="flex items-center gap-2">
204
+ <TerminalIcon size={14} />
205
+ <span>Console Output</span>
206
+ </div>
207
+ <div class="flex items-center gap-3">
208
+ {#if run.status === "running"}
209
+ <div class="text-info-surface flex items-center gap-1.5">
210
+ <div class="bg-info-surface h-1.5 w-1.5 animate-pulse rounded-full"></div>
211
+ Live
212
+ </div>
213
+ {:else if run.status === "pending"}
214
+ <div class="flex items-center gap-1.5 text-warning-surface">
215
+ <div
216
+ class="h-1.5 w-1.5 animate-pulse rounded-full bg-warning-surface"
217
+ ></div>
218
+ Pending
219
+ </div>
220
+ {/if}
221
+ <a
222
+ href="/api/tasks/{encodeURIComponent(run.task_name)}/runs/{encodeURIComponent(
223
+ run.id,
224
+ )}/log/raw"
225
+ download="{run.task_name}-{run.id}.log"
226
+ class="flex items-center gap-1.5 rounded border border-slate-700 px-2 py-1 text-slate-300 transition-colors hover:border-slate-500 hover:bg-slate-800 hover:text-slate-100"
227
+ title="Download the full log (rotated and current parts as one file)"
228
+ >
229
+ <Download size={12} />
230
+ Download
231
+ </a>
232
+ </div>
233
+ </div>
234
+ {#key run.id}
235
+ <LogConsole
236
+ bind:this={logConsole}
237
+ fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
238
+ class="min-h-0 flex-1"
239
+ />
240
+ {/key}
241
+ </div>
242
+ {:else}
243
+ <div
244
+ class="flex flex-1 flex-col items-center justify-center bg-surface-sunken/30 text-on-surface-faint"
245
+ >
246
+ <div class="mb-4 rounded-full border border-outline bg-surface-raised p-4 shadow-sm">
247
+ <Server size={32} class="text-outline-faint" />
248
+ </div>
249
+ <h3 class="mb-1 font-medium text-on-surface">No Run Selected</h3>
250
+ <p class="text-sm">Select a run from the history on the left to view details.</p>
251
+ </div>
252
+ {/if}