mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
* fix(frontend): hide the fork workspace banner from operators * fix(frontend): scope the operator gate to the workspace its role was fetched for * fix(frontend): drop superseded whoami responses instead of writing a stale role * fix(frontend): guard the remaining workspace-switch userStore writers
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { BROWSER } from 'esm-env'
|
|
import { derived, get, type Readable, writable } from 'svelte/store'
|
|
|
|
import type { IntrospectionQuery } from 'graphql'
|
|
import {
|
|
CancelablePromise,
|
|
type OperatorSettings,
|
|
type TokenResponse,
|
|
type UserWorkspaceList,
|
|
type Workspace,
|
|
type WorkspaceDefaultScripts,
|
|
WorkspaceService
|
|
} from './gen'
|
|
import { getLocalSetting, type StateStore } from './utils'
|
|
import { createState } from './svelte5Utils.svelte'
|
|
import { DEFAULT_HUB_BASE_URL } from './hub'
|
|
import type { DbManagerUriState } from './components/dbManagerDrawerModel.svelte'
|
|
|
|
export interface UserExt {
|
|
// Workspace this membership was fetched for. `$workspaceStore` flips
|
|
// synchronously on a switch while the new `whoami` is still in flight, so a
|
|
// consumer whose behavior depends on the role must compare this against the
|
|
// active workspace rather than read a role that still describes the previous one.
|
|
workspace_id: string
|
|
email: string
|
|
name?: string
|
|
username: string
|
|
is_admin: boolean
|
|
is_super_admin: boolean
|
|
operator: boolean
|
|
created_at: string
|
|
groups: string[]
|
|
pgroups: string[]
|
|
folders: string[]
|
|
folders_read: string[]
|
|
folders_owners: string[]
|
|
is_service_account?: boolean
|
|
impersonating_email?: string
|
|
// true when the user is a superadmin viewing a workspace they are not a member of
|
|
non_member?: boolean
|
|
}
|
|
|
|
export interface UserWorkspace {
|
|
id: string
|
|
name: string
|
|
username: string
|
|
color?: string
|
|
operator_settings?: OperatorSettings
|
|
parent_workspace_id?: string | null
|
|
is_dev_workspace?: boolean
|
|
dev_workspace_label?: string | null
|
|
created_by?: string | null
|
|
disabled: boolean
|
|
}
|
|
|
|
const persistedWorkspace = BROWSER && getWorkspaceFromStorage()
|
|
|
|
export function getWorkspaceFromStorage(): string | undefined {
|
|
try {
|
|
return sessionStorage.getItem('workspace') ?? localStorage.getItem('workspace') ?? undefined
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
return undefined
|
|
}
|
|
export function clearWorkspaceFromStorage() {
|
|
localStorage.removeItem('workspace')
|
|
sessionStorage.removeItem('workspace')
|
|
}
|
|
|
|
export const tutorialsToDo = writable<number[]>([])
|
|
export const skippedAll = writable<boolean>(false)
|
|
export const globalEmailInvite = writable<string>('')
|
|
export const awarenessStore = writable<Record<string, string>>(undefined)
|
|
export const enterpriseLicense = writable<string | undefined>(undefined)
|
|
export const whitelabelNameStore = derived([enterpriseLicense], ([enterpriseLicense]) => {
|
|
if (enterpriseLicense?.endsWith('__whitelabel')) {
|
|
return enterpriseLicense.split('__whitelabel')[0]
|
|
}
|
|
return undefined
|
|
})
|
|
export const workerTags = writable<string[] | undefined>(undefined)
|
|
export const usageStore = writable<number>(0)
|
|
export const workspaceUsageStore = writable<number>(0)
|
|
export const initialArgsStore = writable<any>(undefined)
|
|
export const oauthStore = writable<TokenResponse | undefined>(undefined)
|
|
export const userStore = writable<UserExt | undefined>(undefined)
|
|
export const workspaceStore = writable<string | undefined>(
|
|
persistedWorkspace ? String(persistedWorkspace) : undefined
|
|
)
|
|
export const defaultScripts = writable<WorkspaceDefaultScripts | undefined>(undefined)
|
|
export const dbClockDrift = writable<number | undefined>(undefined)
|
|
export const isPremiumStore = writable<boolean>(false)
|
|
export const usersWorkspaceStore = writable<UserWorkspaceList | undefined>(undefined)
|
|
export const superadmin = writable<string | false | undefined>(undefined)
|
|
export const devopsRole = writable<string | false | undefined>(undefined)
|
|
export const lspTokenStore = writable<string | undefined>(undefined)
|
|
export const hubBaseUrlStore = writable<string>(DEFAULT_HUB_BASE_URL)
|
|
export const wsBaseUrlStore = writable<string | undefined>(undefined)
|
|
export const disableHubStore = writable<boolean>(false)
|
|
// What a superadmin standing in a workspace they are not a member of needs to see it as a
|
|
// fork: the workspace itself and its parent. `listUserWorkspaces` is membership-gated, so
|
|
// `$userWorkspaces` would miss them and every fork lookup would read the workspace as a
|
|
// parentless root. Owned by exactly one workspace (`forWorkspace`) — held past a switch,
|
|
// these would go on presenting themselves as memberships (picker, workspace-family lookups).
|
|
export const nonMemberWorkspaces = writable<
|
|
{ forWorkspace: string; workspaces: UserWorkspace[] } | undefined
|
|
>(undefined)
|
|
|
|
/**
|
|
* Records what was resolved for `forWorkspace`, dropping archived workspaces —
|
|
* `userWorkspaces` must never offer one. An archived workspace therefore resolves to an
|
|
* empty set, which still marks it as resolved and stops it being fetched again.
|
|
*/
|
|
export function setNonMemberWorkspaces(forWorkspace: string, workspaces: Workspace[]): void {
|
|
nonMemberWorkspaces.set({
|
|
forWorkspace,
|
|
workspaces: workspaces
|
|
.filter((w) => !w.deleted)
|
|
.map((w) => ({
|
|
id: w.id,
|
|
name: w.name,
|
|
// No `usr` row here, hence no per-workspace username — same stand-in as the
|
|
// synthetic `admins` entry below.
|
|
username: 'superadmin',
|
|
color: w.color,
|
|
parent_workspace_id: w.parent_workspace_id,
|
|
is_dev_workspace: w.is_dev_workspace,
|
|
dev_workspace_label: w.dev_workspace_label,
|
|
disabled: false
|
|
}))
|
|
})
|
|
}
|
|
|
|
export function clearNonMemberWorkspaces(): void {
|
|
// A Svelte store notifies on every write of an object value, so clearing an already
|
|
// empty store is not free: the layout effect that fills this one also reads it, and
|
|
// would re-run itself forever.
|
|
if (get(nonMemberWorkspaces) != undefined) {
|
|
nonMemberWorkspaces.set(undefined)
|
|
}
|
|
}
|
|
|
|
export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
|
|
[usersWorkspaceStore, superadmin, nonMemberWorkspaces],
|
|
([store, superadmin, nonMember]) => {
|
|
const originalWorkspaces = store?.workspaces ?? []
|
|
const workspaces = superadmin
|
|
? [
|
|
...originalWorkspaces.filter((x) => x.id != 'admins'),
|
|
{
|
|
id: 'admins',
|
|
name: 'Admins',
|
|
username: 'superadmin',
|
|
color: undefined,
|
|
operator_settings: undefined,
|
|
disabled: false
|
|
}
|
|
]
|
|
: originalWorkspaces
|
|
const extra = (nonMember?.workspaces ?? []).filter(
|
|
(w) => !workspaces.some((x) => x.id === w.id)
|
|
)
|
|
return extra.length > 0 ? [...workspaces, ...extra] : workspaces
|
|
}
|
|
)
|
|
|
|
export const codeCompletionLoading = writable<boolean>(false)
|
|
export const metadataCompletionEnabled = writable<boolean>(true)
|
|
export const stepInputCompletionEnabled = writable<boolean>(true)
|
|
export const FORMAT_ON_SAVE_SETTING_NAME = 'formatOnSave'
|
|
export const VIM_MODE_SETTING_NAME = 'vimMode'
|
|
export const RELATIVE_LINE_NUMBERS_SETTING_NAME = 'relativeLineNumbers'
|
|
export const CODE_COMPLETION_SETTING_NAME = 'codeCompletionSessionEnabled'
|
|
export const COPILOT_SESSION_MODEL_SETTING_NAME = 'copilotSessionModel'
|
|
export const COPILOT_SESSION_PROVIDER_SETTING_NAME = 'copilotSessionProvider'
|
|
export const COPILOT_SESSION_REASONING_SETTING_NAME = 'copilotSessionReasoning'
|
|
export const formatOnSave = writable<boolean>(
|
|
getLocalSetting(FORMAT_ON_SAVE_SETTING_NAME) != 'false'
|
|
)
|
|
export const vimMode = writable<boolean>(getLocalSetting(VIM_MODE_SETTING_NAME) == 'true')
|
|
export const relativeLineNumbers = writable<boolean>(
|
|
getLocalSetting(RELATIVE_LINE_NUMBERS_SETTING_NAME) == 'true'
|
|
)
|
|
export const codeCompletionSessionEnabled = writable<boolean>(
|
|
getLocalSetting(CODE_COMPLETION_SETTING_NAME) != 'false'
|
|
)
|
|
|
|
export const AI_USER_DISABLED_SETTING_NAME = 'aiUserDisabled'
|
|
// Master per-user (per-device) opt-out for all Windmill AI features. Initialized at
|
|
// module load so it applies on startup, not only once the settings panel mounts.
|
|
export const aiUserDisabled = writable<boolean>(
|
|
getLocalSetting(AI_USER_DISABLED_SETTING_NAME) === 'true'
|
|
)
|
|
|
|
export const usedTriggerKinds = writable<string[]>([])
|
|
|
|
export let globalDbManagerDrawer: StateStore<DbManagerUriState | undefined> = { val: undefined }
|
|
/** Read-only S3 file browser (S3FilePicker instance) mounted in the logged
|
|
* layout, used by Explore buttons in contexts that don't wire their own picker
|
|
* instance. Typed loosely because the component instance type resolves
|
|
* differently in .ts and .svelte contexts. */
|
|
export let globalS3FilePickerExplorer: StateStore<
|
|
{ open: (fileKey?: any, opts?: { s3ResourcePath?: string }) => Promise<void> } | undefined
|
|
> = createState({
|
|
val: undefined
|
|
})
|
|
export let globalForkModal: StateStore<GlobalForkModalState | undefined> = createState({
|
|
val: undefined
|
|
})
|
|
|
|
export type GlobalForkModalState = {
|
|
opened: true
|
|
}
|
|
|
|
export type ForkConflictModalState = {
|
|
kind: string
|
|
kindLabel: string
|
|
parentWorkspaceId: string
|
|
resolve: (proceed: boolean) => void
|
|
}
|
|
|
|
export let forkConflictModal: StateStore<ForkConflictModalState | undefined> = createState({
|
|
val: undefined
|
|
})
|
|
|
|
type SQLBaseSchema = {
|
|
[schemaKey: string]: {
|
|
[tableKey: string]: {
|
|
[columnKey: string]: {
|
|
type: string
|
|
default: string
|
|
required: boolean
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const SQLSchemaLanguages = [
|
|
'mysql',
|
|
'bigquery',
|
|
'postgresql',
|
|
'snowflake',
|
|
'mssql',
|
|
'oracledb'
|
|
] as const
|
|
|
|
export interface SQLSchema {
|
|
lang: (typeof SQLSchemaLanguages)[number] | 'ducklake'
|
|
schema: SQLBaseSchema
|
|
publicOnly: boolean | undefined
|
|
stringified: string
|
|
/** MySQL only: the connection's default database (`DATABASE()`), surfaced by the
|
|
* introspection script. Lets the table picker render the default db's tables
|
|
* unprefixed even when the connection can also see other (non-system) schemas. */
|
|
defaultDb?: string
|
|
}
|
|
|
|
export interface GraphqlSchema {
|
|
lang: 'graphql'
|
|
schema: IntrospectionQuery
|
|
stringified: string
|
|
}
|
|
|
|
export type DBSchema = SQLSchema | GraphqlSchema
|
|
|
|
export type DBSchemas = Partial<Record<string, DBSchema>>
|
|
|
|
export const dbSchemas = writable<DBSchemas>({})
|
|
|
|
export const instanceSettingsSelectedTab = writable('users')
|
|
|
|
export const isCriticalAlertsUIOpen = writable(false)
|
|
|
|
let getWorkspacePromise: CancelablePromise<Workspace> | null = null
|
|
export const workspaceColor: Readable<string | null | undefined> = derived(
|
|
[workspaceStore, usersWorkspaceStore, superadmin],
|
|
([workspaceStore, usersWorkspaceStore, superadmin], set: (value: string | undefined) => void) => {
|
|
if (!workspaceStore || !usersWorkspaceStore) {
|
|
return
|
|
}
|
|
|
|
// First try to get the color from usersWorkspaceStore
|
|
const workspace = usersWorkspaceStore.workspaces.find((w) => w.id === workspaceStore)
|
|
|
|
if (workspace) {
|
|
set(workspace.color)
|
|
return
|
|
}
|
|
|
|
// If workspace not found and user is superadmin, get it as superadmin
|
|
if (!superadmin) {
|
|
set(undefined)
|
|
return
|
|
}
|
|
|
|
getWorkspacePromise?.cancel()
|
|
|
|
getWorkspacePromise = WorkspaceService.getWorkspaceAsSuperAdmin({
|
|
workspace: workspaceStore
|
|
})
|
|
|
|
getWorkspacePromise
|
|
.then((workspace) => set(workspace.color))
|
|
.catch((error) => {
|
|
console.error('error getting workspace as superadmin', error)
|
|
set(undefined)
|
|
})
|
|
}
|
|
)
|
|
|
|
export const isCurrentlyInTutorial: StateStore<boolean> = createState({ val: false })
|
|
|
|
export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] {
|
|
const schema = dbSchema?.schema ?? {}
|
|
const tableNames: string[] = []
|
|
|
|
for (const schemaKey in schema) {
|
|
for (const tableKey in schema[schemaKey]) {
|
|
tableNames.push(`${schemaKey}.${tableKey}`)
|
|
}
|
|
}
|
|
|
|
return tableNames
|
|
}
|