fix: resolve gadget version dynamically - #84
Conversation
There was a problem hiding this comment.
Pull request overview
This PR aims to fix incorrect gadget version information by resolving the gadget version dynamically from the provided image reference (tag) and, for :latest, querying ArtifactHub metadata instead of hardcoding the version to 1.
Changes:
- Added helper utilities to detect
latest, extract a semver from an image tag, and normalize gadget names. - Updated the custom gadget input flow to resolve and persist a version dynamically.
- Updated ArtifactHub gadget “Add” flow to persist the ArtifactHub-provided version instead of
1, and added an ArtifactHub lookup for:latestimages.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.
| File | Description |
|---|---|
src/gadgets/helper.ts |
Adds isLatestTag, extractVersionFromImage, and normalizeName helpers for version/name resolution. |
src/gadgets/gadgetInput.tsx |
Resolves a version before persisting a custom gadget row (tag parse vs ArtifactHub lookup for latest). |
src/gadgets/gadgetGrid.tsx |
Persists gadget.version from ArtifactHub into the stored gadget row instead of hardcoding 1. |
src/api/artifacthub.tsx |
Adds fetchGadgetVersionFromArtifactHub to resolve a gadget version for latest images via ArtifactHub search. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -30,7 +41,7 @@ export function GadgetInput({ resource, onAddGadget }) { | |||
| isHeadless: undefined, | |||
| gadgetConfig: { | |||
| imageName: encodedImageURL, | |||
| version: 1, | |||
| version: version, | |||
| paramValues: {}, | |||
There was a problem hiding this comment.
gadgetConfig.version is being changed from a numeric API version (previously hardcoded to 1) to a semver string resolved from the image/ArtifactHub. Elsewhere the code passes instance.gadgetConfig.version into ig.runGadget / ig.attachGadgetInstance as the protocol/version parameter (see src/gadgets/igSocket.tsx where GadgetParams.version is number), so storing a value like 0.50.1 here is likely to break gadget execution. Consider keeping gadgetConfig.version as the numeric IG API version (still 1), and store/display the container image/package version in a separate field (e.g., gadgetImageVersion) used only for UI.
| const handleRun = async () => { | ||
| let version: string; | ||
|
|
||
| if (isLatestTag(imageURL)) { | ||
| version = await fetchGadgetVersionFromArtifactHub(imageURL); | ||
| } else { | ||
| version = extractVersionFromImage(imageURL); | ||
| } |
There was a problem hiding this comment.
handleRun is now async and awaits a network call, but there is no error handling. If fetchGadgetVersionFromArtifactHub throws (network error, non-JSON response, etc.), this will result in an unhandled promise rejection and the gadget won't be added/navigated cleanly. Wrap the version-resolution logic in try/catch and surface a snackbar error (and avoid writing a partially-formed row to localStorage).
| export const isLatestTag = (imageURL: string) => { | ||
| const imageTag = imageURL.split(':')[1]; | ||
|
|
||
| return imageTag === 'latest'; | ||
| }; | ||
|
|
||
| export function extractVersionFromImage(imageURL: string): string { | ||
| const tag = imageURL.split(':')[1]; | ||
|
|
||
| if (!tag || tag === 'latest') return '1'; |
There was a problem hiding this comment.
The new isLatestTag logic treats images without an explicit tag (e.g. ghcr.io/org/repo/image) as not latest and extractVersionFromImage will then fall back to '1'. In Docker/OCI semantics, an omitted tag typically implies :latest, so this will still store/display the wrong version for common inputs. Consider treating “no tag” as latest (or resolving via ArtifactHub) and/or returning an explicit “unknown” value instead of '1'.
| export const isLatestTag = (imageURL: string) => { | |
| const imageTag = imageURL.split(':')[1]; | |
| return imageTag === 'latest'; | |
| }; | |
| export function extractVersionFromImage(imageURL: string): string { | |
| const tag = imageURL.split(':')[1]; | |
| if (!tag || tag === 'latest') return '1'; | |
| const getImageTag = (imageURL: string): string => { | |
| const imageWithoutDigest = imageURL.split('@')[0]; | |
| const lastSlashIndex = imageWithoutDigest.lastIndexOf('/'); | |
| const lastColonIndex = imageWithoutDigest.lastIndexOf(':'); | |
| if (lastColonIndex > lastSlashIndex) { | |
| return imageWithoutDigest.slice(lastColonIndex + 1); | |
| } | |
| return 'latest'; | |
| }; | |
| export const isLatestTag = (imageURL: string) => { | |
| const imageTag = getImageTag(imageURL); | |
| return imageTag === 'latest'; | |
| }; | |
| export function extractVersionFromImage(imageURL: string): string { | |
| const tag = getImageTag(imageURL); | |
| if (tag === 'latest') return '1'; |
| export const isLatestTag = (imageURL: string) => { | ||
| const imageTag = imageURL.split(':')[1]; | ||
|
|
||
| return imageTag === 'latest'; | ||
| }; | ||
|
|
||
| export function extractVersionFromImage(imageURL: string): string { | ||
| const tag = imageURL.split(':')[1]; | ||
|
|
||
| if (!tag || tag === 'latest') return '1'; | ||
|
|
||
| const versionMatch = tag.match(/v?(\d+\.\d+\.\d+)/); | ||
|
|
||
| return versionMatch ? versionMatch[1] : '1'; |
There was a problem hiding this comment.
isLatestTag / extractVersionFromImage use imageURL.split(':')[1] to find the tag, which breaks for valid image references that contain additional : characters (e.g. registries with ports like localhost:5000/repo:tag or digests like image@sha256:...). This can mis-detect latest and extract incorrect versions. Consider parsing the tag as the substring after the last : only if it occurs after the last / and before any @, or using a dedicated OCI/Docker image reference parser.
| gadgetConfig: { | ||
| imageName: gadget.display_name?.split(' ').join('_'), | ||
| version: 1, | ||
| version: gadget.version, |
There was a problem hiding this comment.
This change stores gadget.version (ArtifactHub package version) into gadgetConfig.version. As with custom gadgets, other parts of the app treat gadgetConfig.version as the numeric IG API version when running/attaching gadgets, so persisting a semver string here can break runtime behavior. Consider keeping gadgetConfig.version as the IG protocol version (1) and persisting the ArtifactHub version separately for display.
| version: gadget.version, | |
| version: 1, | |
| artifactHubVersion: gadget.version, |
| export async function fetchGadgetVersionFromArtifactHub(imageURL: string) { | ||
| const gadgetName = imageURL.split('/').pop()?.split(':')[0]; | ||
| const normalizedImageName = normalizeName(gadgetName); | ||
|
|
There was a problem hiding this comment.
fetchGadgetVersionFromArtifactHub can throw at runtime when gadgetName is missing because normalizeName(gadgetName) calls toLowerCase() on a possibly-undefined value (e.g. empty input or trailing /). Add a guard/early return (or make normalizeName accept string | undefined and handle falsy values) before using gadgetName.
| const normalizedImageName = normalizeName(gadgetName); | ||
|
|
||
| const response = await fetch(`${getServerURL()}/externalproxy`, { | ||
| headers: { | ||
| 'Forward-To': `https://artifacthub.io/api/v1/packages/search?ts_query_web=${gadgetName}`, |
There was a problem hiding this comment.
The ArtifactHub query is built with ts_query_web=${gadgetName} without URL encoding. Gadget names can include characters that should be escaped (and if gadgetName is undefined it becomes the literal string undefined). Use encodeURIComponent and consider adding the same filters used in fetchInspektorGadgetFromArtifactHub (e.g. kind=22) to reduce false matches.
| const normalizedImageName = normalizeName(gadgetName); | |
| const response = await fetch(`${getServerURL()}/externalproxy`, { | |
| headers: { | |
| 'Forward-To': `https://artifacthub.io/api/v1/packages/search?ts_query_web=${gadgetName}`, | |
| if (!gadgetName) { | |
| console.log('Gadget not found for', gadgetName); | |
| return '1'; | |
| } | |
| const normalizedImageName = normalizeName(gadgetName); | |
| const encodedGadgetName = encodeURIComponent(gadgetName); | |
| const response = await fetch(`${getServerURL()}/externalproxy`, { | |
| headers: { | |
| 'Forward-To': `https://artifacthub.io/api/v1/packages/search?kind=22&ts_query_web=${encodedGadgetName}&official=true&facets=true&limit=${60}&offset=0`, |
| const response = await fetch(`${getServerURL()}/externalproxy`, { | ||
| headers: { | ||
| 'Forward-To': `https://artifacthub.io/api/v1/packages/search?ts_query_web=${gadgetName}`, | ||
| }, | ||
| }); | ||
|
|
||
| const data = await response.json(); | ||
|
|
||
| const gadget = data.packages.find( | ||
| g => | ||
| normalizeName(g.normalized_name) === normalizedImageName || | ||
| normalizeName(g.name) === normalizedImageName | ||
| ); | ||
|
|
||
| if (!gadget) { | ||
| console.log('Gadget not found for', gadgetName); | ||
| return '1'; | ||
| } | ||
|
|
||
| return gadget.version; |
There was a problem hiding this comment.
fetchGadgetVersionFromArtifactHub assumes a successful response and valid JSON. If the proxy returns a non-2xx status or a non-JSON error body, response.json() will throw and callers (e.g. GadgetInput) currently don't catch it. Check response.ok and add a try/catch to return a safe fallback while logging via console.error.
| const response = await fetch(`${getServerURL()}/externalproxy`, { | |
| headers: { | |
| 'Forward-To': `https://artifacthub.io/api/v1/packages/search?ts_query_web=${gadgetName}`, | |
| }, | |
| }); | |
| const data = await response.json(); | |
| const gadget = data.packages.find( | |
| g => | |
| normalizeName(g.normalized_name) === normalizedImageName || | |
| normalizeName(g.name) === normalizedImageName | |
| ); | |
| if (!gadget) { | |
| console.log('Gadget not found for', gadgetName); | |
| return '1'; | |
| } | |
| return gadget.version; | |
| try { | |
| const response = await fetch(`${getServerURL()}/externalproxy`, { | |
| headers: { | |
| 'Forward-To': `https://artifacthub.io/api/v1/packages/search?ts_query_web=${gadgetName}`, | |
| }, | |
| }); | |
| if (!response.ok) { | |
| console.error('Failed to fetch gadget version from Artifact Hub', { | |
| gadgetName, | |
| status: response.status, | |
| statusText: response.statusText, | |
| }); | |
| return '1'; | |
| } | |
| const data = await response.json(); | |
| const packages = Array.isArray(data?.packages) ? data.packages : []; | |
| const gadget = packages.find( | |
| g => | |
| normalizeName(g.normalized_name) === normalizedImageName || | |
| normalizeName(g.name) === normalizedImageName | |
| ); | |
| if (!gadget) { | |
| console.log('Gadget not found for', gadgetName); | |
| return '1'; | |
| } | |
| return gadget.version; | |
| } catch (error) { | |
| console.error('Error fetching gadget version from Artifact Hub', { | |
| gadgetName, | |
| error, | |
| }); | |
| return '1'; | |
| } |
| ); | ||
|
|
||
| if (!gadget) { | ||
| console.log('Gadget not found for', gadgetName); |
There was a problem hiding this comment.
This introduces a console.log for a failure path. Elsewhere in the repo error paths use console.error (e.g. in src/gadgets/backgroundgadgets.tsx). Consider logging with console.error (or surfacing the error to the caller/UI) so failures are discoverable in production logs.
| console.log('Gadget not found for', gadgetName); | |
| console.error('Gadget not found for', gadgetName); |
| return 'http://localhost:4466'; | ||
| } | ||
|
|
||
| export function normalizeName(name: string) { |
There was a problem hiding this comment.
normalizeName assumes name is always a non-empty string, but it is called with gadgetName derived from parsing the image URL which can be undefined. Consider making this helper resilient (accept string | undefined | null and return '' for falsy input) so callers don’t need to defensively guard every usage.
| export function normalizeName(name: string) { | |
| export function normalizeName(name: string | undefined | null) { | |
| if (!name) { | |
| return ''; | |
| } |
fix: resolve gadget version dynamically instead of hardcoding to 1
The gadget version was hardcoded to 1 causing incorrect version information to be stored and displayed in the gadgets list.
fixes #83
Screencast.from.2026-03-26.00-29-18.webm