Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/host/controller.js
Original file line number Diff line number Diff line change
@@ -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'));
Expand Down
4 changes: 3 additions & 1 deletion app/host/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

<div class="pull-right">
{{#action-menu model=this.host size="sm" classNames="r-ml10 pull-right"}}
<LinkTo @route="containers.new" @query={{hash hostId=this.host.id}} class="btn btn-sm btn-primary">{{t 'hostsPage.hostPage.addContainer.linkTo'}}</LinkTo>
{{#if this.canCreateContainer}}
<LinkTo @route="containers.new" @query={{hash hostId=this.host.id}} class="btn btn-sm btn-primary">{{t 'hostsPage.hostPage.addContainer.linkTo'}}</LinkTo>
{{/if}}
{{/action-menu}}

{{header-state model=this.host classNames="pull-right"}}
Expand Down
4 changes: 4 additions & 0 deletions app/secrets/index/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default Controller.extend({
headers: [
{
displayName: 'State',
translationKey: 'generic.state',
name: 'stateSort',
sort: ['stateSort','name','id'],
type: 'string',
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
41 changes: 41 additions & 0 deletions docs/releases/web-console-1.6.162.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-modernization-blockers
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-ui-console-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion scripts/check-ui-critical-high-dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -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')}")
Expand Down
89 changes: 89 additions & 0 deletions tests/integration/scoped-desktop-ui-test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
80 changes: 80 additions & 0 deletions tests/unit/host/controller-test.js
Original file line number Diff line number Diff line change
@@ -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();
});
Loading
Loading