mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 00:03:31 +00:00
* feat: thread concurrency semaphore through dedicated worker executors Pass the concurrency_semaphore parameter through bun, deno, and python start_worker functions to handle_dedicated_process. Also fix the DedicatedWorkersSelector to use listWorkspacesAsSuperAdmin (so all workspaces including admins are visible) and skip loading when disabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to limit-workers-one-job branch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 54037e77cdd37777560755fef7075d35906c96d8 This commit updates the EE repository reference after PR #523 was merged in windmill-ee-private. Previous ee-repo-ref: 56890ea8fca2c1c44a1338a27011b4dd1137d9c9 New ee-repo-ref: 54037e77cdd37777560755fef7075d35906c96d8 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
900 lines
27 KiB
Svelte
900 lines
27 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
ScriptService,
|
|
FlowService,
|
|
WorkspaceService,
|
|
ConfigService,
|
|
type FlowModule
|
|
} from '$lib/gen'
|
|
import {
|
|
Check,
|
|
X,
|
|
RefreshCcw,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CodeXml,
|
|
ExternalLink,
|
|
TriangleAlert,
|
|
Layers
|
|
} from 'lucide-svelte'
|
|
import { Button } from './common'
|
|
import Select from './select/Select.svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import Badge from './common/badge/Badge.svelte'
|
|
import { SvelteMap } from 'svelte/reactivity'
|
|
import { untrack } from 'svelte'
|
|
import BarsStaggered from './icons/BarsStaggered.svelte'
|
|
import { parseTag } from './dedicated_worker'
|
|
import Tooltip from './Tooltip.svelte'
|
|
|
|
// A "Runnable" is a script or flow with dedicated_worker=true
|
|
interface Runnable {
|
|
tag: string // workspace:path or workspace:flow/path
|
|
displayName: string
|
|
language: string
|
|
type: 'script' | 'flow'
|
|
path: string
|
|
selected: boolean
|
|
// For flows, the actual runners (steps) that will be spawned
|
|
runners?: FlowRunner[]
|
|
loadingRunners?: boolean
|
|
expanded?: boolean
|
|
// Workspace dependency names from annotations (scripts only)
|
|
workspaceDeps?: string[]
|
|
}
|
|
|
|
// A "FlowRunner" is an individual step within a flow that will get a dedicated worker
|
|
interface FlowRunner {
|
|
stepId: string
|
|
stepSummary?: string
|
|
language?: string
|
|
scriptPath?: string
|
|
isInline: boolean
|
|
}
|
|
|
|
interface Props {
|
|
selectedTags: string[]
|
|
disabled?: boolean
|
|
onchange?: (tags: string[]) => void
|
|
}
|
|
|
|
let { selectedTags = $bindable([]), disabled = false, onchange }: Props = $props()
|
|
|
|
let selectedWorkspace: string | undefined = $state(undefined)
|
|
let runnables: Runnable[] = $state([])
|
|
let loading = $state(false)
|
|
let workspaces: { id: string; name: string }[] = $state([])
|
|
let workspacesLoading = $state(true)
|
|
let selectorExpanded = $state(false)
|
|
// Map of dep name → set of workspaces that have it (for cross-workspace validation)
|
|
let existingDeps: Map<string, Set<string>> = $state(new Map())
|
|
|
|
// Track detailed info for each selected tag (for displaying in summary)
|
|
interface SelectedTagInfo {
|
|
tag: string
|
|
workspace: string
|
|
type: 'script' | 'flow'
|
|
path: string
|
|
language?: string
|
|
runners?: FlowRunner[]
|
|
expanded?: boolean
|
|
loading?: boolean
|
|
workspaceDeps?: string[]
|
|
}
|
|
let selectedTagsInfo: SvelteMap<string, SelectedTagInfo> = $state(new SvelteMap())
|
|
|
|
// Languages that support dedicated workers
|
|
const DEDICATED_WORKER_LANGUAGES = ['python3', 'bun', 'bunnative', 'deno']
|
|
|
|
// Resolve workspace script languages and filter to supported languages
|
|
async function resolveAndFilterRunners(
|
|
workspace: string,
|
|
preliminaryRunners: FlowRunner[]
|
|
): Promise<FlowRunner[]> {
|
|
const runnersWithLanguage = await Promise.all(
|
|
preliminaryRunners.map(async (runner) => {
|
|
if (!runner.isInline && runner.scriptPath) {
|
|
try {
|
|
const script = await ScriptService.getScriptByPath({
|
|
workspace,
|
|
path: runner.scriptPath
|
|
})
|
|
return { ...runner, language: script.language }
|
|
} catch (e) {
|
|
console.error(`Failed to fetch script ${runner.scriptPath}`, e)
|
|
return { ...runner, language: undefined }
|
|
}
|
|
}
|
|
return runner
|
|
})
|
|
)
|
|
|
|
// Filter to only supported languages
|
|
return runnersWithLanguage.filter(
|
|
(runner) => runner.language && DEDICATED_WORKER_LANGUAGES.includes(runner.language)
|
|
)
|
|
}
|
|
|
|
// Load detailed info for all selected tags
|
|
async function loadSelectedTagsInfo(tags: string[]) {
|
|
if (tags.length === 0) {
|
|
selectedTagsInfo = new SvelteMap()
|
|
return
|
|
}
|
|
|
|
// Capture current state without tracking to avoid infinite loops
|
|
const currentInfo = untrack(() => selectedTagsInfo)
|
|
const currentRunnables = untrack(() => runnables)
|
|
const currentExistingDeps = untrack(() => existingDeps)
|
|
|
|
const newInfo = new SvelteMap<string, SelectedTagInfo>()
|
|
|
|
// Fetch dep info only when not disabled (requires devops role)
|
|
let needsFetch = !disabled && currentExistingDeps.size === 0
|
|
if (!needsFetch && !disabled) {
|
|
for (const tag of tags) {
|
|
const existing = currentInfo.get(tag)
|
|
const existingRunnable = currentRunnables.find((r) => r.tag === tag)
|
|
if (!existing?.workspaceDeps && !existingRunnable?.workspaceDeps) {
|
|
const parsed = parseTag(tag)
|
|
if (parsed?.type === 'script') {
|
|
needsFetch = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch dep info across all workspaces (requires devops role)
|
|
const depsPerWorkspace = new Map<string, Map<string, { deps: string[]; language: string }>>()
|
|
if (needsFetch) {
|
|
const [allDedicatedDeps, allWsDeps] = await Promise.all([
|
|
ConfigService.listAllDedicatedWithDeps().catch(() => []),
|
|
ConfigService.listAllWorkspaceDependencies().catch(() => [])
|
|
])
|
|
for (const d of allDedicatedDeps) {
|
|
if (d.workspace_dep_names.length > 0) {
|
|
let wsMap = depsPerWorkspace.get(d.workspace_id)
|
|
if (!wsMap) {
|
|
wsMap = new Map()
|
|
depsPerWorkspace.set(d.workspace_id, wsMap)
|
|
}
|
|
wsMap.set(d.path, {
|
|
deps: d.workspace_dep_names,
|
|
language: d.language
|
|
})
|
|
}
|
|
}
|
|
for (const d of allWsDeps) {
|
|
if (d.name) {
|
|
let wsSet = currentExistingDeps.get(d.name)
|
|
if (!wsSet) {
|
|
wsSet = new Set()
|
|
currentExistingDeps.set(d.name, wsSet)
|
|
}
|
|
wsSet.add(d.workspace_id)
|
|
}
|
|
}
|
|
existingDeps = new Map(currentExistingDeps)
|
|
}
|
|
|
|
try {
|
|
await Promise.all(
|
|
tags.map(async (tag) => {
|
|
// Check if we already have this info cached
|
|
const existing = currentInfo.get(tag)
|
|
if (existing && (existing.type === 'script' || existing.runners !== undefined)) {
|
|
// Backfill workspaceDeps and language if missing
|
|
if (existing.type === 'script' && !existing.workspaceDeps) {
|
|
const depInfo = depsPerWorkspace.get(existing.workspace)?.get(existing.path)
|
|
if (depInfo) {
|
|
existing.workspaceDeps = depInfo.deps
|
|
existing.language = depInfo.language
|
|
}
|
|
}
|
|
newInfo.set(tag, existing)
|
|
return
|
|
}
|
|
|
|
// Check if we have it loaded in runnables
|
|
const existingRunnable = currentRunnables.find((r) => r.tag === tag)
|
|
if (existingRunnable) {
|
|
const ws = tag.substring(0, tag.indexOf(':'))
|
|
const depInfo = depsPerWorkspace.get(ws)?.get(existingRunnable.path)
|
|
newInfo.set(tag, {
|
|
tag,
|
|
workspace: ws,
|
|
type: existingRunnable.type,
|
|
path: existingRunnable.path,
|
|
language: existingRunnable.language,
|
|
runners: existingRunnable.runners,
|
|
expanded: existing?.expanded ?? false,
|
|
workspaceDeps: existingRunnable.workspaceDeps ?? depInfo?.deps
|
|
})
|
|
return
|
|
}
|
|
|
|
// Parse and fetch
|
|
const parsed = parseTag(tag)
|
|
if (!parsed) return
|
|
|
|
if (disabled) {
|
|
// Read-only: just show path info, skip API calls
|
|
newInfo.set(tag, {
|
|
tag,
|
|
workspace: parsed.workspace,
|
|
type: parsed.type,
|
|
path: parsed.path
|
|
})
|
|
return
|
|
}
|
|
|
|
if (parsed.type === 'script') {
|
|
const depInfo = depsPerWorkspace.get(parsed.workspace)?.get(parsed.path)
|
|
let scriptLang = depInfo?.language
|
|
if (!scriptLang) {
|
|
try {
|
|
const script = await ScriptService.getScriptByPath({
|
|
workspace: parsed.workspace,
|
|
path: parsed.path
|
|
})
|
|
scriptLang = script.language
|
|
} catch {}
|
|
}
|
|
newInfo.set(tag, {
|
|
tag,
|
|
workspace: parsed.workspace,
|
|
type: 'script',
|
|
path: parsed.path,
|
|
language: scriptLang,
|
|
workspaceDeps: depInfo?.deps
|
|
})
|
|
} else {
|
|
// Flows need to fetch to get runners
|
|
try {
|
|
const flow = await FlowService.getFlowByPath({
|
|
workspace: parsed.workspace,
|
|
path: parsed.path
|
|
})
|
|
const preliminaryRunners = flow.value?.modules
|
|
? extractRunnersFromModules(flow.value.modules)
|
|
: []
|
|
const runners = await resolveAndFilterRunners(parsed.workspace, preliminaryRunners)
|
|
newInfo.set(tag, {
|
|
tag,
|
|
workspace: parsed.workspace,
|
|
type: 'flow',
|
|
path: parsed.path,
|
|
runners,
|
|
expanded: existing?.expanded ?? false
|
|
})
|
|
} catch (e) {
|
|
console.error(`Failed to load flow ${parsed.path}`, e)
|
|
newInfo.set(tag, {
|
|
tag,
|
|
workspace: parsed.workspace,
|
|
type: 'flow',
|
|
path: parsed.path,
|
|
runners: []
|
|
})
|
|
}
|
|
}
|
|
})
|
|
)
|
|
} finally {
|
|
selectedTagsInfo = newInfo
|
|
}
|
|
}
|
|
|
|
function toggleSelectedTagExpanded(tag: string) {
|
|
const info = selectedTagsInfo.get(tag)
|
|
if (info) {
|
|
// Need to set the whole object to trigger reactivity
|
|
selectedTagsInfo.set(tag, { ...info, expanded: !info.expanded })
|
|
}
|
|
}
|
|
|
|
// Auto-expand selector if no tags selected
|
|
$effect(() => {
|
|
if (selectedTags.length === 0) {
|
|
selectorExpanded = true
|
|
}
|
|
})
|
|
|
|
// Load selected tags info when selectedTags change
|
|
$effect(() => {
|
|
loadSelectedTagsInfo(selectedTags)
|
|
})
|
|
|
|
$effect(() => {
|
|
if (!disabled) {
|
|
loadWorkspaces()
|
|
}
|
|
})
|
|
|
|
async function loadWorkspaces() {
|
|
try {
|
|
workspacesLoading = true
|
|
const ws = await WorkspaceService.listWorkspacesAsSuperAdmin()
|
|
workspaces = ws.map((w) => ({ id: w.id, name: w.name }))
|
|
} catch (e) {
|
|
console.error('Failed to load workspaces', e)
|
|
sendUserToast('Failed to load workspaces', true)
|
|
} finally {
|
|
workspacesLoading = false
|
|
}
|
|
}
|
|
|
|
// Extract runners from flow modules recursively
|
|
// Returns runners with language info for inline scripts, and scriptPath for workspace scripts
|
|
function extractRunnersFromModules(modules: FlowModule[]): FlowRunner[] {
|
|
const runners: FlowRunner[] = []
|
|
|
|
for (const module of modules) {
|
|
const value = module.value
|
|
switch (value.type) {
|
|
case 'rawscript':
|
|
if (DEDICATED_WORKER_LANGUAGES.includes(value.language)) {
|
|
runners.push({
|
|
stepId: module.id,
|
|
stepSummary: module.summary,
|
|
language: value.language,
|
|
scriptPath: value.path,
|
|
isInline: true
|
|
})
|
|
}
|
|
break
|
|
case 'script':
|
|
// For workspace script references, we'll resolve the language later
|
|
runners.push({
|
|
stepId: module.id,
|
|
stepSummary: module.summary,
|
|
language: undefined, // Will be resolved by fetching the script
|
|
scriptPath: value.path,
|
|
isInline: false
|
|
})
|
|
break
|
|
case 'forloopflow':
|
|
runners.push(...extractRunnersFromModules(value.modules))
|
|
break
|
|
case 'whileloopflow':
|
|
runners.push(...extractRunnersFromModules(value.modules))
|
|
break
|
|
case 'branchone':
|
|
for (const branch of value.branches) {
|
|
runners.push(...extractRunnersFromModules(branch.modules))
|
|
}
|
|
runners.push(...extractRunnersFromModules(value.default))
|
|
break
|
|
case 'branchall':
|
|
for (const branch of value.branches) {
|
|
runners.push(...extractRunnersFromModules(branch.modules))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return runners
|
|
}
|
|
|
|
async function loadFlowRunners(runnable: Runnable) {
|
|
if (!selectedWorkspace || runnable.type !== 'flow') return
|
|
|
|
try {
|
|
runnable.loadingRunners = true
|
|
const flow = await FlowService.getFlowByPath({
|
|
workspace: selectedWorkspace,
|
|
path: runnable.path
|
|
})
|
|
|
|
if (flow.value?.modules) {
|
|
const preliminaryRunners = extractRunnersFromModules(flow.value.modules)
|
|
runnable.runners = await resolveAndFilterRunners(selectedWorkspace, preliminaryRunners)
|
|
} else {
|
|
runnable.runners = []
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load flow runners', e)
|
|
runnable.runners = []
|
|
} finally {
|
|
runnable.loadingRunners = false
|
|
}
|
|
}
|
|
|
|
async function loadRunnables(workspaceId: string) {
|
|
try {
|
|
loading = true
|
|
runnables = []
|
|
|
|
const [scripts, flows, dedicatedDeps, allWsDeps] = await Promise.all([
|
|
ScriptService.listScripts({
|
|
workspace: workspaceId,
|
|
dedicatedWorker: true
|
|
}),
|
|
FlowService.listFlows({
|
|
workspace: workspaceId,
|
|
dedicatedWorker: true
|
|
}),
|
|
disabled
|
|
? Promise.resolve([])
|
|
: ScriptService.listDedicatedWithDeps({
|
|
workspace: workspaceId
|
|
}).catch(() => []),
|
|
disabled
|
|
? Promise.resolve([])
|
|
: ConfigService.listAllWorkspaceDependencies().catch(() => [])
|
|
])
|
|
|
|
// Track existing workspace dep names with their workspaces for validation
|
|
const newDeps = new Map<string, Set<string>>()
|
|
for (const d of allWsDeps) {
|
|
if (d.name) {
|
|
let wsSet = newDeps.get(d.name)
|
|
if (!wsSet) {
|
|
wsSet = new Set()
|
|
newDeps.set(d.name, wsSet)
|
|
}
|
|
wsSet.add(d.workspace_id)
|
|
}
|
|
}
|
|
existingDeps = newDeps
|
|
|
|
// Build a map from path -> workspace dep names
|
|
const depsMap = new Map<string, string[]>()
|
|
for (const d of dedicatedDeps) {
|
|
if (d.workspace_dep_names.length > 0) {
|
|
depsMap.set(d.path, d.workspace_dep_names)
|
|
}
|
|
}
|
|
|
|
const newRunnables: Runnable[] = []
|
|
|
|
// Add scripts with supported languages
|
|
for (const script of scripts) {
|
|
if (DEDICATED_WORKER_LANGUAGES.includes(script.language ?? '')) {
|
|
const tag = `${workspaceId}:${script.path}`
|
|
newRunnables.push({
|
|
tag,
|
|
displayName: script.path,
|
|
language: script.language ?? 'unknown',
|
|
type: 'script',
|
|
path: script.path,
|
|
selected: selectedTags.includes(tag),
|
|
workspaceDeps: depsMap.get(script.path)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add flows
|
|
for (const flow of flows) {
|
|
const tag = `${workspaceId}:flow/${flow.path}`
|
|
newRunnables.push({
|
|
tag,
|
|
displayName: flow.path,
|
|
language: 'flow',
|
|
type: 'flow',
|
|
path: flow.path,
|
|
selected: selectedTags.includes(tag),
|
|
runners: undefined,
|
|
loadingRunners: false,
|
|
expanded: false
|
|
})
|
|
}
|
|
|
|
runnables = newRunnables
|
|
|
|
// Load runners for all flows in parallel
|
|
await Promise.all(runnables.filter((r) => r.type === 'flow').map((r) => loadFlowRunners(r)))
|
|
} catch (e) {
|
|
console.error('Failed to load runnables', e)
|
|
sendUserToast('Failed to load scripts/flows', true)
|
|
} finally {
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
function toggleRunnable(runnable: Runnable) {
|
|
runnable.selected = !runnable.selected
|
|
updateSelectedTags()
|
|
}
|
|
|
|
function toggleExpanded(runnable: Runnable) {
|
|
runnable.expanded = !runnable.expanded
|
|
}
|
|
|
|
function selectAll() {
|
|
for (const runnable of runnables) {
|
|
runnable.selected = true
|
|
}
|
|
updateSelectedTags()
|
|
}
|
|
|
|
function deselectAll() {
|
|
for (const runnable of runnables) {
|
|
runnable.selected = false
|
|
}
|
|
updateSelectedTags()
|
|
}
|
|
|
|
function updateSelectedTags() {
|
|
// Keep tags from other workspaces, update only the currently visible ones
|
|
const visibleTags = new Set(runnables.map((r) => r.tag))
|
|
const otherTags = selectedTags.filter((t) => !visibleTags.has(t))
|
|
const newVisibleTags = runnables.filter((r) => r.selected).map((r) => r.tag)
|
|
selectedTags = [...otherTags, ...newVisibleTags]
|
|
onchange?.(selectedTags)
|
|
}
|
|
|
|
function removeTag(tag: string) {
|
|
selectedTags = selectedTags.filter((t) => t !== tag)
|
|
// Also update runnable state if visible
|
|
const runnable = runnables.find((r) => r.tag === tag)
|
|
if (runnable) {
|
|
runnable.selected = false
|
|
}
|
|
onchange?.(selectedTags)
|
|
}
|
|
|
|
$effect(() => {
|
|
if (selectedWorkspace) {
|
|
loadRunnables(selectedWorkspace)
|
|
}
|
|
})
|
|
|
|
let selectedCount = $derived(runnables.filter((r) => r.selected).length)
|
|
|
|
// Compute shared runner groups: scripts sharing a (workspace, dep_name, language) tuple
|
|
interface RunnerGroup {
|
|
workspace: string
|
|
depName: string
|
|
language: string
|
|
tags: string[]
|
|
}
|
|
|
|
let runnerGroups: RunnerGroup[] = $derived.by(() => {
|
|
const groupMap = new Map<string, RunnerGroup>()
|
|
for (const tag of selectedTags) {
|
|
const info = selectedTagsInfo.get(tag)
|
|
if (info?.type === 'script' && info.workspaceDeps) {
|
|
const lang = info.language ?? runnables.find((r) => r.tag === tag)?.language ?? 'unknown'
|
|
for (const dep of info.workspaceDeps) {
|
|
const key = `${info.workspace}:${dep}:${lang}`
|
|
const existing = groupMap.get(key)
|
|
if (existing) {
|
|
existing.tags.push(tag)
|
|
} else {
|
|
groupMap.set(key, {
|
|
workspace: info.workspace,
|
|
depName: dep,
|
|
language: lang,
|
|
tags: [tag]
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Only return groups with 2+ scripts
|
|
return Array.from(groupMap.values()).filter((g) => g.tags.length >= 2)
|
|
})
|
|
|
|
// Map tag → runner group it belongs to
|
|
let tagRunnerGroup: Map<string, RunnerGroup> = $derived.by(() => {
|
|
const map = new Map<string, RunnerGroup>()
|
|
for (const group of runnerGroups) {
|
|
for (const tag of group.tags) {
|
|
map.set(tag, group)
|
|
}
|
|
}
|
|
return map
|
|
})
|
|
|
|
// Tags not in any runner group (standalone dedicated workers)
|
|
let standaloneTags: string[] = $derived(selectedTags.filter((tag) => !tagRunnerGroup.has(tag)))
|
|
</script>
|
|
|
|
{#snippet depBadge(dep: string, workspace: string | undefined)}
|
|
{@const depWorkspaces = existingDeps.get(dep)}
|
|
{@const existsInWorkspace = depWorkspaces?.has(workspace ?? '')}
|
|
{@const existsElsewhere = depWorkspaces && depWorkspaces.size > 0 && !existsInWorkspace}
|
|
{#if existsInWorkspace}
|
|
<Badge color="indigo" small href="/workspace_settings?tab=dependencies&workspace={workspace}">
|
|
{dep}
|
|
<ExternalLink class="h-2.5 w-2.5" />
|
|
</Badge>
|
|
{:else if existsElsewhere}
|
|
<Tooltip small>
|
|
Workspace dependency '{dep}' exists in {[...(depWorkspaces ?? [])].join(', ')} but not in '{workspace}'.
|
|
Each workspace needs its own dependency.
|
|
</Tooltip>
|
|
<Badge color="yellow" small>
|
|
<TriangleAlert class="h-2.5 w-2.5" />
|
|
{dep} (not in {workspace})
|
|
</Badge>
|
|
{:else}
|
|
<Tooltip small>
|
|
Workspace dependency '{dep}' not found in any workspace. Create it in workspace settings to
|
|
enable shared runners.
|
|
</Tooltip>
|
|
<Badge color="yellow" small>
|
|
<TriangleAlert class="h-2.5 w-2.5" />
|
|
{dep}
|
|
</Badge>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet tagRow(tag: string, info: SelectedTagInfo | undefined, standalone: boolean)}
|
|
{@const hasChevron = info?.type === 'flow' && info.runners && info.runners.length > 0}
|
|
<div>
|
|
<div
|
|
class="flex items-center {standalone
|
|
? (hasChevron ? 'pr-3' : 'px-3') + ' py-1.5 bg-surface-secondary'
|
|
: ''}"
|
|
>
|
|
{#if hasChevron}
|
|
<button
|
|
class="p-2 hover:bg-surface-hover transition-colors"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
toggleSelectedTagExpanded(tag)
|
|
}}
|
|
>
|
|
{#if info.expanded}
|
|
<ChevronDown class="h-3 w-3 text-tertiary" />
|
|
{:else}
|
|
<ChevronRight class="h-3 w-3 text-tertiary" />
|
|
{/if}
|
|
</button>
|
|
{:else if !standalone}
|
|
<div class="w-7"></div>
|
|
{/if}
|
|
<div class="flex-1 flex items-center gap-2 {standalone ? '' : 'px-2 py-1.5'} min-w-0">
|
|
{#if info}
|
|
{#if info.type === 'flow'}
|
|
<BarsStaggered size={14} class="flex-shrink-0 text-secondary" />
|
|
{:else}
|
|
<CodeXml size={14} class="flex-shrink-0 text-secondary" />
|
|
{/if}
|
|
<span class="text-xs truncate min-w-0">{info.path}</span>
|
|
<span class="text-xs text-tertiary flex-shrink-0">({info.workspace})</span>
|
|
<span class="flex-1"></span>
|
|
{#if !tagRunnerGroup.has(tag)}
|
|
{#if info.workspaceDeps}
|
|
{#each info.workspaceDeps as dep}
|
|
{@render depBadge(dep, info?.workspace)}
|
|
{/each}
|
|
{/if}
|
|
{#if info.type === 'flow' && info.runners && info.runners.length > 0}
|
|
<Badge color="indigo" small>
|
|
{info.runners.length} runner{info.runners.length !== 1 ? 's' : ''}
|
|
</Badge>
|
|
{:else if info.language}
|
|
<Badge color="gray" small>{info.language}</Badge>
|
|
{/if}
|
|
{/if}
|
|
{:else}
|
|
<span class="text-xs text-tertiary truncate">{tag}</span>
|
|
{/if}
|
|
</div>
|
|
{#if !disabled}
|
|
<button
|
|
class="p-2 hover:text-red-500 transition-colors"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
removeTag(tag)
|
|
}}
|
|
>
|
|
<X class="h-3 w-3" />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if info?.type === 'flow' && info.expanded && info.runners}
|
|
<div class="bg-surface-secondary border-t">
|
|
{#each info.runners as runner (runner.stepId)}
|
|
<div class="flex items-center gap-2 px-9 py-1 text-xs border-t first:border-t-0 min-w-0">
|
|
<span class="font-mono text-tertiary flex-shrink-0">{runner.stepId}</span>
|
|
{#if runner.stepSummary}
|
|
<span class="text-secondary truncate flex-1 min-w-0">{runner.stepSummary}</span>
|
|
{/if}
|
|
<Badge color="gray" small>
|
|
{runner.isInline ? runner.language : runner.scriptPath}
|
|
</Badge>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
<div class="flex flex-col gap-3">
|
|
{#if selectedTags.length > 0}
|
|
<div class="flex flex-col gap-2">
|
|
<div class="border rounded-md bg-surface divide-y">
|
|
<!-- Shared runner groups -->
|
|
{#each runnerGroups as group (`${group.workspace}:${group.depName}:${group.language}`)}
|
|
<div>
|
|
<div class="flex items-center gap-2 px-3 py-1.5 bg-surface-secondary border-b">
|
|
<Layers size={12} class="flex-shrink-0 text-secondary" />
|
|
<span class="text-xs font-medium text-emphasis">Shared runner</span>
|
|
<span class="flex-1"></span>
|
|
{@render depBadge(group.depName, group.workspace)}
|
|
<Badge color="gray" small>{group.language}</Badge>
|
|
</div>
|
|
<div class="divide-y">
|
|
{#each group.tags as tag (tag)}
|
|
{@const info = selectedTagsInfo.get(tag)}
|
|
{@render tagRow(tag, info, false)}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- Standalone scripts/flows -->
|
|
{#each standaloneTags as tag (tag)}
|
|
{@const info = selectedTagsInfo.get(tag)}
|
|
{@render tagRow(tag, info, true)}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Collapsible selector section (hidden for read-only users) -->
|
|
{#if !disabled}
|
|
<div class="border rounded-md">
|
|
<button
|
|
class="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-hover transition-colors"
|
|
onclick={() => (selectorExpanded = !selectorExpanded)}
|
|
>
|
|
{#if selectorExpanded}
|
|
<ChevronDown class="h-4 w-4 text-secondary" />
|
|
{:else}
|
|
<ChevronRight class="h-4 w-4 text-secondary" />
|
|
{/if}
|
|
<span class="text-sm">
|
|
{selectedTags.length > 0 ? 'Add more scripts/flows' : 'Select scripts/flows'}
|
|
</span>
|
|
</button>
|
|
|
|
{#if selectorExpanded}
|
|
<div class="border-t px-3 py-3 flex flex-col gap-3">
|
|
<!-- Workspace selector -->
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-xs text-secondary">Workspace</span>
|
|
<Select
|
|
bind:value={selectedWorkspace}
|
|
items={workspaces.map((w) => ({ value: w.id, label: `${w.name} (${w.id})` }))}
|
|
placeholder="Select workspace..."
|
|
disabled={disabled || workspacesLoading}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Scripts/flows list -->
|
|
{#if selectedWorkspace}
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-secondary"
|
|
>Scripts/flows with dedicated worker enabled</span
|
|
>
|
|
{#if !loading && runnables.length > 0}
|
|
<div class="flex gap-1">
|
|
<Button size="xs2" color="light" on:click={selectAll} {disabled}>All</Button>
|
|
<Button size="xs2" color="light" on:click={deselectAll} {disabled}>None</Button>
|
|
<Button
|
|
size="xs2"
|
|
color="light"
|
|
iconOnly
|
|
startIcon={{ icon: RefreshCcw }}
|
|
on:click={() => selectedWorkspace && loadRunnables(selectedWorkspace)}
|
|
{disabled}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="flex items-center justify-center py-4">
|
|
<RefreshCcw class="animate-spin h-4 w-4 text-secondary" />
|
|
<span class="ml-2 text-xs text-secondary">Loading...</span>
|
|
</div>
|
|
{:else if runnables.length === 0}
|
|
<div class="text-xs text-tertiary py-3 text-center">
|
|
No scripts or flows with dedicated worker enabled found.
|
|
</div>
|
|
{:else}
|
|
<div class="border rounded-md divide-y max-h-64 overflow-y-auto bg-surface">
|
|
{#each runnables as runnable (runnable.tag)}
|
|
<div>
|
|
<div class="flex items-center">
|
|
{#if runnable.type === 'flow' && runnable.runners && runnable.runners.length > 0}
|
|
<button
|
|
class="p-2 hover:bg-surface-hover transition-colors"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
toggleExpanded(runnable)
|
|
}}
|
|
{disabled}
|
|
>
|
|
{#if runnable.expanded}
|
|
<ChevronDown class="h-3 w-3 text-tertiary" />
|
|
{:else}
|
|
<ChevronRight class="h-3 w-3 text-tertiary" />
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
<button
|
|
class="flex-1 flex items-center gap-2 px-2 py-1.5 hover:bg-surface-hover transition-colors text-left min-w-0"
|
|
onclick={(e) => {
|
|
e.stopPropagation()
|
|
if (!disabled) toggleRunnable(runnable)
|
|
}}
|
|
{disabled}
|
|
>
|
|
<div
|
|
class="w-4 h-4 border rounded flex items-center justify-center flex-shrink-0"
|
|
class:bg-blue-500={runnable.selected}
|
|
class:border-blue-500={runnable.selected}
|
|
>
|
|
{#if runnable.selected}
|
|
<Check class="h-3 w-3 text-white" />
|
|
{/if}
|
|
</div>
|
|
<span class="flex-1 text-xs truncate min-w-0">{runnable.displayName}</span
|
|
>
|
|
{#if runnable.type === 'flow' && runnable.runners}
|
|
<span class="text-xs text-tertiary flex-shrink-0">
|
|
{runnable.runners.length}
|
|
</span>
|
|
{/if}
|
|
{#if runnable.workspaceDeps}
|
|
{#each runnable.workspaceDeps as dep}
|
|
{@render depBadge(dep, selectedWorkspace)}
|
|
{/each}
|
|
{/if}
|
|
<Badge color={runnable.type === 'flow' ? 'indigo' : 'gray'} small>
|
|
{runnable.type === 'flow' ? 'flow' : runnable.language}
|
|
</Badge>
|
|
</button>
|
|
</div>
|
|
|
|
{#if runnable.type === 'flow' && runnable.expanded && runnable.runners}
|
|
<div class="bg-surface-secondary border-t">
|
|
{#if runnable.runners.length === 0}
|
|
<div class="px-9 py-1.5 text-xs text-tertiary italic">
|
|
No eligible steps (python3/bun/bunnative/deno)
|
|
</div>
|
|
{:else}
|
|
{#each runnable.runners as runner (runner.stepId)}
|
|
<div
|
|
class="flex items-center gap-2 px-9 py-1 text-xs border-t first:border-t-0 min-w-0"
|
|
>
|
|
<span class="font-mono text-tertiary flex-shrink-0"
|
|
>{runner.stepId}</span
|
|
>
|
|
{#if runner.stepSummary}
|
|
<span class="text-secondary truncate flex-1 min-w-0">
|
|
{runner.stepSummary}
|
|
</span>
|
|
{/if}
|
|
<Badge color="gray" small>
|
|
{runner.isInline ? runner.language : runner.scriptPath}
|
|
</Badge>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
<div class="text-xs text-tertiary">
|
|
{selectedCount} selected
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|