Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9585acd
feat(server): console endpoints for the operator redesign
roncodes Oct 23, 2026
2b006ea
feat(console): operator redesign on ember-ui (Workstream N)
roncodes Oct 23, 2026
fa25b76
feat(server)!: Registry Service only; checkout per the Phase 4 contract
roncodes Oct 23, 2026
6378f3e
feat(console)!: Registry Service only; every hosting with its reason;…
roncodes Oct 23, 2026
c8e8516
fix(console): name the configured registry in Explore errors, never a…
roncodes Oct 23, 2026
1673c7a
fix(console): keep the late-loading Developers panel inside an elemen…
roncodes Oct 23, 2026
b1efbec
fix(console): keep sidebar category counts when Explore results embed…
roncodes Oct 23, 2026
2c9797d
Merge remote-tracking branch 'origin/feature/cli-job-register-command…
roncodes Oct 23, 2026
b18e8e2
Merge remote-tracking branch 'origin/feature/cli-job-register-command…
roncodes Oct 23, 2026
c216ce6
fix(console): default icon in the detail panel when a listing has no …
roncodes Oct 23, 2026
df5185a
Merge remote-tracking branch 'origin/feature/cli-job-register-command…
roncodes Oct 23, 2026
991ca5a
fix(console): licences belong to the registry organization; Purchased…
roncodes Oct 23, 2026
7559584
Merge remote-tracking branch 'origin/feature/cli-job-register-command…
roncodes Oct 23, 2026
137d302
Merge branch 'feature/cli-job-register-commands-827e43' into feat/ope…
roncodes Oct 23, 2026
24f52df
fix(console): promise about 2 minutes only for installs without a con…
roncodes Oct 23, 2026
f421c23
Merge remote-tracking branch 'origin/feature/cli-job-register-command…
roncodes Oct 23, 2026
577e200
Merge feature/cli-job-register-commands-827e43 into feat/operator-con…
roncodes Oct 24, 2026
7ccd55e
fix(console): Register again on the admin page rotates the same instance
roncodes Oct 24, 2026
cf166ad
fix(console): Purchased says updates are blocked; sidebar counts refr…
roncodes Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,27 @@ pnpm install @fleetbase/registry-bridge-engine

## Registry Service

