mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
@@ -180,11 +180,15 @@
|
||||
|
||||
function handleStorageEvent(event) {
|
||||
if (event.key === 'oauth-callback') {
|
||||
processPopupData(event.newValue)
|
||||
console.log('OAuth from storage', event.newValue)
|
||||
// Clean up
|
||||
localStorage.removeItem('oauth-callback')
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
try {
|
||||
processPopupData(JSON.parse(event.newValue))
|
||||
console.log('OAuth from storage', event.newValue)
|
||||
// Clean up
|
||||
localStorage.removeItem('oauth-callback')
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
} catch (e) {
|
||||
console.error('Error processing oauth-callback', e)
|
||||
}
|
||||
} else {
|
||||
console.log('Storage event', event.key)
|
||||
}
|
||||
|
||||
@@ -54,10 +54,7 @@
|
||||
defaultModified !== undefined &&
|
||||
defaultLang !== undefined
|
||||
) {
|
||||
console.log('SETUP')
|
||||
setupModel(defaultLang, defaultOriginal, defaultModified, defaultModifiedLang)
|
||||
} else {
|
||||
console.log('NO SETUP', defaultOriginal, defaultModified, defaultLang)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +77,6 @@
|
||||
}
|
||||
|
||||
export function setOriginal(code: string) {
|
||||
console.log('setOriginal', code)
|
||||
diffEditor?.getModel()?.original?.setValue(code)
|
||||
defaultOriginal = code
|
||||
}
|
||||
@@ -99,7 +95,6 @@
|
||||
}
|
||||
|
||||
export function show(): void {
|
||||
console.log('show')
|
||||
open = true
|
||||
}
|
||||
export function hide(): void {
|
||||
|
||||
@@ -199,11 +199,15 @@
|
||||
|
||||
function handleStorageEvent(event) {
|
||||
if (event.key === 'oauth-success') {
|
||||
processPopupData(event.newValue)
|
||||
console.log('oauth-success from storage')
|
||||
// Clean up
|
||||
localStorage.removeItem('oauth-success')
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
try {
|
||||
processPopupData(JSON.parse(event.newValue))
|
||||
console.log('oauth-success from storage')
|
||||
// Clean up
|
||||
localStorage.removeItem('oauth-success')
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
} catch (e) {
|
||||
console.error('Could not process oauth-success from storage', e)
|
||||
}
|
||||
} else {
|
||||
console.log('Storage event', event.key)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
bind:this={schemaForm}
|
||||
displayType={Boolean(resolvedConfig.displayType)}
|
||||
largeGap={Boolean(resolvedConfig.largeGap)}
|
||||
appPath={defaultIfEmptyString(appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
appPath={defaultIfEmptyString($appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
{computeS3ForceViewerPolicies}
|
||||
{workspace}
|
||||
{css}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
$: !initialized && resolvedPath && initSelection()
|
||||
|
||||
function getButtonProps(resolvedPath: string | undefined) {
|
||||
if (appPath && resolvedPath?.includes(appPath)) {
|
||||
if ($appPath && resolvedPath?.includes($appPath)) {
|
||||
return {
|
||||
onClick: () => {
|
||||
output.result.set({ currentPath: resolvedPath ?? '' })
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
|
||||
const uuid = await AppService.executeComponent({
|
||||
workspace,
|
||||
path: defaultIfEmptyString(appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`),
|
||||
path: defaultIfEmptyString($appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`),
|
||||
requestBody
|
||||
})
|
||||
if (isEditor) {
|
||||
@@ -763,7 +763,7 @@
|
||||
<div class="px-2 h-fit min-h-0">
|
||||
<LightweightSchemaForm
|
||||
schema={schemaStripped}
|
||||
appPath={defaultIfEmptyString(appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
appPath={defaultIfEmptyString($appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
{computeS3ForceViewerPolicies}
|
||||
{workspace}
|
||||
bind:this={schemaForm}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
outputs.result.set(value)
|
||||
}}
|
||||
{forceDisplayUploads}
|
||||
appPath={defaultIfEmptyString(appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
appPath={defaultIfEmptyString($appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
{computeForceViewerPolicies}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
import ComponentList from './componentsPanel/ComponentList.svelte'
|
||||
import ContextPanel from './contextPanel/ContextPanel.svelte'
|
||||
|
||||
import { page } from '$app/stores'
|
||||
import ItemPicker from '$lib/components/ItemPicker.svelte'
|
||||
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import { VariableService, type Job, type Policy } from '$lib/gen'
|
||||
@@ -51,7 +50,6 @@
|
||||
import StylePanel from './settingsPanel/StylePanel.svelte'
|
||||
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import RunnableJobPanel from './RunnableJobPanel.svelte'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import HideButton from './settingsPanel/HideButton.svelte'
|
||||
import AppEditorBottomPanel from './AppEditorBottomPanel.svelte'
|
||||
import panzoom from 'panzoom'
|
||||
@@ -73,6 +71,14 @@
|
||||
}
|
||||
| undefined = undefined
|
||||
export let version: number | undefined = undefined
|
||||
export let newApp: boolean = false
|
||||
export let newPath: string | undefined = undefined
|
||||
export let replaceStateFn: (path: string) => void = (path: string) =>
|
||||
window.history.replaceState(null, '', path)
|
||||
export let gotoFn: (path: string, opt?: Record<string, any> | undefined) => void = (
|
||||
path: string,
|
||||
opt?: Record<string, any>
|
||||
) => window.history.pushState(null, '', path)
|
||||
|
||||
migrateApp(app)
|
||||
|
||||
@@ -118,8 +124,8 @@
|
||||
email: $userStore?.email,
|
||||
groups: $userStore?.groups,
|
||||
username: $userStore?.username,
|
||||
query: Object.fromEntries($page.url.searchParams.entries()),
|
||||
hash: $page.url.hash.substring(1),
|
||||
query: Object.fromEntries(new URL(window.location.href).searchParams.entries()),
|
||||
hash: window.location.hash.substring(1),
|
||||
workspace: $workspaceStore,
|
||||
mode: 'editor',
|
||||
summary: $summaryStore,
|
||||
@@ -135,6 +141,13 @@
|
||||
$secondaryMenuRightStore.isOpen = false
|
||||
$secondaryMenuLeftStore.isOpen = false
|
||||
|
||||
let writablePath = writable(path)
|
||||
$: path && onPathChange()
|
||||
|
||||
function onPathChange() {
|
||||
writablePath.set(path)
|
||||
}
|
||||
|
||||
setContext<AppViewerContext>('AppViewerContext', {
|
||||
worldStore,
|
||||
app: appStore,
|
||||
@@ -146,7 +159,7 @@
|
||||
bgRuns: writable([]),
|
||||
breakpoint,
|
||||
runnableComponents: writable({}),
|
||||
appPath: path,
|
||||
appPath: writablePath,
|
||||
workspace: $workspaceStore ?? '',
|
||||
onchange: () => saveFrontendDraft(),
|
||||
isEditor: true,
|
||||
@@ -167,7 +180,7 @@
|
||||
cssEditorOpen,
|
||||
previewTheme,
|
||||
debuggingComponents: writable({}),
|
||||
replaceStateFn: (path) => replaceState(path, $page.state),
|
||||
replaceStateFn: replaceStateFn,
|
||||
policy: policy,
|
||||
recomputeAllContext: writable({
|
||||
loading: false,
|
||||
@@ -814,6 +827,8 @@
|
||||
{#if !$userStore?.operator}
|
||||
{#if $appStore}
|
||||
<AppEditorHeader
|
||||
{newPath}
|
||||
{newApp}
|
||||
on:restore
|
||||
{policy}
|
||||
{fromHub}
|
||||
@@ -824,6 +839,7 @@
|
||||
leftPanelHidden={leftPanelSize === 0}
|
||||
rightPanelHidden={rightPanelSize === 0}
|
||||
bottomPanelHidden={runnablePanelSize === 0}
|
||||
on:savedNewAppPath
|
||||
on:showLeftPanel={() => showLeftPanel()}
|
||||
on:showRightPanel={() => showRightPanel()}
|
||||
on:hideLeftPanel={() => hideLeftPanel()}
|
||||
@@ -851,8 +867,8 @@
|
||||
isEditor
|
||||
{context}
|
||||
noBackend={false}
|
||||
replaceStateFn={(path) => replaceState(path, $page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
{replaceStateFn}
|
||||
{gotoFn}
|
||||
/>
|
||||
</div>
|
||||
</SplitPanesWrapper>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
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'
|
||||
@@ -127,7 +125,10 @@
|
||||
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
|
||||
@@ -163,7 +164,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let newPath: string = ''
|
||||
let pathError: string | undefined = undefined
|
||||
let appExport: AppExportButton
|
||||
|
||||
@@ -253,11 +253,7 @@
|
||||
input: getCountInput(resourceValue, tableValue, dbType, columnDefs, whereClause),
|
||||
id: x.id + '_count'
|
||||
})
|
||||
console.log(
|
||||
x.id,
|
||||
getCountInput(resourceValue, tableValue, dbType, columnDefs, whereClause),
|
||||
columnDefs
|
||||
)
|
||||
|
||||
r.push({
|
||||
input: getInsertInput(tableValue, columnDefs, resourceValue, dbType),
|
||||
id: x.id + '_insert'
|
||||
@@ -386,7 +382,7 @@
|
||||
async function createApp(path: string) {
|
||||
await computeTriggerables()
|
||||
try {
|
||||
const appId = await AppService.createApp({
|
||||
await AppService.createApp({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
value: $app,
|
||||
@@ -409,7 +405,7 @@
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
goto(`/apps/edit/${appId}`)
|
||||
dispatch('savedNewAppPath', path)
|
||||
} catch (e) {
|
||||
sendUserToast('Error creating app', e)
|
||||
}
|
||||
@@ -436,7 +432,7 @@
|
||||
replaceFalseWithUndefined({
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
policy
|
||||
})
|
||||
)
|
||||
@@ -456,7 +452,7 @@
|
||||
async function syncWithDeployed() {
|
||||
const deployedApp = await AppService.getAppByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath,
|
||||
path: $appPath!,
|
||||
withStarredInfo: true
|
||||
})
|
||||
|
||||
@@ -477,7 +473,7 @@
|
||||
await computeTriggerables()
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath,
|
||||
path: $appPath!,
|
||||
requestBody: {
|
||||
value: $app!,
|
||||
summary: $summary,
|
||||
@@ -500,24 +496,24 @@
|
||||
|
||||
closeSaveDrawer()
|
||||
sendUserToast('App deployed successfully')
|
||||
if (appPath !== npath) {
|
||||
if ($appPath !== npath) {
|
||||
try {
|
||||
localStorage.removeItem(`app-${appPath}`)
|
||||
} catch (e) {
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
window.location.pathname = `/apps/edit/${npath}?nodraft=true`
|
||||
dispatch('savedNewAppPath', npath)
|
||||
}
|
||||
}
|
||||
|
||||
let secretUrl: string | undefined = undefined
|
||||
|
||||
$: appPath != '' && secretUrl == undefined && getSecretUrl()
|
||||
$: $appPath && $appPath != '' && secretUrl == undefined && getSecretUrl()
|
||||
|
||||
async function getSecretUrl() {
|
||||
secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath
|
||||
path: $appPath
|
||||
})
|
||||
}
|
||||
|
||||
@@ -525,7 +521,7 @@
|
||||
await computeTriggerables()
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath,
|
||||
path: $appPath,
|
||||
requestBody: { policy }
|
||||
})
|
||||
if (policy.execution_mode == 'anonymous') {
|
||||
@@ -550,7 +546,7 @@
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
value: $app,
|
||||
path: newPath,
|
||||
path: newEditedPath,
|
||||
summary: $summary,
|
||||
policy,
|
||||
draft_only: true
|
||||
@@ -559,11 +555,11 @@
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: newPath,
|
||||
path: newEditedPath,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: $app,
|
||||
path: newPath,
|
||||
path: newEditedPath,
|
||||
summary: $summary,
|
||||
policy
|
||||
}
|
||||
@@ -572,19 +568,19 @@
|
||||
savedApp = {
|
||||
summary: $summary,
|
||||
value: structuredClone($app),
|
||||
path: newPath,
|
||||
path: newEditedPath,
|
||||
policy,
|
||||
draft_only: true,
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($app),
|
||||
path: newPath,
|
||||
path: newEditedPath,
|
||||
policy
|
||||
}
|
||||
}
|
||||
|
||||
draftDrawerOpen = false
|
||||
goto(`/apps/edit/${newPath}`)
|
||||
dispatch('savedNewAppPath', newEditedPath)
|
||||
} catch (e) {
|
||||
sendUserToast('Error saving initial draft', e)
|
||||
}
|
||||
@@ -592,7 +588,7 @@
|
||||
}
|
||||
|
||||
async function saveDraft(forceSave = false) {
|
||||
if ($page.params.path == undefined) {
|
||||
if (newApp) {
|
||||
// initial draft
|
||||
draftDrawerOpen = true
|
||||
return
|
||||
@@ -604,7 +600,7 @@
|
||||
const current = cleanValueProperties({
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
policy
|
||||
})
|
||||
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
||||
@@ -621,7 +617,7 @@
|
||||
loading.saveDraft = true
|
||||
try {
|
||||
await computeTriggerables()
|
||||
let path = $page.params.path
|
||||
let path = $appPath
|
||||
if (savedApp.draft_only) {
|
||||
await AppService.deleteApp({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -633,7 +629,7 @@
|
||||
value: $app!,
|
||||
summary: $summary,
|
||||
policy,
|
||||
path: newPath || path,
|
||||
path: newEditedPath || path,
|
||||
draft_only: true
|
||||
}
|
||||
})
|
||||
@@ -641,13 +637,13 @@
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: savedApp.draft_only ? newPath || path : path,
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
typ: 'app',
|
||||
value: {
|
||||
value: $app!,
|
||||
summary: $summary,
|
||||
policy,
|
||||
path: newPath || path
|
||||
path: newEditedPath || path
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -657,14 +653,15 @@
|
||||
? {
|
||||
summary: $summary,
|
||||
value: structuredClone($app),
|
||||
path: savedApp.draft_only ? newPath || path : path,
|
||||
policy
|
||||
path: savedApp.draft_only ? newEditedPath || path : path,
|
||||
policy,
|
||||
draft_only: true
|
||||
}
|
||||
: savedApp),
|
||||
draft: {
|
||||
summary: $summary,
|
||||
value: structuredClone($app),
|
||||
path: newPath || path,
|
||||
path: newEditedPath || path,
|
||||
policy
|
||||
}
|
||||
}
|
||||
@@ -676,8 +673,8 @@
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
loading.saveDraft = false
|
||||
if (newPath || path !== path) {
|
||||
goto(`/apps/edit/${newPath || path}`)
|
||||
if (newApp || savedApp.draft_only) {
|
||||
dispatch('savedNewAppPath', newEditedPath || path)
|
||||
}
|
||||
} catch (e) {
|
||||
loading.saveDraft = false
|
||||
@@ -693,9 +690,9 @@
|
||||
try {
|
||||
const appVersion = await AppService.getAppLatestVersion({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath
|
||||
path: $appPath
|
||||
})
|
||||
onLatest = version === appVersion?.version
|
||||
onLatest = appVersion?.version === undefined || version === appVersion?.version
|
||||
} catch (e) {
|
||||
console.error('Error comparing versions', e)
|
||||
onLatest = true
|
||||
@@ -841,7 +838,7 @@
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
policy
|
||||
}
|
||||
})
|
||||
@@ -899,7 +896,7 @@
|
||||
modifiedValue={{
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newPath || savedApp?.draft?.path || savedApp?.path,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
policy
|
||||
}}
|
||||
additionalExitAction={() => {
|
||||
@@ -916,12 +913,12 @@
|
||||
currentValue={{
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newPath || savedApp?.draft?.path || savedApp?.path,
|
||||
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
|
||||
policy
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if appPath == ''}
|
||||
{#if $appPath == ''}
|
||||
<Drawer bind:open={draftDrawerOpen} size="800px">
|
||||
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
|
||||
<Alert title="Require path" type="info">
|
||||
@@ -938,7 +935,7 @@
|
||||
on:keydown|stopPropagation
|
||||
bind:value={$summary}
|
||||
on:keyup={() => {
|
||||
if (appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
||||
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
$summary
|
||||
.toLowerCase()
|
||||
@@ -955,7 +952,7 @@
|
||||
autofocus={false}
|
||||
bind:this={path}
|
||||
bind:error={pathError}
|
||||
bind:path={newPath}
|
||||
bind:path={newEditedPath}
|
||||
bind:dirty={dirtyPath}
|
||||
initialPath=""
|
||||
namePlaceholder="app"
|
||||
@@ -994,7 +991,7 @@
|
||||
bind:value={$summary}
|
||||
on:keydown|stopPropagation
|
||||
on:keyup={() => {
|
||||
if (appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
||||
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
$summary
|
||||
.toLowerCase()
|
||||
@@ -1023,8 +1020,8 @@
|
||||
bind:this={path}
|
||||
bind:dirty={dirtyPath}
|
||||
bind:error={pathError}
|
||||
bind:path={newPath}
|
||||
initialPath={appPath}
|
||||
bind:path={newEditedPath}
|
||||
initialPath={newPath}
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
autofocus={false}
|
||||
@@ -1051,16 +1048,16 @@
|
||||
current: {
|
||||
summary: $summary,
|
||||
value: $app,
|
||||
path: newPath || savedApp.draft?.path || savedApp.path,
|
||||
path: newEditedPath || savedApp.draft?.path || savedApp.path,
|
||||
policy
|
||||
},
|
||||
button: {
|
||||
text: 'Looks good, deploy',
|
||||
onClick: () => {
|
||||
if (appPath == '') {
|
||||
createApp(newPath)
|
||||
if ($appPath == '') {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newPath)
|
||||
handleUpdateApp(newEditedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1076,10 +1073,10 @@
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={pathError != ''}
|
||||
on:click={() => {
|
||||
if (appPath == '') {
|
||||
createApp(newPath)
|
||||
if ($appPath == '') {
|
||||
createApp(newEditedPath)
|
||||
} else {
|
||||
handleUpdateApp(newPath)
|
||||
handleUpdateApp(newEditedPath)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1087,7 +1084,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div class="py-2" />
|
||||
{#if appPath == ''}
|
||||
{#if $appPath == ''}
|
||||
<Alert title="Require saving" type="error">
|
||||
Save this app once before you can publish it
|
||||
</Alert>
|
||||
@@ -1126,8 +1123,8 @@
|
||||
<div class="my-6 box">
|
||||
Public url:
|
||||
{#if secretUrl}
|
||||
{@const url = `${$page.url.hostname}/public/${$workspaceStore}/${secretUrl}`}
|
||||
{@const href = $page.url.protocol + '//' + url}
|
||||
{@const url = `${window.location.hostname}/public/${$workspaceStore}/${secretUrl}`}
|
||||
{@const href = window.location.protocol + '//' + url}
|
||||
<a
|
||||
on:click={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -1168,7 +1165,7 @@
|
||||
|
||||
<Drawer bind:open={historyBrowserDrawerOpen} size="1200px">
|
||||
<DrawerContent title="Deployment History" on:close={() => (historyBrowserDrawerOpen = false)}>
|
||||
<DeploymentHistory on:restore {appPath} />
|
||||
<DeploymentHistory on:restore appPath={$appPath} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -1424,7 +1421,7 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AppReportsDrawer bind:open={appReportingDrawerOpen} {appPath} />
|
||||
<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"
|
||||
@@ -1552,7 +1549,7 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $enterpriseLicense && appPath != ''}
|
||||
{#if $enterpriseLicense && $appPath != ''}
|
||||
<Awareness />
|
||||
{/if}
|
||||
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
|
||||
@@ -1632,7 +1629,7 @@
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={() => saveDraft()}
|
||||
size="xs"
|
||||
disabled={$page.params.path !== undefined && !savedApp}
|
||||
disabled={!newApp && !savedApp}
|
||||
shortCut={{ key: 'S' }}
|
||||
>
|
||||
Draft
|
||||
@@ -1642,7 +1639,7 @@
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={save}
|
||||
size="xs"
|
||||
dropdownItems={appPath != ''
|
||||
dropdownItems={$appPath != ''
|
||||
? () => [
|
||||
{
|
||||
label: 'Fork',
|
||||
|
||||
@@ -118,6 +118,13 @@
|
||||
})
|
||||
}
|
||||
|
||||
let writablePath = writable(appPath)
|
||||
$: appPath && onPathChange()
|
||||
|
||||
function onPathChange() {
|
||||
writablePath.set(appPath)
|
||||
}
|
||||
|
||||
setContext<AppViewerContext>('AppViewerContext', {
|
||||
worldStore: worldStore,
|
||||
initialized: writable({ initialized: false, initializedComponents: [] }),
|
||||
@@ -129,7 +136,7 @@
|
||||
connectingInput,
|
||||
breakpoint,
|
||||
runnableComponents: writable({}),
|
||||
appPath,
|
||||
appPath: writablePath,
|
||||
workspace,
|
||||
onchange: undefined,
|
||||
isEditor,
|
||||
|
||||
@@ -1,672 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import CronInput from '$lib/components/CronInput.svelte'
|
||||
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
import {
|
||||
FlowService,
|
||||
JobService,
|
||||
ScheduleService,
|
||||
SettingService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import { emptyString, formatCron, sendUserToast, tryEvery } from '$lib/utils'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { RotateCw, Save } from 'lucide-svelte'
|
||||
import { CUSTOM_TAGS_SETTING, WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts'
|
||||
import { loadSchemaFromPath } from '$lib/infer'
|
||||
import { hubPaths } from '$lib/hub'
|
||||
import { Drawer } from '$lib/components/common'
|
||||
|
||||
import AppReportsDrawerInner from './AppReportsDrawerInner.svelte'
|
||||
export let appPath: string
|
||||
export let open = false
|
||||
|
||||
let appReportingEnabled = false
|
||||
let appReportingStartupDuration = 5
|
||||
let appReportingSchedule: {
|
||||
cron: string
|
||||
timezone: string
|
||||
} = {
|
||||
cron: '0 0 12 * *',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
let selectedTab: 'email' | 'slack' | 'discord' | 'custom' = $enterpriseLicense
|
||||
? 'slack'
|
||||
: 'custom'
|
||||
|
||||
let screenshotKind: 'pdf' | 'png' = 'pdf'
|
||||
|
||||
let customPath: string | undefined = undefined
|
||||
let customPathSchema: Record<string, any> = {}
|
||||
let args: Record<string, any> = {}
|
||||
let areArgsValid = true
|
||||
|
||||
$: customPath
|
||||
? loadSchemaFromPath(customPath).then((schema) => {
|
||||
customPathSchema = schema
|
||||
? {
|
||||
...schema,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(schema.properties ?? {}).filter(
|
||||
([key, _]) => key !== 'screenshot' && key !== 'app_path' && key !== 'kind'
|
||||
)
|
||||
)
|
||||
}
|
||||
: {}
|
||||
})
|
||||
: (customPathSchema = {})
|
||||
|
||||
let isSlackConnectedWorkspace = false
|
||||
async function getWorspaceSlackSetting() {
|
||||
const settings = await WorkspaceService.getSettings({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (settings.slack_name) {
|
||||
isSlackConnectedWorkspace = true
|
||||
} else {
|
||||
isSlackConnectedWorkspace = false
|
||||
}
|
||||
}
|
||||
getWorspaceSlackSetting()
|
||||
|
||||
async function getAppReportingInfo() {
|
||||
const flowPath = appPath + '_reports'
|
||||
try {
|
||||
const flow = await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
|
||||
const schedule = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
|
||||
appReportingSchedule = {
|
||||
cron: schedule.schedule,
|
||||
timezone: schedule.timezone
|
||||
}
|
||||
appReportingStartupDuration =
|
||||
(schedule.args?.startup_duration as number) ?? appReportingStartupDuration
|
||||
screenshotKind = (schedule.args?.kind as 'png' | 'pdf') ?? screenshotKind
|
||||
|
||||
args = schedule.args
|
||||
? Object.fromEntries(
|
||||
Object.entries(schedule.args).filter(
|
||||
([key, _]) => key !== 'app_path' && key !== 'startup_duration' && key !== 'kind'
|
||||
)
|
||||
)
|
||||
: {}
|
||||
|
||||
selectedTab =
|
||||
flow.value.modules[1]?.value.type === 'script'
|
||||
? flow.value.modules[1].value.path === notificationScripts.email.path
|
||||
? 'email'
|
||||
: flow.value.modules[1].value.path === notificationScripts.slack.path
|
||||
? 'slack'
|
||||
: flow.value.modules[1].value.path === notificationScripts.discord.path
|
||||
? 'discord'
|
||||
: 'custom'
|
||||
: 'custom'
|
||||
|
||||
customPath =
|
||||
selectedTab === 'custom' &&
|
||||
flow.value.modules[1]?.value.type === 'script' &&
|
||||
!flow.value.modules[1].value.path.startsWith('hub/')
|
||||
? flow.value.modules[1].value.path
|
||||
: undefined
|
||||
|
||||
appReportingEnabled = true
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
$: appPath && getAppReportingInfo()
|
||||
|
||||
async function disableAppReporting() {
|
||||
const flowPath = appPath + '_reports'
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
await FlowService.deleteFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
appReportingEnabled = false
|
||||
sendUserToast('App reporting disabled')
|
||||
}
|
||||
|
||||
const appPreviewScript = `import puppeteer from 'puppeteer-core';
|
||||
import dayjs from 'dayjs';
|
||||
export async function main(app_path: string, startup_duration = 5, kind: 'pdf' | 'png' = 'pdf') {
|
||||
let browser = null
|
||||
try {
|
||||
browser = await puppeteer.launch({ headless: true, executablePath: '/usr/bin/chromium', args: ['--no-sandbox',
|
||||
'--no-zygote',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setCookie({
|
||||
"name": "token",
|
||||
"value": Bun.env["WM_TOKEN"],
|
||||
"domain": Bun.env["BASE_URL"]?.replace(/https?:\\/\\//, '')
|
||||
})
|
||||
page
|
||||
.on('console', msg =>
|
||||
console.log(dayjs().format("HH:mm:ss") + " " + msg.type().substr(0, 3).toUpperCase() + " " + msg.text()))
|
||||
.on('pageerror', ({ msg }) => console.log(dayjs().format("HH:mm:ss") + " " + msg));
|
||||
await page.setViewport({ width: 1200, height: 2000 });
|
||||
await page.goto(Bun.env["BASE_URL"] + '/apps/get/' + app_path + '?workspace=' + Bun.env["WM_WORKSPACE"] + "&hideRefreshBar=true&hideEditBtn=true");
|
||||
await page.waitForSelector("#app-content", { timeout: 20000 })
|
||||
await new Promise((resolve, _) => {
|
||||
setTimeout(resolve, startup_duration * 1000)
|
||||
})
|
||||
await page.$eval("#sidebar", el => el.remove())
|
||||
await page.$eval("#content", el => el.classList.remove("md:pl-12"))
|
||||
await page.$$eval(".app-component-refresh-btn", els => els.forEach(el => el.remove()))
|
||||
await page.$$eval(".app-table-footer-btn", els => els.forEach(el => el.remove()))
|
||||
const elem = await page.$('#app-content');
|
||||
const { height } = await elem.boundingBox();
|
||||
await page.setViewport({ width: 1200, height });
|
||||
await new Promise((resolve, _) => {
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
const screenshot = kind === "pdf" ? await page.pdf({
|
||||
printBackground: true,
|
||||
width: 1200,
|
||||
height
|
||||
}) : await page.screenshot({
|
||||
fullPage: true,
|
||||
type: "png",
|
||||
captureBeyondViewport: false
|
||||
});
|
||||
await browser.close();
|
||||
return Buffer.from(screenshot).toString('base64');
|
||||
} catch (err) {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}`
|
||||
|
||||
const notificationScripts = {
|
||||
discord: {
|
||||
path: hubPaths.discordReport,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
discord_webhook: {
|
||||
type: 'object',
|
||||
format: 'resource-discord_webhook',
|
||||
properties: {},
|
||||
required: [],
|
||||
description: ''
|
||||
}
|
||||
},
|
||||
required: ['discord_webhook']
|
||||
}
|
||||
},
|
||||
slack: {
|
||||
path: hubPaths.slackReport, // if to be updated, also update it in in backend/windmill-queue/src/jobs.rs
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channel: {
|
||||
type: 'string',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
required: ['channel']
|
||||
}
|
||||
},
|
||||
email: {
|
||||
path: hubPaths.smtpReport,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
smtp: {
|
||||
type: 'object',
|
||||
format: 'resource-smtp',
|
||||
properties: {},
|
||||
required: [],
|
||||
description: ''
|
||||
},
|
||||
from_email: {
|
||||
type: 'string',
|
||||
default: ''
|
||||
},
|
||||
to_email: {
|
||||
type: 'string',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
required: ['smtp', 'from_email', 'to_email']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFlowArgs() {
|
||||
return {
|
||||
app_path: appPath,
|
||||
startup_duration: appReportingStartupDuration,
|
||||
kind: screenshotKind,
|
||||
...args,
|
||||
...(selectedTab === 'slack'
|
||||
? {
|
||||
slack: '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function getFlowValue() {
|
||||
const notifInputTransforms: {
|
||||
[key: string]: {
|
||||
expr: string
|
||||
type: 'javascript'
|
||||
}
|
||||
} = {
|
||||
app_path: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.app_path'
|
||||
},
|
||||
screenshot: {
|
||||
type: 'javascript',
|
||||
expr: 'results.a'
|
||||
},
|
||||
kind: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.kind'
|
||||
},
|
||||
...Object.fromEntries(
|
||||
Object.keys(args).map((key) => [
|
||||
key,
|
||||
{
|
||||
type: 'javascript',
|
||||
expr: `flow_input.${key}`
|
||||
}
|
||||
])
|
||||
),
|
||||
...(selectedTab === 'slack'
|
||||
? {
|
||||
slack: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.slack'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
const value = {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript' as const,
|
||||
tag: 'chromium',
|
||||
content: appPreviewScript,
|
||||
language: 'bun' as const,
|
||||
input_transforms: {
|
||||
app_path: {
|
||||
expr: 'flow_input.app_path',
|
||||
type: 'javascript' as const
|
||||
},
|
||||
startup_duration: {
|
||||
expr: 'flow_input.startup_duration',
|
||||
type: 'javascript' as const
|
||||
},
|
||||
kind: {
|
||||
expr: 'flow_input.kind',
|
||||
type: 'javascript' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'script' as const,
|
||||
path:
|
||||
selectedTab === 'custom' ? customPath || '' : notificationScripts[selectedTab].path,
|
||||
input_transforms: notifInputTransforms
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
async function enableAppReporting() {
|
||||
const flowPath = appPath + '_reports'
|
||||
|
||||
try {
|
||||
// will only work if the user is super admin
|
||||
const customTags = ((await SettingService.getGlobal({
|
||||
key: CUSTOM_TAGS_SETTING
|
||||
})) ?? []) as string[]
|
||||
|
||||
if (!customTags.includes('chromium')) {
|
||||
await SettingService.setGlobal({
|
||||
key: CUSTOM_TAGS_SETTING,
|
||||
requestBody: {
|
||||
value: [...customTags, 'chromium']
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {}
|
||||
|
||||
await FlowService.deleteFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
|
||||
await FlowService.createFlow({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
summary: appPath + ' - Reports flow',
|
||||
value: getFlowValue(),
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
properties: {
|
||||
app_path: {
|
||||
description: '',
|
||||
type: 'string',
|
||||
default: null,
|
||||
format: ''
|
||||
},
|
||||
startup_duration: {
|
||||
description: '',
|
||||
type: 'integer',
|
||||
default: 5,
|
||||
format: ''
|
||||
},
|
||||
kind: {
|
||||
description: '',
|
||||
type: 'string',
|
||||
enum: ['pdf', 'png'],
|
||||
default: 'pdf',
|
||||
format: ''
|
||||
},
|
||||
...(selectedTab === 'custom'
|
||||
? customPathSchema.properties
|
||||
: notificationScripts[selectedTab].schema.properties),
|
||||
...(selectedTab === 'slack'
|
||||
? {
|
||||
slack: {
|
||||
description: '',
|
||||
type: 'object',
|
||||
format: 'resource-slack',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
required: [
|
||||
'app_path',
|
||||
'startup_duration',
|
||||
'kind',
|
||||
...(selectedTab === 'custom'
|
||||
? customPathSchema.required
|
||||
: notificationScripts[selectedTab].schema.required),
|
||||
...(selectedTab === 'slack' ? ['slack'] : [])
|
||||
],
|
||||
type: 'object'
|
||||
},
|
||||
path: flowPath
|
||||
}
|
||||
})
|
||||
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
|
||||
await ScheduleService.createSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: flowPath,
|
||||
schedule: formatCron(appReportingSchedule.cron),
|
||||
timezone: appReportingSchedule.timezone,
|
||||
script_path: flowPath,
|
||||
is_flow: true,
|
||||
args: getFlowArgs(),
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
appReportingEnabled = true
|
||||
}
|
||||
|
||||
let testLoading = false
|
||||
async function testReport() {
|
||||
try {
|
||||
testLoading = true
|
||||
const jobId = await JobService.runFlowPreview({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
args: getFlowArgs(),
|
||||
value: getFlowValue()
|
||||
}
|
||||
})
|
||||
tryEvery({
|
||||
tryCode: async () => {
|
||||
let testResult = await JobService.getCompletedJob({
|
||||
workspace: $workspaceStore!,
|
||||
id: jobId
|
||||
})
|
||||
testLoading = false
|
||||
sendUserToast(
|
||||
testResult.success
|
||||
? 'Report sent successfully'
|
||||
: 'Report error: ' + testResult.result?.['error']?.['message'],
|
||||
!testResult.success
|
||||
)
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
testLoading = false
|
||||
sendUserToast('Reports flow did not return after 30s', true)
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace: $workspaceStore!,
|
||||
id: jobId,
|
||||
requestBody: {
|
||||
reason: 'Reports flow did not return after 30s'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
interval: 500,
|
||||
timeout: 30000
|
||||
})
|
||||
} catch (err) {
|
||||
sendUserToast('Could not test reports flow: ' + err, true)
|
||||
testLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
let disabled = true
|
||||
$: disabled =
|
||||
emptyString(appReportingSchedule.cron) ||
|
||||
(selectedTab === 'custom' && emptyString(customPath)) ||
|
||||
(selectedTab === 'slack' && !isSlackConnectedWorkspace) ||
|
||||
!areArgsValid
|
||||
</script>
|
||||
|
||||
<Drawer bind:open size="800px">
|
||||
<DrawerContent
|
||||
on:close={() => (open = false)}
|
||||
title="Schedule Reports"
|
||||
tooltip="Send a PDF or PNG preview of any app at a given schedule"
|
||||
documentationLink="https://www.windmill.dev/docs/apps/schedule_reports"
|
||||
><svelte:fragment slot="actions">
|
||||
<div class="mr-4 center-center -mt-2">
|
||||
<Toggle
|
||||
checked={appReportingEnabled}
|
||||
options={{ right: 'enable', left: 'disable' }}
|
||||
on:change={async () => {
|
||||
if (appReportingEnabled) {
|
||||
disableAppReporting()
|
||||
} else {
|
||||
await enableAppReporting()
|
||||
sendUserToast('App reporting enabled')
|
||||
}
|
||||
}}
|
||||
disabled={disabled && !appReportingEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
color="dark"
|
||||
startIcon={{ icon: Save }}
|
||||
size="sm"
|
||||
on:click={async () => {
|
||||
await enableAppReporting()
|
||||
sendUserToast('App reporting updated')
|
||||
open = false
|
||||
}}
|
||||
{disabled}
|
||||
>
|
||||
{appReportingEnabled ? 'Update' : 'Save and enable'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
<div class="flex flex-col gap-8">
|
||||
<Alert type="info" title="Scheduled PDF/PNG reports"
|
||||
>Send a PDF or PNG preview of the app at a given schedule. Enabling this feature will create
|
||||
a flow and a schedule in your workspace.
|
||||
<br /><br />
|
||||
For the flow to be executed, you need to set the WORKER_GROUP environment variable of one of
|
||||
your workers to "reports" or add the tag "chromium" to one of your worker groups.
|
||||
</Alert>
|
||||
|
||||
<Section label="Reporting schedule">
|
||||
<CronInput
|
||||
bind:schedule={appReportingSchedule.cron}
|
||||
bind:timezone={appReportingSchedule.timezone}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
label="Startup duration in seconds"
|
||||
tooltip="The number of seconds to wait before capturing a preview to ensure that all startup scripts
|
||||
have been executed."
|
||||
>
|
||||
<div class="w-full pt-2">
|
||||
<input
|
||||
type="number"
|
||||
class="text-sm w-full font-semibold"
|
||||
bind:value={appReportingStartupDuration}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label="Screenshot kind">
|
||||
<div class="w-full pt-2">
|
||||
<select class="text-sm w-full font-semibold" bind:value={screenshotKind}>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="png">PNG</option>
|
||||
</select>
|
||||
</div></Section
|
||||
>
|
||||
|
||||
<Section label="Notification">
|
||||
<Tabs bind:selected={selectedTab}>
|
||||
{#if !$enterpriseLicense}
|
||||
<Tab value="custom">Custom</Tab>
|
||||
{/if}
|
||||
<Tab value="slack" disabled={!$enterpriseLicense}
|
||||
>Slack{!$enterpriseLicense ? ' (EE only)' : ''}</Tab
|
||||
>
|
||||
<Tab value="discord" disabled={!$enterpriseLicense}
|
||||
>Discord{!$enterpriseLicense ? ' (EE only)' : ''}</Tab
|
||||
>
|
||||
<Tab value="email" disabled={!$enterpriseLicense}>
|
||||
<div class="flex flex-row gap-1 items-center"
|
||||
>Email{!$enterpriseLicense ? ' (EE only)' : ''}
|
||||
</div>
|
||||
</Tab>
|
||||
{#if $enterpriseLicense}
|
||||
<Tab value="custom">Custom</Tab>
|
||||
{/if}
|
||||
</Tabs>
|
||||
{#if selectedTab === 'custom'}
|
||||
<div class="pt-2">
|
||||
<ScriptPicker
|
||||
on:select={(ev) => {
|
||||
customPath = ev.detail.path
|
||||
}}
|
||||
initialPath={customPath}
|
||||
allowRefresh
|
||||
/>
|
||||
</div>
|
||||
<div class="prose text-2xs text-tertiary mt-2">
|
||||
Pick a script that does whatever with the PDF/PNG report.
|
||||
|
||||
<br />
|
||||
|
||||
The script chosen is passed the parameters `screenshot: string`, `kind: 'pdf' | 'png'`,
|
||||
`app_path: string` where `screenshot` is the base64 encoded PDF/PNG report, `kind` is
|
||||
the type of the screenshot, and `app_path` is the path of the app being reported.
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedTab === 'slack'}
|
||||
<div class="pt-4">
|
||||
{#if isSlackConnectedWorkspace}
|
||||
<Alert type="info" title="Will use the Slack resource linked to the workspace" />
|
||||
{:else}
|
||||
<Alert type="error" title="Workspace not connected to Slack">
|
||||
<div class="flex flex-row gap-x-1 w-full items-center">
|
||||
<p class="text-clip grow min-w-0">
|
||||
The workspace needs to be connected to Slack to use this feature. You can <a
|
||||
target="_blank"
|
||||
href="{base}/workspace_settings?tab=slack">configure it here</a
|
||||
>.
|
||||
</p>
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={getWorspaceSlackSetting}
|
||||
startIcon={{ icon: RotateCw }}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full pt-4">
|
||||
{#if selectedTab !== 'custom' || customPath !== undefined}
|
||||
{#key selectedTab + JSON.stringify(customPathSchema ?? {})}
|
||||
<SchemaForm
|
||||
bind:isValid={areArgsValid}
|
||||
bind:args
|
||||
schema={selectedTab !== 'custom'
|
||||
? notificationScripts[selectedTab].schema
|
||||
: customPathSchema}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
loading={testLoading}
|
||||
{disabled}
|
||||
on:click={testReport}
|
||||
size="xs"
|
||||
color="dark"
|
||||
btnClasses="w-auto"
|
||||
>
|
||||
Send test report
|
||||
</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
<AppReportsDrawerInner bind:open {appPath} />
|
||||
</Drawer>
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import CronInput from '$lib/components/CronInput.svelte'
|
||||
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
import {
|
||||
FlowService,
|
||||
JobService,
|
||||
ScheduleService,
|
||||
SettingService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import { emptyString, formatCron, sendUserToast, tryEvery } from '$lib/utils'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { RotateCw, Save } from 'lucide-svelte'
|
||||
import { CUSTOM_TAGS_SETTING, WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts'
|
||||
import { loadSchemaFromPath } from '$lib/infer'
|
||||
import { hubPaths } from '$lib/hub'
|
||||
export let appPath: string
|
||||
export let open = false
|
||||
|
||||
let appReportingEnabled = false
|
||||
let appReportingStartupDuration = 5
|
||||
let appReportingSchedule: {
|
||||
cron: string
|
||||
timezone: string
|
||||
} = {
|
||||
cron: '0 0 12 * *',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
let selectedTab: 'email' | 'slack' | 'discord' | 'custom' = $enterpriseLicense
|
||||
? 'slack'
|
||||
: 'custom'
|
||||
|
||||
let screenshotKind: 'pdf' | 'png' = 'pdf'
|
||||
|
||||
let customPath: string | undefined = undefined
|
||||
let customPathSchema: Record<string, any> = {}
|
||||
let args: Record<string, any> = {}
|
||||
let areArgsValid = true
|
||||
|
||||
$: customPath
|
||||
? loadSchemaFromPath(customPath).then((schema) => {
|
||||
customPathSchema = schema
|
||||
? {
|
||||
...schema,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(schema.properties ?? {}).filter(
|
||||
([key, _]) => key !== 'screenshot' && key !== 'app_path' && key !== 'kind'
|
||||
)
|
||||
)
|
||||
}
|
||||
: {}
|
||||
})
|
||||
: (customPathSchema = {})
|
||||
|
||||
let isSlackConnectedWorkspace = false
|
||||
async function getWorspaceSlackSetting() {
|
||||
const settings = await WorkspaceService.getSettings({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (settings.slack_name) {
|
||||
isSlackConnectedWorkspace = true
|
||||
} else {
|
||||
isSlackConnectedWorkspace = false
|
||||
}
|
||||
}
|
||||
getWorspaceSlackSetting()
|
||||
|
||||
async function getAppReportingInfo() {
|
||||
const flowPath = appPath + '_reports'
|
||||
try {
|
||||
const flow = await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
|
||||
const schedule = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
|
||||
appReportingSchedule = {
|
||||
cron: schedule.schedule,
|
||||
timezone: schedule.timezone
|
||||
}
|
||||
appReportingStartupDuration =
|
||||
(schedule.args?.startup_duration as number) ?? appReportingStartupDuration
|
||||
screenshotKind = (schedule.args?.kind as 'png' | 'pdf') ?? screenshotKind
|
||||
|
||||
args = schedule.args
|
||||
? Object.fromEntries(
|
||||
Object.entries(schedule.args).filter(
|
||||
([key, _]) => key !== 'app_path' && key !== 'startup_duration' && key !== 'kind'
|
||||
)
|
||||
)
|
||||
: {}
|
||||
|
||||
selectedTab =
|
||||
flow.value.modules[1]?.value.type === 'script'
|
||||
? flow.value.modules[1].value.path === notificationScripts.email.path
|
||||
? 'email'
|
||||
: flow.value.modules[1].value.path === notificationScripts.slack.path
|
||||
? 'slack'
|
||||
: flow.value.modules[1].value.path === notificationScripts.discord.path
|
||||
? 'discord'
|
||||
: 'custom'
|
||||
: 'custom'
|
||||
|
||||
customPath =
|
||||
selectedTab === 'custom' &&
|
||||
flow.value.modules[1]?.value.type === 'script' &&
|
||||
!flow.value.modules[1].value.path.startsWith('hub/')
|
||||
? flow.value.modules[1].value.path
|
||||
: undefined
|
||||
|
||||
appReportingEnabled = true
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
$: appPath && getAppReportingInfo()
|
||||
|
||||
async function disableAppReporting() {
|
||||
const flowPath = appPath + '_reports'
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
await FlowService.deleteFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
appReportingEnabled = false
|
||||
sendUserToast('App reporting disabled')
|
||||
}
|
||||
|
||||
const appPreviewScript = `import puppeteer from 'puppeteer-core';
|
||||
import dayjs from 'dayjs';
|
||||
export async function main(app_path: string, startup_duration = 5, kind: 'pdf' | 'png' = 'pdf') {
|
||||
let browser = null
|
||||
try {
|
||||
browser = await puppeteer.launch({ headless: true, executablePath: '/usr/bin/chromium', args: ['--no-sandbox',
|
||||
'--no-zygote',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setCookie({
|
||||
"name": "token",
|
||||
"value": Bun.env["WM_TOKEN"],
|
||||
"domain": Bun.env["BASE_URL"]?.replace(/https?:\\/\\//, '')
|
||||
})
|
||||
page
|
||||
.on('console', msg =>
|
||||
console.log(dayjs().format("HH:mm:ss") + " " + msg.type().substr(0, 3).toUpperCase() + " " + msg.text()))
|
||||
.on('pageerror', ({ msg }) => console.log(dayjs().format("HH:mm:ss") + " " + msg));
|
||||
await page.setViewport({ width: 1200, height: 2000 });
|
||||
await page.goto(Bun.env["BASE_URL"] + '/apps/get/' + app_path + '?workspace=' + Bun.env["WM_WORKSPACE"] + "&hideRefreshBar=true&hideEditBtn=true");
|
||||
await page.waitForSelector("#app-content", { timeout: 20000 })
|
||||
await new Promise((resolve, _) => {
|
||||
setTimeout(resolve, startup_duration * 1000)
|
||||
})
|
||||
await page.$eval("#sidebar", el => el.remove())
|
||||
await page.$eval("#content", el => el.classList.remove("md:pl-12"))
|
||||
await page.$$eval(".app-component-refresh-btn", els => els.forEach(el => el.remove()))
|
||||
await page.$$eval(".app-table-footer-btn", els => els.forEach(el => el.remove()))
|
||||
const elem = await page.$('#app-content');
|
||||
const { height } = await elem.boundingBox();
|
||||
await page.setViewport({ width: 1200, height });
|
||||
await new Promise((resolve, _) => {
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
const screenshot = kind === "pdf" ? await page.pdf({
|
||||
printBackground: true,
|
||||
width: 1200,
|
||||
height
|
||||
}) : await page.screenshot({
|
||||
fullPage: true,
|
||||
type: "png",
|
||||
captureBeyondViewport: false
|
||||
});
|
||||
await browser.close();
|
||||
return Buffer.from(screenshot).toString('base64');
|
||||
} catch (err) {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}`
|
||||
|
||||
const notificationScripts = {
|
||||
discord: {
|
||||
path: hubPaths.discordReport,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
discord_webhook: {
|
||||
type: 'object',
|
||||
format: 'resource-discord_webhook',
|
||||
properties: {},
|
||||
required: [],
|
||||
description: ''
|
||||
}
|
||||
},
|
||||
required: ['discord_webhook']
|
||||
}
|
||||
},
|
||||
slack: {
|
||||
path: hubPaths.slackReport, // if to be updated, also update it in in backend/windmill-queue/src/jobs.rs
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channel: {
|
||||
type: 'string',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
required: ['channel']
|
||||
}
|
||||
},
|
||||
email: {
|
||||
path: hubPaths.smtpReport,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
smtp: {
|
||||
type: 'object',
|
||||
format: 'resource-smtp',
|
||||
properties: {},
|
||||
required: [],
|
||||
description: ''
|
||||
},
|
||||
from_email: {
|
||||
type: 'string',
|
||||
default: ''
|
||||
},
|
||||
to_email: {
|
||||
type: 'string',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
required: ['smtp', 'from_email', 'to_email']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFlowArgs() {
|
||||
return {
|
||||
app_path: appPath,
|
||||
startup_duration: appReportingStartupDuration,
|
||||
kind: screenshotKind,
|
||||
...args,
|
||||
...(selectedTab === 'slack'
|
||||
? {
|
||||
slack: '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function getFlowValue() {
|
||||
const notifInputTransforms: {
|
||||
[key: string]: {
|
||||
expr: string
|
||||
type: 'javascript'
|
||||
}
|
||||
} = {
|
||||
app_path: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.app_path'
|
||||
},
|
||||
screenshot: {
|
||||
type: 'javascript',
|
||||
expr: 'results.a'
|
||||
},
|
||||
kind: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.kind'
|
||||
},
|
||||
...Object.fromEntries(
|
||||
Object.keys(args).map((key) => [
|
||||
key,
|
||||
{
|
||||
type: 'javascript',
|
||||
expr: `flow_input.${key}`
|
||||
}
|
||||
])
|
||||
),
|
||||
...(selectedTab === 'slack'
|
||||
? {
|
||||
slack: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.slack'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
const value = {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript' as const,
|
||||
tag: 'chromium',
|
||||
content: appPreviewScript,
|
||||
language: 'bun' as const,
|
||||
input_transforms: {
|
||||
app_path: {
|
||||
expr: 'flow_input.app_path',
|
||||
type: 'javascript' as const
|
||||
},
|
||||
startup_duration: {
|
||||
expr: 'flow_input.startup_duration',
|
||||
type: 'javascript' as const
|
||||
},
|
||||
kind: {
|
||||
expr: 'flow_input.kind',
|
||||
type: 'javascript' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'script' as const,
|
||||
path:
|
||||
selectedTab === 'custom' ? customPath || '' : notificationScripts[selectedTab].path,
|
||||
input_transforms: notifInputTransforms
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
async function enableAppReporting() {
|
||||
const flowPath = appPath + '_reports'
|
||||
|
||||
try {
|
||||
// will only work if the user is super admin
|
||||
const customTags = ((await SettingService.getGlobal({
|
||||
key: CUSTOM_TAGS_SETTING
|
||||
})) ?? []) as string[]
|
||||
|
||||
if (!customTags.includes('chromium')) {
|
||||
await SettingService.setGlobal({
|
||||
key: CUSTOM_TAGS_SETTING,
|
||||
requestBody: {
|
||||
value: [...customTags, 'chromium']
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {}
|
||||
|
||||
await FlowService.deleteFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
|
||||
await FlowService.createFlow({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
summary: appPath + ' - Reports flow',
|
||||
value: getFlowValue(),
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
properties: {
|
||||
app_path: {
|
||||
description: '',
|
||||
type: 'string',
|
||||
default: null,
|
||||
format: ''
|
||||
},
|
||||
startup_duration: {
|
||||
description: '',
|
||||
type: 'integer',
|
||||
default: 5,
|
||||
format: ''
|
||||
},
|
||||
kind: {
|
||||
description: '',
|
||||
type: 'string',
|
||||
enum: ['pdf', 'png'],
|
||||
default: 'pdf',
|
||||
format: ''
|
||||
},
|
||||
...(selectedTab === 'custom'
|
||||
? customPathSchema.properties
|
||||
: notificationScripts[selectedTab].schema.properties),
|
||||
...(selectedTab === 'slack'
|
||||
? {
|
||||
slack: {
|
||||
description: '',
|
||||
type: 'object',
|
||||
format: 'resource-slack',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
required: [
|
||||
'app_path',
|
||||
'startup_duration',
|
||||
'kind',
|
||||
...(selectedTab === 'custom'
|
||||
? customPathSchema.required
|
||||
: notificationScripts[selectedTab].schema.required),
|
||||
...(selectedTab === 'slack' ? ['slack'] : [])
|
||||
],
|
||||
type: 'object'
|
||||
},
|
||||
path: flowPath
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
path: flowPath
|
||||
})
|
||||
} catch (err) {}
|
||||
|
||||
await ScheduleService.createSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: flowPath,
|
||||
schedule: formatCron(appReportingSchedule.cron),
|
||||
timezone: appReportingSchedule.timezone,
|
||||
script_path: flowPath,
|
||||
is_flow: true,
|
||||
args: getFlowArgs(),
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
appReportingEnabled = true
|
||||
}
|
||||
|
||||
let testLoading = false
|
||||
async function testReport() {
|
||||
try {
|
||||
testLoading = true
|
||||
const jobId = await JobService.runFlowPreview({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
args: getFlowArgs(),
|
||||
value: getFlowValue()
|
||||
}
|
||||
})
|
||||
tryEvery({
|
||||
tryCode: async () => {
|
||||
let testResult = await JobService.getCompletedJob({
|
||||
workspace: $workspaceStore!,
|
||||
id: jobId
|
||||
})
|
||||
testLoading = false
|
||||
sendUserToast(
|
||||
testResult.success
|
||||
? 'Report sent successfully'
|
||||
: 'Report error: ' + testResult.result?.['error']?.['message'],
|
||||
!testResult.success
|
||||
)
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
testLoading = false
|
||||
sendUserToast('Reports flow did not return after 30s', true)
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace: $workspaceStore!,
|
||||
id: jobId,
|
||||
requestBody: {
|
||||
reason: 'Reports flow did not return after 30s'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
interval: 500,
|
||||
timeout: 30000
|
||||
})
|
||||
} catch (err) {
|
||||
sendUserToast('Could not test reports flow: ' + err, true)
|
||||
testLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
let disabled = true
|
||||
$: disabled =
|
||||
emptyString(appReportingSchedule.cron) ||
|
||||
(selectedTab === 'custom' && emptyString(customPath)) ||
|
||||
(selectedTab === 'slack' && !isSlackConnectedWorkspace) ||
|
||||
!areArgsValid
|
||||
</script>
|
||||
|
||||
<DrawerContent
|
||||
on:close={() => (open = false)}
|
||||
title="Schedule Reports"
|
||||
tooltip="Send a PDF or PNG preview of any app at a given schedule"
|
||||
documentationLink="https://www.windmill.dev/docs/apps/schedule_reports"
|
||||
><svelte:fragment slot="actions">
|
||||
<div class="mr-4 center-center -mt-2">
|
||||
<Toggle
|
||||
checked={appReportingEnabled}
|
||||
options={{ right: 'enable', left: 'disable' }}
|
||||
on:change={async () => {
|
||||
if (appReportingEnabled) {
|
||||
disableAppReporting()
|
||||
} else {
|
||||
await enableAppReporting()
|
||||
sendUserToast('App reporting enabled')
|
||||
}
|
||||
}}
|
||||
disabled={disabled && !appReportingEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
color="dark"
|
||||
startIcon={{ icon: Save }}
|
||||
size="sm"
|
||||
on:click={async () => {
|
||||
await enableAppReporting()
|
||||
sendUserToast('App reporting updated')
|
||||
open = false
|
||||
}}
|
||||
{disabled}
|
||||
>
|
||||
{appReportingEnabled ? 'Update' : 'Save and enable'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
<div class="flex flex-col gap-8">
|
||||
<Alert type="info" title="Scheduled PDF/PNG reports"
|
||||
>Send a PDF or PNG preview of the app at a given schedule. Enabling this feature will create a
|
||||
flow and a schedule in your workspace.
|
||||
<br /><br />
|
||||
For the flow to be executed, you need to set the WORKER_GROUP environment variable of one of your
|
||||
workers to "reports" or add the tag "chromium" to one of your worker groups.
|
||||
</Alert>
|
||||
|
||||
<Section label="Reporting schedule">
|
||||
<CronInput
|
||||
bind:schedule={appReportingSchedule.cron}
|
||||
bind:timezone={appReportingSchedule.timezone}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
label="Startup duration in seconds"
|
||||
tooltip="The number of seconds to wait before capturing a preview to ensure that all startup scripts
|
||||
have been executed."
|
||||
>
|
||||
<div class="w-full pt-2">
|
||||
<input
|
||||
type="number"
|
||||
class="text-sm w-full font-semibold"
|
||||
bind:value={appReportingStartupDuration}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label="Screenshot kind">
|
||||
<div class="w-full pt-2">
|
||||
<select class="text-sm w-full font-semibold" bind:value={screenshotKind}>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="png">PNG</option>
|
||||
</select>
|
||||
</div></Section
|
||||
>
|
||||
|
||||
<Section label="Notification">
|
||||
<Tabs bind:selected={selectedTab}>
|
||||
{#if !$enterpriseLicense}
|
||||
<Tab value="custom">Custom</Tab>
|
||||
{/if}
|
||||
<Tab value="slack" disabled={!$enterpriseLicense}
|
||||
>Slack{!$enterpriseLicense ? ' (EE only)' : ''}</Tab
|
||||
>
|
||||
<Tab value="discord" disabled={!$enterpriseLicense}
|
||||
>Discord{!$enterpriseLicense ? ' (EE only)' : ''}</Tab
|
||||
>
|
||||
<Tab value="email" disabled={!$enterpriseLicense}>
|
||||
<div class="flex flex-row gap-1 items-center"
|
||||
>Email{!$enterpriseLicense ? ' (EE only)' : ''}
|
||||
</div>
|
||||
</Tab>
|
||||
{#if $enterpriseLicense}
|
||||
<Tab value="custom">Custom</Tab>
|
||||
{/if}
|
||||
</Tabs>
|
||||
{#if selectedTab === 'custom'}
|
||||
<div class="pt-2">
|
||||
<ScriptPicker
|
||||
on:select={(ev) => {
|
||||
customPath = ev.detail.path
|
||||
}}
|
||||
initialPath={customPath}
|
||||
allowRefresh
|
||||
/>
|
||||
</div>
|
||||
<div class="prose text-2xs text-tertiary mt-2">
|
||||
Pick a script that does whatever with the PDF/PNG report.
|
||||
|
||||
<br />
|
||||
|
||||
The script chosen is passed the parameters `screenshot: string`, `kind: 'pdf' | 'png'`,
|
||||
`app_path: string` where `screenshot` is the base64 encoded PDF/PNG report, `kind` is the
|
||||
type of the screenshot, and `app_path` is the path of the app being reported.
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedTab === 'slack'}
|
||||
<div class="pt-4">
|
||||
{#if isSlackConnectedWorkspace}
|
||||
<Alert type="info" title="Will use the Slack resource linked to the workspace" />
|
||||
{:else}
|
||||
<Alert type="error" title="Workspace not connected to Slack">
|
||||
<div class="flex flex-row gap-x-1 w-full items-center">
|
||||
<p class="text-clip grow min-w-0">
|
||||
The workspace needs to be connected to Slack to use this feature. You can <a
|
||||
target="_blank"
|
||||
href="{base}/workspace_settings?tab=slack">configure it here</a
|
||||
>.
|
||||
</p>
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={getWorspaceSlackSetting}
|
||||
startIcon={{ icon: RotateCw }}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full pt-4">
|
||||
{#if selectedTab !== 'custom' || customPath !== undefined}
|
||||
{#key selectedTab + JSON.stringify(customPathSchema ?? {})}
|
||||
<SchemaForm
|
||||
bind:isValid={areArgsValid}
|
||||
bind:args
|
||||
schema={selectedTab !== 'custom'
|
||||
? notificationScripts[selectedTab].schema
|
||||
: customPathSchema}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
loading={testLoading}
|
||||
{disabled}
|
||||
on:click={testReport}
|
||||
size="xs"
|
||||
color="dark"
|
||||
btnClasses="w-auto"
|
||||
>
|
||||
Send test report
|
||||
</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
async function newInlineScript(content: string, language: Preview['language'], path: string) {
|
||||
const fullPath = `${appPath}/${path}`
|
||||
const fullPath = `${$appPath}/${path}`
|
||||
|
||||
let schema: Schema = emptySchema()
|
||||
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@
|
||||
|
||||
$: inlineScript &&
|
||||
(inlineScript.path = `${defaultIfEmptyString(
|
||||
appPath,
|
||||
$appPath,
|
||||
`u/${$userStore?.username ?? 'unknown'}/newapp`
|
||||
)}/${name?.replaceAll(' ', '_')}`)
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
{#if resolvedPaths[item.originalIndex]}
|
||||
<div class="text-xs text-tertiary flex gap-2 flex-row flex-wrap">
|
||||
Path: <Badge small>{resolvedPaths[item.originalIndex]}</Badge>
|
||||
{#if appPath && resolvedPaths[item.originalIndex]?.includes(appPath)}
|
||||
{#if $appPath && resolvedPaths[item.originalIndex]?.includes($appPath)}
|
||||
<Badge small color="blue"
|
||||
>Current app
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ export type AppViewerContext = {
|
||||
>
|
||||
>
|
||||
staticExporter: Writable<Record<string, () => any>>
|
||||
appPath: string
|
||||
appPath: Writable<string>
|
||||
workspace: string
|
||||
onchange: (() => void) | undefined
|
||||
isEditor: boolean
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
loadApps()
|
||||
|
||||
if (selecteValue === '') {
|
||||
selecteValue = appPath
|
||||
value = appPath
|
||||
selecteValue = $appPath
|
||||
value = $appPath
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -56,7 +56,7 @@
|
||||
items={apps.map((app) => {
|
||||
return {
|
||||
value: app.path,
|
||||
label: app.path === appPath ? `${app.path} (current app)` : app.path
|
||||
label: app.path === $appPath ? `${app.path} (current app)` : app.path
|
||||
}
|
||||
})}
|
||||
placeholder="Pick an app"
|
||||
@@ -71,7 +71,7 @@
|
||||
Current app is not selectable until you have deployed this app at least once.
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if appPath && appPath === value}
|
||||
{#if appPath && $appPath === value}
|
||||
<div class="text-2xs">
|
||||
The current app is selected. If the path changes, the path needs to be updated manually.
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,19 @@
|
||||
{#if value}
|
||||
<div class="h-screen">
|
||||
{#key value}
|
||||
<AppEditor {summary} app={value} path={''} {policy} fromHub={hubId != null} />
|
||||
<AppEditor
|
||||
on:savedNewAppPath={(event) => {
|
||||
goto(`/apps/edit/${event.detail}`)
|
||||
}}
|
||||
{summary}
|
||||
app={value}
|
||||
path={''}
|
||||
{policy}
|
||||
fromHub={hubId != null}
|
||||
newApp={true}
|
||||
replaceStateFn={(path) => replaceState(path, $page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { page } from '$app/stores'
|
||||
import { cleanValueProperties, decodeState, type Value } from '$lib/utils'
|
||||
import { afterNavigate, replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast, type ToastAction } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
@@ -202,14 +202,24 @@
|
||||
{#if app}
|
||||
<div class="h-screen">
|
||||
<AppEditor
|
||||
on:savedNewAppPath={(event) => {
|
||||
goto(`/apps/edit/${event.detail}`)
|
||||
if (app) {
|
||||
app.path = event.detail
|
||||
}
|
||||
}}
|
||||
on:restore={onRestore}
|
||||
summary={app.summary}
|
||||
app={app.value}
|
||||
path={app.path}
|
||||
newPath={app.path}
|
||||
path={$page.params.path}
|
||||
policy={app.policy}
|
||||
bind:savedApp
|
||||
{diffDrawer}
|
||||
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
|
||||
newApp={false}
|
||||
replaceStateFn={(path) => replaceState(path, $page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
workspace={$workspaceStore ?? ''}
|
||||
summary={app.summary}
|
||||
app={app.value}
|
||||
appPath={app.path}
|
||||
appPath={$page.params.path}
|
||||
{breakpoint}
|
||||
policy={app.policy}
|
||||
isEditor={false}
|
||||
|
||||
Reference in New Issue
Block a user