* all

* all
This commit is contained in:
Ruben Fiszel
2024-11-29 16:44:00 +01:00
committed by GitHub
parent b81bf99cc2
commit b0248ffcad
20 changed files with 822 additions and 767 deletions
@@ -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 {
+9 -5
View File
@@ -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()
@@ -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
+1 -1
View File
@@ -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}