With a Registry Service URL configured, Explore, Installed, installs and update checks go through the
[Fleetbase Registry Service](https://github.com/fleetbase/registry) (API `v2`). Without one, Explore falls back to
the cloud catalog at `REGISTRY_HOST` (`~registry/v1/extensions`), read-only.
Explore, Installed, Purchased, installs, updates and purchases all go through the
[Fleetbase Registry Service](https://github.com/fleetbase/registry). Publishing, review and payouts live on the
service's publisher website; the console links to it as "Publisher portal".

| Setting (env) | Purpose |
| --- | --- |
| `REGISTRY_SERVICE_URL` | Service base URL, for example `https://registry.fleetbase.io`. Also editable on the admin Registry Configuration page |
| `REGISTRY_API_VERSION` | `v2` (default when a service URL is set) or `v1` to keep the cloud catalog fallback |
| `REGISTRY_SERVICE_URL` | Service base URL, default `https://registry.fleetbase.io`. Also editable on the admin Registry Configuration page |
| `REGISTRY_INSTANCE_KIND` | `self_hosted` (default) or `cloud`; reported at registration and matched against each extension's hosting |
| `REGISTRY_INSTANCE_TOKEN` | Instance token, normally issued by registering from the admin page and stored encrypted in settings instead |
| `REGISTRY_PUBLISHER_URL` | Publisher portal the console links to; defaults to the service URL |
| `REGISTRY_SERVICE_CHECKOUT` | `auto` (default: on for a registered instance unless the service has no checkout route), `true` or `false` |
| `REGISTRY_SETTINGS_CACHE_TTL` | Seconds the stored registry settings stay cached between requests (default `30`, `0` reads the table every request); a change forgets the cache at once |

Explore lists every published extension. Those this instance cannot install (another hosting kind, no release) show
why on the card and in the detail panel; without a registration, a banner says installs and purchases need one.

Paid extensions are bought through the service's hosted Stripe Checkout ("Instance checkout" in the service README):
`POST ~registry/v1/purchases/checkout` starts it and the console redirects; Stripe returns with `handoff` (and
`cancelled=1` on cancel) and the console polls `GET ~registry/v1/purchases/{handoff}` until the webhook has created
the licence. The receipt of a completed hand-off is kept for Purchased until the service sends it on the licence.

Register the instance on the admin Registry Configuration page with a one-time code from an owner or admin of your
registry organization (`POST /v1/account/instance-codes` on the service). The instance token is stored encrypted with
the app key and only ever shown masked.
Expand Down Expand Up @@ -160,7 +169,7 @@ pnpm build

## Extension installer

Installs, uninstalls and updates run as `InstallExtensionJob` on the `installer` queue, one at a time per instance (a cache lock owned by the running job). The console's `POST installer/install` and `installer/uninstall` return `202 { status: "queued", job }`; poll `GET installer/jobs/{id}` or listen on `install.{company}.{extension}` (`uninstall.…` for uninstalls) for `step` and `progress`. The console's install and uninstall modals do both (`addon/utils/installer-job.js`): they show the queue position and each step, count down to a reload on success, and on failure show the step, message and log tail with Retry.
Installs, uninstalls and updates run as `InstallExtensionJob` on the `installer` queue, one at a time per instance (a cache lock owned by the running job). The console's `POST installer/install`, `installer/update` and `installer/uninstall` return `202 { status: "queued", job }`; poll `GET installer/jobs/{id}` or listen on `install.{company}.{extension}` (`uninstall.…` for uninstalls) for `step` and `progress`. The console's install and uninstall modals do both (`addon/utils/installer-job.js`): they show the queue position and each step, count down to a reload on success, and on failure show the step, message and log tail with Retry.

```bash
php artisan registry:install pallet --release=0.0.2 # or pallet@0.0.2; artisan reserves --version
Expand All @@ -173,7 +182,7 @@ php artisan queue:work --queue=installer --timeout=7200 # the worker that runs

When a release carries a prebuilt engine dist (D3 Outcome A), the engine step checks it against the console's `dist/host-modules.json` with `@fleetbase/ember-core`'s compatibility check, unpacks it into the runtime engines directory (default `<console>/dist/engines-dist`) and adds it to the manifest served at `GET ~registry/v1/engines/runtime`, which the console reads at boot. Without a prebuilt dist it falls back to `pnpm add` and `pnpm build`. Settings live under `registry-bridge.installer` in `server/config/registry-bridge.php`.

Where releases come from is a `ReleaseSource`: with a Registry Service configured the service catalog is asked first and this instance's registry tables second (`SourceRoutingReleaseSource`). The job record's `extension.source` (`registry-service` or `local`) decides where the install is recorded and whether the service hears about it (`InstallReporter`); credentials reach Composer and pnpm through `PackageCredentials`, as process environment only.
Releases come from the Registry Service (`ServiceReleaseSource`); installs are recorded in `registry_service_installs` and reported to the service (`InstallReporter`); credentials reach Composer and pnpm through `PackageCredentials`, as process environment only.

## Contributing
See the Contributing Guide for details on how to contribute to this project.
Expand Down
1 change: 0 additions & 1 deletion addon/adapters/registry-extension-bundle.js

This file was deleted.

1 change: 1 addition & 0 deletions addon/components/cell/extension-action.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<ExtensionCard::Action @resource={{@row}} @size="xs" @onAction={{@column.onAction}} @onOpen={{@column.onOpen}} />
3 changes: 3 additions & 0 deletions addon/components/cell/extension-action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import templateOnly from '@ember/component/template-only';

export default templateOnly();
8 changes: 8 additions & 0 deletions addon/components/cell/extension-identity.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Table::Cell::Identity
@row={{@row}}
@column={{this.columnWith (config "defaultValues.extensionIcon")}}
@value={{@row}}
@onClick={{this.open}}
data-test-extension-identity
...attributes
/>
70 changes: 70 additions & 0 deletions addon/components/cell/extension-identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { unavailableReason } from '../../utils/extension-state';

/**
* An extension in a table cell: icon, name and badges (version, update,
* unavailable here, core) on `Table::Cell::Identity`. Clicking opens the detail
* panel unless the column has its own `onClick`.
*/
export default class CellExtensionIdentityComponent extends Component {
@service extensionPanel;
@service intl;

get extension() {
return this.args.row ?? {};
}

/**
* The identity column, with the extension icon fallback from the console
* config (passed in by the template's `config` helper).
*/
columnWith = (fallbackImage) => {
return {
mediaPath: 'icon_url',
labelPath: 'name',
fallbackImage,
showStatusDot: false,
popover: false,
badgeLimit: 3,
badges: () => this.badges,
...(this.args.column ?? {}),
};
};

get badges() {
const extension = this.extension;
const version = extension.installed_version || extension.current_bundle_version || extension.version;
const badges = [];
if (version) {
badges.push({ key: 'version', label: `v${version}` });
}
if (extension.update_available === true && extension.latest_version) {
badges.push({
key: 'update',
label: this.intl.t('registry-bridge.card.update', { version: extension.latest_version }),
class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
});
}
if (unavailableReason(extension)) {
badges.push({ key: 'unavailable', label: this.intl.t('registry-bridge.card.unavailable'), class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' });
}
if (extension.core_extension === true) {
badges.push({ key: 'core', label: this.intl.t('registry-bridge.installed.status.core') });
}

return badges;
}

@action open(extension, event) {
if (typeof this.args.column?.onClick === 'function') {
return;
}
if (typeof this.args.onClick === 'function') {
return this.args.onClick(extension, event);
}

return this.extensionPanel.open(extension ?? this.extension);
}
}
5 changes: 5 additions & 0 deletions addon/components/cell/extension-status.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="flex flex-row flex-wrap items-center gap-1" data-test-extension-status ...attributes>
{{#each this.badges as |badge|}}
<Badge @status={{badge.status}} @size="xs" @hideStatusDot={{true}} @disableHumanize={{true}} @helpText={{badge.help}}>{{badge.text}}</Badge>
{{/each}}
</div>
49 changes: 49 additions & 0 deletions addon/components/cell/extension-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import formatCurrency from '@fleetbase/ember-ui/utils/format-currency';
import { cardModel, unavailableReason } from '../../utils/extension-state';

/**
* Status badges for an extension row. `@column.variant`:
*
* - `catalog` (default): price and the card's state badges, as on Explore
* - `installed`: Up to date, Update available or Core service
* - `purchased`: Installed vX, Ready to install, or Unavailable here with the reason
*/
export default class CellExtensionStatusComponent extends Component {
@service intl;

get extension() {
return this.args.row ?? {};
}

get badges() {
const extension = this.extension;
const t = (key, params) => this.intl.t(`registry-bridge.${key}`, params);

switch (this.args.column?.variant) {
case 'installed':
if (extension.core_extension === true) {
return [{ text: t('installed.status.core'), status: 'default' }];
}
return extension.update_available === true
? [{ text: t('installed.status.update-available'), status: 'info' }]
: [{ text: t('installed.status.up-to-date'), status: 'success' }];
case 'purchased':
if (extension.is_installed === true) {
return [{ text: t('purchased.status.installed', { version: extension.installed_version || extension.current_bundle_version || '' }).trim(), status: 'success' }];
}
if (unavailableReason(extension)) {
return [{ text: t('card.unavailable'), status: 'warning', help: unavailableReason(extension) }];
}
return [{ text: t('card.ready-to-install'), status: 'info' }];
default: {
const model = cardModel(extension);
const price = model.priceBadge.label
? { text: this.intl.t(model.priceBadge.label.key, model.priceBadge.label.params), status: model.priceBadge.status }
: { text: formatCurrency(model.priceBadge.amount, model.priceBadge.currency), status: model.priceBadge.status };
return [price, ...model.badges.map((badge) => ({ text: this.intl.t(badge.label.key, badge.label.params), status: badge.status, help: badge.help ?? null }))];
}
}
}
}
5 changes: 5 additions & 0 deletions addon/components/cell/licence-activations.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{#if @row.meta.licence.max_activations}}
<span class="text-sm" data-test-licence-activations>{{t "registry-bridge.purchased.activations" used=(or @row.meta.licence.seats_used 0) max=@row.meta.licence.max_activations}}</span>
{{else}}
<span class="text-xs text-gray-500 dark:text-gray-400">-</span>
{{/if}}
3 changes: 3 additions & 0 deletions addon/components/cell/licence-activations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import templateOnly from '@ember/component/template-only';

export default templateOnly();
5 changes: 5 additions & 0 deletions addon/components/cell/licence-receipt.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{#if @row.meta.licence.receipt_url}}
<a href={{@row.meta.licence.receipt_url}} target="_blank" rel="noopener noreferrer" class="text-sm" data-test-licence-receipt>{{t "registry-bridge.purchased.receipt"}}</a>
{{else}}
<span class="text-xs text-gray-500 dark:text-gray-400">-</span>
{{/if}}
3 changes: 3 additions & 0 deletions addon/components/cell/licence-receipt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import templateOnly from '@ember/component/template-only';

export default templateOnly();
5 changes: 5 additions & 0 deletions addon/components/cell/licence-state.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{#if this.badge}}
<Badge @status={{this.badge.status}} @size="xs" @hideStatusDot={{true}} @disableHumanize={{true}} @helpText={{this.badge.help}} data-test-licence-state={{this.state}} data-test-licence-reason={{if this.badge.help this.reason}}>{{this.badge.text}}</Badge>
{{else}}
<span class="text-xs text-gray-500 dark:text-gray-400">-</span>
{{/if}}
43 changes: 43 additions & 0 deletions addon/components/cell/licence-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

/** `revoked_reason` values the Registry Service sets; anything else reads as a plain revoke. */
const REASONS = ['refunded', 'disputed', 'staff'];

/**
* Licence column on Purchased: Active, Refunded or Revoked (`meta.licence_state`), with the service's
* `revoked_reason` as help text. A refunded or revoked licence stays listed for its receipt but no longer
* entitles installs or updates.
*/
export default class CellLicenceStateComponent extends Component {
@service intl;

get state() {
return this.args.row?.meta?.licence_state ?? null;
}

/** The service's `revoked_reason` when this bridge knows it, else `other`. */
get reason() {
const reason = this.args.row?.meta?.licence?.revoked_reason;
return REASONS.includes(reason) ? reason : 'other';
}

get badge() {
const t = (key) => this.intl.t(`registry-bridge.purchased.licence.${key}`);
const help = t(`reason.${this.reason}`);

switch (this.state) {
case 'active':
return { text: t('active'), status: 'success', help: null };
case 'refunded':
return { text: t('refunded'), status: 'warning', help };
case 'revoked':
return { text: t('revoked'), status: 'error', help };
case null:
case undefined:
return null;
default:
return { text: t('inactive'), status: 'default', help: null };
}
}
}
5 changes: 5 additions & 0 deletions addon/components/cell/purchase-price.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{#if this.amount}}
<span class="text-sm {{if this.revoked 'line-through text-gray-500 dark:text-gray-400'}}" data-test-purchase-price>{{format-currency this.amount this.currency}}</span>
{{else}}
<span class="text-xs text-gray-500 dark:text-gray-400" data-test-purchase-price>-</span>
{{/if}}
36 changes: 36 additions & 0 deletions addon/components/cell/purchase-price.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Component from '@glimmer/component';
import { priceOf } from '../../utils/extension-state';

/**
* Price column on Purchased: what the organization paid (the receipt's total, tax included) when the
* service or this instance recorded it, else the listing price. Struck through once the licence is
* refunded or revoked.
*/
export default class CellPurchasePriceComponent extends Component {
get extension() {
return this.args.row ?? {};
}

get purchase() {
const purchase = this.extension.meta?.licence?.purchase;
return purchase && typeof purchase === 'object' ? purchase : {};
}

get amount() {
const paid = Number(this.purchase.amount_total);
if (Number.isFinite(paid) && paid > 0) {
return paid;
}
const listed = priceOf(this.extension);
return listed.free || !(listed.amount > 0) ? null : listed.amount;
}

get currency() {
return (Number(this.purchase.amount_total) > 0 && this.purchase.currency) || this.extension.currency || 'USD';
}

get revoked() {
const state = this.extension.meta?.licence_state;
return state === 'refunded' || state === 'revoked';
}
}
8 changes: 8 additions & 0 deletions addon/components/cell/release-action.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Button
@size="xs"
@text={{@row.actionText}}
@disabled={{@row.actionDisabled}}
@permission="registry-bridge install extension"
@onClick={{fn @column.onInstall @row}}
data-test-release-action={{@row.version}}
/>
3 changes: 3 additions & 0 deletions addon/components/cell/release-action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import templateOnly from '@ember/component/template-only';

export default templateOnly();
6 changes: 6 additions & 0 deletions addon/components/cell/release-version.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="flex flex-row flex-wrap items-center gap-1" data-test-release-version={{@row.version}}>
<span class="font-mono text-xs">{{@row.version}}</span>
{{#if @row.is_latest}}<Badge @status="info" @size="xs" @hideStatusDot={{true}} @disableHumanize={{true}}>{{t "registry-bridge.detail.versions.latest"}}</Badge>{{/if}}
{{#if @row.is_installed}}<Badge @status="success" @size="xs" @hideStatusDot={{true}} @disableHumanize={{true}}>{{t "registry-bridge.card.installed"}}</Badge>{{/if}}
{{#if @row.is_yanked}}<Badge @status="error" @size="xs" @hideStatusDot={{true}} @disableHumanize={{true}}>{{t "registry-bridge.detail.versions.yanked"}}</Badge>{{/if}}
</div>
3 changes: 3 additions & 0 deletions addon/components/cell/release-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import templateOnly from '@ember/component/template-only';

export default templateOnly();
Loading