perf: cache workspace env variables to avoid one query (#5499)

This commit is contained in:
Ruben Fiszel
2025-03-22 18:37:48 +01:00
committed by GitHub
parent d0b38b54dc
commit a3f6db7dca
6 changed files with 512 additions and 439 deletions
@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TRIGGER workspace_envs_change_trigger ON workspace_env;
DROP FUNCTION notify_workspace_envs_change();
@@ -0,0 +1,15 @@
-- Add up migration script here
-- Add up migration script here
CREATE OR REPLACE FUNCTION notify_workspace_envs_change()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('notify_workspace_envs_change', NEW.workspace_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER workspace_envs_change_trigger
AFTER INSERT OR UPDATE OF name, value OR DELETE ON workspace_env
FOR EACH ROW
EXECUTE FUNCTION notify_workspace_envs_change();
+6
View File
@@ -702,6 +702,11 @@ Windmill Community Edition {GIT_VERSION}
tracing::info!("Webhook change detected, invalidating webhook cache: {}", workspace_id);
windmill_api::webhook_util::WEBHOOK_CACHE.remove(workspace_id);
},
"notify_workspace_envs_change" => {
let workspace_id = n.payload();
tracing::info!("Workspace envs change detected, invalidating workspace envs cache: {}", workspace_id);
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(workspace_id);
},
"notify_global_setting_change" => {
tracing::info!("Global setting change detected: {}", n.payload());
match n.payload() {
@@ -980,6 +985,7 @@ async fn listen_pg(url: &str) -> Option<PgListener> {
"notify_config_change",
"notify_global_setting_change",
"notify_webhook_change",
"notify_workspace_envs_change",
])
.await
{
+156 -125
View File
@@ -10,6 +10,7 @@ use crate::error;
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
use chrono::{SecondsFormat, Utc};
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
lazy_static::lazy_static! {
@@ -160,6 +161,10 @@ pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result<String> {
pub const WM_SCHEDULED_FOR: &str = "WM_SCHEDULED_FOR";
lazy_static::lazy_static! {
pub static ref CUSTOM_ENVS_CACHE: Cache<String, (i64, Vec<(String, String)>)> = Cache::new(100);
}
pub async fn get_reserved_variables(
db: &DB,
w_id: &str,
@@ -201,6 +206,8 @@ pub async fn get_reserved_variables(
}
};
let custom_envs = get_cached_workspace_envs(db, w_id).await;
let joined_schedule_path = schedule_path
.clone()
.unwrap_or("manual".to_string())
@@ -223,133 +230,157 @@ pub async fn get_reserved_variables(
};
vec![
ContextualVariable {
name: "WM_WORKSPACE".to_string(),
value: w_id.to_string(),
description: "Workspace id of the current script".to_string(),
ContextualVariable {
name: "WM_WORKSPACE".to_string(),
value: w_id.to_string(),
description: "Workspace id of the current script".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_TOKEN".to_string(),
value: token.to_string(),
description: "Token ephemeral to the current script with equal permission to the \
permission of the run (Usable as a bearer token)"
.to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_TOKEN".to_string(),
value: token.to_string(),
description: "Token ephemeral to the current script with equal permission to the \
permission of the run (Usable as a bearer token)"
.to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_EMAIL".to_string(),
value: email.to_string(),
description: "Email of the user that executed the current script".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_USERNAME".to_string(),
value: username.to_string(),
description: "Username of the user that executed the current script".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_BASE_URL".to_string(),
value: BASE_URL.read().await.clone(),
description: "base url of this instance".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_JOB_ID".to_string(),
value: job_id.to_string(),
description: "Job id of the current script".to_string(),
is_custom: false,
},
ContextualVariable {
name: WM_SCHEDULED_FOR.to_string(),
value: scheduled_for
.map(|ts| ts.to_rfc3339_opts(SecondsFormat::Secs, true))
.unwrap_or_else(|| "".to_string()),
description: "date-time in UTC (e.g: 2014-11-28T12:45:59.324310806Z) of when the job was scheduled".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_JOB_PATH".to_string(),
value: path.unwrap_or_else(|| "".to_string()),
description: "Path of the script or flow being run if any".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_FLOW_JOB_ID".to_string(),
value: flow_id.unwrap_or_else(|| "".to_string()),
description: "Job id of the encapsulating flow if the job is a flow step".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_ROOT_FLOW_JOB_ID".to_string(),
value: root_flow_id.unwrap_or_else(|| "".to_string()),
description: "Job id of the root flow if the job is a flow step".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_FLOW_PATH".to_string(),
value: flow_path.unwrap_or_else(|| "".to_string()),
description: "Path of the encapsulating flow if the job is a flow step".to_string(),
is_custom: false,
},
},
ContextualVariable {
name: "WM_EMAIL".to_string(),
value: email.to_string(),
description: "Email of the user that executed the current script".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_USERNAME".to_string(),
value: username.to_string(),
description: "Username of the user that executed the current script".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_BASE_URL".to_string(),
value: BASE_URL.read().await.clone(),
description: "base url of this instance".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_JOB_ID".to_string(),
value: job_id.to_string(),
description: "Job id of the current script".to_string(),
is_custom: false,
},
ContextualVariable {
name: WM_SCHEDULED_FOR.to_string(),
value: scheduled_for
.map(|ts| ts.to_rfc3339_opts(SecondsFormat::Secs, true))
.unwrap_or_else(|| "".to_string()),
description: "date-time in UTC (e.g: 2014-11-28T12:45:59.324310806Z) of when the job was scheduled".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_JOB_PATH".to_string(),
value: path.unwrap_or_else(|| "".to_string()),
description: "Path of the script or flow being run if any".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_FLOW_JOB_ID".to_string(),
value: flow_id.unwrap_or_else(|| "".to_string()),
description: "Job id of the encapsulating flow if the job is a flow step".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_ROOT_FLOW_JOB_ID".to_string(),
value: root_flow_id.unwrap_or_else(|| "".to_string()),
description: "Job id of the root flow if the job is a flow step".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_FLOW_PATH".to_string(),
value: flow_path.unwrap_or_else(|| "".to_string()),
description: "Path of the encapsulating flow if the job is a flow step".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_SCHEDULE_PATH".to_string(),
value: schedule_path.unwrap_or_else(|| "".to_string()),
description: "Path of the schedule if the job of the step or encapsulating step has \
been triggered by a schedule"
.to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_PERMISSIONED_AS".to_string(),
value: permissioned_as.to_string(),
description: "Fully Qualified (u/g) owner name of executor of the job".to_string(),
ContextualVariable {
name: "WM_SCHEDULE_PATH".to_string(),
value: schedule_path.unwrap_or_else(|| "".to_string()),
description: "Path of the schedule if the job of the step or encapsulating step has \
been triggered by a schedule"
.to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_STATE_PATH".to_string(),
value: state_path.clone(),
description: "State resource path unique to a script and its trigger".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_FLOW_STEP_ID".to_string(),
value: step_id.unwrap_or_else(|| "".to_string()),
description: "The node id in a flow (like 'a', 'b', or 'f')".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_OBJECT_PATH".to_string(),
value: object_path,
description: "Script or flow step execution unique path, useful for storing results in an external service".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_OIDC_JWT".to_string(),
value: jwt_token.unwrap_or_else(|| "".to_string()),
description: "OIDC JWT token (EE only)".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_WORKER_GROUP".to_string(),
value: WORKER_GROUP.clone(),
description: "name of the worker group the job is running on".to_string(),
is_custom: false,
},
].into_iter().chain( sqlx::query_as::<_, (String, String)>(
"SELECT name, value FROM workspace_env WHERE workspace_id = $1",
)
.bind(w_id)
.fetch_all(db)
.await
.unwrap_or_default()
.into_iter().map(|(name, value)| ContextualVariable {
name,
value,
description: "Custom workspace environment variable".to_string(),
is_custom: true,
})).collect()
},
ContextualVariable {
name: "WM_PERMISSIONED_AS".to_string(),
value: permissioned_as.to_string(),
description: "Fully Qualified (u/g) owner name of executor of the job".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_STATE_PATH".to_string(),
value: state_path.clone(),
description: "State resource path unique to a script and its trigger".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_FLOW_STEP_ID".to_string(),
value: step_id.unwrap_or_else(|| "".to_string()),
description: "The node id in a flow (like 'a', 'b', or 'f')".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_OBJECT_PATH".to_string(),
value: object_path,
description: "Script or flow step execution unique path, useful for storing results in an external service".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_OIDC_JWT".to_string(),
value: jwt_token.unwrap_or_else(|| "".to_string()),
description: "OIDC JWT token (EE only)".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_WORKER_GROUP".to_string(),
value: WORKER_GROUP.clone(),
description: "name of the worker group the job is running on".to_string(),
is_custom: false,
},
].into_iter().chain(custom_envs.into_iter().map(|(name, value)| ContextualVariable {
name,
value,
description: "Custom workspace environment variable".to_string(),
is_custom: true,
})
).collect()
}
async fn get_cached_workspace_envs(
db: &sqlx::Pool<sqlx::Postgres>,
w_id: &str,
) -> Vec<(String, String)> {
let cached_envs_o = CUSTOM_ENVS_CACHE.get(w_id).and_then(|(ts, envs)| {
if ts > chrono::Utc::now().timestamp() - (60 * 15) {
Some(envs)
} else {
None
}
});
let custom_envs = if let Some(cached_envs) = cached_envs_o {
cached_envs
} else {
let custom_envs = sqlx::query_as::<_, (String, String)>(
"SELECT name, value FROM workspace_env WHERE workspace_id = $1",
)
.bind(w_id)
.fetch_all(db)
.await
.unwrap_or_default();
CUSTOM_ENVS_CACHE.insert(
w_id.to_string(),
(chrono::Utc::now().timestamp(), custom_envs.clone()),
);
custom_envs
};
custom_envs
}
@@ -40,9 +40,17 @@
name: name
}
})
sendUserToast(`${edit ? 'Updated' : 'Created'} contextual variable ${name}`)
sendUserToast(
`${
edit ? 'Updated' : 'Created'
} contextual variable ${name}. It may take up to a few minutes to update.`
)
dispatch('update')
drawer.closeDrawer()
setTimeout(() => {
dispatch('update')
}, 5000)
}
</script>
@@ -126,7 +126,12 @@
}
})
loadContextualVariables()
sendUserToast(`Custom contextual variable ${row.name} was deleted`)
sendUserToast(
`Custom contextual variable ${row.name} was deleted. It may take up to a few minutes to update.`
)
setTimeout(() => {
loadContextualVariables()
}, 5000)
}
</script>
@@ -139,327 +144,332 @@
f={(x) => x.path + ' ' + x.description}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.variables}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Variables"
tooltip="Save and permission strings to be reused in Scripts and Flows."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
>
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
<Button
size="md"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button size="md" startIcon={{ icon: Plus }} on:click={() => variableEditor.initNew()}>
New&nbsp;variable
</Button>
{/if}
</div>
</PageHeader>
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
<ContextualVariableEditor
bind:this={contextualVariableEditor}
on:update={loadContextualVariables}
/>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadVariables()
}}
/>
<Tabs bind:selected={tab}>
<Tab size="md" value="workspace">
<div class="flex gap-2 items-center my-1">
<Building size={18} />
Workspace
<CenteredPage>
<PageHeader
title="Variables"
tooltip="Save and permission strings to be reused in Scripts and Flows."
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets"
>
<div class="flex flex-row justify-end">
{#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)}
<Button
size="md"
startIcon={{ icon: Plus }}
on:click={() => contextualVariableEditor.initNew()}
>
New&nbsp;contextual&nbsp;variable
</Button>
{:else}
<Button size="md" startIcon={{ icon: Plus }} on:click={() => variableEditor.initNew()}>
New&nbsp;variable
</Button>
{/if}
</div>
</Tab>
<Tab size="md" value="contextual">
<div class="flex gap-2 items-center my-1">
<DollarSign size={18} />
Contextual
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
>
Contextual variables are passed as environment variables when running a script and depends
on the execution context.
</Tooltip>
</div>
</Tab>
</Tabs>
{#if tab == 'workspace'}
<div class="pt-2">
<input placeholder="Search Variable" bind:value={filter} class="input mt-1" />
</div>
<div class="min-h-[56px]">
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
</div>
<div class="relative overflow-x-auto pb-40 pr-4">
{#if !filteredItems}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(3) as _}
<Skeleton layout={[[3.5], 0.5]} />
{/each}
{:else if filteredItems.length == 0}
<div class="flex flex-col items-center justify-center h-full">
<div class="text-md font-medium">No variables found</div>
<div class="text-sm text-secondary">
Try changing the filters or creating a new variable
</div>
</PageHeader>
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
<ContextualVariableEditor
bind:this={contextualVariableEditor}
on:update={loadContextualVariables}
/>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadVariables()
}}
/>
<Tabs bind:selected={tab}>
<Tab size="md" value="workspace">
<div class="flex gap-2 items-center my-1">
<Building size={18} />
Workspace
</div>
{:else}
<DataTable size="xs">
<Head>
<tr>
<Cell head first class="!px-0" />
<Cell head>Path</Cell>
<Cell head>Value</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last />
</tr>
</Head>
<tbody class="divide-y">
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, marked }}
<Row>
<Cell class="!px-0 text-center w-12" first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
</Cell>
<Cell>
<a
class="break-all"
id="edit-{path}"
on:click={() => variableEditor.editVariable(path)}
href="#{path}"
>
{#if marked}
{@html marked}
{:else}
{path}
{/if}
</a>
</Cell>
<Cell>
<span class="inline-flex flex-row items-center gap-2">
<div class="text-sm break-words">
{#if value}
{truncate(value, 20)}
{:else}
&lowast;&lowast;&lowast;&lowast;
{/if}
</div>
{#if is_secret}
<Popover notClickable>
<EyeOff size={12} />
<span slot="text">This item is secret</span>
</Popover>
{/if}
</span>
</Cell>
<Cell class="break-words">
<span class="text-xs text-tertiary">{truncate(description ?? '', 50)} </span>
</Cell>
<Cell class="text-center">
<div class="flex flex-row items-center gap-4">
{#if is_linked}
<Popover notClickable>
<Link size={16} />
<div slot="text">
This variable is linked with a resource of the same path. They are deleted
and renamed together.
</div>
</Popover>
{/if}
{#if account}
<Popover notClickable>
<RefreshCw size={16} />
<div slot="text">
This OAuth token will be kept up-to-date in the background by Windmill
using its refresh token
</div>
</Popover>
{/if}
{#if is_refreshed}
<div class="">
{#if refresh_error}
<Popover notClickable>
<div class="relative inline-flex justify-center items-center w-4 h-4">
<Circle
class="text-red-600 animate-ping absolute z-50 w-4 h-4 fill-current"
size={12}
/>
<Circle
class="text-red-600 relative inline-flex fill-current "
size={12}
/>
</div>
<div slot="text">
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
</div>
</Popover>
{:else if is_expired}
<Popover notClickable>
<Circle
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The access_token is expired, it will get renewed the next time this
variable is fetched or you can request is to be refreshed in the
dropdown on the right.
</div>
</Popover>
{:else}
<Popover notClickable>
<Circle
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The variable was connected through OAuth and the token is not expired.
</div>
</Popover>
{/if}
</div>
{/if}
</div>
</Cell>
<Cell last shouldStopPropagation>
<Dropdown
items={() => {
let owner = isOwner(path, $userStore, $workspaceStore)
return [
{
displayName: 'Edit',
icon: Pen,
action: () => variableEditor.editVariable(path),
disabled: !canWrite
},
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: (event) => {
if (event['shiftKey']) {
deleteVariable(path, account)
} else {
deleteConfirmedCallback = () => {
deleteVariable(path, account)
}
}
},
disabled: !owner
},
...(isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer.openDrawer(path, 'variable')
}
}
]
: []),
{
displayName: owner ? 'Share' : 'See Permissions',
action: () => {
shareModal.openDrawer(path, 'variable')
},
icon: Share
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: RefreshCw,
action: async () => {
await OauthService.refreshToken({
workspace: $workspaceStore ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadVariables()
}
}
]
: [])
]
}}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
</div>
{:else if tab == 'contextual'}
<div class="overflow-auto">
{#if loading.contextual}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(8) as _}
<Skeleton layout={[[2.8], 0.5]} />
{/each}
{:else}
<PageHeader title="Custom contextual variables" primary={false} />
{#if contextualVariables.filter((x) => x.is_custom).length === 0}
</Tab>
<Tab size="md" value="contextual">
<div class="flex gap-2 items-center my-1">
<DollarSign size={18} />
Contextual
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
>
Contextual variables are passed as environment variables when running a script and
depends on the execution context.
</Tooltip>
</div>
</Tab>
</Tabs>
{#if tab == 'workspace'}
<div class="pt-2">
<input placeholder="Search Variable" bind:value={filter} class="input mt-1" />
</div>
<div class="min-h-[56px]">
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
</div>
<div class="relative overflow-x-auto pb-40 pr-4">
{#if !filteredItems}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(3) as _}
<Skeleton layout={[[3.5], 0.5]} />
{/each}
{:else if filteredItems.length == 0}
<div class="flex flex-col items-center justify-center h-full">
<div class="text-md font-medium">No custom contextual variables found</div>
<div class="text-md font-medium">No variables found</div>
<div class="text-sm text-secondary">
Try changing the filters or creating a new variable
</div>
</div>
{:else}
<TableSimple
headers={['Name', 'Value']}
data={contextualVariables.filter((x) => x.is_custom)}
keys={['name', 'value']}
getRowActions={$userStore?.is_admin || $userStore?.is_super_admin
? (row) => {
return [
{
displayName: 'Edit',
action: () => contextualVariableEditor.editVariable(row.name, row.value)
},
{
displayName: 'Delete',
type: 'delete',
action: () => {
deleteContextualVariable(row)
<DataTable size="xs">
<Head>
<tr>
<Cell head first class="!px-0" />
<Cell head>Path</Cell>
<Cell head>Value</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last />
</tr>
</Head>
<tbody class="divide-y">
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, marked }}
<Row>
<Cell class="!px-0 text-center w-12" first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
</Cell>
<Cell>
<a
class="break-all"
id="edit-{path}"
on:click={() => variableEditor.editVariable(path)}
href="#{path}"
>
{#if marked}
{@html marked}
{:else}
{path}
{/if}
</a>
</Cell>
<Cell>
<span class="inline-flex flex-row items-center gap-2">
<div class="text-sm break-words">
{#if value}
{truncate(value, 20)}
{:else}
&lowast;&lowast;&lowast;&lowast;
{/if}
</div>
{#if is_secret}
<Popover notClickable>
<EyeOff size={12} />
<span slot="text">This item is secret</span>
</Popover>
{/if}
</span>
</Cell>
<Cell class="break-words">
<span class="text-xs text-tertiary">{truncate(description ?? '', 50)} </span>
</Cell>
<Cell class="text-center">
<div class="flex flex-row items-center gap-4">
{#if is_linked}
<Popover notClickable>
<Link size={16} />
<div slot="text">
This variable is linked with a resource of the same path. They are
deleted and renamed together.
</div>
</Popover>
{/if}
{#if account}
<Popover notClickable>
<RefreshCw size={16} />
<div slot="text">
This OAuth token will be kept up-to-date in the background by Windmill
using its refresh token
</div>
</Popover>
{/if}
{#if is_refreshed}
<div class="">
{#if refresh_error}
<Popover notClickable>
<div class="relative inline-flex justify-center items-center w-4 h-4">
<Circle
class="text-red-600 animate-ping absolute z-50 w-4 h-4 fill-current"
size={12}
/>
<Circle
class="text-red-600 relative inline-flex fill-current "
size={12}
/>
</div>
<div slot="text">
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
</div>
</Popover>
{:else if is_expired}
<Popover notClickable>
<Circle
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The access_token is expired, it will get renewed the next time this
variable is fetched or you can request is to be refreshed in the
dropdown on the right.
</div>
</Popover>
{:else}
<Popover notClickable>
<Circle
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The variable was connected through OAuth and the token is not
expired.
</div>
</Popover>
{/if}
</div>
{/if}
</div>
</Cell>
<Cell last shouldStopPropagation>
<Dropdown
items={() => {
let owner = isOwner(path, $userStore, $workspaceStore)
return [
{
displayName: 'Edit',
icon: Pen,
action: () => variableEditor.editVariable(path),
disabled: !canWrite
},
{
displayName: 'Delete',
icon: Trash,
type: 'delete',
action: (event) => {
if (event['shiftKey']) {
deleteVariable(path, account)
} else {
deleteConfirmedCallback = () => {
deleteVariable(path, account)
}
}
},
disabled: !owner
},
...(isDeployable(
is_secret ? 'secret' : 'variable',
path,
deployUiSettings
)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer.openDrawer(path, 'variable')
}
}
]
: []),
{
displayName: owner ? 'Share' : 'See Permissions',
action: () => {
shareModal.openDrawer(path, 'variable')
},
icon: Share
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: RefreshCw,
action: async () => {
await OauthService.refreshToken({
workspace: $workspaceStore ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadVariables()
}
}
]
: [])
]
}}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
</div>
{:else if tab == 'contextual'}
<div class="overflow-auto">
{#if loading.contextual}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(8) as _}
<Skeleton layout={[[2.8], 0.5]} />
{/each}
{:else}
<PageHeader title="Custom contextual variables" primary={false} />
{#if contextualVariables.filter((x) => x.is_custom).length === 0}
<div class="flex flex-col items-center justify-center h-full">
<div class="text-md font-medium">No custom contextual variables found</div>
</div>
{:else}
<TableSimple
headers={['Name', 'Value']}
data={contextualVariables.filter((x) => x.is_custom)}
keys={['name', 'value']}
getRowActions={$userStore?.is_admin || $userStore?.is_super_admin
? (row) => {
return [
{
displayName: 'Edit',
action: () => contextualVariableEditor.editVariable(row.name, row.value)
},
{
displayName: 'Delete',
type: 'delete',
action: () => {
deleteContextualVariable(row)
}
}
}
]
}
: undefined}
]
}
: undefined}
/>
{/if}
<PageHeader title="Contextual variables" primary={false} />
<TableSimple
headers={['Name', 'Example of value', 'Description']}
data={contextualVariables.filter((x) => !x.is_custom)}
keys={['name', 'value', 'description']}
/>
{/if}
<PageHeader title="Contextual variables" primary={false} />
<TableSimple
headers={['Name', 'Example of value', 'Description']}
data={contextualVariables.filter((x) => !x.is_custom)}
keys={['name', 'value', 'description']}
/>
{/if}
</div>
{/if}
</CenteredPage>
</div>
{/if}
</CenteredPage>
{/if}
<ConfirmationModal