diff --git a/dashboard/e2e/hardware-listing.spec.ts b/dashboard/e2e/hardware-listing.spec.ts index d6c6746a1..72f115d5e 100644 --- a/dashboard/e2e/hardware-listing.spec.ts +++ b/dashboard/e2e/hardware-listing.spec.ts @@ -59,6 +59,9 @@ test.describe('Hardware Listing Page Tests', () => { await expect(page).toHaveTitle(/KernelCI/); await expect(page).toHaveURL(/\/hardware/); await expect(page.locator(HARDWARE_LISTING_SELECTORS.table)).toBeVisible(); + await expect( + page.getByRole('button', { name: 'CLI command' }), + ).toBeInViewport(); }); test('selecting a tree auto-fills branch and revision', async ({ page }) => { diff --git a/dashboard/e2e/issue-listing.spec.ts b/dashboard/e2e/issue-listing.spec.ts index 31e797d04..c2f492663 100644 --- a/dashboard/e2e/issue-listing.spec.ts +++ b/dashboard/e2e/issue-listing.spec.ts @@ -33,6 +33,9 @@ test.describe('Issue Listing Page Tests', () => { await expect( page.locator(ISSUE_LISTING_SELECTORS.originColumnHeader), ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'CLI command' }), + ).toBeInViewport(); }); test('date range inputs are visible with default values', async ({ diff --git a/dashboard/e2e/tree-compare.spec.ts b/dashboard/e2e/tree-compare.spec.ts index 914c5f249..6be3d7ec4 100644 --- a/dashboard/e2e/tree-compare.spec.ts +++ b/dashboard/e2e/tree-compare.spec.ts @@ -105,4 +105,15 @@ test('loads revisions and comparison data from the API', async ({ page }) => { ).toBeVisible(); await expect(page.getByText('defconfig+allmodconfig')).toBeVisible(); await expect(page.getByText('Regression')).toBeVisible(); + + await expect( + page.getByRole('button', { name: 'CLI command' }), + ).toBeInViewport(); + await page.getByRole('button', { name: 'CLI command' }).click(); + const command = page.getByLabel('Tree comparison'); + await expect(command).toContainText('--giturl'); + await expect(command).not.toContainText('--git-url'); + await expect(command).toContainText('--origin maestro'); + await expect(command).toContainText('--branch master'); + await expect(command).toContainText(`${HASH_A} ${HASH_B}`); }); diff --git a/dashboard/e2e/tree-listing.spec.ts b/dashboard/e2e/tree-listing.spec.ts index 2b524299c..3f71e15a8 100644 --- a/dashboard/e2e/tree-listing.spec.ts +++ b/dashboard/e2e/tree-listing.spec.ts @@ -38,6 +38,9 @@ test.describe('Tree Listing Page Tests', () => { await expect( page.locator(TREE_LISTING_SELECTORS.branchColumnHeader), ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'CLI command' }), + ).toBeInViewport(); }); test('change table size', async ({ page }) => { diff --git a/dashboard/src/components/BuildDetails/BuildDetails.tsx b/dashboard/src/components/BuildDetails/BuildDetails.tsx index 6dc472a8c..f1866e8fe 100644 --- a/dashboard/src/components/BuildDetails/BuildDetails.tsx +++ b/dashboard/src/components/BuildDetails/BuildDetails.tsx @@ -49,7 +49,8 @@ import ButtonOpenLogSheet from '@/components/Button/ButtonOpenLogSheet'; import { processLogData } from '@/hooks/useLogData'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { createResultDetailsCommand } from '@/components/Footer/kcidevCommand'; import { TreeDetailsLink } from '@/components/TreeDetailsLink/TreeDetailsLink'; @@ -303,14 +304,8 @@ const BuildDetails = ({ const kcidevComponent = useMemo( () => ( - ), [buildId], @@ -338,6 +333,9 @@ const BuildDetails = ({
{breadcrumb} +
+ {kcidevComponent} +
)} {filesSection && } - {kcidevComponent}
= { + title: 'Components/Footer/KcidevCommandButton', + component: MemoizedKcidevCommandButton, + decorators: [ + (story): JSX.Element => ( + + {story()} + + ), + ], + args: { + command: { + id: 'trees', + label: 'Tree listing', + argv: [ + 'kci-dev', + 'results', + 'trees', + '--origin', + 'unsafe origin', + '--days', + '7', + ], + omittedFilters: ['tree search'], + reproduction: 'partial', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Interaction: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const page = within(canvasElement.ownerDocument.body); + const writeText = fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const trigger = canvas.getByRole('button', { name: 'CLI command' }); + + await step( + 'opens by keyboard and exposes the complete command', + async () => { + trigger.focus(); + await userEvent.keyboard('{Enter}'); + await expect( + page.getByRole('heading', { name: 'Run this query with kci-dev' }), + ).toBeVisible(); + await expect( + page.getByLabelText('Tree listing: Human-readable'), + ).toHaveTextContent(completeCommand); + await expect(page.getByRole('note')).toHaveTextContent('tree search'); + }, + ); + + await step( + 'reports copy success only after the clipboard resolves', + async () => { + await userEvent.click( + page.getByRole('button', { name: 'Copy command: Human-readable' }), + ); + await expect(writeText).toHaveBeenCalledWith(completeCommand); + await expect(page.getByText('Copied')).toBeVisible(); + }, + ); + + await step('Escape closes the popover and returns focus', async () => { + await userEvent.keyboard('{Escape}'); + await expect(trigger).toHaveFocus(); + }); + + await step( + 'keeps the command selectable and reports clipboard errors', + async () => { + writeText.mockRejectedValueOnce(new Error('Clipboard denied')); + await userEvent.keyboard('{Enter}'); + await userEvent.click( + page.getByRole('button', { name: 'Copy command: Human-readable' }), + ); + await expect(page.getByRole('alert')).toHaveTextContent( + 'Select it above and copy it manually.', + ); + await expect( + page.getByLabelText('Tree listing: Human-readable'), + ).toHaveTextContent(completeCommand); + }, + ); + }, +}; diff --git a/dashboard/src/components/Footer/KcidevCommandButton.tsx b/dashboard/src/components/Footer/KcidevCommandButton.tsx new file mode 100644 index 000000000..4506c6f85 --- /dev/null +++ b/dashboard/src/components/Footer/KcidevCommandButton.tsx @@ -0,0 +1,149 @@ +import { memo, useMemo, useState, type JSX } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { TbTerminal2 } from 'react-icons/tb'; + +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +import { + serializeShellArgv, + type KcidevCommand, + type KcidevCommandVariant, +} from './kcidevCommand'; + +type CopyStatus = 'idle' | 'copied' | 'error'; + +const KcidevCommandButton = ({ + command, +}: { + command?: KcidevCommand | readonly KcidevCommand[]; +}): JSX.Element => { + const [copyStatus, setCopyStatus] = useState('idle'); + const commands = useMemo( + () => (command ? (Array.isArray(command) ? command : [command]) : []), + [command], + ); + const displayedCommands = useMemo( + () => + commands.flatMap(value => + ( + value.variants ?? + ([ + { id: 'human' as const, label: 'Human-readable', argv: value.argv }, + ] satisfies KcidevCommandVariant[]) + ).map((variant: KcidevCommandVariant) => ({ command: value, variant })), + ), + [commands], + ); + + const copyCommand = async (argv: readonly string[]): Promise => { + setCopyStatus('idle'); + try { + await navigator.clipboard.writeText(serializeShellArgv(argv)); + setCopyStatus('copied'); + } catch { + setCopyStatus('error'); + } + }; + + if (commands.length === 0) { + return <>; + } + + return ( +
+ { + if (open) { + setCopyStatus('idle'); + } + }} + > + + + + +

+ +

+ {displayedCommands.map(({ command: value, variant }) => ( +
+

+ {commands.length > 1 && `${value.label}: `} + {variant.label} +

+
+                {serializeShellArgv(variant.argv)}
+              
+ {value.omittedFilters.length > 0 && ( +
+ +
+ )} + +
+ ))} + +
+ {copyStatus === 'copied' && } + {copyStatus === 'error' && ( + + + + )} +
+
+
+
+ ); +}; + +export const MemoizedKcidevCommandButton = memo(KcidevCommandButton); diff --git a/dashboard/src/components/Footer/KcidevFooter.tsx b/dashboard/src/components/Footer/KcidevFooter.tsx deleted file mode 100644 index 5485ef39f..000000000 --- a/dashboard/src/components/Footer/KcidevFooter.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { memo, useMemo, type JSX } from 'react'; -import { FormattedMessage, useIntl } from 'react-intl'; - -import { TbTerminal2 } from 'react-icons/tb'; - -import { TooltipIcon } from '@/components/Icons/TooltipIcon'; - -// These types define the possible args of the command, -// including their variation (command after `kci-dev results`) -type TreeDetailsCmdFlags = { - cmdName: 'summary' | 'builds' | 'boots' | 'tests'; - 'git-url'?: string; - branch?: string; - commit: string; -}; - -type DetailsCmdFlags = { - cmdName: 'build' | 'boot' | 'test'; - id: string; - 'download-logs': boolean; - json: boolean; -}; - -type HardwareDetailsCmdFlags = { - cmdName: - | 'hardware summary' - | 'hardware builds' - | 'hardware boots' - | 'hardware tests'; - name: string; - origin: string; - json?: boolean; -}; - -type HardwareListingCmdFlags = { - cmdName: 'hardware list'; - origin: string; - json: boolean; -}; - -type TreeListingCmdFlags = { - cmdName: 'trees'; -}; - -// This map dictates which flags each commandGroup will accept -type CommandArgsMap = { - treeDetails: TreeDetailsCmdFlags; - details: DetailsCmdFlags; - hardwareDetails: HardwareDetailsCmdFlags; - hardwareListing: HardwareListingCmdFlags; - trees: TreeListingCmdFlags; - issue: never; -}; - -// This type and map will tell which arguments will be added as `command value` or `command --key value` -type PositionalArgs = { - required: string[]; - flags: string[]; -}; - -const commandArgs: { - [K in keyof CommandArgsMap]: PositionalArgs; -} = { - treeDetails: { - required: ['cmdName'], - flags: ['git-url', 'branch', 'commit'], - }, - details: { - required: ['cmdName'], - flags: ['id', 'download-logs', 'json'], - }, - hardwareDetails: { - required: ['cmdName'], - flags: ['name', 'origin', 'json'], - }, - hardwareListing: { - required: ['cmdName'], - flags: ['origin', 'json'], - }, - trees: { - required: ['cmdName'], - flags: [], - }, - issue: { - required: [], - flags: [], - }, -}; - -const BASECOMMAND = 'kci-dev results'; - -const buildCommand = ( - commandGroup: K, - args: CommandArgsMap[K], -): string | undefined => { - const parts = [BASECOMMAND]; - - for (const key in args) { - if (Object.prototype.hasOwnProperty.call(args, key)) { - const value = args[key]; - if (value) { - if (commandArgs[commandGroup]['required'].includes(key)) { - parts.push(String(value)); - } else { - if (typeof value === 'boolean' && value) { - parts.push(`--${key}`); - } else { - parts.push(`--${key} '${value}'`); - } - } - } else { - // for the purpose of the examples, if some of the values - // are missing then we don't return the command - return undefined; - } - } - } - - return parts.join(' '); -}; - -// TODO: there are better ways of passing the args, -// one of them could be changing the parameters of the component itself -// instead of passing the args inside an object, which would also help with memoization -const KcidevFooter = ({ - commandGroup, - args, -}: { - commandGroup: K; - args?: CommandArgsMap[K]; -}): JSX.Element => { - const { formatMessage } = useIntl(); - - const kcidevLink = useMemo(() => { - return ( - - {formatMessage({ id: 'global.kcidev' })} - - ); - }, [formatMessage]); - - const command = useMemo(() => { - if (!args) { - return; - } - - return buildCommand(commandGroup, args); - }, [commandGroup, args]); - - return ( -
- - - - - - - {command && ( - } - /> - )} -
- ); -}; - -export const MemoizedKcidevFooter = memo(KcidevFooter); diff --git a/dashboard/src/components/Footer/KcidevPromotion.tsx b/dashboard/src/components/Footer/KcidevPromotion.tsx new file mode 100644 index 000000000..629004107 --- /dev/null +++ b/dashboard/src/components/Footer/KcidevPromotion.tsx @@ -0,0 +1,21 @@ +import type { JSX } from 'react'; + +import { TbTerminal2 } from 'react-icons/tb'; + +import NavLink from '@/components/SideMenu/NavLink'; + +/** A documentation-area link that introduces users to the kci-dev CLI. */ +export const KcidevPromotion = ({ + onLinkClick, +}: { + onLinkClick?: () => void; +}): JSX.Element => ( + } + idIntl="footer.promotionLabel" + onClickElement={onLinkClick} + target="_blank" + /> +); diff --git a/dashboard/src/components/Footer/kcidevCommand.test.ts b/dashboard/src/components/Footer/kcidevCommand.test.ts new file mode 100644 index 000000000..afcdf3d1b --- /dev/null +++ b/dashboard/src/components/Footer/kcidevCommand.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it } from 'vitest'; + +import { daysToSeconds } from '@/utils/date'; + +import { + createHardwareListingCommand, + createHardwareResultsCommand, + createIssueDetailsCommand, + createIssueListingCommands, + createIssueListingCommandsFromState, + createResultDetailsCommand, + createTreeCompareCommand, + createTreeListingCommand, + createTreeResultsCommand, + serializeKcidevCommand, + serializeShellArgv, + translateDashboardFilters, +} from './kcidevCommand'; + +describe('kci-dev command generation', () => { + it('creates a tree listing with the selected origin and days', () => { + expect( + createTreeListingCommand({ origin: 'maestro', days: 14 })?.argv, + ).toEqual([ + 'kci-dev', + 'results', + 'trees', + '--origin', + 'maestro', + '--days', + '14', + ]); + }); + + it.each(['builds', 'boots', 'tests'] as const)( + 'creates tree %s with deterministic, supported flags', + result => { + const generated = createTreeResultsCommand(result, { + origin: 'maestro', + gitUrl: 'https://example.com/linux.git', + branch: 'main', + commit: 'abc123', + }); + expect(generated?.argv).toEqual([ + 'kci-dev', + 'results', + result, + '--origin', + 'maestro', + '--giturl', + 'https://example.com/linux.git', + '--branch', + 'main', + '--commit', + 'abc123', + ]); + expect(serializeKcidevCommand(generated!)).not.toContain('--git-url'); + }, + ); + + it.each(['build', 'boot', 'test'] as const)('creates %s details', result => { + expect(createResultDetailsCommand(result, 'result-id')?.argv).toEqual([ + 'kci-dev', + 'results', + result, + '--id', + 'result-id', + ]); + }); + + it('creates issue details only when both the id and API origin are available', () => { + expect( + createIssueDetailsCommand({ + id: 'issue-id', + origin: 'maestro', + omittedFilters: ['issue version'], + }), + ).toMatchObject({ + argv: [ + 'kci-dev', + 'results', + 'issue', + '--id', + 'issue-id', + '--origin', + 'maestro', + ], + omittedFilters: ['issue version'], + }); + expect(createIssueDetailsCommand({ id: 'issue-id' })).toBeUndefined(); + expect( + createIssueDetailsCommand({ id: 'issue-id', origin: '' }), + ).toBeUndefined(); + }); + + it('creates one labelled issue-listing command per origin', () => { + expect( + createIssueListingCommands({ + origins: ['maestro', 'test-origin'], + days: 5, + omittedFilters: ['incident', 'text search'], + }), + ).toMatchObject([ + { + id: 'issues-maestro', + label: 'Issues from maestro', + argv: [ + 'kci-dev', + 'results', + 'issues', + '--origin', + 'maestro', + '--days', + '5', + ], + omittedFilters: ['incident', 'text search'], + reproduction: 'partial', + }, + { + id: 'issues-test-origin', + label: 'Issues from test-origin', + argv: [ + 'kci-dev', + 'results', + 'issues', + '--origin', + 'test-origin', + '--days', + '5', + ], + omittedFilters: ['incident', 'text search'], + reproduction: 'partial', + }, + ]); + }); + + it('uses selected origins and converts a custom timestamp range to whole days', () => { + const generated = createIssueListingCommandsFromState({ + selectedOrigins: { maestro: false, selectedA: true, selectedB: true }, + availableOrigins: ['maestro'], + defaultDays: 5, + startTimestampInSeconds: 0, + endTimestampInSeconds: daysToSeconds(1) + 1, + hasCulpritFilter: true, + hasCategoryFilter: true, + hasIncidentFilter: true, + hasTextSearch: true, + }); + + expect(generated.map(value => value.label)).toEqual([ + 'Issues from selectedA', + 'Issues from selectedB', + ]); + expect(generated[0]?.argv).toContain('2'); + expect(generated[0]?.omittedFilters).toEqual([ + 'exact start and end boundaries', + 'issue culprit', + 'issue category', + 'incident', + 'text search', + ]); + }); + + it('falls back to API origins and five days for the default date selection', () => { + const generated = createIssueListingCommandsFromState({ + selectedOrigins: {}, + availableOrigins: ['originA', 'originB'], + defaultDays: 5, + hasCulpritFilter: false, + hasCategoryFilter: false, + hasIncidentFilter: false, + hasTextSearch: false, + }); + + expect(generated.map(value => value.argv)).toEqual([ + ['kci-dev', 'results', 'issues', '--origin', 'originA', '--days', '5'], + ['kci-dev', 'results', 'issues', '--origin', 'originB', '--days', '5'], + ]); + expect(generated[0]?.omittedFilters).toEqual([]); + }); + + it('creates a tree comparison with exactly two ordered positional hashes', () => { + const generated = createTreeCompareCommand({ + origin: 'maestro', + gitUrl: 'https://example.com/linux.git', + branch: 'main', + hashA: 'aaaaaaaa', + hashB: 'bbbbbbbb', + omittedFilters: ['status-pair filter'], + }); + expect(generated?.argv).toEqual([ + 'kci-dev', + 'results', + 'compare', + '--origin', + 'maestro', + '--giturl', + 'https://example.com/linux.git', + '--branch', + 'main', + 'aaaaaaaa', + 'bbbbbbbb', + ]); + expect(generated?.argv).not.toContain('--latest'); + expect(generated?.omittedFilters).toEqual(['status-pair filter']); + }); + + it('hides a tree comparison until all required values are available', () => { + expect( + createTreeCompareCommand({ + origin: 'maestro', + branch: 'main', + hashA: 'a', + hashB: 'b', + }), + ).toBeUndefined(); + expect( + createTreeCompareCommand({ + origin: 'maestro', + gitUrl: '', + branch: 'main', + hashA: 'a', + hashB: 'b', + }), + ).toBeUndefined(); + }); + + it('creates a hardware listing', () => { + expect(createHardwareListingCommand({ origin: 'maestro' })?.argv).toEqual([ + 'kci-dev', + 'results', + 'hardware', + 'list', + '--origin', + 'maestro', + ]); + }); + + it.each(['builds', 'boots', 'tests'] as const)( + 'creates hardware %s', + result => { + expect( + createHardwareResultsCommand(result, { + name: 'qemu', + origin: 'maestro', + })?.argv, + ).toEqual([ + 'kci-dev', + 'results', + 'hardware', + result, + '--name', + 'qemu', + '--origin', + 'maestro', + ]); + }, + ); + + it('keeps commands when optional tree values are missing', () => { + const generated = createTreeResultsCommand('builds', { + origin: 'maestro', + commit: 'abc123', + omittedFilters: ['tableFilter'], + }); + expect(generated?.argv).toEqual([ + 'kci-dev', + 'results', + 'builds', + '--origin', + 'maestro', + '--commit', + 'abc123', + ]); + expect(generated?.omittedFilters).toEqual(['tableFilter']); + }); + + it('does not create commands with missing required values', () => { + expect(createTreeListingCommand({ origin: 'maestro' })).toBeUndefined(); + expect( + createTreeResultsCommand('builds', { origin: 'maestro' }), + ).toBeUndefined(); + expect(createResultDetailsCommand('build')).toBeUndefined(); + expect(createHardwareListingCommand({})).toBeUndefined(); + expect( + createHardwareResultsCommand('builds', { origin: 'maestro' }), + ).toBeUndefined(); + }); + + it('quotes unsafe and empty shell arguments', () => { + expect( + serializeShellArgv([ + 'plain', + 'two words', + "it's", + 'stop; now', + '$(touch /tmp/no)', + 'line\nbreak', + '', + ]), + ).toBe( + `plain 'two words' 'it'"'"'s' 'stop; now' '$(touch /tmp/no)' 'line\nbreak' ''`, + ); + }); + + it('does not add JSON or log-download flags by default', () => { + const commands = [ + createResultDetailsCommand('test', 'id'), + createHardwareListingCommand({ origin: 'maestro' }), + createHardwareResultsCommand('tests', { + name: 'qemu', + origin: 'maestro', + }), + ]; + for (const generated of commands) { + expect(generated?.argv).not.toContain('--json'); + expect(generated?.argv).not.toContain('--download-logs'); + } + }); + + it.each([ + ['architectures', 'arm64', '--arch'], + ['configs', 'defconfig', '--config'], + ['compilers', 'gcc-14', '--compiler'], + ['hardware', 'qemu-arm64', '--hardware'], + ['testPaths', 'boot.login', '--test-path'], + ['bootOrigins', 'tuxsuite', '--boot-origin'], + ] as const)('maps one %s value through %s', (key, value, option) => { + expect(translateDashboardFilters({ [key]: [value] })).toEqual({ + argv: [option, value], + omittedFilters: [], + reproduction: 'exact', + }); + }); + + it.each([ + ['architectures', 'architecture (multiple values)'], + ['configs', 'config (multiple values)'], + ['compilers', 'compiler (multiple values)'], + ['hardware', 'hardware (multiple values)'], + ['testPaths', 'boot/test path (multiple values)'], + ['bootOrigins', 'boot origin (multiple values)'], + ] as const)('omits multiple %s values', (key, label) => { + expect(translateDashboardFilters({ [key]: ['one', 'two'] })).toEqual({ + argv: [], + omittedFilters: [label], + reproduction: 'partial', + }); + }); + + it('maps supported duration boundaries', () => { + expect( + translateDashboardFilters({ minDuration: 1.5, maxDuration: 20 }).argv, + ).toEqual(['--min-duration', '1.5', '--max-duration', '20']); + }); + + it('discloses unsupported filters once in deterministic order', () => { + expect( + translateDashboardFilters({ + statuses: ['PASS'], + labs: ['lab-a'], + issueAssociations: ['issue-a'], + issueCulprits: ['code'], + issueCategories: ['regression'], + issueOptions: ['resolved'], + hasExactDateBoundaries: true, + hasTextSearch: true, + hasTreeCompareStatusPairs: true, + hasHardwareDateWindow: true, + }), + ).toEqual({ + argv: [], + omittedFilters: [ + 'status', + 'lab', + 'issue association', + 'issue culprit', + 'issue category', + 'issue options', + 'exact custom date boundaries', + 'client-side text search', + 'Tree Compare status pairs', + 'hardware date window', + ], + reproduction: 'partial', + }); + }); + + it('creates safe detail variants without downloading by default', () => { + const generated = createResultDetailsCommand('boot', 'boot-id')!; + expect(generated.variants).toEqual([ + { id: 'human', label: 'Human-readable', argv: generated.argv }, + { id: 'json', label: 'JSON', argv: [...generated.argv, '--json'] }, + { + id: 'download', + label: 'Download logs (writes files)', + argv: [...generated.argv, '--download-logs'], + writesFiles: true, + }, + ]); + expect(generated.argv).not.toContain('--download-logs'); + }); + + it('uses public JSON formats for compare and gate variants', () => { + const generated = createTreeCompareCommand({ + origin: 'maestro', + gitUrl: 'https://example.com/linux.git', + branch: 'main', + hashA: 'base', + hashB: 'head', + })!; + expect(generated.variants?.map(variant => variant.argv)).toContainEqual([ + ...generated.argv, + '--format', + 'json', + ]); + expect(generated.variants?.map(variant => variant.argv)).toContainEqual([ + 'kci-dev', + 'results', + 'gate', + '--origin', + 'maestro', + '--giturl', + 'https://example.com/linux.git', + '--branch', + 'main', + '--base', + 'base', + '--head', + 'head', + ]); + }); +}); diff --git a/dashboard/src/components/Footer/kcidevCommand.ts b/dashboard/src/components/Footer/kcidevCommand.ts new file mode 100644 index 000000000..39932a882 --- /dev/null +++ b/dashboard/src/components/Footer/kcidevCommand.ts @@ -0,0 +1,469 @@ +import { daysToSeconds } from '@/utils/date'; +import type { TFilter } from '@/types/general'; + +export type KcidevCommand = { + id: string; + label: string; + argv: readonly string[]; + omittedFilters: readonly string[]; + reproduction: 'exact' | 'partial'; + variants?: readonly KcidevCommandVariant[]; +}; + +export type KcidevCommandVariant = { + id: 'human' | 'json' | 'download' | 'gate' | 'gate-json'; + label: string; + argv: readonly string[]; + writesFiles?: boolean; +}; + +export type DashboardFilters = { + architectures?: readonly string[]; + configs?: readonly string[]; + compilers?: readonly string[]; + hardware?: readonly string[]; + testPaths?: readonly string[]; + bootOrigins?: readonly string[]; + minDuration?: number; + maxDuration?: number; + statuses?: readonly string[]; + labs?: readonly string[]; + issueAssociations?: readonly string[]; + issueCulprits?: readonly string[]; + issueCategories?: readonly string[]; + issueOptions?: readonly string[]; + hasExactDateBoundaries?: boolean; + hasTextSearch?: boolean; + hasTreeCompareStatusPairs?: boolean; + hasHardwareDateWindow?: boolean; +}; + +export type TranslatedFilters = { + argv: readonly string[]; + omittedFilters: readonly string[]; + reproduction: 'exact' | 'partial'; +}; + +const singleValueMappings = [ + ['architectures', '--arch', 'architecture'], + ['configs', '--config', 'config'], + ['compilers', '--compiler', 'compiler'], + ['hardware', '--hardware', 'hardware'], + ['testPaths', '--test-path', 'boot/test path'], + ['bootOrigins', '--boot-origin', 'boot origin'], +] as const; + +/** Translate only filters whose dashboard and kci-dev handler semantics agree. */ +export const translateDashboardFilters = ( + filters: DashboardFilters, +): TranslatedFilters => { + const argv: string[] = []; + const omitted: string[] = []; + for (const [key, option, label] of singleValueMappings) { + const values = filters[key] ?? []; + if (values.length === 1) { + argv.push(option, values[0]); + } else if (values.length > 1) { + omitted.push(`${label} (multiple values)`); + } + } + if (filters.minDuration !== undefined) { + argv.push('--min-duration', String(filters.minDuration)); + } + if (filters.maxDuration !== undefined) { + argv.push('--max-duration', String(filters.maxDuration)); + } + + // kci-dev groups statuses differently from the dashboard, so this is + // deliberately disclosed rather than translated to a misleading command. + if ((filters.statuses?.length ?? 0) > 0) { + omitted.push('status'); + } + const unsupported: Array<[boolean, string]> = [ + [(filters.labs?.length ?? 0) > 0, 'lab'], + [(filters.issueAssociations?.length ?? 0) > 0, 'issue association'], + [(filters.issueCulprits?.length ?? 0) > 0, 'issue culprit'], + [(filters.issueCategories?.length ?? 0) > 0, 'issue category'], + [(filters.issueOptions?.length ?? 0) > 0, 'issue options'], + [filters.hasExactDateBoundaries === true, 'exact custom date boundaries'], + [filters.hasTextSearch === true, 'client-side text search'], + [filters.hasTreeCompareStatusPairs === true, 'Tree Compare status pairs'], + [filters.hasHardwareDateWindow === true, 'hardware date window'], + ]; + unsupported.forEach(([present, label]) => present && omitted.push(label)); + const omittedFilters = [...new Set(omitted)]; + return { + argv, + omittedFilters, + reproduction: omittedFilters.length === 0 ? 'exact' : 'partial', + }; +}; + +const enabledValues = (value: unknown): string[] => + value && typeof value === 'object' + ? Object.entries(value) + .filter(([, enabled]) => enabled === true) + .map(([key]) => key) + : []; + +export const dashboardFiltersFromDiffFilter = ( + diffFilter: TFilter, + result: 'builds' | 'boots' | 'tests', +): DashboardFilters => { + const durationPrefix = result.slice(0, -1) as 'build' | 'boot' | 'test'; + const record = diffFilter as Record; + return { + architectures: enabledValues(record.archs), + configs: enabledValues(record.configs), + compilers: enabledValues(record.compilers), + hardware: enabledValues(record.hardware), + testPaths: enabledValues( + result === 'boots' ? record.bootPath : record.testPath, + ), + bootOrigins: enabledValues(record.bootOrigin), + minDuration: record[`${durationPrefix}DurationMin`] as number | undefined, + maxDuration: record[`${durationPrefix}DurationMax`] as number | undefined, + statuses: enabledValues(record[`${durationPrefix}Status`]), + labs: enabledValues(record.labs), + issueAssociations: enabledValues(record[`${durationPrefix}Issue`]), + issueCulprits: enabledValues(record.issueCulprits), + issueCategories: enabledValues(record.issueCategories), + issueOptions: enabledValues(record.issueOptions), + }; +}; + +type CommonOptions = { + omittedFilters?: readonly string[]; + filters?: DashboardFilters; +}; + +type TreeResultsOptions = CommonOptions & { + origin?: string; + gitUrl?: string; + branch?: string; + commit?: string; +}; + +type HardwareResultsOptions = CommonOptions & { + name?: string; + origin?: string; +}; + +type IssueListingOptions = CommonOptions & { + origins: readonly string[]; + days?: number; +}; + +type IssueListingState = { + selectedOrigins: Readonly>; + availableOrigins: readonly string[]; + defaultDays: number; + startTimestampInSeconds?: number; + endTimestampInSeconds?: number; + hasCulpritFilter: boolean; + hasCategoryFilter: boolean; + hasIncidentFilter: boolean; + hasTextSearch: boolean; +}; + +type TreeCompareOptions = CommonOptions & { + origin?: string; + gitUrl?: string; + branch?: string; + hashA?: string; + hashB?: string; +}; + +const baseArgv = ['kci-dev', 'results'] as const; + +const command = ( + id: string, + label: string, + argv: readonly string[], + omittedFilters: readonly string[] = [], + variants?: readonly KcidevCommandVariant[], +): KcidevCommand => { + const result: KcidevCommand = { + id, + label, + argv, + omittedFilters: [...new Set(omittedFilters)], + reproduction: omittedFilters.length === 0 ? 'exact' : 'partial', + }; + result.variants = variants ?? standardVariants(argv); + return result; +}; + +const standardVariants = ( + argv: readonly string[], + options: { download?: boolean; formatJson?: boolean } = {}, +): KcidevCommandVariant[] => [ + { id: 'human', label: 'Human-readable', argv }, + { + id: 'json', + label: 'JSON', + argv: [ + ...argv, + ...(options.formatJson ? ['--format', 'json'] : ['--json']), + ], + }, + ...(options.download + ? ([ + { + id: 'download', + label: 'Download logs (writes files)', + argv: [...argv, '--download-logs'], + writesFiles: true, + }, + ] satisfies KcidevCommandVariant[]) + : []), +]; + +const appendOption = (argv: string[], option: string, value?: string): void => { + if (value !== undefined) { + argv.push(option, value); + } +}; + +export const createTreeListingCommand = ({ + origin, + days, + omittedFilters, +}: CommonOptions & { + origin?: string; + days?: number; +}): KcidevCommand | undefined => { + if (origin === undefined || days === undefined) { + return undefined; + } + + return command( + 'trees', + 'Tree listing', + [...baseArgv, 'trees', '--origin', origin, '--days', String(days)], + omittedFilters, + ); +}; + +export const createTreeResultsCommand = ( + result: 'builds' | 'boots' | 'tests', + { + origin, + gitUrl, + branch, + commit, + omittedFilters, + filters, + }: TreeResultsOptions, +): KcidevCommand | undefined => { + if (origin === undefined || commit === undefined) { + return undefined; + } + + const argv = [...baseArgv, result, '--origin', origin]; + appendOption(argv, '--giturl', gitUrl); + appendOption(argv, '--branch', branch); + argv.push('--commit', commit); + const translated = translateDashboardFilters(filters ?? {}); + argv.push(...translated.argv); + return command(`tree-${result}`, `Tree ${result}`, argv, [ + ...(omittedFilters ?? []), + ...translated.omittedFilters, + ]); +}; + +export const createResultDetailsCommand = ( + result: 'build' | 'boot' | 'test', + id?: string, + omittedFilters: readonly string[] = [], +): KcidevCommand | undefined => + id === undefined + ? undefined + : command( + `${result}-details`, + `${result} details`, + [...baseArgv, result, '--id', id], + omittedFilters, + standardVariants([...baseArgv, result, '--id', id], { download: true }), + ); + +export const createIssueDetailsCommand = ({ + id, + origin, + omittedFilters, +}: CommonOptions & { + id?: string; + origin?: string; +}): KcidevCommand | undefined => { + if (!id || !origin) { + return undefined; + } + + return command( + 'issue-details', + 'Issue details', + [...baseArgv, 'issue', '--id', id, '--origin', origin], + omittedFilters, + ); +}; + +export const createIssueListingCommands = ({ + origins, + days, + omittedFilters, +}: IssueListingOptions): KcidevCommand[] => { + if (days === undefined) { + return []; + } + + return origins.map(origin => + command( + `issues-${origin}`, + `Issues from ${origin}`, + [...baseArgv, 'issues', '--origin', origin, '--days', String(days)], + omittedFilters, + ), + ); +}; + +export const createIssueListingCommandsFromState = ({ + selectedOrigins, + availableOrigins, + defaultDays, + startTimestampInSeconds, + endTimestampInSeconds, + hasCulpritFilter, + hasCategoryFilter, + hasIncidentFilter, + hasTextSearch, +}: IssueListingState): KcidevCommand[] => { + const selected = Object.entries(selectedOrigins) + .filter(([, enabled]) => enabled) + .map(([origin]) => origin); + const customRange = + startTimestampInSeconds !== undefined || + endTimestampInSeconds !== undefined; + const days = + startTimestampInSeconds !== undefined && endTimestampInSeconds !== undefined + ? Math.max( + 1, + Math.ceil( + (endTimestampInSeconds - startTimestampInSeconds) / + daysToSeconds(1), + ), + ) + : defaultDays; + const omittedFilters = [ + ...(customRange ? ['exact start and end boundaries'] : []), + ...(hasCulpritFilter ? ['issue culprit'] : []), + ...(hasCategoryFilter ? ['issue category'] : []), + ...(hasIncidentFilter ? ['incident'] : []), + ...(hasTextSearch ? ['text search'] : []), + ]; + + return createIssueListingCommands({ + origins: selected.length > 0 ? selected : availableOrigins, + days, + omittedFilters, + }); +}; + +export const createTreeCompareCommand = ({ + origin, + gitUrl, + branch, + hashA, + hashB, + omittedFilters, +}: TreeCompareOptions): KcidevCommand | undefined => { + if (!origin || !gitUrl || !branch || !hashA || !hashB) { + return undefined; + } + + const argv = [ + ...baseArgv, + 'compare', + '--origin', + origin, + '--giturl', + gitUrl, + '--branch', + branch, + hashA, + hashB, + ]; + const gateArgv = [ + ...baseArgv, + 'gate', + '--origin', + origin, + '--giturl', + gitUrl, + '--branch', + branch, + '--base', + hashA, + '--head', + hashB, + ]; + return command('tree-compare', 'Tree comparison', argv, omittedFilters, [ + ...standardVariants(argv, { formatJson: true }), + { id: 'gate', label: 'CI gate', argv: gateArgv }, + { + id: 'gate-json', + label: 'CI gate (JSON)', + argv: [...gateArgv, '--format', 'json'], + }, + ]); +}; + +export const createHardwareListingCommand = ({ + origin, + omittedFilters, +}: CommonOptions & { origin?: string }): KcidevCommand | undefined => + origin === undefined + ? undefined + : command( + 'hardware-list', + 'Hardware listing', + [...baseArgv, 'hardware', 'list', '--origin', origin], + omittedFilters, + ); + +export const createHardwareResultsCommand = ( + result: 'builds' | 'boots' | 'tests', + { name, origin, omittedFilters, filters }: HardwareResultsOptions, +): KcidevCommand | undefined => { + if (name === undefined || origin === undefined) { + return undefined; + } + + const translated = translateDashboardFilters(filters ?? {}); + return command( + `hardware-${result}`, + `Hardware ${result}`, + [ + ...baseArgv, + 'hardware', + result, + '--name', + name, + '--origin', + origin, + ...translated.argv, + ], + [...(omittedFilters ?? []), ...translated.omittedFilters], + ); +}; + +const safeShellWord = /^[A-Za-z0-9_@%+=:,./-]+$/; + +export const serializeShellArgv = (argv: readonly string[]): string => + argv + .map(value => + safeShellWord.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`, + ) + .join(' '); + +export const serializeKcidevCommand = (value: KcidevCommand): string => + serializeShellArgv(value.argv); diff --git a/dashboard/src/components/IssueDetails/IssueDetails.tsx b/dashboard/src/components/IssueDetails/IssueDetails.tsx index afd0b8b2b..d116c163f 100644 --- a/dashboard/src/components/IssueDetails/IssueDetails.tsx +++ b/dashboard/src/components/IssueDetails/IssueDetails.tsx @@ -49,7 +49,8 @@ import { TooltipIcon } from '@/components/Icons/TooltipIcon'; import { Badge } from '@/components/ui/badge'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { createIssueDetailsCommand } from '@/components/Footer/kcidevCommand'; import { IncidentsSection } from './IncidentsSection'; @@ -261,6 +262,16 @@ export const IssueDetails = ({
{breadcrumb} +
+ +
-
diff --git a/dashboard/src/components/SideMenu/SideMenuContent.tsx b/dashboard/src/components/SideMenu/SideMenuContent.tsx index fbb1421cc..cb2310a8e 100644 --- a/dashboard/src/components/SideMenu/SideMenuContent.tsx +++ b/dashboard/src/components/SideMenu/SideMenuContent.tsx @@ -15,6 +15,7 @@ import { Separator } from '@/components/ui/separator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/Tooltip'; import { ExternalLinkIcon } from '@/components/Icons/ExternalLink'; +import { KcidevPromotion } from '@/components/Footer/KcidevPromotion'; import { REPO_URL } from '@/utils/constants/general'; @@ -122,6 +123,9 @@ const SideMenuContent = ({ ))} {linksItemElements} + + + diff --git a/dashboard/src/components/TestDetails/TestDetails.tsx b/dashboard/src/components/TestDetails/TestDetails.tsx index 06a372a1a..f17d48703 100644 --- a/dashboard/src/components/TestDetails/TestDetails.tsx +++ b/dashboard/src/components/TestDetails/TestDetails.tsx @@ -69,7 +69,8 @@ import { dateObjectToTimestampInSeconds, daysToSeconds } from '@/utils/date'; import { REDUCED_TIME_SEARCH } from '@/utils/constants/general'; import { isBadRequestError } from '@/utils/query'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { createResultDetailsCommand } from '@/components/Footer/kcidevCommand'; import { isBoot } from '@/utils/test'; @@ -602,14 +603,8 @@ const TestDetails = ({ breadcrumb }: TestsDetailsProps): JSX.Element => { const command = isBoot(data?.path) ? 'boot' : 'test'; return ( - ); }, [data?.path, testId]); @@ -631,6 +626,10 @@ const TestDetails = ({ breadcrumb }: TestsDetailsProps): JSX.Element => {
{breadcrumb} +
+ {kcidevComponent} +
+
{data && ( { status={issueStatus} error={issueError?.message} /> - {kcidevComponent}
{ const { data, error, status, isLoading } = useTreeListing({ searchFrom: urlFromMap.search, @@ -42,15 +47,24 @@ const TreeListingPage = ({ const kcidevComponent = useMemo( () => ( - + ), - [], + [inputFilter, intervalInDays, origin], ); return ( <>
+
+ {kcidevComponent} +
- {kcidevComponent} ); }; diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 901efe09e..8039109cd 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -103,10 +103,17 @@ export const messages = { 'filter.testStatus': 'Test Status', 'filter.treeSubtitle': 'Please select one or more Trees:', 'filter.treeURL': 'Tree URL', - 'footer.command': 'Command for this page:\n{command}', - 'footer.kcidev': - 'You can use {link} to retrieve KernelCI data from the command line', - 'footer.question': 'Did you know?', + 'footer.cliCommand': 'CLI command', + 'footer.commandDocumentation': 'Command documentation', + 'footer.commandTitle': 'Run this query with kci-dev', + 'footer.copied': 'Copied', + 'footer.copyCommand': 'Copy command', + 'footer.copyError': + 'Could not copy the command. Select it above and copy it manually.', + 'footer.installKcidev': 'Install kci-dev', + 'footer.promotionLabel': 'Install kci-dev', + 'footer.unsupportedFilters': + 'This command cannot reproduce these dashboard filters: {filters}.', 'global.allCount': 'All: {count}', 'global.arch': 'Arch', 'global.architecture': 'Architecture', diff --git a/dashboard/src/pages/Hardware/HardwareListingPage.tsx b/dashboard/src/pages/Hardware/HardwareListingPage.tsx index f5dd5af79..55bd446f8 100644 --- a/dashboard/src/pages/Hardware/HardwareListingPage.tsx +++ b/dashboard/src/pages/Hardware/HardwareListingPage.tsx @@ -19,7 +19,8 @@ import { matchesRegexOrIncludes, } from '@/lib/string'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { createHardwareListingCommand } from '@/components/Footer/kcidevCommand'; import { REDUCED_TIME_SEARCH } from '@/utils/constants/general'; import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing'; @@ -213,9 +214,8 @@ const HardwareListingPage = ({ const kcidevComponent = useMemo( () => ( - ), [origin], @@ -300,12 +300,15 @@ const HardwareListingPage = ({ <>
- - }} - /> - +
+ + }} + /> + + {kcidevComponent} +
- {kcidevComponent} ); }; diff --git a/dashboard/src/pages/IssueListing/IssueListingPage.tsx b/dashboard/src/pages/IssueListing/IssueListingPage.tsx index 3aab5bbbe..c3bdfea6f 100644 --- a/dashboard/src/pages/IssueListing/IssueListingPage.tsx +++ b/dashboard/src/pages/IssueListing/IssueListingPage.tsx @@ -17,7 +17,9 @@ import { formattedBreakLineValue } from '@/locales/messages'; import { mapFilterToReq } from '@/components/Tabs/Filters'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { createIssueListingCommandsFromState } from '@/components/Footer/kcidevCommand'; +import { REDUCED_TIME_SEARCH } from '@/utils/constants/general'; import IssueListingFilter from './IssueListingFilter'; @@ -36,6 +38,26 @@ export const IssueListingPage = ({ const updatePreviousSearch = useSearchStore(s => s.updatePreviousSearch); + const issueCommands = useMemo(() => { + return createIssueListingCommandsFromState({ + selectedOrigins: diffFilter.origins ?? {}, + availableOrigins: data?.filters.origins ?? [], + defaultDays: REDUCED_TIME_SEARCH, + startTimestampInSeconds: searchParams.startTimestampInSeconds, + endTimestampInSeconds: searchParams.endTimestampInSeconds, + hasCulpritFilter: Object.values(diffFilter.issueCulprits ?? {}).some( + Boolean, + ), + hasCategoryFilter: Object.values(diffFilter.issueCategories ?? {}).some( + Boolean, + ), + hasIncidentFilter: Object.values(diffFilter.issueOptions ?? {}).some( + Boolean, + ), + hasTextSearch: Boolean(inputFilter.trim()), + }); + }, [data?.filters.origins, diffFilter, inputFilter, searchParams]); + useEffect( () => updatePreviousSearch(searchParams), [searchParams, updatePreviousSearch], @@ -77,6 +99,7 @@ export const IssueListingPage = ({
+
- ); diff --git a/dashboard/src/pages/TreeCompare/TreeComparePage.tsx b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx index e1869b307..1132a8bbd 100644 --- a/dashboard/src/pages/TreeCompare/TreeComparePage.tsx +++ b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx @@ -21,6 +21,8 @@ import PageWithTitle from '@/components/PageWithTitle'; import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; import Tabs from '@/components/Tabs/Tabs'; import type { ITabItem } from '@/components/Tabs/Tabs'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { createTreeCompareCommand } from '@/components/Footer/kcidevCommand'; import { useCommits } from '@/api/commitHistory'; import { @@ -331,11 +333,24 @@ const TreeComparePage = (): JSX.Element => { -
-

{pageTitle}

-

- -

+
+
+

{pageTitle}

+

+ +

+
+ 0 ? ['status-pair filter'] : [], + })} + />
diff --git a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx index 9bc275961..33db3ca3b 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx @@ -36,7 +36,11 @@ import { generateDiffFilter } from '@/components/Tabs/tabsUtils'; import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; import { MemoizedFilterCard } from '@/components/Cards/FilterCard'; import { sanitizeTreeinfo } from '@/utils/treeDetails'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { + dashboardFiltersFromDiffFilter, + createTreeResultsCommand, +} from '@/components/Footer/kcidevCommand'; import { getStringParam } from '@/utils/utils'; interface BootsTabProps { @@ -52,7 +56,7 @@ const BootsTab = ({ const params = useParams({ from: urlFrom, }); - const { tableFilter, diffFilter, treeInfo } = useSearch({ + const { tableFilter, diffFilter, treeInfo, origin } = useSearch({ from: urlFrom, }); @@ -228,17 +232,19 @@ const BootsTab = ({ const kcidevComponent = useMemo( () => ( - ), [ + diffFilter, + origin, sanitizedTreeInfo.gitBranch, sanitizedTreeInfo.gitUrl, sanitizedTreeInfo.hash, @@ -339,6 +345,9 @@ const BootsTab = ({ } >
+
+ {kcidevComponent} +
- {kcidevComponent}
{isEmptySummary && ( diff --git a/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx index 916ada30e..7e9488f8f 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx @@ -47,7 +47,11 @@ import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; import { MemoizedFilterCard } from '@/components/Cards/FilterCard'; import { sanitizeTreeinfo } from '@/utils/treeDetails'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { + dashboardFiltersFromDiffFilter, + createTreeResultsCommand, +} from '@/components/Footer/kcidevCommand'; import { TreeDetailsBuildsTable } from './TreeDetailsBuildsTable'; @@ -73,7 +77,7 @@ const BuildTab = ({ }: BuildTab): JSX.Element => { const navigate = useNavigate({ from: treeDetailsFromMap[urlFrom] }); const params = useParams({ from: urlFrom }); - const { diffFilter, treeInfo } = useSearch({ + const { diffFilter, treeInfo, origin } = useSearch({ from: urlFrom, }); @@ -178,17 +182,19 @@ const BuildTab = ({ const kcidevComponent = useMemo( () => ( - ), [ + diffFilter, + origin, sanitizedTreeInfo.gitBranch, sanitizedTreeInfo.gitUrl, sanitizedTreeInfo.hash, @@ -276,6 +282,9 @@ const BuildTab = ({ } >
+
+ {kcidevComponent} +
- {kcidevComponent}
{isEmptySummary && ( diff --git a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx index 28237d0d1..0d57a7e5f 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx @@ -36,7 +36,11 @@ import { generateDiffFilter } from '@/components/Tabs/tabsUtils'; import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; import { MemoizedFilterCard } from '@/components/Cards/FilterCard'; import { sanitizeTreeinfo } from '@/utils/treeDetails'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { + dashboardFiltersFromDiffFilter, + createTreeResultsCommand, +} from '@/components/Footer/kcidevCommand'; import { getStringParam } from '@/utils/utils'; interface TestsTabProps { @@ -50,7 +54,7 @@ const TestsTab = ({ }: TestsTabProps): JSX.Element => { const navigate = useNavigate({ from: treeDetailsFromMap[urlFrom] }); const params = useParams({ from: urlFrom }); - const { tableFilter, diffFilter, treeInfo } = useSearch({ + const { tableFilter, diffFilter, treeInfo, origin } = useSearch({ from: urlFrom, }); @@ -227,17 +231,19 @@ const TestsTab = ({ const kcidevComponent = useMemo( () => ( - ), [ + diffFilter, + origin, sanitizedTreeInfo.gitBranch, sanitizedTreeInfo.gitUrl, sanitizedTreeInfo.hash, @@ -338,6 +344,9 @@ const TestsTab = ({ } >
+
+ {kcidevComponent} +
- {kcidevComponent}
{isEmptySummary && ( diff --git a/dashboard/src/pages/TreeListing/Trees.tsx b/dashboard/src/pages/TreeListing/Trees.tsx index 8d76ac498..5d4c139ab 100644 --- a/dashboard/src/pages/TreeListing/Trees.tsx +++ b/dashboard/src/pages/TreeListing/Trees.tsx @@ -12,7 +12,7 @@ const Trees = ({ }: { urlFromMap: TreeListingRoutesMap; }): JSX.Element => { - const { treeSearch } = useSearch({ + const { treeSearch, origin, intervalInDays } = useSearch({ from: urlFromMap.search, }); @@ -20,7 +20,12 @@ const Trees = ({ <>
- +
); diff --git a/dashboard/src/pages/hardwareDetails/Tabs/Boots/BootsTab.tsx b/dashboard/src/pages/hardwareDetails/Tabs/Boots/BootsTab.tsx index 97800e8a6..b3086741b 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/Boots/BootsTab.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/Boots/BootsTab.tsx @@ -31,7 +31,11 @@ import { RedirectFrom, type TFilterObjectsKeys } from '@/types/general'; import { HardwareDetailsTabsQuerySwitcher } from '@/pages/hardwareDetails/Tabs/HardwareDetailsTabsQuerySwitcher'; import { generateDiffFilter } from '@/components/Tabs/tabsUtils'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { + dashboardFiltersFromDiffFilter, + createHardwareResultsCommand, +} from '@/components/Footer/kcidevCommand'; import { MemoizedFilterCard } from '@/components/Cards/FilterCard'; @@ -132,17 +136,15 @@ const BootsTab = ({ const kcidevComponent = useMemo( () => ( - ), - [hardwareId, origin], + [diffFilter, hardwareId, origin], ); const { topCards, bodyCards, footerCards } = useMemo(() => { @@ -200,6 +202,7 @@ const BootsTab = ({ return (
+
{kcidevComponent}
- {kcidevComponent}
); }; diff --git a/dashboard/src/pages/hardwareDetails/Tabs/Build/BuildTab.tsx b/dashboard/src/pages/hardwareDetails/Tabs/Build/BuildTab.tsx index 724d1e065..da2ee6ba5 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/Build/BuildTab.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/Build/BuildTab.tsx @@ -28,7 +28,11 @@ import { HardwareDetailsTabsQuerySwitcher } from '@/pages/hardwareDetails/Tabs/H import { generateDiffFilter } from '@/components/Tabs/tabsUtils'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { + dashboardFiltersFromDiffFilter, + createHardwareResultsCommand, +} from '@/components/Footer/kcidevCommand'; import { HardwareDetailsBuildsTable } from './HardwareDetailsBuildsTable'; @@ -87,17 +91,15 @@ const BuildTab = ({ const kcidevComponent = useMemo( () => ( - ), - [hardwareId, origin], + [diffFilter, hardwareId, origin], ); const { topCards, bodyCards, footerCards } = useMemo(() => { @@ -155,6 +157,7 @@ const BuildTab = ({ return (
+
{kcidevComponent}
- {kcidevComponent}
); diff --git a/dashboard/src/pages/hardwareDetails/Tabs/Tests/TestsTab.tsx b/dashboard/src/pages/hardwareDetails/Tabs/Tests/TestsTab.tsx index 62daabbba..406f36d55 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/Tests/TestsTab.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/Tests/TestsTab.tsx @@ -30,7 +30,11 @@ import { RedirectFrom, type TFilterObjectsKeys } from '@/types/general'; import { HardwareDetailsTabsQuerySwitcher } from '@/pages/hardwareDetails/Tabs/HardwareDetailsTabsQuerySwitcher'; -import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { MemoizedKcidevCommandButton } from '@/components/Footer/KcidevCommandButton'; +import { + dashboardFiltersFromDiffFilter, + createHardwareResultsCommand, +} from '@/components/Footer/kcidevCommand'; import { MemoizedFilterCard } from '@/components/Cards/FilterCard'; @@ -117,17 +121,15 @@ const TestsTab = ({ const kcidevComponent = useMemo( () => ( - ), - [hardwareId, origin], + [diffFilter, hardwareId, origin], ); const { topCards, bodyCards, footerCards } = useMemo(() => { @@ -185,6 +187,7 @@ const TestsTab = ({ return (
+
{kcidevComponent}
- {kcidevComponent}
); };