Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
a0a4e2f
feat: redesign the reports side panel on the V2 endpoint
yuriy-vasilyev Jun 17, 2026
4c615d2
initial tsv exports
rubinaga Jun 9, 2026
2d3f4ba
refine
rubinaga Jun 16, 2026
1530d37
add UI fixes
rubinaga Jun 17, 2026
bc57fc2
improve tests
rubinaga Jun 18, 2026
a81ddb6
add changeset
rubinaga Jun 18, 2026
c077098
copilot fixes
rubinaga Jun 18, 2026
aaf0eda
add more copilot fixes
rubinaga Jun 19, 2026
8e1dcb3
add retry button
rubinaga Jun 24, 2026
fc590c8
prettier fixes
rubinaga Jun 24, 2026
d8993cc
change export id to number
rubinaga Jun 24, 2026
594067b
fix eslint
rubinaga Jun 24, 2026
3d084c6
Merge branch 'main' into feat/reports-tsv-export
marqode Jun 24, 2026
33b0929
Merge branch 'main' into feat/reports-tsv-export
marqode Jun 24, 2026
14ea3c9
merge in tsv-exports
marqode Jun 24, 2026
a8f9c1f
add tsv export form and mock handler for reports
marqode Jun 24, 2026
edb8148
add tests for report export
marqode Jun 24, 2026
ef0e2c1
add changeset
marqode Jun 24, 2026
c707b3a
run prettier, fix lint error
marqode Jun 24, 2026
c49d5e2
integrate changes from other feature branches, add tests
marqode Jun 24, 2026
48186d9
remove extra changeset
marqode Jun 24, 2026
ed390b2
Apply suggestions from code review
marqode Jun 24, 2026
a79cbdf
add Other tooltip to form, close side panel on export download
marqode Jun 24, 2026
1adc3aa
remove dead code
marqode Jun 24, 2026
d2bc0ac
Apply suggestions from code review
marqode Jun 24, 2026
81d87e6
change progressbar design
rubinaga Jun 25, 2026
e4866d8
prettier fixes
rubinaga Jun 25, 2026
14fe183
add copilot suggestions
rubinaga Jun 25, 2026
811fb23
update by_cve report export format
marqode Jun 25, 2026
a6a5d99
update form and detail view with description field, fields for by_cve…
marqode Jun 26, 2026
6490fb2
merge feature/tsv-exports
marqode Jun 26, 2026
8f2da0e
Apply suggestions from code review
marqode Jun 26, 2026
d54bbed
merge main, fix failing test
marqode Jun 26, 2026
0442bbf
update description field
marqode Jun 26, 2026
1fb0957
fix failing tests
marqode Jun 26, 2026
5f40b9e
run prettier
marqode Jun 26, 2026
29ace65
Apply suggestions from code review
marqode Jun 26, 2026
3beae26
update exportsList label and tests
marqode Jun 26, 2026
5565745
update sample description
marqode Jun 26, 2026
ec88683
add cve fields to default selection for instance compliance reports
marqode Jun 29, 2026
7d2759b
Merge branch 'main' into feat/reports-tsv-export
marqode Jun 29, 2026
c448b7b
display primary identity group before compliance in report export fields
marqode Jun 29, 2026
54837eb
merge in field updates, fix tests
marqode Jun 29, 2026
acbb008
restore empty changeset from main
marqode Jun 30, 2026
ab9f51d
fix failing test
marqode Jun 30, 2026
43a7e4a
fix: pagination for mirrors (#686)
marqode Jun 30, 2026
87552f2
feat: restore LRO Mirrors and Publications onto main (#693)
yurii-vasyliev Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reports-side-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"landscape-ui": minor
---

Redesign the instances reports side panel on a single V2 `computers/report` endpoint, with deep-linked counts and a working CSV download. Gated behind the `instance-reports` feature flag (on by default).
Comment thread
marqode marked this conversation as resolved.
Outdated
5 changes: 5 additions & 0 deletions .changeset/ripe-planets-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"landscape-ui": minor
---

Add tsv export feature using v2 endpoint for instance reports
1 change: 0 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ VITE_API_URL_OLD=
VITE_API_URL_DEB_ARCHIVE=
VITE_ROOT_PATH=
VITE_SELF_HOSTED_ENV=
VITE_REPORT_VIEW_ENABLED=
VITE_DETAILED_UPGRADES_VIEW_ENABLED=
VITE_MSW_ENABLED=
VITE_MSW_ENDPOINTS_TO_INTERCEPT=
Expand Down
1 change: 0 additions & 1 deletion .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ VITE_API_URL=/api/v2/
VITE_API_URL_OLD=/api/
VITE_API_URL_DEB_ARCHIVE=/debarchive/v1beta1/
VITE_ROOT_PATH=/new_dashboard/
VITE_REPORT_VIEW_ENABLED=false
VITE_DETAILED_UPGRADES_VIEW_ENABLED=false
VITE_MSW_ENABLED=false
VITE_MSW_ENDPOINTS_TO_INTERCEPT=
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dist-ssr
.venv
.vite-node
coverage
reports
/reports
src/**/*.module.scss.d.ts

# Cache
Expand Down
2 changes: 0 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ export const NOT_AVAILABLE = "N/A";
export const APP_VERSION = import.meta.env.VITE_APP_VERSION;
export const APP_COMMIT = import.meta.env.VITE_APP_COMMIT;
export const FEEDBACK_LINK = "https://bugs.launchpad.net/landscape";
export const REPORT_VIEW_ENABLED =
import.meta.env.VITE_REPORT_VIEW_ENABLED === "true";
export const CONTACT_SUPPORT_TEAM_MESSAGE =
"Something went wrong. Please try again or contact our support team.";
export const DETAILED_UPGRADES_VIEW_ENABLED =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const useCreateStandaloneAccount = () => {
const axiosInstance = axios.create({ baseURL: API_URL });

const { isPending, mutateAsync } = useMutation<
AxiosResponse<unknown>,
AxiosResponse<void>,
AxiosError<ApiError>,
CreateStandaloneAccountParams
>({
Expand Down
1 change: 1 addition & 0 deletions src/features/activities/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { default as useCancelActivities } from "./useCancelActivities";
export { default as useGetActivityTypes } from "./useGetActivityTypes";
export { default as useGetSingleActivity } from "./useGetSingleActivity";
export { default as useRedoActivities } from "./useRedoActivities";
export { useExportActivitiesTsv } from "./useExportActivitiesTsv";
36 changes: 36 additions & 0 deletions src/features/activities/api/useExportActivitiesTsv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import useFetch from "@/hooks/useFetch";
import type { ApiError } from "@/types/api/ApiError";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError, AxiosResponse } from "axios";
import type { ActivitiesExportJob } from "../types/ActivitiesExportJob";

interface CreateActivitiesExportJobParams {
readonly name: string;
readonly query: string;
readonly selected_field_ids: string[];
readonly retain_until: string;
}

export const useExportActivitiesTsv = () => {
const authFetch = useFetch();
const queryClient = useQueryClient();

const { isPending, mutateAsync } = useMutation<
AxiosResponse<ActivitiesExportJob>,
AxiosError<ApiError>,
CreateActivitiesExportJobParams
>({
mutationFn: async (params) =>
authFetch.post<ActivitiesExportJob>("activities/exports", params),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["all-export-jobs"],
});
},
});

return {
exportActivitiesTsv: mutateAsync,
isExportActivitiesTsvLoading: isPending,
};
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
@import "vanilla-framework/scss/settings_spacing";
@import "vanilla-framework/scss/settings_colors";

.description {
width: 40%;
}

.subhead {
background: $colors--theme--background-alt;
border-bottom: 1px solid $colors--theme--border-low-contrast;
display: flex;
gap: $sph--large;
padding: $spv--small 0 $spv--small 2.5rem;

> .buttons {
display: flex;
gap: $sph--large;

> :not(:last-child) {
border-right: 1px solid $colors--theme--border-low-contrast;
padding-right: $sph--large !important;
}
}
}
93 changes: 85 additions & 8 deletions src/features/activities/components/Activities/Activities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ interface ActivitiesProps {
readonly selectedActivities: ActivityCommon[];
readonly setSelectedActivities: (activities: ActivityCommon[]) => void;
readonly instanceId?: number;
readonly isAllSelected?: boolean;
readonly onSelectAll?: () => void;
readonly onClearSelection?: () => void;
}

const Activities: FC<ActivitiesProps> = ({
Expand All @@ -36,6 +39,9 @@ const Activities: FC<ActivitiesProps> = ({
isGettingActivities,
selectedActivities,
setSelectedActivities,
isAllSelected = false,
onSelectAll,
onClearSelection,
}) => {
const handleActivityDetailsOpen = useOpenActivityDetailsPanel();

Expand All @@ -44,15 +50,33 @@ const Activities: FC<ActivitiesProps> = ({
useOpenActivityDetails(handleActivityDetailsOpen);

const handleClearSelection = useCallback(() => {
setSelectedActivities([]);
}, [setSelectedActivities]);
if (onClearSelection) {
onClearSelection();
} else {
setSelectedActivities([]);
}
}, [setSelectedActivities, onClearSelection]);

const toggleAll = useCallback(() => {
setSelectedActivities(selectedActivities.length !== 0 ? [] : activities);
}, [activities, selectedActivities, setSelectedActivities]);
if (isAllSelected || selectedActivities.length !== 0) {
handleClearSelection();
} else {
setSelectedActivities(activities);
}
}, [
activities,
isAllSelected,
selectedActivities,
setSelectedActivities,
handleClearSelection,
]);

const handleToggleActivity = useCallback(
(activity: ActivityCommon) => {
if (isAllSelected) {
handleClearSelection();
return;
}
setSelectedActivities(
selectedActivities.includes(activity)
? selectedActivities.filter(
Expand All @@ -61,7 +85,12 @@ const Activities: FC<ActivitiesProps> = ({
: [...selectedActivities, activity],
);
},
[selectedActivities, setSelectedActivities],
[
isAllSelected,
selectedActivities,
setSelectedActivities,
handleClearSelection,
],
);

const columns = useMemo<Column<ActivityCommon>[]>(
Expand All @@ -77,10 +106,12 @@ const Activities: FC<ActivitiesProps> = ({
inline
onChange={toggleAll}
checked={
activities.length > 0 &&
selectedActivities.length === activities.length
isAllSelected ||
(activities.length > 0 &&
selectedActivities.length === activities.length)
}
indeterminate={
!isAllSelected &&
selectedActivities.length > 0 &&
selectedActivities.length < activities.length
}
Expand All @@ -97,7 +128,9 @@ const Activities: FC<ActivitiesProps> = ({
}
inline
labelClassName="u-no-margin--bottom u-no-padding--top"
checked={selectedActivities.includes(row.original)}
checked={
isAllSelected || selectedActivities.includes(row.original)
}
onChange={() => {
handleToggleActivity(row.original);
}}
Expand Down Expand Up @@ -167,6 +200,7 @@ const Activities: FC<ActivitiesProps> = ({
].filter((col) => !instanceId || col.accessor !== "computer_id"),
[
activities,
isAllSelected,
selectedActivities,
toggleAll,
handleToggleActivity,
Expand All @@ -176,11 +210,53 @@ const Activities: FC<ActivitiesProps> = ({
],
);

const showSubhead =
onSelectAll &&
(isAllSelected || selectedActivities.length > 0) &&
activityCount !== undefined &&
activityCount > activities.length;

const subhead = showSubhead ? (
<tr>
<td colSpan={columns.length} className="u-no-padding">
<div className={classes.subhead}>
<span>
{isAllSelected
? `All ${activityCount} activities selected`
: `${selectedActivities.length} of ${activityCount} activities selected`}
</span>
<div className={classes.buttons}>
{!isAllSelected && onSelectAll && (
<Button
className="u-no-padding u-no-margin"
appearance="link"
onClick={onSelectAll}
>
Select all {activityCount} activities
</Button>
)}
{onClearSelection && (
<Button
className="u-no-padding u-no-margin"
appearance="link"
onClick={onClearSelection}
>
Clear selection
</Button>
)}
</div>
</div>
</td>
</tr>
) : undefined;

return (
<>
<ActivitiesHeader
resetSelectedIds={handleClearSelection}
selected={selectedActivities}
activityCount={activityCount}
isAllSelected={isAllSelected}
/>
{isGettingActivities ? (
<LoadingState />
Expand All @@ -190,6 +266,7 @@ const Activities: FC<ActivitiesProps> = ({
columns={columns}
data={activities}
minWidth={1150}
subhead={subhead}
/>
)}
<TablePagination
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,79 @@
import type { FC } from "react";
import LoadingState from "@/components/layout/LoadingState";
import useDebug from "@/hooks/useDebug";
import useNotify from "@/hooks/useNotify";
import usePageParams from "@/hooks/usePageParams";
import useSidePanel from "@/hooks/useSidePanel";
import { Button, ConfirmationButton } from "@canonical/react-components";
import { lazy, Suspense, type FC } from "react";
import type { ActivityCommon } from "../../types";
import { pluralize } from "@/utils/_helpers";
import { ConfirmationButton } from "@canonical/react-components";
import { getExportTitle } from "./helpers";
import {
useApproveActivities,
useCancelActivities,
useRedoActivities,
} from "../../api";

const ActivitiesExportForm = lazy(
async () => import("../ActivitiesExportForm"),
);

interface ActivitiesActionsProps {
readonly selected: ActivityCommon[];
readonly activityCount?: number;
readonly isAllSelected?: boolean;
readonly exportBaseQuery?: string;
}

const ActivitiesActions: FC<ActivitiesActionsProps> = ({ selected }) => {
const ActivitiesActions: FC<ActivitiesActionsProps> = ({
selected,
activityCount,
isAllSelected = false,
exportBaseQuery = "",
}) => {
const { notify } = useNotify();
const debug = useDebug();
const { setSidePanelContent } = useSidePanel();
const { query, search, status, fromDate, toDate, type } = usePageParams();
const { approveActivities, isApprovingActivities } = useApproveActivities();
const { cancelActivities, isCancelingActivities } = useCancelActivities();
const { redoActivities, isRedoingActivities } = useRedoActivities();

const selectedIds = selected.map((activity) => activity.id);

const exportQuery = [
exportBaseQuery,
search,
query,
status ? `status:${status}` : "",
fromDate ? `created-after:${fromDate}` : "",
toDate ? `created-before:${toDate}` : "",
type ? `type:${type}` : "",
]
.filter(Boolean)
.join(" ");

const title = pluralize(selected.length, ["activity", "activities"], "exact");

const handleExport = () => {
setSidePanelContent(
getExportTitle({
isAllSelected,
selectedCount: selected.length,
activityCount,
}),
<Suspense fallback={<LoadingState />}>
<ActivitiesExportForm
exportParams={{ query: exportQuery }}
selectedActivityIds={
!isAllSelected && selected.length > 0 ? selectedIds : undefined
}
/>
</Suspense>,
"medium",
);
};

const handleApproveActivities = async () => {
try {
await approveActivities({ query: `id:${selectedIds.join(" OR id:")}` });
Expand Down Expand Up @@ -66,6 +116,14 @@ const ActivitiesActions: FC<ActivitiesActionsProps> = ({ selected }) => {
return (
<div key="buttons" className="p-segmented-control">
<div className="p-segmented-control__list">
<Button
className="p-segmented-control__button"
type="button"
disabled={!isAllSelected && selected.length === 0}
onClick={handleExport}
>
<span>Export selection as TSV</span>
</Button>
<ConfirmationButton
className="p-segmented-control__button"
type="button"
Expand Down
Loading
Loading