diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c8387b7fdc..95ed3ea7ff 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1769,6 +1769,75 @@ paths: flow: $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" + /apps/hub/list: + get: + summary: list all available hub apps + operationId: listHubApps + tags: + - app + responses: + "200": + description: hub apps list + content: + application/json: + schema: + type: object + properties: + apps: + type: array + items: + type: object + properties: + id: + type: number + app_id: + type: number + summary: + type: string + apps: + type: array + items: + type: string + approved: + type: boolean + votes: + type: number + required: + - id + - app_id + - summary + - apps + - approved + - votes + + /apps/hub/get/{id}: + get: + summary: get hub app by id + operationId: getHubAppById + tags: + - app + parameters: + - $ref: "#/components/parameters/PathId" + responses: + "200": + description: app + content: + application/json: + schema: + type: object + properties: + app: + type: object + properties: + summary: + type: string + value: {} + required: + - summary + - value + required: + - app + /scripts/hub/get/{path}: get: summary: get hub script content by path diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 82ce59f0ea..3ed1d9824a 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -20,6 +20,7 @@ use axum::{ }; use hyper::StatusCode; use magic_crypt::MagicCryptTrait; +use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; @@ -31,7 +32,9 @@ use windmill_common::{ apps::ListAppQuery, error::{to_anyhow, Error, JsonResult, Result}, users::owner_to_token_owner, - utils::{not_found_if_none, paginate, Pagination, StripPath}, + utils::{ + http_get_from_hub, list_elems_from_hub, not_found_if_none, paginate, Pagination, StripPath, + }, }; use windmill_queue::{push, JobPayload, RawCode}; @@ -53,6 +56,12 @@ pub fn unauthed_service() -> Router { .route("/public_app/:secret", get(get_public_app_by_secret)) } +pub fn global_service() -> Router { + Router::new() + .route("/hub/list", get(list_hub_apps)) + .route("/hub/get/:id", get(get_hub_app_by_id)) +} + #[derive(FromRow, Deserialize, Serialize)] pub struct ListableApp { pub id: i64, @@ -352,6 +361,37 @@ async fn create_app( Ok((StatusCode::CREATED, app.path)) } +async fn list_hub_apps( + Authed { email, .. }: Authed, + Extension(http_client): Extension, +) -> JsonResult { + let flows = list_elems_from_hub( + http_client, + "https://hub.windmill.dev/searchUiData?approved=true", + &email, + ) + .await?; + Ok(Json(flows)) +} + +pub async fn get_hub_app_by_id( + Authed { email, .. }: Authed, + Path(id): Path, + Extension(http_client): Extension, +) -> JsonResult { + let value = http_get_from_hub( + http_client, + &format!("https://hub.windmill.dev/apps/{id}/json"), + &email, + false, + ) + .await? + .json() + .await + .map_err(to_anyhow)?; + Ok(Json(value)) +} + async fn delete_app( authed: Authed, Extension(user_db): Extension, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 6688a547aa..33d9fe56f8 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -141,6 +141,7 @@ pub async fn run_server( .nest("/workers", worker_ping::global_service()) .nest("/scripts", scripts::global_service()) .nest("/flows", flows::global_service()) + .nest("/apps", apps::global_service()) .nest("/schedules", schedule::global_service()) .route_layer(from_extractor::()) .route_layer(from_extractor::()) diff --git a/frontend/src/lib/components/apps/components/form/AppForm.svelte b/frontend/src/lib/components/apps/components/form/AppForm.svelte index 5b3233589c..2937c825cb 100644 --- a/frontend/src/lib/components/apps/components/form/AppForm.svelte +++ b/frontend/src/lib/components/apps/components/form/AppForm.svelte @@ -26,7 +26,6 @@ let runnableComponent: RunnableComponent let isLoading: boolean = false - let ownClick: boolean = false $: outputs = $worldStore?.outputsById[id] as { result: Output> @@ -40,13 +39,8 @@ $: outputs?.loading.subscribe({ next: (value) => { isLoading = value - if (ownClick && !value) { - ownClick = false - } } }) - - $: loading = isLoading && ownClick @@ -54,6 +48,7 @@ -
+
{#if componentInput?.type != 'runnable' || Object.values(componentInput?.fields ?? {}).filter((x) => x.type == 'user').length == 0} @@ -73,14 +68,13 @@
{:else} -
+
{/if} diff --git a/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte b/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte index 3440ebb78d..ceb7986dcc 100644 --- a/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte +++ b/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte @@ -1,5 +1,7 @@ - - jsonViewerDrawer.toggleDrawer()}>
diff --git a/frontend/src/lib/components/apps/editor/AppPreview.svelte b/frontend/src/lib/components/apps/editor/AppPreview.svelte index d524763c57..e294e9db9e 100644 --- a/frontend/src/lib/components/apps/editor/AppPreview.svelte +++ b/frontend/src/lib/components/apps/editor/AppPreview.svelte @@ -23,6 +23,7 @@ export let workspace: string export let isEditor: boolean export let context: Record + export let noBackend: boolean = false const appStore = writable(app) const worldStore = writable(undefined) @@ -53,7 +54,9 @@ workspace, onchange: undefined, isEditor, - jobs: writable([]) + jobs: writable([]), + staticExporter: writable({}), + noBackend }) let mounted = false diff --git a/frontend/src/lib/components/apps/inputType.ts b/frontend/src/lib/components/apps/inputType.ts index 9e71a09168..bed8a95ff0 100644 --- a/frontend/src/lib/components/apps/inputType.ts +++ b/frontend/src/lib/components/apps/inputType.ts @@ -71,6 +71,7 @@ export type ResultInput = { runnable: Runnable fields: Record type: 'runnable' + value?: any } type AppInputSpec = ( diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 358d7a1da5..73814cc58e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -142,11 +142,13 @@ export type AppEditorContext = { connectingInput: Writable breakpoint: Writable runnableComponents: Writable Promise>> + staticExporter: Writable any>> appPath: string, workspace: string, onchange: (() => void) | undefined, isEditor: boolean, - jobs: Writable<{ job: string, component: string }[]> + jobs: Writable<{ job: string, component: string }[]>, + noBackend: boolean } export type EditorMode = 'dnd' | 'preview' diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index ef1814a6b4..07ffd7089e 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -6,6 +6,7 @@ import { AppService, type ListableApp } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' import { + faCodeFork, faEdit, faEye, faFileExport, @@ -57,6 +58,18 @@ Edit
+ {:else} +
+ +
{/if} {/if} diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 59f39d39b6..9aa47dbfa6 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -1,7 +1,7 @@ + + x.summary + ' (' + x.apps.join(', ') + ')'} +/> +
+ + +
+ + +{#if hubApps} + {#if filteredItems.length == 0} + + {:else} +
    + {#each filteredItems as item (item)} +
  • + +
  • + {/each} +
+ {/if} +{:else} +
+ {#each Array(10).fill(0) as _} + + {/each} +{/if} diff --git a/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte b/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte index 6912fda729..debcdd2c20 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte @@ -35,7 +35,7 @@ />
- +
@@ -74,6 +74,8 @@ {/if} {:else} +
+ {#each Array(10).fill(0) as _} {/each} diff --git a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte index fe45f3afd8..2189e34b34 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte @@ -88,6 +88,7 @@
{/if} {:else} +
{#each Array(10).fill(0) as _} {/each} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 41f778b4b7..29b7707b54 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { goto } from '$app/navigation' import { + AppService, FlowService, FolderService, Script, @@ -538,6 +539,17 @@ export async function loadHubFlows() { } } + +export async function loadHubApps() { + try { + const apps = (await AppService.listHubApps()).apps ?? [] + const processed = apps.sort((a, b) => b.votes - a.votes) + return processed + } catch { + console.error('Hub is not available') + } +} + export function formatCron(inp: string): string { // Allow for cron expressions inputted by the user to omit month and year let splitted = inp.split(' ') @@ -561,6 +573,13 @@ export function flowToHubUrl(flow: Flow): URL { return url } + +export function appToHubUrl(staticApp: any): URL { + const url = new URL('https://hub.windmill.dev/apps/add') + url.searchParams.append('app', encodeState(staticApp)) + return url +} + export function scriptToHubUrl( content: string, summary: string, diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index 42e056c33d..c3d31c01a8 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -1,5 +1,5 @@ @@ -108,6 +124,52 @@ + + + + + + + + + {#if appViewerApp?.app} +
+ +
+ {/if} +
+
+
{#if $workspaceStore == 'demo'} @@ -140,26 +202,34 @@ {#if !$userStore?.operator} - - -
- - Workspace -
-
- -
- - Hub Scripts -
-
- -
- - Hub Flows -
-
-
+
+ + +
+ + Workspace +
+
+ +
+ + Hub Scripts +
+
+ +
+ + Hub Flows +
+
+ +
+ + Hub Apps +
+
+
+
{/if}
@@ -168,6 +238,8 @@ viewCode(e.detail)} /> {:else if tab == 'hubflows'} viewFlow(e.detail)} /> + {:else if tab == 'hubapps'} + viewApp(e.detail)} /> {/if}
diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte index 6178ac91a0..487d7b4cc6 100644 --- a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte @@ -2,55 +2,78 @@ import { importStore } from '$lib/components/apps/store' import AppEditor from '$lib/components/apps/editor/AppEditor.svelte' - import { Policy } from '$lib/gen' + import { AppService, Policy } from '$lib/gen' import { page } from '$app/stores' import { decodeState, sendUserToast } from '$lib/utils' import { dirtyStore } from '$lib/components/common/confirmationModal/dirtyStore' - import { userStore } from '$lib/stores' + import { userStore, workspaceStore } from '$lib/stores' import type { App } from '$lib/components/apps/types' import { goto } from '$app/navigation' let nodraft = $page.url.searchParams.get('nodraft') - - if (nodraft) { - goto('?', { replaceState: true }) - } + const hubId = $page.url.searchParams.get('hub') + const templatePath = $page.url.searchParams.get('template') const importJson = $importStore if ($importStore) { $importStore = undefined } - const initialState = nodraft ? undefined : localStorage.getItem('app') + const state = nodraft ? undefined : localStorage.getItem('app') - let value: App = - importJson ?? - (initialState != undefined - ? decodeState(initialState) - : { - grid: [], - title: 'New App', - fullscreen: false, - unusedInlineScripts: [] - }) - - if (!importJson && initialState) { - sendUserToast('App restored from draft') + let summary = '' + let value: App = { + grid: [], + fullscreen: false, + unusedInlineScripts: [] } + + if (nodraft) { + goto('?', { replaceState: true }) + } + + loadApp() + + async function loadApp() { + if (importJson) { + sendUserToast('Loaded from raw JSON') + value = importJson + } else if (templatePath) { + const template = await AppService.getAppByPath({ + workspace: $workspaceStore!, + path: templatePath + }) + value = template.value + sendUserToast('App loaded from template') + goto('?', { replaceState: true }) + } else if (hubId) { + const hub = await AppService.getHubAppById({ id: Number(hubId) }) + value = hub.app.value + summary = hub.app.summary + sendUserToast('App loaded from Hub') + goto('?', { replaceState: true }) + } else if (!templatePath && !hubId && state) { + sendUserToast('App restored from draft') + value = decodeState(state) + } + } + $dirtyStore = false {#if value}
- + {#key value} + + {/key}
{/if} diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index 91abe69e64..92b2b321db 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -32,6 +32,7 @@ {breakpoint} policy={app.policy} isEditor={false} + noBackend={false} />
{:else} diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 159c3676c0..cda8378885 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -50,7 +50,7 @@ const oldPath = flow.path.split('/') flow.path = `u/${$userStore?.username}/${oldPath[oldPath.length - 1]}` flow = flow - $page.url.searchParams.delete('template') + goto('?', { replaceState: true }) selectedId = 'settings-graph' } else if (hubId) { const hub = await FlowService.getHubFlowById({ id: Number(hubId) }) @@ -58,7 +58,7 @@ flow.path = `u/${$userStore?.username}/flow_${hubId}` Object.assign(flow, hub.flow) flow = flow - $page.url.searchParams.delete('hub') + goto('?', { replaceState: true }) selectedId = 'settings-graph' } } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 2ef3f5d1f7..9fba26925b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -11,10 +11,10 @@ // Default toast options const toastOptions = { - duration: 4000, // duration of progress bar tween to the `next` value + duration: 10000, // duration of progress bar tween to the `next` value initial: 1, // initial progress bar value next: 0, // next progress value - pausable: false, // pause progress bar tween on mouse hover + pausable: true, // pause progress bar tween on mouse hover dismissable: true, // allow dismiss with close button reversed: false, // insert new toast to bottom of stack intro: { x: 256 }, // toast intro fly animation settings diff --git a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte index 3d364743ef..720e94ef6d 100644 --- a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte +++ b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte @@ -68,6 +68,7 @@ {:else if app}