diff --git a/backend/.sqlx/query-6c845f168b6265e6cf92e4d33c5409edfdc1847d0348df2ea55504ddaaa67736.json b/backend/.sqlx/query-1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14.json similarity index 53% rename from backend/.sqlx/query-6c845f168b6265e6cf92e4d33c5409edfdc1847d0348df2ea55504ddaaa67736.json rename to backend/.sqlx/query-1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14.json index b4199d4ca1a90..1bad8b94d073f 100644 --- a/backend/.sqlx/query-6c845f168b6265e6cf92e4d33c5409edfdc1847d0348df2ea55504ddaaa67736.json +++ b/backend/.sqlx/query-1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "ordinal": 3, "name": "color", "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "operator_settings", + "type_info": "Jsonb" } ], "parameters": { @@ -33,8 +38,9 @@ false, false, false, - true + true, + null ] }, - "hash": "6c845f168b6265e6cf92e4d33c5409edfdc1847d0348df2ea55504ddaaa67736" + "hash": "1452033a8e2b160883a649c986d6c7ba2f60f41e1abb6ff8332bd2dfa7379d14" } diff --git a/backend/.sqlx/query-1730f39fd1793d45fbb41b21389c61296a3ff7489ae12f52a19f9543173ac597.json b/backend/.sqlx/query-1730f39fd1793d45fbb41b21389c61296a3ff7489ae12f52a19f9543173ac597.json index 64d273ed70528..b6fee2c5ffd94 100644 --- a/backend/.sqlx/query-1730f39fd1793d45fbb41b21389c61296a3ff7489ae12f52a19f9543173ac597.json +++ b/backend/.sqlx/query-1730f39fd1793d45fbb41b21389c61296a3ff7489ae12f52a19f9543173ac597.json @@ -127,6 +127,11 @@ "ordinal": 24, "name": "color", "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "operator_settings", + "type_info": "Jsonb" } ], "parameters": { @@ -159,6 +164,7 @@ true, true, true, + true, true ] }, diff --git a/backend/.sqlx/query-55cb03040bc2a8c53dd7fbb42bbdcc40f463cbc52d94ed9315cf9a547d4c89f2.json b/backend/.sqlx/query-55cb03040bc2a8c53dd7fbb42bbdcc40f463cbc52d94ed9315cf9a547d4c89f2.json index 6092b0551a48c..baf7cb42f584b 100644 --- a/backend/.sqlx/query-55cb03040bc2a8c53dd7fbb42bbdcc40f463cbc52d94ed9315cf9a547d4c89f2.json +++ b/backend/.sqlx/query-55cb03040bc2a8c53dd7fbb42bbdcc40f463cbc52d94ed9315cf9a547d4c89f2.json @@ -127,6 +127,11 @@ "ordinal": 24, "name": "color", "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "operator_settings", + "type_info": "Jsonb" } ], "parameters": { @@ -159,6 +164,7 @@ true, true, true, + true, true ] }, diff --git a/backend/.sqlx/query-597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3.json b/backend/.sqlx/query-597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3.json new file mode 100644 index 0000000000000..57989bb6dec82 --- /dev/null +++ b/backend/.sqlx/query-597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3" +} diff --git a/backend/.sqlx/query-64920b845c0ce81fb99497c03b249bb6cb06581079b5fc5bea5ddd8e7a895b79.json b/backend/.sqlx/query-64920b845c0ce81fb99497c03b249bb6cb06581079b5fc5bea5ddd8e7a895b79.json deleted file mode 100644 index d201c1c08e341..0000000000000 --- a/backend/.sqlx/query-64920b845c0ce81fb99497c03b249bb6cb06581079b5fc5bea5ddd8e7a895b79.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "select path from script where hash = $1 AND workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "64920b845c0ce81fb99497c03b249bb6cb06581079b5fc5bea5ddd8e7a895b79" -} diff --git a/backend/migrations/20250122211942_operator_settings.down.sql b/backend/migrations/20250122211942_operator_settings.down.sql new file mode 100644 index 0000000000000..4831bcdf10ab1 --- /dev/null +++ b/backend/migrations/20250122211942_operator_settings.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings DROP COLUMN operator_settings; \ No newline at end of file diff --git a/backend/migrations/20250122211942_operator_settings.up.sql b/backend/migrations/20250122211942_operator_settings.up.sql new file mode 100644 index 0000000000000..22bbddb17cc28 --- /dev/null +++ b/backend/migrations/20250122211942_operator_settings.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE workspace_settings ADD COLUMN operator_settings JSONB DEFAULT '{ + "runs": true, + "groups": true, + "folders": true, + "workers": true, + "triggers": true, + "resources": true, + "schedules": true, + "variables": true, + "audit_logs": true +}'; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 537ae2892c31e..6819bb5553ef4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1618,6 +1618,29 @@ paths: schema: $ref: "#/components/schemas/User" + /w/{workspace}/workspaces/operator_settings: + post: + operationId: updateOperatorSettings + summary: Update operator settings for a workspace + description: Updates the operator settings for a specific workspace. Requires workspace admin privileges. + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OperatorSettings" + responses: + '200': + description: Operator settings updated successfully + content: + text/plain: + schema: + type: string + /users/exists/{email}: get: summary: exists email @@ -1745,6 +1768,8 @@ paths: type: boolean color: type: string + operator_settings: + $ref: "#/components/schemas/OperatorSettings" required: - code_completion_enabled - automatic_billing @@ -13643,6 +13668,8 @@ components: type: string color: type: string + operator_settings: + $ref: "#/components/schemas/OperatorSettings" required: - id - name @@ -14501,6 +14528,48 @@ components: required: - trigger_kind + OperatorSettings: + nullable: true + type: object + required: + - runs + - schedules + - resources + - variables + - triggers + - audit_logs + - groups + - folders + - workers + properties: + runs: + type: boolean + description: Whether operators can view runs + schedules: + type: boolean + description: Whether operators can view schedules + resources: + type: boolean + description: Whether operators can view resources + variables: + type: boolean + description: Whether operators can view variables + audit_logs: + type: boolean + description: Whether operators can view audit logs + triggers: + type: boolean + description: Whether operators can view triggers + groups: + type: boolean + description: Whether operators can view groups page + folders: + type: boolean + description: Whether operators can view folders page + workers: + type: boolean + description: Whether operators can view workers page + TeamInfo: type: object required: @@ -14521,7 +14590,7 @@ components: description: List of channels within the team items: $ref: '#/components/schemas/ChannelInfo' - + ChannelInfo: type: object required: diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index a4adc61977990..3758f5def4dce 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -121,7 +121,8 @@ pub fn workspaced_service() -> Router { "/critical_alerts/acknowledge_all", post(acknowledge_all_critical_alerts), ) - .route("/critical_alerts/mute", post(mute_critical_alerts)); + .route("/critical_alerts/mute", post(mute_critical_alerts)) + .route("/operator_settings", post(update_operator_settings)); #[cfg(feature = "stripe")] { @@ -188,6 +189,7 @@ pub struct WorkspaceSettings { pub default_scripts: Option, pub mute_critical_alerts: Option, pub color: Option, + pub operator_settings: Option, } #[derive(FromRow, Serialize, Debug)] @@ -286,6 +288,7 @@ struct UserWorkspace { pub name: String, pub username: String, pub color: Option, + pub operator_settings: Option>, } #[derive(Deserialize)] @@ -1366,7 +1369,8 @@ async fn user_workspaces( let mut tx = db.begin().await?; let workspaces = sqlx::query_as!( UserWorkspace, - "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color + "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, + CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings FROM workspace JOIN usr ON usr.workspace_id = workspace.id JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id @@ -2135,3 +2139,41 @@ async fn mute_critical_alerts( pub async fn mute_critical_alerts() -> Error { Error::NotFound("Critical Alerts require EE".to_string()) } + +#[derive(Deserialize, Serialize)] +struct ChangeOperatorSettings { + runs: bool, + schedules: bool, + resources: bool, + variables: bool, + triggers: bool, + audit_logs: bool, + groups: bool, + folders: bool, + workers: bool, +} + +async fn update_operator_settings( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, + Json(settings): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + let settings_json = serde_json::json!(settings); + + sqlx::query!( + "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2", + settings_json, + &w_id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok("Operator settings updated successfully".to_string()) +} diff --git a/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte b/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte new file mode 100644 index 0000000000000..f12062493fd61 --- /dev/null +++ b/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte @@ -0,0 +1,144 @@ + + +
+
+
+
+
+ Configure the operator visibility settings for your workspace. Toggle the settings you + want to enable. +
+
+
+ +
+
+
+ +
+ + + + Section + Description + + + + + + + + + + {#each Object.entries(descriptions) as [key, { title, description }]} + + {title} + {description} + + + + + + + + {/each} + + +
+ +
+ +
+
+
diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 43aa4af06cd4d..11ed4ae4023d8 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -3,7 +3,7 @@ import { Badge, Button, Popup, Skeleton } from '$lib/components/common' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' - + import WorkspaceOperatorSettings from '$lib/components/settings/WorkspaceOperatorSettings.svelte' import InviteUser from '$lib/components/InviteUser.svelte' import PageHeader from '$lib/components/PageHeader.svelte' @@ -441,6 +441,8 @@ + + {#if showInvites} + link.id === 'home' || + ($userWorkspaces && $workspaceStore && $userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] === true) + ) - let secondMenuLinks = [ + $: secondMenuLinks = [ { label: 'Resources', + id: 'resources', href: `${base}/resources` }, { label: 'Variables', + id: 'variables', href: `${base}/variables` }, { label: 'Custom HTTP routes', + id: 'triggers', href: `${base}/routes` }, { label: 'Websocket triggers', + id: 'triggers', href: `${base}/websocket_triggers` }, { label: 'Postgres triggers', - href: `${base}/kafka_triggers` + id: 'triggers', + href: `${base}/postgres_triggers` }, { label: 'Kafka triggers', + id: 'triggers', href: `${base}/kafka_triggers` }, { label: 'NATS triggers', + id: 'triggers', href: `${base}/nats_triggers` }, { label: 'Audit logs', + id: 'audit_logs', href: `${base}/audit_logs` }, { label: 'Groups', + id: 'groups', href: `${base}/groups` }, { label: 'Folders', + id: 'folders', href: `${base}/folders` }, { label: 'Workers', + id: 'workers', href: `${base}/workers` } - ] + ].filter((link) => { + if (!$userWorkspaces || !$workspaceStore) return false; + return $userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] === true + }) let moreOpen = false @@ -208,7 +226,7 @@ class="divide-y" role="none" > - {#if moreOpen == false} + {#if moreOpen == false && secondMenuLinks.length > 0}
More...
{:else} {#each secondMenuLinks as menuLink (menuLink.href ?? menuLink.label)} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 353eeeac7cff5..98886e1bc0d67 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -1,9 +1,11 @@ import { BROWSER } from 'esm-env' import { derived, type Readable, writable } from 'svelte/store' + import { type WorkspaceDefaultScripts, type TokenResponse, type UserWorkspaceList, + type OperatorSettings } from './gen' import type { IntrospectionQuery } from 'graphql' import { getLocalSetting } from './utils' @@ -60,6 +62,7 @@ export const userWorkspaces: Readable< name: string username: string color: string | null + operator_settings?: OperatorSettings }> > = derived([usersWorkspaceStore, superadmin], ([store, superadmin]) => { const originalWorkspaces = store?.workspaces ?? [] @@ -70,7 +73,8 @@ export const userWorkspaces: Readable< id: 'admins', name: 'Admins', username: 'superadmin', - color: null + color: null, + operator_settings: null } ] } else { diff --git a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte index 22f301af5b66a..498a0d1293a88 100644 --- a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte @@ -12,7 +12,7 @@ import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' import type { AuditLog } from '$lib/gen' - import { enterpriseLicense } from '$lib/stores' + import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { Splitpanes, Pane } from 'svelte-splitpanes' let username: string = $page.url.searchParams.get('username') ?? 'all' @@ -32,6 +32,12 @@ let auditLogDrawer: Drawer +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.audit_logs} + +{:else}
@@ -118,6 +124,7 @@ />
+{/if} diff --git a/frontend/src/routes/(root)/(logged)/folders/+page.svelte b/frontend/src/routes/(root)/(logged)/folders/+page.svelte index 4bcbc02c8c90f..090223ebbd6f4 100644 --- a/frontend/src/routes/(root)/(logged)/folders/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/folders/+page.svelte @@ -6,7 +6,7 @@ import Dropdown from '$lib/components/DropdownV2.svelte' import FolderEditor from '$lib/components/FolderEditor.svelte' import PageHeader from '$lib/components/PageHeader.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { Button, Drawer, DrawerContent, Popup, Skeleton } from '$lib/components/common' import FolderInfo from '$lib/components/FolderInfo.svelte' import FolderUsageInfo from '$lib/components/FolderUsageInfo.svelte' @@ -78,6 +78,12 @@ +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.folders} + +{:else}
+{/if} diff --git a/frontend/src/routes/(root)/(logged)/groups/+page.svelte b/frontend/src/routes/(root)/(logged)/groups/+page.svelte index d1fc02a826958..93fc04d45d27f 100644 --- a/frontend/src/routes/(root)/(logged)/groups/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/groups/+page.svelte @@ -11,7 +11,7 @@ import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' import TableCustom from '$lib/components/TableCustom.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { canWrite } from '$lib/utils' import { Pen, Plus, Trash } from 'lucide-svelte' import DataTable from '$lib/components/table/DataTable.svelte' @@ -73,6 +73,12 @@ +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.groups} + +{:else} {/if} +{/if} diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte index f66a7beca1197..13c9cfd1d9869 100644 --- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte @@ -15,7 +15,7 @@ import SharedBadge from '$lib/components/SharedBadge.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import Toggle from '$lib/components/Toggle.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { Code, Eye, Pen, Plus, Share, Trash, Circle } from 'lucide-svelte' import { goto } from '$lib/navigation' import SearchItems from '$lib/components/SearchItems.svelte' @@ -208,6 +208,12 @@ f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} /> +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.triggers} + +{:else} {/if} +{/if} (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} /> +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.triggers} + +{:else} {/if} +{/if} x.path + ' ' + x.resource_type + ' ' + x.description + ' '} /> +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.resources} + +{:else} +{/if} diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte index 6666df29ec51e..bd12e6d5eb377 100644 --- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte @@ -9,7 +9,7 @@ import SharedBadge from '$lib/components/SharedBadge.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import Toggle from '$lib/components/Toggle.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { Route, Code, Eye, Pen, Plus, Share, Trash } from 'lucide-svelte' import { goto } from '$lib/navigation' import SearchItems from '$lib/components/SearchItems.svelte' @@ -156,6 +156,12 @@ f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} /> +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.triggers} + +{:else} {/if} +{/if} +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.runs} + +{:else} {#if innerWidth > 900}
@@ -1371,3 +1377,4 @@
{/if} +{/if} diff --git a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte index f440e546006aa..cad5a7ee4f519 100644 --- a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte @@ -11,7 +11,7 @@ import SharedBadge from '$lib/components/SharedBadge.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import Toggle from '$lib/components/Toggle.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore, userWorkspaces } from '$lib/stores' import { Calendar, Circle, @@ -232,9 +232,12 @@ } onMount(() => { + console.log(`on mount: `, $userStore?.operator, $workspaceStore, $userWorkspaces) loadQueryFilters() }) + $: $userWorkspaces && $userStore && $workspaceStore && console.log(`user workspaces: `, $userWorkspaces) + $: updateQueryFilters(selectedFilterKind, filterUserFolders, filterEnabledDisabled) @@ -247,6 +250,12 @@ f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} /> +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.schedules} + +{:else} {/if} +{/if} x.path + ' ' + x.description} /> +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.variables} + +{:else} {/if} +{/if} +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.workers} + +{:else} {/if} +{/if}