From 58e53d254bd4a229974bba8908a78d2e8a65fa93 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Thu, 1 Oct 2026 11:19:01 +0800 Subject: [PATCH 1/4] 1.6.162: gate Host container creation and translate Secrets headers --- README.md | 14 +++ app/host/controller.js | 6 ++ app/host/template.hbs | 4 +- app/secrets/index/controller.js | 4 + ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.162.md | 41 +++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- tests/integration/scoped-desktop-ui-test.js | 89 +++++++++++++++++++ tests/unit/host/controller-test.js | 80 +++++++++++++++++ tests/unit/secrets/index/controller-test.js | 71 +++++++++++++++ 12 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 docs/releases/web-console-1.6.162.md create mode 100644 tests/integration/scoped-desktop-ui-test.js create mode 100644 tests/unit/host/controller-test.js create mode 100644 tests/unit/secrets/index/controller-test.js 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/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()); +}); From 46501e31071b3d74595aea91908876eec32b7fd6 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Thu, 1 Oct 2026 11:26:38 +0800 Subject: [PATCH 2/4] Sync current Web162 CI version gates --- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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')}") From 4127ffc0d692a5680211344674ea83d1cae7d792 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Thu, 1 Oct 2026 14:50:57 +0800 Subject: [PATCH 3/4] Fix state badge and relative date locale updates --- app/components/badge-state/component.js | 16 +++ app/components/badge-state/template.hbs | 2 +- app/helpers/date-from-now.js | 27 +++- app/services/user-language.js | 9 +- tests/integration/state-date-locale-test.js | 140 ++++++++++++++++++++ tests/unit/helpers/date-from-now-test.js | 45 ++++++- 6 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 tests/integration/state-date-locale-test.js diff --git a/app/components/badge-state/component.js b/app/components/badge-state/component.js index 1e4fc116f6..d650e5205c 100644 --- a/app/components/badge-state/component.js +++ b/app/components/badge-state/component.js @@ -1,7 +1,23 @@ import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { service } from '@ember/service'; + +const TRANSLATED_STATES = ['active', 'running', 'stopped', 'stopping', 'created', 'exited', 'error']; export default Component.extend({ + intl: service(), tagName: 'SPAN', classNames: ['state', 'badge'], classNameBindings: ['model.stateColor', 'model.stateBackground'], + + displayState: computed('model.displayState', 'intl._locale', function() { + let original = this.get('model.displayState'); + let state = String(original || '').toLowerCase(); + + if ( TRANSLATED_STATES.includes(state) ) { + return this.get('intl').t(`formPorts.preflight.state.${state}`); + } + + return original; + }), }); diff --git a/app/components/badge-state/template.hbs b/app/components/badge-state/template.hbs index 29191af25d..d6a3337fcf 100644 --- a/app/components/badge-state/template.hbs +++ b/app/components/badge-state/template.hbs @@ -1 +1 @@ - {{this.model.displayState}} + {{this.displayState}} diff --git a/app/helpers/date-from-now.js b/app/helpers/date-from-now.js index 631e4409ca..6e4881b675 100644 --- a/app/helpers/date-from-now.js +++ b/app/helpers/date-from-now.js @@ -1,7 +1,26 @@ -import { helper as buildHelper } from '@ember/component/helper'; +import Helper from '@ember/component/helper'; +import { observer } from '@ember/object'; +import { service } from '@ember/service'; +import { momentLocale } from 'ui/services/user-language'; -export function dateFromNow(params) { - return moment(params[0]).fromNow(); +export function dateFromNow(params, locale) { + let date = moment(params[0]); + + if ( locale ) { + date.locale(momentLocale(locale)); + } + + return date.fromNow(); } -export default buildHelper(dateFromNow); +export default Helper.extend({ + intl: service(), + + localeChanged: observer('intl._locale', function() { + this.recompute(); + }), + + compute(params) { + return dateFromNow(params, this.get('intl.primaryLocale')); + }, +}); diff --git a/app/services/user-language.js b/app/services/user-language.js index b548c13ad5..59788d1fcb 100644 --- a/app/services/user-language.js +++ b/app/services/user-language.js @@ -21,6 +21,12 @@ const MOMENT_LOCALES = { 'zh-tw': 'zh-tw', }; +export function momentLocale(lang) { + const normalized = (lang || C.LANGUAGE.DEFAULT).toLowerCase(); + + return MOMENT_LOCALES[normalized] || 'en'; +} + export default Service.extend({ prefs : service(), session : service(), @@ -101,8 +107,7 @@ export default Service.extend({ }, setMomentLocale(lang) { - const normalized = this.normalizeLang(lang || C.LANGUAGE.DEFAULT); - moment.locale(MOMENT_LOCALES[normalized] || 'en'); + moment.locale(momentLocale(lang)); }, setDocumentLanguage(lang) { diff --git a/tests/integration/state-date-locale-test.js b/tests/integration/state-date-locale-test.js new file mode 100644 index 0000000000..227bf24696 --- /dev/null +++ b/tests/integration/state-date-locale-test.js @@ -0,0 +1,140 @@ +import { module, test } from 'qunit'; +import { render, find, settled, setupContext, setupRenderingContext, teardownContext } from '@ember/test-helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import resolver from '../helpers/resolver'; +import { initialize as initializePodLayouts } from 'ui/initializers/pod-component-layouts'; +import { initialize as initializeIntl } from 'ui/instance-initializers/intl'; + +module('Integration | Locale | state and relative date', 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}); + initializePodLayouts(); + initializeIntl(this.owner); + this.intl = this.owner.lookup('service:intl'); + + // Read the build's real translation JSON rather than replacing the intl service. + for (let locale of ['en-us', 'zh-tw']) { + let response = await fetch(`/translations/${locale}.json`); + if (!response.ok) { + throw new Error(`Local translation fixture failed: ${locale} ${response.status}`); + } + this.intl.addTranslations(locale, await response.json()); + } + this.intl.setLocale(['en-us']); + this.previousMomentLocale = moment.locale(); + this.previousMomentNow = moment.now; + moment.locale('en'); + moment.now = () => Date.UTC(2026, 9, 1, 12); + await setupRenderingContext(this); + }); + + hooks.afterEach(async function() { + await teardownContext(this); + moment.now = this.previousMomentNow; + moment.locale(this.previousMomentLocale); + this.testRoot.remove(); + }); + + test('relative date rerenders en to zh-tw to en without changing the date or global Moment locale', async function(assert) { + this.created = Date.UTC(2026, 9, 1, 8); + await render(precompileTemplate('{{date-from-now this.created}}')); + assert.strictEqual(find('.relative-date').textContent, '4 hours ago'); + + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await settled(); + assert.strictEqual(find('.relative-date').textContent, '4 小時前', 'the unchanged timestamp is recomputed in Traditional Chinese'); + assert.strictEqual(moment.locale(), 'en', 'the helper uses an instance locale'); + + run(() => this.intl.setLocale(['en-us'])); + await settled(); + assert.strictEqual(find('.relative-date').textContent, '4 hours ago', 'switching back does not leave a stale Chinese result'); + }); + + test('relative date follows the existing zh-hans and English locale mapping', async function(assert) { + this.created = Date.UTC(2026, 9, 1, 8); + run(() => this.intl.setLocale(['zh-hans', 'en-us'])); + await render(precompileTemplate('{{date-from-now this.created}}')); + assert.strictEqual(find('.relative-date').textContent, '4 小时前'); + assert.strictEqual(moment.locale(), 'en', 'the mapped locale remains instance-local'); + }); + + test('all seven known resource states use the existing translations and rerender on locale change', async function(assert) { + this.models = [ + ['active', 'Active', '使用中'], + ['running', 'Running', '執行中'], + ['stopped', 'Stopped', '已停止'], + ['stopping', 'Stopping', '停止中'], + ['created', 'Created', '已建立'], + ['exited', 'Exited', '已結束'], + ['error', 'Error', '發生錯誤'], + ].map(([state, displayState, chinese]) => EmberObject.create({state, displayState, chinese})); + await render(precompileTemplate('{{#each this.models as |model|}}{{badge-state model=model}}{{/each}}')); + + let labels = () => [...this.testRoot.querySelectorAll('.badge')].map((badge) => badge.textContent.trim()); + assert.deepEqual(labels(), this.models.map((model) => model.displayState)); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await settled(); + assert.deepEqual(labels(), this.models.map((model) => model.chinese)); + run(() => this.intl.setLocale(['en-us'])); + await settled(); + assert.deepEqual(labels(), this.models.map((model) => model.displayState)); + }); + + test('unknown states retain the original display text across locale changes', async function(assert) { + this.model = EmberObject.create({state: 'vendor-special', displayState: 'Vendor-Special (custom)'}); + await render(precompileTemplate('{{badge-state model=this.model}}')); + assert.strictEqual(find('.badge').textContent.trim(), 'Vendor-Special (custom)'); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await settled(); + assert.strictEqual(find('.badge').textContent.trim(), 'Vendor-Special (custom)'); + run(() => this.model.set('displayState', 'Vendor updated text')); + await settled(); + assert.strictEqual(find('.badge').textContent.trim(), 'Vendor updated text'); + }); + + test('the model display state retains precedence and changes retain icon and color bindings', async function(assert) { + this.model = EmberObject.create({ + relevantState: 'running', state: 'active', displayState: 'Running', + stateIcon: 'icon-check', stateColor: 'text-success', stateBackground: 'bg-success', + }); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await render(precompileTemplate('{{badge-state model=this.model}}')); + assert.strictEqual(find('.badge').textContent.trim(), '執行中'); + assert.ok(find('.badge.text-success.bg-success'), 'existing status styling is unchanged'); + assert.ok(find('.badge i.icon-check'), 'existing status icon is unchanged'); + + run(() => this.model.setProperties({relevantState: 'stopped', displayState: 'Stopped'})); + await settled(); + assert.strictEqual(find('.badge').textContent.trim(), '已停止'); + run(() => this.model.setProperties({relevantState: 'custom-state', displayState: 'Custom-State'})); + await settled(); + assert.strictEqual(find('.badge').textContent.trim(), 'Custom-State'); + }); + + test('active resources with a health or connection display override retain that original text', async function(assert) { + this.model = EmberObject.create({state: 'active', relevantState: 'active', displayState: 'Disconnected'}); + await render(precompileTemplate('{{badge-state model=this.model}}')); + assert.strictEqual(find('.badge').textContent.trim(), 'Disconnected'); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await settled(); + assert.strictEqual(find('.badge').textContent.trim(), 'Disconnected', 'a machine state does not override the model display semantics'); + run(() => this.model.set('displayState', 'Active')); + await settled(); + assert.strictEqual(find('.badge').textContent.trim(), '使用中', 'a known English display state is translated once the override clears'); + }); + + test('unknown prototype-like states and missing state do not invent a translation', async function(assert) { + this.models = ['__proto__', 'constructor', ''].map((state) => EmberObject.create({state, displayState: `original:${state}`})); + run(() => this.intl.setLocale(['zh-tw', 'en-us'])); + await render(precompileTemplate('{{#each this.models as |model|}}{{badge-state model=model}}{{/each}}')); + assert.deepEqual( + [...this.testRoot.querySelectorAll('.badge')].map((badge) => badge.textContent.trim()), + this.models.map((model) => model.displayState) + ); + }); +}); diff --git a/tests/unit/helpers/date-from-now-test.js b/tests/unit/helpers/date-from-now-test.js index c258975d12..58bae535cf 100644 --- a/tests/unit/helpers/date-from-now-test.js +++ b/tests/unit/helpers/date-from-now-test.js @@ -3,11 +3,48 @@ import { module, test } from 'qunit'; import { dateFromNow } from 'ui/helpers/date-from-now'; +import { momentLocale } from 'ui/services/user-language'; module('Unit | Helper | date from now'); -// Replace this with your real tests. -test('it works', function(assert) { - var result = dateFromNow([42]); - assert.ok(result); +test('the shared Moment locale resolver preserves every existing language mapping', function(assert) { + let languages = ['de-de', 'fa-ir', 'fil-ph', 'fr-fr', 'hu-hu', 'ja-jp', 'ko-kr', 'pt-br', 'ru-ru', 'uk-ua', 'zh-hans', 'zh-tw', 'en-us']; + let locales = ['de', 'fa', 'tl-ph', 'fr', 'hu', 'ja', 'ko', 'pt-br', 'ru', 'uk', 'zh-cn', 'zh-tw', 'en']; + + assert.deepEqual(languages.map((language) => momentLocale(language)), locales); + assert.strictEqual(momentLocale(), 'en', 'the default language is unchanged'); +}); + +test('an explicit locale is instance-local and uses the existing language mapping', function(assert) { + let previousLocale = moment.locale(); + let previousNow = moment.now; + + try { + moment.locale('en'); + moment.now = () => Date.UTC(2026, 9, 1, 12); + let date = [Date.UTC(2026, 9, 1, 8)]; + + assert.strictEqual(dateFromNow(date, 'zh-TW'), '4 小時前'); + assert.strictEqual(dateFromNow(date, 'zh-hans'), '4 小时前'); + assert.strictEqual(dateFromNow(date, 'en-us'), '4 hours ago'); + assert.strictEqual(dateFromNow(date, 'unknown-locale'), '4 hours ago', 'unknown languages retain the existing English fallback'); + assert.strictEqual(moment.locale(), 'en', 'formatting does not mutate the global Moment locale'); + } finally { + moment.now = previousNow; + moment.locale(previousLocale); + } +}); + +test('the named helper function keeps the original global-locale behavior when no locale is supplied', function(assert) { + let previousLocale = moment.locale(); + let previousNow = moment.now; + + try { + moment.locale('zh-tw'); + moment.now = () => Date.UTC(2026, 9, 1, 12); + assert.strictEqual(dateFromNow([Date.UTC(2026, 9, 1, 8)]), '4 小時前'); + } finally { + moment.now = previousNow; + moment.locale(previousLocale); + } }); From 5db737a5e04c4cc15672f97296d5bf521ab9a8da Mon Sep 17 00:00:00 2001 From: chen21019 Date: Thu, 1 Oct 2026 14:55:06 +0800 Subject: [PATCH 4/4] Prepare Web Console 1.6.163 locale source candidate --- COMPATIBILITY.md | 10 ++++ README.md | 17 ++++++- ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.163.md | 47 +++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- 9 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 docs/releases/web-console-1.6.163.md diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 563e9dc857..f8de197e44 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,16 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +The `1.6.163` source candidate localizes state badges only from seven recognized +`model.displayState` labels using existing translation keys. Unknown labels and +health/connection display overrides retain their original text; a machine state +must not replace the model's display semantics. State, icons, colors, API payloads +and authorization remain unchanged. Relative dates recompute on locale changes +and use the existing language-to-Moment mapping on each instance, without +mutating the global Moment locale during formatting. Focused local source tests +passed 12/12; publication, packaged acceptance and full-language acceptance are +not claimed. + Web Console `1.6.161` preserves an existing Certificate's masked key when only name/description are changed. Explicit update-validation options apply only to persisted records and explicitly omitted fields; other required and supplied diff --git a/README.md b/README.md index 0b0086bf5c..0baab4eb3d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,19 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Release `1.6.163` is being prepared for two shared display-locale fixes. +Relative dates recompute when the selected language changes and use a Moment +instance locale, without changing the global locale during formatting. State +badges translate seven known display labels through existing translations; +unknown labels and model health/connection overrides such as Disconnected +remain unchanged. Icons, colors, model state and API behavior are preserved. +Focused native Chrome 153 source validation passed 12/12 tests: seven rendering +checks, three relative-date/helper checks and two existing user-language checks. +Official validation, deterministic candidate packaging, publication and packaged +browser acceptance are still pending. This is not full-language or resource/role +matrix acceptance; earlier HOLD evidence remains HOLD. See the +[preparation note](docs/releases/web-console-1.6.163.md). + Published release `1.6.162` contains 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 @@ -18,8 +31,8 @@ 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), plus two desktop rendering checks. Official CI passed 746/746 tests and produced two byte-identical archives. -Current Server packaging remains `v1.6.495` / Web Console `1.6.161`; packaged -browser acceptance of `1.6.162` is still pending. Responsive/mobile Secrets +At component publication, Server packaging remained `v1.6.495` / Web Console +`1.6.161`; packaged browser acceptance of `1.6.162` was 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 [release note](docs/releases/web-console-1.6.162.md). 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 b9ee3ee126..88b822b10d 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.162", + "version": "1.6.163", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.162", + "version": "1.6.163", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.163.md b/docs/releases/web-console-1.6.163.md new file mode 100644 index 0000000000..8adc07c879 --- /dev/null +++ b/docs/releases/web-console-1.6.163.md @@ -0,0 +1,47 @@ +# Web Console 1.6.163 + +Source preparation only. Official validation, deterministic candidate packaging, +public release and packaged browser acceptance are pending. No `1.6.163` release +asset or Server deployment is claimed. + +## Narrow shared display-locale fixes + +`date-from-now` recomputes when the selected Intl locale changes, even if the +timestamp is unchanged. Each Moment instance uses the existing user-language +locale mapping; formatting does not mutate the global Moment locale. The named +helper function retains its previous global-locale behavior when no locale is +supplied. + +`badge-state` translates the seven recognized display labels Active, Running, +Stopped, Stopping, Created, Exited and Error through existing +`formPorts.preflight.state` translations. It uses `model.displayState`, not the +machine state: an active resource displaying Disconnected must remain +Disconnected. Unknown display labels retain their original text. No translation +keys, model state, icon/color bindings, sorting, filtering or API contracts change. + +## Focused source evidence and pending acceptance + +Native Chrome 153 local source validation passed 12/12 tests, with zero failures, +skips or todos: + +- Seven real DOM rendering tests use the actual Intl service and built + translation JSON. They cover unchanged-timestamp `en-us` to `zh-tw` to `en-us` + switching, instance-only Moment locale, the existing Simplified Chinese locale + mapping, all seven known display labels, unknown fallback, label changes, + retained icons/colors and health/connection override semantics. +- Three helper tests preserve the existing locale mapping, explicit instance + formatting, unknown-language English fallback and the named helper contract. +- Two existing user-language tests retain document language/direction and + English fallback loading behavior. + +These local tests used an existing dependency installation. They are not a fresh +locked CI run or packaged acceptance. The subsequent version/documentation-only +preparation does not change the tested locale semantics. The normal +`validate.yml` workflow dispatch remains the required formal source validation: +it runs `scripts/ci`, builds twice and compares the deterministic candidate +archives. The disabled historical `scripts/build-static` path is not used. + +No full-language, responsive/mobile layout, backend-write, authentication, MFA, +database, runtime-host or VM acceptance is claimed. Earlier HOLD evidence remains +HOLD, and the broader resource/role matrix remains INCOMPLETE. No Server packaging, +production or deployment changes are included. diff --git a/package-lock.json b/package-lock.json index b9ee3ee126..88b822b10d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.162", + "version": "1.6.163", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.162", + "version": "1.6.163", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index f000ce2db4..5be0c0fbb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.162", + "version": "1.6.163", "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 55b0f7776d..83b1cb5853 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.162" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.162" +if [[ "$version" != "1.6.163" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.163" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index ecf01b28f9..d449ac807f 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.162 browser-session broker-broadcast + 1.6.163 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 9ab62f208c..e4d11d3779 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.162": +if package.get("version") != "1.6.163": 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')}")