Files
windmill/frontend/src/lib/components/ScriptEditor.svelte
T
2022-07-23 18:28:41 +02:00

512 lines
14 KiB
Svelte

<script lang="ts">
import { JobService, Job, CompletedJob, VariableService, ScriptService } from '$lib/gen'
import { emptySchema, displayDate } from '$lib/utils'
import type { Schema } from '$lib/common'
import { fade } from 'svelte/transition'
import Icon from 'svelte-awesome'
import {
faCheck,
faChevronDown,
faChevronUp,
faExclamationTriangle,
faMagic,
faSpinner,
faTimes
} from '@fortawesome/free-solid-svg-icons'
import Editor from './Editor.svelte'
import Tooltip from './Tooltip.svelte'
import { onDestroy, onMount } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import TableCustom from './TableCustom.svelte'
import { check } from 'svelte-awesome/icons'
import Modal from './Modal.svelte'
import { inferArgs } from '$lib/infer'
// @ts-ignore
import { VSplitPane } from 'svelte-split-pane'
import SchemaForm from './SchemaForm.svelte'
import DisplayResult from './DisplayResult.svelte'
import type { Preview } from '$lib/gen/models/Preview'
import EditorBar from './EditorBar.svelte'
import { Highlight } from 'svelte-highlight'
import { json, python, typescript } from 'svelte-highlight/languages'
// Exported
export let schema: Schema = emptySchema()
export let code: string
export let path: string | undefined
export let lang: Preview.language
// Control Editor layout
export let viewPreview = true
export let previewTab: 'logs' | 'input' | 'output' | 'history' | 'last_save' = 'logs'
let websocketAlive = { pyright: false, black: false, deno: false }
let modalViewerTitle: string = ''
let modalViewerContent: any
let modalViewerMode: 'logs' | 'result' | 'code' = 'logs'
// Internal state
let editor: Editor
// Preview args input
let args: Record<string, any> = {}
let isValid: boolean = true
// Preview
let previewIsLoading = false
let previewIntervalId: NodeJS.Timer
let previewJob: Job | undefined
let pastPreviews: CompletedJob[] = []
let modalViewer: Modal
let syncIteration: number = 0
let ITERATIONS_BEFORE_SLOW_REFRESH = 100
let lastSave: string | null
$: lastSave = localStorage.getItem(path ?? 'last_save')
export function getEditor(): Editor {
return editor
}
export async function runPreview(): Promise<void> {
try {
if (previewIntervalId) {
clearInterval(previewIntervalId)
}
if (previewIsLoading && previewJob) {
JobService.cancelQueuedJob({
workspace: $workspaceStore!,
id: previewJob.id,
requestBody: {}
})
}
previewIsLoading = true
const previewId = await JobService.runScriptPreview({
workspace: $workspaceStore!,
requestBody: {
path,
content: editor.getCode(),
args: args,
language: lang
}
})
previewJob = undefined
loadPreviewJob(previewId)
syncIteration = 0
previewIntervalId = setInterval(() => {
syncer(previewId)
}, 500)
//TODO fetch preview, every x time, until it's completed
} catch (err) {
previewIsLoading = false
throw err
}
}
async function loadPastPreviews(): Promise<void> {
pastPreviews = await JobService.listCompletedJobs({
workspace: $workspaceStore!,
jobKinds: 'preview',
createdBy: $userStore?.username,
scriptPathExact: path
})
}
async function loadPreviewJob(id: string): Promise<void> {
try {
if (previewJob && `running` in previewJob) {
let previewJobUpdates = await JobService.getJobUpdates({
workspace: $workspaceStore!,
id,
running: previewJob.running,
logOffset: previewJob.logs?.length ?? 0
})
if (previewJobUpdates.new_logs) {
previewJob.logs = (previewJob.logs ?? '').concat(previewJobUpdates.new_logs)
}
if ((previewJobUpdates.running ?? false) || (previewJobUpdates.completed ?? false)) {
previewJob = await JobService.getJob({ workspace: $workspaceStore!, id })
}
} else {
previewJob = await JobService.getJob({ workspace: $workspaceStore!, id })
}
if (previewJob?.type === 'CompletedJob') {
//only CompletedJob has success property
clearInterval(previewIntervalId)
previewIsLoading = false
loadPastPreviews()
}
} catch (err) {
console.error(err)
}
}
async function inferSchema() {
let isDefault: string[] = []
Object.entries(args).forEach(([k, v]) => {
if (schema.properties[k].default == v) {
isDefault.push(k)
}
})
await inferArgs(lang, editor.getCode(), schema)
schema = schema
isDefault.forEach((key) => (args[key] = schema.properties[key].default))
for (const key of Object.keys(args)) {
if (schema.properties[key] == undefined) {
delete args[key]
}
}
}
function syncer(id: string): void {
if (syncIteration > ITERATIONS_BEFORE_SLOW_REFRESH) {
loadPreviewJob(id)
if (previewIntervalId) {
clearInterval(previewIntervalId)
previewIntervalId = setInterval(() => loadPreviewJob(id), 5000)
}
} else {
syncIteration++
loadPreviewJob(id)
}
}
let syncCode: NodeJS.Timer
onMount(() => {
syncCode = setInterval(() => {
const newCode = editor?.getCode()
if (newCode && code != newCode) {
code = editor.getCode()
}
}, 3000)
})
onDestroy(() => {
if (editor) {
code = editor.getCode()
}
if (previewIntervalId) {
clearInterval(previewIntervalId)
}
if (syncCode) {
clearInterval(syncCode)
}
})
</script>
<Modal bind:this={modalViewer}>
<div slot="title">{modalViewerTitle}</div>
<div slot="content">
{#if modalViewerMode === 'result'}
<Highlight language={json} code={JSON.stringify(modalViewerContent, null, 4)} />
{:else if modalViewerMode === 'logs'}
<pre class="overflow-x-auto break-all relative h-full m-2 text-xs bg-white shadow-inner">
{modalViewerContent}
</pre>
{:else if modalViewerMode === 'code'}
{#if lang == 'python3'}
<Highlight language={python} code={modalViewerContent} />
{:else if lang == 'deno'}
<Highlight language={typescript} code={modalViewerContent} />
{/if}
{/if}
</div></Modal
>
<VSplitPane
topPanelSize={viewPreview ? '75%' : '90%'}
downPanelSize={viewPreview ? '25%' : '10%'}
updateCallback={() => {
if (!viewPreview) {
viewPreview = true
}
}}
>
<top slot="top">
<div class="flex flex-col h-full">
<div class="header">
<EditorBar {editor} {lang} {websocketAlive} />
</div>
<div class="flex-1 overflow-hidden">
<Editor
{code}
bind:websocketAlive
bind:this={editor}
cmdEnterAction={() => {
runPreview()
viewPreview = true
}}
formatAction={() => {
code = getEditor().getCode()
localStorage.setItem(path ?? 'last_save', code)
lastSave = code
}}
class="h-full"
deno={lang == 'deno'}
automaticLayout={true}
/>
</div>
</div>
</top>
<down slot="down" class="flex flex-col h-full">
<div class="header">
<div
class="flex flex-row w-full cursor-pointer h-full"
on:click={() => {
viewPreview = !viewPreview
}}
>
<div class="flex flex-row items-baseline">
<div class="font-base py-0 mr-6">
Preview <Tooltip
><span class="font-normal"
>Test your script by running a preview, passing inputs as if you were a user</span
></Tooltip
>
<span style="min-width: 15px; display: inline-block;">
{#if previewIsLoading}
<span transition:fade>
<Icon class="animate-spin" data={faSpinner} scale={0.8} />
</span>
{/if}
</span>
</div>
<button
class="font-semibold my-0 py-0 h-full {previewTab === 'input'
? 'underline drop-shadow-md'
: ''}"
on:click|stopPropagation={() => {
previewTab = 'input'
viewPreview = true
inferSchema()
}}
>
Inputs
</button>
<button
class="font-semibold my-0 py-0 h-full ml-3 {previewTab === 'logs' ? 'underline' : ''}"
on:click|stopPropagation={() => {
previewTab = 'logs'
viewPreview = true
}}
>
Logs
</button>
<button
class="font-semibold my-0 py-0 h-full ml-3 {previewTab === 'output' ? 'underline' : ''}"
on:click|stopPropagation={() => {
previewTab = 'output'
viewPreview = true
}}
>
Result
</button>
<button
class="font-semibold my-0 py-0 h-full ml-3 {previewTab === 'history'
? 'underline'
: ''}"
on:click|stopPropagation={() => {
if (pastPreviews.length == 0) {
loadPastPreviews()
}
previewTab = 'history'
viewPreview = true
}}
>
History
</button>
<button
class="font-semibold my-0 py-0 h-full ml-3 {previewTab === 'last_save'
? 'underline'
: ''}"
on:click|stopPropagation={() => {
previewTab = 'last_save'
viewPreview = true
}}
>
Local save
</button>
</div>
<div class="flex flex-row-reverse grow">
<button
class="mb-1 ml-2"
on:click|stopPropagation={() => {
viewPreview = !viewPreview
}}
><Icon data={viewPreview ? faChevronDown : faChevronUp} scale={0.7} />
</button>
<button
class="default-button py-px text-xs mx-2 align-middle max-h-8"
on:click|stopPropagation={() => {
runPreview()
viewPreview = true
previewTab = 'logs'
}}
>Run preview
</button>
<div class="text-xs text-gray-700 min-w-max mx-2">
Shortcuts: <Tooltip>
Cmd/Ctrl+S: autoformat code and overwrite local save <br />
Cmd/Ctrl+Enter: run preview</Tooltip
>
</div>
</div>
</div>
</div>
<div class="preview flex-1 overflow-hidden p-3">
{#if previewTab === 'logs'}
<pre
class="break-all relative h-full mx-2">{#if previewJob && previewJob.logs}{previewJob.logs}
{:else if previewIsLoading}Starting preview ...
{:else}No preview is available yet{/if}
</pre>
{:else if previewTab === 'input'}
<div class="break-all relative h-full font-sans">
<div class="p-2 w-full">
<button class="default-button w-full mb-4" on:click|stopPropagation={inferSchema}
><Icon data={faMagic} scale={0.7} /><span class="pl-1"
>Infer schema from main parameters</span
>
</button>
</div>
<div class="flex flex-row items-baseline text-2xs text-gray-700 p-2 ml-8 italic">
{#if isValid}
<Icon data={faCheck} class="text-green-600 mr-1" scale={0.6} /> The current preview input
matches requirements defined in arguments
{:else}
<Icon data={faExclamationTriangle} class="text-yellow-500 mr-1" scale={0.6} />The
current preview input doesn't match requirements defined in arguments. Are you sure?{/if}
</div>
<div class="sm:px-8">
<SchemaForm {schema} bind:args bind:isValid />
</div>
</div>
{:else if previewTab === 'output'}
<pre class="overflow-x-auto break-all relative h-full">
{#if previewJob && 'result' in previewJob && previewJob.result}
<DisplayResult result={previewJob.result} />
{:else if previewIsLoading}
Running...
{:else}
No output is available yet
{/if}
</pre>
{:else if previewTab === 'last_save'}
<div class="m-4">
{#if lastSave}
<a
href="#last_save"
class="text-xs"
on:click={() => {
modalViewerContent = lastSave
modalViewerMode = 'code'
modalViewer.openModal()
}}>View last local save for path {path}</a
>
{:else}No local save{/if}
</div>
{:else if previewTab === 'history'}
<TableCustom paginated={false}>
<tr slot="header-row">
<th class="text-xs">id</th>
<th class="text-xs">created at</th>
<th class="text-xs">success</th>
<th class="text-xs">result</th>
<th class="text-xs">code</th>
<th class="text-xs">logs</th>
</tr>
<tbody slot="body">
{#each pastPreviews as { id, created_at, success, result, logs }}
<tr class="">
<td class="text-xs"
><a class="pr-3" href="/run/{id}" target="_blank">{id.substring(30)}</a></td
>
<td class="text-xs">{displayDate(created_at)}</td>
<td class="text-xs">
{#if success}
<Icon class="text-green-600" data={check} scale={0.6} />
{:else}
<Icon class="text-red-700" data={faTimes} scale={0.6} />
{/if}
</td>
<td class="text-xs">
<a
href="#result"
class="text-xs"
on:click={() => {
modalViewerContent = result
modalViewerMode = 'result'
modalViewer.openModal()
}}>{JSON.stringify(result).substring(0, 30)}...</a
></td
>
<td class="text-xs"
><a
href="#code"
class="text-xs"
on:click={async () => {
modalViewerContent = (
await JobService.getCompletedJob({
workspace: $workspaceStore ?? 'NO_W',
id
})
).raw_code
modalViewerMode = 'code'
modalViewer.openModal()
}}
>View code
</a></td
>
<td
><a
href="#logs"
class="text-xs"
on:click={async () => {
modalViewerContent = (
await JobService.getCompletedJob({
workspace: $workspaceStore ?? 'NO_W',
id
})
).logs
modalViewerMode = 'logs'
modalViewer.openModal()
}}
>View logs
</a></td
>
</tr>
{/each}
</tbody>
</TableCustom>
{/if}
</div>
</down>
</VSplitPane>
<style>
.header {
max-height: 28px;
@apply border;
@apply px-2 py-1;
@apply border-l border-r;
@apply bg-gray-100;
@apply text-xs text-gray-700;
}
.preview {
@apply border;
@apply overflow-auto;
@apply bg-white border-l border-r;
@apply text-xs font-mono;
}
</style>