monolith/S05 virtual: render upstream size, URL, and connection status in the upstream table

What this does

Renders each upstream's size, URL, and connection status in the virtual repository detail page's upstream table, as the design (Figma AR-concepting, 589-36301) draws them. It also passes each stored upstream's size and URL through to the Edit page's upstream list, which until now rendered them only for upstreams added through the picker. The fields come from the widened upstream summary that !258049 (merged) brings into GraphQL, so they all arrive on the one upstream-list call with no per-row repository read.

Visibility is dropped for now. The design also draws a visibility icon on each row, but the closed beta creates Private repositories only, so every row would show the same lock that the page heading already shows. The repositories list dropped its per-row icon for the same reason in !248512 (merged). The query does not select visibility either, so the Edit page, which shares the query, does not fetch it. It can come back when Internal and Public visibility ship. See the review thread.

Connection status renders as an icon after the URL, as the design draws it, not as the sidebar's badge. It uses the sidebar's icon and label for each health value, and has a tooltip and an accessible name that names the upstream.

Artifact Registry serves sizeBytes and settings only to a viewer who can read that upstream, so a row can arrive without them. Such a row keeps its name, its Remote badge, and its position, and renders no URL line, no connection status, and a blank Size cell. A hosted upstream has no settings, so its row renders no URL line and no connection status either. The Edit page follows the same rule: an upstream the viewer cannot read keeps its place in the list, with no URL and a blank Size.

The surface is dark behind the artifact_registry_ui feature flag, so there is no changelog entry.

Builds on !258049 (merged) (merged). The spec is gitlab-org/ops/artifact-registry!2998 (merged) (docs/specs/monolith/S05-repository-detail.md, Upstream table, Accessibility, and the acceptance criteria), and this is Step 10 of the amended virtual detail plan in the same MR. Following review, this MR departs from that amendment in two places: rows render no visibility icon, and connection status is an icon rather than the sidebar's badge. The amendment needs updating to match.

Closes #631075 (closed)

Changes

  • get_repository_upstream_repositories.query.graphql: select sizeBytes and settings { url lastHealthStatus } on upstreamRepository. visibility and format stay unselected.
  • constants.js:
    • Add a Size column after Type in UPSTREAM_REPOSITORIES_TABLE_FIELDS, and rebalance the column widths (Order 1/10, Repository 1/2, Type 3/20, Size 3/20, Actions 1/10).
    • Add UPSTREAM_HEALTH_STATUS_ICONS, the icon, icon variant, and label for each health value: status-success / success / Healthy, status-failed / danger / Unhealthy, and severity-unknown / subtle / Unknown. The icons and labels match the sidebar indicator's. Unknown uses subtle because GlIcon has no neutral variant.
  • upstream_repositories_table.vue:
    • Repository cell: the name, then, on a row whose settings is present, a second line holding the upstream URL as plain text (no link, no copy action) and the connection status icon. The icon sits in a compact tertiary button so its tooltip (the verdict) is focusable, and the button's accessible name is Connection status for %{name}: %{verdict}. A row with null settings renders no second line. No visibility icon.
    • Size cell: sizeBytes through humanSize. 0 renders as 0 B, and null renders blank.
  • repositories_edit_form.vue: upstreamSources passes each stored upstream's sizeBytes and settings.url through, the same shape the picker gives an upstream it adds, so the order table's URL line and Size cell render for stored rows too. The save still sends upstream ids only, and the "did the list change" check still compares ids only.
  • Mock data, the repository_upstream_repositories.rb frontend fixture (a readable remote, a readable hosted with size 0, and an unreadable remote whose verdicts all deny, as Artifact Registry sends them), a VirtualRepositoryWithUnreadableUpstream story, and locale/gitlab.pot.
  • Specs:
    • upstream_repositories_table_spec.js: each source kind, each health value (icon, variant, tooltip, and accessible name), an unreadable remote row and an unreadable hosted row, Size 0 and null, and the five headers.
    • repositories_edit_form_spec.js: stored sources carry their URL and size and render them, a stored source and an added one render alike, an unreadable upstream keeps its position with no URL and a blank Size, and a reorder still saves by id.
    • repository_detail_spec.js over the regenerated fixture.

