Files
windmill/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte
T
Ádám Kovács 693608955f fix(frontend): General fixes and updates (#1281)
* fix(frontend): App icon select double click issue

* fix(frontend): Update script metadata page

* fix(frontend): Set dropdown default icon to vertical dots

* fix(frontend): Clean up

* fix(frontend): Update table styles

* fix(frontend): Add spacing to secondary menu items

* fix(frontend): Scale down full path

* fix(frontend): Table loading state

* fix(frontend): Hide script kind setting by default
2023-03-13 14:17:57 +01:00

644 lines
18 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import {
Alert,
Badge,
ButtonPopup,
ButtonPopupItem,
Drawer,
DrawerContent,
UndoRedo
} from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import { dirtyStore } from '$lib/components/common/confirmationModal/dirtyStore'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import ToggleButton from '$lib/components/common/toggleButton/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import Dropdown from '$lib/components/Dropdown.svelte'
import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte'
import { idMutex } from '$lib/components/flows/flowStateUtils'
import FlowStatusViewer from '$lib/components/FlowStatusViewer.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import Path from '$lib/components/Path.svelte'
import TestJobLoader from '$lib/components/TestJobLoader.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { AppService, Job, Policy } from '$lib/gen'
import { redo, undo } from '$lib/history'
import { workspaceStore } from '$lib/stores'
import {
faBug,
faClipboard,
faExternalLink,
faFileExport,
faGlobe,
faSave
} from '@fortawesome/free-solid-svg-icons'
import {
AlignHorizontalSpaceAround,
Expand,
Eye,
Laptop2,
Loader2,
MoreVertical,
Pencil,
Redo,
SlidersHorizontal,
Smartphone,
Undo,
X
} from 'lucide-svelte'
import { getContext } from 'svelte'
import { Icon } from 'svelte-awesome'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { appToHubUrl, classNames, copyToClipboard, sendUserToast } from '../../../utils'
import type {
AppInput,
ConnectedAppInput,
RowAppInput,
StaticAppInput,
UserAppInput
} from '../inputType'
import type { AppEditorContext, AppViewerContext } from '../types'
import { allItems, toStatic } from '../utils'
import AppExportButton from './AppExportButton.svelte'
import AppInputs from './AppInputs.svelte'
import type { AppComponent } from './component/components'
import PanelSection from './settingsPanel/common/PanelSection.svelte'
async function hash(message) {
const msgUint8 = new TextEncoder().encode(message) // encode as (utf-8) Uint8Array
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) // hash the message
const hashArray = Array.from(new Uint8Array(hashBuffer)) // convert buffer to byte array
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
return hashHex
}
export let policy: Policy
export let fromHub: boolean = false
const {
app,
summary,
mode,
breakpoint,
appPath,
jobs,
staticExporter,
errorByComponent,
openDebugRun,
focusedGrid,
selectedComponent
} = getContext<AppViewerContext>('AppViewerContext')
const { history } = getContext<AppEditorContext>('AppEditorContext')
const loading = {
publish: false,
save: false
}
$: if ($openDebugRun == undefined) {
$openDebugRun = (componentId: string) => {
jobsDrawerOpen = true
const job = $jobs.find((job) => job.component === componentId)
if (job) {
selectedJobId = job.job
}
}
}
let newPath: string = ''
let pathError: string | undefined = undefined
let appExport: AppExportButton
let saveDrawerOpen = false
let jobsDrawerOpen = false
let publishDrawerOpen = false
let inputsDrawerOpen = fromHub
function closeSaveDrawer() {
saveDrawerOpen = false
}
function collectStaticFields(
fields: Record<string, StaticAppInput | ConnectedAppInput | RowAppInput | UserAppInput>
) {
return Object.fromEntries(
Object.entries(fields ?? {})
.filter(([k, v]) => v.type == 'static')
.map(([k, v]) => {
return [k, v['value']]
})
)
}
async function computeTriggerables() {
const allTriggers = await Promise.all(
allItems($app.grid, $app.subgrids)
.flatMap((x) => {
let c = x.data as AppComponent
let r: (AppInput | undefined)[] = [c.componentInput]
if (c.type === 'tablecomponent') {
r.push(...c.actionButtons.map((x) => x.componentInput))
}
return r.filter((x) => x)
})
.map(async (input) => {
if (input?.type == 'runnable') {
const staticInputs = collectStaticFields(input.fields)
if (input.runnable?.type == 'runnableByName') {
let hex = await hash(input.runnable.inlineScript?.content)
return [`rawscript/${hex}`, staticInputs]
} else if (input.runnable?.type == 'runnableByPath') {
let prefix =
input.runnable.runType !== 'hubscript' ? input.runnable.runType : 'script'
return [`${prefix}/${input.runnable.path}`, staticInputs]
}
}
return []
})
.concat(
Object.values($app.hiddenInlineScripts ?? {}).map(async (v) => {
let hex = await hash(v.inlineScript?.content)
const staticInputs = collectStaticFields(v.fields)
return [`rawscript/${hex}`, staticInputs]
})
)
)
policy.triggerables = Object.fromEntries(allTriggers.filter((x) => x.length > 0))
}
async function createApp(path: string) {
await computeTriggerables()
try {
const appId = await AppService.createApp({
workspace: $workspaceStore!,
requestBody: {
value: $app,
path,
summary: $summary,
policy
}
})
closeSaveDrawer()
goto(`/apps/edit/${appId}`)
} catch (e) {
sendUserToast('Error creating app', e)
}
}
let secretUrl: string | undefined = undefined
$: secretUrl == undefined && policy.execution_mode == 'anonymous' && getSecretUrl()
async function getSecretUrl() {
secretUrl = await AppService.getPublicSecretOfApp({
workspace: $workspaceStore!,
path: appPath
})
}
async function setPublishState() {
await AppService.updateApp({
workspace: $workspaceStore!,
path: appPath,
requestBody: { policy }
})
if (policy.execution_mode == 'anonymous') {
sendUserToast('App made visible publicly at the secret URL.')
} else {
sendUserToast('App made unaccessible publicly')
}
}
async function save() {
$dirtyStore = false
if ($page.params.path == undefined) {
saveDrawerOpen = true
return
}
loading.save = true
try {
await computeTriggerables()
await AppService.updateApp({
workspace: $workspaceStore!,
path: $page.params.path,
requestBody: {
value: $app!,
summary: $summary,
policy
}
})
sendUserToast('App saved')
loading.save = false
} catch (e) {
loading.save = false
throw e
}
}
let selectedJobId: string | undefined = undefined
let testJobLoader: TestJobLoader
let job: Job | undefined = undefined
let testIsLoading = false
$: selectedJobId && testJobLoader?.watchJob(selectedJobId)
$: hasErrors = Object.keys($errorByComponent).length > 0
let lock = false
function onKeyDown(event: KeyboardEvent) {
if (lock) return
lock = true
switch (event.key) {
case 'Z':
if (event.ctrlKey) {
$app = redo(history)
event.preventDefault()
}
break
case 'z':
if (event.ctrlKey) {
$app = undo(history, $app)
event.preventDefault()
}
break
case 's':
if (event.ctrlKey) {
save()
event.preventDefault()
}
break
// case 'ArrowDown': {
// let ids = generateIds()
// let idx = ids.indexOf($selectedIdStore)
// if (idx > -1 && idx < ids.length - 1) {
// $selectedIdStore = ids[idx + 1]
// event.preventDefault()
// }
// break
// }
// case 'ArrowUp': {
// let ids = generateIds()
// let idx = ids.indexOf($selectedIdStore)
// if (idx > 0 && idx < ids.length) {
// $selectedIdStore = ids[idx - 1]
// event.preventDefault()
// }
// break
// }
}
lock = false
}
</script>
<svelte:window on:keydown={onKeyDown} />
<TestJobLoader bind:this={testJobLoader} bind:isLoading={testIsLoading} bind:job />
<Drawer bind:open={saveDrawerOpen} size="800px">
<DrawerContent title="Create an App" on:close={() => closeSaveDrawer()}>
<Path
bind:error={pathError}
bind:path={newPath}
initialPath=""
namePlaceholder="app"
kind="app"
/>
<div slot="actions">
<Button
startIcon={{ icon: faSave }}
disabled={pathError != ''}
on:click={() => createApp(newPath)}>Create app</Button
>
</div>
</DrawerContent>
</Drawer>
<Drawer bind:open={inputsDrawerOpen} size="600px">
<DrawerContent title="App inputs configuration" on:close={() => (inputsDrawerOpen = false)}>
<AppInputs />
</DrawerContent>
</Drawer>
<Drawer bind:open={jobsDrawerOpen} size="900px">
<DrawerContent noPadding title="Debug Runs" on:close={() => (jobsDrawerOpen = false)}>
<Splitpanes class="!overflow-visible">
<Pane size={25}>
<PanelSection title="Past Runs" smallPadding>
<div class="flex flex-col gap-2 w-full">
{#if $jobs.length > 0}
<div class="flex gap-2 flex-col ">
{#each $jobs ?? [] as { job, component } (job)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
'border flex gap-1 truncate justify-between flex-row w-full items-center p-2 rounded-md cursor-pointer hover:bg-blue-50 hover:text-blue-400',
$errorByComponent[job] ? 'border border-red-500 bg-red-100' : '',
selectedJobId == job && !$errorByComponent[component]
? 'bg-blue-100 text-blue-600'
: ''
)}
on:click={() => (selectedJobId = job)}
>
<span class="text-xs truncate">{job}</span>
<Badge color="dark-indigo">{component}</Badge>
</div>
{/each}
</div>
{:else}
<div class="text-sm text-gray-500">No items</div>
{/if}
</div>
</PanelSection>
</Pane>
<Pane size={75}>
<div class="h-full w-full overflow-auto">
{#if selectedJobId}
{#if !job}
<Skeleton layout={[[40]]} />
{:else}
<div class="flex flex-col h-full w-full gap-4 mb-4">
{#if job?.['running']}
<div class="flex flex-row-reverse w-full">
<Button
color="red"
variant="border"
on:click={() => testJobLoader?.cancelJob()}
>
Cancel
</Button>
</div>
{/if}
<div class="p-2">
<JobArgs args={job?.args} />
</div>
{#if job?.job_kind !== 'flow' && job?.job_kind !== 'flowpreview'}
<Splitpanes horizontal class="grow border w-full">
<Pane size={50} minSize={10}>
<LogViewer content={job?.logs} isLoading={testIsLoading} />
</Pane>
<Pane size={50} minSize={10} class="text-sm text-gray-600">
{#if job != undefined && 'result' in job && job.result != undefined}
<pre class="overflow-x-auto break-words relative h-full px-2"
><DisplayResult result={job.result} /></pre
>
{:else if testIsLoading}
<div class="p-2"><Loader2 class="animate-spin" /> </div>
{/if}
</Pane>
</Splitpanes>
{:else}
<div class="mt-10" />
<FlowProgressBar {job} class="py-4" />
<div class="w-full mt-10 mb-20">
<FlowStatusViewer
jobId={job.id}
on:jobsLoaded={({ detail }) => {
job = detail
}}
/>
</div>
{/if}
</div>
{/if}
{:else}
<div class="text-sm p-2 text-gray-500">Select a job to see its details</div>
{/if}
</div>
</Pane>
</Splitpanes>
</DrawerContent>
</Drawer>
<Drawer bind:open={publishDrawerOpen} size="800px">
<DrawerContent title="Publish an App" on:close={() => (publishDrawerOpen = false)}>
{#if appPath == ''}
<Alert title="Require saving" type="error">
Save this app once before you can publish it
</Alert>
{:else}
<Alert title="App executed on behalf of publisher">
A viewer of the app will execute the runnables of the app on behalf of the publisher
avoiding the risk that a resource or script would not be available to the viewer. To
guarantee tight security, a policy is computed at time of saving of the app which only allow
the scripts/flows referred to in the app to be called on behalf of. Furthermore, static
parameters are not overridable. Hence, users will only be able to use the app as intended by
the publisher without risk for leaking resources not used in the app.
</Alert>
<div class="mt-4" />
<Toggle
options={{
left: `Require read-access`,
right: `Publish publicly for anyone knowing the secret url`
}}
checked={policy.execution_mode == 'anonymous'}
on:change={(e) => {
policy.execution_mode = e.detail
? Policy.execution_mode.ANONYMOUS
: Policy.execution_mode.PUBLISHER
setPublishState()
}}
/>
{#if policy.execution_mode == 'anonymous' && secretUrl}
{@const url = `${$page.url.hostname}/public/${$workspaceStore}/${secretUrl}`}
{@const href = $page.url.protocol + '//' + url}
<div class="my-6 box">
Public url:
<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>
</div>
<Alert type="info" title="Only show latest saved app">
Once made public, you will still need to save the app to make visible the latest changes
</Alert>
{/if}
{/if}
<div />
</DrawerContent>
</Drawer>
<div
class="border-b flex flex-row justify-between py-1 gap-4 gap-y-2 px-4 items-center overflow-y-visible"
>
<div class="min-w-64 w-64">
<input type="text" placeholder="App summary" class="text-sm w-full" bind:value={$summary} />
</div>
<UndoRedo
undoProps={{ disabled: $history?.index === 0 }}
redoProps={{ disabled: $history && $history?.index === $history.history.length - 1 }}
on:undo={() => {
$app = undo(history, $app)
}}
on:redo={() => {
$app = redo(history)
}}
/>
<div class="flex gap-4 items-center grow justify-center">
<div>
<ToggleButtonGroup bind:selected={$mode}>
<ToggleButton position="left" value="dnd" size="xs">
<div class="inline-flex gap-1 items-center">
<Pencil size={14} />
<span class="hidden lg:inline">Editor</span>
</div>
</ToggleButton>
<ToggleButton position="right" value="preview" size="xs">
<div class="inline-flex gap-1 items-center">
<Eye size={14} /> <span class="hidden lg:inline">Preview</span>
</div>
</ToggleButton>
</ToggleButtonGroup>
</div>
<div>
<ToggleButtonGroup bind:selected={$breakpoint}>
<ToggleButton position="left" value="sm" size="xs">
<Smartphone size={14} />
</ToggleButton>
<ToggleButton position="right" value="lg" size="xs">
<Laptop2 size={14} />
</ToggleButton>
</ToggleButtonGroup>
</div>
<div class="hidden lg:block">
{#if $app}
<ToggleButtonGroup bind:selected={$app.fullscreen}>
<ToggleButton position="left" value={false} size="xs">
<div class="flex gap-1 justify-start items-center">
<AlignHorizontalSpaceAround size={14} />
<Tooltip light class="mb-0.5">
The max width is 1168px and the content stay centered instead of taking the full
page width
</Tooltip>
</div>
</ToggleButton>
<ToggleButton position="right" value={true} size="xs">
<Expand size={14} />
</ToggleButton>
</ToggleButtonGroup>
{/if}
</div>
</div>
{#if $focusedGrid !== undefined}
<div class="hidden lg:block">
<Badge color="indigo">
<div class="flex flex-row gap-2 justify-center items-center">
<div>{`Sub grid: ${$focusedGrid.parentComponentId} (${$focusedGrid.subGridIndex})`}</div>
<button
on:click={() => {
$selectedComponent = undefined
$focusedGrid = undefined
}}
>
<X size={14} />
</button>
</div>
</Badge>
</div>
{/if}
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
<Dropdown
placement="bottom-end"
btnClasses="hidden lg:block"
dropdownItems={[
{
displayName: 'JSON',
icon: faFileExport,
action: () => {
appExport.open($app)
}
},
{
displayName: 'Publish to Hub',
icon: faGlobe,
action: () => {
const url = appToHubUrl(toStatic($app, $staticExporter, $summary))
window.open(url.toString(), '_blank')
}
},
{
displayName: 'Hub compatible JSON',
icon: faFileExport,
action: () => {
appExport.open(toStatic($app, $staticExporter, $summary).app)
}
}
]}
/>
<span class="hidden md:inline">
<Button on:click={() => (inputsDrawerOpen = true)} color="light" size="xs" variant="border">
<span class="flex gap-2">
<SlidersHorizontal size={14} />
App inputs
</span>
</Button>
</span>
<span class="hidden md:inline">
<Button
on:click={() => (jobsDrawerOpen = true)}
color={hasErrors ? 'red' : 'light'}
size="xs"
variant="border"
startIcon={{ icon: faBug }}
>
<span class="hidden xl:inline">Debug Runs</span>
</Button>
</span>
<AppExportButton bind:this={appExport} />
<Button
on:click={() => (publishDrawerOpen = true)}
color="light"
size="xs"
variant="border"
startIcon={{ icon: faExternalLink }}
>
<span class="hidden xl:inline">Publish</span>
</Button>
{#if appPath == ''}
<Button
loading={loading.save}
startIcon={{ icon: faSave }}
on:click={save}
color="dark"
size="xs"
>
<span class="hidden md:inline">Save</span>
</Button>
{:else}
<ButtonPopup
loading={loading.save}
startIcon={{ icon: faSave }}
on:click={save}
color="dark"
size="xs"
>
<svelte:fragment slot="main">Save</svelte:fragment>
<ButtonPopupItem
on:click={() => {
window.open(`/apps/add?template=${appPath}`)
}}>Fork</ButtonPopupItem
>
</ButtonPopup>
{/if}
</div>
</div>