mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
Merge branch 'main' into datatable-roles-redesign
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS index_app_version_on_app_id;
|
||||
|
||||
DROP INDEX IF EXISTS index_app_script_on_app;
|
||||
|
||||
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_app_path;
|
||||
|
||||
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_flow_path;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- The FK columns that cascade when a workspace's apps and flows are deleted. Unindexed,
|
||||
-- Postgres seq-scans the whole child table once per deleted parent row, making a workspace
|
||||
-- delete cost O(apps and flows deleted x rows in the instance). Fork deletion is where that
|
||||
-- bites: a fork clones its parent's apps, flows and entire app version history.
|
||||
--
|
||||
-- The workspace_runnable_dependencies pair are partial because the table's check constraint
|
||||
-- makes app_path and flow_path mutually exclusive, halving each index -- the cascade's
|
||||
-- equality on the path proves the predicate. The table's existing path indexes are partial on
|
||||
-- script_hash, which the cascade does not constrain, so they cannot serve it.
|
||||
--
|
||||
-- Dropped before built: a failed concurrent build leaves an invalid index that IF NOT EXISTS
|
||||
-- would accept forever, unused by the planner yet still maintained on every write.
|
||||
--
|
||||
-- No statement separators outside the statements below, comments included: the CONCURRENTLY
|
||||
-- rewrite in windmill-api/src/db.rs splits the file on them and would run comment text as SQL.
|
||||
DROP INDEX IF EXISTS index_app_version_on_app_id;
|
||||
|
||||
CREATE INDEX index_app_version_on_app_id ON app_version (app_id);
|
||||
|
||||
DROP INDEX IF EXISTS index_app_script_on_app;
|
||||
|
||||
CREATE INDEX index_app_script_on_app ON app_script (app);
|
||||
|
||||
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_app_path;
|
||||
|
||||
CREATE INDEX index_workspace_runnable_dependencies_on_app_path
|
||||
ON workspace_runnable_dependencies (app_path, workspace_id) WHERE app_path IS NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_flow_path;
|
||||
|
||||
CREATE INDEX index_workspace_runnable_dependencies_on_flow_path
|
||||
ON workspace_runnable_dependencies (flow_path, workspace_id) WHERE flow_path IS NOT NULL;
|
||||
@@ -108,6 +108,9 @@ lazy_static::lazy_static! {
|
||||
(20260826214706, include_str!(
|
||||
"../../migrations/20260826214706_queue_suspended_drop_legacy_index.up.sql"
|
||||
).replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
|
||||
(20260909163047, include_str!(
|
||||
"../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql"
|
||||
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
|
||||
].into_iter().collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -806,6 +806,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Group notes reference their members by module id. A stale id here is not
|
||||
// merely cosmetic: cleanupGroupNotes drops ids it cannot resolve and deletes
|
||||
// the note once none are left.
|
||||
const notes = flowStore.val.value.notes
|
||||
if (notes) {
|
||||
for (const note of notes) {
|
||||
if (note.contained_node_ids) {
|
||||
note.contained_node_ids = note.contained_node_ids.map((nid) =>
|
||||
nid === id ? newId : nid
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
flowStateStore.val[newId] = flowStateStore.val[id]
|
||||
delete flowStateStore.val[id]
|
||||
refreshStateStore(flowStore)
|
||||
|
||||
@@ -855,7 +855,8 @@
|
||||
...aiToolNodesResult.toolNodes
|
||||
]
|
||||
|
||||
// Collect module IDs hidden inside collapsed groups so note cleanup preserves them
|
||||
// Module IDs hidden inside collapsed groups: a note whose members are all in here has
|
||||
// nothing on screen to wrap, so it is skipped.
|
||||
const collapsedModuleIds = new Set<string>()
|
||||
for (const n of finalNodes) {
|
||||
if (n.type === 'collapsedGroup') {
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
// Create the actual note using NoteEditor context
|
||||
if (noteEditorContext?.noteEditor) {
|
||||
noteEditorContext.noteEditor.addNote({
|
||||
text: '### Free note\nDouble click to edit me',
|
||||
text: '## Note\nDouble click to edit me',
|
||||
position,
|
||||
size,
|
||||
color: DEFAULT_NOTE_COLOR,
|
||||
@@ -107,7 +107,7 @@
|
||||
if (!noteEditorContext?.noteEditor || !contextMenuPosition) return
|
||||
|
||||
noteEditorContext.noteEditor.addNote({
|
||||
text: '### Free note\nDouble click to edit me',
|
||||
text: '## Note\nDouble click to edit me',
|
||||
position: contextMenuPosition,
|
||||
size: { width: 300, height: 200 },
|
||||
color: DEFAULT_NOTE_COLOR,
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
function handleAddStickyNote() {
|
||||
if (noteEditorContext?.noteEditor && pendingFlowPosition) {
|
||||
noteEditorContext.noteEditor.addNote({
|
||||
text: '### Free note\nDouble click to edit me',
|
||||
text: '## Note\nDouble click to edit me',
|
||||
position: {
|
||||
x: pendingFlowPosition.x,
|
||||
y: pendingFlowPosition.y - (graphContext?.yOffset || 0)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors'
|
||||
import { generateId } from './util'
|
||||
import { getContext, setContext } from 'svelte'
|
||||
import { completeAndSplitGroup } from './groupDetectionUtils'
|
||||
import { forEachFlowModule } from '../flows/dfs'
|
||||
|
||||
/**
|
||||
* Utility class for editing flow notes via direct flowStore mutations
|
||||
@@ -158,10 +159,7 @@ export class NoteEditor {
|
||||
/**
|
||||
* Create a group note containing the specified node IDs
|
||||
*/
|
||||
createGroupNote(
|
||||
nodeIds: string[],
|
||||
text: string = '### Group note\nDouble click to edit me'
|
||||
): string {
|
||||
createGroupNote(nodeIds: string[], text: string = '## Note\nDouble click to edit me'): string {
|
||||
// Filter ids in case they contain subflow nodes
|
||||
let filteredNodeIds: string[] = nodeIds
|
||||
let subflowIds: string[] = []
|
||||
@@ -219,10 +217,7 @@ export class NoteEditor {
|
||||
/**
|
||||
* Clean up group notes using DAG path completion
|
||||
*/
|
||||
cleanupGroupNotes(
|
||||
flowNodes: { id: string; parentIds?: string[] }[],
|
||||
collapsedModuleIds?: Set<string>
|
||||
): void {
|
||||
cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[] }[]): void {
|
||||
if (!this.isAvailable()) {
|
||||
return
|
||||
}
|
||||
@@ -232,19 +227,23 @@ export class NoteEditor {
|
||||
if (groupNotes.length === 0) return
|
||||
|
||||
let hasChanges = false
|
||||
const nodeSet = new Set(flowNodes.map((n) => n.id))
|
||||
const renderedIds = new Set(flowNodes.map((n) => n.id))
|
||||
|
||||
// Include collapsed module IDs as valid — they are hidden but still exist
|
||||
if (collapsedModuleIds) {
|
||||
for (const id of collapsedModuleIds) {
|
||||
nodeSet.add(id)
|
||||
}
|
||||
}
|
||||
// A note's members are module ids, and the flow's own modules are what say whether one
|
||||
// still exists. The rendered nodes are a view of them: a live module is absent from it
|
||||
// while its group is collapsed, and again in the pass after its id changed, so pruning
|
||||
// against the render alone deletes steps out of notes that are perfectly valid.
|
||||
const moduleIds = new Set<string>()
|
||||
forEachFlowModule(this.flowStore.val.value?.modules ?? [], (mod) => {
|
||||
moduleIds.add(mod.id)
|
||||
})
|
||||
|
||||
// Step 1: Clean invalid nodes from existing group notes
|
||||
for (const note of groupNotes) {
|
||||
const originalIds = note.contained_node_ids || []
|
||||
const validIds = originalIds.filter((id) => nodeSet.has(id))
|
||||
// Path completion below can add ids that exist only in the graph (group
|
||||
// boundaries), so a rendered node counts as valid alongside a live module.
|
||||
const validIds = originalIds.filter((id) => moduleIds.has(id) || renderedIds.has(id))
|
||||
|
||||
if (validIds.length !== originalIds.length) {
|
||||
note.contained_node_ids = validIds
|
||||
@@ -259,9 +258,9 @@ export class NoteEditor {
|
||||
const originalNodes = note.contained_node_ids || []
|
||||
if (originalNodes.length === 0) continue
|
||||
|
||||
// Skip path completion for notes that reference collapsed modules,
|
||||
// since the DAG is incomplete when groups are collapsed
|
||||
if (collapsedModuleIds && originalNodes.some((id) => collapsedModuleIds.has(id))) {
|
||||
// Path completion walks the rendered edges, so it can only place members it can
|
||||
// see; one it cannot would come back as unreachable and be dropped from the note.
|
||||
if (originalNodes.some((id) => !renderedIds.has(id))) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Mock modules that transitively import CSS/Monaco
|
||||
vi.mock('monaco-editor', () => ({}))
|
||||
vi.mock('@xyflow/svelte', () => ({}))
|
||||
|
||||
import type { FlowModule, OpenFlow } from '$lib/gen'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import type { ExtendedOpenFlow } from '../flows/types'
|
||||
import { NoteEditor } from './noteEditor.svelte'
|
||||
|
||||
function makeFlowStore(
|
||||
moduleIds: string[],
|
||||
containedNodeIds: string[]
|
||||
): StateStore<ExtendedOpenFlow> {
|
||||
const modules: FlowModule[] = moduleIds.map((id) => ({
|
||||
id,
|
||||
value: { type: 'rawscript', content: '', language: 'bun' } as any
|
||||
}))
|
||||
const flow: OpenFlow = {
|
||||
summary: '',
|
||||
value: {
|
||||
modules,
|
||||
notes: [
|
||||
{
|
||||
id: 'note',
|
||||
text: 'note',
|
||||
color: 'yellow',
|
||||
type: 'group',
|
||||
contained_node_ids: containedNodeIds
|
||||
}
|
||||
]
|
||||
},
|
||||
schema: {}
|
||||
}
|
||||
return { val: flow as ExtendedOpenFlow } as StateStore<ExtendedOpenFlow>
|
||||
}
|
||||
|
||||
function containedIds(flowStore: StateStore<ExtendedOpenFlow>): string[] | undefined {
|
||||
return flowStore.val.value.notes?.[0]?.contained_node_ids
|
||||
}
|
||||
|
||||
describe('cleanupGroupNotes', () => {
|
||||
it('keeps a module the graph has not rendered', () => {
|
||||
// Both the pass after a module id changed and a collapsed group leave a live module
|
||||
// out of the rendered nodes.
|
||||
const flowStore = makeFlowStore(['renamed', 'b'], ['renamed', 'b'])
|
||||
new NoteEditor(flowStore).cleanupGroupNotes([{ id: 'b' }])
|
||||
|
||||
expect(containedIds(flowStore)).toEqual(['renamed', 'b'])
|
||||
})
|
||||
|
||||
it('drops a module that no longer exists in the flow', () => {
|
||||
const flowStore = makeFlowStore(['b'], ['deleted', 'b'])
|
||||
new NoteEditor(flowStore).cleanupGroupNotes([{ id: 'b' }])
|
||||
|
||||
expect(containedIds(flowStore)).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
@@ -264,7 +264,7 @@ export function computeNoteNodes(
|
||||
|
||||
if (editMode) {
|
||||
if (noteEditorContext?.noteEditor?.isAvailable()) {
|
||||
noteEditorContext.noteEditor.cleanupGroupNotes(nodes, collapsedModuleIds)
|
||||
noteEditorContext.noteEditor.cleanupGroupNotes(nodes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
* call site with layout-only classes (padding, width, bg); anything typographic
|
||||
* belongs here.
|
||||
*
|
||||
* - 'xs': micro scale for dense secondary panes (chat reasoning blocks)
|
||||
* - 'xs': micro scale for dense secondary panes (chat reasoning blocks, group notes)
|
||||
* - 'sm': compact chat-bubble scale (assistant messages, flow/app chat, settings)
|
||||
* - 'doc': same rhythm and body size as 'sm', with a taller heading ramp
|
||||
* (lg/base/sm) and semibold headings for document-like surfaces (artifacts)
|
||||
*
|
||||
* h1 and h2 step above the body size in every preset, so `#` and `##` read as
|
||||
* headings rather than bold body text. Below that the tight 'xs' and 'sm' scales
|
||||
* run out of room, and h3 down differentiates by weight and colour alone.
|
||||
*/
|
||||
|
||||
// Kept as literal template parts: Tailwind's scanner reads class names verbatim
|
||||
@@ -28,8 +32,8 @@ const bodyXs =
|
||||
'text-primary prose-p:text-primary prose-li:text-primary prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs prose-table:text-xs'
|
||||
|
||||
export const markdownProse = {
|
||||
xs: `${base} prose-sm leading-snug prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`,
|
||||
sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`,
|
||||
xs: `${base} prose-sm leading-snug prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-sm prose-h2:text-xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`,
|
||||
sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-base prose-h2:text-sm prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`,
|
||||
doc: `${base} ${rhythm} ${bodyXs} prose-headings:mt-8 prose-headings:mb-2 prose-headings:font-semibold prose-headings:text-emphasis prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs prose-pre:bg-transparent prose-pre:p-0`
|
||||
} as const
|
||||
|
||||
|
||||
@@ -614,6 +614,39 @@
|
||||
</div>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
|
||||
{#if promptScope === 'workspace'}
|
||||
<!-- Recorded usage must be priced with the rates the chats actually ran under.
|
||||
A workspace on instance defaults has no rates of its own, so the effective
|
||||
ones come from copilotInfo rather than from this form's (empty) workspace
|
||||
config. -->
|
||||
<AiUsagePanel
|
||||
workspace={effectiveWorkspace}
|
||||
modelPricing={usesInstanceAiConfig ? ($copilotInfo.modelPricing ?? {}) : modelPricing}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Below the usage it explains: the rates are read as a correction to what the
|
||||
table above already shows. Kept on its own `showWorkspaceOverrideEditor` gate so
|
||||
the instance scope, which has no usage panel, still edits rates. -->
|
||||
{#if showWorkspaceOverrideEditor}
|
||||
<ModelPricing {aiProviders} bind:modelPricing />
|
||||
{/if}
|
||||
|
||||
{#if promptScope === 'workspace'}
|
||||
<SettingCard
|
||||
label="Hide AI sessions"
|
||||
description="Hides AI sessions and every other AI assistant button (chat, code generation and completion, AI fix) from all members of this workspace. AI agent steps and the AI sandbox in flows are not affected and keep using the providers configured above. This hides the assistant in the UI only; it does not restrict API access to the configured providers."
|
||||
>
|
||||
<Toggle
|
||||
checked={copilotDisabled}
|
||||
on:change={(e) => {
|
||||
copilotDisabled = e.detail
|
||||
}}
|
||||
options={{ right: 'Hide AI sessions in this workspace' }}
|
||||
/>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AIPromptsModal
|
||||
@@ -624,39 +657,6 @@
|
||||
scope={promptScope}
|
||||
/>
|
||||
|
||||
{#if promptScope === 'workspace'}
|
||||
<!-- Recorded usage must be priced with the rates the chats actually ran under.
|
||||
A workspace on instance defaults has no rates of its own, so the effective
|
||||
ones come from copilotInfo rather than from this form's (empty) workspace
|
||||
config. -->
|
||||
<AiUsagePanel
|
||||
workspace={effectiveWorkspace}
|
||||
modelPricing={usesInstanceAiConfig ? ($copilotInfo.modelPricing ?? {}) : modelPricing}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Below the usage it explains: the rates are read as a correction to what the
|
||||
table above already shows. Kept on its own `showWorkspaceOverrideEditor` gate so
|
||||
the instance scope, which has no usage panel, still edits rates. -->
|
||||
{#if showWorkspaceOverrideEditor}
|
||||
<ModelPricing {aiProviders} bind:modelPricing />
|
||||
{/if}
|
||||
|
||||
{#if promptScope === 'workspace'}
|
||||
<SettingCard
|
||||
label="Hide AI sessions"
|
||||
description="Hides AI sessions and every other AI assistant button (chat, code generation and completion, AI fix) from all members of this workspace. AI agent steps and the AI sandbox in flows are not affected and keep using the providers configured above. This hides the assistant in the UI only; it does not restrict API access to the configured providers."
|
||||
>
|
||||
<Toggle
|
||||
checked={copilotDisabled}
|
||||
on:change={(e) => {
|
||||
copilotDisabled = e.detail
|
||||
}}
|
||||
options={{ right: 'Hide AI sessions in this workspace' }}
|
||||
/>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
|
||||
<!-- Not gated on `showWorkspaceOverrideEditor`: a workspace on instance defaults still has
|
||||
the hide toggle above to save. -->
|
||||
<SettingsFooter
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
## Heading 2
|
||||
|
||||
### Heading 3
|
||||
|
||||
Body text with **bold**, *italic*, a [link](https://windmill.dev), and \`inline code\` that must stay readable in both themes.
|
||||
|
||||
> A block quote should be legible too.
|
||||
@@ -68,7 +70,13 @@ Raw sanitized HTML (via rehypeRaw) must keep its content, not render empty:
|
||||
// AssistantMessage, and fenced code blocks go through CodeDisplay →
|
||||
// HighlightCode. Exercise several languages + prose so the code-block styling
|
||||
// can be tuned against the real render path, not an approximation.
|
||||
const chatSampleContent = `Here's how you'd wire up the trigger. First, some prose with \`inline code\`, a [link](https://windmill.dev), and **bold** text so we can see how code sits next to surrounding content.
|
||||
const chatSampleContent = `# Wiring up the trigger
|
||||
|
||||
Here's how you'd wire up the trigger. First, some prose with \`inline code\`, a [link](https://windmill.dev), and **bold** text so we can see how code sits next to surrounding content.
|
||||
|
||||
## The script
|
||||
|
||||
### A subsection
|
||||
|
||||
\`\`\`python
|
||||
def main(name: str = "world"):
|
||||
@@ -188,7 +196,19 @@ That's the full round-trip.`
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="markdown" class="p-4">
|
||||
<GfmMarkdown md={sampleMarkdown} />
|
||||
<div class="text-xs text-tertiary mb-3">
|
||||
The three <code>markdownProse</code> presets, same source. Each is sized for its own
|
||||
surface: <code>xs</code> for group notes and chat reasoning, <code>sm</code> for chat
|
||||
bubbles, sticky notes and markdown job results, <code>doc</code> for artifacts.
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
{#each ['xs', 'sm', 'doc'] as const as prose}
|
||||
<div class="border border-border-light rounded-lg p-3 bg-surface min-w-0">
|
||||
<div class="text-2xs text-tertiary font-mono mb-2">{prose}</div>
|
||||
<GfmMarkdown md={sampleMarkdown} {prose} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="chat" class="p-4">
|
||||
<div class="text-xs text-tertiary mb-3">
|
||||
|
||||
Reference in New Issue
Block a user