mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
1731 lines
48 KiB
Svelte
1731 lines
48 KiB
Svelte
<script lang="ts">
|
|
import { Alert, Badge, Drawer, DrawerContent, Tab, Tabs, UndoRedo } from '$lib/components/common'
|
|
import Button from '$lib/components/common/button/Button.svelte'
|
|
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
|
import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte'
|
|
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 { AppService, DraftService, type Job, type Policy } from '$lib/gen'
|
|
import { redo, undo } from '$lib/history'
|
|
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
|
import {
|
|
AlignHorizontalSpaceAround,
|
|
BellOff,
|
|
Bug,
|
|
DiffIcon,
|
|
Expand,
|
|
FileJson,
|
|
FileUp,
|
|
FormInput,
|
|
History,
|
|
Laptop2,
|
|
Loader2,
|
|
MoreVertical,
|
|
RefreshCw,
|
|
Save,
|
|
Smartphone,
|
|
FileClock,
|
|
Sun,
|
|
Moon,
|
|
SunMoon
|
|
} from 'lucide-svelte'
|
|
import { createEventDispatcher, getContext } from 'svelte'
|
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
|
import {
|
|
classNames,
|
|
cleanValueProperties,
|
|
truncateRev,
|
|
orderedJsonStringify,
|
|
type Value,
|
|
replaceFalseWithUndefined
|
|
} from '../../../utils'
|
|
import type {
|
|
AppInput,
|
|
ConnectedAppInput,
|
|
RowAppInput,
|
|
Runnable,
|
|
StaticAppInput,
|
|
UserAppInput
|
|
} from '../inputType'
|
|
import type { App, AppEditorContext, AppViewerContext } from '../types'
|
|
import { BG_PREFIX, 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'
|
|
import PreviewToggle from './PreviewToggle.svelte'
|
|
|
|
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
|
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
|
import Tooltip from '$lib/components/Tooltip.svelte'
|
|
import { Sha256 } from '@aws-crypto/sha256-js'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import DeploymentHistory from './DeploymentHistory.svelte'
|
|
import Awareness from '$lib/components/Awareness.svelte'
|
|
import { secondaryMenuLeftStore, secondaryMenuRightStore } from './settingsPanel/secondaryMenu'
|
|
import ButtonDropdown from '$lib/components/common/button/ButtonDropdown.svelte'
|
|
import { MenuItem } from '@rgossiaux/svelte-headlessui'
|
|
import AppEditorTutorial from './AppEditorTutorial.svelte'
|
|
import AppTimeline from './AppTimeline.svelte'
|
|
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
|
import AppReportsDrawer from './AppReportsDrawer.svelte'
|
|
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
|
import { type ColumnDef, getPrimaryKeys } from '../components/display/dbtable/utils'
|
|
import DebugPanel from './contextPanel/DebugPanel.svelte'
|
|
import { getCountInput } from '../components/display/dbtable/queries/count'
|
|
import { getSelectInput } from '../components/display/dbtable/queries/select'
|
|
import { getInsertInput } from '../components/display/dbtable/queries/insert'
|
|
import { getUpdateInput } from '../components/display/dbtable/queries/update'
|
|
import { getDeleteInput } from '../components/display/dbtable/queries/delete'
|
|
import { collectOneOfFields } from './appUtils'
|
|
import Summary from '$lib/components/Summary.svelte'
|
|
import ToggleEnable from '$lib/components/common/toggleButton-v2/ToggleEnable.svelte'
|
|
import HideButton from './settingsPanel/HideButton.svelte'
|
|
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
|
import { computeS3FileInputPolicy, computeWorkspaceS3FileInputPolicy } from './appUtilsS3'
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import { base } from '$lib/base'
|
|
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
|
|
|
async function hash(message) {
|
|
try {
|
|
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
|
|
} catch {
|
|
//subtle not available, trying pure js
|
|
const hash = new Sha256()
|
|
hash.update(message ?? '')
|
|
const result = Array.from(await hash.digest())
|
|
const hex = result.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
|
|
return hex
|
|
}
|
|
}
|
|
|
|
export let policy: Policy
|
|
export let fromHub: boolean = false
|
|
export let diffDrawer: DiffDrawer | undefined = undefined
|
|
export let savedApp:
|
|
| {
|
|
value: App
|
|
draft?: any
|
|
path: string
|
|
summary: string
|
|
policy: any
|
|
draft_only?: boolean
|
|
custom_path?: string
|
|
}
|
|
| undefined = undefined
|
|
export let version: number | undefined = undefined
|
|
export let leftPanelHidden: boolean = false
|
|
export let rightPanelHidden: boolean = false
|
|
export let bottomPanelHidden: boolean = false
|
|
export let newApp: boolean
|
|
export let newPath: string = ''
|
|
|
|
let newEditedPath = ''
|
|
let deployedValue: Value | undefined = undefined // Value to diff against
|
|
let deployedBy: string | undefined = undefined // Author
|
|
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
|
|
let open: boolean = false // Is confirmation modal open
|
|
|
|
const {
|
|
app,
|
|
summary,
|
|
breakpoint,
|
|
appPath,
|
|
jobs,
|
|
jobsById,
|
|
staticExporter,
|
|
errorByComponent,
|
|
openDebugRun,
|
|
mode,
|
|
darkMode
|
|
} = getContext<AppViewerContext>('AppViewerContext')
|
|
|
|
const { history, jobsDrawerOpen, refreshComponents } =
|
|
getContext<AppEditorContext>('AppEditorContext')
|
|
|
|
const loading = {
|
|
publish: false,
|
|
save: false,
|
|
saveDraft: false
|
|
}
|
|
|
|
$: if ($openDebugRun == undefined) {
|
|
$openDebugRun = (jobId: string) => {
|
|
$jobsDrawerOpen = true
|
|
selectedJobId = jobId
|
|
}
|
|
}
|
|
|
|
let pathError: string | undefined = undefined
|
|
let appExport: AppExportButton
|
|
|
|
let draftDrawerOpen = false
|
|
let saveDrawerOpen = false
|
|
let inputsDrawerOpen = fromHub
|
|
let historyBrowserDrawerOpen = false
|
|
let debugAppDrawerOpen = false
|
|
let deploymentMsg: string | undefined = undefined
|
|
|
|
function closeSaveDrawer() {
|
|
saveDrawerOpen = false
|
|
}
|
|
|
|
function closeDraftDrawer() {
|
|
draftDrawerOpen = 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']]
|
|
})
|
|
)
|
|
}
|
|
|
|
type TriggerableV2 = {
|
|
static_inputs: Record<string, any>
|
|
one_of_inputs?: Record<string, any[] | undefined>
|
|
allow_user_resources?: string[]
|
|
}
|
|
|
|
async function computeTriggerables() {
|
|
const items = allItems($app.grid, $app.subgrids)
|
|
|
|
console.debug('items', items)
|
|
|
|
const allTriggers: ([string, TriggerableV2] | undefined)[] = (await Promise.all(
|
|
items
|
|
.flatMap((x) => {
|
|
let c = x.data as AppComponent
|
|
let r: { input: AppInput | undefined; id: string }[] = [
|
|
{ input: c.componentInput, id: x.id }
|
|
]
|
|
if (c.type === 'tablecomponent') {
|
|
r.push(...c.actionButtons.map((x) => ({ input: x.componentInput, id: x.id })))
|
|
}
|
|
if (
|
|
(c.type === 'aggridcomponent' ||
|
|
c.type === 'aggridcomponentee' ||
|
|
c.type === 'dbexplorercomponent' ||
|
|
c.type === 'aggridinfinitecomponent' ||
|
|
c.type === 'aggridinfinitecomponentee') &&
|
|
Array.isArray(c.actions)
|
|
) {
|
|
r.push(...c.actions.map((x) => ({ input: x.componentInput, id: x.id })))
|
|
}
|
|
if (c.type === 'menucomponent') {
|
|
r.push(...c.menuItems.map((x) => ({ input: x.componentInput, id: x.id })))
|
|
}
|
|
if (c.type === 'dbexplorercomponent') {
|
|
let nr: { id: string; input: AppInput }[] = []
|
|
let config = c.configuration as any
|
|
|
|
const dbType = config?.type?.selected
|
|
let pg = config?.type?.configuration?.[dbType]
|
|
|
|
if (pg && dbType) {
|
|
const { table, resource } = pg
|
|
const tableValue = table.value
|
|
const resourceValue = resource.value
|
|
const columnDefs = (c.configuration.columnDefs as any).value as ColumnDef[]
|
|
const whereClause = (c.configuration.whereClause as any).value as unknown as
|
|
| string
|
|
| undefined
|
|
if (tableValue && resourceValue && columnDefs) {
|
|
r.push({
|
|
input: getSelectInput(resourceValue, tableValue, columnDefs, whereClause, dbType),
|
|
id: x.id
|
|
})
|
|
|
|
r.push({
|
|
input: getCountInput(resourceValue, tableValue, dbType, columnDefs, whereClause),
|
|
id: x.id + '_count'
|
|
})
|
|
|
|
r.push({
|
|
input: getInsertInput(tableValue, columnDefs, resourceValue, dbType),
|
|
id: x.id + '_insert'
|
|
})
|
|
|
|
let primaryColumns = getPrimaryKeys(columnDefs)
|
|
let columns = columnDefs?.filter((x) => primaryColumns.includes(x.field))
|
|
|
|
r.push({
|
|
input: getDeleteInput(resourceValue, tableValue, columns, dbType),
|
|
id: x.id + '_delete'
|
|
})
|
|
|
|
columnDefs
|
|
.filter((col) => col.editable || config.allEditable.value)
|
|
.forEach((column) => {
|
|
r.push({
|
|
input: getUpdateInput(resourceValue, tableValue, column, columns, dbType),
|
|
id: x.id + '_update'
|
|
})
|
|
})
|
|
}
|
|
}
|
|
r.push(...nr)
|
|
}
|
|
|
|
const processed = r
|
|
.filter((x) => x.input)
|
|
.map(async (o) => {
|
|
if (o.input?.type == 'runnable') {
|
|
return await processRunnable(o.id, o.input.runnable, o.input.fields)
|
|
}
|
|
})
|
|
|
|
return processed as Promise<[string, TriggerableV2] | undefined>[]
|
|
})
|
|
.concat(
|
|
Object.values($app.hiddenInlineScripts ?? {}).map(async (v, i) => {
|
|
return await processRunnable(BG_PREFIX + i, v, v.fields)
|
|
}) as Promise<[string, TriggerableV2] | undefined>[]
|
|
)
|
|
)) as ([string, TriggerableV2] | undefined)[]
|
|
|
|
delete policy.triggerables
|
|
const ntriggerables: Record<string, TriggerableV2> = Object.fromEntries(
|
|
allTriggers.filter(Boolean) as [string, TriggerableV2][]
|
|
)
|
|
policy.triggerables_v2 = ntriggerables
|
|
|
|
const s3_inputs = items
|
|
.filter((x) => (x.data as AppComponent).type === 's3fileinputcomponent')
|
|
.map((x) => {
|
|
const c = x.data as AppComponent
|
|
const config = c.configuration as any
|
|
return computeS3FileInputPolicy(config?.type?.configuration?.s3, $app)
|
|
})
|
|
.filter(Boolean) as {
|
|
allowed_resources: string[]
|
|
allow_user_resources: boolean
|
|
file_key_regex: string
|
|
}[]
|
|
|
|
if (
|
|
items.findIndex((x) => {
|
|
const c = x.data as AppComponent
|
|
if (c.type === 'schemaformcomponent') {
|
|
return (
|
|
Object.values((c.componentInput as any)?.value?.properties ?? {}).findIndex(
|
|
(p: any) => p?.type === 'object' && p?.format === 'resource-s3_object'
|
|
) !== -1
|
|
)
|
|
} else if (c.type === 'formbuttoncomponent' || c.type === 'formcomponent') {
|
|
return (
|
|
Object.values((c.componentInput as any)?.fields ?? {}).findIndex(
|
|
(p: any) => p?.fieldType === 'object' && p?.format === 'resource-s3_object'
|
|
) !== -1
|
|
)
|
|
} else {
|
|
return false
|
|
}
|
|
}) !== -1
|
|
) {
|
|
s3_inputs.push(computeWorkspaceS3FileInputPolicy())
|
|
}
|
|
|
|
policy.s3_inputs = s3_inputs
|
|
}
|
|
|
|
async function processRunnable(
|
|
id: string,
|
|
runnable: Runnable,
|
|
fields: Record<string, any>
|
|
): Promise<[string, TriggerableV2] | undefined> {
|
|
const staticInputs = collectStaticFields(fields)
|
|
const oneOfInputs = collectOneOfFields(fields, $app)
|
|
const allowUserResources: string[] = Object.entries(fields)
|
|
.map(([k, v]) => {
|
|
return v['allowUserResources'] ? k : undefined
|
|
})
|
|
.filter(Boolean) as string[]
|
|
|
|
if (runnable?.type == 'runnableByName') {
|
|
let hex = await hash(runnable.inlineScript?.content)
|
|
console.log('hex', hex, id)
|
|
return [
|
|
`${id}:rawscript/${hex}`,
|
|
{
|
|
static_inputs: staticInputs,
|
|
one_of_inputs: oneOfInputs,
|
|
allow_user_resources: allowUserResources
|
|
}
|
|
]
|
|
} else if (runnable?.type == 'runnableByPath') {
|
|
let prefix = runnable.runType !== 'hubscript' ? runnable.runType : 'script'
|
|
return [
|
|
`${id}:${prefix}/${runnable.path}`,
|
|
{
|
|
static_inputs: staticInputs,
|
|
one_of_inputs: oneOfInputs,
|
|
allow_user_resources: allowUserResources
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
async function createApp(path: string) {
|
|
await computeTriggerables()
|
|
try {
|
|
await AppService.createApp({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
value: $app,
|
|
path,
|
|
summary: $summary,
|
|
policy,
|
|
deployment_message: deploymentMsg
|
|
}
|
|
})
|
|
savedApp = {
|
|
summary: $summary,
|
|
value: structuredClone($app),
|
|
path: path,
|
|
policy: policy
|
|
}
|
|
closeSaveDrawer()
|
|
sendUserToast('App deployed successfully')
|
|
try {
|
|
localStorage.removeItem(`app-${path}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
dispatch('savedNewAppPath', path)
|
|
} catch (e) {
|
|
sendUserToast('Error creating app', e)
|
|
}
|
|
}
|
|
|
|
async function handleUpdateApp(npath: string) {
|
|
// We have to make sure there is no updates when we clicked the button
|
|
await compareVersions()
|
|
|
|
if (onLatest) {
|
|
// Handle directly
|
|
await updateApp(npath)
|
|
} else {
|
|
// There is onLatest, but we need more information while deploying
|
|
// We need it to show diff
|
|
// Handle through confirmation modal
|
|
await syncWithDeployed()
|
|
if (
|
|
deployedValue &&
|
|
savedApp &&
|
|
$app &&
|
|
orderedJsonStringify(deployedValue) ===
|
|
orderedJsonStringify(
|
|
replaceFalseWithUndefined({
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy
|
|
})
|
|
)
|
|
) {
|
|
await updateApp(npath)
|
|
} else {
|
|
confirmCallback = async () => {
|
|
open = false
|
|
await updateApp(npath)
|
|
}
|
|
// Open confirmation modal
|
|
open = true
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncWithDeployed() {
|
|
const deployedApp = await AppService.getAppByPath({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath!,
|
|
withStarredInfo: true
|
|
})
|
|
|
|
deployedBy = deployedApp.created_by
|
|
|
|
// Strip off extra information
|
|
deployedValue = replaceFalseWithUndefined({
|
|
...deployedApp,
|
|
id: undefined,
|
|
created_at: undefined,
|
|
created_by: undefined,
|
|
versions: undefined,
|
|
extra_perms: undefined
|
|
})
|
|
}
|
|
|
|
async function updateApp(npath: string) {
|
|
await computeTriggerables()
|
|
await AppService.updateApp({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath!,
|
|
requestBody: {
|
|
value: $app!,
|
|
summary: $summary,
|
|
policy,
|
|
path: npath,
|
|
deployment_message: deploymentMsg,
|
|
custom_path: $userStore?.is_admin || $userStore?.is_super_admin ? (customPath ?? '') : undefined
|
|
}
|
|
})
|
|
savedApp = {
|
|
summary: $summary,
|
|
value: structuredClone($app),
|
|
path: npath,
|
|
policy,
|
|
custom_path: customPath
|
|
}
|
|
const appHistory = await AppService.getAppHistoryByPath({
|
|
workspace: $workspaceStore!,
|
|
path: npath
|
|
})
|
|
version = appHistory[0]?.version
|
|
|
|
closeSaveDrawer()
|
|
sendUserToast('App deployed successfully')
|
|
if ($appPath !== npath) {
|
|
try {
|
|
localStorage.removeItem(`app-${appPath}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
dispatch('savedNewAppPath', npath)
|
|
}
|
|
}
|
|
|
|
let secretUrl: string | undefined = undefined
|
|
|
|
$: $appPath && $appPath != '' && secretUrl == undefined && getSecretUrl()
|
|
|
|
async function getSecretUrl() {
|
|
secretUrl = await AppService.getPublicSecretOfApp({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath
|
|
})
|
|
}
|
|
|
|
async function setPublishState() {
|
|
await computeTriggerables()
|
|
await AppService.updateApp({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath,
|
|
requestBody: { policy }
|
|
})
|
|
if (policy.execution_mode == 'anonymous') {
|
|
sendUserToast('App require no login to be accessed')
|
|
} else {
|
|
sendUserToast('App require login and read-access')
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
$secondaryMenuLeftStore.isOpen = false
|
|
$secondaryMenuRightStore.isOpen = false
|
|
|
|
saveDrawerOpen = true
|
|
return
|
|
}
|
|
|
|
async function saveInitialDraft() {
|
|
await computeTriggerables()
|
|
try {
|
|
await AppService.createApp({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
value: $app,
|
|
path: newEditedPath,
|
|
summary: $summary,
|
|
policy,
|
|
draft_only: true
|
|
}
|
|
})
|
|
await DraftService.createDraft({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path: newEditedPath,
|
|
typ: 'app',
|
|
value: {
|
|
value: $app,
|
|
path: newEditedPath,
|
|
summary: $summary,
|
|
policy
|
|
}
|
|
}
|
|
})
|
|
savedApp = {
|
|
summary: $summary,
|
|
value: structuredClone($app),
|
|
path: newEditedPath,
|
|
policy,
|
|
draft_only: true,
|
|
draft: {
|
|
summary: $summary,
|
|
value: structuredClone($app),
|
|
path: newEditedPath,
|
|
policy
|
|
}
|
|
}
|
|
|
|
draftDrawerOpen = false
|
|
dispatch('savedNewAppPath', newEditedPath)
|
|
} catch (e) {
|
|
sendUserToast('Error saving initial draft', e)
|
|
}
|
|
draftDrawerOpen = false
|
|
}
|
|
|
|
async function saveDraft(forceSave = false) {
|
|
if (newApp) {
|
|
// initial draft
|
|
draftDrawerOpen = true
|
|
return
|
|
}
|
|
if (!savedApp) {
|
|
return
|
|
}
|
|
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
|
|
const current = cleanValueProperties({
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy
|
|
})
|
|
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
|
sendUserToast('No changes detected, ignoring', false, [
|
|
{
|
|
label: 'Save anyway',
|
|
callback: () => {
|
|
saveDraft(true)
|
|
}
|
|
}
|
|
])
|
|
return
|
|
}
|
|
loading.saveDraft = true
|
|
try {
|
|
await computeTriggerables()
|
|
let path = $appPath
|
|
if (savedApp.draft_only) {
|
|
await AppService.deleteApp({
|
|
workspace: $workspaceStore!,
|
|
path: path
|
|
})
|
|
await AppService.createApp({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
value: $app!,
|
|
summary: $summary,
|
|
policy,
|
|
path: newEditedPath || path,
|
|
draft_only: true
|
|
}
|
|
})
|
|
}
|
|
await DraftService.createDraft({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path: savedApp.draft_only ? newEditedPath || path : path,
|
|
typ: 'app',
|
|
value: {
|
|
value: $app!,
|
|
summary: $summary,
|
|
policy,
|
|
path: newEditedPath || path
|
|
}
|
|
}
|
|
})
|
|
|
|
savedApp = {
|
|
...(savedApp?.draft_only
|
|
? {
|
|
summary: $summary,
|
|
value: structuredClone($app),
|
|
path: savedApp.draft_only ? newEditedPath || path : path,
|
|
policy,
|
|
draft_only: true
|
|
}
|
|
: savedApp),
|
|
draft: {
|
|
summary: $summary,
|
|
value: structuredClone($app),
|
|
path: newEditedPath || path,
|
|
policy
|
|
}
|
|
}
|
|
|
|
sendUserToast('Draft saved')
|
|
try {
|
|
localStorage.removeItem(`app-${path}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
loading.saveDraft = false
|
|
if (newApp || savedApp.draft_only) {
|
|
dispatch('savedNewAppPath', newEditedPath || path)
|
|
}
|
|
} catch (e) {
|
|
loading.saveDraft = false
|
|
throw e
|
|
}
|
|
}
|
|
|
|
let onLatest = true
|
|
async function compareVersions() {
|
|
if (version === undefined) {
|
|
return
|
|
}
|
|
try {
|
|
const appVersion = await AppService.getAppLatestVersion({
|
|
workspace: $workspaceStore!,
|
|
path: $appPath
|
|
})
|
|
onLatest = appVersion?.version === undefined || version === appVersion?.version
|
|
} catch (e) {
|
|
console.error('Error comparing versions', e)
|
|
onLatest = true
|
|
}
|
|
}
|
|
|
|
$: saveDrawerOpen && compareVersions()
|
|
|
|
let selectedJobId: string | undefined = undefined
|
|
let testJobLoader: TestJobLoader
|
|
let job: Job | undefined = undefined
|
|
let testIsLoading = false
|
|
|
|
$: selectedJobId && !selectedJobId?.includes('Frontend') && testJobLoader?.watchJob(selectedJobId)
|
|
|
|
$: if (selectedJobId?.includes('Frontend') && selectedJobId) {
|
|
job = undefined
|
|
}
|
|
|
|
$: hasErrors = Object.keys($errorByComponent).length > 0
|
|
|
|
let lock = false
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (lock) return
|
|
|
|
let classes = event.target?.['className']
|
|
if (
|
|
(typeof classes === 'string' && classes.includes('inputarea')) ||
|
|
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!)
|
|
) {
|
|
return
|
|
}
|
|
|
|
lock = true
|
|
|
|
switch (event.key) {
|
|
case 'Z':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
$app = redo(history)
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 'z':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
$app = undo(history, $app)
|
|
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 's':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
saveDraft()
|
|
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
|
|
}
|
|
|
|
let dirtyPath = false
|
|
let path: Path | undefined = undefined
|
|
|
|
let moreItems = [
|
|
{
|
|
displayName: 'Deployment History',
|
|
icon: History,
|
|
action: () => {
|
|
historyBrowserDrawerOpen = true
|
|
},
|
|
disabled: !savedApp
|
|
},
|
|
{
|
|
displayName: 'Export',
|
|
icon: FileJson,
|
|
action: () => {
|
|
appExport.open($app)
|
|
}
|
|
},
|
|
// {
|
|
// displayName: 'Publish to Hub',
|
|
// icon: faGlobe,
|
|
// action: () => {
|
|
// const url = appToHubUrl(toStatic($app, $staticExporter, $summary, $hubBaseUrlStore))
|
|
// window.open(url.toString(), '_blank')
|
|
// }
|
|
// },
|
|
{
|
|
displayName: 'Hub compatible JSON',
|
|
icon: FileUp,
|
|
action: () => {
|
|
appExport.open(toStatic($app, $staticExporter, $summary).app)
|
|
}
|
|
},
|
|
{
|
|
displayName: 'App Inputs',
|
|
icon: FormInput,
|
|
action: () => {
|
|
inputsDrawerOpen = true
|
|
}
|
|
},
|
|
{
|
|
displayName: 'Schedule Reports',
|
|
icon: FileClock,
|
|
action: () => {
|
|
appReportingDrawerOpen = true
|
|
},
|
|
disabled: !savedApp || savedApp.draft_only
|
|
},
|
|
{
|
|
displayName: 'Diff',
|
|
icon: DiffIcon,
|
|
action: async () => {
|
|
if (!savedApp) {
|
|
return
|
|
}
|
|
|
|
// deployedValue should be syncronized when we open Diff
|
|
await syncWithDeployed()
|
|
|
|
diffDrawer?.openDrawer()
|
|
diffDrawer?.setDiff({
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? savedApp,
|
|
draft: savedApp.draft,
|
|
current: {
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy
|
|
}
|
|
})
|
|
},
|
|
disabled: !savedApp
|
|
},
|
|
// App debug menu
|
|
{
|
|
displayName: 'Troubleshoot panel',
|
|
icon: Bug,
|
|
action: () => {
|
|
debugAppDrawerOpen = true
|
|
}
|
|
}
|
|
]
|
|
|
|
let appEditorTutorial: AppEditorTutorial | undefined = undefined
|
|
|
|
export function toggleTutorial() {
|
|
appEditorTutorial?.toggleTutorial()
|
|
}
|
|
|
|
let rightColumnSelect: 'timeline' | 'detail' = 'timeline'
|
|
|
|
let appReportingDrawerOpen = false
|
|
|
|
export function openTroubleshootPanel() {
|
|
debugAppDrawerOpen = true
|
|
}
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
function setTheme(newDarkMode: boolean | undefined) {
|
|
let globalDarkMode = window.localStorage.getItem('dark-mode')
|
|
? window.localStorage.getItem('dark-mode') === 'dark'
|
|
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
if (newDarkMode === true || (newDarkMode === null && globalDarkMode)) {
|
|
document.documentElement.classList.add('dark')
|
|
} else if (newDarkMode === false) {
|
|
document.documentElement.classList.remove('dark')
|
|
}
|
|
$darkMode = newDarkMode ?? globalDarkMode
|
|
}
|
|
|
|
let priorDarkMode = document.documentElement.classList.contains('dark')
|
|
setTheme($app?.darkMode)
|
|
|
|
let customPath = savedApp?.custom_path
|
|
let dirtyCustomPath = false
|
|
let customPathError = ''
|
|
$: fullCustomUrl = `${window.location.origin}${base}/a/${
|
|
isCloudHosted() ? $workspaceStore + '/' : ''
|
|
}${customPath}`
|
|
async function appExists(customPath: string) {
|
|
return await AppService.customPathExists({
|
|
workspace: $workspaceStore!,
|
|
customPath
|
|
})
|
|
}
|
|
let validateTimeout: NodeJS.Timeout | undefined = undefined
|
|
async function validateCustomPath(customPath: string): Promise<void> {
|
|
customPathError = ''
|
|
if (validateTimeout) {
|
|
clearTimeout(validateTimeout)
|
|
}
|
|
validateTimeout = setTimeout(async () => {
|
|
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
|
|
customPathError = 'Invalid path'
|
|
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
|
|
customPathError = 'Path already taken'
|
|
} else {
|
|
customPathError = ''
|
|
}
|
|
validateTimeout = undefined
|
|
}, 500)
|
|
}
|
|
$: customPath !== undefined && validateCustomPath(customPath)
|
|
</script>
|
|
|
|
<svelte:window on:keydown={onKeyDown} />
|
|
|
|
<TestJobLoader bind:this={testJobLoader} bind:isLoading={testIsLoading} bind:job />
|
|
<UnsavedConfirmationModal
|
|
{diffDrawer}
|
|
savedValue={savedApp}
|
|
modifiedValue={{
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
|
policy
|
|
}}
|
|
additionalExitAction={() => {
|
|
setTheme(priorDarkMode)
|
|
}}
|
|
/>
|
|
|
|
<DeployOverrideConfirmationModal
|
|
bind:deployedBy
|
|
bind:confirmCallback
|
|
bind:open
|
|
{diffDrawer}
|
|
bind:deployedValue
|
|
currentValue={{
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
|
policy
|
|
}}
|
|
/>
|
|
|
|
{#if $appPath == ''}
|
|
<Drawer bind:open={draftDrawerOpen} size="800px">
|
|
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
|
|
<Alert title="Require path" type="info">
|
|
Choose a path to save the initial draft of the app.
|
|
</Alert>
|
|
<h3>Summary</h3>
|
|
<div class="w-full pt-2">
|
|
<!-- svelte-ignore a11y-autofocus -->
|
|
<input
|
|
autofocus
|
|
type="text"
|
|
placeholder="App summary"
|
|
class="text-sm w-full font-semibold"
|
|
on:keydown|stopPropagation
|
|
bind:value={$summary}
|
|
on:keyup={() => {
|
|
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
|
path?.setName(
|
|
$summary
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_]/g, '_')
|
|
.replace(/-+/g, '_')
|
|
.replace(/^-|-$/g, '')
|
|
)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div class="py-2" />
|
|
<Path
|
|
autofocus={false}
|
|
bind:this={path}
|
|
bind:error={pathError}
|
|
bind:path={newEditedPath}
|
|
bind:dirty={dirtyPath}
|
|
initialPath=""
|
|
namePlaceholder="app"
|
|
kind="app"
|
|
/>
|
|
<div class="py-4" />
|
|
|
|
<div slot="actions">
|
|
<Button
|
|
startIcon={{ icon: Save }}
|
|
disabled={pathError != ''}
|
|
on:click={() => saveInitialDraft()}
|
|
>
|
|
Save initial draft
|
|
</Button>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
{/if}
|
|
<Drawer bind:open={saveDrawerOpen} size="800px">
|
|
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
|
|
{#if !onLatest}
|
|
<Alert title="You're not on the latest app version. " type="warning">
|
|
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
|
|
</Alert>
|
|
<div class="py-2" />
|
|
{/if}
|
|
<span class="text-secondary text-sm font-bold">Summary</span>
|
|
<div class="w-full pt-2">
|
|
<!-- svelte-ignore a11y-autofocus -->
|
|
<input
|
|
autofocus
|
|
type="text"
|
|
placeholder="App summary"
|
|
class="text-sm w-full"
|
|
bind:value={$summary}
|
|
on:keydown|stopPropagation
|
|
on:keyup={() => {
|
|
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
|
path?.setName(
|
|
$summary
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_]/g, '_')
|
|
.replace(/-+/g, '_')
|
|
.replace(/^-|-$/g, '')
|
|
)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div class="py-4" />
|
|
<span class="text-secondary text-sm font-bold">Deployment message</span>
|
|
<div class="w-full pt-2">
|
|
<!-- svelte-ignore a11y-autofocus -->
|
|
<input
|
|
type="text"
|
|
placeholder="Optional deployment message"
|
|
class="text-sm w-full"
|
|
bind:value={deploymentMsg}
|
|
/>
|
|
</div>
|
|
<div class="py-4" />
|
|
<span class="text-secondary text-sm font-bold">Path</span>
|
|
<Path
|
|
bind:this={path}
|
|
bind:dirty={dirtyPath}
|
|
bind:error={pathError}
|
|
bind:path={newEditedPath}
|
|
initialPath={newPath}
|
|
namePlaceholder="app"
|
|
kind="app"
|
|
autofocus={false}
|
|
/>
|
|
|
|
<div slot="actions" class="flex flex-row gap-4">
|
|
<Button
|
|
variant="border"
|
|
color="light"
|
|
disabled={!savedApp || savedApp.draft_only}
|
|
on:click={async () => {
|
|
if (!savedApp) {
|
|
return
|
|
}
|
|
// deployedValue should be syncronized when we open Diff
|
|
await syncWithDeployed()
|
|
|
|
saveDrawerOpen = false
|
|
diffDrawer?.openDrawer()
|
|
diffDrawer?.setDiff({
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? savedApp,
|
|
draft: savedApp.draft,
|
|
current: {
|
|
summary: $summary,
|
|
value: $app,
|
|
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
|
policy
|
|
},
|
|
button: {
|
|
text: 'Looks good, deploy',
|
|
onClick: () => {
|
|
if ($appPath == '') {
|
|
createApp(newEditedPath)
|
|
} else {
|
|
handleUpdateApp(newEditedPath)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}}
|
|
>
|
|
<div class="flex flex-row gap-2 items-center">
|
|
<DiffIcon size={14} />
|
|
Diff
|
|
</div>
|
|
</Button>
|
|
<Button
|
|
startIcon={{ icon: Save }}
|
|
disabled={pathError != '' || customPathError != ''}
|
|
on:click={() => {
|
|
if ($appPath == '') {
|
|
createApp(newEditedPath)
|
|
} else {
|
|
handleUpdateApp(newEditedPath)
|
|
}
|
|
}}
|
|
>
|
|
Deploy
|
|
</Button>
|
|
</div>
|
|
<div class="py-2" />
|
|
{#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 you">
|
|
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
|
|
<Tooltip>
|
|
It ensures that all required resources/runnable visible for publisher but not for viewer
|
|
at time of creating the app would prevent the execution of the app. To guarantee tight
|
|
security, a policy is computed at time of deployment 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.
|
|
</Tooltip>
|
|
</Alert>
|
|
|
|
<div class="mt-10" />
|
|
|
|
<h2>Public URL</h2>
|
|
<div class="mt-4" />
|
|
|
|
<div class="flex gap-2 items-center">
|
|
<Toggle
|
|
options={{
|
|
left: `Require login and read-access`,
|
|
right: `No login required`
|
|
}}
|
|
checked={policy.execution_mode == 'anonymous'}
|
|
on:change={(e) => {
|
|
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
|
setPublishState()
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="my-6 box">
|
|
<div class="text-secondary">
|
|
<div>Public URL</div>
|
|
</div>
|
|
{#if secretUrl}
|
|
{@const href = `${window.location.origin}${base}/public/${$workspaceStore}/${secretUrl}`}
|
|
<ClipboardPanel content={href} size="md" />
|
|
{:else}<Loader2 class="animate-spin" />
|
|
{/if}
|
|
<div class="text-xs text-secondary mt-1">
|
|
Share this url directly or embed it using an iframe (if requiring login, top-level domain
|
|
of embedding app must be the same as the one of Windmill)
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
{#if !$enterpriseLicense}
|
|
<Alert title="EE Only" type="warning" size="xs">
|
|
Custom path is an enterprise only feature.
|
|
</Alert>
|
|
<div class="mb-2" />
|
|
{:else if !($userStore?.is_admin || $userStore?.is_super_admin)}
|
|
<Alert type="warning" title="Admin only" size="xs">
|
|
Custom path can only be set by workspace admins
|
|
</Alert>
|
|
<div class="mb-2" />
|
|
{/if}
|
|
<Toggle
|
|
on:change={({ detail }) => {
|
|
customPath = detail ? '' : undefined
|
|
}}
|
|
checked={customPath !== undefined}
|
|
options={{
|
|
right: 'Use a custom URL'
|
|
}}
|
|
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
|
|
/>
|
|
|
|
{#if customPath !== undefined}
|
|
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
|
|
<div>Custom path</div>
|
|
</div>
|
|
<input
|
|
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
|
|
type="text"
|
|
autocomplete="off"
|
|
bind:value={customPath}
|
|
class={customPathError === ''
|
|
? ''
|
|
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
|
|
on:input={() => {
|
|
dirtyCustomPath = true
|
|
}}
|
|
/>
|
|
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
|
|
<div>Custom public URL</div>
|
|
</div>
|
|
<ClipboardPanel content={fullCustomUrl} size="md" />
|
|
|
|
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
|
|
>{dirtyCustomPath ? customPathError : ''}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<Alert type="info" title="Only latest deployed app is publicly available">
|
|
You will still need to deploy the app to make visible the latest changes
|
|
</Alert>
|
|
|
|
<a
|
|
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
|
|
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
|
|
>
|
|
{/if}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={inputsDrawerOpen} size="600px">
|
|
<DrawerContent title="App inputs configuration" on:close={() => (inputsDrawerOpen = false)}>
|
|
<AppInputs />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={historyBrowserDrawerOpen} size="1200px">
|
|
<DrawerContent title="Deployment History" on:close={() => (historyBrowserDrawerOpen = false)}>
|
|
<DeploymentHistory on:restore appPath={$appPath} />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={debugAppDrawerOpen} size="800px">
|
|
<DrawerContent title="Troubleshoot Panel" on:close={() => (debugAppDrawerOpen = false)}>
|
|
<DebugPanel />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={$jobsDrawerOpen} size="900px">
|
|
<DrawerContent
|
|
noPadding
|
|
title="Debug Runs"
|
|
on:close={() => {
|
|
$jobsDrawerOpen = false
|
|
}}
|
|
tooltip="Look at latests runs to spot potential bugs."
|
|
documentationLink="https://www.windmill.dev/docs/apps/app_debugging"
|
|
>
|
|
<Splitpanes class="!overflow-visible">
|
|
<Pane size={25}>
|
|
<PanelSection title="Past Runs">
|
|
<div class="flex flex-col gap-2 w-full">
|
|
{#if $jobs.length > 0}
|
|
<div class="flex gap-2 flex-col-reverse">
|
|
{#each $jobs ?? [] as id}
|
|
{@const selectedJob = $jobsById[id]}
|
|
{#if selectedJob}
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<div
|
|
class={classNames(
|
|
'border flex gap-1 truncate justify-between flex-row w-full items-center p-2 rounded-md cursor-pointer hover:bg-surface-secondary hover:text-blue-400',
|
|
selectedJob.error ? 'border border-red-500 text-primary' : '',
|
|
selectedJob.error && $errorByComponent[selectedJob.component]?.id == id
|
|
? selectedJobId == id
|
|
? 'bg-red-600 !border-blue-600'
|
|
: 'bg-red-400'
|
|
: selectedJobId == id
|
|
? 'text-blue-600'
|
|
: ''
|
|
)}
|
|
on:click={() => {
|
|
selectedJobId = id
|
|
rightColumnSelect = 'detail'
|
|
}}
|
|
>
|
|
<span class="text-xs truncate">{truncateRev(selectedJob.job, 20)}</span>
|
|
<Badge color="indigo">{selectedJob.component}</Badge>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="text-sm text-tertiary">No items</div>
|
|
{/if}
|
|
</div>
|
|
</PanelSection>
|
|
</Pane>
|
|
<Pane size={75}>
|
|
<div class="w-full h-full flex flex-col">
|
|
<div>
|
|
<Tabs bind:selected={rightColumnSelect}>
|
|
<Tab value="timeline"><span class="font-semibold text-md">Timeline</span></Tab>
|
|
<Tab value="detail"><span class="font-semibold">Details</span></Tab>
|
|
</Tabs>
|
|
</div>
|
|
{#if rightColumnSelect == 'timeline'}
|
|
<div class="p-2 grow overflow-auto">
|
|
<AppTimeline />
|
|
</div>
|
|
{:else if rightColumnSelect == 'detail'}
|
|
<div class="grow flex flex-col w-full overflow-auto">
|
|
{#if selectedJobId}
|
|
{#if selectedJobId?.includes('Frontend')}
|
|
{@const jobResult = $jobsById[selectedJobId]}
|
|
{#if jobResult?.error !== undefined}
|
|
<Splitpanes horizontal class="grow border w-full">
|
|
<Pane size={10} minSize={10}>
|
|
<LogViewer
|
|
content={`Logs are avaiable in the browser console directly`}
|
|
isLoading={false}
|
|
tag={undefined}
|
|
/>
|
|
</Pane>
|
|
<Pane size={90} minSize={10} class="text-sm text-secondary">
|
|
<div class="relative h-full px-2">
|
|
<DisplayResult
|
|
result={{
|
|
error: { name: 'Frontend execution error', message: jobResult.error }
|
|
}}
|
|
/>
|
|
</div>
|
|
</Pane>
|
|
</Splitpanes>
|
|
{:else if jobResult !== undefined}
|
|
<Splitpanes horizontal class="grow border w-full">
|
|
<Pane size={10} minSize={10}>
|
|
<LogViewer
|
|
content={`Logs are avaiable in the browser console directly`}
|
|
isLoading={false}
|
|
tag={undefined}
|
|
/>
|
|
</Pane>
|
|
<Pane size={90} minSize={10} class="text-sm text-secondary">
|
|
<div class="relative h-full px-2">
|
|
<DisplayResult
|
|
workspaceId={$workspaceStore}
|
|
jobId={selectedJobId}
|
|
result={jobResult.result}
|
|
/>
|
|
</div>
|
|
</Pane>
|
|
</Splitpanes>
|
|
{:else}
|
|
<Loader2 class="animate-spin" />
|
|
{/if}
|
|
{:else}
|
|
<div class="flex flex-col h-full w-full mb-4">
|
|
{#if job?.['running']}
|
|
<div class="flex flex-row-reverse w-full">
|
|
<Button
|
|
color="red"
|
|
variant="border"
|
|
on:click={() => testJobLoader?.cancelJob()}
|
|
>
|
|
<Loader2 size={14} class="animate-spin mr-2" />
|
|
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{#if job?.args}
|
|
<div class="p-2">
|
|
<JobArgs
|
|
id={job.id}
|
|
workspace={job.workspace_id ?? $workspaceStore ?? 'no_w'}
|
|
args={job?.args}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if job?.raw_code}
|
|
<div class="pb-2 pl-2 pr-2 w-full overflow-auto h-full max-h-[80px]">
|
|
<HighlightCode language={job?.language} code={job?.raw_code} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if job?.job_kind !== 'flow' && job?.job_kind !== 'flowpreview'}
|
|
{@const jobResult = $jobsById[selectedJobId]}
|
|
<Splitpanes horizontal class="grow border w-full">
|
|
<Pane size={50} minSize={10}>
|
|
<LogViewer
|
|
duration={job?.['duration_ms']}
|
|
jobId={job?.id}
|
|
content={job?.logs}
|
|
isLoading={testIsLoading && job?.['running'] == false}
|
|
tag={job?.tag}
|
|
/>
|
|
</Pane>
|
|
<Pane size={50} minSize={10} class="text-sm text-secondary">
|
|
{#if job != undefined && 'result' in job && job.result != undefined}<div
|
|
class="relative h-full px-2"
|
|
><DisplayResult
|
|
workspaceId={$workspaceStore}
|
|
jobId={selectedJobId}
|
|
result={job.result}
|
|
/></div
|
|
>
|
|
{:else if testIsLoading}
|
|
<div class="p-2"><Loader2 class="animate-spin" /> </div>
|
|
{:else if job != undefined && 'result' in job && job?.['result'] == undefined}
|
|
<div class="p-2 text-tertiary">Result is undefined</div>
|
|
{:else}
|
|
<div class="p-2 text-tertiary">
|
|
<Loader2 size={14} class="animate-spin mr-2" />
|
|
</div>
|
|
{/if}
|
|
</Pane>
|
|
{#if jobResult?.transformer}
|
|
<Pane size={50} minSize={10} class="text-sm text-secondary p-2">
|
|
<div class="font-bold">Transformer results</div>
|
|
{#if job != undefined && 'result' in job && job.result != undefined}
|
|
<div class="relative h-full px-2">
|
|
<DisplayResult
|
|
workspaceId={$workspaceStore}
|
|
jobId={selectedJobId}
|
|
result={jobResult?.transformer}
|
|
/>
|
|
</div>
|
|
{:else if testIsLoading}
|
|
<div class="p-2"><Loader2 class="animate-spin" /> </div>
|
|
{:else if job != undefined && 'result' in job && job?.['result'] == undefined}
|
|
<div class="p-2 text-tertiary">Result is undefined</div>
|
|
{:else}
|
|
<div class="p-2 text-tertiary">
|
|
<Loader2 size={14} class="animate-spin mr-2" />
|
|
</div>
|
|
{/if}
|
|
</Pane>
|
|
{/if}
|
|
</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-tertiary">Select a job to see its details</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Pane>
|
|
</Splitpanes>
|
|
<svelte:fragment slot="actions">
|
|
<Button
|
|
size="md"
|
|
color="light"
|
|
variant="border"
|
|
on:click={() => {
|
|
$refreshComponents?.()
|
|
}}
|
|
title="Refresh App"
|
|
>
|
|
Refresh app <RefreshCw size={16} />
|
|
</Button>
|
|
|
|
<Button
|
|
size="md"
|
|
color="light"
|
|
variant="border"
|
|
on:click={() => {
|
|
errorByComponent.set({})
|
|
jobs.set([])
|
|
}}
|
|
>Clear jobs
|
|
</Button>
|
|
{#if hasErrors}
|
|
<Button size="md" color="light" variant="border" on:click={() => errorByComponent.set({})}
|
|
>Clear Errors <BellOff size={14} />
|
|
</Button>
|
|
{/if}
|
|
</svelte:fragment>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<AppReportsDrawer bind:open={appReportingDrawerOpen} appPath={$appPath ?? ''} />
|
|
|
|
<div
|
|
class="border-b flex flex-row justify-between py-1 gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto"
|
|
>
|
|
<div class="flex flex-row gap-2 items-center">
|
|
<Summary bind:value={$summary} />
|
|
<div class="flex gap-2">
|
|
<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)
|
|
}}
|
|
/>
|
|
|
|
{#if $app}
|
|
<ToggleButtonGroup class="h-[30px]" bind:selected={$app.fullscreen}>
|
|
<ToggleButton
|
|
icon={AlignHorizontalSpaceAround}
|
|
value={false}
|
|
tooltip="The max width is 1168px and the content stay centered instead of taking the full page width"
|
|
iconProps={{ size: 16 }}
|
|
/>
|
|
<ToggleButton
|
|
tooltip="The width is of the app if the full width of its container"
|
|
icon={Expand}
|
|
value={true}
|
|
iconProps={{ size: 16 }}
|
|
/>
|
|
</ToggleButtonGroup>
|
|
{/if}
|
|
{#if $app}
|
|
<ToggleButtonGroup
|
|
class="h-[30px]"
|
|
on:selected={(e) => {
|
|
setTheme(e.detail)
|
|
}}
|
|
bind:selected={$app.darkMode}
|
|
>
|
|
<ToggleButton
|
|
icon={SunMoon}
|
|
value={undefined}
|
|
tooltip="The app mode between dark/light is automatic"
|
|
iconProps={{ size: 16 }}
|
|
/>
|
|
<ToggleButton
|
|
icon={Sun}
|
|
value={false}
|
|
tooltip="Force light mode"
|
|
iconProps={{ size: 16 }}
|
|
/>
|
|
<ToggleButton
|
|
tooltip="Force dark mode"
|
|
icon={Moon}
|
|
value={true}
|
|
iconProps={{ size: 16 }}
|
|
/>
|
|
</ToggleButtonGroup>
|
|
{/if}
|
|
<div class="flex flex-row gap-2">
|
|
<ToggleButtonGroup class="h-[30px]" bind:selected={$breakpoint}>
|
|
<ToggleButton
|
|
tooltip="Computer View"
|
|
icon={Laptop2}
|
|
value={'lg'}
|
|
iconProps={{ size: 16 }}
|
|
/>
|
|
<ToggleButton
|
|
tooltip="Mobile View"
|
|
icon={Smartphone}
|
|
value={'sm'}
|
|
iconProps={{ size: 16 }}
|
|
/>
|
|
{#if $breakpoint === 'sm'}
|
|
<ToggleEnable
|
|
tooltip="Desktop view is enabled by default. Enable this to customize the layout of the components for the mobile view"
|
|
label="Enable mobile view for smaller screens"
|
|
bind:checked={$app.mobileViewOnSmallerScreens}
|
|
iconProps={{ size: 16 }}
|
|
iconOnly={false}
|
|
/>
|
|
{/if}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if $mode !== 'preview'}
|
|
<div class="flex gap-1">
|
|
<HideButton
|
|
direction="left"
|
|
hidden={leftPanelHidden}
|
|
on:click={() => {
|
|
if (leftPanelHidden) {
|
|
dispatch('showLeftPanel')
|
|
} else {
|
|
dispatch('hideLeftPanel')
|
|
}
|
|
}}
|
|
/>
|
|
<HideButton
|
|
hidden={bottomPanelHidden}
|
|
direction="bottom"
|
|
on:click={() => {
|
|
if (bottomPanelHidden) {
|
|
dispatch('showBottomPanel')
|
|
} else {
|
|
dispatch('hideBottomPanel')
|
|
}
|
|
}}
|
|
/>
|
|
<HideButton
|
|
hidden={rightPanelHidden}
|
|
direction="right"
|
|
on:click={() => {
|
|
if (rightPanelHidden) {
|
|
dispatch('showRightPanel')
|
|
} else {
|
|
dispatch('hideRightPanel')
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if $enterpriseLicense && $appPath != ''}
|
|
<Awareness />
|
|
{/if}
|
|
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
|
|
<ButtonDropdown hasPadding={false}>
|
|
<svelte:fragment slot="buttonReplacement">
|
|
<Button nonCaptureEvent size="xs" color="light">
|
|
<div class="flex flex-row items-center">
|
|
<MoreVertical size={14} />
|
|
</div>
|
|
</Button>
|
|
</svelte:fragment>
|
|
<svelte:fragment slot="items">
|
|
{#each moreItems as item}
|
|
<MenuItem
|
|
on:click={item.action}
|
|
disabled={item.disabled}
|
|
class={item.disabled ? 'opacity-50' : ''}
|
|
>
|
|
<div
|
|
class={classNames(
|
|
'text-primary flex flex-row items-center text-left px-4 py-2 gap-2 cursor-pointer hover:bg-surface-hover !text-xs font-semibold'
|
|
)}
|
|
>
|
|
<svelte:component this={item.icon} size={14} />
|
|
{item.displayName}
|
|
</div>
|
|
</MenuItem>
|
|
{/each}
|
|
</svelte:fragment>
|
|
</ButtonDropdown>
|
|
<AppEditorTutorial bind:this={appEditorTutorial} />
|
|
|
|
<div class="hidden md:inline relative overflow-visible">
|
|
{#if hasErrors}
|
|
<span
|
|
class="animate-ping absolute inline-flex rounded-full bg-red-600 h-2 w-2 z-50 -right-0.5 -top-0.5"
|
|
/>
|
|
<span
|
|
class=" absolute inline-flex rounded-full bg-red-600 h-2 w-2 z-50 -right-0.5 -top-0.5"
|
|
/>
|
|
{/if}
|
|
<Button
|
|
on:click={() => {
|
|
if (selectedJobId == undefined && $jobs.length > 0) {
|
|
selectedJobId = $jobs[$jobs.length - 1]
|
|
}
|
|
$jobsDrawerOpen = true
|
|
}}
|
|
color={hasErrors ? 'red' : 'light'}
|
|
size="xs"
|
|
variant="border"
|
|
btnClasses="relative"
|
|
>
|
|
<div class="flex flex-row gap-1 items-center">
|
|
<Bug size={14} />
|
|
<div>Debug runs</div>
|
|
<div class="text-2xs text-tertiary"
|
|
>({$jobs?.length > 99 ? '99+' : $jobs?.length ?? 0})</div
|
|
>
|
|
{#if hasErrors}
|
|
<Button
|
|
size="xs"
|
|
btnClasses="-my-2 !px-1 !py-0"
|
|
color="light"
|
|
variant="border"
|
|
on:click={() => errorByComponent.set({})}
|
|
><BellOff size={12} />
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
<AppExportButton bind:this={appExport} />
|
|
<PreviewToggle loading={loading.save} />
|
|
<Button
|
|
loading={loading.save}
|
|
startIcon={{ icon: Save }}
|
|
on:click={() => saveDraft()}
|
|
size="xs"
|
|
disabled={!newApp && !savedApp}
|
|
shortCut={{ key: 'S' }}
|
|
>
|
|
Draft
|
|
</Button>
|
|
<Button
|
|
loading={loading.save}
|
|
startIcon={{ icon: Save }}
|
|
on:click={save}
|
|
size="xs"
|
|
dropdownItems={$appPath != ''
|
|
? () => [
|
|
{
|
|
label: 'Fork',
|
|
onClick: () => {
|
|
window.open(`/apps/add?template=${appPath}`)
|
|
}
|
|
}
|
|
]
|
|
: undefined}
|
|
>
|
|
Deploy
|
|
</Button>
|
|
</div>
|
|
</div>
|