connection_indicator.vue is not changed. An earlier revision gave it a table-row mode, which review replaced with the inline icon.

References

How to set up and validate locally

Check out 631075-s05-render-upstream-visibility-size-url. Steps 1 to 6 need no Artifact Registry. Steps 7 to 9 run against a real Artifact Registry on the caproni rig with real role grants: Step 7 checks that it serves sizeBytes and settings only for an upstream the viewer can read, Step 8 covers the three health values, and Step 9 covers the Edit page. All three are recorded under Evidence. Nothing is stubbed.

Step 1 - Regenerate the frontend fixture

The fixture holds a readable remote, a readable hosted upstream with size 0, and a remote upstream the user cannot read.

bin/rspec ee/spec/frontend/fixtures/artifact_registry/repository_upstream_repositories.rb
Output (✅)
Finished in 12.59 seconds (files took 29.28 seconds to load)
2 examples, 0 failures

Step 2 - The Jest suites pass

The changed specs, plus the Edit page's form, upstream section, order table, and picker, and the unchanged connection indicator and sidebar specs, as regression checks.

D=ee/spec/frontend/packages_and_registries/artifact_registry
node_modules/.bin/jest --config jest.config.js \
  $D/repositories/detail/upstream_repositories_table_spec.js \
  $D/repositories/detail/repository_detail_spec.js \
  $D/repositories/detail/upstream_repository_actions_spec.js \
  $D/repositories/detail/connection_section_spec.js \
  $D/repositories/edit/repositories_edit_form_spec.js \
  $D/repositories/components/repository_form_spec.js \
  $D/repositories/components/upstream_repositories_section_spec.js \
  $D/repositories/components/upstream_repositories_order_table_spec.js \
  $D/repositories/components/upstream_repository_picker_spec.js \
  $D/repositories/components/connection_indicator_spec.js \
  $D/repositories/components/remote_source_section_spec.js \
  $D/graphql/cache_config_spec.js
Output (✅)
PASS .../detail/connection_section_spec.js
PASS .../detail/upstream_repository_actions_spec.js
PASS .../components/upstream_repositories_order_table_spec.js
PASS .../detail/upstream_repositories_table_spec.js
PASS .../components/connection_indicator_spec.js
PASS .../components/upstream_repository_picker_spec.js
PASS .../graphql/cache_config_spec.js
PASS .../components/upstream_repositories_section_spec.js
PASS .../components/remote_source_section_spec.js
PASS .../detail/repository_detail_spec.js
PASS .../components/repository_form_spec.js
PASS .../edit/repositories_edit_form_spec.js
Test Suites: 12 passed, 12 total
Tests:       789 passed, 789 total

The four new repositories_edit_form_spec.js examples fail against the old { id, name, kind } mapping.

Step 3 - The query document validates against the schema

bundle exec rake gitlab:graphql:validate 2>&1 | grep -E "get_repository_upstream_repositories|ERROR|FAIL"
Output (✅)
OK    ee/app/assets/javascripts/packages_and_registries/artifact_registry/graphql/queries/get_repository_upstream_repositories.query.graphql

The rake task exits 0 with no ERROR or FAIL lines.

Step 4 - Prettier is clean on the changed files

git diff --name-only origin/master...HEAD \
  | grep -E '\.(js|vue|graphql)$' | xargs node_modules/.bin/prettier --check
Output (✅)
Checking formatting...
All matched files use Prettier code style!

Step 5 - gitlab.pot is in sync

tooling/bin/gettext_extractor locale/gitlab.pot >/dev/null && git diff --exit-code --stat locale/gitlab.pot && echo "gitlab.pot in sync"
Output (✅)
gitlab.pot in sync

Step 6 - RuboCop is clean on the fixture

bundle exec rubocop --no-server ee/spec/frontend/fixtures/artifact_registry/repository_upstream_repositories.rb
Output (✅)
1 file inspected, no offenses detected

