mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Converge UI primitives and readiness guards
This commit is contained in:
parent
241ac9feaf
commit
09ed857869
28 changed files with 538 additions and 166 deletions
|
|
@ -8,21 +8,24 @@
|
|||
|
||||
1. The latest shipped Pulse v6 prerelease tag is `v6.0.0-rc.6`.
|
||||
2. That shipped prerelease tag resolves to commit `c25e95cb2b071551df95c8add62773905ba0628b`.
|
||||
3. The governed release profile in `docs/release-control/control_plane.json`
|
||||
3. The selected remote ref `origin/pulse/v6-release` is still behind the current
|
||||
local governed branch state, so `Release Dry Run` would exercise stale remote
|
||||
control-plane metadata instead of the intended candidate.
|
||||
4. The governed release profile in `docs/release-control/control_plane.json`
|
||||
currently declares both `prerelease_branch` and `stable_branch` as
|
||||
`pulse/v6-release`.
|
||||
4. The active control-plane target is still `v6-product-lane-expansion`, not
|
||||
5. The active control-plane target is still `v6-product-lane-expansion`, not
|
||||
`v6-ga-promotion`.
|
||||
5. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a
|
||||
6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a
|
||||
local GA candidate exists on the governed stable line.
|
||||
6. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
|
||||
7. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
|
||||
non-publish `Release Dry Run` for the current `6.0.0` candidate.
|
||||
7. `docs/releases/RELEASE_NOTES_v6.md` and
|
||||
8. `docs/releases/RELEASE_NOTES_v6.md` and
|
||||
`docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` now carry the
|
||||
currently proposed exact dates for the eventual GA notice:
|
||||
- `v6` GA date: `2026-06-04`
|
||||
- `v5` end-of-support date: `2026-09-02`
|
||||
8. There is still no governed `Release Dry Run` artifact or rehearsal record
|
||||
9. There is still no governed `Release Dry Run` artifact or rehearsal record
|
||||
exercising stable inputs for:
|
||||
- `version=6.0.0`
|
||||
- `promoted_from_tag=v6.0.0-rc.6`
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ notification, timeline, threshold ignored-prefix, and resource threshold note
|
|||
editors must compose the shared `FormTextarea` primitive for label/id/help
|
||||
wiring and textarea chrome instead of rendering raw native `<textarea>` shells
|
||||
in alert-owned runtime components.
|
||||
Alert resource threshold action presentation is also shared with
|
||||
frontend-primitives: row, mobile-card, global-default, and bulk-selection
|
||||
icon-only actions must compose `ActionIconButton` for shared size, tone, focus,
|
||||
title, and accessible-name behavior instead of rendering local `<button>` plus
|
||||
inline SVG shells in alert-owned runtime components.
|
||||
|
||||
## Extension Points
|
||||
|
||||
|
|
|
|||
|
|
@ -1453,6 +1453,12 @@ browser helper must treat `PLAYWRIGHT_BASE_URL` as the browser truth and leave
|
|||
`PULSE_BASE_URL` available for backend-oriented health checks and setup
|
||||
traffic, so split browser/backend proof can target fresh frontend code without
|
||||
rewiring the API-side contract.
|
||||
The Playwright managed-local-backend harness is part of that same canonical
|
||||
integration runtime boundary. `tests/integration/scripts/managed-local-backend.mjs`
|
||||
must seed a per-run audit signing key for local proof startup, while honoring
|
||||
explicit `PULSE_AUDIT_SIGNING_KEY` or deterministic `PULSE_E2E_AUDIT_SIGNING_KEY`
|
||||
overrides, so the runtime audit logger can remain fail-closed without breaking
|
||||
managed local backend tests.
|
||||
That same integration-README ownership includes the retired local commercial
|
||||
trial probe guidance. The snapshot-clean trial instructions for
|
||||
`tests/integration/scripts/retired-trial-acquisition-contract.sh` must describe
|
||||
|
|
|
|||
|
|
@ -510,6 +510,12 @@ not a replacement status card, CTA band, or page-local nested card.
|
|||
and reporting exports use the shared `success`, `successOutline`, and
|
||||
`successGhost` Button variants instead of carrying page-local emerald action
|
||||
shells.
|
||||
Compact icon-only row, inline, and floating action controls belong to
|
||||
`ActionIconButton`. Feature surfaces may own the icon choice, click handler,
|
||||
label text, and layout slot, but icon-button size, tone, focus ring,
|
||||
disabled treatment, title fallback, and accessible name wiring must come
|
||||
from that shared primitive rather than page-local `<button>` plus inline SVG
|
||||
shells.
|
||||
Settings selection helpers such as `ResourcePicker` must use the same
|
||||
`Button` primitive for select-all, clear, and chip remove actions instead of
|
||||
restoring footer-local action shells.
|
||||
|
|
|
|||
|
|
@ -3033,6 +3033,7 @@
|
|||
"tests/integration/QUICK_START.md",
|
||||
"tests/integration/README.md",
|
||||
"tests/integration/scripts/managed-dev-runtime.mjs",
|
||||
"tests/integration/scripts/managed-local-backend.mjs",
|
||||
"tests/integration/tests/helpers.ts",
|
||||
"tests/integration/tests/runtime-defaults.ts"
|
||||
],
|
||||
|
|
@ -3044,6 +3045,7 @@
|
|||
"scripts/tests/test-hot-dev-bg.sh",
|
||||
"scripts/tests/test-hot-dev-runtime.sh",
|
||||
"scripts/tests/test-toggle-mock.sh",
|
||||
"tests/integration/scripts/managed-local-backend.test.mjs",
|
||||
"tests/integration/tests/16-dev-runtime-recovery.spec.ts"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -520,6 +520,58 @@
|
|||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "action-icon-button-shell",
|
||||
"category": "action-button",
|
||||
"summary": "Compact icon-only row and inline actions must compose ActionIconButton so size, tone, focus, disabled, title, aria-label, and icon behavior stay canonical instead of recreating page-local button and SVG shells.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"export": "ActionIconButton"
|
||||
},
|
||||
"requiredConsumers": [
|
||||
{ "path": "src/components/Alerts/AlertResourceTableDesktop.tsx" },
|
||||
{ "path": "src/components/Alerts/AlertResourceTableMobile.tsx" },
|
||||
{ "path": "src/components/Alerts/AlertResourceTableRow.tsx" },
|
||||
{ "path": "src/components/Alerts/ResourceTable.tsx" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/Alerts/AlertResourceTableDesktop.tsx",
|
||||
"patterns": ["<svg", "class=\"p-1 hover:text-muted", "class=\"p-1 text-red-600"]
|
||||
},
|
||||
{
|
||||
"path": "src/components/Alerts/AlertResourceTableMobile.tsx",
|
||||
"patterns": [
|
||||
"<svg",
|
||||
"class=\"p-1.5 bg-blue-50",
|
||||
"class=\"p-1.5 bg-surface-hover",
|
||||
"class=\"p-1.5 bg-green-50"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/components/Alerts/AlertResourceTableRow.tsx",
|
||||
"patterns": [
|
||||
"<svg",
|
||||
"class=\"p-1 hover:text-muted",
|
||||
"class=\"p-1 text-blue-600",
|
||||
"class=\"p-1 hover:text-base-content"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/components/Alerts/ResourceTable.tsx",
|
||||
"patterns": [
|
||||
"<svg",
|
||||
"text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"src/components/Alerts/ResourceTable.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "copy-value-action-shell",
|
||||
"category": "copy-action",
|
||||
|
|
@ -2231,6 +2283,31 @@
|
|||
}
|
||||
],
|
||||
"patternGuards": [
|
||||
{
|
||||
"id": "alert-resource-action-local-svg-button-shell",
|
||||
"category": "action-button",
|
||||
"summary": "Alert resource threshold action controls must not recreate icon-only buttons with inline SVGs locally; compose ActionIconButton and lucide icons for row, mobile, and global actions.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"export": "ActionIconButton"
|
||||
},
|
||||
"scopes": ["src/components/Alerts"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": ["<button", "<svg", "Edit thresholds"],
|
||||
"legacyReason": "Retired migration debt. Alert resource threshold action buttons compose ActionIconButton for shared size, tone, focus, and accessible label behavior.",
|
||||
"allowedPaths": [],
|
||||
"ignoredPaths": [
|
||||
"src/components/Alerts/ResourceTable.test.tsx",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts"
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"src/components/Alerts/ResourceTable.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patrol-ai-local-loading-spinner-shell",
|
||||
"category": "loading-indicator",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import appSource from '@/App.tsx?raw';
|
||||
|
|
@ -9,6 +9,20 @@ import appRuntimeStateSource from '@/useAppRuntimeState.ts?raw';
|
|||
|
||||
const appStylesSource = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
|
||||
const headerAuditSource = readFileSync(join(process.cwd(), 'scripts/header-audit.mjs'), 'utf8');
|
||||
const integrationTestsDir = join(process.cwd(), '..', 'tests', 'integration', 'tests');
|
||||
|
||||
function readIntegrationTestSources(dir: string): Array<{ path: string; source: string }> {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return readIntegrationTestSources(path);
|
||||
}
|
||||
if (!entry.isFile() || !path.endsWith('.ts')) {
|
||||
return [];
|
||||
}
|
||||
return [{ path, source: readFileSync(path, 'utf8') }];
|
||||
});
|
||||
}
|
||||
|
||||
describe('App architecture', () => {
|
||||
it('keeps App as the entry shell that delegates runtime and chrome ownership', () => {
|
||||
|
|
@ -175,7 +189,9 @@ describe('App architecture', () => {
|
|||
expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {");
|
||||
expect(appSource).toContain('window.setTimeout(() => {');
|
||||
expect(appSource).toContain("closest('[data-ai-model-picker]')");
|
||||
expect(appSource).toContain('if (!e.defaultPrevented && !isModelPickerEscape && aiChatStore.isOpen) {');
|
||||
expect(appSource).toContain(
|
||||
'if (!e.defaultPrevented && !isModelPickerEscape && aiChatStore.isOpen) {',
|
||||
);
|
||||
expect(appSource).toContain('<AIChat onClose={() => aiChatStore.close()} />');
|
||||
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
|
||||
expect(appSource).not.toContain('TrialBanner');
|
||||
|
|
@ -183,6 +199,21 @@ describe('App architecture', () => {
|
|||
expect(appSource).not.toContain('monitoredSystemLimitWarningBanner');
|
||||
});
|
||||
|
||||
it('keeps integration browser proofs off the retired AI route', () => {
|
||||
const routesSource = readFileSync(join(integrationTestsDir, 'routes.ts'), 'utf8');
|
||||
const retiredRouteNavigations = readIntegrationTestSources(integrationTestsDir).flatMap(
|
||||
({ path, source }) =>
|
||||
source
|
||||
.split('\n')
|
||||
.flatMap((line, index) =>
|
||||
/page\.goto\(\s*(['"])\/ai\1/.test(line) ? [`${path}:${index + 1}`] : [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(routesSource).toContain('export const PATROL_ROUTE = "/patrol";');
|
||||
expect(retiredRouteNavigations).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => {
|
||||
expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)');
|
||||
expect(appLayoutSource).toContain(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { For, Show, createEffect } from 'solid-js';
|
||||
import ChevronDown from 'lucide-solid/icons/chevron-down';
|
||||
import Trash2 from 'lucide-solid/icons/trash-2';
|
||||
|
||||
import { ActionIconButton } from '@/components/shared/Button';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { TogglePrimitive } from '@/components/shared/Toggle';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
|
|
@ -331,53 +334,31 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
|
|||
typeof props.table.onMetricDelayChange === 'function'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={() => props.setShowDelayRow(!props.showDelayRow())}
|
||||
class="p-1 hover:text-muted transition-colors"
|
||||
title={
|
||||
props.showDelayRow()
|
||||
? 'Hide alert delay settings'
|
||||
: 'Show alert delay settings'
|
||||
}
|
||||
aria-label={
|
||||
label={
|
||||
props.showDelayRow()
|
||||
? 'Hide alert delay settings'
|
||||
: 'Show alert delay settings'
|
||||
}
|
||||
tone="muted"
|
||||
size="xs"
|
||||
>
|
||||
<svg
|
||||
<ChevronDown
|
||||
class={`w-4 h-4 transition-transform ${props.showDelayRow() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ActionIconButton>
|
||||
</Show>
|
||||
<Show when={props.hasCustomGlobalDefaults() && props.table.onResetDefaults}>
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={() => props.table.onResetDefaults?.()}
|
||||
class="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title={getAlertResourceTableResetFactoryDefaultsLabel()}
|
||||
aria-label={getAlertResourceTableResetFactoryDefaultsLabel()}
|
||||
label={getAlertResourceTableResetFactoryDefaultsLabel()}
|
||||
tone="danger"
|
||||
size="xs"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</Show>
|
||||
<Show when={!props.table.showDelayColumn && !props.hasCustomGlobalDefaults()}>
|
||||
<span class="text-sm text-slate-400" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { For, Show } from 'solid-js';
|
||||
import Check from 'lucide-solid/icons/check';
|
||||
import Pencil from 'lucide-solid/icons/pencil';
|
||||
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
|
||||
import X from 'lucide-solid/icons/x';
|
||||
|
||||
import { ActionIconButton } from '@/components/shared/Button';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { FormTextarea } from '@/components/shared/FormTextarea';
|
||||
import { TogglePrimitive } from '@/components/shared/Toggle';
|
||||
|
|
@ -254,68 +258,32 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
|||
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<Show when={!isEditing() && resource.type !== 'dockerHost'}>
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={() => startEditing(resource)}
|
||||
class="p-1.5 bg-blue-50 dark:bg-blue-900 text-blue-600 rounded"
|
||||
aria-label={`Edit thresholds for ${getAlertResourceLabel(resource)}`}
|
||||
label={`Edit thresholds for ${getAlertResourceLabel(resource)}`}
|
||||
tone="accent"
|
||||
size="sm"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Pencil class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</Show>
|
||||
<Show when={isEditing()}>
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={cancelEditing}
|
||||
class="p-1.5 bg-surface-hover text-slate-600 rounded"
|
||||
aria-label="Cancel threshold edits"
|
||||
label="Cancel threshold edits"
|
||||
tone="muted"
|
||||
size="sm"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
<ActionIconButton
|
||||
onClick={() => saveEditing(resource.id)}
|
||||
class="p-1.5 bg-green-50 dark:bg-green-900 text-green-600 rounded"
|
||||
aria-label={`Save threshold edits for ${getAlertResourceLabel(resource)}`}
|
||||
label={`Save threshold edits for ${getAlertResourceLabel(resource)}`}
|
||||
tone="success"
|
||||
size="sm"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Check class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
|
|
@ -323,15 +291,15 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
|
|||
(resource.type === 'agent' && resource.disableConnectivity)
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={() => props.table.onRemoveOverride(resource.id)}
|
||||
class="p-1.5 bg-surface-alt hover:text-muted rounded transition-colors"
|
||||
aria-label={`Revert to defaults for ${getAlertResourceLabel(resource)}`}
|
||||
label={`Revert to defaults for ${getAlertResourceLabel(resource)}`}
|
||||
title={getAlertResourceTableRevertToDefaultsLabel()}
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4" />
|
||||
</button>
|
||||
<RotateCcw class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { For, Show } from 'solid-js';
|
||||
import Pencil from 'lucide-solid/icons/pencil';
|
||||
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
|
||||
import X from 'lucide-solid/icons/x';
|
||||
|
||||
import { ActionIconButton } from '@/components/shared/Button';
|
||||
import { FormTextarea } from '@/components/shared/FormTextarea';
|
||||
import { TogglePrimitive } from '@/components/shared/Toggle';
|
||||
import { StatusBadge } from '@/components/shared/StatusBadge';
|
||||
|
|
@ -563,22 +566,14 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
|
|||
<Show
|
||||
when={!isEditing()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={cancelEditing}
|
||||
class="p-1 hover:text-muted"
|
||||
title="Cancel editing"
|
||||
aria-label="Cancel editing"
|
||||
label="Cancel editing"
|
||||
tone="muted"
|
||||
size="xs"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
}
|
||||
>
|
||||
{(() => {
|
||||
|
|
@ -599,33 +594,26 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
|
|||
}
|
||||
>
|
||||
<Show when={showEdit}>
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={() => startEditing()}
|
||||
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
label={`Edit thresholds for ${resourceLabel()}`}
|
||||
title="Edit thresholds"
|
||||
aria-label={`Edit thresholds for ${resourceLabel()}`}
|
||||
tone="accent"
|
||||
size="xs"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Pencil class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</Show>
|
||||
<Show when={showRevert}>
|
||||
<button
|
||||
type="button"
|
||||
<ActionIconButton
|
||||
onClick={() => props.onRemoveOverride(props.resource.id)}
|
||||
class="p-1 hover:text-base-content transition-colors"
|
||||
label={`Revert to defaults for ${resourceLabel()}`}
|
||||
title={getAlertResourceTableRevertToDefaultsLabel()}
|
||||
aria-label={`Revert to defaults for ${resourceLabel()}`}
|
||||
tone="neutral"
|
||||
size="xs"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4" />
|
||||
</button>
|
||||
<RotateCcw class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -114,6 +114,26 @@ vi.mock('lucide-solid/icons/rotate-ccw', () => ({
|
|||
default: (props: any) => <svg data-testid="rotate-ccw-icon" class={props.class} />,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-solid/icons/check', () => ({
|
||||
default: (props: any) => <svg data-testid="check-icon" class={props.class} />,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-solid/icons/chevron-down', () => ({
|
||||
default: (props: any) => <svg data-testid="chevron-down-icon" class={props.class} />,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-solid/icons/pencil', () => ({
|
||||
default: (props: any) => <svg data-testid="pencil-icon" class={props.class} />,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-solid/icons/trash-2', () => ({
|
||||
default: (props: any) => <svg data-testid="trash-2-icon" class={props.class} />,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-solid/icons/x', () => ({
|
||||
default: (props: any) => <svg data-testid="x-icon" class={props.class} />,
|
||||
}));
|
||||
|
||||
import { ResourceTable, type Resource } from './ResourceTable';
|
||||
|
||||
// --- Helpers ---
|
||||
|
|
@ -276,9 +296,13 @@ describe('ResourceTable', () => {
|
|||
|
||||
describe('table ownership model', () => {
|
||||
it('keeps table state and metric rules in dedicated owners', () => {
|
||||
const rawSvgTag = ['<', 'svg'].join('');
|
||||
|
||||
expect(resourceTableSource).toContain('useAlertResourceTableState');
|
||||
expect(resourceTableSource).toContain('AlertResourceTableDesktop');
|
||||
expect(resourceTableSource).toContain('AlertResourceTableMobile');
|
||||
expect(resourceTableSource).toContain('ActionIconButton');
|
||||
expect(resourceTableSource).not.toContain(rawSvgTag);
|
||||
expect(resourceTableSource).not.toContain('const flattenResources = (): Resource[] => {');
|
||||
expect(resourceTableSource).not.toContain(
|
||||
'const normalizeMetricKey = (column: string): string => {',
|
||||
|
|
@ -291,15 +315,21 @@ describe('ResourceTable', () => {
|
|||
);
|
||||
expect(alertResourceTableDesktopSource).toContain('AlertResourceTableRow');
|
||||
expect(alertResourceTableDesktopSource).toContain('AlertResourceGroupHeader');
|
||||
expect(alertResourceTableDesktopSource).toContain('ActionIconButton');
|
||||
expect(alertResourceTableDesktopSource).not.toContain(rawSvgTag);
|
||||
expect(alertResourceTableMobileSource).toContain('export function AlertResourceTableMobile');
|
||||
expect(alertResourceTableMobileSource).toContain('AlertResourceGroupHeader');
|
||||
expect(alertResourceTableMobileSource).toContain('FormTextarea');
|
||||
expect(alertResourceTableMobileSource).toContain('ActionIconButton');
|
||||
expect(alertResourceTableMobileSource).not.toContain(['<', 'textarea'].join(''));
|
||||
expect(alertResourceTableMobileSource).not.toContain(rawSvgTag);
|
||||
expect(alertResourceGroupHeaderSource).toContain('export function AlertResourceGroupHeader');
|
||||
expect(alertResourceTableRowSource).toContain('export function AlertResourceTableRow');
|
||||
expect(alertResourceTableRowSource).toContain('alertResourceSupportsMetric');
|
||||
expect(alertResourceTableRowSource).toContain('FormTextarea');
|
||||
expect(alertResourceTableRowSource).toContain('ActionIconButton');
|
||||
expect(alertResourceTableRowSource).not.toContain(['<', 'textarea'].join(''));
|
||||
expect(alertResourceTableRowSource).not.toContain(rawSvgTag);
|
||||
expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState');
|
||||
expect(alertResourceTableModelSource).toContain(
|
||||
'export function normalizeAlertResourceMetricKey',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Show } from 'solid-js';
|
||||
import X from 'lucide-solid/icons/x';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import type { Alert } from '@/types/api';
|
||||
import {
|
||||
ALERT_BULK_EDIT_CLEAR_LABEL,
|
||||
getAlertBulkEditOpenLabel,
|
||||
} from '@/utils/alertBulkEditPresentation';
|
||||
import { ActionIconButton } from '@/components/shared/Button';
|
||||
import { useAlertResourceTableState } from './useAlertResourceTableState';
|
||||
import type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tableTypes';
|
||||
import { AlertResourceTableDesktop } from './AlertResourceTableDesktop';
|
||||
|
|
@ -135,22 +137,15 @@ export function ResourceTable(props: ResourceTableProps) {
|
|||
>
|
||||
{getAlertBulkEditOpenLabel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5 transition-colors focus:outline-none"
|
||||
<ActionIconButton
|
||||
class="rounded-full bg-surface text-slate-400 hover:bg-slate-700 hover:text-white focus-visible:ring-offset-0"
|
||||
onClick={clearSelectedIds}
|
||||
aria-label={ALERT_BULK_EDIT_CLEAR_LABEL}
|
||||
title={ALERT_BULK_EDIT_CLEAR_LABEL}
|
||||
label={ALERT_BULK_EDIT_CLEAR_LABEL}
|
||||
size="sm"
|
||||
tone="neutral"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</ActionIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Route, Router } from '@solidjs/router';
|
|||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ActionIconButton,
|
||||
Button,
|
||||
ButtonLink,
|
||||
CommandCopyButton,
|
||||
|
|
@ -27,6 +28,10 @@ describe('Button', () => {
|
|||
expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_VARIANT_CLASSES');
|
||||
expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain('getCopyValueButtonClass');
|
||||
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_TONE_CLASSES');
|
||||
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain('getActionIconButtonClass');
|
||||
expect(buttonSource).toContain('export function ActionIconButton');
|
||||
expect(buttonModelSource).toContain(
|
||||
"secondary: 'border border-border bg-surface text-base-content shadow-sm hover:bg-surface-hover'",
|
||||
);
|
||||
|
|
@ -194,6 +199,26 @@ describe('Button', () => {
|
|||
expect(screen.getByRole('button', { name: 'Copy blank' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders compact action icon buttons through the shared primitive', () => {
|
||||
const onClick = vi.fn();
|
||||
|
||||
render(() => (
|
||||
<ActionIconButton label="Edit thresholds" tone="accent" size="xs" onClick={onClick}>
|
||||
<span aria-hidden="true">E</span>
|
||||
</ActionIconButton>
|
||||
));
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Edit thresholds' });
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
expect(button).toHaveAttribute('title', 'Edit thresholds');
|
||||
expect(button).toHaveClass('h-6');
|
||||
expect(button).toHaveClass('w-6');
|
||||
expect(button).toHaveClass('bg-blue-50');
|
||||
|
||||
button.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders drawer header actions through the shared button family', () => {
|
||||
const onAsk = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import CheckIcon from 'lucide-solid/icons/check';
|
|||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import { JSX, Show, mergeProps, splitProps } from 'solid-js';
|
||||
import {
|
||||
getActionIconButtonClass,
|
||||
getButtonClass,
|
||||
getCopyValueButtonClass,
|
||||
getDrawerHeaderActionButtonClass,
|
||||
getDrawerHeaderActionGroupClass,
|
||||
getDrawerHeaderIconButtonClass,
|
||||
type ActionIconButtonSize,
|
||||
type ActionIconButtonTone,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
type CopyValueButtonSize,
|
||||
|
|
@ -51,6 +54,17 @@ export interface CopyValueButtonProps extends Omit<
|
|||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export interface ActionIconButtonProps extends Omit<
|
||||
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> {
|
||||
label: string;
|
||||
tone?: ActionIconButtonTone;
|
||||
size?: ActionIconButtonSize;
|
||||
class?: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export interface DrawerHeaderActionGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
|
|
@ -217,6 +231,41 @@ export function CopyValueButton(props: CopyValueButtonProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function ActionIconButton(props: ActionIconButtonProps) {
|
||||
const merged = mergeProps(
|
||||
{
|
||||
tone: 'neutral' as ActionIconButtonTone,
|
||||
size: 'sm' as ActionIconButtonSize,
|
||||
type: 'button' as const,
|
||||
},
|
||||
props,
|
||||
);
|
||||
const [local, rest] = splitProps(merged, [
|
||||
'label',
|
||||
'tone',
|
||||
'size',
|
||||
'class',
|
||||
'children',
|
||||
'title',
|
||||
'aria-label',
|
||||
]);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
class={getActionIconButtonClass({
|
||||
tone: local.tone,
|
||||
size: local.size,
|
||||
class: local.class,
|
||||
})}
|
||||
title={local.title ?? local.label}
|
||||
aria-label={local['aria-label'] ?? local.label}
|
||||
>
|
||||
{local.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonLink(props: ButtonLinkProps) {
|
||||
const merged = mergeProps(
|
||||
{ variant: 'secondary' as ButtonVariant, size: 'md' as ButtonSize },
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ import diskListSource from '@/components/Storage/DiskList.tsx?raw';
|
|||
import alertOverviewStatsCardsSource from '@/features/alerts/AlertOverviewStatsCards.tsx?raw';
|
||||
import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw';
|
||||
import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw';
|
||||
import alertResourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw';
|
||||
import alertResourceTableDesktopSource from '@/components/Alerts/AlertResourceTableDesktop.tsx?raw';
|
||||
import alertResourceTableMobileSource from '@/components/Alerts/AlertResourceTableMobile.tsx?raw';
|
||||
import alertResourceTableRowSource from '@/components/Alerts/AlertResourceTableRow.tsx?raw';
|
||||
|
|
@ -2440,9 +2441,13 @@ describe('shared primitive guardrails', () => {
|
|||
}>;
|
||||
};
|
||||
const registeredRule = registry.rules?.find((rule) => rule.id === 'button-command-shell');
|
||||
const actionIconRule = registry.rules?.find((rule) => rule.id === 'action-icon-button-shell');
|
||||
const registeredGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'button-secondary-command-local-shell',
|
||||
);
|
||||
const alertResourceActionGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'alert-resource-action-local-svg-button-shell',
|
||||
);
|
||||
const commandCopyGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'button-command-copy-local-shell',
|
||||
);
|
||||
|
|
@ -2792,6 +2797,61 @@ describe('shared primitive guardrails', () => {
|
|||
expect(drawerHeaderIconGuard?.scopes).toEqual(['src/components', 'src/features', 'src/pages']);
|
||||
expect(drawerHeaderIconGuard?.allowedPaths ?? []).toHaveLength(0);
|
||||
expect(drawerHeaderIconGuard?.ignoredPaths).toEqual(['src/components/shared/Button.test.tsx']);
|
||||
expect(actionIconRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(actionIconRule?.canonical?.export).toBe('ActionIconButton');
|
||||
expect(actionIconRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/Alerts/AlertResourceTableDesktop.tsx',
|
||||
'src/components/Alerts/AlertResourceTableMobile.tsx',
|
||||
'src/components/Alerts/AlertResourceTableRow.tsx',
|
||||
'src/components/Alerts/ResourceTable.tsx',
|
||||
]);
|
||||
expect(actionIconRule?.forbiddenPatterns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'src/components/Alerts/AlertResourceTableDesktop.tsx',
|
||||
patterns: expect.arrayContaining([
|
||||
'<svg',
|
||||
'class="p-1 hover:text-muted',
|
||||
'class="p-1 text-red-600',
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: 'src/components/Alerts/AlertResourceTableMobile.tsx',
|
||||
patterns: expect.arrayContaining([
|
||||
'<svg',
|
||||
'class="p-1.5 bg-blue-50',
|
||||
'class="p-1.5 bg-surface-hover',
|
||||
'class="p-1.5 bg-green-50',
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: 'src/components/Alerts/AlertResourceTableRow.tsx',
|
||||
patterns: expect.arrayContaining([
|
||||
'<svg',
|
||||
'class="p-1 hover:text-muted',
|
||||
'class="p-1 text-blue-600',
|
||||
'class="p-1 hover:text-base-content',
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: 'src/components/Alerts/ResourceTable.tsx',
|
||||
patterns: expect.arrayContaining([
|
||||
'<svg',
|
||||
'text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5',
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(alertResourceActionGuard?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(alertResourceActionGuard?.canonical?.export).toBe('ActionIconButton');
|
||||
expect(alertResourceActionGuard?.allPatterns).toEqual(['<button', '<svg', 'Edit thresholds']);
|
||||
expect(alertResourceActionGuard?.scopes).toEqual(['src/components/Alerts']);
|
||||
expect(alertResourceActionGuard?.allowedPaths ?? []).toHaveLength(0);
|
||||
expect(alertResourceActionGuard?.ignoredPaths).toEqual([
|
||||
'src/components/Alerts/ResourceTable.test.tsx',
|
||||
'src/components/shared/Button.test.tsx',
|
||||
'src/components/shared/SharedPrimitives.guardrails.test.ts',
|
||||
]);
|
||||
expect(copyValueRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(copyValueRule?.canonical?.export).toBe('CopyValueButton');
|
||||
expect(copyValueRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
|
|
@ -2831,12 +2891,14 @@ describe('shared primitive guardrails', () => {
|
|||
expect(buttonSource).toContain('export function Button');
|
||||
expect(buttonSource).toContain('export function CommandCopyButton');
|
||||
expect(buttonSource).toContain('export function CopyValueButton');
|
||||
expect(buttonSource).toContain('export function ActionIconButton');
|
||||
expect(buttonSource).toContain('export function DrawerHeaderActionButton');
|
||||
expect(buttonSource).toContain('export function DrawerHeaderActionGroup');
|
||||
expect(buttonSource).toContain('export function DrawerHeaderIconButton');
|
||||
expect(buttonSource).toContain('export function ButtonLink');
|
||||
expect(buttonSource).toContain('getButtonClass');
|
||||
expect(buttonSource).toContain('getCopyValueButtonClass');
|
||||
expect(buttonSource).toContain('getActionIconButtonClass');
|
||||
expect(copyableCodeRowSource).toContain('CopyValueButton');
|
||||
expect(buttonModelSource).toContain('BUTTON_VARIANT_CLASSES');
|
||||
expect(buttonModelSource).toContain('BUTTON_SIZE_CLASSES');
|
||||
|
|
@ -2848,11 +2910,23 @@ describe('shared primitive guardrails', () => {
|
|||
expect(buttonModelSource).toContain('successGhost:');
|
||||
expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_VARIANT_CLASSES');
|
||||
expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_TONE_CLASSES');
|
||||
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain('dangerOutline:');
|
||||
expect(buttonModelSource).toContain('settingsAction:');
|
||||
expect(buttonModelSource).toContain('getCopyValueButtonClass');
|
||||
expect(buttonModelSource).toContain('getActionIconButtonClass');
|
||||
expect(buttonModelSource).toContain('getDrawerHeaderActionButtonClass');
|
||||
expect(buttonModelSource).toContain('getDrawerHeaderIconButtonClass');
|
||||
for (const source of [
|
||||
alertResourceTableSource,
|
||||
alertResourceTableDesktopSource,
|
||||
alertResourceTableMobileSource,
|
||||
alertResourceTableRowSource,
|
||||
]) {
|
||||
expect(source).toContain('ActionIconButton');
|
||||
expect(source).not.toContain(['<', 'svg'].join(''));
|
||||
}
|
||||
for (const drawerSource of [guestDrawerSource, resourceDetailDrawerSource]) {
|
||||
expect(drawerSource).toContain('@/components/shared/Button');
|
||||
expect(drawerSource).toContain('DrawerHeaderActionGroup');
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ export const getButtonClass = (options: ButtonClassOptions = {}): string =>
|
|||
|
||||
export type CopyValueButtonVariant = 'neutral' | 'ghost' | 'accent' | 'chip';
|
||||
export type CopyValueButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'chip';
|
||||
export type ActionIconButtonTone = 'neutral' | 'muted' | 'accent' | 'success' | 'danger';
|
||||
export type ActionIconButtonSize = 'xs' | 'sm' | 'md';
|
||||
|
||||
export const COPY_VALUE_BUTTON_BASE_CLASS =
|
||||
'inline-flex shrink-0 items-center justify-center rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
|
@ -108,6 +110,42 @@ export const getCopyValueButtonClass = (options: CopyValueButtonClassOptions = {
|
|||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
export const ACTION_ICON_BUTTON_BASE_CLASS =
|
||||
'inline-flex shrink-0 items-center justify-center rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export const ACTION_ICON_BUTTON_TONE_CLASSES: Record<ActionIconButtonTone, string> = {
|
||||
neutral: 'text-base-content hover:bg-surface-hover hover:text-base-content',
|
||||
muted: 'text-muted hover:bg-surface-hover hover:text-base-content',
|
||||
accent:
|
||||
'bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 dark:bg-blue-900 dark:text-blue-400 dark:hover:bg-blue-950 dark:hover:text-blue-300',
|
||||
success:
|
||||
'bg-green-50 text-green-600 hover:bg-green-100 hover:text-green-700 dark:bg-green-900 dark:text-green-400 dark:hover:bg-green-950 dark:hover:text-green-300',
|
||||
danger:
|
||||
'text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300',
|
||||
};
|
||||
|
||||
export const ACTION_ICON_BUTTON_SIZE_CLASSES: Record<ActionIconButtonSize, string> = {
|
||||
xs: 'h-6 w-6',
|
||||
sm: 'h-7 w-7',
|
||||
md: 'h-8 w-8',
|
||||
};
|
||||
|
||||
export type ActionIconButtonClassOptions = {
|
||||
tone?: ActionIconButtonTone;
|
||||
size?: ActionIconButtonSize;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
export const getActionIconButtonClass = (options: ActionIconButtonClassOptions = {}): string =>
|
||||
[
|
||||
ACTION_ICON_BUTTON_BASE_CLASS,
|
||||
ACTION_ICON_BUTTON_TONE_CLASSES[options.tone ?? 'neutral'],
|
||||
ACTION_ICON_BUTTON_SIZE_CLASSES[options.size ?? 'sm'],
|
||||
options.class,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
export const DRAWER_HEADER_ACTION_GROUP_CLASS = 'flex shrink-0 items-center gap-1.5';
|
||||
|
||||
export const DRAWER_HEADER_ACTION_BUTTON_CLASS =
|
||||
|
|
|
|||
|
|
@ -410,8 +410,8 @@ describe('platform overview layout guardrails', () => {
|
|||
expect(truenasSystemsTableSource).toMatch(
|
||||
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?System/,
|
||||
);
|
||||
expect(truenasSystemsTableSource).toContain(
|
||||
'<span class="md:hidden">{formatPercent(storagePercent())}</span>',
|
||||
expect(truenasSystemsTableSource).toMatch(
|
||||
/<span class="md:hidden">\s*<PlatformTablePercentValue value={storagePercent\(\)} \/>\s*<\/span>/,
|
||||
);
|
||||
expect(vsphereHostsTableSource).toMatch(
|
||||
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Host/,
|
||||
|
|
|
|||
|
|
@ -208,7 +208,27 @@ def _staged_python_script_run(
|
|||
env["PYTHONPATH"] = (
|
||||
script_dir if not current_pythonpath else f"{script_dir}{os.pathsep}{current_pythonpath}"
|
||||
)
|
||||
return [interpreter, "-", *run[2:]], read_repo_text(rel, staged=True), env
|
||||
script_path = str((REPO_ROOT / rel).resolve())
|
||||
script_content = read_repo_text(rel, staged=True)
|
||||
wrapper = (
|
||||
"from pathlib import Path\n"
|
||||
"import sys\n\n"
|
||||
f"_script_path = {script_path!r}\n"
|
||||
f"_script_content = {script_content!r}\n"
|
||||
"_script_dir = str(Path(_script_path).parent)\n"
|
||||
"if _script_dir not in sys.path:\n"
|
||||
" sys.path.insert(0, _script_dir)\n"
|
||||
"sys.argv = [_script_path, *sys.argv[1:]]\n"
|
||||
"_globals = {\n"
|
||||
" '__name__': '__main__',\n"
|
||||
" '__file__': _script_path,\n"
|
||||
" '__package__': None,\n"
|
||||
" '__cached__': None,\n"
|
||||
" '__spec__': None,\n"
|
||||
"}\n"
|
||||
"exec(compile(_script_content, _script_path, 'exec'), _globals)\n"
|
||||
)
|
||||
return [interpreter, "-", *run[2:]], wrapper, env
|
||||
|
||||
|
||||
def run_selected_proof_commands(commands: list[dict[str, Any]], *, staged: bool = False) -> int:
|
||||
|
|
|
|||
|
|
@ -292,6 +292,53 @@ class ReadinessAssertionGuardTest(unittest.TestCase):
|
|||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual((repo_root / "proof.out").read_text(encoding="utf-8"), "ok")
|
||||
|
||||
def test_staged_repo_python_script_preserves_file_and_argv_identity(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo_root = Path(tmpdir)
|
||||
self.git(repo_root, "init")
|
||||
|
||||
script_rel = "scripts/release_control/proof_script.py"
|
||||
script_path = repo_root / script_rel
|
||||
script_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
script_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"from pathlib import Path",
|
||||
"import sys",
|
||||
"",
|
||||
"Path('proof.out').write_text(",
|
||||
" f'{Path(__file__).resolve()}\\n{sys.argv[0]}\\n{sys.argv[1]}',",
|
||||
" encoding='utf-8',",
|
||||
")",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.git(repo_root, "add", script_rel)
|
||||
|
||||
script_path.write_text("raise SystemExit(1)\n", encoding="utf-8")
|
||||
|
||||
commands = [
|
||||
{
|
||||
"assertion_id": "RA8",
|
||||
"command_id": "staged-script",
|
||||
"cwd": ".",
|
||||
"run": ["python3", script_rel, "--flag"],
|
||||
}
|
||||
]
|
||||
|
||||
with mock.patch.object(readiness_assertion_guard, "REPO_ROOT", repo_root), mock.patch(
|
||||
"repo_file_io.REPO_ROOT", repo_root
|
||||
):
|
||||
exit_code = readiness_assertion_guard.run_selected_proof_commands(commands, staged=True)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(
|
||||
(repo_root / "proof.out").read_text(encoding="utf-8").splitlines(),
|
||||
[str(script_path.resolve()), str(script_path.resolve()), "--flag"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
import { withExclusiveLock } from '../../../scripts/exclusive-lock.mjs';
|
||||
|
|
@ -155,6 +156,9 @@ export function buildManagedLocalBackendEnv(state, env = process.env) {
|
|||
PULSE_PUBLIC_URL: state.baseURL,
|
||||
PULSE_DATA_DIR: state.dataDir,
|
||||
PULSE_AUDIT_DIR: state.dataDir,
|
||||
PULSE_AUDIT_SIGNING_KEY: trim(env.PULSE_AUDIT_SIGNING_KEY)
|
||||
|| trim(env.PULSE_E2E_AUDIT_SIGNING_KEY)
|
||||
|| randomBytes(32).toString('hex'),
|
||||
PULSE_METRICS_PORT: state.metricsPort,
|
||||
PULSE_DEV: 'true',
|
||||
PULSE_E2E_BILLING_STATE_PATH: state.billingStatePath,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ test('buildManagedLocalBackendEnv seeds auth, bootstrap token, and billing path'
|
|||
assert.equal(env.PULSE_DEV, 'true');
|
||||
assert.equal(env.PULSE_DATA_DIR, state.dataDir);
|
||||
assert.equal(env.PULSE_E2E_BILLING_STATE_PATH, state.billingStatePath);
|
||||
assert.match(env.PULSE_AUDIT_SIGNING_KEY, /^[0-9a-f]{64}$/);
|
||||
assert.equal(env.PULSE_E2E_BOOTSTRAP_TOKEN.length > 0, true);
|
||||
assert.equal(env.PULSE_E2E_PRIMARY_API_TOKEN.length > 0, true);
|
||||
assert.equal(env.PULSE_METRICS_PORT, '0');
|
||||
|
|
@ -79,6 +80,28 @@ test('buildManagedLocalBackendEnv seeds auth, bootstrap token, and billing path'
|
|||
assert.match(env.ALLOWED_ORIGINS, /5173/);
|
||||
});
|
||||
|
||||
test('buildManagedLocalBackendEnv preserves explicit audit signing key', () => {
|
||||
const state = buildManagedLocalBackendState({
|
||||
PULSE_E2E_LOCAL_BACKEND_PORT: '9002',
|
||||
});
|
||||
const env = buildManagedLocalBackendEnv(state, {
|
||||
PULSE_AUDIT_SIGNING_KEY: 'explicit-audit-key',
|
||||
});
|
||||
|
||||
assert.equal(env.PULSE_AUDIT_SIGNING_KEY, 'explicit-audit-key');
|
||||
});
|
||||
|
||||
test('buildManagedLocalBackendEnv accepts deterministic e2e audit signing key', () => {
|
||||
const state = buildManagedLocalBackendState({
|
||||
PULSE_E2E_LOCAL_BACKEND_PORT: '9002',
|
||||
});
|
||||
const env = buildManagedLocalBackendEnv(state, {
|
||||
PULSE_E2E_AUDIT_SIGNING_KEY: 'e2e-audit-key',
|
||||
});
|
||||
|
||||
assert.equal(env.PULSE_AUDIT_SIGNING_KEY, 'e2e-audit-key');
|
||||
});
|
||||
|
||||
test('buildManagedLocalBackendEnv seeds deterministic auth in hosted mode', () => {
|
||||
const state = buildManagedLocalBackendState({
|
||||
PULSE_E2E_LOCAL_BACKEND_PORT: '9002',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { apiRequest, ensureAuthenticated } from "./helpers";
|
||||
import { PATROL_ROUTE } from "./routes";
|
||||
|
||||
/**
|
||||
* V6 License Activation E2E Test
|
||||
|
|
@ -298,7 +299,7 @@ test.describe.serial("V6 license activation flow", () => {
|
|||
).toBeVisible();
|
||||
await expect(page.getByText(/Patrol quickstart/i)).toHaveCount(0);
|
||||
|
||||
await page.goto("/ai");
|
||||
await page.goto(PATROL_ROUTE);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Patrol" }).first(),
|
||||
).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { test as base, expect } from "@playwright/test";
|
||||
|
||||
import { createAuthenticatedStorageState } from "./helpers";
|
||||
import { PATROL_ROUTE } from "./routes";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -321,7 +322,7 @@ test.describe("TrueNAS patrol finding links", () => {
|
|||
});
|
||||
});
|
||||
|
||||
await page.goto("/ai", { waitUntil: "domcontentloaded" });
|
||||
await page.goto(PATROL_ROUTE, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { test as base, expect } from "@playwright/test";
|
||||
|
||||
import { createAuthenticatedStorageState } from "./helpers";
|
||||
import { PATROL_ROUTE } from "./routes";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -252,7 +253,7 @@ test.describe("TrueNAS patrol run history", () => {
|
|||
});
|
||||
});
|
||||
|
||||
await page.goto("/ai", { waitUntil: "domcontentloaded" });
|
||||
await page.goto(PATROL_ROUTE, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Runs" }).click();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { ensureAuthenticated } from "./helpers";
|
||||
import { PATROL_ROUTE } from "./routes";
|
||||
|
||||
type AISettingsPayload = {
|
||||
enabled: boolean;
|
||||
|
|
@ -379,7 +380,7 @@ test.describe("Retired quickstart browser contract", () => {
|
|||
},
|
||||
});
|
||||
|
||||
await page.goto("/ai", { waitUntil: "domcontentloaded" });
|
||||
await page.goto(PATROL_ROUTE, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByText("Patrol Paused").first()).toBeVisible();
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ test.describe("Assistant & Patrol settings provider setup", () => {
|
|||
expect(updateRequests[0]).not.toHaveProperty("model");
|
||||
|
||||
await expect(
|
||||
page.getByText("Assistant & Patrol Model Overrides"),
|
||||
page.getByRole("button", { name: "Model Overrides" }),
|
||||
).toBeVisible();
|
||||
const workloadDiscoveryToggle = page.getByRole("button", {
|
||||
name: /workload discovery/i,
|
||||
|
|
@ -179,10 +179,7 @@ test.describe("Assistant & Patrol settings provider setup", () => {
|
|||
await workloadDiscoveryToggle.click();
|
||||
await expect(page.getByText("Enable workload discovery")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Workload discovery gives Pulse Assistant and Patrol concrete service context, so chat responses and verification findings can reference real services and commands instead of generic advice.",
|
||||
{ exact: true },
|
||||
),
|
||||
page.getByRole("button", { name: "Run discovery now" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /enable assistant and patrol/i }),
|
||||
|
|
|
|||
|
|
@ -86,9 +86,7 @@ test.describe.serial('Self-hosted paid prompt visibility', () => {
|
|||
|
||||
await page.goto('/settings/security-roles');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Infrastructure', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Roles' })).toBeVisible();
|
||||
await expect(page.getByText('Custom Roles (Pro)')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Remote Access' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Roles' })).toHaveCount(0);
|
||||
|
|
|
|||
1
tests/integration/tests/routes.ts
Normal file
1
tests/integration/tests/routes.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const PATROL_ROUTE = "/patrol";
|
||||
Loading…
Add table
Add a link
Reference in a new issue