raw app storage on db + s3 (#6749)
This commit is contained in:
24
backend/.sqlx/query-01050e7057f3d1971ad9e47ac83bf6a3c3c9f41689c3607f0b264437ae6b3324.json
generated
Normal file
24
backend/.sqlx/query-01050e7057f3d1971ad9e47ac83bf6a3c3c9f41689c3607f0b264437ae6b3324.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT data FROM app_bundles WHERE app_version_id = $1 AND file_type = $2 AND w_id = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "01050e7057f3d1971ad9e47ac83bf6a3c3c9f41689c3607f0b264437ae6b3324"
|
||||
}
|
||||
17
backend/.sqlx/query-abaae3dde751a41b2dbb7856ece1c840d0ea8d59346ed9e88f6f609edb543d7e.json
generated
Normal file
17
backend/.sqlx/query-abaae3dde751a41b2dbb7856ece1c840d0ea8d59346ed9e88f6f609edb543d7e.json
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO app_bundles (app_version_id, w_id, file_type, data) VALUES ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "abaae3dde751a41b2dbb7856ece1c840d0ea8d59346ed9e88f6f609edb543d7e"
|
||||
}
|
||||
23
backend/.sqlx/query-fad966db585b91c9ce143c9aa26a826aec1ddb193a7f4988c5f12b1a2d8ce071.json
generated
Normal file
23
backend/.sqlx/query-fad966db585b91c9ce143c9aa26a826aec1ddb193a7f4988c5f12b1a2d8ce071.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT app.versions[array_upper(app.versions, 1)] FROM app\n WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "versions",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fad966db585b91c9ce143c9aa26a826aec1ddb193a7f4988c5f12b1a2d8ce071"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add down migration script here
|
||||
DROP TABLE app_bundles;
|
||||
8
backend/migrations/20251001140645_raw_app_bundles.up.sql
Normal file
8
backend/migrations/20251001140645_raw_app_bundles.up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Add up migration script here
|
||||
CREATE TABLE app_bundles (
|
||||
app_version_id BIGINT NOT NULL,
|
||||
w_id VARCHAR(255) NOT NULL,
|
||||
file_type VARCHAR(10) NOT NULL,
|
||||
data BYTEA NOT NULL,
|
||||
PRIMARY KEY (app_version_id, file_type)
|
||||
);
|
||||
@@ -7003,6 +7003,23 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/secret_of_latest_version/{path}:
|
||||
get:
|
||||
summary: get public secret of latest version of an app bundle
|
||||
operationId: getPublicSecretOfLatestVersionOfApp
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: app secret
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/get/v/{id}:
|
||||
get:
|
||||
summary: get app by version
|
||||
|
||||
@@ -88,6 +88,10 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get/lite/*path", get(get_app_lite))
|
||||
.route("/get/draft/*path", get(get_app_w_draft))
|
||||
.route("/secret_of/*path", get(get_secret_id))
|
||||
.route(
|
||||
"/secret_of_latest_version/*path",
|
||||
get(get_latest_version_secret_id),
|
||||
)
|
||||
.route("/get/v/*id", get(get_app_by_id))
|
||||
.route("/get_data/v/*id", get(get_raw_app_data))
|
||||
.route("/exists/*path", get(exists_app))
|
||||
@@ -389,19 +393,85 @@ async fn list_apps(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
async fn get_raw_app_data(Path((w_id, version_id)): Path<(String, String)>) -> Result<Response> {
|
||||
let file_path = format!("/tmp/wmill/{}/{}", w_id, version_id);
|
||||
let file = tokio::fs::File::open(file_path).await?;
|
||||
let stream = tokio_util::io::ReaderStream::new(file);
|
||||
let res = Response::builder().header(
|
||||
http::header::CONTENT_TYPE,
|
||||
if version_id.ends_with(".css") {
|
||||
"text/css"
|
||||
} else {
|
||||
"text/javascript"
|
||||
},
|
||||
);
|
||||
Ok(res.body(Body::from_stream(stream)).unwrap())
|
||||
async fn get_raw_app_data(
|
||||
Path((w_id, secret_with_ext)): Path<(String, String)>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> Result<Response> {
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
let object_store = windmill_common::s3_helpers::get_object_store().await;
|
||||
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
|
||||
let object_store: Option<()> = None;
|
||||
|
||||
// tracing::info!("secret_with_ext: {}", secret_with_ext);
|
||||
let mut splitted = secret_with_ext.split('.');
|
||||
let secret_id = splitted.next().unwrap_or("");
|
||||
|
||||
if secret_id.is_empty() {
|
||||
return Err(Error::BadRequest("Invalid secret".to_string()));
|
||||
}
|
||||
|
||||
let id = get_id_from_secret(
|
||||
&db,
|
||||
&w_id,
|
||||
secret_id.to_string(),
|
||||
Some(BUNDLE_SECRET_PREFIX),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let file_type = splitted.next().unwrap_or("");
|
||||
let file_type = if file_type == "css" {
|
||||
"css"
|
||||
} else if file_type == "js" {
|
||||
"js"
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"Invalid file type, only .css and .js are supported".to_string(),
|
||||
));
|
||||
};
|
||||
// tracing::info!("file_type: {}", file_type);
|
||||
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
|
||||
|
||||
#[allow(unused_assignments)]
|
||||
let mut body: Option<Body> = None;
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
if let Some(os) = object_store {
|
||||
let stream = os
|
||||
.get(&object_store::path::Path::from(path))
|
||||
.await?
|
||||
.bytes()
|
||||
.await?;
|
||||
tracing::info!("stream: {}", stream.len());
|
||||
body = Some(Body::from(stream));
|
||||
}
|
||||
|
||||
if body.is_none() {
|
||||
let get_raw_app_file = sqlx::query_scalar!(
|
||||
"SELECT data FROM app_bundles WHERE app_version_id = $1 AND file_type = $2 AND w_id = $3",
|
||||
id,
|
||||
file_type,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if let Some(file) = get_raw_app_file {
|
||||
body = Some(Body::from(file));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(body) = body {
|
||||
// let stream = tokio_util::io::ReaderStream::new(file);
|
||||
let res = Response::builder().header(
|
||||
http::header::CONTENT_TYPE,
|
||||
if file_type == "css" {
|
||||
"text/css"
|
||||
} else {
|
||||
"text/javascript"
|
||||
},
|
||||
);
|
||||
Ok(res.body(body).unwrap())
|
||||
} else {
|
||||
return Err(Error::NotFound("File not found".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// async fn get_app_version(
|
||||
@@ -692,14 +762,7 @@ async fn get_public_app_by_secret(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
) -> JsonResult<AppWithLastVersion> {
|
||||
let mc = build_crypt(&db, &w_id).await?;
|
||||
|
||||
let decrypted = mc
|
||||
.decrypt_bytes_to_bytes(&(hex::decode(secret)?))
|
||||
.map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?;
|
||||
|
||||
let id: i64 = bytes.parse().map_err(to_anyhow)?;
|
||||
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
|
||||
|
||||
let app_o = sqlx::query_as::<_, AppWithLastVersion>(
|
||||
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
|
||||
@@ -747,6 +810,27 @@ async fn get_public_app_by_secret(
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
async fn get_id_from_secret(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
secret: String,
|
||||
prefix: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let mc = build_crypt(db, w_id).await?;
|
||||
let decrypted = mc
|
||||
.decrypt_bytes_to_bytes(&(hex::decode(secret)?))
|
||||
.map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?;
|
||||
if let Some(prefix) = prefix {
|
||||
if !bytes.starts_with(prefix) {
|
||||
return Err(Error::BadRequest("Invalid secret".to_string()));
|
||||
}
|
||||
bytes = bytes.strip_prefix(prefix).unwrap_or("");
|
||||
}
|
||||
let id: i64 = bytes.parse().map_err(to_anyhow)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get_public_resource(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
@@ -803,16 +887,87 @@ async fn get_secret_id(
|
||||
Ok(hx)
|
||||
}
|
||||
|
||||
const BUNDLE_SECRET_PREFIX: &str = "bundle_";
|
||||
|
||||
async fn get_latest_version_secret_id(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let id_o = sqlx::query_scalar!(
|
||||
"SELECT app.versions[array_upper(app.versions, 1)] FROM app
|
||||
WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let id = not_found_if_none(id_o, "App", path.to_string())?;
|
||||
|
||||
let mc = build_crypt(&db, &w_id).await?;
|
||||
|
||||
let hx = hex::encode(mc.encrypt_str_to_bytes(format!("{}{}", BUNDLE_SECRET_PREFIX, id)));
|
||||
|
||||
Ok(hx)
|
||||
}
|
||||
|
||||
use windmill_common::error;
|
||||
|
||||
async fn store_raw_app_file<'a>(
|
||||
w_id: &str,
|
||||
id: &i64,
|
||||
file_type: &str,
|
||||
data: bytes::Bytes,
|
||||
tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
|
||||
) -> Result<()> {
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
let object_store = windmill_common::s3_helpers::get_object_store().await;
|
||||
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
|
||||
let object_store: Option<()> = None;
|
||||
|
||||
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
if let Some(os) = object_store {
|
||||
if let Err(e) = os
|
||||
.put(&object_store::path::Path::from(path.clone()), data.into())
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to put snapshot to s3 at {path}: {:?}", e);
|
||||
return Err(error::Error::ExecutionErr(format!(
|
||||
"Failed to put {path} to s3"
|
||||
)));
|
||||
}
|
||||
tracing::info!("Successfully put snapshot to s3 at {path}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO app_bundles (app_version_id, w_id, file_type, data) VALUES ($1, $2, $3, $4)",
|
||||
id,
|
||||
w_id,
|
||||
file_type,
|
||||
data.to_vec()
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
macro_rules! process_app_multipart {
|
||||
($authed:expr, $user_db:expr, $db:expr, $w_id:expr, $path:expr, $multipart:expr, $internal_fn:expr) => {
|
||||
async {
|
||||
let mut saved_app = None;
|
||||
let mut uploaded_js = false;
|
||||
|
||||
//todo: use s3 instead
|
||||
let file_path = format!("/tmp/wmill/{}", $w_id);
|
||||
std::fs::create_dir_all(&file_path).unwrap();
|
||||
|
||||
let mut multipart = $multipart;
|
||||
while let Some(field) = multipart.next_field().await.unwrap() {
|
||||
let name = field.name().unwrap().to_string();
|
||||
@@ -831,9 +986,8 @@ macro_rules! process_app_multipart {
|
||||
.await?;
|
||||
saved_app = Some((npath, nid, ntx));
|
||||
} else if name == "js" {
|
||||
if let Some((_npath, id, _tx)) = saved_app.as_ref() {
|
||||
let file_path = format!("{}/{}.js", file_path, id);
|
||||
std::fs::write(file_path, data).unwrap();
|
||||
if let Some((_npath, id, tx)) = saved_app.as_mut() {
|
||||
store_raw_app_file($w_id, &id, "js", data, tx).await?;
|
||||
uploaded_js = true;
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
@@ -841,9 +995,8 @@ macro_rules! process_app_multipart {
|
||||
));
|
||||
}
|
||||
} else if name == "css" {
|
||||
if let Some((_npath, id, _tx)) = saved_app.as_ref() {
|
||||
let file_path = format!("{}/{}.css", file_path, id);
|
||||
std::fs::write(file_path, data).unwrap();
|
||||
if let Some((_npath, id, tx)) = saved_app.as_mut() {
|
||||
store_raw_app_file($w_id, &id, "css", data, tx).await?;
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"App payload need to be created first".to_string(),
|
||||
|
||||
@@ -20,7 +20,7 @@ console.log('Running postinstall for root project');
|
||||
|
||||
import { x } from 'tar'
|
||||
|
||||
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-d44b577.tar.gz'
|
||||
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-8957900.tar.gz'
|
||||
const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz')
|
||||
const extractTo = path.join(process.cwd(), 'static/ui_builder/')
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler, stopPropagation } from 'svelte/legacy'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import { Alert, Drawer, DrawerContent, UndoRedo } from '$lib/components/common'
|
||||
import { Drawer, DrawerContent, UndoRedo } from '$lib/components/common'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { AppService, DraftService, SettingService, type Policy } from '$lib/gen'
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { redo, undo } from '$lib/history.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import type { Item } from '$lib/utils'
|
||||
@@ -22,7 +18,6 @@
|
||||
FormInput,
|
||||
History,
|
||||
Laptop2,
|
||||
Loader2,
|
||||
Save,
|
||||
Smartphone,
|
||||
FileClock,
|
||||
@@ -30,8 +25,7 @@
|
||||
Moon,
|
||||
SunMoon,
|
||||
Zap,
|
||||
Globe,
|
||||
AlertTriangle
|
||||
Globe
|
||||
} from 'lucide-svelte'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import {
|
||||
@@ -51,7 +45,6 @@
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeploymentHistory from './DeploymentHistory.svelte'
|
||||
import Awareness from '$lib/components/Awareness.svelte'
|
||||
@@ -75,14 +68,14 @@
|
||||
computeWorkspaceS3FileInputPolicy,
|
||||
computeS3ImageViewerPolicy
|
||||
} from './appUtilsS3'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { base } from '$lib/base'
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
import AppJobsDrawer from './AppJobsDrawer.svelte'
|
||||
import { collectStaticFields, type TriggerableV2 } from './commonAppUtils'
|
||||
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
|
||||
import { Sha256 } from '@aws-crypto/sha256-js'
|
||||
import type { DiffDrawerI } from '$lib/components/diff_drawer'
|
||||
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
|
||||
import AppEditorHeaderDeployInitialDraft from './AppEditorHeaderDeployInitialDraft.svelte'
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
|
||||
async function hash(message) {
|
||||
try {
|
||||
@@ -158,6 +151,7 @@
|
||||
let deployedBy: string | undefined = $state(undefined) // Author
|
||||
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
||||
let open: boolean = $state(false) // Is confirmation modal open
|
||||
let customPathError: string = $state('')
|
||||
|
||||
const {
|
||||
app,
|
||||
@@ -184,7 +178,7 @@
|
||||
|
||||
let selectedJobId: string | undefined = $state(undefined)
|
||||
|
||||
let pathError: string | undefined = $state(undefined)
|
||||
let pathError: string = $state('')
|
||||
let appExport: AppExportButton | undefined = $state()
|
||||
|
||||
let draftDrawerOpen = $state(false)
|
||||
@@ -193,7 +187,7 @@
|
||||
let historyBrowserDrawerOpen = $state(false)
|
||||
let debugAppDrawerOpen = $state(false)
|
||||
let lazyDrawerOpen = $state(false)
|
||||
let deploymentMsg: string | undefined = $state(undefined)
|
||||
let deploymentMsg = $state('')
|
||||
|
||||
function closeSaveDrawer() {
|
||||
saveDrawerOpen = false
|
||||
@@ -533,15 +527,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let secretUrl: string | undefined = $state(undefined)
|
||||
|
||||
async function getSecretUrl() {
|
||||
secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: $appPath
|
||||
})
|
||||
}
|
||||
|
||||
async function setPublishState() {
|
||||
await computeTriggerables()
|
||||
await AppService.updateApp({
|
||||
@@ -792,15 +777,6 @@
|
||||
lock = false
|
||||
}
|
||||
|
||||
let dirtyPath = $state(false)
|
||||
let path: Path | undefined = $state(undefined)
|
||||
|
||||
let secretUrlHref = $derived(
|
||||
secretUrl
|
||||
? `${window.location.origin}${base}/public/${$workspaceStore}/${secretUrl}`
|
||||
: undefined
|
||||
)
|
||||
|
||||
let moreItems = $derived([
|
||||
{
|
||||
displayName: 'Deployment history',
|
||||
@@ -820,9 +796,13 @@
|
||||
{
|
||||
displayName: 'Public URL',
|
||||
icon: Globe,
|
||||
disabled: !secretUrlHref,
|
||||
href: secretUrlHref,
|
||||
hrefTarget: '_blank'
|
||||
action: async () => {
|
||||
const secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: $appPath
|
||||
})
|
||||
window.open(computeSecretUrl(secretUrl), '_blank')
|
||||
}
|
||||
},
|
||||
// {
|
||||
// displayName: 'Publish to Hub',
|
||||
@@ -927,42 +907,7 @@
|
||||
setTheme($app?.darkMode)
|
||||
|
||||
let customPath = $state(savedApp?.custom_path)
|
||||
let dirtyCustomPath = $state(false)
|
||||
let customPathError = $state('')
|
||||
let globalWorkspacedRoute = $state(false)
|
||||
|
||||
async function loadGlobalWorkspacedRouteSetting() {
|
||||
try {
|
||||
const setting = await SettingService.getGlobal({ key: 'app_workspaced_route' })
|
||||
globalWorkspacedRoute = (setting as boolean) ?? false
|
||||
} catch (error) {
|
||||
globalWorkspacedRoute = false
|
||||
}
|
||||
}
|
||||
|
||||
async function appExists(customPath: string) {
|
||||
return await AppService.customPathExists({
|
||||
workspace: $workspaceStore!,
|
||||
customPath
|
||||
})
|
||||
}
|
||||
let validateTimeout: number | undefined = undefined
|
||||
async function validateCustomPath(customPath: string): Promise<void> {
|
||||
customPathError = ''
|
||||
if (validateTimeout) {
|
||||
clearTimeout(validateTimeout)
|
||||
}
|
||||
validateTimeout = setTimeout(async () => {
|
||||
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
|
||||
customPathError = 'Invalid path'
|
||||
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
|
||||
customPathError = 'Path already taken'
|
||||
} else {
|
||||
customPathError = ''
|
||||
}
|
||||
validateTimeout = undefined
|
||||
}, 500)
|
||||
}
|
||||
$effect(() => {
|
||||
if ($openDebugRun == undefined) {
|
||||
$openDebugRun = (jobId: string) => {
|
||||
@@ -971,23 +916,11 @@
|
||||
}
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
$appPath && $appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
saveDrawerOpen && untrack(() => compareVersions())
|
||||
})
|
||||
let hasErrors = $derived(Object.keys($errorByComponent).length > 0)
|
||||
let fullCustomUrl = $derived(
|
||||
`${window.location.origin}${base}/a/${
|
||||
isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : ''
|
||||
}${customPath}`
|
||||
)
|
||||
$effect(() => {
|
||||
;[customPath]
|
||||
untrack(() => customPath !== undefined && validateCustomPath(customPath))
|
||||
})
|
||||
loadGlobalWorkspacedRouteSetting()
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
@@ -1028,45 +961,6 @@
|
||||
{#if $appPath == ''}
|
||||
<Drawer bind:open={draftDrawerOpen} size="800px">
|
||||
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
|
||||
<Alert bgClass="mb-4" title="Require path" type="info">
|
||||
Choose a path to save the initial draft of the app.
|
||||
</Alert>
|
||||
<h3>Summary</h3>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="App summary"
|
||||
class="text-sm w-full font-semibold"
|
||||
onkeydown={stopPropagation(bubble('keydown'))}
|
||||
bind:value={$summary}
|
||||
onkeyup={() => {
|
||||
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
$summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-2"></div>
|
||||
<Path
|
||||
autofocus={false}
|
||||
bind:this={path}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
bind:dirty={dirtyPath}
|
||||
initialPath=""
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
/>
|
||||
<div class="py-4"></div>
|
||||
|
||||
{#snippet actions()}
|
||||
<div>
|
||||
<Button
|
||||
@@ -1078,6 +972,8 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<AppEditorHeaderDeployInitialDraft {summary} {appPath} bind:pathError bind:newEditedPath />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
@@ -1100,59 +996,6 @@
|
||||
/>
|
||||
<Drawer bind:open={saveDrawerOpen} size="800px">
|
||||
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
|
||||
{#if !onLatest}
|
||||
<Alert title="You're not on the latest app version. " type="warning">
|
||||
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
|
||||
</Alert>
|
||||
<div class="py-2"></div>
|
||||
{/if}
|
||||
<span class="text-secondary text-sm font-bold">Summary</span>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="App summary"
|
||||
class="text-sm w-full"
|
||||
bind:value={$summary}
|
||||
onkeydown={stopPropagation(bubble('keydown'))}
|
||||
onkeyup={() => {
|
||||
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
$summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-4"></div>
|
||||
<span class="text-secondary text-sm font-bold">Deployment message</span>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Optional deployment message"
|
||||
class="text-sm w-full"
|
||||
bind:value={deploymentMsg}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-4"></div>
|
||||
<span class="text-secondary text-sm font-bold">Path</span>
|
||||
<Path
|
||||
bind:this={path}
|
||||
bind:dirty={dirtyPath}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
initialPath={newPath}
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
autofocus={false}
|
||||
/>
|
||||
|
||||
{#snippet actions()}
|
||||
<div class="flex flex-row gap-4">
|
||||
<Button
|
||||
@@ -1212,111 +1055,21 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="py-2"></div>
|
||||
<Alert title="App executed on behalf of you">
|
||||
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
|
||||
<Tooltip>
|
||||
It ensures that all required resources/runnable visible for publisher but not for viewer at
|
||||
time of creating the app would prevent the execution of the app. To guarantee tight
|
||||
security, a policy is computed at time of deployment of the app which only allow the
|
||||
scripts/flows referred to in the app to be called on behalf of. Furthermore, static
|
||||
parameters are not overridable. Hence, users will only be able to use the app as intended by
|
||||
the publisher without risk for leaking resources not used in the app.
|
||||
</Tooltip>
|
||||
</Alert>
|
||||
|
||||
<div class="mt-10"></div>
|
||||
|
||||
<h2>Public URL</h2>
|
||||
|
||||
<div class="my-6">
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Require login and read-access`,
|
||||
right: `No login required`
|
||||
}}
|
||||
checked={policy.execution_mode == 'anonymous'}
|
||||
on:change={(e) => {
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
setPublishState()
|
||||
}}
|
||||
disabled={$appPath == ''}
|
||||
/>
|
||||
</div>
|
||||
{#if $appPath == ''}
|
||||
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
|
||||
{:else if secretUrlHref}
|
||||
<ClipboardPanel content={secretUrlHref} size="md" />
|
||||
{:else}<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
Share this url directly or embed it using an iframe (if requiring login, top-level domain of
|
||||
embedding app must be the same as the one of Windmill)
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
<Alert type="warning" title="Admin only" size="xs">
|
||||
Custom path can only be set by workspace admins
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap mb-2">
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
<Toggle
|
||||
on:change={({ detail }) => {
|
||||
customPath = detail ? '' : undefined
|
||||
if (customPath === undefined) {
|
||||
customPathError = ''
|
||||
}
|
||||
}}
|
||||
checked={customPath !== undefined}
|
||||
options={{
|
||||
right: 'Use a custom URL'
|
||||
}}
|
||||
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
/>
|
||||
|
||||
{#if customPath !== undefined}
|
||||
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
|
||||
<div>Custom path</div>
|
||||
</div>
|
||||
<input
|
||||
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={customPath}
|
||||
class={customPathError === ''
|
||||
? ''
|
||||
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
|
||||
oninput={() => {
|
||||
dirtyCustomPath = true
|
||||
}}
|
||||
/>
|
||||
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
|
||||
<div>Custom public URL</div>
|
||||
</div>
|
||||
<ClipboardPanel content={fullCustomUrl} size="md" />
|
||||
|
||||
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
|
||||
>{dirtyCustomPath ? customPathError : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Alert type="info" title="Only latest deployed app is publicly available">
|
||||
You will still need to deploy the app to make visible the latest changes
|
||||
</Alert>
|
||||
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
|
||||
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
|
||||
>
|
||||
<AppEditorHeaderDeploy
|
||||
{newPath}
|
||||
{policy}
|
||||
{setPublishState}
|
||||
appPath={$appPath}
|
||||
{onLatest}
|
||||
{savedApp}
|
||||
bind:summary={$summary}
|
||||
bind:customPath
|
||||
bind:deploymentMsg
|
||||
bind:customPathError
|
||||
bind:pathError
|
||||
bind:newEditedPath
|
||||
hideSecretUrl={false}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { Alert } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Loader2, AlertTriangle } from 'lucide-svelte'
|
||||
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { AppService, SettingService } from '$lib/gen'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
let {
|
||||
policy,
|
||||
setPublishState,
|
||||
appPath,
|
||||
customPath = $bindable(),
|
||||
onLatest,
|
||||
savedApp,
|
||||
summary = $bindable(),
|
||||
deploymentMsg = $bindable(),
|
||||
customPathError = $bindable(),
|
||||
pathError = $bindable(),
|
||||
newEditedPath = $bindable(),
|
||||
newPath,
|
||||
hideSecretUrl = false
|
||||
}: {
|
||||
policy: any
|
||||
setPublishState: () => void
|
||||
appPath: string
|
||||
customPath: string | undefined
|
||||
onLatest: boolean
|
||||
savedApp: any
|
||||
summary: string
|
||||
deploymentMsg: string | undefined
|
||||
customPathError: string
|
||||
pathError: string
|
||||
newEditedPath: string
|
||||
newPath: string
|
||||
hideSecretUrl: boolean
|
||||
} = $props()
|
||||
let dirtyCustomPath = $state(false)
|
||||
let path: Path | undefined = $state(undefined)
|
||||
|
||||
let dirtyPath = $state(false)
|
||||
|
||||
async function appExists(customPath: string) {
|
||||
return await AppService.customPathExists({
|
||||
workspace: $workspaceStore!,
|
||||
customPath
|
||||
})
|
||||
}
|
||||
|
||||
let globalWorkspacedRoute = $state(false)
|
||||
|
||||
async function loadGlobalWorkspacedRouteSetting() {
|
||||
try {
|
||||
const setting = await SettingService.getGlobal({ key: 'app_workspaced_route' })
|
||||
globalWorkspacedRoute = (setting as boolean) ?? false
|
||||
} catch (error) {
|
||||
globalWorkspacedRoute = false
|
||||
}
|
||||
}
|
||||
|
||||
loadGlobalWorkspacedRouteSetting()
|
||||
|
||||
let secretUrl: string | undefined = $state(undefined)
|
||||
let secretUrlHref = $derived(secretUrl ? computeSecretUrl(secretUrl) : undefined)
|
||||
let fullCustomUrl = $derived(
|
||||
`${window.location.origin}${base}/a/${
|
||||
isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : ''
|
||||
}${customPath}`
|
||||
)
|
||||
async function getSecretUrl() {
|
||||
secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
|
||||
let validateTimeout: number | undefined = undefined
|
||||
async function validateCustomPath(customPath: string): Promise<void> {
|
||||
customPathError = ''
|
||||
if (validateTimeout) {
|
||||
clearTimeout(validateTimeout)
|
||||
}
|
||||
validateTimeout = setTimeout(async () => {
|
||||
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
|
||||
customPathError = 'Invalid path'
|
||||
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
|
||||
customPathError = 'Path already taken'
|
||||
} else {
|
||||
customPathError = ''
|
||||
}
|
||||
validateTimeout = undefined
|
||||
}, 500)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
;[customPath]
|
||||
untrack(() => customPath !== undefined && validateCustomPath(customPath))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl())
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if !onLatest}
|
||||
<Alert title="You're not on the latest app version. " type="warning">
|
||||
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
|
||||
</Alert>
|
||||
<div class="py-2"></div>
|
||||
{/if}
|
||||
<span class="text-secondary text-sm font-bold">Summary</span>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="App summary"
|
||||
class="text-sm w-full"
|
||||
bind:value={summary}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onkeyup={() => {
|
||||
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-4"></div>
|
||||
<span class="text-secondary text-sm font-bold">Deployment message</span>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Optional deployment message"
|
||||
class="text-sm w-full"
|
||||
bind:value={deploymentMsg}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-4"></div>
|
||||
<span class="text-secondary text-sm font-bold">Path</span>
|
||||
<Path
|
||||
bind:this={path}
|
||||
bind:dirty={dirtyPath}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
initialPath={newPath}
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
autofocus={false}
|
||||
/>
|
||||
|
||||
<div class="py-2"></div>
|
||||
<Alert title="App executed on behalf of you">
|
||||
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
|
||||
<Tooltip>
|
||||
It ensures that all required resources/runnable visible for publisher but not for viewer at time
|
||||
of creating the app would prevent the execution of the app. To guarantee tight security, a
|
||||
policy is computed at time of deployment of the app which only allow the scripts/flows referred
|
||||
to in the app to be called on behalf of. Furthermore, static parameters are not overridable.
|
||||
Hence, users will only be able to use the app as intended by the publisher without risk for
|
||||
leaking resources not used in the app.
|
||||
</Tooltip>
|
||||
</Alert>
|
||||
|
||||
<div class="mt-10"></div>
|
||||
|
||||
{#if !hideSecretUrl}
|
||||
<h2>Public URL</h2>
|
||||
|
||||
<div class="my-6">
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Require login and read-access`,
|
||||
right: `No login required`
|
||||
}}
|
||||
checked={policy.execution_mode == 'anonymous'}
|
||||
on:change={(e) => {
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
setPublishState()
|
||||
}}
|
||||
disabled={appPath == ''}
|
||||
/>
|
||||
</div>
|
||||
{#if appPath == ''}
|
||||
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
|
||||
{:else if secretUrlHref}
|
||||
<ClipboardPanel content={secretUrlHref} size="md" />
|
||||
{:else}<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
Share this url directly or embed it using an iframe (if requiring login, top-level domain of
|
||||
embedding app must be the same as the one of Windmill)
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
<Alert type="warning" title="Admin only" size="xs">
|
||||
Custom path can only be set by workspace admins
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
{#if !$enterpriseLicense}
|
||||
<div class="flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap mb-2">
|
||||
<AlertTriangle size={16} />
|
||||
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
<Toggle
|
||||
on:change={({ detail }) => {
|
||||
customPath = detail ? '' : undefined
|
||||
if (customPath === undefined) {
|
||||
customPathError = ''
|
||||
}
|
||||
}}
|
||||
checked={customPath !== undefined}
|
||||
options={{
|
||||
right: 'Use a custom URL'
|
||||
}}
|
||||
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
/>
|
||||
|
||||
{#if customPath !== undefined}
|
||||
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
|
||||
<div>Custom path</div>
|
||||
</div>
|
||||
<input
|
||||
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={customPath}
|
||||
class={customPathError === ''
|
||||
? ''
|
||||
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
|
||||
oninput={() => {
|
||||
dirtyCustomPath = true
|
||||
}}
|
||||
/>
|
||||
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
|
||||
<div>Custom public URL</div>
|
||||
</div>
|
||||
<ClipboardPanel content={fullCustomUrl} size="md" />
|
||||
|
||||
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
|
||||
>{dirtyCustomPath ? customPathError : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Alert type="info" title="Only latest deployed app is publicly available">
|
||||
You will still need to deploy the app to make visible the latest changes
|
||||
</Alert>
|
||||
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
|
||||
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
|
||||
>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Alert } from '$lib/components/common'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
|
||||
let { summary, appPath, pathError = $bindable(), newEditedPath = $bindable() } = $props()
|
||||
|
||||
let path: Path | undefined = $state(undefined)
|
||||
let dirtyPath = $state(false)
|
||||
</script>
|
||||
|
||||
<Alert bgClass="mb-4" title="Require path" type="info">
|
||||
Choose a path to save the initial draft of the app.
|
||||
</Alert>
|
||||
<h3>Summary</h3>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="App summary"
|
||||
class="text-sm w-full font-semibold"
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
bind:value={$summary}
|
||||
onkeyup={() => {
|
||||
if ($appPath == '' && $summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
$summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-2"></div>
|
||||
<Path
|
||||
autofocus={false}
|
||||
bind:this={path}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
bind:dirty={dirtyPath}
|
||||
initialPath=""
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
/>
|
||||
<div class="py-4"></div>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { base } from "$lib/base"
|
||||
import { workspaceStore } from "$lib/stores"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
export function computeSecretUrl(secretUrl: string) {
|
||||
return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}`
|
||||
}
|
||||
@@ -6,7 +6,11 @@
|
||||
import SvelteIcon from '../icons/SvelteIcon.svelte'
|
||||
import VueIcon from '../icons/VueIcon.svelte'
|
||||
|
||||
export let file: string
|
||||
interface Props {
|
||||
file: string
|
||||
}
|
||||
|
||||
let { file }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if file.endsWith('.tsx')}
|
||||
|
||||
@@ -5,13 +5,25 @@
|
||||
import type { HiddenRunnable, JobById } from '../apps/types'
|
||||
import { JobService } from '$lib/gen'
|
||||
|
||||
export let iframe: HTMLIFrameElement | undefined
|
||||
export let path: string
|
||||
export let runnables: Record<string, HiddenRunnable>
|
||||
export let jobs: string[] = []
|
||||
export let jobsById: Record<string, JobById> = {}
|
||||
export let editor: boolean
|
||||
export let workspace: string
|
||||
interface Props {
|
||||
iframe: HTMLIFrameElement | undefined
|
||||
path: string
|
||||
runnables: Record<string, HiddenRunnable>
|
||||
jobs?: string[]
|
||||
jobsById?: Record<string, JobById>
|
||||
editor: boolean
|
||||
workspace: string
|
||||
}
|
||||
|
||||
let {
|
||||
iframe,
|
||||
path,
|
||||
runnables,
|
||||
jobs = $bindable([]),
|
||||
jobsById = $bindable({}),
|
||||
editor,
|
||||
workspace
|
||||
}: Props = $props()
|
||||
|
||||
let listener = async (event) => {
|
||||
const data = event.data
|
||||
@@ -87,4 +99,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:message={listener} />
|
||||
<svelte:window onmessage={listener} />
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { writable } from 'svelte/store'
|
||||
import RawAppInlineScriptsPanel from './RawAppInlineScriptsPanel.svelte'
|
||||
import type { HiddenRunnable, JobById } from '../apps/types'
|
||||
import RawAppEditorHeader from './RawAppEditorHeader.svelte'
|
||||
@@ -52,7 +51,7 @@
|
||||
}: Props = $props()
|
||||
export const version: number | undefined = undefined
|
||||
|
||||
let runnables = writable(initRunnables)
|
||||
let runnables = $state(initRunnables)
|
||||
|
||||
let files: Record<string, string> | undefined = $state(initFiles)
|
||||
|
||||
@@ -65,7 +64,7 @@
|
||||
path != '' ? `rawapp-${path}` : 'rawapp',
|
||||
encodeState({
|
||||
files,
|
||||
runnables: $runnables
|
||||
runnables: runnables
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -97,7 +96,7 @@
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'setRunnables',
|
||||
dts: genWmillTs($runnables)
|
||||
dts: genWmillTs(runnables)
|
||||
},
|
||||
'*'
|
||||
)
|
||||
@@ -129,7 +128,7 @@
|
||||
|
||||
let darkMode: boolean = $state(false)
|
||||
run(() => {
|
||||
$runnables && files && saveFrontendDraft()
|
||||
runnables && files && saveFrontendDraft()
|
||||
})
|
||||
run(() => {
|
||||
iframe?.addEventListener('load', () => {
|
||||
@@ -140,7 +139,7 @@
|
||||
iframe && iframeLoaded && initFiles && populateFiles()
|
||||
})
|
||||
run(() => {
|
||||
iframe && iframeLoaded && $runnables && populateRunnables()
|
||||
iframe && iframeLoaded && runnables && populateRunnables()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -153,7 +152,7 @@
|
||||
{iframe}
|
||||
bind:jobs
|
||||
bind:jobsById
|
||||
runnables={$runnables}
|
||||
{runnables}
|
||||
{path}
|
||||
/>
|
||||
<div class="max-h-screen overflow-hidden h-screen min-h-0 flex flex-col">
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Badge, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import { Badge, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
Bug,
|
||||
DiffIcon,
|
||||
FileJson,
|
||||
FileUp,
|
||||
History,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pen,
|
||||
Save
|
||||
} from 'lucide-svelte'
|
||||
import { Bug, DiffIcon, FileJson, FileUp, History, MoreVertical, Pen, Save } from 'lucide-svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
@@ -30,7 +18,6 @@
|
||||
import AppExportButton from '../apps/editor/AppExportButton.svelte'
|
||||
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte'
|
||||
import Awareness from '$lib/components/Awareness.svelte'
|
||||
@@ -38,17 +25,16 @@
|
||||
|
||||
import Summary from '$lib/components/Summary.svelte'
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { base } from '$lib/base'
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
|
||||
import type { HiddenRunnable } from '../apps/types'
|
||||
import type { Writable } from 'svelte/store'
|
||||
import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte'
|
||||
import type { Runnable } from '../apps/inputType'
|
||||
import { collectStaticFields, hash, type TriggerableV2 } from '../apps/editor/commonAppUtils'
|
||||
import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte'
|
||||
import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte'
|
||||
|
||||
// async function hash(message) {
|
||||
// try {
|
||||
@@ -65,60 +51,75 @@
|
||||
// const hex = result.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
|
||||
// return hex
|
||||
// }
|
||||
// }
|
||||
|
||||
export let summary: string
|
||||
export let policy: Policy
|
||||
export let diffDrawer: DiffDrawer | undefined = undefined
|
||||
export let savedApp:
|
||||
| {
|
||||
value: any
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined = undefined
|
||||
export let version: number | undefined = undefined
|
||||
interface Props {
|
||||
// }
|
||||
summary: string
|
||||
policy: Policy
|
||||
diffDrawer?: DiffDrawer | undefined
|
||||
savedApp?:
|
||||
| {
|
||||
value: any
|
||||
draft?: any
|
||||
path: string
|
||||
summary: string
|
||||
policy: any
|
||||
draft_only?: boolean
|
||||
custom_path?: string
|
||||
}
|
||||
| undefined
|
||||
version?: number | undefined
|
||||
newApp: boolean
|
||||
newPath?: string
|
||||
appPath: string
|
||||
runnables: Record<string, HiddenRunnable>
|
||||
files: Record<string, string> | undefined
|
||||
jobs: string[]
|
||||
jobsById: Record<string, any>
|
||||
getBundle: () => Promise<{
|
||||
js: string
|
||||
css: string
|
||||
}>
|
||||
}
|
||||
|
||||
export let newApp: boolean
|
||||
export let newPath: string = ''
|
||||
export let appPath: string
|
||||
export let runnables: Writable<Record<string, HiddenRunnable>>
|
||||
export let files: Record<string, string> | undefined
|
||||
export let jobs: string[]
|
||||
export let jobsById: Record<string, any>
|
||||
export let getBundle: () => Promise<{
|
||||
js: string
|
||||
css: string
|
||||
}>
|
||||
let {
|
||||
summary = $bindable(),
|
||||
policy = $bindable(),
|
||||
diffDrawer = undefined,
|
||||
savedApp = $bindable(undefined),
|
||||
version = $bindable(undefined),
|
||||
newApp,
|
||||
newPath = '',
|
||||
appPath,
|
||||
runnables,
|
||||
files,
|
||||
jobs = $bindable(),
|
||||
jobsById = $bindable(),
|
||||
getBundle
|
||||
}: Props = $props()
|
||||
|
||||
let newEditedPath = ''
|
||||
let newEditedPath = $state('')
|
||||
|
||||
$: app = files ? { runnables: $runnables, files } : undefined
|
||||
|
||||
let deployedValue: Value | undefined = undefined // Value to diff against
|
||||
let deployedBy: string | undefined = undefined // Author
|
||||
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
|
||||
let open: boolean = false // Is confirmation modal open
|
||||
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
||||
let deployedBy: string | undefined = $state(undefined) // Author
|
||||
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
||||
let open: boolean = $state(false) // Is confirmation modal open
|
||||
|
||||
// const { app, summary, appPath, jobs, jobsById, staticExporter } = getContext('AppViewerContext')
|
||||
|
||||
const loading = {
|
||||
const loading = $state({
|
||||
publish: false,
|
||||
save: false,
|
||||
saveDraft: false
|
||||
}
|
||||
})
|
||||
|
||||
let pathError: string | undefined = undefined
|
||||
let appExport: AppExportButton
|
||||
let pathError: string = $state('')
|
||||
let appExport = $state() as AppExportButton | undefined
|
||||
|
||||
let draftDrawerOpen = false
|
||||
let saveDrawerOpen = false
|
||||
let historyBrowserDrawerOpen = false
|
||||
let deploymentMsg: string | undefined = undefined
|
||||
let draftDrawerOpen = $state(false)
|
||||
let saveDrawerOpen = $state(false)
|
||||
let historyBrowserDrawerOpen = $state(false)
|
||||
let deploymentMsg: string | undefined = $state(undefined)
|
||||
|
||||
function closeSaveDrawer() {
|
||||
saveDrawerOpen = false
|
||||
@@ -136,7 +137,7 @@
|
||||
: `u/${$userStore?.username}`
|
||||
policy.triggerables_v2 = Object.fromEntries(
|
||||
(await Promise.all(
|
||||
Object.values($runnables).map(async (runnable) => {
|
||||
Object.values(runnables).map(async (runnable) => {
|
||||
return await processRunnable(runnable.name, runnable, runnable.fields)
|
||||
})
|
||||
)) as [string, TriggerableV2][]
|
||||
@@ -333,17 +334,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let secretUrl: string | undefined = undefined
|
||||
|
||||
$: appPath && appPath != '' && secretUrl == undefined && getSecretUrl()
|
||||
|
||||
async function getSecretUrl() {
|
||||
secretUrl = await AppService.getPublicSecretOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath
|
||||
})
|
||||
}
|
||||
|
||||
async function setPublishState() {
|
||||
await computeTriggerables()
|
||||
await AppService.updateApp({
|
||||
@@ -532,7 +522,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let onLatest = true
|
||||
let onLatest = $state(true)
|
||||
async function compareVersions() {
|
||||
if (version === undefined) {
|
||||
return
|
||||
@@ -549,11 +539,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: saveDrawerOpen && compareVersions()
|
||||
|
||||
let dirtyPath = false
|
||||
let path: Path | undefined = undefined
|
||||
|
||||
let moreItems = [
|
||||
{
|
||||
displayName: 'Deployment history',
|
||||
@@ -567,7 +552,7 @@
|
||||
displayName: 'Export',
|
||||
icon: FileJson,
|
||||
action: () => {
|
||||
appExport.open(app)
|
||||
appExport?.open(app)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -609,38 +594,10 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let customPath = savedApp?.custom_path
|
||||
let dirtyCustomPath = false
|
||||
let customPathError = ''
|
||||
$: fullCustomUrl = `${window.location.origin}${base}/a/${
|
||||
isCloudHosted() ? $workspaceStore + '/' : ''
|
||||
}${customPath}`
|
||||
async function appExists(customPath: string) {
|
||||
return await AppService.customPathExists({
|
||||
workspace: $workspaceStore!,
|
||||
customPath
|
||||
})
|
||||
}
|
||||
let validateTimeout: number | undefined = undefined
|
||||
async function validateCustomPath(customPath: string): Promise<void> {
|
||||
customPathError = ''
|
||||
if (validateTimeout) {
|
||||
clearTimeout(validateTimeout)
|
||||
}
|
||||
validateTimeout = setTimeout(async () => {
|
||||
if (!/^[\w-]+(\/[\w-]+)*$/.test(customPath)) {
|
||||
customPathError = 'Invalid path'
|
||||
} else if (customPath !== savedApp?.custom_path && (await appExists(customPath))) {
|
||||
customPathError = 'Path already taken'
|
||||
} else {
|
||||
customPathError = ''
|
||||
}
|
||||
validateTimeout = undefined
|
||||
}, 500)
|
||||
}
|
||||
$: customPath !== undefined && validateCustomPath(customPath)
|
||||
let customPath = $state(savedApp?.custom_path)
|
||||
let customPathError = $state('')
|
||||
|
||||
let jobsDrawerOpen = false
|
||||
let jobsDrawerOpen = $state(false)
|
||||
|
||||
function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
return {
|
||||
@@ -654,6 +611,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
let app = $derived(files ? { runnables: runnables, files } : undefined)
|
||||
|
||||
$effect(() => {
|
||||
saveDrawerOpen && compareVersions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
|
||||
@@ -676,45 +638,6 @@
|
||||
{#if appPath == ''}
|
||||
<Drawer bind:open={draftDrawerOpen} size="800px">
|
||||
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
|
||||
<Alert bgClass="mb-4" title="Require path" type="info">
|
||||
Choose a path to save the initial draft of the app.
|
||||
</Alert>
|
||||
<h3>Summary</h3>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="App summary"
|
||||
class="text-sm w-full font-semibold"
|
||||
on:keydown|stopPropagation
|
||||
bind:value={summary}
|
||||
on:keyup={() => {
|
||||
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-2"></div>
|
||||
<Path
|
||||
autofocus={false}
|
||||
bind:this={path}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
bind:dirty={dirtyPath}
|
||||
initialPath=""
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
/>
|
||||
<div class="py-4"></div>
|
||||
|
||||
{#snippet actions()}
|
||||
<div>
|
||||
<Button
|
||||
@@ -726,64 +649,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
<AppEditorHeaderDeployInitialDraft {summary} {appPath} bind:pathError bind:newEditedPath />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
<Drawer bind:open={saveDrawerOpen} size="800px">
|
||||
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
|
||||
{#if !onLatest}
|
||||
<Alert title="You're not on the latest app version. " type="warning">
|
||||
By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff.
|
||||
</Alert>
|
||||
<div class="py-2"></div>
|
||||
{/if}
|
||||
<span class="text-secondary text-sm font-bold">Summary</span>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="App summary"
|
||||
class="text-sm w-full"
|
||||
bind:value={summary}
|
||||
on:keydown|stopPropagation
|
||||
on:keyup={() => {
|
||||
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-4"></div>
|
||||
<span class="text-secondary text-sm font-bold">Deployment message</span>
|
||||
<div class="w-full pt-2">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Optional deployment message"
|
||||
class="text-sm w-full"
|
||||
bind:value={deploymentMsg}
|
||||
/>
|
||||
</div>
|
||||
<div class="py-4"></div>
|
||||
<span class="text-secondary text-sm font-bold">Path</span>
|
||||
<Path
|
||||
bind:this={path}
|
||||
bind:dirty={dirtyPath}
|
||||
bind:error={pathError}
|
||||
bind:path={newEditedPath}
|
||||
initialPath={newPath}
|
||||
namePlaceholder="app"
|
||||
kind="app"
|
||||
autofocus={false}
|
||||
/>
|
||||
|
||||
{#snippet actions()}
|
||||
<div class="flex flex-row gap-4">
|
||||
<Button
|
||||
@@ -843,116 +714,22 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="py-2"></div>
|
||||
{#if appPath == ''}
|
||||
<Alert title="Require saving" type="error">
|
||||
Save this app once before you can publish it
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert title="App executed on behalf of you">
|
||||
A viewer of the app will execute the runnables of the app on behalf of the publisher (you)
|
||||
<Tooltip>
|
||||
It ensures that all required resources/runnable visible for publisher but not for viewer
|
||||
at time of creating the app would prevent the execution of the app. To guarantee tight
|
||||
security, a policy is computed at time of deployment of the app which only allow the
|
||||
scripts/flows referred to in the app to be called on behalf of. Furthermore, static
|
||||
parameters are not overridable. Hence, users will only be able to use the app as intended
|
||||
by the publisher without risk for leaking resources not used in the app.
|
||||
</Tooltip>
|
||||
</Alert>
|
||||
|
||||
<div class="mt-10"></div>
|
||||
|
||||
<h2>Public URL</h2>
|
||||
<div class="mt-4"></div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Require login and read-access`,
|
||||
right: `No login required`
|
||||
}}
|
||||
checked={policy.execution_mode == 'anonymous'}
|
||||
on:change={(e) => {
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
setPublishState()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-6 box">
|
||||
<div class="text-secondary">
|
||||
<div>Public URL</div>
|
||||
</div>
|
||||
{#if secretUrl}
|
||||
{@const href = `${window.location.origin}${base}/public/${$workspaceStore}/${secretUrl}`}
|
||||
<ClipboardPanel content={href} size="md" />
|
||||
{:else}<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
Share this url directly or embed it using an iframe (if requiring login, top-level domain
|
||||
of embedding app must be the same as the one of Windmill)
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !$enterpriseLicense}
|
||||
<Alert title="EE Only" type="warning" size="xs">
|
||||
Custom path is an enterprise only feature.
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{:else if !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
<Alert type="warning" title="Admin only" size="xs">
|
||||
Custom path can only be set by workspace admins
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
<Toggle
|
||||
on:change={({ detail }) => {
|
||||
customPath = detail ? '' : undefined
|
||||
}}
|
||||
checked={customPath !== undefined}
|
||||
options={{
|
||||
right: 'Use a custom URL'
|
||||
}}
|
||||
disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
/>
|
||||
|
||||
{#if customPath !== undefined}
|
||||
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
|
||||
<div>Custom path</div>
|
||||
</div>
|
||||
<input
|
||||
disabled={!($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={customPath}
|
||||
class={customPathError === ''
|
||||
? ''
|
||||
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
|
||||
on:input={() => {
|
||||
dirtyCustomPath = true
|
||||
}}
|
||||
/>
|
||||
<div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between">
|
||||
<div>Custom public URL</div>
|
||||
</div>
|
||||
<ClipboardPanel content={fullCustomUrl} size="md" />
|
||||
|
||||
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
|
||||
>{dirtyCustomPath ? customPathError : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Alert type="info" title="Only latest deployed app is publicly available">
|
||||
You will still need to deploy the app to make visible the latest changes
|
||||
</Alert>
|
||||
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification"
|
||||
class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a
|
||||
>
|
||||
{/if}
|
||||
<AppEditorHeaderDeploy
|
||||
{newPath}
|
||||
{policy}
|
||||
{setPublishState}
|
||||
{appPath}
|
||||
{onLatest}
|
||||
{savedApp}
|
||||
{summary}
|
||||
bind:customPath
|
||||
bind:deploymentMsg
|
||||
bind:customPathError
|
||||
bind:pathError
|
||||
bind:newEditedPath
|
||||
hideSecretUrl={true}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -988,7 +765,7 @@
|
||||
<div class="flex justify-start w-full border rounded-md overflow-hidden">
|
||||
<div>
|
||||
<button
|
||||
on:click={async () => {
|
||||
onclick={async () => {
|
||||
saveDrawerOpen = true
|
||||
setTimeout(() => {
|
||||
document.getElementById('path')?.focus()
|
||||
@@ -1009,7 +786,7 @@
|
||||
value={defaultIfEmptyString(newEditedPath, newPath)}
|
||||
size={defaultIfEmptyString(newEditedPath, newPath)?.length || 50}
|
||||
class="font-mono !text-xs !min-w-[96px] !max-w-[300px] !w-full !h-[28px] !my-0 !py-0 !border-l-0 !rounded-l-none !border-0 !shadow-none"
|
||||
on:focus={({ currentTarget }) => {
|
||||
onfocus={({ currentTarget }) => {
|
||||
currentTarget.select()
|
||||
}}
|
||||
/>
|
||||
@@ -1021,13 +798,13 @@
|
||||
{/if}
|
||||
<div class="flex flex-row gap-2 justify-end items-center overflow-visible">
|
||||
<DropdownV2 items={moreItems} class="h-auto">
|
||||
<svelte:fragment slot="buttonReplacement">
|
||||
{#snippet buttonReplacement()}
|
||||
<Button nonCaptureEvent size="xs" color="light">
|
||||
<div class="flex flex-row items-center">
|
||||
<MoreVertical size={14} />
|
||||
</div>
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<div class="hidden md:inline relative overflow-visible">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler, stopPropagation } from 'svelte/legacy'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import type { Preview } from '$lib/gen'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
@@ -19,20 +22,33 @@
|
||||
import RunButton from '$lib/components/RunButton.svelte'
|
||||
import { computeFields } from '../apps/editor/inlineScriptsPanel/utils'
|
||||
|
||||
let inlineScriptEditorDrawer: InlineScriptEditorDrawer
|
||||
let inlineScriptEditorDrawer = $state() as InlineScriptEditorDrawer | undefined
|
||||
|
||||
export let inlineScript: InlineScript | undefined
|
||||
export let name: string | undefined = undefined
|
||||
export let id: string
|
||||
export let fields: Record<string, AppInput> = {}
|
||||
export let path: string
|
||||
export let isLoading: boolean = false
|
||||
export let onRun: () => Promise<void>
|
||||
export let onCancel: () => Promise<void>
|
||||
interface Props {
|
||||
inlineScript: InlineScript | undefined
|
||||
name?: string | undefined
|
||||
id: string
|
||||
fields?: Record<string, AppInput>
|
||||
path: string
|
||||
isLoading?: boolean
|
||||
onRun: () => Promise<void>
|
||||
onCancel: () => Promise<void>
|
||||
editor?: Editor | undefined
|
||||
}
|
||||
|
||||
export let editor: Editor | undefined = undefined
|
||||
let diffEditor: DiffEditor
|
||||
let validCode = true
|
||||
let {
|
||||
inlineScript = $bindable(),
|
||||
name = $bindable(undefined),
|
||||
id,
|
||||
fields = $bindable({}),
|
||||
path,
|
||||
isLoading = false,
|
||||
onRun,
|
||||
onCancel,
|
||||
editor = $bindable(undefined)
|
||||
}: Props = $props()
|
||||
let diffEditor = $state() as DiffEditor | undefined
|
||||
let validCode = $state(true)
|
||||
|
||||
async function inferInlineScriptSchema(
|
||||
language: Preview['language'],
|
||||
@@ -72,7 +88,7 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let drawerIsOpen: boolean | undefined = undefined
|
||||
let drawerIsOpen: boolean | undefined = $state(undefined)
|
||||
</script>
|
||||
|
||||
{#if inlineScript}
|
||||
@@ -95,11 +111,11 @@
|
||||
{#if name !== undefined}
|
||||
<div class="flex flex-row gap-2 w-full items-center">
|
||||
<input
|
||||
on:keydown|stopPropagation
|
||||
onkeydown={stopPropagation(bubble('keydown'))}
|
||||
bind:value={name}
|
||||
placeholder="Inline script name"
|
||||
class="!text-xs !rounded-sm !shadow-none"
|
||||
on:keyup={() => {
|
||||
onkeyup={() => {
|
||||
// $app = $app
|
||||
// if (stateId) {
|
||||
// $stateId++
|
||||
|
||||
@@ -5,28 +5,25 @@
|
||||
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
|
||||
import DocLink from '../apps/editor/settingsPanel/DocLink.svelte'
|
||||
import HideButton from '../apps/editor/settingsPanel/HideButton.svelte'
|
||||
import type { Writable } from 'svelte/store'
|
||||
import type { Runnable } from '../apps/inputType'
|
||||
import { getNextId } from '$lib/components/flows/idUtils'
|
||||
|
||||
interface Props {
|
||||
selectedRunnable: string | undefined
|
||||
runnables: Writable<Record<string, Runnable>>
|
||||
runnables: Record<string, Runnable>
|
||||
}
|
||||
|
||||
let { selectedRunnable = $bindable(), runnables }: Props = $props()
|
||||
|
||||
function createBackgroundScript() {
|
||||
const nid = getNextId(Object.keys($runnables ?? {}))
|
||||
const nid = getNextId(Object.keys(runnables ?? {}))
|
||||
const newScriptPath = `Backend Runnable ${nid}`
|
||||
runnables.update((r) => {
|
||||
r[nid] = {
|
||||
name: newScriptPath,
|
||||
inlineScript: undefined,
|
||||
type: 'runnableByName'
|
||||
}
|
||||
return r
|
||||
})
|
||||
runnables[nid] = {
|
||||
name: newScriptPath,
|
||||
inlineScript: undefined,
|
||||
type: 'runnableByName'
|
||||
}
|
||||
|
||||
selectedRunnable = nid
|
||||
}
|
||||
|
||||
@@ -62,8 +59,8 @@
|
||||
<div class="w-full flex flex-col gap-6 py-1">
|
||||
<div>
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
{#if Object.keys($runnables ?? {}).length > 0}
|
||||
{#each Object.entries($runnables ?? {}) as [id, runnable]}
|
||||
{#if Object.keys(runnables ?? {}).length > 0}
|
||||
{#each Object.entries(runnables ?? {}) as [id, runnable]}
|
||||
{#if runnable}
|
||||
<button
|
||||
{id}
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Writable } from 'svelte/store'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import RawAppInlineScriptPanelList from './RawAppInlineScriptPanelList.svelte'
|
||||
import RawAppInlineScripRunnable from './RawAppInlineScriptRunnable.svelte'
|
||||
import { createScriptFromInlineScript } from '../apps/editor/inlineScriptsPanel/utils'
|
||||
import type { Runnable } from '../apps/inputType'
|
||||
|
||||
export let runnables: Writable<Record<string, Runnable>>
|
||||
export let selectedRunnable: string | undefined
|
||||
export let appPath: string
|
||||
interface Props {
|
||||
runnables: Record<string, Runnable>
|
||||
selectedRunnable: string | undefined
|
||||
appPath: string
|
||||
width?: number | undefined
|
||||
}
|
||||
|
||||
export let width: number | undefined = undefined
|
||||
let { runnables, selectedRunnable = $bindable(), appPath, width = undefined }: Props = $props()
|
||||
</script>
|
||||
|
||||
<Splitpanes
|
||||
@@ -28,7 +30,7 @@
|
||||
<div class="text-sm text-secondary text-center py-8 px-2">
|
||||
Select a runnable on the left panel
|
||||
</div>
|
||||
{:else if $runnables?.[selectedRunnable]}
|
||||
{:else if runnables?.[selectedRunnable]}
|
||||
{#key selectedRunnable}
|
||||
<RawAppInlineScripRunnable
|
||||
{appPath}
|
||||
@@ -41,16 +43,13 @@
|
||||
)
|
||||
}}
|
||||
on:delete={() => {
|
||||
runnables.update((runnables) => {
|
||||
if (selectedRunnable) {
|
||||
delete runnables[selectedRunnable]
|
||||
}
|
||||
selectedRunnable = undefined
|
||||
return { ...runnables }
|
||||
})
|
||||
if (selectedRunnable) {
|
||||
delete runnables[selectedRunnable]
|
||||
}
|
||||
selectedRunnable = undefined
|
||||
}}
|
||||
id={selectedRunnable}
|
||||
bind:runnable={$runnables[selectedRunnable]}
|
||||
bind:runnable={runnables[selectedRunnable]}
|
||||
/>{/key}
|
||||
{:else}
|
||||
<div class="text-sm text-tertiary text-center py-8 px-2">
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
|
||||
import { htmlContent } from './utils'
|
||||
|
||||
export let workspace: string
|
||||
export let user: UserExt | undefined
|
||||
export let version: number
|
||||
export let path: string
|
||||
export let runnables: Record<string, HiddenRunnable>
|
||||
interface Props {
|
||||
workspace: string
|
||||
user: UserExt | undefined
|
||||
secret: string | undefined
|
||||
path: string
|
||||
runnables: Record<string, HiddenRunnable>
|
||||
}
|
||||
|
||||
let iframe: HTMLIFrameElement
|
||||
let { workspace, user, secret, path, runnables }: Props = $props()
|
||||
|
||||
let iframe = $state() as HTMLIFrameElement | undefined
|
||||
</script>
|
||||
|
||||
<RawAppBackgroundRunner {workspace} editor={false} {iframe} {runnables} {path} />
|
||||
@@ -18,6 +22,6 @@
|
||||
<iframe
|
||||
bind:this={iframe}
|
||||
title="raw-app"
|
||||
srcDoc={htmlContent(workspace, version, { ctx: user, workspace })}
|
||||
srcDoc={htmlContent(workspace, secret, { ctx: user, workspace })}
|
||||
class="w-full h-full min-h-screen bg-white border-none"
|
||||
></iframe>
|
||||
|
||||
@@ -7,21 +7,21 @@ export type RawApp = {
|
||||
files: string[]
|
||||
}
|
||||
|
||||
export function htmlContent(workspace: string, version: number, ctx: any) {
|
||||
export function htmlContent(workspace: string, secret: string | undefined, ctx: any) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>App Preview</title>
|
||||
<link rel="stylesheet" href="/api/w/${workspace}/apps/get_data/v/${version}.css" />
|
||||
<link rel="stylesheet" href="/api/w/${workspace}/apps/get_data/v/${secret}.css" />
|
||||
<script>
|
||||
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/api/w/${workspace}/apps/get_data/v/${version}.js"></script>
|
||||
<script src="/api/w/${workspace}/apps/get_data/v/${secret}.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -58,20 +58,20 @@ export function genWmillTs(runnables: Record<string, HiddenRunnable>) {
|
||||
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
|
||||
|
||||
${Object.entries(runnables)
|
||||
.map(([k, v]) => `export type RunBg${capitalize(k)} = ${hiddenRunnableToTsType(v)}\n`)
|
||||
.map(([k, v]) => `export type RunBg${capitalize(k)} = ${hiddenRunnableToTsType(v)}\n`)
|
||||
|
||||
.join('\n')}
|
||||
.join('\n')}
|
||||
|
||||
export const runBg = {
|
||||
${Object.keys(runnables)
|
||||
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<any>`)
|
||||
.join(',\n')}
|
||||
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<any>`)
|
||||
.join(',\n')}
|
||||
}
|
||||
|
||||
export const runBgAsync = {
|
||||
${Object.keys(runnables)
|
||||
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<string>`)
|
||||
.join(',\n')}
|
||||
.map((k) => ` ${k}: null as unknown as (data: RunBg${capitalize(k)}) => Promise<string>`)
|
||||
.join(',\n')}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
let app = $state(undefined) as AppWithLastVersion | undefined
|
||||
|
||||
let secret = $state(undefined) as string | undefined
|
||||
async function loadApp() {
|
||||
console.log('Loading app')
|
||||
app = await AppService.getAppLiteByPath({
|
||||
@@ -21,17 +22,22 @@
|
||||
})
|
||||
}
|
||||
|
||||
async function loadSecret() {
|
||||
secret = await AppService.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
$workspaceStore && loadApp()
|
||||
$workspaceStore && loadSecret()
|
||||
})
|
||||
|
||||
let can_write = $derived(canWrite(page.params.path ?? '', app?.extra_perms ?? {}, $userStore))
|
||||
function getRunnables(app: AppWithLastVersion) {
|
||||
return (app?.value?.runnables ?? {}) as Record<string, HiddenRunnable>
|
||||
}
|
||||
function getVersion(app: AppWithLastVersion) {
|
||||
return app?.value?.version as number
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full min-h-[600px] w-full relative p-2bg-white">
|
||||
@@ -43,7 +49,7 @@
|
||||
workspace={$workspaceStore}
|
||||
user={$userStore}
|
||||
runnables={getRunnables(app)}
|
||||
version={getVersion(app)}
|
||||
{secret}
|
||||
/>
|
||||
{/if}
|
||||
{#if can_write && !hideEditBtn}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
cd ../../windmill-ui-code-builder
|
||||
cd ../../../windmill-code-ui-builder
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
HASH=${HASH::-1}
|
||||
|
||||
sed -i "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../windmill/frontend/scripts/untar_ui_builder.js
|
||||
sed -i "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../git/windmill/frontend/scripts/untar_ui_builder.js
|
||||
|
||||
Reference in New Issue
Block a user