Step 7 - Against a real Artifact Registry, an upstream the viewer cannot read arrives without its fields

A local Artifact Registry runs with authorization.unenforced: true, so every upstream is readable there. This step uses the caproni rig with the Artifact Registry and IAM Data Access fragments instead, so every read is decided by a real role grant in IAM Data Access, evaluated by the GLAZ sidecar as on GitLab.com.

Extend both fragments (IAM last) in caproni.local.yaml, enable monolith edit mode, put repos/gitlab on this branch, then caproni up and caproni run:

extends:
  - fragments/caproni.artifact-registry.yaml
  - fragments/caproni.iam-data-access.yaml
repositories:
  gitlab:
    edit_mode: true
reloaders:
  gitlab:
    edit_mode: true

Artifact Registry must be v2.125.0 or later. v2.119.0 is the first release with the widened upstream summary (gitlab-org/ops/artifact-registry!2959 (merged)), and v2.125.0 is the first that accepts upstream_repository_ids on an update, which Step 9's save needs (an older one refuses it with unknown field "upstream_repository_ids"). The recordings ran on v2.129.0. Enable org_stage_experimental, org_stage_ga, and artifact_registry_ui in the edit-mode database. The virtual npm repository npm-virtual-demo has two upstreams, the remote npmjs-proxy and the hosted team-internal-npm.

Then create non-admin organization members and grant each a role on the namespace or on single repositories, as root:

mutation($id: UserID!, $r: String!, $role: ArtifactRegistryRole!) {
  artifactRegistryRoleGrant(input: { assigneeId: $id, resourceId: $r, role: $role }) { errors }
}
User Namespace default Repository grants Cannot read Used in
ar-e2e-viewer Artifact Viewer none nothing Steps 7 and 8
ar-e2e-reads-hosted none Artifact Viewer on npm-virtual-demo and team-internal-npm npmjs-proxy Step 7
ar-e2e-reads-remote none Artifact Viewer on npm-virtual-demo and npmjs-proxy team-internal-npm Step 7
ar-e2e-admin Artifact Admin none nothing Steps 8 and 9
ar-e2e-edits-virtual none Artifact Manager on npm-virtual-demo, Artifact Viewer on team-internal-npm npmjs-proxy Step 9
Output (✅)
ar-e2e-reads-hosted: id=8 admin=false org_member=true
  Artifact Viewer on npm-virtual-demo: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
  Artifact Viewer on team-internal-npm: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
ar-e2e-reads-remote: id=9 admin=false org_member=true
  Artifact Viewer on npm-virtual-demo: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
  Artifact Viewer on npmjs-proxy: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
ar-e2e-edits-virtual: id=10 admin=false org_member=true
  ARTIFACT_MANAGER on npm-virtual-demo: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}
  ARTIFACT_VIEWER on team-internal-npm: {"data":{"artifactRegistryRoleGrant":{"errors":[]}}}

Read the upstream list as each user, first through the client to see which keys Artifact Registry sends, then through this branch's query document. Run it with .gitlab/caproni/exec.sh rails runner from repos/gitlab:

org = Organizations::Organization.find_by!(path: 'default')
query = File.read('ee/app/assets/javascripts/packages_and_registries/artifact_registry/graphql/queries/get_repository_upstream_repositories.query.graphql')

%w[ar-e2e-viewer ar-e2e-reads-hosted ar-e2e-reads-remote ar-e2e-edits-virtual].each do |username|
  user = User.find_by!(username: username)
  puts "== #{username}"

  client = ArtifactRegistry::Client.new(current_user: user, organization: org)
  client.upstream_repositories(slug: 'default', repository_name: 'npm-virtual-demo', format: 'npm',
    include_permissions: true).each do |association|
    upstream = association.upstream_repository
    keys = upstream.instance_variable_get(:@attributes).keys.sort
    puts "  AR #{upstream.name} (#{upstream.kind}) keys: #{keys.join(', ')}"
  end

  result = GitlabSchema.execute(query, context: { current_user: user, current_organization: org },
    variables: { organizationId: org.to_global_id.to_s, name: 'npm-virtual-demo' }).to_h
  puts "  GraphQL errors: #{result['errors'].inspect}"
  result.dig('data', 'organization', 'artifactRegistryRepository', 'upstreamRepositories').each do |association|
    summary = association['upstreamRepository']
    puts "  GraphQL #{summary['name']} (#{summary['kind']}): " \
      "#{summary.except('__typename', 'id', 'name', 'kind', 'userPermissions').to_json}"
  end
