|
260 | 260 | drawerStore.close() |
261 | 261 | } |
262 | 262 |
|
263 | | - type DockerCheckStatus = 'idle' | 'pending' | 'valid' | 'invalid' | 'timeout' |
| 263 | + type DockerCheckStatus = 'idle' | 'pending' | 'valid' | 'invalid' | 'timeout' | 'warning' |
264 | 264 | type DockerCheckState = { |
265 | 265 | status: DockerCheckStatus |
266 | 266 | message: string |
|
269 | 269 | type DockerCheckResult = { |
270 | 270 | ok: boolean |
271 | 271 | timedOut?: boolean |
| 272 | + message?: string |
| 273 | + warning?: boolean |
272 | 274 | } |
273 | 275 |
|
274 | 276 | const requiresDockerRegistry = (driver: string): boolean => RegistryDrivers.includes(driver) |
|
277 | 279 | let dockerCheckAbort: AbortController | null = null |
278 | 280 | let dockerCheckDebounce: ReturnType<typeof setTimeout> | null = null |
279 | 281 |
|
280 | | - class TimeoutError extends Error { |
281 | | - constructor(message = 'Request timed out') { |
282 | | - super(message) |
283 | | - this.name = 'TimeoutError' |
284 | | - } |
285 | | - } |
286 | | -
|
287 | 282 | const resetDockerCheck = () => { |
288 | 283 | if (dockerCheckAbort) { |
289 | 284 | dockerCheckAbort.abort() |
|
300 | 295 | resetDockerCheck() |
301 | 296 | } |
302 | 297 |
|
303 | | - const fetchWithTimeout = async ( |
304 | | - url: string, |
305 | | - init: RequestInit = {}, |
306 | | - timeoutMs: number | null = 5000, |
307 | | - externalSignal?: AbortSignal |
308 | | - ): Promise<Response> => { |
309 | | - const controller = new AbortController() |
310 | | - const { signal } = controller |
311 | | - let timedOut = false |
312 | | -
|
313 | | - let timeoutId: ReturnType<typeof setTimeout> | null = null |
314 | | - if (timeoutMs !== null && Number.isFinite(timeoutMs) && timeoutMs > 0) { |
315 | | - timeoutId = setTimeout(() => { |
316 | | - timedOut = true |
317 | | - controller.abort() |
318 | | - }, timeoutMs) |
319 | | - } |
320 | | -
|
321 | | - const cleanup = () => { |
322 | | - if (timeoutId) clearTimeout(timeoutId) |
323 | | - if (externalSignal && abortHandler) { |
324 | | - externalSignal.removeEventListener('abort', abortHandler) |
325 | | - } |
326 | | - } |
327 | | -
|
328 | | - let abortHandler: (() => void) | null = null |
329 | | - if (externalSignal) { |
330 | | - if (externalSignal.aborted) { |
331 | | - controller.abort() |
332 | | - } else { |
333 | | - abortHandler = () => controller.abort() |
334 | | - externalSignal.addEventListener('abort', abortHandler) |
335 | | - } |
336 | | - } |
337 | | -
|
338 | | - try { |
339 | | - return await fetch(url, { ...init, signal }) |
340 | | - } catch (error) { |
341 | | - if (timedOut) { |
342 | | - throw new TimeoutError() |
343 | | - } |
344 | | - throw error |
345 | | - } finally { |
346 | | - cleanup() |
347 | | - } |
348 | | - } |
349 | | -
|
350 | 298 | const checkDockerImageAvailability = async ( |
351 | 299 | imageUri: string, |
352 | 300 | signal?: AbortSignal, |
353 | 301 | timeoutMs: number | null = 5000 |
354 | 302 | ): Promise<DockerCheckResult> => { |
355 | 303 | if (!imageUri?.trim()) return { ok: false } |
356 | 304 |
|
357 | | - const normalized = imageUri.trim().replace(/^https?:\/\//, '') |
358 | | - const protocol = imageUri.startsWith('http://') ? 'http' : 'https' |
359 | | -
|
360 | | - const lastAt = normalized.lastIndexOf('@') |
361 | | - const lastColon = normalized.lastIndexOf(':') |
362 | | - const separatorIndex = lastAt > -1 ? lastAt : lastColon |
363 | | -
|
364 | | - if (separatorIndex === -1) { |
365 | | - return { ok: false } |
366 | | - } |
367 | | -
|
368 | | - const hostAndRepo = normalized.slice(0, separatorIndex) |
369 | | - const reference = normalized.slice(separatorIndex + 1) |
370 | | -
|
371 | | - if (!hostAndRepo || !reference) { |
372 | | - return { ok: false } |
373 | | - } |
374 | | -
|
375 | | - const [host, ...repoParts] = hostAndRepo.split('/') |
376 | | - if (!host || repoParts.length === 0) { |
377 | | - return { ok: false } |
378 | | - } |
379 | | -
|
380 | | - const repository = repoParts.join('/') |
381 | | - const manifestUrl = `${protocol}://${host}/v2/${repository}/manifests/${reference}` |
382 | | -
|
383 | 305 | try { |
384 | | - const response = await fetchWithTimeout( |
385 | | - manifestUrl, |
386 | | - { |
387 | | - method: 'GET', |
388 | | - headers: { |
389 | | - Accept: 'application/vnd.docker.distribution.manifest.v2+json' |
390 | | - } |
| 306 | + const response = await fetch('/api/components/registry', { |
| 307 | + method: 'POST', |
| 308 | + headers: { |
| 309 | + 'Content-Type': 'application/json' |
391 | 310 | }, |
392 | | - timeoutMs, |
| 311 | + body: JSON.stringify({ |
| 312 | + imageUri, |
| 313 | + timeoutMs |
| 314 | + }), |
393 | 315 | signal |
394 | | - ) |
| 316 | + }) |
395 | 317 |
|
396 | | - return { ok: response.ok } |
397 | | - } catch (error) { |
398 | | - if ((error as Error).name === 'TimeoutError') { |
399 | | - return { ok: false, timedOut: true } |
| 318 | + const payload = (await response.json().catch(() => null)) as DockerCheckResult | null |
| 319 | + if (!payload) { |
| 320 | + return { |
| 321 | + ok: false, |
| 322 | + message: 'Invalid response from verification service.' |
| 323 | + } |
400 | 324 | } |
| 325 | +
|
| 326 | + return payload |
| 327 | + } catch (error) { |
401 | 328 | if ((error as DOMException).name === 'AbortError') { |
402 | 329 | throw error |
403 | 330 | } |
404 | | - throw error |
| 331 | +
|
| 332 | + return { |
| 333 | + ok: false, |
| 334 | + warning: true, |
| 335 | + message: 'Failed to contact verification service.' |
| 336 | + } |
405 | 337 | } |
406 | 338 | } |
407 | 339 |
|
|
436 | 368 | } |
437 | 369 | } else if (result.ok) { |
438 | 370 | dockerCheck = { status: 'valid', message: '' } |
| 371 | + } else if (result.warning) { |
| 372 | + dockerCheck = { |
| 373 | + status: 'warning', |
| 374 | + message: |
| 375 | + result.message || |
| 376 | + 'Could not verify Docker image because the request failed unexpectedly.' |
| 377 | + } |
439 | 378 | } else { |
440 | 379 | dockerCheck = { |
441 | 380 | status: 'invalid', |
442 | | - message: 'Docker image not reachable. Please ensure the image is available.' |
| 381 | + message: |
| 382 | + result.message || |
| 383 | + 'Docker image not reachable. Please ensure the image is available.' |
443 | 384 | } |
444 | 385 | } |
445 | 386 | }) |
446 | 387 | .catch((error) => { |
447 | 388 | if ((error as DOMException).name === 'AbortError') { |
448 | 389 | return |
449 | 390 | } |
| 391 | +
|
| 392 | + if (error instanceof TypeError) { |
| 393 | + dockerCheck = { |
| 394 | + status: 'warning', |
| 395 | + message: 'Could not contact the verification service. Please check your connection.' |
| 396 | + } |
| 397 | + return |
| 398 | + } |
| 399 | +
|
450 | 400 | dockerCheck = { |
451 | 401 | status: 'invalid', |
452 | 402 | message: 'Failed to verify Docker image. Please check the URL and network.' |
|
698 | 648 | {#if dockerCheck.status === 'invalid'} |
699 | 649 | <Tip tipTheme="error"> |
700 | 650 | {dockerCheck.message} |
701 | | - </Tip> |
702 | | - {:else if dockerCheck.status === 'timeout'} |
703 | | - <Tip tipTheme="tertiary"> |
704 | | - <div class="flex flex-wrap items-center justify-between gap-2"> |
705 | | - <span>{dockerCheck.message}</span> |
706 | | - <div class="flex flex-wrap gap-2"> |
707 | | - <button |
708 | | - type="button" |
709 | | - class="button-warning" |
710 | | - on:click={() => runDockerCheck(component.target, null)} |
711 | | - > |
712 | | - Retry (no timeout) |
| 651 | + </Tip> |
| 652 | + {:else if dockerCheck.status === 'timeout'} |
| 653 | + <Tip tipTheme="tertiary"> |
| 654 | + <div class="flex flex-wrap items-center justify-between gap-2"> |
| 655 | + <span>{dockerCheck.message}</span> |
| 656 | + <div class="flex flex-wrap gap-2"> |
| 657 | + <button |
| 658 | + type="button" |
| 659 | + class="button-warning" |
| 660 | + on:click={() => runDockerCheck(component.target, null)} |
| 661 | + > |
| 662 | + Retry (no timeout) |
| 663 | + </button> |
| 664 | + <button |
| 665 | + type="button" |
| 666 | + class="button-neutral" |
| 667 | + on:click={cancelDockerCheck} |
| 668 | + > |
| 669 | + Cancel |
| 670 | + </button> |
| 671 | + </div> |
| 672 | + </div> |
| 673 | + </Tip> |
| 674 | + {:else if dockerCheck.status === 'pending'} |
| 675 | + <Tip tipTheme="primary"> |
| 676 | + <div class="flex flex-wrap items-center justify-between gap-2"> |
| 677 | + <span>Checking image availability…</span> |
| 678 | + <button type="button" class="button-neutral" on:click={cancelDockerCheck}> |
| 679 | + Cancel |
713 | 680 | </button> |
| 681 | + </div> |
| 682 | + </Tip> |
| 683 | + {:else if dockerCheck.status === 'warning'} |
| 684 | + <Tip tipTheme="warning"> |
| 685 | + <div class="flex flex-wrap items-center justify-between gap-2"> |
| 686 | + <span>{dockerCheck.message}</span> |
714 | 687 | <button |
715 | 688 | type="button" |
716 | 689 | class="button-neutral" |
717 | | - on:click={cancelDockerCheck} |
| 690 | + on:click={() => runDockerCheck(component.target, null)} |
718 | 691 | > |
719 | | - Cancel |
| 692 | + Retry anyway |
720 | 693 | </button> |
721 | 694 | </div> |
722 | | - </div> |
723 | | - </Tip> |
724 | | - {:else if dockerCheck.status === 'pending'} |
725 | | - <Tip tipTheme="primary"> |
726 | | - <div class="flex flex-wrap items-center justify-between gap-2"> |
727 | | - <span>Checking image availability…</span> |
728 | | - <button type="button" class="button-neutral" on:click={cancelDockerCheck}> |
729 | | - Cancel |
730 | | - </button> |
731 | | - </div> |
732 | | - </Tip> |
| 695 | + </Tip> |
| 696 | + {/if} |
733 | 697 | {/if} |
734 | | - {/if} |
735 | | - |
736 | | - <TextInput |
737 | | - style="md:col-span-2" |
738 | | - label="Target" |
739 | | - name="target" |
740 | | - bind:value={component.target} |
741 | | - error={component.target === '' ? "Target can't be empty" : ''} |
742 | | - /> |
743 | 698 |
|
744 | | - <div class="hidden group-focus-within:block"> |
745 | | - <Tip> |
746 | | - The target can be a Docker image name (Docker, Swarm and Kubernetes Driver), a URL |
747 | | - (Remote Driver) or a Java class path (UIMADriver). |
| 699 | + <TextInput |
| 700 | + style="md:col-span-2" |
| 701 | + label="Target" |
| 702 | + name="target" |
| 703 | + bind:value={component.target} |
| 704 | + error={component.target === '' ? "Target can't be empty" : ''} |
| 705 | + > |
| 706 | + <span slot="labelContent" class="flex w-full items-center justify-between gap-3"> |
| 707 | + <span>Target</span> |
| 708 | + {#if dockerCheck.status === 'valid'} |
| 709 | + <span class="badge variant-soft-success font-bold text-xs tracking-wide"> |
| 710 | + IMAGE ONLINE |
| 711 | + </span> |
| 712 | + {/if} |
| 713 | + </span> |
| 714 | + </TextInput> |
| 715 | + |
| 716 | + <div class="hidden group-focus-within:block"> |
| 717 | + <Tip> |
| 718 | + The target can be a Docker image name (Docker, Swarm and Kubernetes Driver), a URL |
| 719 | + (Remote Driver) or a Java class path (UIMADriver). |
748 | 720 | </Tip> |
749 | 721 | </div> |
750 | 722 | </div> |
|
0 commit comments