mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
feat: add hub support for apps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Client>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
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<i32>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
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<UserDB>,
|
||||
|
||||
@@ -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::<Authed>())
|
||||
.route_layer(from_extractor::<users::Tokened>())
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
let runnableComponent: RunnableComponent
|
||||
|
||||
let isLoading: boolean = false
|
||||
let ownClick: boolean = false
|
||||
|
||||
$: outputs = $worldStore?.outputsById[id] as {
|
||||
result: Output<Array<any>>
|
||||
@@ -40,13 +39,8 @@
|
||||
$: outputs?.loading.subscribe({
|
||||
next: (value) => {
|
||||
isLoading = value
|
||||
if (ownClick && !value) {
|
||||
ownClick = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$: loading = isLoading && ownClick
|
||||
</script>
|
||||
|
||||
<InputValue {id} input={configuration.label} bind:value={labelValue} />
|
||||
@@ -54,6 +48,7 @@
|
||||
<InputValue {id} input={configuration.size} bind:value={size} />
|
||||
|
||||
<RunnableWrapper
|
||||
noMinH
|
||||
bind:runnableComponent
|
||||
bind:componentInput
|
||||
{id}
|
||||
@@ -62,7 +57,7 @@
|
||||
forceSchemaDisplay={true}
|
||||
>
|
||||
<AlignWrapper {horizontalAlignment}>
|
||||
<div class="flex flex-col gap-2 px-4 w-full">
|
||||
<div class="flex flex-col gap-2 px-4 w-full ">
|
||||
<div>
|
||||
{#if componentInput?.type != 'runnable' || Object.values(componentInput?.fields ?? {}).filter((x) => x.type == 'user').length == 0}
|
||||
<span class="text-gray-600 italic text-sm py-2">
|
||||
@@ -73,14 +68,13 @@
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
{loading}
|
||||
btnClasses="mt-1"
|
||||
loading={isLoading}
|
||||
btnClasses="my-1"
|
||||
on:pointerdown={(e) => {
|
||||
e?.stopPropagation()
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
}}
|
||||
on:click={() => {
|
||||
ownClick = true
|
||||
runnableComponent?.runComponent()
|
||||
|
||||
if (recomputeIds) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import TestJobLoader from '$lib/components/TestJobLoader.svelte'
|
||||
import { AppService, type CompletedJob } from '$lib/gen'
|
||||
import { defaultIfEmptyString, emptySchema } from '$lib/utils'
|
||||
import { defaultIfEmptyString, emptySchema, sendUserToast } from '$lib/utils'
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import type { AppInputs, Runnable } from '../../inputType'
|
||||
import type { Output } from '../../rx'
|
||||
@@ -21,16 +21,17 @@
|
||||
export let autoRefresh: boolean = true
|
||||
export let result: any = undefined
|
||||
export let forceSchemaDisplay: boolean = false
|
||||
export let noMinH = false
|
||||
|
||||
const { worldStore, runnableComponents, workspace, appPath, isEditor, jobs } =
|
||||
const { worldStore, runnableComponents, workspace, appPath, isEditor, jobs, noBackend } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
onMount(() => {
|
||||
if (autoRefresh) {
|
||||
$runnableComponents[id] = async () => {
|
||||
await executeComponent()
|
||||
await executeComponent(true)
|
||||
}
|
||||
executeComponent()
|
||||
executeComponent(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -43,7 +44,7 @@
|
||||
function setDebouncedExecute() {
|
||||
executeTimeout && clearTimeout(executeTimeout)
|
||||
executeTimeout = setTimeout(() => {
|
||||
executeComponent()
|
||||
executeComponent(true)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
@@ -176,7 +177,13 @@
|
||||
[]
|
||||
)
|
||||
|
||||
async function executeComponent() {
|
||||
async function executeComponent(noToast = false) {
|
||||
if (noBackend) {
|
||||
if (!noToast) {
|
||||
sendUserToast('This app is not connected to a windmill backend, it is a static preview')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (runnable?.type === 'runnableByName' && !runnable.inlineScript) {
|
||||
return
|
||||
}
|
||||
@@ -294,7 +301,7 @@
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grow min-w-1/2 min-h-[66%]">
|
||||
<div class="grow min-w-1/2 {noMinH ? '' : 'min-h-[66%]'}">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import type { AppInput } from '../../inputType'
|
||||
import type { AppEditorContext } from '../../types'
|
||||
import { isScriptByNameDefined, isScriptByPathDefined } from '../../utils'
|
||||
import NonRunnableComponent from './NonRunnableComponent.svelte'
|
||||
import RunnableComponent from './RunnableComponent.svelte'
|
||||
@@ -7,12 +9,22 @@
|
||||
export let componentInput: AppInput | undefined
|
||||
export let id: string
|
||||
export let result: any = undefined
|
||||
export let noMinH = false
|
||||
|
||||
export let extraQueryParams: Record<string, any> = {}
|
||||
export let autoRefresh: boolean = true
|
||||
export let runnableComponent: RunnableComponent | undefined = undefined
|
||||
export let forceSchemaDisplay: boolean = false
|
||||
|
||||
const { staticExporter, noBackend } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
if (noBackend && componentInput?.type == 'runnable') {
|
||||
result = componentInput?.['value']
|
||||
}
|
||||
onMount(() => {
|
||||
$staticExporter[id] = () => result
|
||||
})
|
||||
|
||||
function isRunnableDefined() {
|
||||
return isScriptByNameDefined(componentInput) || isScriptByPathDefined(componentInput)
|
||||
}
|
||||
@@ -30,6 +42,7 @@
|
||||
{id}
|
||||
{extraQueryParams}
|
||||
{forceSchemaDisplay}
|
||||
{noMinH}
|
||||
>
|
||||
<slot />
|
||||
</RunnableComponent>
|
||||
|
||||
@@ -69,7 +69,9 @@
|
||||
workspace: $workspaceStore ?? '',
|
||||
onchange: () => saveDraft(),
|
||||
isEditor: true,
|
||||
jobs: writable([])
|
||||
jobs: writable([]),
|
||||
staticExporter: writable({}),
|
||||
noBackend: false
|
||||
})
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined = undefined
|
||||
@@ -128,6 +130,7 @@
|
||||
{policy}
|
||||
isEditor
|
||||
{context}
|
||||
noBackend={false}
|
||||
/>
|
||||
{:else}
|
||||
<SplitPanesWrapper>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import ToggleButton from '$lib/components/common/toggleButton/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import FlowJobResult from '$lib/components/FlowJobResult.svelte'
|
||||
import Dropdown from '$lib/components/Dropdown.svelte'
|
||||
import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte'
|
||||
import FlowStatusViewer from '$lib/components/FlowStatusViewer.svelte'
|
||||
import JobArgs from '$lib/components/JobArgs.svelte'
|
||||
@@ -19,21 +19,29 @@
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { AppService, Job, Policy } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { faBug, faClipboard, faExternalLink, faSave } from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
faBug,
|
||||
faClipboard,
|
||||
faExternalLink,
|
||||
faFileExport,
|
||||
faGlobe,
|
||||
faSave
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
AlignHorizontalSpaceAround,
|
||||
Expand,
|
||||
Eye,
|
||||
Laptop2,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Smartphone
|
||||
} from 'lucide-svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import { Icon } from 'svelte-awesome'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { classNames, copyToClipboard, sendUserToast } from '../../../utils'
|
||||
import type { AppComponent, AppEditorContext } from '../types'
|
||||
import { appToHubUrl, classNames, copyToClipboard, sendUserToast } from '../../../utils'
|
||||
import type { App, AppComponent, AppEditorContext } from '../types'
|
||||
import AppExportButton from './AppExportButton.svelte'
|
||||
import PanelSection from './settingsPanel/common/PanelSection.svelte'
|
||||
|
||||
@@ -47,7 +55,7 @@
|
||||
|
||||
export let policy: Policy
|
||||
|
||||
const { app, summary, mode, breakpoint, appPath, jobs } =
|
||||
const { app, summary, mode, breakpoint, appPath, jobs, staticExporter } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
const loading = {
|
||||
publish: false,
|
||||
@@ -57,6 +65,8 @@
|
||||
let newPath: string = ''
|
||||
let pathError: string | undefined = undefined
|
||||
|
||||
let appExport: AppExportButton
|
||||
|
||||
let saveDrawerOpen = false
|
||||
let jobsDrawerOpen = false
|
||||
let publishDrawerOpen = false
|
||||
@@ -65,6 +75,17 @@
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
|
||||
function toStatic(): { app: App; summary: string } {
|
||||
const newApp: App = JSON.parse(JSON.stringify($app))
|
||||
newApp.grid.forEach((x) => {
|
||||
let c: AppComponent = x.data
|
||||
if (c.componentInput?.type == 'runnable') {
|
||||
c.componentInput.value = $staticExporter[x.id]()
|
||||
}
|
||||
})
|
||||
return { app: newApp, summary: $summary }
|
||||
}
|
||||
|
||||
async function computeTriggerables() {
|
||||
const allTriggers = await Promise.all(
|
||||
$app.grid.map(async (x) => {
|
||||
@@ -333,7 +354,7 @@
|
||||
</Drawer>
|
||||
|
||||
<div
|
||||
class="border-b flex flex-row justify-between py-1 gap-4 overflow-x-auto gap-y-2 px-4 items-center"
|
||||
class="border-b flex flex-row justify-between py-1 gap-4 gap-y-2 px-4 items-center overflow-y-visible"
|
||||
>
|
||||
<div class="min-w-64 w-64">
|
||||
<input type="text" placeholder="App summary" class="text-sm w-full" bind:value={$summary} />
|
||||
@@ -344,11 +365,13 @@
|
||||
<ToggleButton position="left" value="dnd" size="xs">
|
||||
<div class="inline-flex gap-1 items-center">
|
||||
<Pencil size={14} />
|
||||
Editor
|
||||
<span class="hidden md:inline">Editor</span>
|
||||
</div>
|
||||
</ToggleButton>
|
||||
<ToggleButton position="right" value="preview" size="xs">
|
||||
<div class="inline-flex gap-1 items-center"> <Eye size={14} /> Preview</div>
|
||||
<div class="inline-flex gap-1 items-center">
|
||||
<Eye size={14} /> <span class="hidden md:inline">Preview</span>
|
||||
</div>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
@@ -363,7 +386,7 @@
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<span class="hidden lg:block">
|
||||
<div class="hidden lg:block">
|
||||
<ToggleButtonGroup bind:selected={$app.fullscreen}>
|
||||
<ToggleButton position="left" value={false} size="xs">
|
||||
<div class="flex gap-1 justify-start">
|
||||
@@ -378,22 +401,44 @@
|
||||
<Expand size={14} />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row grow gap-2 justify-end items-center">
|
||||
<Button
|
||||
on:click={() => (jobsDrawerOpen = true)}
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faBug }}
|
||||
<div class="flex flex-row grow gap-2 justify-end items-center overflow-visible">
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
btnClasses="!text-gray-700 !bg-transparent hover:!bg-gray-400/20 !p-[6px] hidden lg:block"
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'JSON',
|
||||
icon: faFileExport,
|
||||
action: () => {
|
||||
appExport.open()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Publish to Hub',
|
||||
icon: faGlobe,
|
||||
action: () => {
|
||||
const url = appToHubUrl(toStatic())
|
||||
window.open(url.toString(), '_blank')
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
Debug Runs
|
||||
</Button>
|
||||
<span class="hidden lg:block">
|
||||
<AppExportButton app={$app} />
|
||||
<MoreVertical size={20} />
|
||||
</Dropdown>
|
||||
<span class="hidden md:inline">
|
||||
<Button
|
||||
on:click={() => (jobsDrawerOpen = true)}
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faBug }}
|
||||
>
|
||||
<span class="hidden md:inline">Debug Runs</span>
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
<AppExportButton bind:this={appExport} app={$app} />
|
||||
<Button
|
||||
on:click={() => (publishDrawerOpen = true)}
|
||||
color="light"
|
||||
@@ -401,7 +446,7 @@
|
||||
variant="border"
|
||||
startIcon={{ icon: faExternalLink }}
|
||||
>
|
||||
Publish
|
||||
<span class="hidden md:inline">Publish</span>
|
||||
</Button>
|
||||
<Button
|
||||
loading={loading.save}
|
||||
@@ -410,7 +455,7 @@
|
||||
color="dark"
|
||||
size="xs"
|
||||
>
|
||||
Save
|
||||
<span class="hidden md:inline">Save</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
|
||||
import { faClipboard, faFileExport } from '@fortawesome/free-solid-svg-icons'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { faClipboard } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import json from 'svelte-highlight/languages/json'
|
||||
import { Button } from '../../common'
|
||||
@@ -13,14 +12,13 @@
|
||||
|
||||
let jsonViewerDrawer: Drawer
|
||||
|
||||
export function open() {
|
||||
jsonViewerDrawer?.toggleDrawer()
|
||||
}
|
||||
|
||||
export let app: App
|
||||
</script>
|
||||
|
||||
<Button size="xs" variant="border" color="light" on:click={() => jsonViewerDrawer.toggleDrawer()}>
|
||||
<Icon data={faFileExport} scale={0.6} class="inline mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
|
||||
<Drawer bind:this={jsonViewerDrawer} size="800px">
|
||||
<DrawerContent title="App JSON" on:close={() => jsonViewerDrawer.toggleDrawer()}>
|
||||
<div class="relative">
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
export let workspace: string
|
||||
export let isEditor: boolean
|
||||
export let context: Record<string, any>
|
||||
export let noBackend: boolean = false
|
||||
|
||||
const appStore = writable<App>(app)
|
||||
const worldStore = writable<World | undefined>(undefined)
|
||||
@@ -53,7 +54,9 @@
|
||||
workspace,
|
||||
onchange: undefined,
|
||||
isEditor,
|
||||
jobs: writable([])
|
||||
jobs: writable([]),
|
||||
staticExporter: writable({}),
|
||||
noBackend
|
||||
})
|
||||
|
||||
let mounted = false
|
||||
|
||||
@@ -71,6 +71,7 @@ export type ResultInput = {
|
||||
runnable: Runnable
|
||||
fields: Record<string, StaticAppInput | ConnectedAppInput | RowAppInput | UserAppInput>
|
||||
type: 'runnable'
|
||||
value?: any
|
||||
}
|
||||
|
||||
type AppInputSpec<T extends InputType, U, V extends InputType = never> = (
|
||||
|
||||
@@ -142,11 +142,13 @@ export type AppEditorContext = {
|
||||
connectingInput: Writable<ConnectingInput>
|
||||
breakpoint: Writable<EditorBreakpoint>
|
||||
runnableComponents: Writable<Record<string, () => Promise<void>>>
|
||||
staticExporter: Writable<Record<string, () => 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'
|
||||
|
||||
@@ -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
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
href="/apps/add?template={path}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { classNames } from '$lib/utils'
|
||||
import { faBarsStaggered } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Code2, LayoutDashboard, Wind } from 'lucide-svelte'
|
||||
import { Code2, LayoutDashboard } from 'lucide-svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
|
||||
export let kind: 'script' | 'flow' | 'app'
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { Badge, Skeleton } from '$lib/components/common'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import { loadHubApps } from '$lib/utils'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
|
||||
export let filter = ''
|
||||
|
||||
type Item = { apps: string[]; summary: string; path: string }
|
||||
let hubApps: any[] | undefined = undefined
|
||||
let filteredItems: (Item & { marked?: string })[] = []
|
||||
let appFilter: string | undefined = undefined
|
||||
|
||||
$: prefilteredItems = appFilter
|
||||
? (hubApps ?? []).filter((i) => i.apps.includes(appFilter))
|
||||
: hubApps ?? []
|
||||
|
||||
$: apps = Array.from(new Set(filteredItems?.flatMap((x) => x.apps) ?? [])).sort()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
onMount(async () => {
|
||||
hubApps = await loadHubApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={prefilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
|
||||
/>
|
||||
<div class="w-full flex mt-1 items-center gap-2">
|
||||
<slot />
|
||||
<input type="text" placeholder="Search Hub Apps" bind:value={filter} class="text-2xl grow" />
|
||||
</div>
|
||||
<ListFilters filters={apps} bind:selectedFilter={appFilter} resourceType />
|
||||
|
||||
{#if hubApps}
|
||||
{#if filteredItems.length == 0}
|
||||
<NoItemFound />
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-200 border rounded-md">
|
||||
{#each filteredItems as item (item)}
|
||||
<li class="flex flex-row w-full">
|
||||
<button
|
||||
class="p-4 gap-4 flex flex-row grow justify-between hover:bg-gray-50 bg-white transition-all items-center rounded-md"
|
||||
on:click={() => dispatch('pick', item)}
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<RowIcon kind="app" />
|
||||
|
||||
<div class="w-full text-left font-normal ">
|
||||
<div class="text-gray-900 flex-wrap text-md font-semibold mb-1">
|
||||
{#if item.marked}
|
||||
{@html item.marked ?? ''}
|
||||
{:else}
|
||||
{item.summary ?? ''}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-1/3 gap-2 flex flex-wrap justify-end">
|
||||
{#each item.apps as app}
|
||||
<Badge color="gray" baseClass="border">{app}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="my-2" />
|
||||
{#each Array(10).fill(0) as _}
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -35,7 +35,7 @@
|
||||
/>
|
||||
<div class="w-full flex mt-1 items-center gap-2">
|
||||
<slot />
|
||||
<input type="text" placeholder="Search Hub Scripts" bind:value={filter} class="text-2xl grow" />
|
||||
<input type="text" placeholder="Search Hub Flows" bind:value={filter} class="text-2xl grow" />
|
||||
</div>
|
||||
<ListFilters filters={apps} bind:selectedFilter={appFilter} resourceType />
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="my-2" />
|
||||
|
||||
{#each Array(10).fill(0) as _}
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="my-2" />
|
||||
{#each Array(10).fill(0) as _}
|
||||
<Skeleton layout={[0.5, [4]]} />
|
||||
{/each}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { FlowService, type OpenFlow } from '$lib/gen'
|
||||
import { AppService, FlowService, type OpenFlow } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { Alert, Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
@@ -16,8 +16,12 @@
|
||||
|
||||
import ItemsList from '$lib/components/home/ItemsList.svelte'
|
||||
import CreateActionsApp from '$lib/components/flows/CreateActionsApp.svelte'
|
||||
import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte'
|
||||
import AppPreview from '$lib/components/apps/editor/AppPreview.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
|
||||
type Tab = 'hubscripts' | 'hubflows' | 'workspace'
|
||||
type Tab = 'hubscripts' | 'hubflows' | 'hubapps' | 'workspace'
|
||||
|
||||
let tab: Tab = 'workspace'
|
||||
let filter: string = ''
|
||||
@@ -25,11 +29,16 @@
|
||||
let flowViewer: Drawer
|
||||
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined
|
||||
|
||||
let appViewer: Drawer
|
||||
let appViewerApp: { app?: any & { id?: number } } | undefined
|
||||
|
||||
let codeViewer: Drawer
|
||||
let codeViewerContent: string = ''
|
||||
let codeViewerLanguage: 'deno' | 'python3' | 'go' | 'bash' = 'deno'
|
||||
let codeViewerObj: HubItem | undefined = undefined
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
|
||||
async function viewCode(obj: HubItem) {
|
||||
const { content, language } = await getScriptByPath(obj.path)
|
||||
codeViewerContent = content
|
||||
@@ -44,6 +53,13 @@
|
||||
flowViewerFlow = hub
|
||||
flowViewer.openDrawer?.()
|
||||
}
|
||||
|
||||
async function viewApp(obj: { app_id: number }): Promise<void> {
|
||||
const hub = await AppService.getHubAppById({ id: obj.app_id })
|
||||
delete hub['comments']
|
||||
appViewerApp = hub
|
||||
appViewer.openDrawer?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={codeViewer} size="900px">
|
||||
@@ -108,6 +124,52 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={appViewer} size="1200px">
|
||||
<DrawerContent title="Hub app" on:close={appViewer.closeDrawer}>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
href="https://hub.windmill.dev/apps/{appViewerApp?.app?.id}"
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Globe2 size={18} />
|
||||
View on the Hub
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href="/apps/add?hub={appViewerApp?.app?.id}"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
color="dark"
|
||||
size="xs"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
|
||||
{#if appViewerApp?.app}
|
||||
<div class="p-4">
|
||||
<AppPreview
|
||||
app={appViewerApp?.app?.value}
|
||||
appPath="''"
|
||||
{breakpoint}
|
||||
policy={{}}
|
||||
workspace="hub"
|
||||
isEditor={false}
|
||||
context={{
|
||||
username: $userStore?.username ?? 'anonymous',
|
||||
email: $userStore?.email ?? 'anonymous'
|
||||
}}
|
||||
summary={appViewerApp?.app.summary ?? ''}
|
||||
noBackend
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 md:px-8 h-fit-content">
|
||||
{#if $workspaceStore == 'demo'}
|
||||
@@ -140,26 +202,34 @@
|
||||
</PageHeader>
|
||||
|
||||
{#if !$userStore?.operator}
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubscripts">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Scripts
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubflows">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Flows
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<div class="w-full overflow-auto scrollbar-hidden">
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubscripts">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Scripts
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubflows">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Flows
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubapps">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Apps
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="my-2" />
|
||||
<div class="flex flex-col gap-y-16">
|
||||
@@ -168,6 +238,8 @@
|
||||
<PickHubScript bind:filter on:pick={(e) => viewCode(e.detail)} />
|
||||
{:else if tab == 'hubflows'}
|
||||
<PickHubFlow bind:filter on:pick={(e) => viewFlow(e.detail)} />
|
||||
{:else if tab == 'hubapps'}
|
||||
<PickHubApp bind:filter on:pick={(e) => viewApp(e.detail)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
</script>
|
||||
|
||||
{#if value}
|
||||
<div class="h-screen">
|
||||
<AppEditor
|
||||
summary={''}
|
||||
app={value}
|
||||
path={''}
|
||||
policy={{
|
||||
on_behalf_of: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: Policy.execution_mode.PUBLISHER
|
||||
}}
|
||||
/>
|
||||
{#key value}
|
||||
<AppEditor
|
||||
{summary}
|
||||
app={value}
|
||||
path={''}
|
||||
policy={{
|
||||
on_behalf_of: `u/${$userStore?.username}`,
|
||||
on_behalf_of_email: $userStore?.email,
|
||||
execution_mode: Policy.execution_mode.PUBLISHER
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
{breakpoint}
|
||||
policy={app.policy}
|
||||
isEditor={false}
|
||||
noBackend={false}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
{:else if app}
|
||||
<div class="border rounded-md p-2 w-full">
|
||||
<AppPreview
|
||||
noBackend={false}
|
||||
context={{ email: $userStore?.email, username: $userStore?.username }}
|
||||
workspace={$page.params.workspace}
|
||||
summary={app.summary}
|
||||
|
||||
Reference in New Issue
Block a user