Run page v2 (#1805)

* feat(frontend): Run page v2

* feat(frontend): Run page v2

* feat(frontend): Run page v2

* feat(frontend): migrate script details

* feat(frontend): migrate script details

* feat(frontend): done

* feat(frontend): revert

* feat(frontend): fix archive script

* feat(frontend): remove old run page

* feat(frontend): Update wording

* feat(frontend): done

* feat(frontend): Remove buttons for operator mode

* feat(frontend): Fix spacing + add protocol

* feat(frontend): Fix build
This commit is contained in:
Faton Ramadani
2023-07-08 13:40:47 +02:00
committed by GitHub
parent 518bf23005
commit 19c025228a
21 changed files with 1223 additions and 920 deletions
+4 -25
View File
@@ -1,35 +1,14 @@
<script lang="ts">
import { page } from '$app/stores'
import { workspaceStore } from '$lib/stores'
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
import { slide } from 'svelte/transition'
import InlineCodeCopy from './InlineCodeCopy.svelte'
$: opened = false
$: url = `${$page.url.protocol}//${$page.url.hostname}/`
</script>
<div class="text-sm mt-2 flex">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<button
class="underline mr-4 text-gray-600"
on:click={() => {
opened = !opened
}}
>
CLI quick setup
<Icon class="ml-2" data={opened ? faChevronUp : faChevronDown} scale={0.7} />
</button>
</div>
{#if opened}
<div
transition:slide|local={{ duration: 100 }}
class="border border-gray-200 rounded-md p-4 mt-2 text-sm text-gray-600"
role="alert"
id="dynamic-input-help-box"
>
<div>
<div class="text-sm font-semibold"> CLI quick setup </div>
<div class="text-sm text-gray-600" role="alert" id="dynamic-input-help-box">
<ul class="pl-0 pt-2 list-decimal list-inside">
<li
>Install the latest wmill CLI from deno.land: <InlineCodeCopy
@@ -45,4 +24,4 @@
<li>Use the run command above!</li>
</ul>
</div>
{/if}
</div>
@@ -4,13 +4,14 @@
import Icon from 'svelte-awesome'
import { Button } from './common'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
export let open = false
export let text: string
export let small = false
</script>
<div class="flex">
<div class={twMerge('flex', $$props.class)}>
<Button
variant="border"
color="light"
@@ -73,7 +73,7 @@
<div
class="{noSide
? 'col-span-3'
: 'sm:col-span-2 col-span-3'} w-full border border-gray-400 max-h-full"
: 'sm:col-span-2 col-span-3'} w-full border border-gray-200 max-h-full"
class:overflow-auto={overflowAuto}
>
<FlowGraph
@@ -34,7 +34,9 @@
schema: flow.schema
}
export let tab: 'ui' | 'json' | 'schema' = 'ui'
export let noGraph: boolean = false
export let tab: 'ui' | 'json' | 'schema' = noGraph ? 'schema' : 'ui'
export let noSummary = false
let open: { [id: number]: boolean } = {}
@@ -48,7 +50,9 @@
</script>
<Tabs bind:selected={tab}>
<Tab value="ui">Graph</Tab>
{#if !noGraph}
<Tab value="ui">Graph</Tab>
{/if}
<Tab value="json">JSON</Tab>
<Tab value="schema">Input Schema</Tab>
@@ -91,9 +95,9 @@
<div class="relative pt-2">
<Button
on:click={() => copyToClipboard(JSON.stringify(flowFiltered, null, 4))}
color="dark"
color="light"
variant="border"
size="sm"
size="xs"
startIcon={{ icon: faClipboard }}
btnClasses="absolute top-2 right-2 w-min"
>
@@ -7,7 +7,7 @@
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<pre class="bg-gray-700 text-gray-100 p-2 font-mono text-sm whitespace-pre-wrap my-2"
<pre class="bg-gray-700 text-gray-100 p-2 font-mono text-sm whitespace-pre-wrap my-2 rounded-md"
>{content} <span on:click={() => copyToClipboard(content)} class="cursor-pointer ml-2"
><Icon data={faClipboard} /></span
></pre
+55 -52
View File
@@ -131,7 +131,7 @@
{#if runnable?.schema}
<div class="my-2" />
{#if !runnable.schema.properties || Object.keys(runnable.schema.properties).length === 0}
<div class="text-sm p-4">No arguments</div>
<div class="text-sm py-4 italic">No arguments</div>
{:else}
{#key reloadArgs}
<SchemaForm
@@ -150,67 +150,70 @@
{#if schedulable}
<div class="mt-10" />
<div class="flex gap-2 items-start flex-wrap justify-between mt-2 md:mt-6 mb-6">
<div class="flex-row-reverse flex grow">
<div class="flex-row-reverse flex w-full gap-2">
<Button
{loading}
color="dark"
btnClasses="!px-6 !py-1"
btnClasses="!px-6 !py-1 !h-8"
disabled={!isValid}
on:click={() => runAction(scheduledForStr, args, invisible_to_owner)}
>
{scheduledForStr ? 'Schedule to run later' : buttonText}
</Button>
<div>
<CollapseLink small text="Advanced" class="justify-end">
<div class="flex flex-col gap-4 mt-2 border p-2">
<div class="flex flex-col gap-2">
{#if SCRIPT_VIEW_SHOW_SCHEDULE_RUN_LATER}
<div class="border rounded-md p-3 pt-4">
<div class="px-2 font-semibold text-sm">Schedule to run later</div>
<div class="flex flex-row items-end">
<div class="w-max md:w-2/3 mt-2 mb-1">
<label for="run-time" />
<input
class="inline-block"
type="datetime-local"
id="run-time"
name="run-scheduled-time"
bind:value={scheduledForStr}
min={getToday().toISOString().slice(0, 16)}
/>
</div>
<Button
variant="border"
color="blue"
size="sm"
btnClasses="mx-2 mb-1"
on:click={() => {
scheduledForStr = undefined
}}
>
Clear
</Button>
</div>
</div>
{/if}
</div>
{#if runnable?.path?.startsWith(`u/${$userStore?.username}`) != true && (runnable?.path?.split('/')?.length ?? 0) > 2}
<div class="flex items-center gap-1">
<Toggle
options={{
right: `make run invisible to others`
}}
bind:checked={invisible_to_owner}
/>
<Tooltip
>By default, runs are visible to the owner(s) of the script or flow being
triggered</Tooltip
>
</div>
{/if}
</div>
</CollapseLink>
</div>
</div>
</div>
<CollapseLink small text="Advanced">
<div class="flex flex-col gap-4 mt-2 border p-2">
<div class="flex flex-col gap-2">
{#if SCRIPT_VIEW_SHOW_SCHEDULE_RUN_LATER}
<div class="border rounded-md p-3 pt-4">
<div class="px-2 font-semibold text-sm">Schedule to run later</div>
<div class="flex flex-row items-end">
<div class="w-max md:w-2/3 mt-2 mb-1">
<label for="run-time" />
<input
class="inline-block"
type="datetime-local"
id="run-time"
name="run-scheduled-time"
bind:value={scheduledForStr}
min={getToday().toISOString().slice(0, 16)}
/>
</div>
<Button
variant="border"
color="blue"
size="sm"
btnClasses="mx-2 mb-1"
on:click={() => {
scheduledForStr = undefined
}}
>
Clear
</Button>
</div>
</div>
{/if}
</div>
{#if runnable?.path?.startsWith(`u/${$userStore?.username}`) != true && (runnable?.path?.split('/')?.length ?? 0) > 2}
<div class="flex items-center gap-1">
<Toggle
options={{
right: `make run invisible to others`
}}
bind:checked={invisible_to_owner}
/>
<Tooltip
>By default, runs are visible to the owner(s) of the script or flow being triggered</Tooltip
>
</div>
{/if}
</div>
</CollapseLink>
{:else if !topButton}
<Button
btnClasses="!px-6 !py-1 w-full"
@@ -154,7 +154,7 @@
disabled={!isValid}
loading={savingInputs}
startIcon={{ icon: faSave }}
color="blue"
color="light"
size="xs"
>
<span>Save Current Input</span>
@@ -257,7 +257,7 @@
<Pane>
<div class="w-full flex flex-col gap-4 p-2">
<span class="text-sm font-extrabold">Previous Inputs</span>
<span class="text-sm font-extrabold">Previous runs</span>
<div class="w-full flex flex-col gap-1 p-0 h-full overflow-y-auto">
{#if previousInputs.length > 0}
@@ -6,7 +6,12 @@
import { workspaceStore } from '$lib/stores'
import { Skeleton } from '$lib/components/common'
import FlowModuleScript from './flows/content/FlowModuleScript.svelte'
import { createEventDispatcher } from 'svelte'
import Button from './common/button/Button.svelte'
const dispatch = createEventDispatcher()
export let openDetails: boolean = false
export let scriptPath: string
let selectedVersion: string | undefined = undefined
@@ -38,6 +43,18 @@
on:click={() => (selectedVersion = version)}
>
<span class="text-xs truncate">{version}</span>
{#if openDetails}
<Button
on:click={() => {
dispatch('openDetails', { version })
}}
size="xs2"
color="light"
variant="border"
>
Open details
</Button>
{/if}
</div>
{/each}
</div>
@@ -11,6 +11,8 @@
export let versions: number[]
debugger
let selectedVersion: number | undefined = undefined
let selected: AppWithLastVersion | undefined = undefined
@@ -100,16 +100,6 @@
</div>
{/if}
{/if}
<Button
href="/apps/get/{path}"
color="dark"
size="xs"
spacingSize="md"
startIcon={{ icon: faEye }}
>
View
</Button>
</span>
<Dropdown
placement="bottom-end"
@@ -12,10 +12,8 @@
faCalendarAlt,
faCodeFork,
faEdit,
faEye,
faFileExport,
faList,
faPlay,
faShare,
faTrashAlt
} from '@fortawesome/free-solid-svg-icons'
@@ -68,7 +66,7 @@
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<Row
href={`/flows/run/${path}`}
href="/flows/get/{path}?workspace={$workspaceStore}"
kind="flow"
workspaceId={workspace_id ?? $workspaceStore ?? ''}
{marked}
@@ -115,28 +113,6 @@
</div>
{/if}
{/if}
{#if !draft_only}
<Button
href="/flows/get/{path}?workspace={$workspaceStore}"
color="light"
variant="border"
size="xs"
spacingSize="md"
startIcon={{ icon: faEye }}
>
Detail
</Button>
<Button
href="/flows/run/{path}"
color="dark"
size="xs"
spacingSize="md"
endIcon={{ icon: faPlay }}
>
Run
</Button>
{/if}
</span>
<Dropdown
@@ -22,21 +22,27 @@
</script>
<div
class="hover:bg-gray-50 w-full inline-flex items-center px-4 py-4 gap-4 first-of-type:!border-t-0
class="hover:bg-gray-50 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 {color}"
>
<RowIcon {href} {kind} />
<a {href} class="min-w-0 grow hover:underline decoration-gray-400">
<div class="text-gray-900 flex-wrap text-left text-sm font-semibold mb-1 truncate">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? path : summary}
{/if}
<a
{href}
class="min-w-0 grow hover:underline decoration-gray-400 px-4 py-4 flex flex-row items-center gap-4"
>
<div>
<RowIcon {href} {kind} />
</div>
<div class="text-gray-600 text-2xs truncate text-left font-light">
{path}
<div>
<div class="text-gray-900 flex-wrap text-left text-sm font-semibold mb-1 truncate">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? path : summary}
{/if}
</div>
<div class="text-gray-600 text-2xs truncate text-left font-light">
{path}
</div>
</div>
</a>
{#if $$slots.badges}
@@ -13,10 +13,8 @@
faCalendarAlt,
faCodeFork,
faEdit,
faEye,
faFileExport,
faList,
faPlay,
faShare,
faTrashAlt
} from '@fortawesome/free-solid-svg-icons'
@@ -86,7 +84,7 @@
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<Row
href={`/scripts/run/${hash}`}
href="/scripts/get/{hash}?workspace={$workspaceStore}"
kind="script"
{marked}
{path}
@@ -139,28 +137,6 @@
</div>
{/if}
{/if}
{#if !draft_only}
<Button
href="/scripts/get/{hash}?workspace={$workspaceStore}"
color="light"
variant="border"
size="xs"
spacingSize="md"
startIcon={{ icon: faEye }}
>
Detail
</Button>
<Button
href="/scripts/run/{hash}"
color="dark"
size="xs"
spacingSize="md"
endIcon={{ icon: faPlay }}
>
Run
</Button>
{/if}
</span>
<Dropdown
placement="bottom-end"
@@ -0,0 +1,21 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils'
import { Clipboard } from 'lucide-svelte'
export let content: string
export let title: string
</script>
<div class="text-xs font-semibold">{title}</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="px-2 py-1 border flex items-center cursor-pointer bg-gray-100 text-gray-900 justify-between rounded-md"
on:click={(e) => {
e.preventDefault()
copyToClipboard(content)
}}
>
<div class="text-xs whitespace-pre-wrap">{content}</div>
<Clipboard size={14} />
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Menu from '$lib/components/details/Menu.svelte'
import MenuItem from '$lib/components/common/menu/MenuItem.svelte'
import { classNames } from '$lib/utils'
type MainButton = {
label: string
href: string
buttonProps: ButtonProps
}
type ButtonProps = any
type MenuItemButton = {
label: string
Icon: any
onclick: () => void
color?: 'red'
}
export let mainButtons: MainButton[] = []
export let menuItems: MenuItemButton[] = []
export let title: string
</script>
<div class="border-b p-2 shadow-md">
<div class="mx-auto">
<div class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 h-8 items-center">
<div class="grow text-lg font-bold">
{title}
</div>
<div class="flex gap-2 items-center">
<Menu>
<svelte:fragment slot="items">
{#each menuItems as { label, Icon, onclick, color } (label)}
<MenuItem
on:click={() => {
const div = document.querySelector('[id^="headlessui-menu-items"]')
div?.parentElement?.remove()
onclick()
}}
>
<div
class={classNames(
'text-xs flex items-center gap-2 flex-row-2',
color === 'red' ? 'text-red-500' : ''
)}
>
<Icon class="h-4" />
{label}
</div>
</MenuItem>
{/each}
</svelte:fragment>
</Menu>
{#each mainButtons as btn}
<Button
{...btn.buttonProps}
startIcon={{ icon: btn.buttonProps.startIcon }}
on:click={btn.buttonProps.onClick}
>
{btn.label}
</Button>
{/each}
</div>
</div>
</div>
</div>
@@ -0,0 +1,83 @@
<script lang="ts">
import { Tabs, Tab, TabContent } from '$lib/components/common'
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { CalendarCheck2, Terminal, Webhook } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
let triggerSelected: 'webhooks' | 'schedule' | 'cli' = 'webhooks'
export let isOperator: boolean = false
</script>
<main class="h-screen w-full">
<div class="flex flex-col h-full">
<slot name="header" />
<SplitPanesWrapper>
<Splitpanes>
<Pane size={60} minSize={20} maxSize={70}>
<slot name="form" />
</Pane>
<Pane size={40} minSize={20}>
<Splitpanes horizontal class="h-full">
<Pane size={100}>
<Tabs selected="saved_inputs">
<Tab value="saved_inputs">Saved inputs</Tab>
{#if !isOperator}
<Tab value="details">Details & Triggers</Tab>
{/if}
<svelte:fragment slot="content">
<div class="overflow-hidden" style="height:calc(100% - 32px);">
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="details" class="flex flex-col flex-1 h-full">
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={20}>
<slot name="details" />
</Pane>
<Pane size={50} minSize={20}>
<Tabs bind:selected={triggerSelected}>
<Tab value="webhooks">
<span class="flex flex-row gap-2 items-center">
<Webhook size={14} />
Webhooks
</span>
</Tab>
<Tab value="schedule">
<span class="flex flex-row gap-2 items-center">
<CalendarCheck2 size={14} />
Schedule
</span>
</Tab>
<Tab value="cli">
<span class="flex flex-row gap-2 items-center">
<Terminal size={14} />
CLI
</span>
</Tab>
</Tabs>
<div class="h-[calc(100%-32px)]">
<div class="h-full overflow-auto">
{#if triggerSelected === 'webhooks'}
<slot name="webhooks" />
{:else if triggerSelected === 'schedule'}
<slot name="schedule" />
{:else if triggerSelected === 'cli'}
<slot name="cli" />
{/if}
</div>
</div>
</Pane>
</Splitpanes>
</TabContent>
</div>
</svelte:fragment>
</Tabs>
</Pane>
</Splitpanes>
</Pane>
</Splitpanes>
</SplitPanesWrapper>
</div>
</main>
@@ -0,0 +1,57 @@
<script lang="ts">
import { Menu, Transition, MenuButton, MenuItems } from '@rgossiaux/svelte-headlessui'
import { MoreVertical } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { createPopperActions, type PopperOptions } from 'svelte-popperjs'
import Portal from 'svelte-portal'
export let hasPadding: boolean = true
const [popperRef, popperContent] = createPopperActions({ placement: 'auto' })
const popperOptions: PopperOptions<{}> = {
placement: 'bottom-end',
strategy: 'fixed',
modifiers: [
{ name: 'offset', options: { offset: [8, 8] } },
{
name: 'arrow',
options: {
padding: 10
}
}
]
}
</script>
<Menu let:open as="div" class="relative hover:z-50 flex w-full h-full">
<span use:popperRef>
<MenuButton
class={twMerge('h-full w-full flex flex-row gap-2 items-center', hasPadding ? 'px-2' : '')}
>
<MoreVertical class="w-5 h-5" />
</MenuButton>
</span>
<Portal>
<div use:popperContent={popperOptions} class="z-[2000]">
<Transition
show={open}
enter="transition ease-out duration-[25ms]"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-[25ms]"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
class="absolute border right-0 z-50 w-56 origin-top-right top-1 rounded-md bg-white shadow-md focus:outline-none cursor-pointer"
>
<div class="my-1">
<slot name="items" />
</div>
</MenuItems>
</Transition>
</div>
</Portal>
</Menu>
@@ -0,0 +1,246 @@
<script lang="ts">
import { page } from '$app/stores'
import Tooltip from '$lib/components/Tooltip.svelte'
import { workspaceStore } from '$lib/stores'
import bash from 'svelte-highlight/languages/bash'
import { Tabs, Tab, TabContent, Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
DEFAULT_WEBHOOK_TYPE,
SCRIPT_VIEW_SHOW_EXAMPLE_CURL,
SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON
} from '$lib/consts'
import { ArrowDownRight, ArrowUpRight } from 'lucide-svelte'
import UserSettings from '../UserSettings.svelte'
import { Highlight } from 'svelte-highlight'
import { typescript } from 'svelte-highlight/languages'
import ClipboardPanel from './ClipboardPanel.svelte'
let userSettings: UserSettings
export let webhooks: {
async: {
hash?: string
path: string
}
sync: {
hash?: string
path: string
get_path?: string
}
}
export let args: any
export let scopes: string[] = []
export let isFlow: boolean = false
let webhookType: 'async' | 'sync' = DEFAULT_WEBHOOK_TYPE
let requestType: 'hash' | 'path' | 'get_path' = isFlow ? 'path' : 'path'
let tokenType: 'query' | 'headers' = 'headers'
$: if (webhookType === 'async' && requestType === 'get_path') {
// Request type is not supported for async webhooks
requestType = 'hash'
}
function getProtocol(url: string) {
if (url.startsWith('localhost')) {
return 'http://'
} else if (url.startsWith('http://') || url.startsWith('https://')) {
return ''
} else {
return 'https://'
}
}
$: url = webhooks[webhookType][requestType]
let token = 'YOUR_TOKEN'
</script>
<UserSettings bind:this={userSettings} {scopes} />
<div class="p-2 flex flex-col w-full gap-4">
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<div class="flex flex-row justify-between my-2 gap-2">
<input bind:value={token} placeholder="YOUR_TOKEN" class="!text-xs" />
<Button size="xs" color="light" variant="border" on:click={userSettings.openDrawer}>
Create a Webhook-specific Token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It is
safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
</div>
{/if}
<div class="flex flex-col gap-2">
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Request type</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={webhookType}>
<ToggleButton
label="Async"
value="async"
tooltip="Jobs can be triggered in asynchronous mode, meaning that the webhook is triggered, and the returning value is the uuid of the job assigned to execute the underlying code."
/>
<ToggleButton
label="Sync"
value="sync"
tooltip="The second type of autogenerated endpoint is the synchronous webhook. This webhook triggers the execution, automatically extracts the underlying code's return value and returns it as the response."
/>
</ToggleButtonGroup>
</div>
{#if !isFlow}
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Call method</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={requestType}>
<ToggleButton
label="POST by path"
value="path"
icon={ArrowUpRight}
selectedColor="#fb923c"
/>
<ToggleButton
label="POST by hash"
value="hash"
icon={ArrowUpRight}
selectedColor="#fb923c"
/>
<ToggleButton
label="GET by path"
value="get_path"
icon={ArrowDownRight}
disabled={webhookType !== 'sync'}
selectedColor="#14b8a6"
/>
</ToggleButtonGroup>
</div>
{/if}
<div class="flex flex-row justify-between">
<div class="text-xs font-semibold flex flex-row items-center">Token configuration</div>
<ToggleButtonGroup class="h-[30px] w-auto" bind:selected={tokenType}>
<ToggleButton label="Token in Headers" value="headers" />
<ToggleButton label="Token in Query" value="query" />
</ToggleButtonGroup>
</div>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Tabs selected="rest">
<Tab value="rest" size="xs">REST</Tab>
{#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL}
<Tab value="curl" size="xs">Curl</Tab>
{/if}
<Tab value="fetch" size="xs">Fetch</Tab>
<svelte:fragment slot="content">
<TabContent value="rest" class="flex flex-col flex-1 h-full ">
<div class="flex flex-col gap-2">
<ClipboardPanel
title="Url"
content={tokenType === 'query'
? `${getProtocol(url)}${url}?token=${token}`
: `${getProtocol(url)}${url}`}
/>
{#if requestType !== 'get_path'}
<ClipboardPanel title="Body" content={JSON.stringify(args, null, 2)} />
{/if}
<ClipboardPanel
title="Headers"
content={JSON.stringify(
tokenType === 'query'
? {
'Content-Type': 'application/json'
}
: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
null,
2
)}
/>
</div>
</TabContent>
<TabContent value="curl" class="flex flex-col flex-1 h-full">
<div class="flex flex-col gap-2">
<Highlight
language={bash}
code={`${requestType !== 'get_path' ? `BODY='${JSON.stringify(args)}'` : ''}
URL=${tokenType === 'query' ? `${url}?token=${token}` : url}\n
curl -H 'Content-Type: application/json' ${
tokenType === 'headers' ? `-H 'Authorization: Bearer ${token}' ` : ''
}-X ${requestType === 'get_path' ? 'GET' : 'POST'} ${
requestType !== 'get_path' ? `-d "$BODY" ` : ''
}$URL
`}
class="p-2 whitespace-pre-wrap border rounded-md"
/>
<Highlight
language={bash}
class="p-2 whitespace-pre-wrap border rounded-md"
code={`${
webhookType === 'sync'
? 'echo $RESPONSE'
: `UUID=$(echo $RESPONSE | jq -r .uuid)
URL=${$page.url.protocol}//${$page.url.hostname}/api/w/${$workspaceStore}/jobs_u/completed/get_result_maybe/$UUID
while true; do
RESULT=$(curl -H "Authorization: Bearer $TOKEN" $URL)
COMPLETED=$(echo $RESULT | jq .completed)
if [ "$COMPLETED" = "true" ]; then
echo 'Job result: ' $RESULT
break
else
sleep 1
fi
done`
}`}
/>
</div>
</TabContent>
<TabContent value="fetch" class="flex flex-col flex-1 h-full">
<Highlight
language={typescript}
class="p-2 whitespace-pre-wrap border rounded-md"
code={`${
requestType !== 'get_path' ? 'const body = ' + JSON.stringify(args, null, 2) : ''
}
fetch(${tokenType === 'query' ? `\`${url}?token=${token}\`` : `\`${url}\``}, {
method: '${requestType === 'get_path' ? 'GET' : 'POST'}',
headers: {
'Content-Type': 'application/json',
${tokenType === 'headers' ? `'Authorization': 'Bearer ${token}',` : ''}
},
${requestType !== 'get_path' ? `body: body` : ''}
}).then(
response => response.json()
).then(data => {
${
webhookType === 'sync'
? 'console.log(data)'
: `let UUID = data.uuid;
let checkCompletion = setInterval(() => {
fetch(\`${$page.url.protocol}//${$page.url.hostname}/api/w/${$workspaceStore}/jobs_u/completed/get_result_maybe/\$\{UUID\}\`, {
method: 'GET',
headers: {
'Authorization': 'Bearer ${token}'
}
}).then(response => response.json())
.then(data => {
if (data.completed) {
console.log('Job result: ', data);
clearInterval(checkCompletion);
}
});
}, 1000);`
}
});`}
/>
</TabContent>
</svelte:fragment>
</Tabs>
</div>
@@ -0,0 +1,65 @@
import { ScriptService, JobService, Script } from '$lib/gen'
export async function deleteScript(hash: string, workspace: string) {
return await ScriptService.deleteScriptByHash({ workspace: workspace, hash })
}
export async function archiveScript(hash: string, workspace: string) {
return await ScriptService.archiveScriptByHash({ workspace: workspace, hash })
}
export async function unarchiveScript(hash: string, workspace: string) {
const r = await ScriptService.getScriptByHash({ workspace: workspace, hash })
const ns = await ScriptService.createScript({
workspace: workspace,
requestBody: {
...r,
parent_hash: hash,
lock: r.lock?.split('\n')
}
})
return ns
}
export async function getScriptDeploymentStatus(hash: string, workspace: string): Promise<any> {
return await ScriptService.getScriptDeploymentStatus({
workspace: workspace,
hash: hash
})
}
export async function loadScript(hash: string, workspace: string): Promise<any> {
let script: Script
try {
script = await ScriptService.getScriptByHash({ workspace: workspace, hash })
} catch {
script = await ScriptService.getScriptByPath({ workspace: workspace, path: hash })
hash = script.hash
}
return { script, hash }
}
export async function runScript(
scheduledForStr: string | undefined,
args: Record<string, any>,
hash: string,
workspace: string,
invisibleToOwner?: boolean
) {
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
let run = await JobService.runScriptByHash({
workspace: workspace,
hash: hash,
requestBody: args,
scheduledFor,
invisibleToOwner
})
return run
}
export function curlCommand(async: boolean, args: any, $page: any, workspace: string, script: any) {
return `curl -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -X POST -d '${JSON.stringify(
args
)}' ${$page.url.protocol}//${$page.url.hostname}/api/w/${workspace}/jobs/run${
async ? '' : '_wait_result'
}/p/${script?.path}`
}
@@ -3,56 +3,47 @@
import { FlowService, JobService, ScheduleService, type Flow, type Schedule } from '$lib/gen'
import {
canWrite,
copyToClipboard,
defaultIfEmptyString,
displayDaysAgo,
emptyString,
encodeState
} from '$lib/utils'
import {
faArchive,
faCalendar,
faChevronDown,
faChevronUp,
faClipboard,
faCodeFork,
faEdit,
faFileExport,
faList,
faPlay,
faShare,
faTrash
} from '@fortawesome/free-solid-svg-icons'
import { faCalendar, faCodeFork, faEdit } from '@fortawesome/free-solid-svg-icons'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
import { goto } from '$app/navigation'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, Skeleton, Tab, TabContent, Tabs } from '$lib/components/common'
import CronInput from '$lib/components/CronInput.svelte'
import FlowViewer from '$lib/components/FlowViewer.svelte'
import { Alert, Button, Skeleton } from '$lib/components/common'
import JobArgs from '$lib/components/JobArgs.svelte'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import UserSettings from '$lib/components/UserSettings.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import Icon from 'svelte-awesome'
import { slide } from 'svelte/transition'
import { sendUserToast } from '$lib/toast'
import Urlize from '$lib/components/Urlize.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import SavedInputs from '$lib/components/SavedInputs.svelte'
import { FolderOpen, Archive, Trash, Server, Share, PenBox, ListOrdered } from 'lucide-svelte'
let userSettings: UserSettings
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import WebhooksPanel from '$lib/components/details/WebhooksPanel.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import CliHelpBox from '$lib/components/CliHelpBox.svelte'
import InlineCodeCopy from '$lib/components/InlineCodeCopy.svelte'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
let flow: Flow | undefined
let schedule: Schedule | undefined
let can_write = false
let path = $page.params.path
let shareModal: ShareModal
$: cliCommand = `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'`
$: {
if ($workspaceStore && $userStore) {
loadFlow()
@@ -100,6 +91,8 @@
requestBody: { enabled }
})
loadSchedule()
sendUserToast(`Schedule ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
sendUserToast(`Cannot ` + (enabled ? 'disable' : 'enable') + ` schedule: ${err}`, true)
loadSchedule()
@@ -135,35 +128,95 @@
}
let scheduleEditor: ScheduleEditor
let viewWebhookCommand = false
let args = undefined
function curlCommand(async: boolean) {
return `curl -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -X POST -d '${JSON.stringify(
args
)}' ${$page.url.protocol}//${$page.url.hostname}/api/w/${$workspaceStore}/jobs/run${
async ? '' : '_wait_result'
}/f/${flow?.path}`
}
let webhook: HTMLHeadElement
let moveDrawer: MoveDrawer
let deploymentDrawer: DeployWorkspaceDrawer
let runForm: RunForm
function getMainButtons(flow: Flow | undefined) {
if (!flow || $userStore?.operator) return []
const buttons: any = []
if (!$userStore?.operator) {
buttons.push({
label: 'Fork',
buttonProps: {
href: `/flows/add?template=${flow.path}`,
variant: 'border',
color: 'light',
size: 'xs',
disabled: !can_write,
startIcon: faCodeFork
}
})
buttons.push({
label: 'Edit',
buttonProps: {
href: `/flows/edit/${path}?nodraft=true&args=${encodeState(args)}`,
variant: 'contained',
size: 'sm',
color: 'dark',
disabled: !can_write,
startIcon: faEdit
}
})
}
return buttons
}
function getMenuItems(flow: Flow | undefined) {
if (!flow || $userStore?.operator) return []
const menuItems: any = []
menuItems.push({
label: 'Share',
onclick: () => shareModal.openDrawer(flow?.path ?? '', 'flow'),
Icon: Share,
disabled: !can_write
})
menuItems.push({
label: 'Move/Rename',
onclick: () => moveDrawer.openDrawer(flow?.path ?? '', flow?.summary, 'flow'),
Icon: FolderOpen
})
menuItems.push({
label: 'Deploy to staging/prod',
onclick: () => deploymentDrawer.openDrawer(flow?.path ?? '', 'flow'),
Icon: Server
})
if (can_write) {
menuItems.push({
label: flow.archived ? 'Unarchive' : 'Archive',
onclick: () => flow?.path && archiveFlow(),
Icon: Archive,
color: 'red'
})
menuItems.push({
label: 'Delete',
onclick: () => flow?.path && deleteFlow(),
Icon: Trash,
color: 'red'
})
}
return menuItems
}
</script>
<ScheduleEditor on:update={() => loadSchedule()} bind:this={scheduleEditor} />
<UserSettings bind:this={userSettings} scopes={[`run:flow/${flow?.path}`]} />
<Skeleton
class="!max-w-6xl !px-4 sm:!px-6 md:!px-8"
loading={!flow}
layout={[0.75, [2, 0, 2], 2.25, [{ h: 1.5, w: 40 }], 0.2, [{ h: 1, w: 30 }]]}
/>
<ScheduleEditor on:update={() => loadSchedule()} bind:this={scheduleEditor} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ShareModal bind:this={shareModal} />
<MoveDrawer
bind:this={moveDrawer}
on:update={async (e) => {
@@ -173,261 +226,117 @@
}}
/>
<CenteredPage>
{#if flow}
<div class="prose-sm mx-auto mt-6">
<div
class="flex flex-row-reverse w-full flex-wrap md:flex-nowrap justify-between gap-x-2 gap-y-4"
>
<div class="flex flex-row-reverse gap-2 h-full">
<Button
href="/flows/run/{path}"
variant="contained"
color="blue"
size="md"
startIcon={{ icon: faPlay }}
>
Run
</Button>
{#if !$userStore?.operator}
<Button
href="/flows/edit/{path}?nodraft=true&args={encodeState(args)}"
variant="contained"
color="blue"
size="md"
startIcon={{ icon: faEdit }}
disabled={!can_write}
>
Edit
</Button>
<Button
href="/flows/add?template={flow.path}"
variant="border"
color="blue"
size="md"
startIcon={{ icon: faCodeFork }}
>
Fork
</Button>
{/if}
<Button
href="/runs/{flow.path}"
variant="border"
color="blue"
size="md"
startIcon={{ icon: faList }}
>
View runs
</Button>
</div>
<h1 class="mb-1 truncate grow">
{defaultIfEmptyString(flow.summary, flow.path)}
</h1>
</div>
{#if !emptyString(flow.summary)}
<span class="text-lg font-semibold">{flow.path}</span>
{/if}
</div>
{/if}
<ShareModal bind:this={shareModal} />
{#if flow}
<DetailPageLayout isOperator={$userStore?.operator}>
<svelte:fragment slot="header">
<DetailPageHeader
mainButtons={getMainButtons(flow)}
menuItems={getMenuItems(flow)}
title={defaultIfEmptyString(flow.summary, flow.path)}
/>
</svelte:fragment>
<svelte:fragment slot="form">
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane size={60} minSize={20}>
<div class="p-8 w-full max-w-3xl mx-auto gap-2">
<div class="flex flex-col gap-0.5">
{#if !emptyString(flow.summary)}
<span class="text-lg font-semibold">{flow.path}</span>
{/if}
<span class="text-sm text-gray-600">
Edited {displayDaysAgo(flow.edited_at ?? '')} by {flow.edited_by}
</span>
<div class="grid grid-cols-1 gap-6 max-w-7xl pb-6">
<Skeleton
loading={!flow}
layout={[[{ h: 1.5, w: 40 }], 1, [4], 2.25, [{ h: 1.5, w: 30 }], 1, [10]]}
/>
{#if flow}
<div>
<span class="text-sm text-gray-600">
Edited {displayDaysAgo(flow.edited_at ?? '')} by {flow.edited_by}
{#if flow.archived}
<div class="" />
<Alert type="error" title="Archived">This flow was archived</Alert>
{/if}
{#if schedule}
<a href="#primary-schedule" class="ml-2">
<Badge color="dark-blue">Primary schedule</Badge>
</a>{/if}</span
>
{#if flow.archived}
<div class="mt-2" />
<Alert type="error" title="Archived">This flow was archived</Alert>
{/if}
<div class="flex gap-2 flex-wrap mt-2">
<!-- <Button
target="_blank"
href={flowToHubUrl(flow).toString()}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faGlobe }}
>
Publish to Hub
</Button> -->
<Button
on:click={() => shareModal.openDrawer(flow?.path ?? '', 'flow')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faShare }}
disabled={!can_write}
>
Share
</Button>
<Button
on:click={() => scheduleEditor?.openNew(true, flow?.path ?? '')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faCalendar }}
>
New Schedule
</Button>
<Button
on:click={() => moveDrawer.openDrawer(flow?.path ?? '', flow?.summary, 'flow')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faEdit }}
>
Move/Rename
</Button>
<Button
on:click={() => deploymentDrawer.openDrawer(flow?.path ?? '', 'flow')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faFileExport }}
>
Deploy to staging/prod
</Button>
<Button
btnClasses="ml-2"
variant="border"
size="xs"
on:click={() => webhook.scrollIntoView()}>Webhook</Button
>
</div>
</div>
<div class="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div class="col-span-2 box">
<RunForm
{loading}
autofocus
detailed={false}
bind:isValid
runnable={flow}
runAction={runFlow}
bind:args
viewCliRun
isFlow
/>
</div>
{#if !emptyString(flow.description)}
<div class="box overflow-auto break-words whitespace-pre-wrap">
<Urlize text={defaultIfEmptyString(flow.description, 'No description')} />
</div>
{/if}
</div>
<div class="mt-4">
<FlowViewer {flow} noSummary={true} />
<h2 bind:this={webhook} class="mt-10 text-gray-700 pb-1 mb-3 border-b"
>Webhooks<Tooltip>
Pass the input as a json payload, the token as a Bearer token or as query arg
`?token=XXX` and pass as header: 'Content-Type: application/json'. The webhook also
support 'x-www-form-urlencoded' encoded payloads <a
href="https://www.windmill.dev/docs/core_concepts/webhooks">See docs</a
></Tooltip
></h2
>
<div class="box max-w-5xl">
<div class="flex w-full flex-justify-between mb-1">
<a
on:click={(e) => {
e.preventDefault()
copyToClipboard($page.url.protocol + '//' + urlAsync)
}}
href={$page.url.protocol + '//' + urlAsync}
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1 w-full"
>
{urlAsync}
<span class="text-gray-700 ml-2">
<Icon data={faClipboard} />
</span>
</a>
<Badge>UUID/Async</Badge>
</div>
<div class="mb-2 w-full flex flex-justify-between">
<a
on:click={(e) => {
e.preventDefault()
copyToClipboard($page.url.protocol + '//' + urlSync)
}}
href={$page.url.protocol + '//' + urlSync}
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1 w-full"
>
{urlSync}
<span class="text-gray-700 ml-2">
<Icon data={faClipboard} />
</span>
</a>
<Badge>Result/Sync</Badge>
</div>
<div class="flex flex-row-reverse">
<Button size="xs" on:click={userSettings.openDrawer}
>Create a Webhook-specific Token <Tooltip
>The token will have a scope such that it can only be used to trigger this flow. It
is safe to share as it cannot be used to impersonate you.</Tooltip
></Button
>
</div>
<div class="flex flex-col gap-2 mt-2">
<div class="flex">
<Button
color="light"
size="lg"
endIcon={{ icon: viewWebhookCommand ? faChevronUp : faChevronDown }}
on:click={() => (viewWebhookCommand = !viewWebhookCommand)}
>
CURL
</Button>
</div>
{#if viewWebhookCommand}
<div transition:slide|local class="px-4">
<Tabs selected="async">
<Tab value="async">UUID/Async</Tab>
<Tab value="sync">Result/Sync</Tab>
<svelte:fragment slot="content">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<pre class="bg-gray-700 text-gray-100 p-2 font-mono text-sm whitespace-pre-wrap"
><TabContent value="async"
>{curlCommand(true)} <span
on:click={() => copyToClipboard(curlCommand(true))}
class="cursor-pointer ml-2"><Icon data={faClipboard} /></span
><br /><br />//^ returns an UUID. Fetch result until completed == true<br
/>curl -H "Authorization: Bearer $TOKEN" {$page.url.protocol}//{$page.url
.hostname}/api/w/{$workspaceStore}/jobs_u/completed/get_result_maybe/$UUID</TabContent
><TabContent value="sync"
>{curlCommand(false)} <span
on:click={() => copyToClipboard(curlCommand(false))}
class="cursor-pointer ml-2"><Icon data={faClipboard} /></span
></TabContent
></pre
>
</svelte:fragment>
</Tabs>
{#if !emptyString(flow.description)}
<div class=" break-words whitespace-pre-wrap text-sm">
<Urlize text={defaultIfEmptyString(flow.description, 'No description')} />
</div>
{/if}
</div>
{/if}
</div>
</div>
{#if schedule}
<div class="mt-10">
<h2
id="primary-schedule"
class="text-gray-700 pb-1 mb-3 border-b inline-flex flex-row items-center gap-x-4"
><div>Primary Schedule </div>
<Badge color="gray">{schedule.schedule}</Badge>
<RunForm
{loading}
autofocus
detailed={false}
bind:isValid
runnable={flow}
runAction={runFlow}
bind:args
isFlow
bind:this={runForm}
/>
</div>
</Pane>
<Pane size={40} minSize={20}>
<FlowGraphViewer download {flow} overflowAuto noSide={true} />
</Pane>
</Splitpanes>
</SplitPanesWrapper>
</svelte:fragment>
<svelte:fragment slot="save_inputs">
<SavedInputs
flowPath={flow?.path}
{isValid}
args={args ?? {}}
on:selected_args={(e) => {
runForm?.setArgs(JSON.parse(JSON.stringify(e.detail)))
}}
/>
</svelte:fragment>
<svelte:fragment slot="details">
<div class="p-1">
<SchemaViewer schema={flow.schema} />
</div>
</svelte:fragment>
<svelte:fragment slot="webhooks">
<WebhooksPanel
scopes={[`run:flow/${flow?.path}`]}
webhooks={{
async: {
path: urlAsync
},
sync: {
path: urlSync
}
}}
isFlow={true}
{args}
/>
</svelte:fragment>
<svelte:fragment slot="schedule">
<div class="flex flex-row justify-end p-2">
<Button
on:click={() => scheduleEditor?.openNew(true, flow?.path ?? '')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faCalendar }}
>
New Schedule
</Button>
</div>
{#if schedule}
<div class="p-2 flex flex-col gap-2">
<div class="flex flex-row justify-between h-8">
<div class="flex flex-row gap-2">
<input
class="inline-block !w-32"
type="text"
id="cron-schedule"
name="cron-schedule"
placeholder="*/30 * * * *"
value={schedule.schedule}
disabled={true}
/>
<Badge color="indigo" small>Primary schedule</Badge>
</div>
<div class="flex flex-row gap-2">
<Toggle
checked={schedule.enabled}
on:change={(e) => {
@@ -437,51 +346,41 @@
sendUserToast('not enough permission', true)
}
}}
options={{
right: 'On'
}}
size="xs"
/>
<Button size="xs" on:click={() => scheduleEditor?.openEdit(flow?.path ?? '', true)}
>Edit schedule</Button
<Button size="xs" variant="border" color="light" href={`/runs/${flow?.path}`}>
<div class="flex flex-row gap-2">
<ListOrdered size={14} />
Runs
</div>
</Button>
<Button
size="xs"
color="dark"
on:click={() => scheduleEditor?.openEdit(flow?.path ?? '', true)}
>
</h2>
<div class="max-w-lg">
<PenBox size={14} />
</Button>
</div>
</div>
{#if Object.keys(schedule?.args ?? {}).length > 0}
<div class="">
<JobArgs args={schedule.args ?? {}} />
</div>
<div class="box max-w-5xl mt-2">
<CronInput
disabled={true}
schedule={schedule.schedule}
timezone={schedule.timezone}
/>
</div>
</div>
{/if}
{:else}
<div class="text-xs texg-gray-700"> This flow takes no argument </div>
{/if}
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="cli">
<div class="p-2">
<InlineCodeCopy content={cliCommand} />
<CliHelpBox />
</div>
<div>
{#if can_write}
<h2 class="mt-4">Danger zone</h2>
<div class="flex gap-2 mt-6">
<Button
on:click={() => flow?.path && archiveFlow()}
variant="border"
color="red"
size="md"
startIcon={{ icon: faArchive }}
disabled={!can_write}
>
{flow.archived ? 'Unarchive' : 'Archive'}
</Button>
<Button
on:click={() => flow?.path && deleteFlow()}
variant="border"
color="red"
size="md"
startIcon={{ icon: faTrash }}
disabled={!can_write}
>
Delete
</Button>
</div>
{/if}
</div>
{/if}
</div>
</CenteredPage>
</svelte:fragment>
</DetailPageLayout>
{/if}
@@ -2,71 +2,70 @@
import { page } from '$app/stores'
import { JobService, ScriptService, type Script } from '$lib/gen'
import {
truncateHash,
displayDaysAgo,
defaultIfEmptyString,
copyToClipboard,
emptyString,
encodeState,
canWrite
canWrite,
displayDaysAgo,
truncateHash
} from '$lib/utils'
import {
faPlay,
faEdit,
faArchive,
faList,
faTrash,
faCalendar,
faShare,
faGlobe,
faCodeFork,
faClipboard,
faArrowLeft,
faChevronUp,
faChevronDown,
faFileExport
} from '@fortawesome/free-solid-svg-icons'
import { faEdit, faCalendar, faCodeFork, faHistory } from '@fortawesome/free-solid-svg-icons'
import Tooltip from '$lib/components/Tooltip.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { onDestroy } from 'svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import { Badge, Tabs, Tab, TabContent, Button, Alert } from '$lib/components/common'
import {
Tabs,
Tab,
TabContent,
Button,
Badge,
Alert,
DrawerContent,
Drawer
} from '$lib/components/common'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import UserSettings from '$lib/components/UserSettings.svelte'
import Icon from 'svelte-awesome'
import RunForm from '$lib/components/RunForm.svelte'
import { goto } from '$app/navigation'
import Popover from '$lib/components/Popover.svelte'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import { Loader2 } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import {
DEFAULT_WEBHOOK_TYPE,
SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB,
SCRIPT_VIEW_SHOW_SCHEDULE,
SCRIPT_VIEW_SHOW_EXAMPLE_CURL,
SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON,
SCRIPT_VIEW_WEBHOOK_INFO_LINK,
SCRIPT_VIEW_WEBHOOK_INFO_TIP
} from '$lib/consts'
import { sendUserToast } from '$lib/toast'
import { scriptToHubUrl } from '$lib/hub'
import Urlize from '$lib/components/Urlize.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
let userSettings: UserSettings
import SavedInputs from '$lib/components/SavedInputs.svelte'
import WebhooksPanel from '$lib/components/details/WebhooksPanel.svelte'
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import InlineCodeCopy from '$lib/components/InlineCodeCopy.svelte'
import CliHelpBox from '$lib/components/CliHelpBox.svelte'
import {
Archive,
ArchiveRestore,
FolderOpen,
Globe2,
Loader2,
Server,
Share,
Trash
} from 'lucide-svelte'
import { SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB } from '$lib/consts'
import { scriptToHubUrl } from '$lib/hub'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
let script: Script | undefined
let topHash: string | undefined
let can_write = false
let deploymentInProgress = false
let intervalId: NodeJS.Timer
let shareModal: ShareModal
let runForm: RunForm
$: cliCommand = `wmill script run ${script?.path} -d '${JSON.stringify(args)}'`
$: loading = !script
$: if ($workspaceStore) {
@@ -186,20 +185,147 @@
}
}
let scheduleEditor: ScheduleEditor
let webhookElem: HTMLHeadingElement
let viewWebhookCommand = false
let args = undefined
function curlCommand(async: boolean) {
return `curl -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -X POST -d '${JSON.stringify(
args
)}' ${$page.url.protocol}//${$page.url.hostname}/api/w/${$workspaceStore}/jobs/run${
async ? '' : '_wait_result'
}/p/${script?.path}`
}
let moveDrawer: MoveDrawer
let deploymentDrawer: DeployWorkspaceDrawer
function getMainButtons(script: Script | undefined) {
if (!script || $userStore?.operator) return []
const buttons: any = []
if (Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0) {
buttons.push({
label: `History`,
buttonProps: {
onClick: () => {
versionsDrawerOpen = !versionsDrawerOpen
},
size: 'xs',
color: 'light',
startIcon: faHistory
}
})
}
if (!$userStore?.operator) {
if (!topHash) {
buttons.push({
label: 'Fork',
buttonProps: {
href: `/scripts/add?template=${script.path}`,
size: 'xs',
color: 'light',
startIcon: faCodeFork
}
})
}
buttons.push({
label: 'Edit',
buttonProps: {
href: `/scripts/edit/${script.path}?args=${encodeState(args)}${
topHash ? `&hash=${script.hash}&topHash=` + topHash : ''
}`,
disabled: !can_write,
size: 'xs',
startIcon: faEdit,
color: 'dark',
variant: 'contained'
}
})
}
return buttons
}
function getMenuItems(script: Script | undefined) {
if (!script || $userStore?.operator) return []
const menuItems: any = []
menuItems.push({
label: 'Move/Rename',
Icon: FolderOpen,
onclick: () => {
moveDrawer.openDrawer(script?.path ?? '', script?.summary, 'script')
}
})
menuItems.push({
label: 'Share',
Icon: Share,
onclick: () => {
shareModal.openDrawer(script?.path ?? '', 'script')
}
})
menuItems.push({
label: 'Deploy to staging/prod',
Icon: Server,
onclick: () => {
deploymentDrawer.openDrawer(script?.path ?? '', 'script')
}
})
if (SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB) {
menuItems.push({
label: 'Publish to Hub',
Icon: Globe2,
onclick: () => {
if (!script) return
goto(
scriptToHubUrl(
script.content,
script.summary,
script.description ?? '',
script.kind,
script.language,
script.schema,
script.language == 'deno' ? '' : script.lock
).toString()
)
}
})
}
if (script.archived) {
menuItems.push({
label: 'Unarchive',
Icon: ArchiveRestore,
onclick: async () => {
unarchiveScript(script.hash)
},
color: 'red'
})
} else {
menuItems.push({
label: 'Archive',
Icon: Archive,
onclick: async () => {
archiveScript(script.hash)
},
color: 'red'
})
}
menuItems.push({
label: 'Delete',
Icon: Trash,
onclick: async () => {
deleteScript(script.path)
},
color: 'red'
})
return menuItems
}
let versionsDrawerOpen = false
</script>
<MoveDrawer
@@ -209,237 +335,158 @@
loadScript($page.params.hash)
}}
/>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<ScheduleEditor bind:this={scheduleEditor} />
<ShareModal bind:this={shareModal} />
{#if script}
<CenteredPage>
<Skeleton {loading} layout={[[{ h: 1.5, w: 40 }], 1, [{ h: 1, w: 30 }]]} />
<Drawer bind:open={versionsDrawerOpen} size="1200px">
<DrawerContent title="Versions History" on:close={() => (versionsDrawerOpen = false)}>
<ScriptVersionHistory
scriptPath={script.path}
openDetails
on:openDetails={(e) => {
if (script) {
goto(`/scripts/get/${e.detail.version}?workspace=${$workspaceStore}`)
}
versionsDrawerOpen = false
}}
/>
</DrawerContent>
</Drawer>
<DetailPageLayout isOperator={$userStore?.operator}>
<svelte:fragment slot="header">
<DetailPageHeader
mainButtons={getMainButtons(script)}
menuItems={getMenuItems(script)}
title={defaultIfEmptyString(script.summary, script.path)}
/>
</svelte:fragment>
<svelte:fragment slot="form">
<div class="p-8 w-full max-w-3xl mx-auto">
<div class="flex flex-col gap-0.5 mb-8">
{#if script.lock_error_logs || topHash || script.archived || script.deleted}
<div class="flex flex-col gap-2 my-2">
{#if script.lock_error_logs}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p class="font-bold">Error deploying this script</p>
<p>
This script has not been deployed successfully because of the following errors:
</p>
<pre class="w-full text-xs mt-2 whitespace-pre-wrap">{script.lock_error_logs}</pre
>
</div>
{/if}
{#if topHash}
<div class="mt-2" />
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/get/{topHash}?workspace={$workspaceStore}"
>Go to the HEAD of this path</a
>
</Alert>
{/if}
{#if script.archived && !topHash}
<Alert type="error" title="Archived">This path was archived</Alert>
{/if}
{#if script.deleted}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4" role="alert">
<p class="font-bold">Deleted</p>
<p>The content of this script was deleted (by an admin, no less)</p>
</div>
{/if}
</div>
{/if}
<div class="prose-sm mx-auto mt-6">
<div
class="flex flex-row-reverse w-full flex-wrap md:flex-nowrap justify-between gap-x-2 gap-y-4"
>
<div class="flex flex-row-reverse gap-2 h-full">
<Button
href={`/scripts/run/${script.hash}`}
color="blue"
size="md"
startIcon={{ icon: faPlay }}
>
Run
</Button>
{#if !$userStore?.operator}
<Button
href={`/scripts/edit/${script.path}?args=${encodeState(args)}${
topHash ? `&hash=${script.hash}&topHash=` + topHash : ''
}`}
color="blue"
size="md"
startIcon={{ icon: faEdit }}
disabled={!can_write}
>
Edit
</Button>
{#if !topHash}
<Button
href={`/scripts/add?template=${script.path}`}
variant="border"
size="md"
startIcon={{ icon: faCodeFork }}
>
Fork
</Button>
{#if !emptyString(script.summary)}
<span class="text-lg font-semibold">{script.path}</span>
{/if}
<div class="flex flex-row gap-x-2 flex-wrap items-center mt-2">
<span class="text-sm text-gray-600">
Edited {displayDaysAgo(script.created_at || '')} by {script.created_by || 'unknown'}
</span>
<Badge color="dark-gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{/if}
<Button
href={`/runs/${script.path}`}
size="md"
startIcon={{ icon: faList }}
color="light"
variant="border"
>
View Runs
</Button>
</div>
<div class="grow truncate">
<h1 class="mb-1 truncate">{defaultIfEmptyString(script.summary, script.path)}</h1>
</div>
</div>
{#if !emptyString(script.summary)}
<span class="text-lg font-semibold">{script.path}</span>
{/if}
<div class="flex flex-row gap-x-2 flex-wrap items-center mt-2">
<span class="text-sm text-gray-600">
Edited {displayDaysAgo(script.created_at || '')} by {script.created_by || 'unknown'}
</span>
<Badge color="dark-gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
{#if deploymentInProgress}
<Badge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
</Badge>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
</div>
<div class="flex gap-2 flex-wrap mt-4">
{#if SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB}
<Button
disabled={deploymentInProgress}
target="_blank"
href={scriptToHubUrl(
script.content,
script.summary,
script.description ?? '',
script.kind,
script.language,
script.schema,
script.language == 'deno' ? '' : script.lock
).toString()}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faGlobe }}
>
Publish to Hub
</Button>
{/if}
<Button
on:click={() => shareModal.openDrawer(script?.path ?? '', 'script')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faShare }}
disabled={!can_write}
>
Share
</Button>
{#if SCRIPT_VIEW_SHOW_SCHEDULE}
<Button
on:click={() => scheduleEditor?.openNew(false, script?.path ?? '')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faCalendar }}
>
New Schedule
</Button>
{/if}
<Button
on:click={() => moveDrawer.openDrawer(script?.path ?? '', script?.summary, 'script')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faEdit }}
>
Move/Rename
</Button>
<Button
on:click={() => deploymentDrawer.openDrawer(script?.path ?? '', 'script')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faFileExport }}
>
Deploy to staging/prod
</Button>
<Button
color="dark"
variant="border"
size="xs"
on:click={() => webhookElem.scrollIntoView()}>Webhooks</Button
>
{#if Array.isArray(script.parent_hashes) && script.parent_hashes.length > 0}
<Button
color="dark"
variant="contained"
size="xs"
startIcon={{ icon: faArrowLeft }}
href="/scripts/get/{script.parent_hashes[0]}?workspace={$workspaceStore}"
dropdownItems={script.parent_hashes.map((hash) => ({
href: `/scripts/get/${hash}?workspace=${$workspaceStore}`,
label: hash
}))}
>
Previous version ({script.parent_hashes.length})
</Button>
{/if}
</div>
{#if script.lock_error_logs || topHash || script.archived || script.deleted}
<div class="flex flex-col gap-2 my-2">
{#if script.lock_error_logs}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p class="font-bold">Error deploying this script</p>
<p>This script has not been deployed successfully because of the following errors:</p>
<pre class="w-full text-xs mt-2 whitespace-pre-wrap">{script.lock_error_logs}</pre>
</div>
{/if}
{#if topHash}
<div class="mt-2" />
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/get/{topHash}?workspace={$workspaceStore}"
>Go to the HEAD of this path</a
>
</Alert>
{/if}
{#if script.archived && !topHash}
<Alert type="error" title="Archived">This path was archived</Alert>
{/if}
{#if script.deleted}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4" role="alert">
<p class="font-bold">Deleted</p>
<p>The content of this script was deleted (by an admin, no less)</p>
</div>
{/if}
</div>
{/if}
<div class="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div class="col-span-2 box">
<RunForm
loading={runLoading}
autofocus
detailed={false}
bind:isValid
runnable={script}
runAction={runScript}
bind:args
viewCliRun
isFlow={false}
/>
</div>
{#if !emptyString(script.description)}
<div class="box overflow-auto break-words whitespace-pre-wrap">
<Urlize text={defaultIfEmptyString(script.description, 'No description')} />
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
{#if deploymentInProgress}
<Badge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
</Badge>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
</div>
{/if}
</div>
<div class="mt-8">
{#if !emptyString(script.description)}
<div class="border p-2">
<Urlize text={defaultIfEmptyString(script.description, 'No description')} />
</div>
{/if}
</div>
<RunForm
loading={runLoading}
autofocus
detailed={false}
bind:isValid
runnable={script}
runAction={runScript}
bind:args
schedulable={true}
isFlow={false}
bind:this={runForm}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="save_inputs">
{#if args}
<SavedInputs
scriptPath={script?.path}
scriptHash={topHash}
{isValid}
{args}
on:selected_args={(e) => {
runForm?.setArgs(JSON.parse(JSON.stringify(e.detail)))
}}
/>
{/if}
</svelte:fragment>
<svelte:fragment slot="webhooks">
<WebhooksPanel scopes={[`run:script/${script?.path}`]} {webhooks} {args} />
</svelte:fragment>
<svelte:fragment slot="schedule">
<div class="p-2 flex flex-col">
<Button
on:click={() => scheduleEditor?.openNew(false, script?.path ?? '')}
variant="border"
color="light"
size="xs"
startIcon={{ icon: faCalendar }}
>
New Schedule
</Button>
</div>
</svelte:fragment>
<svelte:fragment slot="details">
<div>
<Skeleton {loading} layout={[[20]]} />
<Tabs selected="code">
<Tab value="code">Code</Tab>
<Tab value="dependencies">Dependencies lock file</Tab>
<Tab value="arguments"
><span class="inline-flex items-center gap-1">
Arguments JSON Schema
<Tab value="code" size="xs">Code</Tab>
<Tab value="dependencies" size="xs">Lock file</Tab>
<Tab value="arguments" size="xs">
<span class="inline-flex items-center gap-1">
Inputs
<Tooltip>
The jsonschema defines the constraints that the payload must respect to be
compatible with the input parameters of this script. The UI form is generated
@@ -452,21 +499,25 @@
</Tab>
<svelte:fragment slot="content">
<TabContent value="code">
<div class="border rounded-sm mt-2">
<HighlightCode language={script.language} code={script.content} />
<div class="p-2">
<HighlightCode
language={script.language}
code={script.content}
class="whitespace-pre"
/>
</div>
</TabContent>
<TabContent value="dependencies">
<div class="border rounded-sm mt-2">
<div class="">
{#if script?.lock}
<pre>{script.lock}</pre>
<pre class="bg-gray-50 text-sm p-2">{script.lock}</pre>
{:else}
<p>There is no lock file for this script</p>
<p class="bg-gray-50 text-sm p-2"> There is no lock file for this script</p>
{/if}
</div>
</TabContent>
<TabContent value="arguments">
<div class="max-w-2xl">
<div class="p-2">
<SchemaViewer schema={script.schema} />
</div>
</TabContent>
@@ -482,155 +533,12 @@
{/each}
</ul>
{/if}
<div class="max-w-2xl mt-12">
<h3 class="mb-4" bind:this={webhookElem} id="webhooks">
Webhooks
<Tooltip>
{SCRIPT_VIEW_WEBHOOK_INFO_TIP}
<a href={SCRIPT_VIEW_WEBHOOK_INFO_LINK} class="text-blue-500"> See docs </a>
</Tooltip>
</h3>
<Skeleton {loading} layout={[[8.5]]} />
<Tabs selected={DEFAULT_WEBHOOK_TYPE}>
<Tab value="async">UUID/Async</Tab>
<Tab value="sync">Result/Sync</Tab>
<svelte:fragment slot="content">
{#each Object.keys(webhooks) as key}
<TabContent value={key}>
<ul>
{#each Object.keys(webhooks[key]) as type}
{@const url = webhooks[key][type]}
{@const href = $page.url.protocol + '//' + url}
<li class="flex justify-between items-center mt-2">
<a
on:click={(e) => {
e.preventDefault()
copyToClipboard(href)
}}
{href}
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1"
>
{url}
<span class="text-gray-700 ml-2">
<Icon data={faClipboard} />
</span>
</a>
{#if type == 'get_path'}
<div class="flex flex-row gap-1">
<Badge>GET</Badge>
<Tooltip
>This webhook unlike the others which are all POST takes in a GET
request. The payload must be passed as the query arg `payload` and
encoded in JSON first, then in an URL safe base64. e.g:
`encodeURIComponent(btoa(JSON.stringify({'{a: 2}'})))` `
</Tooltip>
</div>
{:else}
<div class="flex flex-row gap-1">
<Badge>POST</Badge>
<Badge color="dark-gray" capitalize>
{type}
</Badge>
</div>
{/if}
</li>
{/each}
</ul>
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<div class="flex flex-row-reverse mt-2">
<Button size="xs" on:click={userSettings.openDrawer}
>Create a Webhook-specific Token <Tooltip
>The token will have a scope such that it can only be used to trigger this
script. It is safe to share as it cannot be used to impersonate you.</Tooltip
></Button
>
</div>
{/if}
{#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL}
<div class="flex">
<Button
color="light"
size="lg"
endIcon={{ icon: viewWebhookCommand ? faChevronUp : faChevronDown }}
on:click={() => (viewWebhookCommand = !viewWebhookCommand)}
>
CURL
</Button>
</div>
{/if}
{#if viewWebhookCommand}
{@const command = curlCommand(key == 'async')}
<div transition:slide|local class="px-4">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<pre class="bg-gray-700 text-gray-100 p-2 font-mono text-sm whitespace-pre-wrap"
>{command} <span
on:click={() => copyToClipboard(command)}
class="cursor-pointer ml-2"><Icon data={faClipboard} /></span
>{#if key == 'async'}<br /><br
/>//^ returns an UUID. Fetch until completed == true<br
/>curl -H "Authorization: Bearer $TOKEN" {$page.url.protocol}//{$page.url
.hostname}/api/w/{$workspaceStore}/jobs_u/completed/get_result_maybe/$UUID{/if}</pre
>
</div>
{/if}
</TabContent>
{/each}
</svelte:fragment>
</Tabs>
</svelte:fragment>
<svelte:fragment slot="cli">
<div class="p-2 flex flex-col gap-4">
<InlineCodeCopy content={cliCommand} />
<CliHelpBox />
</div>
<div class="mt-32">
{#if can_write}
<h3>Danger zone</h3>
<div class="flex gap-2">
<Popover>
<Button
size="xs"
on:click={() => {
script?.hash && deleteScript(script.hash)
}}
color="red"
variant="contained"
startIcon={{ icon: faTrash }}
disabled={!($superadmin || ($userStore?.is_admin ?? false))}
>
Delete
</Button>
<span slot="text">require to be admin</span>
</Popover>
{#if script.archived}
<Button
size="xs"
on:click={() => {
script?.hash && unarchiveScript(script.hash)
}}
color="red"
variant="border"
startIcon={{ icon: faArchive }}
>
Unarchive
</Button>
{:else}
<Button
size="xs"
on:click={() => {
script?.hash && archiveScript(script.hash)
}}
color="red"
variant="border"
startIcon={{ icon: faArchive }}
>
Archive
</Button>
{/if}
</div>
{/if}
</div>
</div>
</CenteredPage>
</svelte:fragment>
</DetailPageLayout>
{/if}
<UserSettings bind:this={userSettings} scopes={[`run:script/${script?.path}`]} />
<ShareModal bind:this={shareModal} />