diff --git a/README.md b/README.md index 48f7f31..f0c1c2f 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 `/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. diff --git a/addon/adapters/registry-extension-bundle.js b/addon/adapters/registry-extension-bundle.js deleted file mode 100644 index ffb8d66..0000000 --- a/addon/adapters/registry-extension-bundle.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './registry-bridge'; diff --git a/addon/components/cell/extension-action.hbs b/addon/components/cell/extension-action.hbs new file mode 100644 index 0000000..393b8c8 --- /dev/null +++ b/addon/components/cell/extension-action.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/extension-action.js b/addon/components/cell/extension-action.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/cell/extension-action.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/cell/extension-identity.hbs b/addon/components/cell/extension-identity.hbs new file mode 100644 index 0000000..f64e225 --- /dev/null +++ b/addon/components/cell/extension-identity.hbs @@ -0,0 +1,8 @@ + diff --git a/addon/components/cell/extension-identity.js b/addon/components/cell/extension-identity.js new file mode 100644 index 0000000..cdc8b06 --- /dev/null +++ b/addon/components/cell/extension-identity.js @@ -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); + } +} diff --git a/addon/components/cell/extension-status.hbs b/addon/components/cell/extension-status.hbs new file mode 100644 index 0000000..9387ab9 --- /dev/null +++ b/addon/components/cell/extension-status.hbs @@ -0,0 +1,5 @@ +
+ {{#each this.badges as |badge|}} + {{badge.text}} + {{/each}} +
diff --git a/addon/components/cell/extension-status.js b/addon/components/cell/extension-status.js new file mode 100644 index 0000000..0cfd808 --- /dev/null +++ b/addon/components/cell/extension-status.js @@ -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 }))]; + } + } + } +} diff --git a/addon/components/cell/licence-activations.hbs b/addon/components/cell/licence-activations.hbs new file mode 100644 index 0000000..7281a58 --- /dev/null +++ b/addon/components/cell/licence-activations.hbs @@ -0,0 +1,5 @@ +{{#if @row.meta.licence.max_activations}} + {{t "registry-bridge.purchased.activations" used=(or @row.meta.licence.seats_used 0) max=@row.meta.licence.max_activations}} +{{else}} + - +{{/if}} diff --git a/addon/components/cell/licence-activations.js b/addon/components/cell/licence-activations.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/cell/licence-activations.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/cell/licence-receipt.hbs b/addon/components/cell/licence-receipt.hbs new file mode 100644 index 0000000..ddb28df --- /dev/null +++ b/addon/components/cell/licence-receipt.hbs @@ -0,0 +1,5 @@ +{{#if @row.meta.licence.receipt_url}} + {{t "registry-bridge.purchased.receipt"}} +{{else}} + - +{{/if}} diff --git a/addon/components/cell/licence-receipt.js b/addon/components/cell/licence-receipt.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/cell/licence-receipt.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/cell/licence-state.hbs b/addon/components/cell/licence-state.hbs new file mode 100644 index 0000000..e59bd02 --- /dev/null +++ b/addon/components/cell/licence-state.hbs @@ -0,0 +1,5 @@ +{{#if this.badge}} + {{this.badge.text}} +{{else}} + - +{{/if}} diff --git a/addon/components/cell/licence-state.js b/addon/components/cell/licence-state.js new file mode 100644 index 0000000..1b480d3 --- /dev/null +++ b/addon/components/cell/licence-state.js @@ -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 }; + } + } +} diff --git a/addon/components/cell/purchase-price.hbs b/addon/components/cell/purchase-price.hbs new file mode 100644 index 0000000..6dfe8d7 --- /dev/null +++ b/addon/components/cell/purchase-price.hbs @@ -0,0 +1,5 @@ +{{#if this.amount}} + {{format-currency this.amount this.currency}} +{{else}} + - +{{/if}} diff --git a/addon/components/cell/purchase-price.js b/addon/components/cell/purchase-price.js new file mode 100644 index 0000000..1cc9338 --- /dev/null +++ b/addon/components/cell/purchase-price.js @@ -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'; + } +} diff --git a/addon/components/cell/release-action.hbs b/addon/components/cell/release-action.hbs new file mode 100644 index 0000000..042e3b6 --- /dev/null +++ b/addon/components/cell/release-action.hbs @@ -0,0 +1,8 @@ +