mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Keep Actions target names whole on narrow cards (#2539)
## Problem
Each Actions card's second line shows the target name, its type and the
time. The name and type were sibling flex items that shrank together. So
on a phone the line read "analytics-ba… · Virtual ma…" and "backup-coor…
· App cont…", which hid which resource the action touches. That's what
the operator needs to see before opening the review. From 360 to 430px
every target name was cut; from 480px up everything fit.
## Change
In `src/pages/Actions.tsx`, the name and the type now sit in their own
inline flex row:
- **Name:** never shrinks and is capped at the row's width (`shrink-0
max-w-full truncate`).
- **Type:** wrapped together with its separator, takes whatever room is
left (`min-w-0 flex-1 truncate`).
The time keeps its place. A first attempt with a large shrink factor
still gave the name about 0.08px of the shortfall, which was enough to
draw an ellipsis at every pixel density. The two-level row hands the
name no shortfall at all.
## Verification
Mock-mode private stack on main 1dcf9418aa, three open demo actions,
before against after at 360, 390, 430, 480, 640, 768, 1024 and 1440px:
| Viewport | `analytics-batch-01` (main → after) | `backup-coordinator`
(main → after) |
|---|---|---|
| 360 | 61 → 107 of 107px | 67 → 112 of 112px |
| 390 | 77 → 107 | 84 → 112 |
| 430 | 98 → 107 | 107 → 112 |
- **Exact fit:** the name's box equals its text width at 1×, 2× and 3×
pixel ratio. The type with its separator gets the rest ("Virtual
machine" 21, 51 and 91px at 360, 390 and 430px; whole from 480px).
Nothing changes from 480px up.
- **Long names:** a name longer than the line, injected in the browser,
truncates inside it at 360 and 390px with the time fully visible.
- **Interactions:** the review opens from a card and closes with Escape
at 390 and 1024px, and the History tab works. No console errors.
- **Tests:** the new
`features/actions/__tests__/Actions.metaLine.test.tsx` pins the
structure and fails against main's source. 25 test files across actions,
pages, routing and the resource-actions API pass.
The browser receipt is
`frontend-modern/browser-verification/2294fd5a84fdc31a.json`.
This commit is contained in:
@@ -3568,8 +3568,12 @@ not part of `ActionRequest`, durable plan identity, or `planHash`; a resource
|
||||
rename therefore cannot change action authority. Opaque canonical resource IDs
|
||||
remain in the row's accessible name and title, while the visual row prefers the
|
||||
API-supplied name and type and falls back to the bounded ID-derived type plus
|
||||
short suffix only when the resource projection is unavailable. Read-only demo
|
||||
posture is quiet supporting context rather than a page-level callout. The
|
||||
short suffix only when the resource projection is unavailable. When the row is
|
||||
too narrow for both, the resource name keeps its width and only the type, with
|
||||
its separator, gives way: on a phone they used to shrink together, so the row
|
||||
read "analytics-ba… · Virtual ma…" and hid which resource the action touches.
|
||||
Read-only demo posture is quiet supporting context rather than a page-level
|
||||
callout. The
|
||||
empty Open queue must explain producer state rather than promising content:
|
||||
when the effective Patrol mode is Watch only (read via the canonical
|
||||
`/api/ai/patrol/autonomy` settings read, failing closed to the generic calm
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a",
|
||||
"verified_at": "2026-10-05T21:08:04Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/pages/Actions.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/pages/Actions.tsx": "17ded57aa6d0476a01c4d0cfec5dee8c9fed2be0fbf50c3d3f5cd2e40c278de6"
|
||||
},
|
||||
"routes": [
|
||||
"/actions",
|
||||
"/actions/history"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 360,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 430,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 480,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 640,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (PULSE_MOCK_MODE) serving this tree on main 1dcf9418aa; before = main, after = main plus this change, measured in the same session on the three open demo actions",
|
||||
"Main: the target name and its type shrank together, so from 360 to 430 every name was cut (analytics-batch-01 61-98 of 107px, backup-coordinator 67-107 of 112px) beside a cut type (Virtual ma\u2026, App conta\u2026); from 480 everything fit",
|
||||
"Patched: the name keeps its full width at every width (box equals text width, 106.61px for analytics-batch-01, at 1x, 2x and 3x device pixel ratio), and the type with its separator takes the rest (Virtual machine 21px at 360, 51px at 390, 91px at 430, whole from 480); nothing changes from 480 up",
|
||||
"A name longer than the line (very-long-resource-name-on-a-production-cluster-node-07, injected) truncates inside the line at 360 and 390 with the relative time still fully visible, and fits whole at 768",
|
||||
"Before and after element screenshots of the three cards at 360, 390 and 1024 at 3x density"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /actions fresh at 390 (mobile emulation) and resized through each viewport before measuring",
|
||||
"Opened the second card's review at 390 and 1024 (Governed action review, analytics-batch-01 \u00b7 Virtual machine) and closed it with Escape",
|
||||
"Switched to the History tab (/actions/history, 3 rows); no console errors"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { SecurityAPI } from '@/api/security';
|
||||
import type { ActionAuditRecord } from '@/types/actionAudit';
|
||||
import { getActionResourcePresentation } from '../actionPresentation';
|
||||
import Actions from '../../../pages/Actions';
|
||||
|
||||
vi.mock('@/api/resourceActions', () => ({
|
||||
ResourceActionsAPI: { listActions: vi.fn(), getAction: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/api/security', () => ({ SecurityAPI: { getStatus: vi.fn() } }));
|
||||
vi.mock('@/api/patrol', () => ({ getPatrolAutonomySettings: vi.fn().mockResolvedValue({}) }));
|
||||
|
||||
const resourceId = 'docker:container:analytics-batch-01';
|
||||
|
||||
const audit: ActionAuditRecord = {
|
||||
id: 'restart-analytics',
|
||||
createdAt: '2026-09-29T18:00:00Z',
|
||||
updatedAt: '2026-09-29T18:01:00Z',
|
||||
state: 'executing',
|
||||
decisionRevision: 1,
|
||||
request: {
|
||||
requestId: 'request-restart-analytics',
|
||||
resourceId,
|
||||
capabilityName: 'restart',
|
||||
reason: 'Recover checkout after three failed health checks',
|
||||
requestedBy: 'operator',
|
||||
},
|
||||
plan: {
|
||||
actionId: 'restart-analytics',
|
||||
requestId: 'request-restart-analytics',
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
approvalPolicy: 'none',
|
||||
rollbackAvailable: false,
|
||||
expiresAt: '2026-09-29T21:00:00Z',
|
||||
planHash: 'sha256:plan-restart-analytics',
|
||||
},
|
||||
verificationOutcome: { status: 'unknown' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
vi.mocked(ResourceActionsAPI.listActions).mockResolvedValue({
|
||||
actions: [audit],
|
||||
count: 1,
|
||||
view: 'pending',
|
||||
readOnly: false,
|
||||
});
|
||||
vi.mocked(SecurityAPI.getStatus).mockResolvedValue({
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: false },
|
||||
} as Awaited<ReturnType<typeof SecurityAPI.getStatus>>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('Actions card resource line', () => {
|
||||
it('keeps the target name whole and lets its type give way on narrow cards', async () => {
|
||||
const { label, detail } = getActionResourcePresentation(resourceId);
|
||||
expect(label).toBe('analytics-batch-01');
|
||||
expect(detail).not.toBe('');
|
||||
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route path="*" component={Actions} />
|
||||
</Router>
|
||||
));
|
||||
|
||||
// On a phone the name and its type used to shrink together, so the line
|
||||
// read "analytics-ba… · Virtual ma…" and hid which resource the action
|
||||
// touches. The name never shrinks below its own width; the type and its
|
||||
// separator take whatever room is left.
|
||||
const name = await screen.findByText(label);
|
||||
expect(name).toHaveClass('shrink-0', 'max-w-full', 'truncate');
|
||||
const type = screen.getByText(detail).parentElement!;
|
||||
expect(type).toHaveClass('min-w-0', 'flex-1', 'truncate');
|
||||
expect(type.querySelector('[aria-hidden="true"]')).toHaveTextContent('·');
|
||||
expect(type.parentElement).toBe(name.parentElement);
|
||||
expect(name.parentElement?.closest(`[title="${resourceId}"]`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -334,13 +334,19 @@ export function Actions() {
|
||||
class="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted"
|
||||
title={action.request.resourceId}
|
||||
>
|
||||
<span class="truncate">{resource().label}</span>
|
||||
<Show when={resource().detail}>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="truncate font-mono text-[11px]">
|
||||
{resource().detail}
|
||||
</span>
|
||||
</Show>
|
||||
{/* The name says what the action touches, so it keeps its
|
||||
width and only the type, with its separator, gives way. */}
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="max-w-full shrink-0 truncate">{resource().label}</span>
|
||||
<Show when={resource().detail}>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span class="mr-1.5" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span class="font-mono text-[11px]">{resource().detail}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="shrink-0">{formatRelativeTime(action.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user