feat: display the home page as a table

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-07 18:11:58 +02:00
co-authored by Claude Opus 5.5
parent 1ab2c6d681
commit eebfb30443
17 changed files with 621 additions and 218 deletions
+6
View File
@@ -33363,6 +33363,12 @@ components:
mode:
type: string
enum: [enabled, disabled, suspended]
schedule:
description: the cron expression, for a schedule only
type: string
timezone:
description: the schedule's timezone, for a schedule only
type: string
TriggersCount:
type: object
+21 -15
View File
@@ -1130,6 +1130,11 @@ struct AttachedTrigger {
path: String,
/// `enabled` | `disabled` | `suspended`
mode: String,
/// A schedule's cron expression and timezone; absent for every other kind.
#[serde(skip_serializing_if = "Option::is_none")]
schedule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
timezone: Option<String>,
}
#[derive(Serialize, Default)]
@@ -1189,30 +1194,31 @@ async fn get_runnables_activity(
ORDER BY j.created_at DESC"
);
let branch = |table: &str, kind: &str, mode: &str| {
const NO_CRON: &str = "NULL::text AS schedule, NULL::text AS timezone";
let branch = |table: &str, kind: &str, mode: &str, cron: &str| {
format!(
"SELECT '{kind}' AS kind, path, script_path, is_flow, {mode} AS mode FROM {table} \
"SELECT '{kind}' AS kind, path, script_path, is_flow, {mode} AS mode, {cron} FROM {table} \
WHERE workspace_id = $1 AND script_path = ANY($2)"
)
};
let trigger_mode = "mode::text";
let bool_mode = "CASE WHEN enabled THEN 'enabled' ELSE 'disabled' END";
let branches = [
branch("schedule", "schedule", bool_mode),
branch("http_trigger", "http", trigger_mode),
branch("websocket_trigger", "websocket", trigger_mode),
branch("kafka_trigger", "kafka", trigger_mode),
branch("nats_trigger", "nats", trigger_mode),
branch("postgres_trigger", "postgres", trigger_mode),
branch("mqtt_trigger", "mqtt", trigger_mode),
branch("amqp_trigger", "amqp", trigger_mode),
branch("sqs_trigger", "sqs", trigger_mode),
branch("gcp_trigger", "gcp", trigger_mode),
branch("azure_trigger", "azure", trigger_mode),
branch("email_trigger", "email", trigger_mode),
branch("schedule", "schedule", bool_mode, "schedule, timezone"),
branch("http_trigger", "http", trigger_mode, NO_CRON),
branch("websocket_trigger", "websocket", trigger_mode, NO_CRON),
branch("kafka_trigger", "kafka", trigger_mode, NO_CRON),
branch("nats_trigger", "nats", trigger_mode, NO_CRON),
branch("postgres_trigger", "postgres", trigger_mode, NO_CRON),
branch("mqtt_trigger", "mqtt", trigger_mode, NO_CRON),
branch("amqp_trigger", "amqp", trigger_mode, NO_CRON),
branch("sqs_trigger", "sqs", trigger_mode, NO_CRON),
branch("gcp_trigger", "gcp", trigger_mode, NO_CRON),
branch("azure_trigger", "azure", trigger_mode, NO_CRON),
branch("email_trigger", "email", trigger_mode, NO_CRON),
format!(
"SELECT service_name::text AS kind, COALESCE(NULLIF(summary, ''), external_id) AS path, \
script_path, is_flow, {bool_mode} AS mode FROM native_trigger \
script_path, is_flow, {bool_mode} AS mode, {NO_CRON} FROM native_trigger \
WHERE workspace_id = $1 AND script_path = ANY($2)"
),
];
@@ -236,7 +236,7 @@
{/if}
</span>
{:else}
<Badge small color="indigo" class="gap-0.5">
<Badge small color="indigo" class="gap-0.5 px-1 py-0">
{draft_only ? 'Draft only' : 'Draft'}
</Badge>
{/if}
@@ -128,8 +128,8 @@
: 'rounded-md px-2 py-0.5',
verySmall ? 'px-0.5 py-0.5' : '',
'flex flex-row gap-1 items-center justify-center',
classNames,
compact ? 'text-3xs px-1 py-0 gap-0.5' : ''
compact ? 'text-2xs px-1.5 py-0.5 gap-1' : '',
classNames
)
)
const handleHide = () => (hidden = !hidden)
@@ -151,7 +151,7 @@
role={clickable ? 'button' : undefined}
>
{#if icon?.icon && icon.position === 'left'}
<icon.icon size={compact ? 10 : 12} />
<icon.icon size={12} />
{/if}
{#if compact}
<!-- `contents` keeps the children in the badge's flex layout; only the
@@ -163,7 +163,7 @@
{@render children?.()}
{/if}
{#if icon?.icon && icon.position === 'right'}
<icon.icon size={compact ? 10 : 12} />
<icon.icon size={12} />
{/if}
{#if dismissable}
<button onclick={handleHide}>
@@ -70,8 +70,7 @@
canFavorite={false}
{depth}
>
{#snippet badges()}
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
{#snippet draftBadge()}
<DraftBadge
is_draft={agent.is_draft}
draft_only={agent.draft_only}
@@ -81,6 +80,11 @@
path={agent.path}
onMigrated={() => dispatch('change')}
/>
{/snippet}
{#snippet sharedBadge()}
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
{/snippet}
{#snippet labelBadges()}
<InheritedLabels labels={agent.inherited_labels} />
{/snippet}
@@ -88,10 +92,10 @@
{#if agent.canWrite}
<span class="hidden md:inline-flex">
<Button
variant="default"
wrapperClasses="w-16"
variant="accent"
wrapperClasses="w-16 invisible group-hover/row:visible group-focus-within/row:visible"
startIcon={{ icon: Pen }}
unifiedSize="md"
unifiedSize="sm"
href={editHref}
>
Edit
@@ -99,6 +103,8 @@
</span>
{/if}
<Dropdown
size="sm"
fixedHeight={false}
items={async () =>
agentMenuItems({
deployUiSettings: await getDeployUiSettings(),
@@ -118,14 +118,15 @@
{depth}
{rowSelection}
>
{#snippet badges()}
{#snippet tagBadges()}
{#if app.execution_mode == 'anonymous'}
<Badge small icon={{ icon: Eye }}>Public</Badge>
{/if}
{#if !app.raw_app}
<Badge small title="Low-code app">Legacy</Badge>
{/if}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
{/snippet}
{#snippet draftBadge()}
<DraftBadge
is_draft={app.is_draft}
draft_only={app.draft_only}
@@ -136,6 +137,11 @@
path={app.path}
onMigrated={() => dispatch('change')}
/>
{/snippet}
{#snippet sharedBadge()}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
{/snippet}
{#snippet labelBadges()}
{#if app.labels?.length}
<div class="flex items-center gap-0.5">
{#each app.labels.slice(0, 3) as label}
@@ -164,8 +170,9 @@
<Button
aiId={`edit-app-button-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Edits the app ${app.summary?.length > 0 ? app.summary : app.path}`}
variant="default"
wrapperClasses="w-16"
variant="accent"
wrapperClasses="w-16 invisible group-hover/row:visible group-focus-within/row:visible"
unifiedSize="sm"
startIcon={{ icon: Pen }}
href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}"
>
@@ -179,6 +186,8 @@
{/if}
</span>
<Dropdown
size="sm"
fixedHeight={false}
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
items={async () => {
@@ -155,11 +155,12 @@
{rowSelection}
titleBadge={flow.chat_input_enabled ? chatBadge : undefined}
>
{#snippet badges()}
{#snippet tagBadges()}
{#if flow.archived}
<Badge color="red" baseClass="border">archived</Badge>
{/if}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
{/snippet}
{#snippet draftBadge()}
<DraftBadge
is_draft={flow.is_draft}
draft_only={flow.draft_only}
@@ -170,6 +171,11 @@
path={flow.path}
onMigrated={() => dispatch('change')}
/>
{/snippet}
{#snippet sharedBadge()}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
{/snippet}
{#snippet labelBadges()}
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
@@ -195,10 +201,10 @@
{#if !hideForOperator && showEditButton && flow.canWrite && !flow.archived}
<div>
<Button
variant="default"
wrapperClasses="w-16"
variant="accent"
wrapperClasses="w-16 invisible group-hover/row:visible group-focus-within/row:visible"
startIcon={{ icon: Pen }}
unifiedSize="md"
unifiedSize="sm"
href="{base}/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
@@ -214,6 +220,8 @@
</span>
<Dropdown
size="sm"
fixedHeight={false}
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
items={async () => {
@@ -43,11 +43,13 @@
canFavorite={true}
{depth}
>
{#snippet badges()}
{#snippet sharedBadge()}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
{/snippet}
{#snippet actions()}
<Dropdown
size="sm"
fixedHeight={false}
items={async () => {
let { path } = app
@@ -11,6 +11,12 @@
import type { RowSelection } from './rowSelection'
import RunnableActivityCells from '$lib/components/home/RunnableActivityCells.svelte'
import { setCompactBadges } from '../badge/density'
import {
EMPTY_CELL_DASH,
HOME_TABLE_BADGE_GRID,
HOME_TABLE_GRID,
isHomeTable
} from '$lib/components/home/homeTable'
interface Props {
marked: string | undefined
@@ -68,6 +74,13 @@
workspaceId: string
depth?: number
badges?: import('svelte').Snippet
/** Badge groups the home table gives a column each; elsewhere they render
* inline, in this order, after `badges`. */
languageBadge?: import('svelte').Snippet
tagBadges?: import('svelte').Snippet
draftBadge?: import('svelte').Snippet
sharedBadge?: import('svelte').Snippet
labelBadges?: import('svelte').Snippet
actions?: import('svelte').Snippet
customSummary?: import('svelte').Snippet
/** Rendered inline right after the title, unlike `badges`, which sit in
@@ -105,6 +118,11 @@
workspaceId,
depth = 0,
badges,
languageBadge,
tagBadges,
draftBadge,
sharedBadge,
labelBadges,
actions,
customSummary,
titleBadge,
@@ -120,6 +138,7 @@
?.slice(-1)?.[0]) ?? ''
setCompactBadges()
const homeTable = isHomeTable()
let rowEl: HTMLDivElement | undefined = $state()
$effect(() => {
@@ -175,124 +194,209 @@
}}
></div>
{/if}
<!-- Tree-view alignment: a folder header's icon sits at px-4 (16px) + its inner
padding-left of depth*16, i.e. (depth+1)*16. This row's inline padding-left
overrides px-4, so it must carry the full (depth+1)*16 for a file to line up
with its sibling folder at the same depth. -->
<div
bind:this={rowEl}
data-row-selection-key={rowSelection?.key}
data-row-keyboard-selected={keyboardSelected ? 'true' : undefined}
class={twMerge(
'group/row relative w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b pl-4 pr-1 py-3 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : '',
disabled ? 'opacity-25' : 'hover:bg-surface-hover',
clickToSelect || inSelectionMode ? 'cursor-pointer select-none' : '',
selected || rowSelection?.selected
? 'bg-surface-accent-selected'
: keyboardSelected
? 'bg-gray-200 dark:bg-gray-700'
: ''
)}
style={depth > 0 ? `padding-left: ${(depth + 1) * 16}px;` : ''}
role={clickToSelect ? 'button' : undefined}
tabindex={clickToSelect ? 0 : undefined}
onclick={handleRowClick}
onkeydown={clickToSelect ? handleRowKeydown : undefined}
>
<!-- Everything interactive sits at z-[1], above the title link's row-wide
click overlay. -->
{#if isSelectable}
<div class="relative z-[1] flex">
<Checkbox checked={selected} onChange={onSelect} />
</div>
{:else if selectDisabledReason}
<div class="relative z-[1] flex">
<Tooltip class="cursor-not-allowed">
<Checkbox disabled checked={false} />
{#snippet text()}{selectDisabledReason}{/snippet}
</Tooltip>
</div>
{:else if alignWithSelectable}
<div class="rounded max-w-4 w-full"></div>
{/if}
{#if rowSelection}
<!-- The icon slot itself: the kind icon until the row is hovered (or
selection mode is on), the checkbox from then on. Both are stacked in a
fixed 16px box and swapped with visibility so nothing shifts. -->
<div class="shrink relative z-[1] w-4 h-4">
{#if homeTable}
<div
bind:this={rowEl}
data-row-selection-key={rowSelection?.key}
data-row-keyboard-selected={keyboardSelected ? 'true' : undefined}
class={twMerge(
HOME_TABLE_GRID,
'group/row relative w-full px-3 py-2.5 rounded-md',
disabled ? 'opacity-25' : 'hover:bg-surface-hover',
inSelectionMode ? 'cursor-pointer select-none' : '',
rowSelection?.selected
? 'bg-surface-accent-selected'
: keyboardSelected
? 'bg-gray-200 dark:bg-gray-700'
: ''
)}
onclick={handleRowClick}
>
{#if href && !inSelectionMode}
<a
{href}
aria-label={!summary || summary.length == 0 ? displayPath : summary}
class="absolute inset-0"
></a>
{/if}
<!-- The kind icon, swapped for the checkbox on hover and in selection mode. -->
<div class="relative z-[1] w-4 h-4">
<div
class={twMerge(
'absolute inset-0',
rowSelection.active ? 'invisible' : 'group-hover/row:invisible'
rowSelection ? (rowSelection.active ? 'invisible' : 'group-hover/row:invisible') : ''
)}
>
<RowIcon {kind} {triggerKind} />
</div>
<Checkbox
class={twMerge(
'absolute inset-0 w-4 h-4',
rowSelection.active ? '' : 'invisible group-hover/row:visible'
)}
checked={rowSelection.selected}
title={rowSelection.selected ? 'Deselect' : 'Select (shift-click to select a range)'}
onClick={(e) => {
// Left unprevented on purpose: the browser's own toggle already lands
// on the value we are about to compute, except on a range re-select,
// which Checkbox re-asserts. Preventing it would revert the box AFTER
// the update and leave every clicked row visually unticked.
e.stopPropagation()
rowSelection?.onToggle(e)
}}
/>
{#if rowSelection}
<Checkbox
class={twMerge(
'absolute inset-0 w-4 h-4',
rowSelection.active ? '' : 'invisible group-hover/row:visible'
)}
checked={rowSelection.selected}
title={rowSelection.selected ? 'Deselect' : 'Select (shift-click to select a range)'}
onClick={(e) => {
e.stopPropagation()
rowSelection?.onToggle(e)
}}
/>
{/if}
</div>
{/if}
<!-- The link covers the whole row rather than wrapping the title, so the
badges on the title line can hold their own popovers and buttons. -->
{#if href && !inSelectionMode}
<a
{href}
aria-label={!summary || summary.length == 0 ? displayPath : summary}
class="absolute inset-0"
></a>
{/if}
{@render rowContent(!rowSelection, !!href && !inSelectionMode)}
{#if errorHandlerMuted}
<BellOff class="w-8 opacity-60" size={12} fill="currentcolor" />
{/if}
<div class="relative z-[1] flex items-center gap-3 shrink-0">
{#if kind == 'script' || kind == 'flow' || kind == 'app' || kind == 'raw_app' || kind == 'agent'}
<RunnableActivityCells
kind={kind == 'script' || kind == 'flow' ? kind : undefined}
{path}
/>
{/if}
{#if kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow'}
<div class="center-center h-full text-xs font-semibold text-secondary w-7">
{#if canFavorite}
<Star {kind} {path} {workspaceId} {summary} />
{:else}
<span class="p-1 opacity-20" title="Deploy it to add it to favorites">
<StarIcon size={16} />
</span>
{/if}
<div
class="min-w-0 flex items-center gap-2"
style={depth > 0 ? `padding-left: ${depth * 16}px;` : ''}
>
{@render rowContent(false, !!href && !inSelectionMode, false)}
{#if errorHandlerMuted}
<BellOff class="shrink-0 opacity-60" size={12} fill="currentcolor" />
{/if}
</div>
<!-- Fixed sub-columns, so each badge group lines up across rows. -->
<div
class="relative z-[1] hidden lg:grid {HOME_TABLE_BADGE_GRID}"
>
<div class="flex flex-wrap items-center gap-1 min-w-0 {EMPTY_CELL_DASH}">
{@render badges?.()}
{@render labelBadges?.()}
</div>
{:else}
<div class="w-7"></div>
{/if}
<div data-row-actions class="flex gap-2 items-center justify-end">
{@render actions?.()}
<div class="flex items-center">{@render languageBadge?.()}</div>
<div class="flex flex-wrap items-center gap-1 min-w-0">{@render tagBadges?.()}</div>
<div class="flex items-center">{@render sharedBadge?.()}</div>
</div>
<RunnableActivityCells kind={kind == 'script' || kind == 'flow' ? kind : undefined} {path} />
<div class="relative z-[1] flex items-center justify-end gap-2">
<div data-row-actions class="flex gap-2 items-center">
{@render actions?.()}
</div>
{@render favorite()}
</div>
</div>
</div>
{:else}
<!-- Tree-view alignment: a folder header's icon sits at px-4 (16px) + its inner
padding-left of depth*16, i.e. (depth+1)*16. This row's inline padding-left
overrides px-4, so it must carry the full (depth+1)*16 for a file to line up
with its sibling folder at the same depth. -->
<div
bind:this={rowEl}
data-row-selection-key={rowSelection?.key}
data-row-keyboard-selected={keyboardSelected ? 'true' : undefined}
class={twMerge(
'group/row relative w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b pl-4 pr-1 py-3 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : '',
disabled ? 'opacity-25' : 'hover:bg-surface-hover',
clickToSelect || inSelectionMode ? 'cursor-pointer select-none' : '',
selected || rowSelection?.selected
? 'bg-surface-accent-selected'
: keyboardSelected
? 'bg-gray-200 dark:bg-gray-700'
: ''
)}
style={depth > 0 ? `padding-left: ${(depth + 1) * 16}px;` : ''}
role={clickToSelect ? 'button' : undefined}
tabindex={clickToSelect ? 0 : undefined}
onclick={handleRowClick}
onkeydown={clickToSelect ? handleRowKeydown : undefined}
>
<!-- Everything interactive sits at z-[1], above the title link's row-wide
click overlay. -->
{#if isSelectable}
<div class="relative z-[1] flex">
<Checkbox checked={selected} onChange={onSelect} />
</div>
{:else if selectDisabledReason}
<div class="relative z-[1] flex">
<Tooltip class="cursor-not-allowed">
<Checkbox disabled checked={false} />
{#snippet text()}{selectDisabledReason}{/snippet}
</Tooltip>
</div>
{:else if alignWithSelectable}
<div class="rounded max-w-4 w-full"></div>
{/if}
{#snippet rowContent(withIcon: boolean, linked: boolean)}
{#if rowSelection}
<!-- The icon slot itself: the kind icon until the row is hovered (or
selection mode is on), the checkbox from then on. Both are stacked in a
fixed 16px box and swapped with visibility so nothing shifts. -->
<div class="shrink relative z-[1] w-4 h-4">
<div
class={twMerge(
'absolute inset-0',
rowSelection.active ? 'invisible' : 'group-hover/row:invisible'
)}
>
<RowIcon {kind} {triggerKind} />
</div>
<Checkbox
class={twMerge(
'absolute inset-0 w-4 h-4',
rowSelection.active ? '' : 'invisible group-hover/row:visible'
)}
checked={rowSelection.selected}
title={rowSelection.selected ? 'Deselect' : 'Select (shift-click to select a range)'}
onClick={(e) => {
// Left unprevented on purpose: the browser's own toggle already lands
// on the value we are about to compute, except on a range re-select,
// which Checkbox re-asserts. Preventing it would revert the box AFTER
// the update and leave every clicked row visually unticked.
e.stopPropagation()
rowSelection?.onToggle(e)
}}
/>
</div>
{/if}
<!-- The link covers the whole row rather than wrapping the title, so the
badges on the title line can hold their own popovers and buttons. -->
{#if href && !inSelectionMode}
<a
{href}
aria-label={!summary || summary.length == 0 ? displayPath : summary}
class="absolute inset-0"
></a>
{/if}
{@render rowContent(!rowSelection, !!href && !inSelectionMode)}
{#if errorHandlerMuted}
<BellOff class="w-8 opacity-60" size={12} fill="currentcolor" />
{/if}
<div class="relative z-[1] flex items-center gap-3 shrink-0">
{#if kind == 'script' || kind == 'flow' || kind == 'app' || kind == 'raw_app' || kind == 'agent'}
<RunnableActivityCells
kind={kind == 'script' || kind == 'flow' ? kind : undefined}
{path}
/>
{/if}
{@render favorite()}
<div data-row-actions class="flex gap-2 items-center justify-end">
{@render actions?.()}
</div>
</div>
</div>
{/if}
{#snippet favorite()}
{#if kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow'}
<div class="center-center h-full text-xs font-semibold text-secondary w-7">
{#if canFavorite}
<Star {kind} {path} {workspaceId} {summary} />
{:else}
<span class="p-1 opacity-20" title="Deploy it to add it to favorites">
<StarIcon size={16} />
</span>
{/if}
</div>
{:else}
<div class="w-7"></div>
{/if}
{/snippet}
{#snippet rowContent(withIcon: boolean, linked: boolean, inlineBadges = true)}
{#if withIcon}
<div class="shrink">
<RowIcon {kind} {triggerKind} />
@@ -302,7 +406,8 @@
<div class="flex items-center gap-1.5 min-w-0">
<div
class={twMerge(
'text-emphasis flex-wrap text-left text-xs font-semibold min-w-0',
'text-emphasis flex-wrap text-left text-xs min-w-0',
homeTable ? 'font-normal' : 'font-semibold',
titleBadge ? 'inline-flex items-center gap-2' : '',
linked ? 'group-hover/row:underline decoration-gray-400' : ''
)}
@@ -316,9 +421,18 @@
{/if}
{@render titleBadge?.()}
</div>
{#if badges}
{#if !inlineBadges && draftBadge}
<!-- The home table keeps the draft marker on the title line. -->
<div class="relative z-[1] flex items-center shrink-0">{@render draftBadge()}</div>
{/if}
{#if inlineBadges && (badges || languageBadge || tagBadges || draftBadge || sharedBadge || labelBadges)}
<div class="relative z-[1] hidden lg:flex flex-row gap-1 items-center shrink-0">
{@render badges()}
{@render badges?.()}
{@render languageBadge?.()}
{@render tagBadges?.()}
{@render draftBadge?.()}
{@render sharedBadge?.()}
{@render labelBadges?.()}
</div>
{/if}
</div>
@@ -172,10 +172,12 @@
{depth}
{rowSelection}
>
{#snippet badges()}
{#snippet languageBadge()}
{#if script.language}
<LanguageIcon lang={script.language} width={14} height={14} />
{/if}
{/snippet}
{#snippet tagBadges()}
{#if script.lock_error_logs}
<Badge color="red" baseClass="border border-red-200">Deployment failed</Badge>
{/if}
@@ -210,7 +212,11 @@
>{script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)}</Badge
>
{/if}
{/snippet}
{#snippet sharedBadge()}
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
{/snippet}
{#snippet draftBadge()}
<DraftBadge
is_draft={script.is_draft}
draft_only={script.draft_only}
@@ -221,6 +227,8 @@
path={script.path}
onMigrated={() => dispatch('change')}
/>
{/snippet}
{#snippet labelBadges()}
{#if script.labels?.length}
<div class="flex items-center gap-0.5">
{#each script.labels.slice(0, 3) as label}
@@ -257,10 +265,10 @@
<Button
aiId={`edit-script-button-${script.summary?.length > 0 ? script.summary : script.path}`}
aiDescription={`Edits the script ${script.summary?.length > 0 ? script.summary : script.path}`}
variant="default"
wrapperClasses="w-16"
variant="accent"
wrapperClasses="w-16 invisible group-hover/row:visible group-focus-within/row:visible"
startIcon={{ icon: Pen }}
unifiedSize="md"
unifiedSize="sm"
href="{base}/scripts/edit/{script.path}"
>
Edit
@@ -274,6 +282,8 @@
{/if}
</span>
<Dropdown
size="sm"
fixedHeight={false}
aiId={`script-row-dropdown-${script.summary?.length > 0 ? script.summary : script.path}`}
aiDescription={`Open dropdown for script ${script.summary?.length > 0 ? script.summary : script.path} options`}
items={async () => {
@@ -65,6 +65,8 @@
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
import { HomeActivity, setHomeActivity } from './homeActivity.svelte'
import { HOME_TABLE_BADGE_GRID, HOME_TABLE_GRID, setHomeTable } from './homeTable'
import Checkbox from '../common/checkbox/Checkbox.svelte'
const operatorBuilderFlows = useOperatorBuilderFlows()
@@ -1871,6 +1873,7 @@
// Latest runs and attached triggers, fetched in batches for the rows on screen.
const homeActivity = new HomeActivity(() => $workspaceStore)
setHomeActivity(homeActivity)
setHomeTable()
$effect(() => {
$workspaceStore
untrack(() => homeActivity.reset())
@@ -2140,45 +2143,49 @@
</div>
{/if}
{:else if treeView}
<!-- Remount the tree on a MODE change only (treeKey = view/owner/search/label):
<div class="border rounded-md bg-surface-tertiary">
{@render tableHeader()}
<!-- Remount the tree on a MODE change only (treeKey = view/owner/search/label):
expanded folders (their `opened` state is local to each TreeView) collapse
and re-load fresh for the new mode. An in-place order/archive/library/kind
change keeps treeKey stable, so folders stay open and refresh via
reloadItems instead (see loadOwnerItems). -->
{#key treeKey}
<TreeViewRoot
items={treeSource}
{collapseAll}
sortCompare={compareItems}
groupDesc={sortOrder === 'name_desc'}
hasMoreServer={treeGlobalHasMore}
onLoadMore={fetchMoreServer}
pipelineFolders={visiblePipelineFolders}
allFolders={treeInjectFolders}
allUsers={treeInjectUsers}
ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined}
selfUsername={$userStore?.username}
groupOtherUsers={treeLazyMode}
ownerLoad={treeLazyMode ? ownerLoad : undefined}
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
onCollapseOwner={treeLazyMode ? collapseOwner : undefined}
isSearching={filter !== ''}
on:scriptChanged={reloadItemsAndCounts}
on:flowChanged={reloadItemsAndCounts}
on:appChanged={reloadItemsAndCounts}
on:rawAppChanged={reloadItemsAndCounts}
on:reload={reloadItemsAndCounts}
{showCode}
showEditButton={showEditButtons}
/>
{/key}
{#key treeKey}
<TreeViewRoot
items={treeSource}
{collapseAll}
sortCompare={compareItems}
groupDesc={sortOrder === 'name_desc'}
hasMoreServer={treeGlobalHasMore}
onLoadMore={fetchMoreServer}
pipelineFolders={visiblePipelineFolders}
allFolders={treeInjectFolders}
allUsers={treeInjectUsers}
ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined}
selfUsername={$userStore?.username}
groupOtherUsers={treeLazyMode}
ownerLoad={treeLazyMode ? ownerLoad : undefined}
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
onCollapseOwner={treeLazyMode ? collapseOwner : undefined}
isSearching={filter !== ''}
on:scriptChanged={reloadItemsAndCounts}
on:flowChanged={reloadItemsAndCounts}
on:appChanged={reloadItemsAndCounts}
on:rawAppChanged={reloadItemsAndCounts}
on:reload={reloadItemsAndCounts}
{showCode}
showEditButton={showEditButtons}
/>
{/key}
</div>
{:else}
<div class="border rounded-md bg-surface-tertiary" class:wm-imported={justImported}>
{@render tableHeader()}
{#if filter === ''}
{#each [...visiblePipelineFolders].sort() as folder (folder)}
<a
href="{base}/pipeline/{encodeURIComponent(folder)}"
class="w-full inline-flex items-center gap-4 px-4 py-3 border-b last:border-b-0 hover:bg-surface-hover transition-colors text-sm first-of-type:rounded-t-md"
class="w-full inline-flex items-center gap-4 px-3 py-2 rounded-md hover:bg-surface-hover transition-colors text-sm"
>
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
<span class="text-xs font-medium text-emphasis truncate">Pipeline · f/{folder}</span>
@@ -2237,6 +2244,29 @@
</div>
</CenteredPage>
{#snippet tableHeader()}
<div class="{HOME_TABLE_GRID} px-3 py-1.5 border-b text-2xs font-medium text-secondary">
<div class="flex items-center">
{#if homeSelection.available}
{@const state = homeSelection.renderedSelection}
<Checkbox
checked={state === 'all'}
indeterminate={state === 'some'}
title={state === 'all' ? 'Deselect all' : 'Select all'}
onChange={() => homeSelection.toggleAllRendered()}
/>
{/if}
</div>
<div>Name</div>
<div class="hidden lg:grid {HOME_TABLE_BADGE_GRID}">
<div>Labels</div>
</div>
<div class="hidden lg:block text-center">Runs</div>
<div class="hidden lg:block">Triggers</div>
<div></div>
</div>
{/snippet}
{#if homeSelection.active && $workspaceStore}
<BulkActionsBar
selection={homeSelection}
@@ -3,6 +3,7 @@
import { Tooltip } from '$lib/components/meltComponents'
import { triggerDisplayNamesMap, triggerIconMapMono } from '$lib/components/triggers/utils'
import { displayDate } from '$lib/utils'
import { describeSchedule } from '$lib/utils/describeCron'
import { twMerge } from 'tailwind-merge'
import { getHomeActivity, type ActivityKind } from './homeActivity.svelte'
@@ -32,6 +33,17 @@
triggers.length > MAX_TRIGGER_ICONS ? triggers.slice(0, MAX_TRIGGER_ICONS - 1) : triggers
)
let hiddenTriggers = $derived(triggers.slice(visibleTriggers.length))
// A runnable whose only trigger is a schedule shows its cadence instead of an icon.
let loneSchedule = $derived(
triggers.length === 1 && triggers[0].kind === 'schedule' && triggers[0].schedule
? triggers[0]
: undefined
)
let loneScheduleText = $derived(
loneSchedule?.schedule
? (describeSchedule(loneSchedule.schedule, loneSchedule.timezone) ?? loneSchedule.schedule)
: undefined
)
type RunStatus = (typeof runs)[number]['status']
@@ -59,35 +71,7 @@
</script>
{#if homeActivity}
<div class="hidden lg:flex items-center justify-end gap-0.5 w-[4.5rem] shrink-0">
{#each visibleTriggers as trigger (trigger.kind + trigger.path)}
<Tooltip>
{@render triggerChip(trigger.kind, trigger.mode)}
{#snippet text()}
{@render triggerLine(trigger)}
{/snippet}
</Tooltip>
{/each}
{#if hiddenTriggers.length > 0}
<Tooltip>
<div
class="h-5 min-w-5 px-1 rounded-md bg-surface-secondary text-3xs font-semibold text-secondary center-center"
>
+{hiddenTriggers.length}
</div>
{#snippet text()}
<div class="flex flex-col gap-1">
<span class="font-semibold">{triggers.length} triggers</span>
{#each triggers as trigger (trigger.kind + trigger.path)}
{@render triggerLine(trigger)}
{/each}
</div>
{/snippet}
</Tooltip>
{/if}
</div>
<div class="hidden lg:flex w-5 shrink-0 justify-center ml-1.5">
<div class="relative z-[1] hidden lg:flex w-5 shrink-0 justify-center">
{#if kind}
<Tooltip>
<a
@@ -124,6 +108,51 @@
</Tooltip>
{/if}
</div>
<div class="relative z-[1] hidden lg:flex items-center justify-start gap-0.5 w-44 min-w-0 shrink-0">
{#if loneSchedule}
<Tooltip class="min-w-0">
<div
class={twMerge(
'h-5 px-1.5 rounded-md bg-surface-secondary text-secondary text-2xs flex items-center gap-1 min-w-0',
loneSchedule.mode === 'enabled' ? '' : 'opacity-50'
)}
>
{@render triggerIconOnly('schedule')}
<span class="truncate">{loneScheduleText}</span>
</div>
{#snippet text()}
{@render triggerLine(loneSchedule)}
{/snippet}
</Tooltip>
{:else}
{#each visibleTriggers as trigger (trigger.kind + trigger.path)}
<Tooltip>
{@render triggerChip(trigger.kind, trigger.mode)}
{#snippet text()}
{@render triggerLine(trigger)}
{/snippet}
</Tooltip>
{/each}
{#if hiddenTriggers.length > 0}
<Tooltip>
<div
class="h-5 min-w-5 px-1 rounded-md bg-surface-secondary text-3xs font-semibold text-secondary center-center"
>
+{hiddenTriggers.length}
</div>
{#snippet text()}
<div class="flex flex-col gap-1">
<span class="font-semibold">{triggers.length} triggers</span>
{#each triggers as trigger (trigger.kind + trigger.path)}
{@render triggerLine(trigger)}
{/each}
</div>
{/snippet}
</Tooltip>
{/if}
{/if}
</div>
{/if}
{#snippet triggerChip(kind: string, mode: string)}
@@ -142,6 +171,13 @@
</div>
{/snippet}
{#snippet triggerIconOnly(kind: string)}
{@const Icon = triggerIcon(kind)}
{#if Icon}
<Icon size={12} />
{/if}
{/snippet}
{#snippet triggerLine(trigger: { kind: string; path: string; mode: string })}
{@const Icon = triggerIcon(trigger.kind)}
<div class="flex items-center gap-2">
@@ -7,6 +7,7 @@
import Item from './Item.svelte'
import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils'
import { twMerge } from 'tailwind-merge'
import { isHomeTable } from './homeTable'
import { pluralize } from '$lib/utils'
import { base } from '$lib/base'
import { Button } from '$lib/components/common'
@@ -72,6 +73,9 @@
leaf
}: Props = $props()
// The home table draws no separators between its rows.
const separator = isHomeTable() ? 'rounded-md hover:bg-surface-hover' : 'border-b'
let visualDepth = $derived(depth + indent)
// Bounds the request burst from "expand all": however many root owners the tree
@@ -264,7 +268,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOwner}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
class="px-4 py-2 {separator} w-full flex flex-row items-center justify-between cursor-pointer"
>
<div
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
@@ -321,7 +325,7 @@
it under the same folder; py-2 was visibly shorter. -->
<a
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
class="flex items-center gap-4 px-4 py-3 {separator} text-sm hover:bg-surface-hover transition-colors"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
@@ -353,7 +357,7 @@
{/each}
{#if effectiveMax < item.items.length}
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
class="px-4 py-2 {separator} flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<!-- Rows, not items: this slices the node's own entries, where a subfolder
@@ -390,7 +394,7 @@
is the point: without them this reads as an optional extra rather than
as rows still missing. -->
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
class="px-4 py-2 {separator} flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<span class="text-xs text-secondary">
@@ -277,11 +277,8 @@
<div class="text-xs font-normal text-hint">No items</div>
</div>
{:else}
<!-- Every row draws its own border-b, so the last one would double the frame's bottom border.
Pulling the content 1px down puts that border under the frame, where overflow-hidden
clips it, whichever row (collapsed owner, leaf, expanded subtree) ends the list. -->
<div class="border rounded-md bg-surface-tertiary overflow-hidden">
<div class="-mb-px">
<div>
<div>
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)}
{#if row.kind === 'otherUsers'}
<!-- Same shape as an owner row, so it reads as part of the tree. It only reveals
@@ -290,7 +287,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOtherUsers}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
class="px-3 py-2 rounded-md hover:bg-surface-hover w-full flex flex-row items-center justify-between cursor-pointer"
>
<div class="flex flex-row items-center gap-4">
<Users size={16} class="text-secondary" />
@@ -324,7 +321,7 @@
{/each}
{#if nbOtherUsersDisplayed < otherUsers.length}
<div
class="pl-8 pr-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
class="pl-8 pr-4 py-2 flex flex-row items-center justify-between gap-4"
>
<span class="text-xs text-secondary">
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users
@@ -352,7 +349,7 @@
has to read as part of the list to be noticed at all. -->
<div
bind:this={footerEl}
class="px-4 py-3 flex flex-row items-center justify-between gap-4 bg-surface-secondary"
class="px-3 py-2 flex flex-row items-center justify-between gap-4"
>
<span class="text-xs text-secondary">
{#if nbDisplayed < rows.length}
@@ -150,6 +150,24 @@ export class HomeSelection {
else this.anchor = undefined
}
/** How many of the rows on screen are selected, for a select-all control. */
get renderedSelection(): 'none' | 'some' | 'all' {
let selected = 0
for (const key of this.registry.keys()) if (this.selected.has(key)) selected++
if (selected === 0) return 'none'
return selected === this.registry.size ? 'all' : 'some'
}
/** Select every row on screen, or clear the selection when all already are. */
toggleAllRendered(): void {
if (this.renderedSelection === 'all') {
this.exit()
return
}
for (const [key, item] of this.registry) this.selected.set(key, item)
this.anchor = undefined
}
toggle(item: BulkItem, range = false): void {
if (range && this.anchor != undefined && this.anchor !== item.key) {
if (this.selectRange(this.anchor, item.key)) return
@@ -0,0 +1,29 @@
import { getContext, setContext } from 'svelte'
/**
* Column template shared by the home table's header and every row, so the
* columns line up across separately rendered rows (the tree nests them):
* select · name · badges · runs · triggers · actions. Below `lg` the labels,
* triggers and runs cells are hidden, and the template drops them too.
*/
export const HOME_TABLE_GRID =
'grid items-center gap-x-5 grid-cols-[1rem_minmax(0,1fr)_9rem] lg:grid-cols-[1rem_minmax(0,2fr)_minmax(0,1.5fr)_1.25rem_11rem_9rem]'
/** Sub-columns of the badges area: labels · language · type tags · shared. */
export const HOME_TABLE_BADGE_GRID =
'grid-cols-[minmax(0,1fr)_1rem_5rem_1.25rem] gap-x-2 items-center min-w-0'
/** Shows a muted dash in a table cell that renders no element. */
export const EMPTY_CELL_DASH =
"[&:not(:has(*))]:after:content-['–'] [&:not(:has(*))]:after:text-hint [&:not(:has(*))]:after:text-2xs [&:not(:has(*))]:after:opacity-40"
const CONTEXT_KEY = 'homeTable'
/** Rows below the calling component render as home table rows. */
export function setHomeTable() {
setContext(CONTEXT_KEY, true)
}
export function isHomeTable(): boolean {
return getContext<boolean | undefined>(CONTEXT_KEY) ?? false
}
+128
View File
@@ -0,0 +1,128 @@
import { formatCron } from '$lib/utils'
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const DAY_ALIASES: Record<string, number> = {
SUN: 0,
MON: 1,
TUE: 2,
WED: 3,
THU: 4,
FRI: 5,
SAT: 6
}
const isNum = (f: string) => /^\d+$/.test(f)
const everyN = (f: string) => /^\*\/(\d+)$/.exec(f)?.[1]
function time(hour: string, minute: string): string {
return `${Number(hour)}:${minute.padStart(2, '0')}`
}
function ordinal(n: number): string {
const rest = n % 100
if (rest >= 11 && rest <= 13) return `${n}th`
return `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}`
}
function dayNumber(token: string): number | undefined {
const t = token.toUpperCase()
if (t in DAY_ALIASES) return DAY_ALIASES[t]
if (!isNum(t)) return undefined
const n = Number(t)
return n >= 0 && n <= 7 ? n % 7 : undefined
}
/** `1-5`, `MON-FRI`, `1,3,5`, `6` → sorted day numbers; undefined for anything else. */
function days(field: string): number[] | undefined {
const out = new Set<number>()
for (const part of field.split(',')) {
const [from, to] = part.split('-')
const a = dayNumber(from)
if (a === undefined) return undefined
if (to === undefined) {
out.add(a)
continue
}
const b = dayNumber(to)
if (b === undefined || b < a) return undefined
for (let d = a; d <= b; d++) out.add(d)
}
return [...out].sort()
}
function listDays(ds: number[]): string {
if (ds.join() === '1,2,3,4,5') return 'weekday'
if (ds.join() === '0,6') return 'weekend day'
const names = ds.map((d) => DAY_NAMES[d])
return names.length === 1
? names[0]
: `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
/**
* A plain-English reading of a Windmill cron expression — "Every hour",
* "Every day at 4:00", "Every Monday at 9:30" — or undefined when it is not one
* of the common shapes, so the caller can show the expression itself.
*
* Windmill crons have six fields, seconds first; a five-field entry is padded
* at the end the way the schedule editor saves it (`formatCron`).
*/
export function describeCron(cron: string): string | undefined {
const fields = formatCron(cron.trim()).split(/\s+/)
if (fields.length !== 6) return undefined
const [sec, min, hour, dom, month, dow] = fields
if (month !== '*') return undefined
const anyDay = dom === '*' && (dow === '*' || dow === '?')
if (anyDay && min === '*' && hour === '*') {
if (sec === '*') return 'Every second'
const n = everyN(sec)
if (n) return `Every ${n} seconds`
}
if (sec !== '0') return undefined
if (anyDay) {
if (hour === '*') {
if (min === '*') return 'Every minute'
const n = everyN(min)
if (n) return `Every ${n} minutes`
if (min === '0') return 'Every hour'
if (isNum(min)) return `Every hour at :${min.padStart(2, '0')}`
return undefined
}
const n = everyN(hour)
if (n && min === '0') return `Every ${n} hours`
if (isNum(hour) && isNum(min)) return `Every day at ${time(hour, min)}`
return undefined
}
if (!isNum(hour) || !isNum(min)) return undefined
if (dom === '*') {
const ds = days(dow)
return ds ? `Every ${listDays(ds)} at ${time(hour, min)}` : undefined
}
if (isNum(dom) && (dow === '*' || dow === '?')) {
return `Every month on the ${ordinal(Number(dom))} at ${time(hour, min)}`
}
return undefined
}
function shortZone(timezone: string): string {
try {
return (
new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short' })
.formatToParts(new Date())
.find((p) => p.type === 'timeZoneName')?.value ?? timezone
)
} catch {
return timezone
}
}
/** `describeCron` of a schedule, naming its zone whenever the text states a
* wall-clock time: "Every day at 12:00 UTC". */
export function describeSchedule(cron: string, timezone?: string): string | undefined {
const text = describeCron(cron)
if (!text || !timezone || !/ at \d/.test(text)) return text
return `${text} ${shortZone(timezone)}`
}