end
Output (✅)

Artifact Registry still sends visibility to a viewer who can read the upstream. The query no longer selects it.

== ar-e2e-viewer
  AR npmjs-proxy (remote) keys: format, id, kind, name, permissions, settings, size_bytes, visibility
  AR team-internal-npm (hosted) keys: format, id, kind, name, permissions, size_bytes, visibility
  GraphQL errors: nil
  GraphQL npmjs-proxy (REMOTE): {"sizeBytes":"0","settings":{"url":"https://registry.npmjs.org","lastHealthStatus":"HEALTHY"}}
  GraphQL team-internal-npm (HOSTED): {"sizeBytes":"0","settings":null}
== ar-e2e-reads-hosted
  AR npmjs-proxy (remote) keys: format, id, kind, name, permissions
  AR team-internal-npm (hosted) keys: format, id, kind, name, permissions, size_bytes, visibility
  GraphQL errors: nil
  GraphQL npmjs-proxy (REMOTE): {"sizeBytes":null,"settings":null}
  GraphQL team-internal-npm (HOSTED): {"sizeBytes":"0","settings":null}
== ar-e2e-reads-remote
  AR npmjs-proxy (remote) keys: format, id, kind, name, permissions, settings, size_bytes, visibility
  AR team-internal-npm (hosted) keys: format, id, kind, name, permissions
  GraphQL errors: nil
  GraphQL npmjs-proxy (REMOTE): {"sizeBytes":"0","settings":{"url":"https://registry.npmjs.org","lastHealthStatus":"HEALTHY"}}
  GraphQL team-internal-npm (HOSTED): {"sizeBytes":null,"settings":null}
== ar-e2e-edits-virtual
  AR npmjs-proxy (remote) keys: format, id, kind, name, permissions
  AR team-internal-npm (hosted) keys: format, id, kind, name, permissions, size_bytes, visibility
  GraphQL errors: nil
  GraphQL npmjs-proxy (REMOTE): {"sizeBytes":null,"settings":null}
  GraphQL team-internal-npm (HOSTED): {"sizeBytes":"0","settings":null}

Sign in as ar-e2e-viewer, ar-e2e-reads-hosted, and ar-e2e-reads-remote in turn and open /o/default/-/artifact_registry/default/repositories/npm-virtual-demo.

Output (✅)

Read from the rendered page (visible nodes only), next to the upstream list the page's own GraphQL request returned:

ar-e2e-viewer
  headers: Order | Repository | Type | Size
  1  npmjs-proxy        https://registry.npmjs.org  [status-success] "Connection status for npmjs-proxy: Healthy"  Remote  0 B
  2  team-internal-npm  (no second line)                                                                         Hosted  0 B

ar-e2e-reads-hosted
  headers: Order | Repository | Type | Size
  1  npmjs-proxy        (no second line)  Remote  (blank)
  2  team-internal-npm  (no second line)  Hosted  0 B
  GraphQL npmjs-proxy: sizeBytes null, settings null

ar-e2e-reads-remote
  headers: Order | Repository | Type | Size
  1  npmjs-proxy        https://registry.npmjs.org  [status-success] "Connection status for npmjs-proxy: Healthy"  Remote  0 B
  2  team-internal-npm  (no second line)                                                                         Hosted  (blank)
  GraphQL team-internal-npm: sizeBytes null, settings null

every run: no icon in any Repository cell except the status icon, tbody [role="status"] count 0,
links in the Repository cells 0

