diff --git a/README.md b/README.md
index 600ee8f74a..19c08deb46 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,20 @@ PastureStack is an independent community effort to preserve, audit, and moderniz
## Project status
+Release `1.6.162` is being prepared for two narrow desktop fixes. The Secrets
+table headers use the existing generic translations while retaining their
+sorting, search and QA mapping fields. Host details show Add Container only
+when the current environment's loaded container schema permits creation;
+schema reloads and environment changes cannot reuse stale create access.
+Container-list and Host-card capability checks and the direct Add route remain
+unchanged. The permission gate uses schema capabilities, not role-name checks.
+Focused source validation passed Secrets 2/2 and Host 8/8 tests (four new Host
+cases plus four adjacent existing cases). Official CI, publication and packaged
+browser acceptance are still pending. Responsive/mobile Secrets labels and
+all-language layout acceptance are not claimed. Earlier HOLD evidence remains
+HOLD and the broader resource/role matrix remains INCOMPLETE. See the
+[preparation note](docs/releases/web-console-1.6.162.md).
+
Published release `1.6.161` fixes existing Certificate metadata edits
blocked by the masked private key. Name/description-only edits omit certificate
material from the PUT body; new certificates and material replacements keep
diff --git a/app/host/controller.js b/app/host/controller.js
index a61071f6cd..24aa351e3c 100644
--- a/app/host/controller.js
+++ b/app/host/controller.js
@@ -1,10 +1,16 @@
import { alias } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
+import { service } from '@ember/service';
export default Controller.extend({
application: controller(),
+ projects: service(),
host: alias('model.host'),
+ canCreateContainer: function() {
+ return this.get('projects').canCreateResource('container');
+ }.property('projects.current.id', 'projects.schemaProjectId', 'projects.schemaLoadGeneration'),
+
actions: {
changeHost(host) {
this.get('router').transitionTo('host', host.get('id'));
diff --git a/app/host/template.hbs b/app/host/template.hbs
index 873745efff..45cd72aa44 100644
--- a/app/host/template.hbs
+++ b/app/host/template.hbs
@@ -7,7 +7,9 @@
{{#action-menu model=this.host size="sm" classNames="r-ml10 pull-right"}}
- {{t 'hostsPage.hostPage.addContainer.linkTo'}}
+ {{#if this.canCreateContainer}}
+ {{t 'hostsPage.hostPage.addContainer.linkTo'}}
+ {{/if}}
{{/action-menu}}
{{header-state model=this.host classNames="pull-right"}}
diff --git a/app/secrets/index/controller.js b/app/secrets/index/controller.js
index ea0357e943..5a77c9143d 100644
--- a/app/secrets/index/controller.js
+++ b/app/secrets/index/controller.js
@@ -49,6 +49,7 @@ export default Controller.extend({
headers: [
{
displayName: 'State',
+ translationKey: 'generic.state',
name: 'stateSort',
sort: ['stateSort','name','id'],
type: 'string',
@@ -58,18 +59,21 @@ export default Controller.extend({
},
{
displayName: 'Name',
+ translationKey: 'generic.name',
name: 'name',
sort: ['name','id'],
type: 'string',
},
{
displayName: 'Description',
+ translationKey: 'generic.description',
name: 'description',
sort: ['description','name','id'],
type: 'string',
},
{
displayName: 'Created',
+ translationKey: 'generic.created',
name: 'created',
sort: ['primaryHost.displayName','name','id'],
searchField: false,
diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json
index 937d12900b..b9ee3ee126 100644
--- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json
+++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json
@@ -1,12 +1,12 @@
{
"name": "@pasturestack/web-console",
- "version": "1.6.161",
+ "version": "1.6.162",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pasturestack/web-console",
- "version": "1.6.161",
+ "version": "1.6.162",
"license": "Apache-2.0",
"dependencies": {
"sass": "1.103.1"
diff --git a/docs/releases/web-console-1.6.162.md b/docs/releases/web-console-1.6.162.md
new file mode 100644
index 0000000000..339660022f
--- /dev/null
+++ b/docs/releases/web-console-1.6.162.md
@@ -0,0 +1,41 @@
+# Web Console 1.6.162
+
+Preparation only. Official CI, publication and packaged browser acceptance are
+pending; this note does not declare an artifact or deployment ready.
+
+## Narrow desktop fixes
+
+The Secrets table's State, Name, Description and Created desktop headers use
+the existing generic translation keys. Their fallback display names, column
+names, sorting, search fields, widths and existing QA mappings are unchanged.
+No new translations or shared table-component changes are introduced.
+
+The Host-detail Add Container link now follows the current environment's
+loaded container-create capability. Missing or stale schema readiness and a
+schema reload that removes POST permission hide the link. Roles whose current
+container schema permits POST retain the same link and selected Host query.
+The existing Container-list, Host-card and direct Add-route capability checks
+are unchanged. This is not a role-name rule or a backend authorization change.
+
+## Source validation and remaining acceptance
+
+Focused native Chrome 153 source validation passed Secrets 2/2 tests and Host
+8/8 tests. The Host filter executed four new cases plus four adjacent existing
+cases, not only the four new cases. Tests cover schema readiness, create
+capability, environment switching, same-environment schema generations and
+preservation of the Secrets table's legacy fields.
+
+Two additional narrow rendering checks use the actual Host template and the
+actual desktop table-header translation chain. Their results are recorded
+separately from the preceding controller tests and from packaged acceptance.
+
+Responsive/mobile Secrets data-title labels still use the shared component's
+legacy displayName mechanism and are outside this fix. No mobile or
+all-language layout acceptance is claimed. The Host template rendering test
+checks link visibility and query binding, not server write authorization.
+
+No API, authentication, session, OIDC, MFA, database, stored-data, runtime-host
+or VM contract changes are included. The earlier HOLD receipts remain HOLD;
+the broader resource/role matrix remains INCOMPLETE. Packaged desktop checks
+on the subsequent Server artifact remain required. No company-site deployment
+is authorized.
diff --git a/package-lock.json b/package-lock.json
index 937d12900b..b9ee3ee126 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@pasturestack/web-console",
- "version": "1.6.161",
+ "version": "1.6.162",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pasturestack/web-console",
- "version": "1.6.161",
+ "version": "1.6.162",
"license": "Apache-2.0",
"dependencies": {
"sass": "1.103.1"
diff --git a/package.json b/package.json
index c711f68631..f000ce2db4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@pasturestack/web-console",
- "version": "1.6.161",
+ "version": "1.6.162",
"private": true,
"description": "PastureStack browser console for the compatible control platform.",
"repository": {
diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers
index 8defb663b2..55b0f7776d 100755
--- a/scripts/check-modernization-blockers
+++ b/scripts/check-modernization-blockers
@@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f:
print(json.load(f).get('version', ''))
PY
)
-if [[ "$version" != "1.6.161" ]]; then
- echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.161"
+if [[ "$version" != "1.6.162" ]]; then
+ echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.162"
failures=$((failures + 1))
fi
diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace
index 40987082d5..ecf01b28f9 100755
--- a/scripts/check-ui-console-workspace
+++ b/scripts/check-ui-console-workspace
@@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI
fi
printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \
- 1.6.161 browser-session broker-broadcast
+ 1.6.162 browser-session broker-broadcast
diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies
index ea03e020a5..9ab62f208c 100755
--- a/scripts/check-ui-critical-high-dependencies
+++ b/scripts/check-ui-critical-high-dependencies
@@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes:
lock = json.loads(lock_bytes)
packages = lock.get("packages", {})
root = packages.get("", {})
-if package.get("version") != "1.6.161":
+if package.get("version") != "1.6.162":
fail(f"unexpected Web Console version: {package.get('version')}")
if root.get("version") != package.get("version"):
fail(f"lock root version differs: {root.get('version')}")
diff --git a/tests/integration/scoped-desktop-ui-test.js b/tests/integration/scoped-desktop-ui-test.js
new file mode 100644
index 0000000000..7ced6b6358
--- /dev/null
+++ b/tests/integration/scoped-desktop-ui-test.js
@@ -0,0 +1,89 @@
+import EmberObject from '@ember/object';
+import { A } from '@ember/array';
+import { alias } from '@ember/object/computed';
+import Component from '@ember/component';
+import { helper } from '@ember/component/helper';
+import Service from '@ember/service';
+import { run } from '@ember/runloop';
+import { precompileTemplate } from '@ember/template-compilation';
+import { find, findAll, render, settled, setupContext, setupRenderingContext, teardownContext } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import HostTemplate from 'ui/host/template';
+import SecretsIndexController from 'ui/secrets/index/controller';
+import { initialize as initializeIntl } from 'ui/instance-initializers/intl';
+import resolver from '../helpers/resolver';
+
+module('Integration | UI | scoped desktop fixes', function(hooks) {
+ hooks.beforeEach(async function() {
+ this.testRoot = document.createElement('div');
+ this.testRoot.id = 'ember-testing';
+ document.body.appendChild(this.testRoot);
+ await setupContext(this, {resolver});
+ initializeIntl(this.owner);
+ this.intl = this.owner.lookup('service:intl');
+ for (let locale of ['en-us', 'zh-tw']) {
+ this.intl.addTranslations(locale, await (await fetch(`/translations/${locale}.json`)).json());
+ }
+ this.owner.register('service:prefs', Service.extend({tablePerPage: 25}));
+ await setupRenderingContext(this);
+ });
+
+ hooks.afterEach(async function() {
+ if (this.secretsController) {
+ run(() => this.secretsController.destroy());
+ }
+ await teardownContext(this);
+ this.testRoot.remove();
+ });
+
+ test('the real Host template hides denied Add Container and preserves an allowed host query', async function(assert) {
+ // Only unrelated Host widgets and routing are inert. The production Host
+ // template still supplies the capability conditional and exact LinkTo args.
+ const Yielding = Component.extend({tagName: '', layout: precompileTemplate('{{yield}}')});
+ this.owner.register('component:action-menu', Yielding);
+ this.owner.register('component:link-to', Component.extend({
+ tagName: 'a',
+ layout: precompileTemplate('{{yield}}'),
+ attributeBindings: ['route:data-route', 'hostId:data-host-id'],
+ hostId: alias('query.hostId'),
+ }));
+ for (let name of ['power-select', 'select-dot', 'header-state', 'copy-ip', 'info-multi-stats']) {
+ this.owner.register(`component:${name}`, Component.extend({tagName: 'span'}));
+ }
+ this.owner.register('helper:outlet', helper(() => null));
+ this.host = EmberObject.create({id: 'synthetic-host', info: {osInfo: {kernelVersion: 'test'}}});
+ this.model = {host: this.host, all: A([this.host])};
+ this.actions = {changeHost() {}};
+ this.intl.setLocale(['zh-tw', 'en-us']);
+ this.set('canCreateContainer', false);
+
+ await render(HostTemplate);
+ assert.strictEqual(findAll('.header [data-route="containers.new"]').length, 0, 'denied or unloaded capability has no Add link');
+ this.set('canCreateContainer', true);
+ await settled();
+ let add = find('.header [data-route="containers.new"]');
+ assert.ok(add, 'the actual template exposes the creator entry');
+ assert.strictEqual(add.getAttribute('data-host-id'), 'synthetic-host', 'LinkTo receives the exact selected Host ID');
+ assert.strictEqual(add.textContent.trim(), this.intl.t('hostsPage.hostPage.addContainer.linkTo'));
+ this.set('canCreateContainer', false);
+ await settled();
+ assert.strictEqual(findAll('.header [data-route="containers.new"]').length, 0, 'revoked capability removes an already-rendered entry');
+ run(() => this.host.destroy());
+ });
+
+ test('real desktop table headers render the Secrets generic keys and follow locale changes', async function(assert) {
+ this.secretsController = SecretsIndexController.create();
+ this.headers = this.secretsController.get('headers');
+ this.rows = A([]);
+ this.intl.setLocale(['en-us']);
+
+ await render(precompileTemplate('{{sortable-table headers=this.headers body=this.rows sortBy="name" paging=false bulkActions=false search=false}}'));
+ let labels = () => findAll('thead tr.fixed-header > th').filter(node => node.getAttribute('data-column-role') !== 'actions').map(node => node.textContent.replace(/\s+/g, ' ').trim());
+ assert.deepEqual(labels(), ['State', 'Name', 'Description', 'Created'], 'the real table retains English desktop labels');
+ this.intl.setLocale(['zh-tw', 'en-us']);
+ await settled();
+ assert.deepEqual(labels(), ['狀態', '名稱', '描述', '建立'], 'the same real table headers react to Traditional Chinese');
+ assert.strictEqual(findAll('thead tr.fixed-header > th[data-column-role="actions"]').length, 1, 'the unchanged actions column remains present');
+ });
+});
diff --git a/tests/unit/host/controller-test.js b/tests/unit/host/controller-test.js
new file mode 100644
index 0000000000..3fcee62117
--- /dev/null
+++ b/tests/unit/host/controller-test.js
@@ -0,0 +1,80 @@
+import EmberObject from '@ember/object';
+import { module, test } from 'qunit';
+import HostController from 'ui/host/controller';
+import ProjectsService from 'ui/services/projects';
+import { createOwned, destroyOwned } from '../../helpers/owned-subject';
+
+module('Unit | Controller | host');
+
+function createSubject(assert, schemaProjectId = '1a2540') {
+ let project = EmberObject.create({id: '1a2540'});
+ let schema = EmberObject.create({collectionMethods: ['GET', 'POST']});
+ let projects = createOwned(ProjectsService, {
+ current: project,
+ schemaProjectId,
+ store: {
+ canCreate(type) {
+ assert.strictEqual(type, 'container', 'checks the container schema, not host permissions');
+ return schema.get('collectionMethods').includes('POST');
+ },
+ },
+ }, 'service');
+ let controller = createOwned(HostController, {projects}, 'controller');
+
+ return {
+ controller,
+ project,
+ projects,
+ schema,
+ destroy() {
+ destroyOwned(controller);
+ destroyOwned(projects);
+ destroyOwned(project);
+ destroyOwned(schema);
+ },
+ };
+}
+
+test('add-container stays hidden until the current project schema is ready', function(assert) {
+ let subject = createSubject(assert, null);
+
+ assert.false(subject.controller.get('canCreateContainer'), 'schema loading cannot expose the action');
+ subject.projects.set('schemaProjectId', '1a2540');
+ assert.true(subject.controller.get('canCreateContainer'), 'a matching loaded POST schema exposes the action');
+ subject.destroy();
+});
+
+test('add-container follows the container POST capability', function(assert) {
+ let subject = createSubject(assert);
+
+ subject.schema.set('collectionMethods', ['GET']);
+ assert.false(subject.controller.get('canCreateContainer'), 'a read-only container schema hides the action');
+ subject.schema.set('collectionMethods', ['GET', 'POST']);
+ subject.projects.incrementProperty('schemaLoadGeneration');
+ assert.true(subject.controller.get('canCreateContainer'), 'a creator schema exposes the action without role-name checks');
+ subject.destroy();
+});
+
+test('a project switch cannot reuse a stale container capability', function(assert) {
+ let subject = createSubject(assert);
+
+ assert.true(subject.controller.get('canCreateContainer'), 'starts with an authorized loaded schema');
+ subject.project.set('id', '1a-other');
+ assert.false(subject.controller.get('canCreateContainer'), 'the old project schema cannot grant access');
+ subject.projects.set('schemaProjectId', '1a-other');
+ assert.true(subject.controller.get('canCreateContainer'), 'the new matching schema may grant access');
+ subject.destroy();
+});
+
+test('same-project schema generations revoke cached container creation', function(assert) {
+ let subject = createSubject(assert);
+
+ assert.true(subject.controller.get('canCreateContainer'), 'starts with the current POST capability');
+ subject.schema.set('collectionMethods', ['GET']);
+ subject.projects.incrementProperty('schemaLoadGeneration');
+ assert.false(subject.controller.get('canCreateContainer'), 'a same-project schema reload revokes the cached action');
+ subject.schema.set('collectionMethods', ['GET', 'POST']);
+ subject.projects.incrementProperty('schemaLoadGeneration');
+ assert.true(subject.controller.get('canCreateContainer'), 'a later schema generation can restore the action');
+ subject.destroy();
+});
diff --git a/tests/unit/secrets/index/controller-test.js b/tests/unit/secrets/index/controller-test.js
new file mode 100644
index 0000000000..d7b263fd28
--- /dev/null
+++ b/tests/unit/secrets/index/controller-test.js
@@ -0,0 +1,71 @@
+import { run } from '@ember/runloop';
+import { module, test } from 'qunit';
+
+import SecretsIndexController from 'ui/secrets/index/controller';
+
+module('Unit | Controller | secrets/index | desktop headers');
+
+test('desktop headers use existing generic translation keys', function(assert) {
+ let controller = SecretsIndexController.create();
+ let headers = controller.get('headers');
+
+ assert.deepEqual(headers.filter((header) => !header.isActions).map((header) => header.translationKey), [
+ 'generic.state',
+ 'generic.name',
+ 'generic.description',
+ 'generic.created',
+ ]);
+ assert.strictEqual(headers[4].translationKey, undefined, 'actions header remains unchanged and visually suppressed');
+
+ run(() => controller.destroy());
+});
+
+test('translation keys preserve all legacy table and QA mapping fields', function(assert) {
+ let controller = SecretsIndexController.create();
+ let legacyHeaders = controller.get('headers').map((header) => {
+ let legacy = {...header};
+
+ delete legacy.translationKey;
+ return legacy;
+ });
+
+ assert.deepEqual(legacyHeaders, [
+ {
+ displayName: 'State',
+ name: 'stateSort',
+ sort: ['stateSort', 'name', 'id'],
+ type: 'string',
+ searchField: 'displayState',
+ classNames: '',
+ width: '125px',
+ },
+ {
+ displayName: 'Name',
+ name: 'name',
+ sort: ['name', 'id'],
+ type: 'string',
+ },
+ {
+ displayName: 'Description',
+ name: 'description',
+ sort: ['description', 'name', 'id'],
+ type: 'string',
+ },
+ {
+ displayName: 'Created',
+ name: 'created',
+ sort: ['primaryHost.displayName', 'name', 'id'],
+ searchField: false,
+ type: 'string',
+ },
+ {
+ displayName: 'Actions',
+ isActions: true,
+ width: '110px',
+ },
+ ], 'only four translationKey properties are added; names, sorting, searching, widths and fallback labels stay intact');
+ assert.equal(controller.get('sortBy'), 'name');
+ assert.deepEqual(controller.get('queryParams'), ['sortBy']);
+
+ run(() => controller.destroy());
+});