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