No user here holds update_repository, delete_artifact, or delete_repository_upstream, so the Actions column is dropped on every run.

Recordings: Real role grants.

Step 8 - The Unhealthy and Unknown health values, and a size above zero

A remote upstream's stored verdict is whatever its connection tests left, and npmjs-proxy only reaches Healthy and 0 B. On the same rig, as ar-e2e-admin, create two more npm remotes and a second virtual repository through GraphQL:

  • npm-health-unreachable, URL https://example.com:81/npm/. Run the connection test on it three times. The port does not answer, and Artifact Registry records Unhealthy after its default unhealthy_threshold of three consecutive failures. A URL that answers with any HTTP status, even 404, counts as reachable, so it needs a port that does not answer.
  • npm-health-untested, URL https://registry.npmmirror.com/. It is never tested, so it stays Unknown.
  • npm-virtual-health, a virtual npm repository whose upstreams are, in order, npmjs-proxy, npm-health-unreachable, npm-health-untested, remote-npm (an existing remote holding 2.91 MiB), and team-internal-npm.
mutation($input: ArtifactRegistryRepositoryCreateInput!) {
  artifactRegistryRepositoryCreate(input: $input) { repository { id name kind } errors }
}

mutation($input: ArtifactRegistryRepositoryTestConnectionInput!) {
  artifactRegistryRepositoryTestConnection(input: $input) { passed httpStatus lastHealthStatus lastHealthCheckedAt errors }
}
Output (✅)

The three connection tests on npm-health-unreachable, then each upstream's stored verdict:

{"passed":false,"httpStatus":null,"lastHealthStatus":"UNKNOWN","lastHealthCheckedAt":"2026-10-01T04:09:04+00:00","errors":[]}
{"passed":false,"httpStatus":null,"lastHealthStatus":"UNKNOWN","lastHealthCheckedAt":"2026-10-01T04:09:29+00:00","errors":[]}
{"passed":false,"httpStatus":null,"lastHealthStatus":"UNHEALTHY","lastHealthCheckedAt":"2026-10-01T04:09:34+00:00","errors":[]}

npmjs-proxy             REMOTE  sizeBytes 0        https://registry.npmjs.org     HEALTHY
npm-health-unreachable  REMOTE  sizeBytes 0        https://example.com:81/npm     UNHEALTHY
npm-health-untested     REMOTE  sizeBytes 0        https://registry.npmmirror.com UNKNOWN (never checked)
remote-npm              REMOTE  sizeBytes 3048126  https://registry.npmjs.org     HEALTHY
team-internal-npm       HOSTED  sizeBytes 0        (no settings)

Then sign in as ar-e2e-viewer and open /o/default/-/artifact_registry/default/repositories/npm-virtual-health.

Output (✅)
headers: Order | Repository | Type | Size
1  npmjs-proxy             https://registry.npmjs.org      [status-success   success] "Connection status for npmjs-proxy: Healthy"               Remote  0 B
2  npm-health-unreachable  https://example.com:81/npm      [status-failed    danger]  "Connection status for npm-health-unreachable: Unhealthy"  Remote  0 B
3  npm-health-untested     https://registry.npmmirror.com  [severity-unknown subtle]  "Connection status for npm-health-untested: Unknown"      Remote  0 B
4  remote-npm              https://registry.npmjs.org      [status-success   success] "Connection status for remote-npm: Healthy"                Remote  2.91 MiB
5  team-internal-npm       (no second line)                                                                                                  Hosted  0 B
each status icon is a <button> whose tooltip shows the verdict, and has no visible text
tbody [role="status"] count: 0

Recording: Health values.

Step 9 - The Edit page renders each stored upstream's URL and size, and still saves by id

As ar-e2e-admin, open /o/default/-/artifact_registry/default/repositories/npm-virtual-demo/edit. Both stored upstreams show their size, and the remote shows its URL. Add remote-npm through Add repository, which renders the same way, then remove it again without saving. Move team-internal-npm up and select Save changes: the detail page shows the new order. Edit again, move it back, and save.

