Files
windmill/frontend/src/lib/components/ApiConnectForm.svelte
T
Ruben FiszelandClaude Opus 4.8 07d4b674f1 fix: manual resource type sync fetches from hub first, cache as fallback (#10269)
* fix: manual resource type sync fetches from hub first, cache as fallback

The superadmin "Synchronize resource types" endpoint
(POST /api/settings/sync_cached_resource_types) was cache-first: it read the
on-disk hub_rt cache and only fell back to the hub when no cache file existed.
Since that cache is refreshed by a daily cron, a newly-published hub type could
not be pulled on demand, the button replayed the stale cache and reported
"Synced 0", so the type never landed in the admins workspace.

The manual endpoint is now hub-first: it fetches the live list and upserts it
into admins, falling back to reading the on-disk cache only when the hub is
unreachable (airgapped install / network error), logging which path it took.
The startup/offline sync in main.rs (SYNC_CACHED_RT + the cache-rt cron) stays
cache-based and owns writing the cache, so this endpoint never touches it.

Adds an optional `name` query param: when a specific type is requested and is
still absent from the hub after syncing, the endpoint returns an explicit
not-found instead of a silent "Synced 0". The three not-found frontend call
sites (ResourceForm, AppConnectInner, ApiConnectForm) thread the type name
through SyncResourceTypes; the global instance-settings button stays name-less.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: run whole-list sync before the targeted not-found check, word 404 by source

Address review nits: the optional `name` check ran before the upsert loop, so a
`?name=<absent>` request skipped the whole-list refresh; move it after the loop so
the sync always happens. Also word the not-found 404 by source, the cache-fallback
path (hub unreachable) no longer claims it checked the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:09:24 +02:00

285 lines
7.7 KiB
Svelte

<script lang="ts">
import { OauthService, type ResourceType } from '$lib/gen'
import FilesetEditor from './FilesetEditor.svelte'
import { workspaceStore } from '$lib/stores'
import { emptySchema, emptyString } from '$lib/utils'
import SchemaForm from './SchemaForm.svelte'
import Toggle from './Toggle.svelte'
import TestConnection from './TestConnection.svelte'
import SupabaseIcon from './icons/SupabaseIcon.svelte'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { base } from '$lib/base'
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
import BedrockCredentialsCheck from './BedrockCredentialsCheck.svelte'
import { isCloudHosted } from '$lib/cloud'
import ResourceGen from './copilot/ResourceGen.svelte'
import SyncResourceTypes from './SyncResourceTypes.svelte'
interface Props {
resourceType: string
resourceTypeInfo: ResourceType | undefined
args?: Record<string, any> | any
linkedSecrets?: string[]
isValid?: boolean
linkedSecretCandidates?: string[] | undefined
description?: string | undefined
onSynced?: () => void
}
let {
resourceType,
resourceTypeInfo,
args = $bindable({}),
linkedSecrets = $bindable([]),
isValid = $bindable(true),
linkedSecretCandidates = undefined,
description = $bindable(undefined),
onSynced = undefined
}: Props = $props()
let schema = $state(emptySchema())
let notFound = $state(false)
let supabaseWizard = $state(false)
async function isSupabaseAvailable() {
try {
supabaseWizard = ((await OauthService.listOauthConnects()) ?? []).some(
(c) => c.name === 'supabase_wizard'
)
} catch (error) {}
}
async function loadSchema() {
if (!resourceTypeInfo) return
rawCode = '{}'
viewJsonSchema = false
try {
schema = resourceTypeInfo.schema as any
schema.order = schema.order ?? Object.keys(schema.properties).sort()
notFound = false
} catch (e) {
notFound = true
}
}
function parseJson() {
try {
args = JSON.parse(rawCode)
error = ''
isValid = true
} catch (e) {
isValid = false
error = e.message
}
}
let error = $state('')
let rawCode = $state('')
let viewJsonSchema = $state(false)
function switchTab(asJson: boolean) {
viewJsonSchema = asJson
if (asJson) {
rawCode = JSON.stringify(args, null, 2)
} else {
parseJson()
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
textFileContent = args.content
}
}
}
let connectionString = $state('')
let validConnectionString = $state(true)
function parseConnectionString(close: (_: any) => void) {
const regex =
/postgres(?:ql)?:\/\/(?<user>[^:@]+)(?::(?<password>[^@]+))?@(?<host>[^:\/?]+)(?::(?<port>\d+))?\/(?<dbname>[^\?]+)?(?:\?.*sslmode=(?<sslmode>[^&]+))?/
const match = connectionString.match(regex)
if (match) {
validConnectionString = true
const { user, password, host, port, dbname, sslmode } = match.groups!
rawCode = JSON.stringify(
{
...args,
user,
password: password || args?.password,
host,
port: (port ? Number(port) : undefined) || args?.port,
dbname: dbname || args?.dbname,
sslmode: sslmode || args?.sslmode
},
null,
2
)
rawCodeEditor?.setCode(rawCode)
close(null)
} else {
validConnectionString = false
}
}
let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined)
let textFileContent: string | undefined = $state(undefined)
function parseTextFileContent() {
args = {
content: textFileContent
}
}
$effect(() => {
$workspaceStore && untrack(() => loadSchema())
})
$effect(() => {
notFound && rawCode && untrack(() => parseJson())
})
$effect(() => {
rawCode && untrack(() => parseJson())
})
$effect(() => {
textFileContent && untrack(() => parseTextFileContent())
})
$effect(() => {
resourceType == 'postgresql' && untrack(() => isSupabaseAvailable())
})
</script>
{#if !notFound}
<div class="w-full flex gap-2 flex-row-reverse items-center">
<Toggle
on:change={(e) => switchTab(e.detail)}
options={{
right: 'As JSON'
}}
class="as-json-toggle"
/>
<ResourceGen
bind:args
{resourceType}
resourceSchema={notFound ? undefined : schema}
isFileset={resourceTypeInfo?.is_fileset ?? false}
/>
<TestConnection {resourceType} {args} />
{#if resourceType == 'postgresql'}
<Popover
floatingConfig={{
placement: 'bottom'
}}
>
{#snippet trigger()}
<Button spacingSize="sm" size="xs" variant="default" nonCaptureEvent>
From connection string
</Button>
{/snippet}
{#snippet content({ close })}
<div class="block text-primary p-4">
<div class="w-[550px] flex flex-col items-start gap-1">
<div class="flex flex-row gap-1 w-full">
<input
type="text"
bind:value={connectionString}
placeholder="postgres://user:password@host:5432/dbname?sslmode=disable"
/>
<Button
size="xs"
color="blue"
buttonType="button"
on:click={() => {
parseConnectionString(close)
}}
disabled={connectionString.length <= 0}
>
Apply
</Button>
</div>
{#if !validConnectionString}
<p class="text-red-500 text-xs">Could not parse connection string</p>
{/if}
</div>
</div>
{/snippet}
</Popover>
{/if}
{#if resourceType == 'postgresql' && supabaseWizard}
<a
target="_blank"
href="{base}/api/oauth/connect/supabase_wizard"
class="border rounded-lg flex flex-row gap-2 items-center text-xs px-3 py-1.5 h-8 bg-[#F1F3F5] hover:bg-[#E6E8EB] dark:bg-[#1C1C1C] dark:hover:bg-black"
>
<SupabaseIcon height="16px" width="16px" />
<div class="text-[#11181C] dark:text-[#EDEDED] font-semibold">Connect Supabase</div>
</a>
{/if}
<GitHubAppIntegration
{resourceType}
{args}
{description}
onArgsUpdate={(newArgs) => {
args = newArgs
rawCode = JSON.stringify(args, null, 2)
rawCodeEditor?.setCode(rawCode)
}}
onDescriptionUpdate={(newDescription) => (description = newDescription)}
/>
</div>
{#if resourceType?.includes('bedrock') && !isCloudHosted()}
<BedrockCredentialsCheck />
{/if}
{:else}
<p class="text-primary font-normal text-xs mb-4"
>No corresponding resource type found in your workspace for {resourceType}. Define the value in
JSON directly</p
>
<SyncResourceTypes {resourceType} {onSynced} />
{/if}
{#if notFound || viewJsonSchema}
{#if !emptyString(error)}<span class="text-red-400 text-xs mb-1 flex flex-row-reverse"
>{error}</span
>{:else}<div class="py-2"></div>{/if}
<div class="h-full w-full border p-1 rounded">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={rawCodeEditor}
autoHeight
lang="json"
bind:code={rawCode}
fixedOverflowWidgets={false}
/>
{/await}
</div>
{:else if resourceTypeInfo?.is_fileset}
<h5 class="mt-1 inline-flex items-center gap-4"> Fileset </h5>
<FilesetEditor bind:args />
{:else if resourceTypeInfo?.format_extension}
<h5 class="mt-4 inline-flex items-center gap-4">
File content ({resourceTypeInfo.format_extension})
</h5>
<div class="py-2"></div>
<div class="h-full w-full border p-1 rounded">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={rawCodeEditor}
autoHeight
lang={resourceTypeInfo.format_extension}
bind:code={textFileContent}
fixedOverflowWidgets={false}
/>
{/await}
</div>
{:else}
<SchemaForm
onlyMaskPassword
noDelete
{linkedSecretCandidates}
bind:linkedSecrets
isValid
{schema}
bind:args
/>
{/if}