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/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/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')}") 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); + } });