fix(ui): pre-release fixes and improvements (#9278)

This commit is contained in:
Alejandro Bailo
2025-11-20 16:18:25 +01:00
committed by GitHub
parent 9a1ddedd94
commit 6426558b18
41 changed files with 889 additions and 674 deletions
+2
View File
@@ -8,6 +8,8 @@ import { LighthouseIcon } from "@/components/icons/Icons";
import { Chat } from "@/components/lighthouse";
import { ContentLayout } from "@/components/ui";
export const dynamic = "force-dynamic";
export default async function AIChatbot() {
const hasConfig = await isLighthouseConfigured();
@@ -48,7 +48,7 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
: [];
const selectedIds = current ? current.split(",").filter(Boolean) : [];
const visibleProviders = providers
.filter((p) => p.attributes.connection?.connected)
// .filter((p) => p.attributes.connection?.connected)
.filter((p) =>
selectedTypesList.length > 0
? selectedTypesList.includes(p.attributes.provider)
@@ -132,7 +132,7 @@ export const ProviderTypeSelector = ({
const availableTypes = Array.from(
new Set(
providers
.filter((p) => p.attributes.connection?.connected)
// .filter((p) => p.attributes.connection?.connected)
.map((p) => p.attributes.provider),
),
) as ProviderType[];
@@ -21,10 +21,10 @@ export const ThreatScoreSSR = async ({
const snapshot = threatScoreData.data[0];
const attributes = snapshot.attributes;
// Parse score from decimal string to number and round to integer
const score = Math.round(parseFloat(attributes.overall_score));
// Parse score from decimal string to number
const score = parseFloat(attributes.overall_score);
const scoreDelta = attributes.score_delta
? Math.round(parseFloat(attributes.score_delta))
? parseFloat(attributes.score_delta)
: null;
return (
@@ -66,14 +66,11 @@ function convertSectionScoresToTooltipData(
if (!sectionScores) return [];
return Object.entries(sectionScores).map(([name, value]) => {
// Round to nearest integer
const roundedValue = Math.round(value);
// Determine color based on the same ranges as THREAT_LEVEL_CONFIG
const threatLevel = getThreatLevel(roundedValue);
const threatLevel = getThreatLevel(value);
const color = THREAT_LEVEL_CONFIG[threatLevel].chartColor;
return { name, value: roundedValue, color };
return { name, value, color };
});
}
@@ -162,8 +159,12 @@ export function ThreatScore({
{scoreDelta !== undefined &&
scoreDelta !== null &&
scoreDelta !== 0 && (
<div className="flex items-center gap-1">
<ThumbsUp size={14} className="flex-shrink-0" />
<div className="flex items-start gap-1.5">
<ThumbsUp
size={16}
className="mt-0.5 shrink-0"
style={{ minWidth: "16px", minHeight: "16px" }}
/>
<p>
Threat score has{" "}
{scoreDelta > 0 ? "improved" : "decreased"} by{" "}
@@ -174,10 +175,11 @@ export function ThreatScore({
{/* Gaps Message */}
{gaps.length > 0 && (
<div className="flex items-start gap-1">
<div className="flex items-start gap-1.5">
<MessageCircleWarning
size={14}
className="mt-1 flex-shrink-0"
size={16}
className="mt-0.5 shrink-0"
style={{ minWidth: "16px", minHeight: "16px" }}
/>
<p>
Major gaps include {gaps.slice(0, 2).join(", ")}
+14 -8
View File
@@ -10,18 +10,24 @@ interface AuthLayoutProps {
export const AuthLayout = ({ title, children }: AuthLayoutProps) => {
return (
<div className="relative flex h-screen w-screen">
<div className="relative flex w-full items-center justify-center lg:w-full">
<div className="relative flex min-h-screen w-full overflow-x-hidden overflow-y-auto">
<div className="relative flex w-full flex-col items-center justify-center px-4 py-32">
{/* Background Pattern */}
<div className="absolute h-full w-full bg-[radial-gradient(#6af400_1px,transparent_1px)] mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_10%,transparent_80%)] bg-size-[16px_16px]"></div>
<div
className="absolute inset-0 mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_10%,transparent_80%)] bg-size-[16px_16px]"
style={{
backgroundImage:
"radial-gradient(var(--bg-button-primary) 1px, transparent 1px)",
}}
></div>
{/* Prowler Logo */}
<div className="relative z-10 mb-8 flex w-full max-w-[300px]">
<ProwlerExtended width={300} className="h-auto w-full" />
</div>
{/* Auth Form Container */}
<div className="rounded-large border-divider shadow-small dark:bg-background/85 relative z-10 flex w-full max-w-sm flex-col gap-4 border bg-white/90 px-8 py-10 md:max-w-md">
{/* Prowler Logo */}
<div className="absolute -top-[100px] left-1/2 z-10 flex h-fit w-fit -translate-x-1/2">
<ProwlerExtended width={300} />
</div>
{/* Header with Title and Theme Toggle */}
<div className="flex items-center justify-between">
<p className="pb-2 text-xl font-medium">{title}</p>
+7 -11
View File
@@ -58,21 +58,17 @@ export const PasswordRequirementsMessage = ({
return (
<div className={className}>
<div
className={`rounded-xl border p-3 ${
allRequirementsMet
? "border-system-success bg-system-success/10"
: "border-red-200 bg-red-50"
}`}
className={`bg-bg-neutral-primary rounded-xl border p-3 ${allRequirementsMet ? "border-bg-pass" : "border-bg-fail"}`}
role="region"
aria-label="Password requirements status"
>
{allRequirementsMet ? (
<div className="flex items-center gap-2">
<CheckCircle
className="text-system-success h-4 w-4 shrink-0"
className="text-text-success h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-system-success text-xs leading-tight font-medium">
<p className="text-text-neutral-primary text-xs leading-tight font-medium">
Password meets all requirements
</p>
</div>
@@ -80,10 +76,10 @@ export const PasswordRequirementsMessage = ({
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<AlertCircle
className="h-4 w-4 shrink-0 text-red-600"
className="text-text-error h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-xs leading-tight font-medium text-red-700">
<p className="text-text-neutral-primary text-xs leading-tight font-medium">
Password must include:
</p>
</div>
@@ -99,12 +95,12 @@ export const PasswordRequirementsMessage = ({
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 shrink-0 rounded-full ${
req.isMet ? "bg-system-success" : "bg-red-400"
req.isMet ? "bg-text-success" : "bg-text-error"
}`}
aria-hidden="true"
/>
<span
className={`${req.isMet ? "text-system-success" : "text-red-700"}`}
className="text-text-success-primary"
aria-label={`${req.label} ${req.isMet ? "satisfied" : "required"}`}
>
{req.label}
+8 -4
View File
@@ -3,7 +3,7 @@
import { Checkbox } from "@heroui/checkbox";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import { createNewUser } from "@/actions/auth";
import { AuthDivider } from "@/components/auth/oss/auth-divider";
@@ -62,6 +62,12 @@ export const SignUpForm = ({
},
});
const passwordValue = useWatch({
control: form.control,
name: "password",
defaultValue: "",
});
const isLoading = form.formState.isSubmitting;
const onSubmit = async (data: SignUpFormData) => {
@@ -152,9 +158,7 @@ export const SignUpForm = ({
showFormMessage
/>
<CustomInput control={form.control} name="password" password />
<PasswordRequirementsMessage
password={form.watch("password") || ""}
/>
<PasswordRequirementsMessage password={passwordValue || ""} />
<CustomInput
control={form.control}
name="confirmPassword"
@@ -2,6 +2,7 @@
import { useState } from "react";
import { Button } from "@/components/shadcn";
import { Accordion, AccordionItemProps } from "@/components/ui";
export const ClientAccordionWrapper = ({
@@ -59,12 +60,15 @@ export const ClientAccordionWrapper = ({
<div>
{!hideExpandButton && (
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary mt-[-16px] flex justify-end text-xs font-medium transition-colors">
<button
<Button
onClick={handleToggleExpand}
aria-label={isExpanded ? "Collapse all" : "Expand all"}
variant="ghost"
size="sm"
className="mb-1"
>
{isExpanded ? "Collapse all" : "Expand all"}
</button>
</Button>
</div>
)}
<Accordion
@@ -1,7 +1,7 @@
import { Divider } from "@heroui/divider";
import { Tooltip } from "@heroui/tooltip";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
import { ProviderType } from "@/types";
interface ComplianceScanInfoProps {
@@ -20,15 +20,16 @@ interface ComplianceScanInfoProps {
export const ComplianceScanInfo = ({ scan }: ComplianceScanInfoProps) => {
return (
<div className="flex items-center gap-2">
<EntityInfoShort
cloudProvider={scan.providerInfo.provider}
entityAlias={scan.providerInfo.alias}
entityId={scan.providerInfo.uid}
hideCopyButton
snippetWidth="max-w-[100px]"
/>
<Divider orientation="vertical" className="h-6" />
<div className="flex items-center gap-4">
<div className="flex shrink-0 items-center">
<EntityInfo
cloudProvider={scan.providerInfo.provider}
entityAlias={scan.providerInfo.alias}
entityId={scan.providerInfo.uid}
showCopyAction={false}
/>
</div>
<Divider orientation="vertical" className="h-8" />
<div className="flex flex-col items-start whitespace-nowrap">
<Tooltip
content={scan.attributes.name || "- -"}
@@ -115,7 +115,7 @@ export const ThreatScoreBadge = ({
<div className="flex flex-col items-end gap-1">
<span className={`text-2xl font-bold ${getTextColor()}`}>
{score.toFixed(1)}%
{score}%
</span>
<Progress
aria-label="ThreatScore progress"
+73 -84
View File
@@ -5,14 +5,7 @@ import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Selection } from "@react-types/shared";
import { Search, Send } from "lucide-react";
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -49,6 +42,15 @@ const sendToJiraSchema = z.object({
type SendToJiraFormData = z.infer<typeof sendToJiraSchema>;
const selectorClassNames = {
trigger: "min-h-12",
popoverContent: "bg-bg-neutral-secondary",
listboxWrapper: "max-h-[300px] bg-bg-neutral-secondary",
listbox: "gap-0",
label: "tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
value: "text-text-neutral-secondary text-small",
};
// The commented code is related to issue types, which are not required for the first implementation, but will be used in the future
export const SendToJiraModal = ({
isOpen,
@@ -75,9 +77,8 @@ export const SendToJiraModal = ({
const selectedIntegration = form.watch("integration");
// const selectedProject = form.watch("project");
const hasConnectedIntegration = useMemo(
() => integrations.some((i) => i.attributes.connected === true),
[integrations],
const hasConnectedIntegration = integrations.some(
(i) => i.attributes.connected === true,
);
const getSelectedValue = (keys: Selection): string => {
@@ -91,37 +92,39 @@ export const SendToJiraModal = ({
onOpenChange(next);
};
const fetchJiraIntegrations = useCallback(async () => {
setIsFetchingIntegrations(true);
try {
const result = await getJiraIntegrations();
if (!result.success) {
throw new Error(result.error || "Unable to fetch Jira integrations");
}
setIntegrations(result.data);
// Auto-select if only one integration
if (result.data.length === 1) {
form.setValue("integration", result.data[0].id);
}
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: "Failed to load Jira integrations";
toast({
variant: "destructive",
title: "Failed to load integrations",
description: message,
});
} finally {
setIsFetchingIntegrations(false);
}
}, [form, toast]);
// Fetch Jira integrations when modal opens
useEffect(() => {
if (isOpen) {
const fetchJiraIntegrations = async () => {
setIsFetchingIntegrations(true);
try {
const result = await getJiraIntegrations();
if (!result.success) {
throw new Error(
result.error || "Unable to fetch Jira integrations",
);
}
setIntegrations(result.data);
// Auto-select if only one integration
if (result.data.length === 1) {
form.setValue("integration", result.data[0].id);
}
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: "Failed to load Jira integrations";
toast({
variant: "destructive",
title: "Failed to load integrations",
description: message,
});
} finally {
setIsFetchingIntegrations(false);
}
};
fetchJiraIntegrations();
} else {
// Reset form when modal closes
@@ -129,7 +132,7 @@ export const SendToJiraModal = ({
setSearchProjectValue("");
// setSearchIssueTypeValue("");
}
}, [isOpen, form, fetchJiraIntegrations]);
}, [isOpen, form, toast]);
const handleSubmit = async (data: SendToJiraFormData) => {
// Close modal immediately; continue processing in background
@@ -179,19 +182,18 @@ export const SendToJiraModal = ({
(i) => i.id === selectedIntegration,
);
const projects: Record<string, string> = useMemo(
() =>
selectedIntegrationData?.attributes.configuration.projects ??
({} as Record<string, string>),
[selectedIntegrationData],
);
const projects: Record<string, string> =
selectedIntegrationData?.attributes.configuration.projects ??
({} as Record<string, string>);
const projectEntries = Object.entries(projects);
const shouldShowProjectSearch = projectEntries.length > 5;
// const issueTypes: string[] =
// selectedIntegrationData?.attributes.configuration.issue_types ||
// ([] as string[]);
// Filter projects based on search
const filteredProjects = useMemo(() => {
const projectEntries = Object.entries(projects);
const filteredProjects = (() => {
if (!searchProjectValue) return projectEntries;
const lowerSearch = searchProjectValue.toLowerCase();
@@ -200,7 +202,7 @@ export const SendToJiraModal = ({
key.toLowerCase().includes(lowerSearch) ||
name.toLowerCase().includes(lowerSearch),
);
}, [projects, searchProjectValue]);
})();
// Filter issue types based on search
// const filteredIssueTypes = useMemo(() => {
@@ -257,13 +259,7 @@ export const SendToJiraModal = ({
isDisabled={isFetchingIntegrations}
isInvalid={!!form.formState.errors.integration}
startContent={<JiraIcon size={16} />}
classNames={{
trigger: "min-h-12",
popoverContent: "dark:bg-gray-800",
label:
"tracking-tight font-light !text-default-500 text-xs z-0!",
value: "text-default-500 text-small dark:text-gray-300",
}}
classNames={selectorClassNames}
>
{integrations.map((integration) => (
<SelectItem
@@ -312,35 +308,28 @@ export const SendToJiraModal = ({
variant="bordered"
labelPlacement="inside"
isInvalid={!!form.formState.errors.project}
classNames={{
trigger: "min-h-12",
popoverContent: "dark:bg-gray-800",
listboxWrapper: "max-h-[300px] dark:bg-gray-800",
label:
"tracking-tight font-light !text-default-500 text-xs z-0!",
value: "text-default-500 text-small dark:text-gray-300",
}}
classNames={selectorClassNames}
listboxProps={{
topContent:
filteredProjects.length > 5 ? (
<div className="bg-content1 sticky top-0 z-10 py-2 dark:bg-gray-800">
<Input
isClearable
placeholder="Search projects..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchProjectValue}
onValueChange={setSearchProjectValue}
onClear={() => setSearchProjectValue("")}
classNames={{
inputWrapper:
"border-default-200 bg-transparent hover:bg-default-100/50",
input: "text-small",
}}
/>
</div>
) : null,
topContent: shouldShowProjectSearch ? (
<div className="sticky top-0 z-10 py-2">
<Input
isClearable
placeholder="Search projects..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchProjectValue}
onValueChange={setSearchProjectValue}
onClear={() => setSearchProjectValue("")}
classNames={{
inputWrapper:
"border-border-input-primary bg-bg-input-primary hover:bg-bg-neutral-secondary",
input: "text-small",
clearButton: "text-default-400",
}}
/>
</div>
) : null,
}}
>
{filteredProjects.map(([key, name]) => (
@@ -9,7 +9,7 @@ import { DataTableRowActions } from "@/components/findings/table/data-table-row-
import { InfoIcon } from "@/components/icons";
import {
DateWithTime,
EntityInfoShort,
EntityInfo,
SnippetChip,
} from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
@@ -91,8 +91,11 @@ const FindingDetailsCell = ({ row }: { row: any }) => {
export const ColumnFindings: ColumnDef<FindingProps>[] = [
{
id: "moreInfo",
header: "Details",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <FindingDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "check",
@@ -115,9 +118,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
<div className="flex flex-row items-center gap-4">
{delta === "new" || delta === "changed" ? (
<DeltaIndicator delta={delta} />
) : (
<div className="w-2" />
)}
) : null}
<p className="mr-7 text-sm break-words whitespace-normal">
{checktitle}
</p>
@@ -131,7 +132,9 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
},
{
accessorKey: "resourceName",
header: "Resource name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
@@ -143,6 +146,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
/>
);
},
enableSorting: false,
},
{
accessorKey: "severity",
@@ -210,7 +214,9 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
// },
{
accessorKey: "region",
header: "Region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
@@ -220,18 +226,24 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
</div>
);
},
enableSorting: false,
},
{
accessorKey: "service",
header: "Service",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Service" />
),
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-xs">{servicename}</p>;
},
enableSorting: false,
},
{
accessorKey: "cloudProvider",
header: "Cloud Provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
@@ -239,7 +251,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
return (
<>
<EntityInfoShort
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias as string}
entityId={uid as string}
@@ -247,12 +259,16 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
</>
);
},
enableSorting: false,
},
{
id: "actions",
header: "Actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
@@ -14,7 +14,7 @@ import {
} from "@/components/shadcn";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { EntityInfoShort, InfoField } from "@/components/ui/entities";
import { EntityInfo, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
@@ -119,7 +119,7 @@ export const FindingDetail = ({
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">
<EntityInfoShort
<EntityInfo
cloudProvider={providerDetails.provider as ProviderType}
entityAlias={providerDetails.alias}
entityId={providerDetails.uid}
@@ -222,7 +222,7 @@ export const FindingDetail = ({
{/* CLI Command section */}
{attributes.check_metadata.remediation.code.cli && (
<InfoField label="CLI Command" variant="simple">
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800">
<Snippet>
<span className="text-xs whitespace-pre-line">
{attributes.check_metadata.remediation.code.cli}
</span>
@@ -7,74 +7,81 @@ export const AzureProviderBadge: React.FC<IconSvgProps> = ({
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<g fill="none">
<rect width="256" height="256" fill="#f4f2ed" rx="60" />
<path
fill="url(#skillIconsAzureLight0)"
d="M94.674 34.002h59.182L92.42 216.032a9.44 9.44 0 0 1-8.94 6.419H37.422a9.42 9.42 0 0 1-9.318-8.026a9.4 9.4 0 0 1 .39-4.407L85.733 40.421A9.44 9.44 0 0 1 94.674 34z"
/>
<path
fill="#0078d4"
d="M180.674 156.095H86.826a4.34 4.34 0 0 0-4.045 2.75a4.34 4.34 0 0 0 1.079 4.771l60.305 56.287a9.48 9.48 0 0 0 6.468 2.548h53.141z"
/>
<path
fill="url(#skillIconsAzureLight1)"
d="M94.675 34.002a9.36 9.36 0 0 0-8.962 6.544L28.565 209.863a9.412 9.412 0 0 0 8.882 12.588h47.247a10.1 10.1 0 0 0 7.75-6.592l11.397-33.586l40.708 37.968a9.63 9.63 0 0 0 6.059 2.21h52.943l-23.22-66.355l-67.689.016l41.428-122.11z"
/>
<path
fill="url(#skillIconsAzureLight2)"
d="M170.264 40.412a9.42 9.42 0 0 0-8.928-6.41H95.379a9.42 9.42 0 0 1 8.928 6.41l57.241 169.604a9.43 9.43 0 0 1-1.273 8.509a9.43 9.43 0 0 1-7.655 3.928h65.959a9.43 9.43 0 0 0 7.654-3.929a9.42 9.42 0 0 0 1.272-8.508z"
/>
<defs>
<linearGradient
id="skillIconsAzureLight0"
x1="116.244"
x2="54.783"
y1="47.967"
y2="229.54"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#114a8b" />
<stop offset="1" stopColor="#0669bc" />
</linearGradient>
<linearGradient
id="skillIconsAzureLight1"
x1="135.444"
x2="121.227"
y1="132.585"
y2="137.392"
gradientUnits="userSpaceOnUse"
>
<stop stopOpacity="0.3" />
<stop offset="0.071" stopOpacity="0.2" />
<stop offset="0.321" stopOpacity="0.1" />
<stop offset="0.623" stopOpacity="0.05" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="skillIconsAzureLight2"
x1="127.625"
x2="195.091"
y1="42.671"
y2="222.414"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3ccbf4" />
<stop offset="1" stopColor="#2892df" />
</linearGradient>
</defs>
</g>
</svg>
);
}) => {
const uniqueId = React.useId();
const gradientId0 = `azure-gradient-0-${uniqueId}`;
const gradientId1 = `azure-gradient-1-${uniqueId}`;
const gradientId2 = `azure-gradient-2-${uniqueId}`;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<g fill="none">
<rect width="256" height="256" fill="#f4f2ed" rx="60" />
<path
fill={`url(#${gradientId0})`}
d="M94.674 34.002h59.182L92.42 216.032a9.44 9.44 0 0 1-8.94 6.419H37.422a9.42 9.42 0 0 1-9.318-8.026a9.4 9.4 0 0 1 .39-4.407L85.733 40.421A9.44 9.44 0 0 1 94.674 34z"
/>
<path
fill="#0078d4"
d="M180.674 156.095H86.826a4.34 4.34 0 0 0-4.045 2.75a4.34 4.34 0 0 0 1.079 4.771l60.305 56.287a9.48 9.48 0 0 0 6.468 2.548h53.141z"
/>
<path
fill={`url(#${gradientId1})`}
d="M94.675 34.002a9.36 9.36 0 0 0-8.962 6.544L28.565 209.863a9.412 9.412 0 0 0 8.882 12.588h47.247a10.1 10.1 0 0 0 7.75-6.592l11.397-33.586l40.708 37.968a9.63 9.63 0 0 0 6.059 2.21h52.943l-23.22-66.355l-67.689.016l41.428-122.11z"
/>
<path
fill={`url(#${gradientId2})`}
d="M170.264 40.412a9.42 9.42 0 0 0-8.928-6.41H95.379a9.42 9.42 0 0 1 8.928 6.41l57.241 169.604a9.43 9.43 0 0 1-1.273 8.509a9.43 9.43 0 0 1-7.655 3.928h65.959a9.43 9.43 0 0 0 7.654-3.929a9.42 9.42 0 0 0 1.272-8.508z"
/>
<defs>
<linearGradient
id={gradientId0}
x1="116.244"
x2="54.783"
y1="47.967"
y2="229.54"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#114a8b" />
<stop offset="1" stopColor="#0669bc" />
</linearGradient>
<linearGradient
id={gradientId1}
x1="135.444"
x2="121.227"
y1="132.585"
y2="137.392"
gradientUnits="userSpaceOnUse"
>
<stop stopOpacity="0.3" />
<stop offset="0.071" stopOpacity="0.2" />
<stop offset="0.321" stopOpacity="0.1" />
<stop offset="0.623" stopOpacity="0.05" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id={gradientId2}
x1="127.625"
x2="195.091"
y1="42.671"
y2="222.414"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3ccbf4" />
<stop offset="1" stopColor="#2892df" />
</linearGradient>
</defs>
</g>
</svg>
);
};
@@ -1,126 +1,144 @@
import * as React from "react";
import type { FC } from "react";
import { useId } from "react";
import { IconSvgProps } from "@/types";
import type { IconSvgProps } from "@/types";
export const M365ProviderBadge: React.FC<IconSvgProps> = ({
export const M365ProviderBadge: FC<IconSvgProps> = ({
size,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height || 64}
role="presentation"
viewBox="0 0 256 256"
width={size || width || 64}
{...props}
>
<g>
<rect width="256" height="256" rx="60" fill="#f4f2ed" />
<g transform="scale(3.5) translate(2 2)">
<g clipPath="url(#clip0)">
<g clipPath="url(#clip1)">
<path
d="M53.1574 10.3146C52.1706 7.19669 49.2773 5.07764 46.007 5.07764L43.5352 5.07764C39.9228 5.07764 36.8237 7.65268 36.1621 11.2039L32.4891 30.9179L33.5912 27.2788C34.5491 24.1158 37.4644 21.9526 40.7692 21.9526H52.2499L58.8326 24.2562L62.3337 21.9644C59.0634 21.9644 56.1701 19.8336 55.1833 16.7157L53.1574 10.3146Z"
fill="url(#paint0_radial)"
/>
<path
d="M20.615 62.8082C21.5914 65.9426 24.4927 68.0777 27.7757 68.0777H32.6415C36.7421 68.0777 40.0824 64.7845 40.1408 60.6844L40.3984 42.5737L39.4114 45.86C38.459 49.0313 35.5396 51.2027 32.2284 51.2027H20.75L14.8141 48.4965L11.4807 51.2027C14.7636 51.2027 17.665 53.3378 18.6414 56.4722L20.615 62.8082Z"
fill="url(#paint1_radial)"
/>
<path
d="M45.5 5.07764H19.25C11.75 5.07764 7.25001 14.7496 4.25002 24.4216C0.695797 35.8804 -3.95498 51.2056 9.50001 51.2056H20.931C24.2656 51.2056 27.1975 49.0121 28.135 45.812C30.1073 39.0797 33.5545 27.3661 36.2631 18.446C37.6417 13.906 38.79 10.007 40.5523 7.57888C41.5404 6.21761 43.1871 5.07764 45.5 5.07764Z"
fill="url(#paint2_linear)"
/>
<path
d="M27.4946 68.0776H53.7446C61.2446 68.0776 65.7446 58.4071 68.7446 48.7365C72.2988 37.2794 76.9496 21.9565 63.4946 21.9565H52.0633C48.7288 21.9565 45.797 24.1499 44.8594 27.3499C42.8871 34.0812 39.44 45.7927 36.7314 54.7113C35.3529 59.2506 34.2046 63.149 32.4422 65.5768C31.4542 66.9378 29.8075 68.0776 27.4946 68.0776Z"
fill="url(#paint4_radial)"
/>
<rect
x="24.125"
y="51.2031"
width="48.375"
height="21.375"
rx="3.63727"
fill="black"
/>
<text x="26" y="67" fill="white" fontSize="16" fontWeight="bold">
M365
</text>
}) => {
const uniqueId = useId();
const gradientId0 = `m365-gradient-0-${uniqueId}`;
const gradientId1 = `m365-gradient-1-${uniqueId}`;
const gradientId2 = `m365-gradient-2-${uniqueId}`;
const gradientId4 = `m365-gradient-4-${uniqueId}`;
const clipId0 = `m365-clip-0-${uniqueId}`;
const clipId1 = `m365-clip-1-${uniqueId}`;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<g>
<rect width="256" height="256" rx="60" fill="#f4f2ed" />
<g transform="scale(3.5) translate(2 2)">
<g clipPath={`url(#${clipId0})`}>
<g clipPath={`url(#${clipId1})`}>
<path
d="M53.1574 10.3146C52.1706 7.19669 49.2773 5.07764 46.007 5.07764L43.5352 5.07764C39.9228 5.07764 36.8237 7.65268 36.1621 11.2039L32.4891 30.9179L33.5912 27.2788C34.5491 24.1158 37.4644 21.9526 40.7692 21.9526H52.2499L58.8326 24.2562L62.3337 21.9644C59.0634 21.9644 56.1701 19.8336 55.1833 16.7157L53.1574 10.3146Z"
fill={`url(#${gradientId0})`}
/>
<path
d="M20.615 62.8082C21.5914 65.9426 24.4927 68.0777 27.7757 68.0777H32.6415C36.7421 68.0777 40.0824 64.7845 40.1408 60.6844L40.3984 42.5737L39.4114 45.86C38.459 49.0313 35.5396 51.2027 32.2284 51.2027H20.75L14.8141 48.4965L11.4807 51.2027C14.7636 51.2027 17.665 53.3378 18.6414 56.4722L20.615 62.8082Z"
fill={`url(#${gradientId1})`}
/>
<path
d="M45.5 5.07764H19.25C11.75 5.07764 7.25001 14.7496 4.25002 24.4216C0.695797 35.8804 -3.95498 51.2056 9.50001 51.2056H20.931C24.2656 51.2056 27.1975 49.0121 28.135 45.812C30.1073 39.0797 33.5545 27.3661 36.2631 18.446C37.6417 13.906 38.79 10.007 40.5523 7.57888C41.5404 6.21761 43.1871 5.07764 45.5 5.07764Z"
fill={`url(#${gradientId2})`}
/>
<path
d="M27.4946 68.0776H53.7446C61.2446 68.0776 65.7446 58.4071 68.7446 48.7365C72.2988 37.2794 76.9496 21.9565 63.4946 21.9565H52.0633C48.7288 21.9565 45.797 24.1499 44.8594 27.3499C42.8871 34.0812 39.44 45.7927 36.7314 54.7113C35.3529 59.2506 34.2046 63.149 32.4422 65.5768C31.4542 66.9378 29.8075 68.0776 27.4946 68.0776Z"
fill={`url(#${gradientId4})`}
/>
<rect
x="24.125"
y="51.2031"
width="48.375"
height="21.375"
rx="3.63727"
fill="#131313"
/>
<text
x="27.5"
y="67"
fill="#ffffff"
fontFamily="Inter, Arial, sans-serif"
fontSize="16"
fontWeight="700"
>
M365
</text>
</g>
</g>
<defs>
<radialGradient
id={gradientId0}
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(59.4363 31.0868) rotate(-130.285) scale(27.6431 26.1575)"
>
<stop offset="0.0955758" stopColor="#00AEFF" />
<stop offset="0.773185" stopColor="#2253CE" />
<stop offset="1" stopColor="#0736C4" />
</radialGradient>
<radialGradient
id={gradientId1}
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(15.3608 50.9716) rotate(50.2556) scale(25.0142 24.5538)"
>
<stop stopColor="#FFB657" />
<stop offset="0.633728" stopColor="#FF5F3D" />
<stop offset="0.923392" stopColor="#C02B3C" />
</radialGradient>
<linearGradient
id={gradientId2}
x1="17.6789"
y1="10.6669"
x2="21.2461"
y2="52.961"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.156162" stopColor="#0D91E1" />
<stop offset="0.487484" stopColor="#52B471" />
<stop offset="0.652394" stopColor="#98BD42" />
<stop offset="0.937361" stopColor="#FFC800" />
</linearGradient>
<radialGradient
id={gradientId4}
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(64.843 17.441) rotate(109.722) scale(61.4524 75.0539)"
>
<stop offset="0.0661714" stopColor="#8C48FF" />
<stop offset="0.5" stopColor="#F2598A" />
<stop offset="0.895833" stopColor="#FFB152" />
</radialGradient>
<clipPath id={clipId0}>
<rect
width="72"
height="72"
fill="white"
transform="translate(0.5 0.578125)"
/>
</clipPath>
<clipPath id={clipId1}>
<rect
width="72"
height="72"
fill="white"
transform="translate(0.5 0.578125)"
/>
</clipPath>
</defs>
</g>
<defs>
<radialGradient
id="paint0_radial"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(59.4363 31.0868) rotate(-130.285) scale(27.6431 26.1575)"
>
<stop offset="0.0955758" stopColor="#00AEFF" />
<stop offset="0.773185" stopColor="#2253CE" />
<stop offset="1" stopColor="#0736C4" />
</radialGradient>
<radialGradient
id="paint1_radial"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(15.3608 50.9716) rotate(50.2556) scale(25.0142 24.5538)"
>
<stop stopColor="#FFB657" />
<stop offset="0.633728" stopColor="#FF5F3D" />
<stop offset="0.923392" stopColor="#C02B3C" />
</radialGradient>
<linearGradient
id="paint2_linear"
x1="17.6789"
y1="10.6669"
x2="21.2461"
y2="52.961"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.156162" stopColor="#0D91E1" />
<stop offset="0.487484" stopColor="#52B471" />
<stop offset="0.652394" stopColor="#98BD42" />
<stop offset="0.937361" stopColor="#FFC800" />
</linearGradient>
<radialGradient
id="paint4_radial"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(64.843 17.441) rotate(109.722) scale(61.4524 75.0539)"
>
<stop offset="0.0661714" stopColor="#8C48FF" />
<stop offset="0.5" stopColor="#F2598A" />
<stop offset="0.895833" stopColor="#FFB152" />
</radialGradient>
<clipPath id="clip0">
<rect
width="72"
height="72"
fill="white"
transform="translate(0.5 0.578125)"
/>
</clipPath>
<clipPath id="clip1">
<rect
width="72"
height="72"
fill="white"
transform="translate(0.5 0.578125)"
/>
</clipPath>
</defs>
</g>
</g>
</svg>
);
</svg>
);
};
@@ -15,11 +15,14 @@ const getInvitationData = (row: { original: InvitationProps }) => {
export const ColumnsInvitation: ColumnDef<InvitationProps>[] = [
{
accessorKey: "email",
header: () => <div className="text-left">Email</div>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row }) => {
const data = getInvitationData(row);
return <p className="font-semibold">{data?.email || "N/A"}</p>;
},
enableSorting: false,
},
{
accessorKey: "state",
@@ -33,12 +36,15 @@ export const ColumnsInvitation: ColumnDef<InvitationProps>[] = [
},
{
accessorKey: "role",
header: () => <div className="text-left">Role</div>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Role" />
),
cell: ({ row }) => {
const roleName =
row.original.relationships?.role?.attributes?.name || "No Role";
return <p className="font-semibold">{roleName}</p>;
},
enableSorting: false,
},
{
accessorKey: "inserted_at",
@@ -71,11 +77,14 @@ export const ColumnsInvitation: ColumnDef<InvitationProps>[] = [
},
{
accessorKey: "actions",
header: () => <div className="text-right">Actions</div>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
id: "actions",
cell: ({ row }) => {
const roles = row.original.roles;
return <DataTableRowActions row={row} roles={roles} />;
},
enableSorting: false,
},
];
@@ -8,7 +8,7 @@ import {
PaperclipIcon,
PlusIcon,
SendIcon,
SquareIcon,
StopCircleIcon,
XIcon,
} from "lucide-react";
import { nanoid } from "nanoid";
@@ -935,7 +935,7 @@ export const PromptInputSubmit = ({
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
Icon = <StopCircleIcon className="size-5" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
+103 -64
View File
@@ -104,6 +104,9 @@ export const Chat = ({
// Provider and model management
const [providers, setProviders] = useState<Provider[]>(initialProviders);
const loadedProvidersRef = useRef<Set<LighthouseProvider>>(new Set());
const [loadingProviders, setLoadingProviders] = useState<
Set<LighthouseProvider>
>(new Set());
// Initialize selectedModel with defaults from props
const [selectedModel, setSelectedModel] = useState<SelectedModel>(() => {
@@ -142,6 +145,7 @@ export const Chat = ({
// Mark as loaded
loadedProvidersRef.current.add(providerType);
setLoadingProviders((prev) => new Set(prev).add(providerType));
try {
const response = await getLighthouseModelIds(providerType);
@@ -167,66 +171,79 @@ export const Chat = ({
console.error(`Error loading models for ${providerType}:`, error);
// Remove from loaded on error so it can be retried
loadedProvidersRef.current.delete(providerType);
} finally {
setLoadingProviders((prev) => {
const next = new Set(prev);
next.delete(providerType);
return next;
});
}
};
const { messages, sendMessage, status, error, setMessages, regenerate } =
useChat({
transport: new DefaultChatTransport({
api: "/api/lighthouse/analyst",
credentials: "same-origin",
body: () => ({
model: selectedModelRef.current.modelId,
provider: selectedModelRef.current.providerType,
}),
const {
messages,
sendMessage,
status,
error,
setMessages,
regenerate,
stop,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/lighthouse/analyst",
credentials: "same-origin",
body: () => ({
model: selectedModelRef.current.modelId,
provider: selectedModelRef.current.providerType,
}),
experimental_throttle: 100,
onFinish: ({ message }) => {
// There is no specific way to output the error message from langgraph supervisor
// Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]:
// Detect error messages sent from backend using specific prefix and display the error
const firstTextPart = message.parts.find((p) => p.type === "text");
if (
firstTextPart &&
"text" in firstTextPart &&
firstTextPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")
) {
const errorText = firstTextPart.text
.replace("[LIGHTHOUSE_ANALYST_ERROR]:", "")
.trim();
setErrorMessage(errorText);
// Remove error message from chat history
setMessages((prev) =>
prev.filter((m) => {
const textPart = m.parts.find((p) => p.type === "text");
return !(
textPart &&
"text" in textPart &&
textPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")
);
}),
);
restoreLastUserMessage();
}
},
onError: (error) => {
console.error("Chat error:", error);
if (
error?.message?.includes("<html>") &&
error?.message?.includes("<title>403 Forbidden</title>")
) {
restoreLastUserMessage();
setErrorMessage("403 Forbidden");
return;
}
restoreLastUserMessage();
setErrorMessage(
error?.message || "An error occurred. Please retry your message.",
}),
experimental_throttle: 100,
onFinish: ({ message }) => {
// There is no specific way to output the error message from langgraph supervisor
// Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]:
// Detect error messages sent from backend using specific prefix and display the error
const firstTextPart = message.parts.find((p) => p.type === "text");
if (
firstTextPart &&
"text" in firstTextPart &&
firstTextPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")
) {
const errorText = firstTextPart.text
.replace("[LIGHTHOUSE_ANALYST_ERROR]:", "")
.trim();
setErrorMessage(errorText);
// Remove error message from chat history
setMessages((prev) =>
prev.filter((m) => {
const textPart = m.parts.find((p) => p.type === "text");
return !(
textPart &&
"text" in textPart &&
textPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")
);
}),
);
},
});
restoreLastUserMessage();
}
},
onError: (error) => {
console.error("Chat error:", error);
if (
error?.message?.includes("<html>") &&
error?.message?.includes("<title>403 Forbidden</title>")
) {
restoreLastUserMessage();
setErrorMessage("403 Forbidden");
return;
}
restoreLastUserMessage();
setErrorMessage(
error?.message || "An error occurred. Please retry your message.",
);
},
});
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
@@ -264,6 +281,12 @@ export const Chat = ({
}
};
const stopGeneration = () => {
if (status === "streaming" || status === "submitted") {
stop();
}
};
// Auto-scroll to bottom when new messages arrive or when streaming
useEffect(() => {
if (messagesContainerRef.current) {
@@ -542,15 +565,18 @@ export const Chat = ({
<Combobox
value={`${selectedModel.providerType}:${selectedModel.modelId}`}
onValueChange={(value) => {
const [providerType, modelId] = value.split(":");
const separatorIndex = value.indexOf(":");
if (separatorIndex === -1) return;
const providerType = value.slice(
0,
separatorIndex,
) as LighthouseProvider;
const modelId = value.slice(separatorIndex + 1);
const provider = providers.find((p) => p.id === providerType);
const model = provider?.models.find((m) => m.id === modelId);
if (provider && model) {
handleModelSelect(
providerType as LighthouseProvider,
modelId,
model.name,
);
handleModelSelect(providerType, modelId, model.name);
}
}}
groups={providers.map((provider) => ({
@@ -560,6 +586,8 @@ export const Chat = ({
label: model.name,
})),
}))}
loading={loadingProviders.size > 0}
loadingMessage="Loading models..."
placeholder={selectedModel.modelName || "Select model..."}
searchPlaceholder="Search models..."
emptyMessage="No model found."
@@ -570,10 +598,21 @@ export const Chat = ({
{/* Submit Button */}
<PromptInputSubmit
status={status}
type={
status === "streaming" || status === "submitted"
? "button"
: "submit"
}
onClick={(event) => {
if (status === "streaming" || status === "submitted") {
event.preventDefault();
stopGeneration();
}
}}
disabled={
status === "streaming" ||
status === "submitted" ||
!uiState.inputValue?.trim()
!uiState.inputValue?.trim() &&
status !== "streaming" &&
status !== "submitted"
}
/>
</PromptInputToolbar>
@@ -82,8 +82,10 @@ export const ColumnGroups: ColumnDef<ProviderGroup>[] = [
},
{
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
@@ -10,11 +10,15 @@ import { DeltaIndicator } from "@/components/findings/table/delta-indicator";
import { InfoIcon } from "@/components/icons";
import {
DateWithTime,
EntityInfoShort,
EntityInfo,
SnippetChip,
} from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import {
DataTableColumnHeader,
SeverityBadge,
StatusFindingBadge,
} from "@/components/ui/table";
import { FindingProps, ProviderType } from "@/types";
const getFindingsData = (row: { original: FindingProps }) => {
@@ -72,12 +76,17 @@ const FindingDetailsCell = ({ row }: { row: any }) => {
export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
{
id: "moreInfo",
header: "Details",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <FindingDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "check",
header: "Finding",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Finding" />
),
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
const {
@@ -102,10 +111,13 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
</div>
);
},
enableSorting: false,
},
{
accessorKey: "resourceName",
header: "Resource name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
@@ -117,20 +129,26 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
/>
);
},
enableSorting: false,
},
{
accessorKey: "severity",
header: "Severity",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Severity" />
),
cell: ({ row }) => {
const {
attributes: { severity },
} = getFindingsData(row);
return <SeverityBadge severity={severity} />;
},
enableSorting: false,
},
{
accessorKey: "status",
header: "Status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const {
attributes: { status },
@@ -138,10 +156,13 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
return <StatusFindingBadge size="sm" status={status} />;
},
enableSorting: false,
},
{
accessorKey: "updated_at",
header: "Last seen",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Last seen" />
),
cell: ({ row }) => {
const {
attributes: { updated_at },
@@ -152,10 +173,13 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
</div>
);
},
enableSorting: false,
},
{
accessorKey: "region",
header: "Region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
@@ -165,18 +189,24 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
</div>
);
},
enableSorting: false,
},
{
accessorKey: "service",
header: "Service",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Service" />
),
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="text-small max-w-96 truncate">{servicename}</p>;
},
enableSorting: false,
},
{
accessorKey: "cloudProvider",
header: "Cloud Provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
@@ -184,7 +214,7 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
return (
<>
<EntityInfoShort
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias as string}
entityId={uid as string}
@@ -192,5 +222,6 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
</>
);
},
enableSorting: false,
},
];
@@ -45,21 +45,27 @@ export const ColumnProviders: ColumnDef<ProviderProps>[] = [
},
{
accessorKey: "scanJobs",
header: "Scan Jobs",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scan Jobs" />
),
cell: ({ row }) => {
const {
attributes: { uid },
} = getProviderData(row);
return <LinkToScans providerUid={uid} />;
},
enableSorting: false,
},
{
accessorKey: "groupNames",
header: "Groups",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Groups" />
),
cell: ({ row }) => {
const { groupNames } = getProviderData(row);
return <GroupNameChips groupNames={groupNames || []} />;
},
enableSorting: false,
},
{
accessorKey: "uid",
@@ -95,9 +101,11 @@ export const ColumnProviders: ColumnDef<ProviderProps>[] = [
},
{
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
@@ -5,7 +5,7 @@ import { Database } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { InfoIcon } from "@/components/icons";
import { EntityInfoShort, SnippetChip } from "@/components/ui/entities";
import { EntityInfo, SnippetChip } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { DataTableColumnHeader } from "@/components/ui/table";
import { ProviderType, ResourceProps } from "@/types";
@@ -62,12 +62,17 @@ const ResourceDetailsCell = ({ row }: { row: any }) => {
export const ColumnResources: ColumnDef<ResourceProps>[] = [
{
id: "moreInfo",
header: "Details",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <ResourceDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "resourceName",
header: "Resource name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
const displayName =
@@ -83,10 +88,13 @@ export const ColumnResources: ColumnDef<ResourceProps>[] = [
/>
);
},
enableSorting: false,
},
{
accessorKey: "failedFindings",
header: () => <div className="text-center">Failed Findings</div>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Failed Findings" />
),
cell: ({ row }) => {
const failedFindingsCount = getResourceData(
row,
@@ -94,17 +102,14 @@ export const ColumnResources: ColumnDef<ResourceProps>[] = [
) as number;
return (
<>
<p className="text-center">
<span
className={`mx-auto flex h-6 w-6 items-center justify-center rounded-full bg-yellow-100 text-xs font-semibold text-yellow-800 ${getChipStyle(failedFindingsCount)}`}
>
{failedFindingsCount}
</span>
</p>
</>
<span
className={`ml-10 flex h-6 w-6 items-center justify-center rounded-full bg-yellow-100 text-xs font-semibold text-yellow-800 ${getChipStyle(failedFindingsCount)}`}
>
{failedFindingsCount}
</span>
);
},
enableSorting: false,
},
{
accessorKey: "region",
@@ -157,14 +162,16 @@ export const ColumnResources: ColumnDef<ResourceProps>[] = [
},
{
accessorKey: "provider",
header: "Cloud Provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<EntityInfoShort
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias && typeof alias === "string" ? alias : undefined}
entityId={uid && typeof uid === "string" ? uid : undefined}
@@ -172,5 +179,6 @@ export const ColumnResources: ColumnDef<ResourceProps>[] = [
</>
);
},
enableSorting: false,
},
];
+4 -1
View File
@@ -105,10 +105,13 @@ export const ColumnsRoles: ColumnDef<RolesProps["data"][number]>[] = [
},
{
accessorKey: "actions",
header: () => <div className="text-right">Actions</div>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
id: "actions",
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import { EntityInfoShort } from "@/components/ui/entities";
import { EntityInfo } from "@/components/ui/entities";
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
interface SelectScanProviderProps<
@@ -54,7 +54,7 @@ export const SelectScanProvider = <
<SelectTrigger>
<SelectValue placeholder="Choose a cloud provider">
{selectedItem ? (
<EntityInfoShort
<EntityInfo
cloudProvider={
selectedItem.providerType as
| "aws"
@@ -64,7 +64,7 @@ export const SelectScanProvider = <
}
entityAlias={selectedItem.alias}
entityId={selectedItem.uid}
hideCopyButton
showCopyAction={false}
/>
) : (
"Choose a cloud provider"
@@ -74,7 +74,7 @@ export const SelectScanProvider = <
<SelectContent>
{providers.map((item) => (
<SelectItem key={item.providerId} value={item.providerId}>
<EntityInfoShort
<EntityInfo
cloudProvider={
item.providerType as
| "aws"
@@ -84,7 +84,7 @@ export const SelectScanProvider = <
}
entityAlias={item.alias}
entityId={item.uid}
hideCopyButton
showCopyAction={false}
/>
</SelectItem>
))}
+56 -74
View File
@@ -2,11 +2,8 @@
import { Snippet } from "@heroui/snippet";
import {
DateWithTime,
EntityInfoShort,
InfoField,
} from "@/components/ui/entities";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { DateWithTime, EntityInfo, InfoField } from "@/components/ui/entities";
import { StatusBadge } from "@/components/ui/table/status-badge";
import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types";
@@ -28,21 +25,6 @@ const formatDuration = (seconds: number) => {
return parts.join(" ");
};
const Section = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<div className="dark:bg-prowler-blue-400 flex flex-col gap-4 rounded-lg p-4 shadow">
<h3 className="text-md dark:text-prowler-theme-pale/90 font-medium text-gray-800">
{title}
</h3>
{children}
</div>
);
export const ScanDetail = ({
scanDetails,
}: {
@@ -68,7 +50,7 @@ export const ScanDetail = ({
loadingProgress={scan.progress}
/>
</div>
<EntityInfoShort
<EntityInfo
cloudProvider={providerDetails?.provider as ProviderType}
entityAlias={providerDetails?.alias}
entityId={providerDetails?.uid}
@@ -77,63 +59,63 @@ export const ScanDetail = ({
</div>
{/* Scan Details */}
<Section title="Scan Details">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Scan Name">{renderValue(scan.name)}</InfoField>
<InfoField label="Resources Scanned">
{scan.unique_resource_count}
</InfoField>
<InfoField label="Progress">{scan.progress}%</InfoField>
</div>
<Card variant="base" padding="lg">
<CardHeader>
<CardTitle>Scan Details</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Scan Name">{renderValue(scan.name)}</InfoField>
<InfoField label="Resources Scanned">
{scan.unique_resource_count}
</InfoField>
<InfoField label="Progress">{scan.progress}%</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Trigger">{renderValue(scan.trigger)}</InfoField>
<InfoField label="State">{renderValue(scan.state)}</InfoField>
<InfoField label="Duration">
{formatDuration(scan.duration)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Trigger">{renderValue(scan.trigger)}</InfoField>
<InfoField label="State">{renderValue(scan.state)}</InfoField>
<InfoField label="Duration">
{formatDuration(scan.duration)}
</InfoField>
</div>
<InfoField label="Scan ID" variant="simple">
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800" hideSymbol>
{scanDetails.id}
</Snippet>
</InfoField>
<InfoField label="Scan ID" variant="simple">
<Snippet hideSymbol>{scanDetails.id}</Snippet>
</InfoField>
{scan.state === "failed" && taskDetails?.attributes.result && (
<>
{taskDetails.attributes.result.exc_message && (
<InfoField label="Error Message" variant="simple">
<Snippet
className="bg-gray-50 py-1 dark:bg-slate-800"
hideSymbol
>
<span className="text-xs whitespace-pre-line">
{taskDetails.attributes.result.exc_message.join("\n")}
</span>
</Snippet>
</InfoField>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Error Type">
{renderValue(taskDetails.attributes.result.exc_type)}
</InfoField>
</div>
</>
)}
{scan.state === "failed" && taskDetails?.attributes.result && (
<>
{taskDetails.attributes.result.exc_message && (
<InfoField label="Error Message" variant="simple">
<Snippet hideSymbol>
<span className="text-xs whitespace-pre-line">
{taskDetails.attributes.result.exc_message.join("\n")}
</span>
</Snippet>
</InfoField>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Error Type">
{renderValue(taskDetails.attributes.result.exc_type)}
</InfoField>
</div>
</>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Started At">
<DateWithTime inline dateTime={scan.started_at || "-"} />
</InfoField>
<InfoField label="Completed At">
<DateWithTime inline dateTime={scan.completed_at || "-"} />
</InfoField>
<InfoField label="Scheduled At">
<DateWithTime inline dateTime={scan.scheduled_at || "-"} />
</InfoField>
</div>
</Section>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Started At">
<DateWithTime inline dateTime={scan.started_at || "-"} />
</InfoField>
<InfoField label="Completed At">
<DateWithTime inline dateTime={scan.completed_at || "-"} />
</InfoField>
<InfoField label="Scheduled At">
<DateWithTime inline dateTime={scan.scheduled_at || "-"} />
</InfoField>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -1,12 +1,11 @@
"use client";
import { Tooltip } from "@heroui/tooltip";
import { ColumnDef } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { InfoIcon } from "@/components/icons";
import { TableLink } from "@/components/ui/custom";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table";
import { ProviderType, ScanProps } from "@/types";
@@ -67,12 +66,17 @@ const ScanDetailsCell = ({ row }: { row: any }) => {
export const ColumnGetScans: ColumnDef<ScanProps>[] = [
{
id: "moreInfo",
header: "Details",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <ScanDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "cloudProvider",
header: () => <p className="pr-8">Cloud Provider</p>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const providerInfo = row.original.providerInfo;
@@ -83,18 +87,21 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
const { provider, uid, alias } = providerInfo;
return (
<EntityInfoShort
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias}
entityId={uid}
/>
);
},
enableSorting: false,
},
{
accessorKey: "started_at",
header: () => <p className="pr-8">Started at</p>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Started at" />
),
cell: ({ row }) => {
const {
attributes: { started_at },
@@ -106,10 +113,13 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
</div>
);
},
enableSorting: false,
},
{
accessorKey: "status",
header: "Status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const {
attributes: { state },
@@ -123,10 +133,13 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
</div>
);
},
enableSorting: false,
},
{
accessorKey: "findings",
header: "Findings",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Findings" />
),
cell: ({ row }) => {
const { id } = getScanData(row);
const scanState = row.original.attributes?.state;
@@ -138,10 +151,13 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
/>
);
},
enableSorting: false,
},
{
accessorKey: "compliance",
header: "Compliance",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Compliance" />
),
cell: ({ row }) => {
const { id } = getScanData(row);
const scanState = row.original.attributes?.state;
@@ -153,21 +169,12 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
/>
);
},
enableSorting: false,
},
{
id: "download",
header: () => (
<div className="flex items-end gap-x-1">
<p className="w-fit text-xs">Download</p>
<Tooltip
className="text-xs"
content="Download a ZIP file that includes the JSON (OCSF), CSV, and HTML scan reports, along with the compliance report."
>
<div className="flex items-center gap-2">
<InfoIcon className="text-primary mb-1" size={12} />
</div>
</Tooltip>
</div>
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Download" />
),
cell: ({ row }) => {
return (
@@ -176,21 +183,13 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
</div>
);
},
enableSorting: false,
},
// {
// accessorKey: "scanner_args",
// header: "Scanner Args",
// cell: ({ row }) => {
// const {
// attributes: { scanner_args },
// } = getScanData(row);
// return <p className="font-medium">{scanner_args?.only_logs}</p>;
// },
// },
{
accessorKey: "resources",
header: "Resources",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resources" />
),
cell: ({ row }) => {
const {
attributes: { unique_resource_count },
@@ -201,16 +200,20 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
</div>
);
},
enableSorting: false,
},
{
accessorKey: "scheduled_at",
header: "Scheduled at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scheduled at" />
),
cell: ({ row }) => {
const {
attributes: { scheduled_at },
} = getScanData(row);
return <DateWithTime dateTime={scheduled_at} />;
},
enableSorting: false,
},
{
accessorKey: "completed_at",
@@ -272,8 +275,10 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
},
{
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
@@ -1,58 +1,64 @@
import { Card, CardContent, CardHeader } from "@/components/shadcn";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const SkeletonScanDetail = () => {
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Header Skeleton */}
<div className="flex items-center gap-4">
<div className="bg-default-200 h-8 w-24 animate-pulse rounded-full" />
<Skeleton className="h-8 w-24 rounded-full" />
<div className="flex items-center gap-2">
<div className="bg-default-200 relative h-8 w-8 animate-pulse rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex flex-col gap-1">
<div className="bg-default-200 h-4 w-32 animate-pulse rounded" />
<div className="bg-default-200 h-3 w-24 animate-pulse rounded" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
{/* Scan Details Section Skeleton */}
<div className="dark:bg-prowler-blue-400 flex flex-col gap-4 rounded-lg p-4 shadow">
<div className="bg-default-200 h-5 w-32 animate-pulse rounded" />
<Card variant="base" padding="lg">
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* First grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid1-${index}`} className="flex flex-col gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-full" />
</div>
))}
</div>
{/* First grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid1-${index}`} className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-24 animate-pulse rounded" />
<div className="bg-default-200 h-5 w-full animate-pulse rounded" />
</div>
))}
</div>
{/* Second grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid2-${index}`} className="flex flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-full" />
</div>
))}
</div>
{/* Second grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid2-${index}`} className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-20 animate-pulse rounded" />
<div className="bg-default-200 h-5 w-full animate-pulse rounded" />
</div>
))}
</div>
{/* Scan ID field */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
{/* Scan ID field */}
<div className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-20 animate-pulse rounded" />
<div className="bg-default-200 h-10 w-full animate-pulse rounded" />
</div>
{/* Third grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid3-${index}`} className="flex flex-col gap-2">
<div className="bg-default-200 h-4 w-24 animate-pulse rounded" />
<div className="bg-default-200 h-5 w-full animate-pulse rounded" />
</div>
))}
</div>
</div>
{/* Third grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid3-${index}`} className="flex flex-col gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
};
@@ -103,7 +103,7 @@ export const ResourceStatsCard = ({
>
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
{emptyState ? (
<div className="flex h-[51px] w-full flex-col items-center justify-center">
<div className="flex h-[51px] w-full flex-col items-start justify-center md:items-center">
<p className="text-text-neutral-secondary text-center text-sm leading-5 font-medium">
{emptyState.message}
</p>
+14 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { Check, ChevronsUpDown } from "lucide-react";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
@@ -72,6 +72,8 @@ export interface ComboboxProps
contentClassName?: string;
disabled?: boolean;
showSelectedFirst?: boolean;
loading?: boolean;
loadingMessage?: string;
}
export function Combobox({
@@ -88,6 +90,8 @@ export function Combobox({
variant = "default",
disabled = false,
showSelectedFirst = true,
loading = false,
loadingMessage = "Loading...",
}: ComboboxProps) {
const [open, setOpen] = useState(false);
@@ -127,8 +131,16 @@ export function Combobox({
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} className="h-9" />
{!loading && (
<CommandInput placeholder={searchPlaceholder} className="h-9" />
)}
<CommandList className="minimal-scrollbar max-h-[400px]">
{loading && (
<div className="text-text-neutral-tertiary flex items-center gap-2 px-3 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{loadingMessage}</span>
</div>
)}
<CommandEmpty>{emptyMessage}</CommandEmpty>
{/* Show selected option first if enabled */}
+1
View File
@@ -12,3 +12,4 @@ export * from "./separator/separator";
export * from "./skeleton/skeleton";
export * from "./tabs/generic-tabs";
export * from "./tabs/tabs";
export * from "./tooltip";
+4 -2
View File
@@ -131,13 +131,15 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-4 py-3 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-12 pl-4 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
className,
)}
{...props}
>
<SelectPrimitive.ItemText asChild>
<span className="flex min-w-0 items-center gap-2">{children}</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
{children}
</span>
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator asChild>
<CheckIcon className="text-bg-button-secondary absolute right-4 size-5" />
+5 -2
View File
@@ -120,7 +120,10 @@ export const Accordion = ({
return (
<NextUIAccordion
className={cn("w-full px-0!", className)}
className={cn(
"bg-bg-neutral-primary border-border-neutral-secondary w-full rounded-lg border",
className,
)}
variant={variant}
selectionMode={selectionMode}
selectedKeys={expandedKeys}
@@ -139,7 +142,7 @@ export const Accordion = ({
isDisabled={item.isDisabled}
indicator={<ChevronDown className="text-gray-500" />}
classNames={{
base: index === 0 || index === 1 ? "my-1" : "my-1",
base: index === 0 || index === 1 ? "my-2" : "my-2",
title: "text-sm",
subtitle: "text-xs text-gray-500",
trigger:
@@ -1,63 +0,0 @@
import { Tooltip } from "@heroui/tooltip";
import React from "react";
import { IdIcon } from "@/components/icons";
import { ProviderType } from "@/types";
import { getProviderLogo } from "./get-provider-logo";
import { SnippetChip } from "./snippet-chip";
interface EntityInfoProps {
cloudProvider: ProviderType;
entityAlias?: string;
entityId?: string;
hideCopyButton?: boolean;
snippetWidth?: string;
showConnectionStatus?: boolean;
maxWidth?: string;
}
export const EntityInfoShort: React.FC<EntityInfoProps> = ({
cloudProvider,
entityAlias,
entityId,
hideCopyButton = false,
showConnectionStatus = false,
maxWidth = "max-w-[120px]",
}) => {
return (
<div className="flex items-center justify-start">
<div className="flex items-center justify-between gap-x-2">
<div className="relative shrink-0">
{getProviderLogo(cloudProvider)}
{showConnectionStatus && (
<Tooltip
size="sm"
content={showConnectionStatus ? "Connected" : "Not Connected"}
>
<span
className={`absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full ${
showConnectionStatus ? "bg-green-500" : "bg-red-500"
}`}
/>
</Tooltip>
)}
</div>
<div className={`flex ${maxWidth} flex-col gap-1`}>
{entityAlias && (
<Tooltip content={entityAlias} placement="top" size="sm">
<span className="text-default-500 truncate text-xs text-ellipsis">
{entityAlias}
</span>
</Tooltip>
)}
<SnippetChip
value={entityId ?? ""}
hideCopyButton={hideCopyButton}
icon={<IdIcon className="size-4" />}
/>
</div>
</div>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { Tooltip } from "@heroui/tooltip";
import { useEffect, useState } from "react";
import { CopyIcon, DoneIcon } from "@/components/icons";
import type { ProviderType } from "@/types";
import { getProviderLogo } from "./get-provider-logo";
interface EntityInfoProps {
cloudProvider: ProviderType;
entityAlias?: string;
entityId?: string;
snippetWidth?: string;
showConnectionStatus?: boolean;
maxWidth?: string;
showCopyAction?: boolean;
}
export const EntityInfo = ({
cloudProvider,
entityAlias,
entityId,
showConnectionStatus = false,
maxWidth = "w-[120px]",
showCopyAction = true,
}: EntityInfoProps) => {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return undefined;
const timer = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(timer);
}, [copied]);
const handleCopyEntityId = async () => {
if (!entityId) return;
try {
await navigator.clipboard.writeText(entityId);
setCopied(true);
} catch (_error) {
setCopied(false);
}
};
const canCopy = Boolean(entityId && showCopyAction);
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
{getProviderLogo(cloudProvider)}
{showConnectionStatus && (
<Tooltip
size="sm"
content={showConnectionStatus ? "Connected" : "Not Connected"}
>
<span
className={`absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full ${
showConnectionStatus ? "bg-green-500" : "bg-red-500"
}`}
/>
</Tooltip>
)}
</div>
<div className={`flex ${maxWidth} flex-col gap-1`}>
{entityAlias ? (
<Tooltip content={entityAlias} placement="top-start" size="sm">
<p className="text-text-neutral-primary truncate text-left text-xs font-medium">
{entityAlias}
</p>
</Tooltip>
) : (
<Tooltip content="No alias" placement="top-start" size="sm">
<p className="text-text-neutral-secondary truncate text-left text-xs">
-
</p>
</Tooltip>
)}
{entityId && (
<div className="flex min-w-0 items-center gap-1">
<Tooltip content={entityId} placement="top-start" size="sm">
<p className="text-text-neutral-secondary min-w-0 truncate text-left text-xs">
{entityId}
</p>
</Tooltip>
{canCopy && (
<Tooltip
content={copied ? "Copied" : "Copy to clipboard"}
placement="top"
size="sm"
>
<button
type="button"
onClick={handleCopyEntityId}
aria-label="Copiar ID de la entidad"
className="hover:bg-bg-neutral-tertiary focus-visible:ring-bg-data-info text-text-neutral-secondary hover:text-text-neutral-primary rounded-md p-1 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
{copied ? <DoneIcon size={14} /> : <CopyIcon size={14} />}
</button>
</Tooltip>
)}
</div>
)}
</div>
</div>
);
};
+1 -1
View File
@@ -1,5 +1,5 @@
export * from "./date-with-time";
export * from "./entity-info-short";
export * from "./entity-info";
export * from "./get-provider-logo";
export * from "./info-field";
export * from "./scan-status";
@@ -75,14 +75,18 @@ export const DataTableColumnHeader = <TData, TValue>({
};
if (!column.getCanSort()) {
return <div>{title}</div>;
return (
<div className="text-text-neutral-primary flex items-center justify-between px-0 text-left align-middle text-sm font-semibold whitespace-nowrap outline-none">
<span className="block break-normal whitespace-nowrap">{title}</span>
</div>
);
}
return (
<Button
variant="ghost"
size="sm"
className="flex h-10 w-full items-center justify-between px-0 text-left align-middle text-sm font-semibold whitespace-nowrap text-slate-500 dark:text-slate-400"
className="text-text-neutral-primary hover:text-text-neutral-tertiary -ml-3 flex items-center justify-between px-0 text-left align-middle text-sm font-semibold whitespace-nowrap outline-none hover:bg-transparent"
onClick={getToggleSortingHandler}
>
<span className="block break-normal whitespace-nowrap">{title}</span>
@@ -13,7 +13,7 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { EntityInfoShort } from "@/components/ui/entities/entity-info-short";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { isConnectionStatus, isScanEntity } from "@/lib/helper-filters";
import {
@@ -78,11 +78,11 @@ export const DataTableFilterCustom = ({
// Provider entity
const providerEntity = entity as ProviderEntity;
return (
<EntityInfoShort
<EntityInfo
cloudProvider={providerEntity.provider}
entityAlias={providerEntity.alias ?? undefined}
entityId={providerEntity.uid}
hideCopyButton
showCopyAction={false}
/>
);
};
+1 -4
View File
@@ -21,10 +21,7 @@ const TableHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn(
"[&>tr]:first:shadow-small [&>tr]:first:rounded-lg",
className,
)}
className={cn("[&>tr]:first:rounded-lg", className)}
{...props}
/>
));
@@ -35,7 +35,9 @@ export const createApiKeyColumns = (
},
{
id: "email",
header: "Email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row }) => <EmailCell apiKey={row.original} />,
enableSorting: false,
},
@@ -52,7 +54,9 @@ export const createApiKeyColumns = (
},
{
accessorKey: "last_used_at",
header: "Last Used",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Last Used" />
),
cell: ({ row }) => <LastUsedCell apiKey={row.original} />,
enableSorting: false,
},
@@ -76,11 +80,12 @@ export const createApiKeyColumns = (
},
{
id: "actions",
header: "",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
return (
<DataTableRowActions row={row} onEdit={onEdit} onRevoke={onRevoke} />
);
},
enableSorting: false,
},
];
+8 -2
View File
@@ -35,11 +35,14 @@ export const ColumnsUser: ColumnDef<UserProps>[] = [
},
{
accessorKey: "role",
header: () => <div className="text-left">Role</div>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Role" />
),
cell: ({ row }) => {
const { role } = getUserData(row);
return <p className="font-semibold">{role?.name || "No Role"}</p>;
},
enableSorting: false,
},
{
accessorKey: "company_name",
@@ -72,11 +75,14 @@ export const ColumnsUser: ColumnDef<UserProps>[] = [
{
accessorKey: "actions",
header: () => <div className="text-right">Actions</div>,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
id: "actions",
cell: ({ row }) => {
const roles = row.original.roles;
return <DataTableRowActions row={row} roles={roles} />;
},
enableSorting: false,
},
];