mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
feat(frontend): Script editor (#518)
* feat(frontend): Migrate Tabs * feat(frontend): Generalise Menu * feat(frontend): Menu component done * feat(frontend): Fix placements * feat(frontend): Rework component splitting * feat(frontend): Clean unused imports * feat(frontend): Script editor done * feat(frontend): Code cleanup * feat(frontend): Fix linting * feat(frontend): Add running animation Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
This commit is contained in:
co-authored by
Ruben Fiszel
parent
6bb80b803d
commit
a2265f7f41
@@ -2,11 +2,10 @@
|
||||
import { ResourceService, ScriptService, VariableService } from '$lib/gen'
|
||||
import { getScriptByPath, loadHubScripts, sendUserToast } from '$lib/utils'
|
||||
|
||||
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCode, faCube, faFile, faRotate, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import Icon from 'svelte-awesome'
|
||||
|
||||
import { hubScripts, workspaceStore } from '$lib/stores'
|
||||
import { Button } from 'flowbite-svelte'
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import { python, typescript } from 'svelte-highlight/languages'
|
||||
import type Editor from './Editor.svelte'
|
||||
@@ -170,55 +169,69 @@
|
||||
</ItemPicker>
|
||||
|
||||
<ResourceEditor bind:this={resourceEditor} on:refresh={resourcePicker.openModal} />
|
||||
|
||||
<VariableEditor bind:this={variableEditor} on:create={variablePicker.openModal} />
|
||||
|
||||
<div class="flex flex-row w-full">
|
||||
<div class="flex flex-row w-full space-x-2 whitespace-nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
color="alternative"
|
||||
<div class="flex divide-x items-center">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="mr-1 bg-white rounded-md items-center flex hover:bg-gray-100 font-medium text-xs p-2"
|
||||
on:click={() => {
|
||||
variablePicker.openModal()
|
||||
}}
|
||||
>
|
||||
<Icon data={faSearch} scale={0.7} class="mr-2" />
|
||||
Variable picker
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
color="alternative"
|
||||
<Icon data={faFile} class="h-4 w-4 mr-2" />
|
||||
Insert variable
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="mx-1 bg-white rounded-md items-center flex hover:bg-gray-100 font-medium text-xs p-2"
|
||||
on:click={() => {
|
||||
resourcePicker.openModal()
|
||||
}}
|
||||
>
|
||||
<Icon data={faSearch} scale={0.7} class="mr-2" />
|
||||
Resource picker
|
||||
</Button>
|
||||
<Icon data={faCube} class="h-4 w-4 mr-2" />
|
||||
Insert resource
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
color="alternative"
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="mx-1 bg-white rounded-md items-center flex hover:bg-gray-100 font-medium text-xs p-2"
|
||||
on:click={() => {
|
||||
scriptPicker.openModal()
|
||||
}}
|
||||
>
|
||||
<Icon data={faSearch} scale={0.7} class="mr-2" />
|
||||
Script explorer
|
||||
</Button>
|
||||
<Icon data={faCode} class="h-4 w-4 mr-2" />
|
||||
Search script
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-row-reverse gap-x-2 w-full">
|
||||
<Button
|
||||
size="xs"
|
||||
class={!websocketAlive.pyright && !websocketAlive.deno && !websocketAlive.black
|
||||
? 'bg-red-50'
|
||||
: ''}
|
||||
color="alternative"
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="mx-1 bg-white rounded-md items-center flex hover:bg-gray-100 font-medium text-xs p-2"
|
||||
on:click={() => {
|
||||
editor.clearContent()
|
||||
}}
|
||||
>
|
||||
<Icon data={faRotateLeft} class="h-4 w-4 mr-2" />
|
||||
Reset content
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 bg-white rounded-md items-center flex hover:bg-gray-100 font-medium text-xs p-2"
|
||||
on:click={() => {
|
||||
editor.reloadWebsocket()
|
||||
}}
|
||||
>
|
||||
<Icon data={faRotate} class="h-4 w-4 mr-2" />
|
||||
|
||||
Reload assistants
|
||||
<span class="ml-1">
|
||||
{#if lang == 'deno'}
|
||||
@@ -229,13 +242,6 @@
|
||||
<span class={websocketAlive.black ? 'text-green-600' : 'text-red-700'}>Black</span>)
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="alternative"
|
||||
on:click={() => {
|
||||
editor.clearContent()
|
||||
}}>Reset content</Button
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { OFFSET } from './CronInput.svelte'
|
||||
import Drawer from './Drawer.svelte'
|
||||
import Drawer from './common/drawer/Drawer.svelte'
|
||||
import FlowEditor from './FlowEditor.svelte'
|
||||
import FlowPreviewContent from './FlowPreviewContent.svelte'
|
||||
import { flowStateStore, flowStateToFlow, type FlowState } from './flows/flowState'
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
import Required from './Required.svelte'
|
||||
import ScriptEditor from './ScriptEditor.svelte'
|
||||
import ScriptSchema from './ScriptSchema.svelte'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
|
||||
let editor: ScriptEditor
|
||||
let scriptSchema: ScriptSchema
|
||||
$: step = Number($page.url.searchParams.get('step')) || 1
|
||||
|
||||
export let script: Script
|
||||
export let initialPath: string = ''
|
||||
@@ -32,7 +32,7 @@
|
||||
let pathError = ''
|
||||
|
||||
$: setQueryWithoutLoad($page.url, 'state', encodeState(script))
|
||||
|
||||
$: step = Number($page.url.searchParams.get('step')) || 1
|
||||
$: {
|
||||
if (script.language == 'python3') {
|
||||
script.is_trigger = false
|
||||
@@ -103,11 +103,11 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-screen ">
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- Nav between steps-->
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="justify-between flex flex-row drop-shadow-sm w-full mt-4">
|
||||
<div class="wizard-nav flex flex-row w-full">
|
||||
<div class="flex flex-col w-full px-4 py-2 border-b shadow-sm">
|
||||
<div class="justify-between flex flex-row drop-shadow-sm w-full">
|
||||
<div class="flex flex-row w-full">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem>
|
||||
<button on:click={() => changeStep(1)} class={step === 1 ? 'font-bold' : null}>
|
||||
@@ -141,8 +141,10 @@
|
||||
class="default-button px-6 max-h-8"
|
||||
on:click={() => {
|
||||
changeStep(step + 1)
|
||||
}}>Next</button
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
{:else}
|
||||
<button class="default-button px-6 self-end" on:click={editScript}>Save</button>
|
||||
{/if}
|
||||
@@ -151,8 +153,10 @@
|
||||
class="default-button-secondary px-6 max-h-8 mr-2"
|
||||
on:click={async () => {
|
||||
changeStep(step - 1)
|
||||
}}>Back</button
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{/if}
|
||||
{#if step == 2}
|
||||
<button
|
||||
@@ -160,97 +164,68 @@
|
||||
on:click={async () => {
|
||||
await inferSchema()
|
||||
editScript()
|
||||
}}>Save (commit)</button
|
||||
}}
|
||||
>
|
||||
Save (commit)
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between gap-x-20">
|
||||
<span class="my-1 text-xs text-gray-500">
|
||||
<a target="_blank" href="https://github.com/windmill-labs/windmill-gh-action-deploy"
|
||||
>sync from github instead</a
|
||||
>
|
||||
</span>
|
||||
<span class="my-1 text-sm text-gray-500 italic">
|
||||
{#if script.hash != ''} Editing from {script.hash} with path{/if}
|
||||
{#if initialPath && initialPath != script.path} {initialPath} → {/if}
|
||||
{script.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
{#if step === 1}
|
||||
<div class="grid grid-cols-1 gap-6 max-w-7xl">
|
||||
<Path
|
||||
bind:error={pathError}
|
||||
bind:path={script.path}
|
||||
{initialPath}
|
||||
on:enter={() => changeStep(2)}
|
||||
namePlaceholder="my_script"
|
||||
kind="script"
|
||||
>
|
||||
<div slot="ownerToolkit">
|
||||
Script permissions depend on their path. Select the group <span class="font-mono"
|
||||
>all</span
|
||||
>
|
||||
to share your script, and <span class="font-mono">user</span> to keep it private.
|
||||
<a href="https://docs.windmill.dev/docs/reference/namespaces">docs</a>
|
||||
</div>
|
||||
</Path>
|
||||
<h3 class="text-gray-700 border-b">Language</h3>
|
||||
<div class="max-w-md">
|
||||
<RadioButton
|
||||
label="Language"
|
||||
options={[
|
||||
['Typescript (Deno)', 'deno'],
|
||||
['Python 3.10', 'python3']
|
||||
]}
|
||||
on:change={(e) => initContent(e.detail, template)}
|
||||
bind:value={script.language}
|
||||
/>
|
||||
</div>
|
||||
{#if script.language == 'deno'}
|
||||
<h4 class="text-gray-700 border-b">Template</h4>
|
||||
|
||||
<CenteredPage>
|
||||
<div class="space-y-6">
|
||||
<Path
|
||||
bind:error={pathError}
|
||||
bind:path={script.path}
|
||||
{initialPath}
|
||||
on:enter={() => changeStep(2)}
|
||||
namePlaceholder="my_script"
|
||||
kind="script"
|
||||
>
|
||||
<div slot="ownerToolkit">
|
||||
Script permissions depend on their path. Select the group
|
||||
<span class="font-mono"> all </span>
|
||||
to share your script, and <span class="font-mono">user</span> to keep it private.
|
||||
<a href="https://docs.windmill.dev/docs/reference/namespaces">docs</a>
|
||||
</div>
|
||||
</Path>
|
||||
<h3 class="text-gray-700 border-b">Language</h3>
|
||||
<div class="max-w-md">
|
||||
<RadioButton
|
||||
label="Template"
|
||||
label="Language"
|
||||
options={[
|
||||
['None', undefined],
|
||||
['PostgreSQL', 'pgsql']
|
||||
['Typescript (Deno)', 'deno'],
|
||||
['Python 3.10', 'python3']
|
||||
]}
|
||||
on:change={(e) => initContent(script.language, e.detail)}
|
||||
bind:value={template}
|
||||
on:change={(e) => initContent(e.detail, template)}
|
||||
bind:value={script.language}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if script.language == 'deno'}
|
||||
<h4 class="text-gray-700 border-b">Template</h4>
|
||||
|
||||
<h3 class="text-gray-700 pb-1 border-b">Metadata</h3>
|
||||
<div class="max-w-md">
|
||||
<RadioButton
|
||||
label="Template"
|
||||
options={[
|
||||
['None', undefined],
|
||||
['PostgreSQL', 'pgsql']
|
||||
]}
|
||||
on:change={(e) => initContent(script.language, e.detail)}
|
||||
bind:value={template}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="block ">
|
||||
<span class="text-gray-700">Summary <Required required={false} /></span>
|
||||
<textarea
|
||||
bind:value={script.summary}
|
||||
class="
|
||||
mt-1
|
||||
block
|
||||
w-full
|
||||
rounded-md
|
||||
border-gray-300
|
||||
shadow-sm
|
||||
focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50
|
||||
"
|
||||
placeholder="A very short summary of the script displayed when the script is listed"
|
||||
rows="1"
|
||||
/>
|
||||
</label>
|
||||
<label class="block" for="inp">
|
||||
<span class="text-gray-700"
|
||||
>Description<Required required={false} detail="accept markdown formatting" />
|
||||
<h3 class="text-gray-700 pb-1 border-b">Metadata</h3>
|
||||
|
||||
<label class="block ">
|
||||
<span class="text-gray-700">Summary <Required required={false} /></span>
|
||||
<textarea
|
||||
id="inp"
|
||||
bind:value={script.description}
|
||||
bind:value={script.summary}
|
||||
class="
|
||||
mt-1
|
||||
block
|
||||
@@ -260,69 +235,80 @@
|
||||
shadow-sm
|
||||
focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50
|
||||
"
|
||||
placeholder="A description to help users understand what this script does and how to use it."
|
||||
rows="3"
|
||||
placeholder="A very short summary of the script displayed when the script is listed"
|
||||
rows="1"
|
||||
/>
|
||||
</span></label
|
||||
>
|
||||
</label>
|
||||
<label class="block" for="inp">
|
||||
<span class="text-gray-700"
|
||||
>Description<Required required={false} detail="accept markdown formatting" />
|
||||
<textarea
|
||||
id="inp"
|
||||
bind:value={script.description}
|
||||
class="
|
||||
mt-1
|
||||
block
|
||||
w-full
|
||||
rounded-md
|
||||
border-gray-300
|
||||
shadow-sm
|
||||
focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50
|
||||
"
|
||||
placeholder="A description to help users understand what this script does and how to use it."
|
||||
rows="3"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-gray-700 mr-2">Save as template</span>
|
||||
<input type="checkbox" bind:checked={script.is_template} />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-gray-700 mr-2">Save as template</span>
|
||||
<input type="checkbox" bind:checked={script.is_template} />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-gray-700 mr-2">Save as trigger script</span>
|
||||
<input
|
||||
disabled={script.language == 'python3'}
|
||||
type="checkbox"
|
||||
bind:checked={script.is_trigger}
|
||||
on:change={() => {
|
||||
if (
|
||||
script.content == DENO_INIT_CODE ||
|
||||
script.content == DENO_INIT_CODE_TRIGGER ||
|
||||
script.content == POSTGRES_INIT_CODE
|
||||
) {
|
||||
initContent(script.language, template)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-gray-700 mr-2">Save as trigger script</span>
|
||||
<input
|
||||
disabled={script.language == 'python3'}
|
||||
type="checkbox"
|
||||
bind:checked={script.is_trigger}
|
||||
on:change={() => {
|
||||
if (
|
||||
script.content == DENO_INIT_CODE ||
|
||||
script.content == DENO_INIT_CODE_TRIGGER ||
|
||||
script.content == POSTGRES_INIT_CODE
|
||||
) {
|
||||
initContent(script.language, template)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<h3 class="text-gray-700 ">Description rendered</h3>
|
||||
<div
|
||||
class="prose mt-5 text-xs shadow-inner shadow-blue p-4 overflow-auto"
|
||||
style="max-height: 200px;"
|
||||
>
|
||||
<SvelteMarkdown source={script.description ?? ''} />
|
||||
<div>
|
||||
<h3 class="text-gray-700 ">Description rendered</h3>
|
||||
<div
|
||||
class="prose mt-5 text-xs shadow-inner shadow-blue p-4 overflow-auto"
|
||||
style="max-height: 200px;"
|
||||
>
|
||||
<SvelteMarkdown source={script.description ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CenteredPage>
|
||||
{:else if step === 2}
|
||||
<div class="flex-1 overflow-auto">
|
||||
<ScriptEditor
|
||||
bind:this={editor}
|
||||
bind:schema={script.schema}
|
||||
path={script.path}
|
||||
bind:code={script.content}
|
||||
lang={script.language}
|
||||
/>
|
||||
</div>
|
||||
{:else if step === 3}
|
||||
<ScriptSchema
|
||||
bind:summary={script.summary}
|
||||
bind:description={script.description}
|
||||
<ScriptEditor
|
||||
bind:this={editor}
|
||||
bind:schema={script.schema}
|
||||
path={script.path}
|
||||
bind:code={script.content}
|
||||
lang={script.language}
|
||||
/>
|
||||
{:else if step === 3}
|
||||
<CenteredPage>
|
||||
<ScriptSchema
|
||||
bind:summary={script.summary}
|
||||
bind:description={script.description}
|
||||
bind:schema={script.schema}
|
||||
/>
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* .wizard-nav {
|
||||
@apply w-1/2 sm:w-1/4;
|
||||
} */
|
||||
.wizard-nav button {
|
||||
max-height: 30px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,52 +2,35 @@
|
||||
import type { Schema } from '$lib/common'
|
||||
import { CompletedJob, Job, JobService } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { displayDate, emptySchema } from '$lib/utils'
|
||||
import { classNames, emptySchema } from '$lib/utils'
|
||||
import {
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faExclamationTriangle,
|
||||
faSpinner,
|
||||
faTimes
|
||||
faPlay,
|
||||
faRotateRight
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { check } from 'svelte-awesome/icons'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Editor from './Editor.svelte'
|
||||
import Modal from './Modal.svelte'
|
||||
import TableCustom from './TableCustom.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
|
||||
import { inferArgs } from '$lib/infer'
|
||||
|
||||
// @ts-ignore
|
||||
import type { Preview } from '$lib/gen/models/Preview'
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import { json, python, typescript } from 'svelte-highlight/languages'
|
||||
import { VSplitPane } from 'svelte-split-pane'
|
||||
import DisplayResult from './DisplayResult.svelte'
|
||||
import EditorBar from './EditorBar.svelte'
|
||||
|
||||
import SchemaForm from './SchemaForm.svelte'
|
||||
import LogPanel from './script_editor/LogPanel.svelte'
|
||||
import { HSplitPane, VSplitPane } from 'svelte-split-pane'
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||
import EditorBar from './EditorBar.svelte'
|
||||
|
||||
// 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' = 'input'
|
||||
|
||||
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
|
||||
|
||||
@@ -60,14 +43,11 @@
|
||||
let previewIntervalId: NodeJS.Timer
|
||||
let previewJob: Job | undefined
|
||||
let pastPreviews: CompletedJob[] = []
|
||||
|
||||
let modalViewer: Modal
|
||||
let lastSave: string | null
|
||||
|
||||
let syncIteration: number = 0
|
||||
let ITERATIONS_BEFORE_SLOW_REFRESH = 100
|
||||
|
||||
let lastSave: string | null
|
||||
|
||||
$: lastSave = localStorage.getItem(path ?? 'last_save')
|
||||
|
||||
export function getEditor(): Editor {
|
||||
@@ -186,6 +166,7 @@
|
||||
}
|
||||
|
||||
let syncCode: NodeJS.Timer
|
||||
|
||||
onMount(() => {
|
||||
syncCode = setInterval(() => {
|
||||
const newCode = editor?.getCode()
|
||||
@@ -194,6 +175,7 @@
|
||||
}
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (editor) {
|
||||
code = editor.getCode()
|
||||
@@ -207,315 +189,120 @@
|
||||
})
|
||||
</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
|
||||
>
|
||||
<div class="border-b shadow-sm p-1 pr-4">
|
||||
<div class="flex justify-between">
|
||||
<EditorBar {editor} {lang} {websocketAlive} />
|
||||
|
||||
<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 class="flex divide-x">
|
||||
<div>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/windmill-labs/windmill-gh-action-deploy"
|
||||
class="text-gray-800 mx-1 bg-white rounded-md items-center flex border-gray-300 hover:bg-gray-100 font-medium text-xs p-2"
|
||||
>
|
||||
<Icon data={faGithub} class="h-4 w-4 mr-2" />
|
||||
Sync from Github
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 overflow-hidden border p-2 rounded"
|
||||
on:mouseleave={() => {
|
||||
code = getEditor().getCode()
|
||||
inferSchema()
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
{code}
|
||||
bind:websocketAlive
|
||||
bind:this={editor}
|
||||
cmdEnterAction={() => {
|
||||
runPreview()
|
||||
viewPreview = true
|
||||
}}
|
||||
formatAction={async () => {
|
||||
code = getEditor().getCode()
|
||||
await inferSchema()
|
||||
localStorage.setItem(path ?? 'last_save', code)
|
||||
lastSave = code
|
||||
}}
|
||||
on:blur={() => {
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => runPreview()}
|
||||
disabled={previewIsLoading}
|
||||
class="text-white ml-1 w-28 bg-blue-500 hover:bg-blue-700 rounded-md flex justify-center items-center focus:outline-none font-medium text-xs p-2"
|
||||
>
|
||||
<Icon
|
||||
data={previewIsLoading ? faRotateRight : faPlay}
|
||||
class={classNames('h-4 w-4 mr-2', previewIsLoading ? 'animate-spin' : 'animate-none')}
|
||||
/>
|
||||
|
||||
{previewIsLoading ? 'Running' : 'Run preview'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<HSplitPane leftPaneSize="60%" rightPaneSize="40%" minLeftPaneSize="50px" minRightPaneSize="50px">
|
||||
<left slot="left">
|
||||
<div class="h-full">
|
||||
<div
|
||||
class="p-2 h-full"
|
||||
on:mouseleave={() => {
|
||||
code = getEditor().getCode()
|
||||
inferSchema()
|
||||
}}
|
||||
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 flex-wrap items-baseline">
|
||||
<div class="font-base py-0 mr-6 hidden md:block">
|
||||
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
|
||||
>
|
||||
<Editor
|
||||
{code}
|
||||
bind:websocketAlive
|
||||
bind:this={editor}
|
||||
cmdEnterAction={() => {
|
||||
runPreview()
|
||||
}}
|
||||
formatAction={async () => {
|
||||
code = getEditor().getCode()
|
||||
await inferSchema()
|
||||
localStorage.setItem(path ?? 'last_save', code)
|
||||
lastSave = code
|
||||
}}
|
||||
on:blur={() => {
|
||||
code = getEditor().getCode()
|
||||
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 hidden md:block mx-2">
|
||||
Shortcuts: <Tooltip>
|
||||
Cmd/Ctrl+S: autoformat code and overwrite local save <br />
|
||||
Cmd/Ctrl+Enter: run preview</Tooltip
|
||||
>
|
||||
</div>
|
||||
class="flex flex-1 h-full"
|
||||
deno={lang == 'deno'}
|
||||
automaticLayout={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div bind:this={div} class="preview flex-1 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 -mt-2">
|
||||
<div class="items-baseline text-xs text-gray-700 px-2 ml-8 italic hidden md:block">
|
||||
<p>
|
||||
Move the focus outside of the text editor to recompute the input schema from main
|
||||
signature or press Ctrl/Cmd+S
|
||||
</p>
|
||||
<p class="">
|
||||
{#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{/if}
|
||||
</p>
|
||||
</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>
|
||||
<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 }}
|
||||
<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 {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.preview {
|
||||
@apply border;
|
||||
@apply overflow-auto;
|
||||
@apply bg-white border-l border-r;
|
||||
@apply text-xs font-mono;
|
||||
}
|
||||
</style>
|
||||
</left>
|
||||
<right slot="right">
|
||||
<div class="h-full">
|
||||
<VSplitPane topPanelSize="50%" downPanelSize="50%">
|
||||
<top slot="top">
|
||||
<div class="h-full overflow-auto">
|
||||
<div class="p-4">
|
||||
<div class="break-all relative font-sans">
|
||||
<div class="items-baseline text-xs text-gray-700 italic hidden md:block">
|
||||
<p>
|
||||
Move the focus outside of the text editor to recompute the input schema from
|
||||
main signature or press Ctrl/Cmd+S
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
{#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
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<SchemaForm {schema} bind:args bind:isValid />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</top>
|
||||
<down slot="down">
|
||||
<div class="pt-1 h-full overflow-auto">
|
||||
<LogPanel
|
||||
{path}
|
||||
{lang}
|
||||
{previewJob}
|
||||
{pastPreviews}
|
||||
{previewIsLoading}
|
||||
bind:lastSave
|
||||
/>
|
||||
</div>
|
||||
</down>
|
||||
</VSplitPane>
|
||||
</div>
|
||||
</right>
|
||||
</HSplitPane>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { faClose } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
|
||||
export let title: string | undefined = undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<div class="divide-y h-screen">
|
||||
<div class="flex justify-between items-center py-2 px-4">
|
||||
<span class="text-sm font-bold">{title}</span>
|
||||
<button on:click={() => dispatch('close')}>
|
||||
<Icon data={faClose} class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-2 h-full">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { CompletedJob, Job, JobService, Preview } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { check } from 'svelte-awesome/icons'
|
||||
|
||||
import Tabs from '../common/tabs/Tabs.svelte'
|
||||
import Tab from '../common/tabs/Tab.svelte'
|
||||
import TabContent from '../common/tabs/TabContent.svelte'
|
||||
import DisplayResult from '../DisplayResult.svelte'
|
||||
import TableCustom from '../TableCustom.svelte'
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import { json, python, typescript } from 'svelte-highlight/languages'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
|
||||
export let path: string | undefined
|
||||
export let lang: Preview.language
|
||||
export let previewIsLoading = false
|
||||
export let previewJob: Job | undefined
|
||||
export let pastPreviews: CompletedJob[] = []
|
||||
export let lastSave: string | null
|
||||
|
||||
type DrawerContent = {
|
||||
mode: 'json' | Preview.language | 'plain'
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
let selectedTab = 'logs'
|
||||
let drawerOpen: boolean = false
|
||||
let drawerContent: DrawerContent | undefined = undefined
|
||||
|
||||
function openDrawer(newContent: DrawerContent) {
|
||||
drawerContent = newContent
|
||||
drawerOpen = true
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:open={drawerOpen} size="800px">
|
||||
<DrawerContent title={drawerContent?.title} on:close={() => closeDrawer()}>
|
||||
{#if drawerContent?.mode === 'json'}
|
||||
<Highlight language={json} code={JSON.stringify(drawerContent.content, null, 4)} />
|
||||
{:else if drawerContent?.mode === 'plain'}
|
||||
<pre class="overflow-x-auto break-all relative h-full m-2 text-xs bg-white shadow-inner p-2">
|
||||
{drawerContent?.content}
|
||||
</pre>
|
||||
{:else if drawerContent?.mode === 'deno'}
|
||||
<Highlight language={typescript} code={drawerContent?.content} />
|
||||
{:else if drawerContent?.mode === 'python3'}
|
||||
<Highlight language={python} code={drawerContent?.content} />
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Tabs bind:selected={selectedTab}>
|
||||
<Tab value="logs"><span class="text-xs">Logs</span></Tab>
|
||||
<Tab value="results"><span class="text-xs">Results</span></Tab>
|
||||
<Tab value="history"><span class="text-xs">History</span></Tab>
|
||||
<Tab value="last_save"><span class="text-xs">Last save</span></Tab>
|
||||
|
||||
<svelte:fragment slot="content">
|
||||
<div class="px-4">
|
||||
<TabContent value="logs">
|
||||
<pre class="whitespace-pre-wrap break-all relative h-full bg-gray-50 text-xs p-2">
|
||||
{#if previewJob && previewJob.logs}
|
||||
{previewJob.logs}
|
||||
{:else if previewIsLoading}
|
||||
Starting preview ...
|
||||
{:else}
|
||||
No preview is available yet
|
||||
{/if}
|
||||
</pre>
|
||||
</TabContent>
|
||||
<TabContent value="results">
|
||||
<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>
|
||||
</TabContent>
|
||||
<TabContent value="history">
|
||||
<TableCustom>
|
||||
<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 }}
|
||||
<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={() => {
|
||||
openDrawer({ mode: 'json', content: result, title: 'Result' })
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(result).substring(0, 30)}...
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<a
|
||||
href="#code"
|
||||
class="text-xs"
|
||||
on:click={async () => {
|
||||
const code = (
|
||||
await JobService.getCompletedJob({
|
||||
workspace: $workspaceStore ?? 'NO_W',
|
||||
id
|
||||
})
|
||||
).raw_code
|
||||
|
||||
openDrawer({ mode: lang, content: String(code), title: `Code ${lang}` })
|
||||
}}
|
||||
>
|
||||
View code
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="#logs"
|
||||
class="text-xs"
|
||||
on:click={async () => {
|
||||
const logs = (
|
||||
await JobService.getCompletedJob({
|
||||
workspace: $workspaceStore ?? 'NO_W',
|
||||
id
|
||||
})
|
||||
).logs
|
||||
openDrawer({ mode: 'plain', content: String(logs), title: `Code ${lang}` })
|
||||
}}
|
||||
>
|
||||
View logs
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
</TabContent>
|
||||
<TabContent value="last_save">
|
||||
{#if lastSave}
|
||||
<a
|
||||
href="#last_save"
|
||||
class="text-xs"
|
||||
on:click={() => {
|
||||
openDrawer({ mode: lang, content: String(lastSave), title: `Code ${lang}` })
|
||||
}}
|
||||
>
|
||||
View last local save for path {path}
|
||||
</a>
|
||||
{:else}
|
||||
No local save
|
||||
{/if}
|
||||
</TabContent>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tabs>
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
'hidden md:flex md:flex-col md:fixed md:inset-y-0 transition-all ease-in-out duration-200 ',
|
||||
'hidden md:flex md:flex-col md:fixed md:inset-y-0 transition-all ease-in-out duration-200 shadow-md',
|
||||
isCollapsed ? 'md:w-12' : 'md:w-48'
|
||||
)}
|
||||
>
|
||||
@@ -136,7 +136,7 @@
|
||||
)}
|
||||
>
|
||||
<main>
|
||||
<div class="border-b w-full">
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="py-2 px-2 sm:px-4 md:px-8 flex justify-between items-center shadow-sm max-w-6xl mx-auto md:hidden"
|
||||
>
|
||||
|
||||
@@ -77,6 +77,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<CenteredPage>
|
||||
<ScriptBuilder {script} />
|
||||
</CenteredPage>
|
||||
<ScriptBuilder {script} />
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { decodeState } from '$lib/utils'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
|
||||
const initialState = $page.url.searchParams.get('state')
|
||||
let scriptLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined
|
||||
@@ -41,7 +40,5 @@
|
||||
</script>
|
||||
|
||||
{#if script}
|
||||
<CenteredPage>
|
||||
<ScriptBuilder {initialPath} {script} />
|
||||
</CenteredPage>
|
||||
<ScriptBuilder {initialPath} {script} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user