diff --git a/src/app/layouts/AppLayout/Sidebar.tsx b/src/app/layouts/AppLayout/Sidebar.tsx index 41c9d89..862c888 100644 --- a/src/app/layouts/AppLayout/Sidebar.tsx +++ b/src/app/layouts/AppLayout/Sidebar.tsx @@ -56,13 +56,20 @@ export function Sidebar() { const isDark = theme === "dark"; useEffect(() => { - if (!projects.length) return; + if (isLoading) return; + if (!projects.length) { + if (selectedProject) { + setSelectedProject(null); + setUserRole(null); + } + return; + } const selectedExists = selectedProject ? projects.some((project) => project.id === selectedProject.id) : false; if (!selectedExists) { setSelectedProject({ id: projects[0].id, name: projects[0].name }); setUserRole(getProjectUserRole(projects[0], user?.id)); } - }, [projects, selectedProject, setSelectedProject]); + }, [isLoading, projects, selectedProject, setSelectedProject, setUserRole, user?.id]); const selectPlaceholder = isLoading ? "Loading projects..." diff --git a/src/features/dashboard/api/dashboardService.ts b/src/features/dashboard/api/dashboardService.ts index 38b493d..041fca0 100644 --- a/src/features/dashboard/api/dashboardService.ts +++ b/src/features/dashboard/api/dashboardService.ts @@ -2,8 +2,8 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -import type { ProjectDashboardResponse } from "@coveritlabs/contracts"; import { apiClient } from "@shared/api/client"; +import type { ProjectDashboardResponse } from "../model/types/dashboard.types"; export const dashboardService = { async getProjectDashboard(projectId: string, versionId?: string): Promise { diff --git a/src/features/dashboard/index.ts b/src/features/dashboard/index.ts index 335f0b7..7c8cc8b 100644 --- a/src/features/dashboard/index.ts +++ b/src/features/dashboard/index.ts @@ -4,3 +4,13 @@ export { dashboardService } from "./api/dashboardService"; export { useProjectDashboard } from "./model/queries/useProjectDashboard"; +export type { + ProjectActivity, + ProjectCoverageSummary, + ProjectDashboardResponse, + ProjectDashboardVersionRef, + ProjectLatestCrawlSession, + ProjectLatestRun, + ProjectLatestTestFlow, + ProjectRunStatistics, +} from "./model/types/dashboard.types"; diff --git a/src/features/dashboard/model/queries/useProjectDashboard.ts b/src/features/dashboard/model/queries/useProjectDashboard.ts index 00ed309..44fba63 100644 --- a/src/features/dashboard/model/queries/useProjectDashboard.ts +++ b/src/features/dashboard/model/queries/useProjectDashboard.ts @@ -3,9 +3,9 @@ // See LICENSE file in the project root for full license information. import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { ProjectDashboardResponse } from "@coveritlabs/contracts"; import { queryKeys } from "@shared/config/queryKeys"; import { dashboardService } from "../../api/dashboardService"; +import type { ProjectDashboardResponse } from "../types/dashboard.types"; export function useProjectDashboard(projectId: string | null, versionId?: string) { const queryClient = useQueryClient(); diff --git a/src/features/dashboard/model/types/dashboard.types.ts b/src/features/dashboard/model/types/dashboard.types.ts new file mode 100644 index 0000000..d53228f --- /dev/null +++ b/src/features/dashboard/model/types/dashboard.types.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +export interface ProjectDashboardVersionRef { + id: string; + version: string; + applicationId: string; + applicationName: string; +} + +export interface ProjectCoverageSummary { + percentage: number; + coveredTransitions: number; + totalTransitions: number; + crawlSessionId?: string; + calculatedAt?: string; +} + +export interface ProjectRunStatistics { + passedCount: number; + warningCount: number; + failedCount: number; + reportedWarningCount: number; + reportedFailedCount: number; + totalRuns: number; +} + +export interface ProjectLatestRun { + id: string; + runId: string; + displayName: string; + status: string; + applicationId: string; + applicationName: string; + versionId?: string; + version?: string; + passedCount: number; + warningCount: number; + failedCount: number; + createdAt: string; +} + +export interface ProjectLatestCrawlSession { + id: string; + status: string; + triggerType: string; + applicationId: string; + applicationName: string; + versionId: string; + version: string; + stateCount: number; + transitionCount: number; + createdAt: string; + startedAt?: string; + finishedAt?: string; +} + +export interface ProjectLatestTestFlow { + id: string; + crawlSessionId: string; + applicationId: string; + applicationName: string; + versionId: string; + version: string; + checkpointStateHash: string; + checkpointUrl: string; + isClipped: boolean; + stepCount: number; + createdAt: string; +} + +export interface ProjectActivity { + id: string; + projectId: string; + eventType: string; + entityType: string; + entityId?: string; + message: string; + actorUserId?: string; + actorName?: string; + actorEmail?: string; + createdAt: string; +} + +export interface ProjectDashboardResponse { + selectedVersion?: ProjectDashboardVersionRef; + coverage: ProjectCoverageSummary; + runStatistics: ProjectRunStatistics; + latestRuns: ProjectLatestRun[]; + latestCrawlSessions: ProjectLatestCrawlSession[]; + latestTestFlows: ProjectLatestTestFlow[]; + recentActivities: ProjectActivity[]; +} diff --git a/src/features/regression-runs/ui/RegressionRuns.module.scss b/src/features/regression-runs/ui/RegressionRuns.module.scss index 9eb8234..15bbfbd 100644 --- a/src/features/regression-runs/ui/RegressionRuns.module.scss +++ b/src/features/regression-runs/ui/RegressionRuns.module.scss @@ -247,6 +247,24 @@ white-space: nowrap; } +.refreshButton { + min-height: 1.9rem; +} + +.spinIcon { + animation: regression-runs-spin 1s linear infinite; +} + +@keyframes regression-runs-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .runTimestamp { color: var(--text-secondary); font-size: 0.84rem; diff --git a/src/features/regression-runs/ui/RegressionRuns.tsx b/src/features/regression-runs/ui/RegressionRuns.tsx index 2f96fc1..e8fecef 100644 --- a/src/features/regression-runs/ui/RegressionRuns.tsx +++ b/src/features/regression-runs/ui/RegressionRuns.tsx @@ -50,6 +50,8 @@ function RegressionRuns() { data: applications = [], isLoading: applicationsLoading, isError: applicationsError, + isFetching: applicationsFetching, + refetch: refetchApplications, } = useTargetApplications(selectedProject?.id ?? null); const [searchParams, setSearchParams] = useSearchParams(); const [searchText, setSearchText] = useState(""); @@ -318,6 +320,33 @@ function RegressionRuns() { ); const latestRunAt = runs[0]?.createdAt; + const isRefreshing = + applicationsFetching || + runsQuery.isFetching || + runDetailsQuery.isFetching || + scenariosQuery.isFetching || + scenarioQuery.isFetching || + runArtifactsQuery.isFetching || + scenarioEventsQuery.isFetching || + scenarioArtifactsQuery.isFetching || + jiraStatusQuery.isFetching; + + const handleRefresh = () => { + const refreshes: Array> = [refetchApplications(), runsQuery.refetch(), jiraStatusQuery.refetch()]; + + if (runId) { + refreshes.push(runDetailsQuery.refetch(), scenariosQuery.refetch()); + if (runTab === "artifacts") refreshes.push(runArtifactsQuery.refetch()); + } + + if (runId && scenarioId && runTab === "scenarios") { + refreshes.push(scenarioQuery.refetch()); + if (scenarioTab === "events") refreshes.push(scenarioEventsQuery.refetch()); + if (scenarioTab === "artifacts") refreshes.push(scenarioArtifactsQuery.refetch()); + } + + void Promise.all(refreshes); + }; const clearFilters = () => { setSearchText(""); @@ -368,6 +397,8 @@ function RegressionRuns() { applicationName={activeApplication?.name ?? null} latestRunAt={latestRunAt} runCount={filteredRuns.length} + isRefreshing={isRefreshing} + onRefresh={handleRefresh} />
diff --git a/src/features/regression-runs/ui/components/filters/header.tsx b/src/features/regression-runs/ui/components/filters/header.tsx index 96a22d9..cb54abd 100644 --- a/src/features/regression-runs/ui/components/filters/header.tsx +++ b/src/features/regression-runs/ui/components/filters/header.tsx @@ -2,7 +2,9 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -import { AppWindow, Clock3 } from "lucide-react"; +import { AppWindow, Clock3, RefreshCw } from "lucide-react"; +import { Button } from "@shared/ui"; +import { cn } from "@shared/utils/cn"; import styles from "../../RegressionRuns.module.scss"; import { formatDateTime } from "../../../lib/formatters"; @@ -10,10 +12,14 @@ export function RegressionRunsHeader({ applicationName, latestRunAt, runCount, + isRefreshing, + onRefresh, }: { applicationName: string | null; latestRunAt?: string; runCount: number; + isRefreshing: boolean; + onRefresh: () => void; }) { return (
@@ -26,6 +32,17 @@ export function RegressionRunsHeader({
+ {applicationName && (
diff --git a/src/features/target-applications/api/applicationDetailsService.ts b/src/features/target-applications/api/applicationDetailsService.ts index 2f35c8b..b3d7dba 100644 --- a/src/features/target-applications/api/applicationDetailsService.ts +++ b/src/features/target-applications/api/applicationDetailsService.ts @@ -282,6 +282,7 @@ function getStats(sessions: CrawlSession[]): ApplicationDetailStats { const statesDiscovered = crawledSessions.reduce((total, session) => total + (session.statesDiscovered ?? 0), 0); return { + versionCount: 0, crawledCount: crawledSessions.length, statesDiscovered: statesDiscovered, lastCrawlDate: lastSession?.startedAt.slice(0, 10) ?? "—", @@ -420,6 +421,18 @@ export const applicationDetailsService = { return response.data; }, + async deleteCrawlSession(params: { + projectId: string; + applicationId: string; + versionId: string; + sessionId: string; + }): Promise { + const response = await apiClient.delete( + `projects/${params.projectId}/target-applications/${params.applicationId}/versions/${params.versionId}/crawl-sessions/${params.sessionId}`, + ); + return response.data; + }, + async connectManualSession(params: { projectId: string; applicationId: string; @@ -455,11 +468,11 @@ export const applicationDetailsService = { applicationId: string; config: RegressionCodebaseConfig; }): Promise { - const body = { + const body: { frameworkName: string; repositoryUrl: string; apiKey?: string } = { frameworkName: "Playwright", repositoryUrl: params.config.repositoryUrl, - apiKey: params.config.apiKey, }; + if (params.config.apiKey) body.apiKey = params.config.apiKey; if (params.config.id) { await apiClient.put( diff --git a/src/features/target-applications/index.ts b/src/features/target-applications/index.ts index cc12396..210b844 100644 --- a/src/features/target-applications/index.ts +++ b/src/features/target-applications/index.ts @@ -9,6 +9,7 @@ export { useCreateCrawlSession, useConnectManualSession, useDeleteCrawlSchedule, + useDeleteCrawlSession, useReattachManualSession, useSaveCrawlSchedule, useSaveRegressionCodebaseConfig, diff --git a/src/features/target-applications/model/mutations/useApplicationDetailsMutations.ts b/src/features/target-applications/model/mutations/useApplicationDetailsMutations.ts index 1e2c16b..9f3db87 100644 --- a/src/features/target-applications/model/mutations/useApplicationDetailsMutations.ts +++ b/src/features/target-applications/model/mutations/useApplicationDetailsMutations.ts @@ -37,6 +37,9 @@ function invalidateApplicationDetails( queryClient.invalidateQueries({ queryKey: queryKeys.targetApplications.crawlSessions(projectId, applicationId, versionId), }); + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboard.all, + }); } } @@ -114,6 +117,34 @@ export function useStartCrawlSession() { }); } +export function useDeleteCrawlSession() { + const queryClient = useQueryClient(); + + return useMutation< + unknown, + Error, + { projectId: string; applicationId: string; versionId: string; sessionId: string } + >({ + mutationFn: ({ projectId, applicationId, versionId, sessionId }) => + applicationDetailsService.deleteCrawlSession({ projectId, applicationId, versionId, sessionId }), + onSuccess: (_data, variables) => { + toast.success("Crawl session deleted"); + invalidateApplicationDetails(queryClient, variables.projectId, variables.applicationId, variables.versionId); + queryClient.removeQueries({ + queryKey: queryKeys.targetApplications.crawlSession( + variables.projectId, + variables.applicationId, + variables.versionId, + variables.sessionId, + ), + }); + }, + onError: (error) => { + toast.error("Failed to delete crawl session", error.message); + }, + }); +} + export function useConnectManualSession() { return useMutation< ManualSessionConnectResponse, diff --git a/src/features/target-applications/model/types/applicationDetails.types.ts b/src/features/target-applications/model/types/applicationDetails.types.ts index f76e319..3f9d8df 100644 --- a/src/features/target-applications/model/types/applicationDetails.types.ts +++ b/src/features/target-applications/model/types/applicationDetails.types.ts @@ -10,6 +10,7 @@ export type ApplicationDetailTab = "crawl-sessions" | "schedules"; export type ScheduleFrequency = "daily" | "weekly" | "monthly"; export interface ApplicationDetailStats { + versionCount: number; crawledCount: number; statesDiscovered: number; lastCrawlDate: string; diff --git a/src/features/test-flows/ui/TestFlows.module.scss b/src/features/test-flows/ui/TestFlows.module.scss index 5dfc65f..2eb62f4 100644 --- a/src/features/test-flows/ui/TestFlows.module.scss +++ b/src/features/test-flows/ui/TestFlows.module.scss @@ -62,6 +62,14 @@ white-space: nowrap; } +.refreshButton { + min-height: 1.9rem; +} + +.spinIcon { + animation: test-flows-spin 1s linear infinite; +} + .filtersCard { padding: 0.75rem; border: 1px solid var(--border); @@ -390,6 +398,16 @@ } } +@keyframes test-flows-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .generateForm { display: flex; flex-direction: column; diff --git a/src/features/test-flows/ui/TestFlows.tsx b/src/features/test-flows/ui/TestFlows.tsx index 708f0a8..4b93aa4 100644 --- a/src/features/test-flows/ui/TestFlows.tsx +++ b/src/features/test-flows/ui/TestFlows.tsx @@ -199,6 +199,8 @@ function TestFlows() { data: applications = [], isLoading: applicationsLoading, isError: applicationsError, + isFetching: applicationsQueryFetching, + refetch: refetchApplicationsQuery, } = useTargetApplications(selectedProject?.id ?? null); const [searchParams, setSearchParams] = useSearchParams(); const [searchText, setSearchText] = useState(""); @@ -315,6 +317,15 @@ function TestFlows() { const latestFlowAt = getLatestFlowAt(filteredFlows); const nextCursor = flowsQuery.data?.nextCursor ?? null; const currentPage = cursorStack.length + 1; + const isRefreshing = + applicationsLoading || + applicationsQueryFetching || + flowsQuery.isFetching || + regressionCodebasesQuery.isFetching; + + const handleRefresh = () => { + void Promise.all([refetchApplicationsQuery(), flowsQuery.refetch(), regressionCodebasesQuery.refetch()]); + }; const resetPagination = () => { setCursor(null); @@ -361,6 +372,8 @@ function TestFlows() { applicationName={activeApplication?.name ?? null} flowCount={filteredFlows.length} latestFlowAt={latestFlowAt ? formatDateTime(latestFlowAt) : undefined} + isRefreshing={isRefreshing} + onRefresh={handleRefresh} /> void; }) { return (
@@ -24,6 +30,17 @@ export function TestFlowsHeader({
+ {applicationName && (
diff --git a/src/features/user-guides/ui/UserGuides.module.scss b/src/features/user-guides/ui/UserGuides.module.scss index dc7c0ed..e5dea5a 100644 --- a/src/features/user-guides/ui/UserGuides.module.scss +++ b/src/features/user-guides/ui/UserGuides.module.scss @@ -20,6 +20,7 @@ .header { display: flex; align-items: center; + justify-content: space-between; gap: $space-3; min-height: 4.6rem; padding: $space-4 $space-7; @@ -45,6 +46,15 @@ } } +.headerRefreshButton { + flex: 0 0 auto; +} + +.headerRefreshIcon { + width: 0.9rem; + height: 0.9rem; +} + .headerIcon, .emptyIcon { display: inline-flex; diff --git a/src/features/user-guides/ui/UserGuides.tsx b/src/features/user-guides/ui/UserGuides.tsx index f6ee283..9f508bd 100644 --- a/src/features/user-guides/ui/UserGuides.tsx +++ b/src/features/user-guides/ui/UserGuides.tsx @@ -25,6 +25,7 @@ import { useUserGuideStates, useUserGuideVersions, } from "../model/queries/useUserGuides"; +import { cn } from "@shared/utils/cn"; import type { GenerateGuideParams, UserGuideApplication, @@ -136,7 +137,7 @@ function StateOptionRow({ option }: { option: StateOption }) { ); } -function PageHeader() { +function PageHeader({ isRefreshing, onRefresh }: { isRefreshing: boolean; onRefresh: () => void }) { return (
@@ -146,6 +147,17 @@ function PageHeader() { states.

+
); } @@ -213,6 +225,11 @@ function UserGuides() { const applications = useMemo(() => applicationsQuery.data ?? [], [applicationsQuery.data]); const versions = useMemo(() => versionsQuery.data ?? [], [versionsQuery.data]); const states = useMemo(() => statesQuery.data ?? [], [statesQuery.data]); + const isRefreshing = applicationsQuery.isFetching || versionsQuery.isFetching || statesQuery.isFetching; + + const handleRefresh = () => { + void Promise.all([applicationsQuery.refetch(), versionsQuery.refetch(), statesQuery.refetch()]); + }; const selectedApplication = findById(applications, selectedApplicationId); const selectedVersion = findById(versions, selectedVersionId); @@ -386,7 +403,7 @@ function UserGuides() { return (
- +
{!selectedProject ? ( diff --git a/src/pages/Administration/Administration.module.scss b/src/pages/Administration/Administration.module.scss index 83d3533..7672d43 100644 --- a/src/pages/Administration/Administration.module.scss +++ b/src/pages/Administration/Administration.module.scss @@ -35,6 +35,12 @@ gap: $space-3; } +.actionButtons { + display: flex; + gap: $space-2; + align-items: center; +} + .sidebarTitle { display: flex; align-items: center; @@ -561,6 +567,20 @@ height: 1rem; } +.spinIcon { + animation: admin-spin 1s linear infinite; +} + +@keyframes admin-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .iconMedium { width: 1.25rem; height: 1.25rem; diff --git a/src/pages/Administration/Administration.tsx b/src/pages/Administration/Administration.tsx index 3959413..f4bb309 100644 --- a/src/pages/Administration/Administration.tsx +++ b/src/pages/Administration/Administration.tsx @@ -2,39 +2,52 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -import { useEffect, useMemo, useReducer, useRef, useState } from "react"; -import { useSearchParams, useParams } from "react-router-dom"; -import { useQueryClient } from "@tanstack/react-query"; -import { Users, Plus, Settings, ChevronRight, UserPlus, FolderKanban, Edit2, Trash2, LogOut, Plug } from "lucide-react"; -import { Button, toast } from "@shared/ui"; -import { cn } from "@shared/utils/cn"; -import { queryKeys } from "@shared/config/queryKeys"; -import { useProjects } from "@features/projects"; +import { useAuthStore, useUIStore } from "@app/store"; +import type { Member } from "@coveritlabs/contracts"; +import type { ProjectRole } from "@features/projects"; import { + DEFAULT_PROJECT_ROLE, + getProjectUserRole, + normalizeProjectRole, useAddProjectMembers, useCreateProject, useDeleteProject, useLeaveProject, + useProjects, useRemoveProjectMembers, useUpdateProject, useUpdateProjectMember, } from "@features/projects"; -import type { ProjectRole } from "@features/projects"; -import { DEFAULT_PROJECT_ROLE } from "@features/projects"; -import { getProjectUserRole, normalizeProjectRole } from "@features/projects"; +import { queryKeys } from "@shared/config/queryKeys"; import { GRADIENTS } from "@shared/constants/gradients"; +import { Button, toast } from "@shared/ui"; +import { cn } from "@shared/utils/cn"; +import { useQueryClient } from "@tanstack/react-query"; +import { + ChevronRight, + Edit2, + FolderKanban, + LogOut, + Plug, + Plus, + RefreshCw, + Settings, + Trash2, + UserPlus, + Users, +} from "lucide-react"; +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; import styles from "./Administration.module.scss"; -import { AdministrationMembersTable } from "./AdministrationMembersTable"; import { AdministrationIntegrations } from "./AdministrationIntegrations"; +import { AdministrationMembersTable } from "./AdministrationMembersTable"; import { + AddMemberModal, AddProjectModal, - EditProjectModal, DeleteProjectModal, - AddMemberModal, + EditProjectModal, LeaveProjectModal, } from "./AdministrationModals"; -import { useAuthStore, useUIStore } from "@app/store"; -import type { Member } from "@coveritlabs/contracts"; type AdministrationTab = "members" | "integrations"; @@ -55,7 +68,7 @@ const Administration = () => { const { projectId: routeProjectId } = useParams<{ projectId?: string }>(); const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); - const { data: projects = [], isLoading, isError } = useProjects(); + const { data: projects = [], isLoading, isError, isFetching, refetch: refetchProjects } = useProjects(); const createProject = useCreateProject(); const updateProject = useUpdateProject(); const deleteProject = useDeleteProject(); @@ -66,6 +79,7 @@ const Administration = () => { const user = useAuthStore((state) => state.user); const storeSelectedProject = useUIStore((s) => s.selectedProject); + const setStoreSelectedProject = useUIStore((s) => s.setSelectedProject); const setUserRole = useUIStore((s) => s.setUserRole); const [selectedProjectId, setSelectedProjectId] = useState(null); const [editingMemberId, setEditingMemberId] = useState(null); @@ -166,6 +180,13 @@ const Administration = () => { return projects.some((p) => p.id !== ignoreId && p.name.trim().toLowerCase() === normalized); }; + const handleRefresh = () => { + void refetchProjects(); + if (selectedProjectId) { + void queryClient.invalidateQueries({ queryKey: queryKeys.integrations.all }); + } + }; + const handleAddProject = (name: string, description: string) => { createProject.mutate( { name, ...(description && { description }) }, @@ -190,6 +211,10 @@ const Administration = () => { { onSuccess: () => { closeModal(); + if (storeSelectedProject?.id === selectedProject.id) { + setStoreSelectedProject(null); + setUserRole(null); + } setSelectedProjectId(null); }, }, @@ -248,14 +273,27 @@ const Administration = () => {

Administration

- +
+ + +

Manage projects and team members

diff --git a/src/pages/Applications/Applications.module.scss b/src/pages/Applications/Applications.module.scss index c549c93..56f15f0 100644 --- a/src/pages/Applications/Applications.module.scss +++ b/src/pages/Applications/Applications.module.scss @@ -549,6 +549,13 @@ gap: $space-2; } +.secretField { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $space-2; + align-items: center; +} + .modalActions { display: flex; justify-content: flex-end; @@ -1173,6 +1180,10 @@ animation: status-spin 1s linear infinite; } +.spinIcon { + animation: status-spin 1s linear infinite; +} + .sessionApplicationCell { display: flex; flex-direction: column; diff --git a/src/pages/Applications/Applications.tsx b/src/pages/Applications/Applications.tsx index a8ccce8..b092772 100644 --- a/src/pages/Applications/Applications.tsx +++ b/src/pages/Applications/Applications.tsx @@ -26,10 +26,11 @@ import { useCreateTargetApplication, useCreateTargetApplicationVersion, useDeleteCrawlSchedule, + useDeleteCrawlSession, useDeleteTargetApplication, useDeleteTargetApplicationVersion, - useRegressionConfig, useReattachManualSession, + useRegressionConfig, useRotateTargetApplicationApiKey, useSaveCrawlSchedule, useSaveRegressionCodebaseConfig, @@ -38,8 +39,8 @@ import { useToggleCrawlSchedule, useUpdateTargetApplication, } from "@features/target-applications"; -import { GRADIENTS } from "@shared/constants/gradients"; import { ROUTES } from "@shared/config/routes"; +import { GRADIENTS } from "@shared/constants/gradients"; import { Badge, Button, Card, Input } from "@shared/ui"; import { cn } from "@shared/utils/cn"; import type { LucideIcon } from "lucide-react"; @@ -59,6 +60,7 @@ import { Network, Play, Plus, + RefreshCw, Search, Tag, Trash2, @@ -74,6 +76,7 @@ import { AddVersionModal, CreateCrawlSessionModal, DeleteApplicationModal, + DeleteCrawlSessionModal, DeleteScheduleModal, DeleteVersionModal, EditApplicationModal, @@ -93,7 +96,8 @@ type ModalState = | { type: "regressionConfig" } | { type: "createSession" } | { type: "scheduleConfig" } - | { type: "deleteSchedule" }; + | { type: "deleteSchedule" } + | { type: "deleteSession" }; type ModalAction = { type: ModalState["type"] }; @@ -126,6 +130,7 @@ const TRIGGER_FILTERS: Array<{ value: CrawlSessionTriggerFilter; label: string } { value: "all", label: "All Triggers" }, { value: "manual", label: "Manual" }, { value: "scheduled", label: "Scheduled" }, + { value: "on_demand", label: "On Demand" }, ]; const DEFAULT_STATS: ApplicationDetailStats = { @@ -333,9 +338,12 @@ function SessionsTable({ onCreate, canCreate, createDisabledReason, + canDelete, onView, onStart, + onDelete, startingSessionId, + deletingSessionId, }: { sessions: CrawlSession[]; totalSessions: number; @@ -346,9 +354,12 @@ function SessionsTable({ onCreate: () => void; canCreate: boolean; createDisabledReason?: string; + canDelete: boolean; onView: (sessionId: string) => void; onStart: (sessionId: string) => void; + onDelete: (session: CrawlSession) => void; startingSessionId?: string | null; + deletingSessionId?: string | null; }) { return ( @@ -496,6 +507,19 @@ function SessionsTable({ > + {canDelete && ( + + )}
@@ -734,7 +758,13 @@ function LabelText({ children }: { children: ReactNode }) { const Applications = () => { const selectedProject = useUIStore((s) => s.selectedProject); const navigate = useNavigate(); - const { data: applications = [], isLoading, isError } = useTargetApplications(selectedProject?.id ?? null); + const { + data: applications = [], + isLoading, + isError, + isFetching: applicationsFetching, + refetch: refetchApplications, + } = useTargetApplications(selectedProject?.id ?? null); const createTargetApplication = useCreateTargetApplication(); const updateTargetApplication = useUpdateTargetApplication(); const deleteTargetApplication = useDeleteTargetApplication(); @@ -744,6 +774,7 @@ const Applications = () => { const saveRegressionCodebaseConfig = useSaveRegressionCodebaseConfig(); const createCrawlSession = useCreateCrawlSession(); const startCrawlSession = useStartCrawlSession(); + const deleteCrawlSession = useDeleteCrawlSession(); const reattachManualSession = useReattachManualSession(); const saveCrawlSchedule = useSaveCrawlSchedule(); const toggleCrawlSchedule = useToggleCrawlSchedule(); @@ -760,8 +791,10 @@ const Applications = () => { const [triggerFilter, setTriggerFilter] = useState("all"); const [editingSchedule, setEditingSchedule] = useState(null); const [scheduleToDelete, setScheduleToDelete] = useState(null); + const [sessionToDelete, setSessionToDelete] = useState(null); const [selectedSessionId, setSelectedSessionId] = useState(null); const [sessionFormInitialData, setSessionFormInitialData] = useState(undefined); + const [savedRegressionApiKeys, setSavedRegressionApiKeys] = useState>({}); const closeModal = () => dispatchModal({ type: "none" }); const typedApplications = useMemo(() => applications as ApplicationView[], [applications]); @@ -802,7 +835,11 @@ const Applications = () => { const isAdmin = userRole === "ADMIN"; const isMember = userRole === "ADMIN" || userRole === "MEMBER"; - const { data: applicationDetails } = useApplicationDetails( + const { + data: applicationDetails, + isFetching: applicationDetailsFetching, + refetch: refetchApplicationDetails, + } = useApplicationDetails( selectedProject?.id ?? null, selectedApplication?.id ?? null, selectedVersion?.id ?? null, @@ -811,7 +848,11 @@ const Applications = () => { selectedApplication?.baseUrl ?? "", selectedVersion?.version, ); - const { data: queriedSessions = [] } = useCrawlSessions( + const { + data: queriedSessions = [], + isFetching: sessionsFetching, + refetch: refetchSessions, + } = useCrawlSessions( selectedProject?.id ?? null, selectedApplication?.id ?? null, selectedVersion?.id ?? null, @@ -819,14 +860,16 @@ const Applications = () => { selectedApplication?.baseUrl ?? "", selectedVersion?.version, ); - const { data: queriedRegressionConfig } = useRegressionConfig( - selectedProject?.id ?? null, - selectedApplication?.id ?? null, - ); - const { data: queriedSchedules = [] } = useCrawlSchedules( - selectedProject?.id ?? null, - selectedApplication?.id ?? null, - ); + const { + data: queriedRegressionConfig, + isFetching: regressionConfigFetching, + refetch: refetchRegressionConfig, + } = useRegressionConfig(selectedProject?.id ?? null, selectedApplication?.id ?? null); + const { + data: queriedSchedules = [], + isFetching: schedulesFetching, + refetch: refetchSchedules, + } = useCrawlSchedules(selectedProject?.id ?? null, selectedApplication?.id ?? null); const { data: detailedSelectedSession } = useCrawlSession( selectedProject?.id ?? null, selectedApplication?.id ?? null, @@ -836,7 +879,11 @@ const Applications = () => { selectedApplication?.baseUrl ?? "", selectedVersion?.version, ); - const regressionConfig = queriedRegressionConfig; + const regressionConfig = useMemo(() => { + if (!queriedRegressionConfig) return queriedRegressionConfig; + const savedApiKey = savedRegressionApiKeys[queriedRegressionConfig.id ?? ""]; + return savedApiKey ? { ...queriedRegressionConfig, apiKey: savedApiKey } : queriedRegressionConfig; + }, [queriedRegressionConfig, savedRegressionApiKeys]); const schedules = queriedSchedules; useEffect(() => { @@ -891,6 +938,23 @@ const Applications = () => { [queriedSessions, statusFilter, triggerFilter], ); + const isRefreshing = + applicationsFetching || + applicationDetailsFetching || + sessionsFetching || + regressionConfigFetching || + schedulesFetching; + + const handleRefresh = () => { + void Promise.all([ + refetchApplications(), + refetchApplicationDetails(), + refetchSessions(), + refetchRegressionConfig(), + refetchSchedules(), + ]); + }; + const isApplicationNameDuplicate = (name: string, ignoreId?: string) => { const normalized = name.trim().toLowerCase(); if (!normalized) return false; @@ -995,7 +1059,14 @@ const Applications = () => { versionId: selectedVersion?.id, config, }, - { onSuccess: closeModal }, + { + onSuccess: (savedConfig) => { + if (config.apiKey && savedConfig.id) { + setSavedRegressionApiKeys((keys) => ({ ...keys, [savedConfig.id!]: config.apiKey! })); + } + closeModal(); + }, + }, ); }; @@ -1035,6 +1106,31 @@ const Applications = () => { }); }; + const handleOpenDeleteSession = (session: CrawlSession) => { + setSessionToDelete(session); + dispatchModal({ type: "deleteSession" }); + }; + + const handleConfirmDeleteSession = () => { + if (!selectedProject || !selectedApplication || !selectedVersion || !sessionToDelete) return; + + deleteCrawlSession.mutate( + { + projectId: selectedProject.id, + applicationId: selectedApplication.id, + versionId: selectedVersion.id, + sessionId: sessionToDelete.id, + }, + { + onSuccess: () => { + if (selectedSessionId === sessionToDelete.id) setSelectedSessionId(null); + setSessionToDelete(null); + closeModal(); + }, + }, + ); + }; + const handleOpenAddSchedule = () => { setEditingSchedule(null); dispatchModal({ type: "scheduleConfig" }); @@ -1187,16 +1283,31 @@ const Applications = () => {

Applications

- {isAdmin && ( + +
- )} + + {isAdmin && ( + + )} +
@@ -1415,9 +1526,12 @@ const Applications = () => { ? "Select a version before creating a session." : "Configure a codebase first." } + canDelete={isAdmin} onView={setSelectedSessionId} onStart={handleStartSession} + onDelete={handleOpenDeleteSession} startingSessionId={startCrawlSession.variables?.sessionId ?? null} + deletingSessionId={deleteCrawlSession.variables?.sessionId ?? null} /> ) : ( { }} /> )} + + {modal.type === "deleteSession" && sessionToDelete && ( + { + if (deleteCrawlSession.isPending) return; + setSessionToDelete(null); + closeModal(); + }} + /> + )}
); }; diff --git a/src/pages/Applications/ApplicationsModals.tsx b/src/pages/Applications/ApplicationsModals.tsx index 2245097..8882cd0 100644 --- a/src/pages/Applications/ApplicationsModals.tsx +++ b/src/pages/Applications/ApplicationsModals.tsx @@ -3,7 +3,7 @@ // See LICENSE file in the application root for full license information. import { type MouseEvent, useState } from "react"; -import { Check, Copy, Plus, Trash2, X } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, Plus, Trash2, X } from "lucide-react"; import { Button, Input, Label, Select } from "@shared/ui"; import type { CreateCrawlSessionInput, @@ -462,6 +462,7 @@ export const RegressionCodebaseConfigModal = ({ }: RegressionCodebaseConfigModalProps) => { const [repositoryUrl, setRepositoryUrl] = useState(initialConfig.repositoryUrl); const [apiKey, setApiKey] = useState(initialConfig.apiKey ?? ""); + const [apiKeyVisible, setApiKeyVisible] = useState(false); const canSave = repositoryUrl.trim().length > 0; @@ -502,12 +503,26 @@ export const RegressionCodebaseConfigModal = ({
- setApiKey(event.target.value)} - /> +
+ setApiKey(event.target.value)} + placeholder={initialConfig.id && !initialConfig.apiKey ? "Leave blank to keep saved key" : undefined} + /> + +

GitHub token or other auth credential for pushing generated tests

@@ -900,6 +915,45 @@ export const CreateCrawlSessionModal = ({ initialData, onConfirm, onClose }: Cre ); }; +interface DeleteCrawlSessionModalProps { + sessionId: string; + isDeleting: boolean; + onConfirm: () => void; + onClose: () => void; +} + +export const DeleteCrawlSessionModal = ({ + sessionId, + isDeleting, + onConfirm, + onClose, +}: DeleteCrawlSessionModalProps) => ( +
closeOnBackdropMouseDown(event, onClose)}> +
e.stopPropagation()}> +
+

Delete Crawl Session

+ +
+
+

+ Delete crawl session {sessionId}? Active sessions will be aborted before they + are removed. +

+
+ + +
+
+
+
+); + interface ScheduleConfigModalProps { initialSchedule?: CrawlSchedule | null; onConfirm: (frequency: ScheduleFrequency, runTimeUtc: string) => void; diff --git a/src/pages/Dashboard/Dashboard.module.scss b/src/pages/Dashboard/Dashboard.module.scss index ab03148..28b5a64 100644 --- a/src/pages/Dashboard/Dashboard.module.scss +++ b/src/pages/Dashboard/Dashboard.module.scss @@ -44,11 +44,24 @@ .headerControls { display: grid; - grid-template-columns: auto minmax(15rem, 18rem); + grid-template-columns: auto auto minmax(15rem, 18rem); gap: $space-2; align-items: center; } +.refreshButton { + justify-self: end; +} + +.refreshIcon { + width: 1rem; + height: 1rem; +} + +.spinIcon { + animation: dashboard-spin 1s linear infinite; +} + .versionLabel { color: var(--text-secondary); font-size: 0.76rem; @@ -395,6 +408,16 @@ font-size: 0.78rem; } +@keyframes dashboard-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .emptyState { display: flex; flex-direction: column; @@ -418,6 +441,30 @@ } } +.plainEmptyState { + display: flex; + flex: 1; + min-height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $space-3; + padding: $space-6; + text-align: center; + + h3 { + margin: 0; + color: var(--text-primary); + font-size: 1.35rem; + } + + p { + max-width: 28rem; + margin: 0; + color: var(--text-secondary); + } +} + .emptyIcon { width: 2rem; height: 2rem; diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx index 80641a0..3e22a90 100644 --- a/src/pages/Dashboard/Dashboard.tsx +++ b/src/pages/Dashboard/Dashboard.tsx @@ -3,22 +3,25 @@ // See LICENSE file in the project root for full license information. import { useEffect, useMemo, useState, type ReactNode } from "react"; -import type { - ProjectActivity, - ProjectCoverageSummary, - ProjectLatestCrawlSession, - ProjectLatestRun, - ProjectLatestTestFlow, - ProjectRunStatistics, - TargetApplicationResponse, -} from "@coveritlabs/contracts"; -import { Activity, AlertTriangle, CheckCircle2, Clock3, FlaskConical, XCircle } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import type { TargetApplicationResponse } from "@coveritlabs/contracts"; +import { Activity, AlertTriangle, CheckCircle2, Clock3, FlaskConical, RefreshCw, XCircle } from "lucide-react"; import { useUIStore } from "@app/store"; -import { useProjectDashboard } from "@features/dashboard"; +import { + useProjectDashboard, + type ProjectActivity, + type ProjectCoverageSummary, + type ProjectLatestCrawlSession, + type ProjectLatestRun, + type ProjectLatestTestFlow, + type ProjectRunStatistics, +} from "@features/dashboard"; +import { useProjects } from "@features/projects"; import { useTargetApplications } from "@features/target-applications"; +import { ROUTES } from "@shared/config/routes"; import { ContentErrorPanel } from "@shared/feedback/ContentErrorPanel"; import { PageLoader } from "@shared/feedback/PageLoader/PageLoader"; -import { Badge, Card, Select } from "@shared/ui"; +import { Badge, Button, Card, Select } from "@shared/ui"; import { cn } from "@shared/utils/cn"; import styles from "./Dashboard.module.scss"; @@ -82,16 +85,43 @@ function getActivityLabel(eventType: string) { return titleCase(eventType.replace(/\./g, " ")); } -function EmptyState({ title, description }: { title: string; description: string }) { +function EmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: ReactNode; +}) { return (

{title}

{description}

+ {action}
); } +function PlainEmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: ReactNode; +}) { + return ( +
+

{title}

+

{description}

+ {action} +
+ ); +} + function KpiCard({ label, value, @@ -244,18 +274,32 @@ function buildVersionOptions(applications: TargetApplicationResponse[]): Version } function Dashboard() { + const navigate = useNavigate(); const selectedProject = useUIStore((state) => state.selectedProject); + const setSelectedProject = useUIStore((state) => state.setSelectedProject); + const setUserRole = useUIStore((state) => state.setUserRole); + const { data: projects = [], isLoading: projectsLoading } = useProjects(); const [selectedVersionId, setSelectedVersionId] = useState(LATEST_VERSION_VALUE); + const selectedProjectExists = Boolean( + selectedProject && projects.some((project) => project.id === selectedProject.id), + ); + const activeProject = projectsLoading || selectedProjectExists ? selectedProject : null; const { data: applications = [], isLoading: applicationsLoading, isError: applicationsError, - } = useTargetApplications(selectedProject?.id ?? null); + refetch: refetchApplications, + } = useTargetApplications(activeProject?.id ?? null); const versionOptions = useMemo(() => buildVersionOptions(applications), [applications]); const resolvedVersionId = selectedVersionId === LATEST_VERSION_VALUE ? undefined : selectedVersionId; - const dashboardQuery = useProjectDashboard(selectedProject?.id ?? null, resolvedVersionId); + const dashboardQuery = useProjectDashboard(activeProject?.id ?? null, resolvedVersionId); const dashboard = dashboardQuery.data; + const isRefreshing = applicationsLoading || dashboardQuery.isFetching; + + const handleRefresh = () => { + void Promise.all([dashboardQuery.refetch(), refetchApplications()]); + }; useEffect(() => { if (selectedVersionId === LATEST_VERSION_VALUE) return; @@ -263,8 +307,30 @@ function Dashboard() { if (!versionExists) setSelectedVersionId(LATEST_VERSION_VALUE); }, [selectedVersionId, versionOptions]); - if (!selectedProject) { - return ; + useEffect(() => { + if (projectsLoading || !selectedProject || selectedProjectExists) return; + setSelectedProject(null); + setUserRole(null); + }, [projectsLoading, selectedProject, selectedProjectExists, setSelectedProject, setUserRole]); + + if (!activeProject) { + if (!projectsLoading && projects.length === 0) { + return ( + navigate(ROUTES.ADMINISTRATE)}> + Create Project + + } + /> + ); + } + + return ( + + ); } if (applicationsLoading && !dashboard) { @@ -300,10 +366,21 @@ function Dashboard() {
-

{selectedProject.name}

+

{activeProject.name}

Project scope dashboard

+ Version