Then, as ar-e2e-edits-virtual, open the same page and select Save changes without changing anything.

Output (✅)

Read from the order table (visible nodes only), with the updateArtifactRegistryRepository input each save sent:

ar-e2e-admin, on load
  npmjs-proxy        https://registry.npmjs.org  Remote  0 B
  team-internal-npm  (no URL)                    Hosted  0 B
after adding remote-npm through the picker
  remote-npm         https://registry.npmjs.org  Remote  2.91 MiB
after removing it, moving team-internal-npm up, and saving
  input: {"name":"npm-virtual-demo","description":"","upstreamRepositoryIds":["01a0ebda-e8fb-7b65-ad69-a4dd6d30b97b","01a0ebda-e888-7571-90e6-d9435e16a1c8"]}
  detail page: 1 team-internal-npm, 2 npmjs-proxy
after moving it back and saving
  input: {"name":"npm-virtual-demo","description":"","upstreamRepositoryIds":["01a0ebda-e888-7571-90e6-d9435e16a1c8","01a0ebda-e8fb-7b65-ad69-a4dd6d30b97b"]}
  detail page: 1 npmjs-proxy, 2 team-internal-npm

ar-e2e-edits-virtual, on load
  npmjs-proxy        (no URL)  Remote  (blank)
  team-internal-npm  (no URL)  Hosted  0 B
saving the unchanged list
  input: {"name":"npm-virtual-demo","description":""}
  detail page: 1 npmjs-proxy, 2 team-internal-npm

The upstream ids are the only upstream data a save sends, and an unchanged list sends none.

Recordings: Edit page.

Evidence

Change type: UI only (the upstream query document, two Vue components, and constants). Every recording ran on the caproni rig with Artifact Registry v2.129.0, the GLAZ v10.1.0 sidecar, and IAM Data Access, with the monolith in edit mode at this branch's head, 316e843e5d818. Nothing is stubbed. Every recording is as a non-admin organization member.

Rails console

The Step 7 runner script is the console evidence: it shows the keys Artifact Registry sends per user, and what the query resolves. No client change. The client fields are !258049 (merged).

GraphiQL

N/A. No GraphQL schema change. !258049 (merged) carries the GraphiQL recordings for these fields.

Screen recordings

Real role grants: ar-e2e-viewer, Artifact Viewer on the namespace. Both upstreams are readable. The remote npmjs-proxy row shows https://registry.npmjs.org as plain text, then the Healthy status icon (hovered, so its tooltip shows), and 0 B. The hosted team-internal-npm row shows no second line and 0 B. No row shows a visibility icon.

Real role grants: ar-e2e-reads-hosted, Artifact Viewer on npm-virtual-demo and team-internal-npm only. Artifact Registry sends no size_bytes or settings for npmjs-proxy, so its row keeps its position, name, and Remote badge, and renders no URL, no status icon, and a blank Size cell. The hosted row still shows 0 B.

Real role grants: ar-e2e-reads-remote, Artifact Viewer on npm-virtual-demo and npmjs-proxy only. The reverse: the remote row renders in full, and the hosted team-internal-npm row renders a blank Size cell.

Health values: ar-e2e-viewer on npm-virtual-health (Step 8). Healthy, Unhealthy, and Unknown each render their own icon, each hovered in turn so its tooltip shows, and remote-npm renders 2.91 MiB.

Edit page: ar-e2e-admin, Artifact Admin on the namespace (Step 9). The stored upstreams show their URL and size, remote-npm added through the picker renders the same way and is removed again, and a reorder saves and shows on the detail page before the original order is restored.

Edit page: ar-e2e-edits-virtual, Artifact Manager on npm-virtual-demo and Artifact Viewer on team-internal-npm only (Step 9). npmjs-proxy keeps its place with no URL and a blank Size, and saving the unchanged list returns to the detail page.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

🤖 Generated with Claude Code

Edited by Fiona McCawley

Merge request reports

Loading
Loading