Compare commits

..

3 Commits

Author SHA1 Message Date
2f0a247c11 Describe query 2025-07-14 18:01:35 -04:00
513f942aae Add batching to not lock DB 2025-07-14 00:39:34 -04:00
218408493a Optimize token cleanup job 2025-07-11 22:05:32 -04:00
7 changed files with 415 additions and 288 deletions

View File

@ -30,10 +30,17 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
const removeExpiredTokens = async (tx?: Knex) => { const removeExpiredTokens = async (tx?: Knex) => {
logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token started`); logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token started`);
const BATCH_SIZE = 10000;
const MAX_RETRY_ON_FAILURE = 3;
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const MAX_TTL = 315_360_000; // Maximum TTL value in seconds (10 years) const MAX_TTL = 315_360_000; // Maximum TTL value in seconds (10 years)
try { let deletedTokenIds: { id: string }[] = [];
const docs = (tx || db)(TableName.IdentityAccessToken) let numberOfRetryOnFailure = 0;
let isRetrying = false;
const getExpiredTokensQuery = (dbClient: Knex | Knex.Transaction) =>
dbClient(TableName.IdentityAccessToken)
.where({ .where({
isAccessTokenRevoked: true isAccessTokenRevoked: true
}) })
@ -47,34 +54,64 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
); );
}) })
.orWhere((qb) => { .orWhere((qb) => {
void qb.where("accessTokenTTL", ">", 0).andWhere((qb2) => { void qb.where("accessTokenTTL", ">", 0).andWhereRaw(
void qb2 `
.where((qb3) => { -- Check if the token's effective expiration time has passed.
void qb3 -- The expiration time is calculated by adding its TTL to its last renewal/creation time.
.whereNotNull("accessTokenLastRenewedAt") COALESCE(
// accessTokenLastRenewedAt + convert_integer_to_seconds(accessTokenTTL) < present_date "${TableName.IdentityAccessToken}"."accessTokenLastRenewedAt", -- Use last renewal time if available
.andWhereRaw( "${TableName.IdentityAccessToken}"."createdAt" -- Otherwise, use creation time
`"${TableName.IdentityAccessToken}"."accessTokenLastRenewedAt" + make_interval(secs => LEAST("${TableName.IdentityAccessToken}"."accessTokenTTL", ?)) < NOW()`, )
[MAX_TTL] + make_interval(
); secs => LEAST(
}) "${TableName.IdentityAccessToken}"."accessTokenTTL", -- Token's specified TTL
.orWhere((qb3) => { ? -- Capped by MAX_TTL (parameterized value)
void qb3 )
.whereNull("accessTokenLastRenewedAt") )
// created + convert_integer_to_seconds(accessTokenTTL) < present_date < NOW() -- Check if the calculated time is before now
.andWhereRaw( `,
`"${TableName.IdentityAccessToken}"."createdAt" + make_interval(secs => LEAST("${TableName.IdentityAccessToken}"."accessTokenTTL", ?)) < NOW()`,
[MAX_TTL] [MAX_TTL]
); );
}); });
do {
try {
const deleteBatch = async (dbClient: Knex | Knex.Transaction) => {
const idsToDeleteQuery = getExpiredTokensQuery(dbClient).select("id").limit(BATCH_SIZE);
return dbClient(TableName.IdentityAccessToken).whereIn("id", idsToDeleteQuery).del().returning("id");
};
if (tx) {
// eslint-disable-next-line no-await-in-loop
deletedTokenIds = await deleteBatch(tx);
} else {
// eslint-disable-next-line no-await-in-loop
deletedTokenIds = await db.transaction(async (trx) => {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
return deleteBatch(trx);
}); });
})
.delete();
await docs;
logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token completed`);
} catch (error) {
throw new DatabaseError({ error, name: "IdentityAccessTokenPrune" });
} }
numberOfRetryOnFailure = 0; // reset
} catch (error) {
numberOfRetryOnFailure += 1;
logger.error(error, "Failed to delete a batch of expired identity access tokens on pruning");
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;
} while (deletedTokenIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) {
logger.error(
`IdentityAccessTokenPrune: Pruning failed and stopped after ${MAX_RETRY_ON_FAILURE} consecutive retries.`
);
}
logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token completed`);
}; };
return { ...identityAccessTokenOrm, findOne, removeExpiredTokens }; return { ...identityAccessTokenOrm, findOne, removeExpiredTokens };

View File

@ -1,9 +1,8 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { Commit, CommitHistoryItem, CommitWithChanges, RollbackPreview } from "./types"; import { CommitHistoryItem, CommitWithChanges, RollbackPreview } from "./types";
export const commitKeys = { export const commitKeys = {
count: ({ count: ({
@ -243,6 +242,7 @@ export const useGetFolderCommitHistory = ({
workspaceId, workspaceId,
environment, environment,
directory, directory,
offset = 0,
limit = 20, limit = 20,
search, search,
sort = "desc" sort = "desc"
@ -250,33 +250,22 @@ export const useGetFolderCommitHistory = ({
workspaceId: string; workspaceId: string;
environment: string; environment: string;
directory: string; directory: string;
offset?: number;
limit?: number; limit?: number;
search?: string; search?: string;
sort?: "asc" | "desc"; sort?: "asc" | "desc";
}) => { }) => {
return useInfiniteQuery({ return useQuery({
initialPageParam: 0, queryKey: [
queryKey: [commitKeys.history({ workspaceId, environment, directory }), limit, search, sort], commitKeys.history({ workspaceId, environment, directory }),
queryFn: ({ pageParam }) => offset,
fetchFolderCommitHistory(workspaceId, environment, directory, pageParam, limit, search, sort), limit,
enabled: Boolean(workspaceId && environment), search,
select: (data) => { sort
return (data?.pages ?? []) ],
?.map((page) => page.commits) queryFn: () =>
.flat() fetchFolderCommitHistory(workspaceId, environment, directory, offset, limit, search, sort),
.reduce( enabled: Boolean(workspaceId && environment)
(acc, commit) => {
const date = format(new Date(commit.createdAt), "MMM d, yyyy");
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(commit);
return acc;
},
{} as Record<string, Commit[]>
);
},
getNextPageParam: (lastPage, pages) => (lastPage.hasMore ? pages.length * limit : undefined)
}); });
}; };

View File

@ -62,16 +62,3 @@ export type RollbackPreview = {
folderPath: string; folderPath: string;
changes: RollbackChange[]; changes: RollbackChange[];
}; };
interface CommitActorMetadata {
email?: string;
name?: string;
}
export interface Commit {
id: string;
message: string;
createdAt: string;
actorType: string;
actorMetadata?: CommitActorMetadata;
}

View File

@ -1,10 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
faAngleDown,
faChevronLeft,
faCodeCommit,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu"; import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
import { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
@ -12,14 +7,12 @@ import { useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button,
ContentLoader,
DeleteActionModal, DeleteActionModal,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, IconButton,
PageHeader Spinner
} from "@app/components/v2"; } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
import { import {
@ -115,25 +108,25 @@ export const CommitDetailsTab = ({
// If no commit is selected or data is loading, show appropriate message // If no commit is selected or data is loading, show appropriate message
if (!selectedCommitId) { if (!selectedCommitId) {
return ( return (
<EmptyState className="mt-40" title="Select a commit to view details." icon={faCodeCommit}> <div className="flex h-64 items-center justify-center">
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}> <p className="text-gray-400">Select a commit to view details</p>
Back to Commits </div>
</Button>
</EmptyState>
); );
} }
if (isLoading) { if (isLoading) {
return <ContentLoader />; return (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" />
</div>
);
} }
if (!commitDetails) { if (!commitDetails) {
return ( return (
<EmptyState className="mt-40" title="No details found for this commit." icon={faCodeCommit}> <div className="flex h-64 items-center justify-center">
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}> <p className="text-gray-400">No details found for this commit</p>
Back to Commits </div>
</Button>
</EmptyState>
); );
} }
@ -145,11 +138,9 @@ export const CommitDetailsTab = ({
} catch (error) { } catch (error) {
console.error("Failed to parse commit details:", error); console.error("Failed to parse commit details:", error);
return ( return (
<EmptyState className="mt-40" title="Error parsing commit details." icon={faWarning}> <div className="flex h-64 items-center justify-center">
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}> <p className="text-gray-400">Error parsing commit details</p>
Back to Commits </div>
</Button>
</EmptyState>
); );
} }
@ -232,12 +223,13 @@ export const CommitDetailsTab = ({
// Render an item from the merged list // Render an item from the merged list
const renderMergedItem = (item: MergedItem): JSX.Element => { const renderMergedItem = (item: MergedItem): JSX.Element => {
return ( return (
<div key={item.id} className="mb-2">
<SecretVersionDiffView <SecretVersionDiffView
key={item.id}
item={item} item={item}
isCollapsed={collapsedItems[item.id]} isCollapsed={collapsedItems[item.id]}
onToggleCollapse={(id) => toggleItemCollapsed(id)} onToggleCollapse={(id) => toggleItemCollapsed(id)}
/> />
</div>
); );
}; };
@ -248,29 +240,34 @@ export const CommitDetailsTab = ({
"Unknown"; "Unknown";
return ( return (
<> <div className="w-full">
<Button <div>
variant="link" <div className="flex justify-between pb-2">
type="submit" <div className="w-5/6">
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />} <div>
onClick={() => { <div className="flex items-center">
goBackToHistory(); <h1 className="mr-4 truncate text-3xl font-semibold text-white">
}} {parsedCommitDetails.changes?.message || "No message"}
> </h1>
Commit History </div>
</Button> </div>
<PageHeader <div className="font-small mb-4 mt-2 flex items-center text-sm">
title={`${parsedCommitDetails.changes?.message}` || "No message"} <p>
description={ <span> Commited by </span>
<> <b>{actorDisplay}</b>
Commited by {actorDisplay} on{" "} <span> on </span>
{formatDisplayDate(parsedCommitDetails.changes?.createdAt || new Date().toISOString())} <b>
{parsedCommitDetails.changes?.isLatest && ( {formatDisplayDate(
<span className="ml-1 text-mineshaft-400">(Latest)</span> parsedCommitDetails.changes?.createdAt || new Date().toISOString()
)} )}
</> </b>
} {parsedCommitDetails.changes?.isLatest && (
> <span className="ml-1 italic text-gray-400">(Latest)</span>
)}
</p>
</div>
</div>
<div className="flex items-center justify-start">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionCommitsActions.PerformRollback} I={ProjectPermissionCommitsActions.PerformRollback}
a={ProjectPermissionSub.Commits} a={ProjectPermissionSub.Commits}
@ -282,19 +279,24 @@ export const CommitDetailsTab = ({
disabled={!isAllowed} disabled={!isAllowed}
className={`${!isAllowed ? "cursor-not-allowed" : ""}`} className={`${!isAllowed ? "cursor-not-allowed" : ""}`}
> >
<Button <IconButton
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />} ariaLabel="commit-options"
variant="solid" variant="outline_bg"
className="h-min" className="h-10 rounded border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-medium"
colorSchema="secondary"
> >
Restore Options <p className="mr-2">Restore Options</p>
</Button> <FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-w-sm bg-bunker-500" sideOffset={2}> <DropdownMenuContent
align="end"
sideOffset={2}
className="animate-in fade-in-50 zoom-in-95 min-w-[240px] rounded-md bg-mineshaft-800 p-1 shadow-lg"
style={{ marginTop: "0" }}
>
{!parsedCommitDetails.changes.isLatest && ( {!parsedCommitDetails.changes.isLatest && (
<DropdownMenuItem <DropdownMenuItem
className="group cursor-pointer border-b border-mineshaft-600 px-3 py-3 transition-colors hover:bg-mineshaft-700" className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
onClick={() => goToRollbackPreview()} onClick={() => goToRollbackPreview()}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
@ -302,7 +304,7 @@ export const CommitDetailsTab = ({
<span className="text-sm font-medium text-white"> <span className="text-sm font-medium text-white">
Roll back to this commit Roll back to this commit
</span> </span>
<span className="whitespace-normal break-words text-xs leading-snug text-gray-400"> <span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
Return this folder to its exact state at the time of this commit, Return this folder to its exact state at the time of this commit,
discarding all other changes made after it discarding all other changes made after it
</span> </span>
@ -310,14 +312,15 @@ export const CommitDetailsTab = ({
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem <DropdownMenuItem
className="group cursor-pointer px-3 py-3 transition-colors hover:bg-mineshaft-700" className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
onClick={() => handlePopUpOpen("revertChanges")} onClick={() => handlePopUpOpen("revertChanges")}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-white">Revert changes</span> <span className="text-sm font-medium text-white">Revert changes</span>
<span className="whitespace-normal break-words text-xs leading-snug text-gray-400"> <span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
Will restore to the previous version of affected resources Will restore to the previous version of affected resources
</span> </span>
</div> </div>
@ -327,25 +330,24 @@ export const CommitDetailsTab = ({
</DropdownMenu> </DropdownMenu>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</PageHeader>
<div className="flex w-full flex-col rounded-lg border border-mineshaft-600 bg-mineshaft-900 pt-4">
<div className="mx-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Commit Changes</h3>
</div> </div>
<div className="flex flex-col overflow-hidden px-4"> </div>
<div className="thin-scrollbar overflow-y-auto py-4">
<div className="py-2">
<div className="overflow-hidden">
<div className="space-y-2">
{sortedChangedItems.length > 0 ? ( {sortedChangedItems.length > 0 ? (
sortedChangedItems.map((item) => renderMergedItem(item)) sortedChangedItems.map((item) => renderMergedItem(item))
) : ( ) : (
<EmptyState <div className="flex h-32 items-center justify-center rounded-lg border border-mineshaft-600 bg-mineshaft-800">
title="No changes found." <p className="text-gray-400">No changed items found</p>
className="h-full pb-0 pt-28" </div>
icon={faCodeCommit}
/>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.revertChanges.isOpen} isOpen={popUp.revertChanges.isOpen}
deleteKey="revert" deleteKey="revert"
@ -355,6 +357,6 @@ export const CommitDetailsTab = ({
onDeleteApproved={handleRevertChanges} onDeleteApproved={handleRevertChanges}
buttonText="Yes, revert changes" buttonText="Yes, revert changes"
/> />
</> </div>
); );
}; };

View File

@ -225,9 +225,12 @@ const renderJsonWithDiffs = (
const getLineClass = (different: boolean) => { const getLineClass = (different: boolean) => {
if (!different) return "flex"; if (!different) return "flex";
return isOldVersion return isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300";
? "flex bg-red-500/50 rounded-sm text-red-300" };
: "flex bg-green-500/50 rounded-sm text-green-300";
const getHighlightClass = (different: boolean) => {
if (!different) return "";
return isOldVersion ? "bg-red-900 rounded px-1" : "bg-green-900 rounded px-1";
}; };
const prefix = isDifferent ? (isOldVersion ? "-" : "+") : " "; const prefix = isDifferent ? (isOldVersion ? "-" : "+") : " ";
@ -252,8 +255,8 @@ const renderJsonWithDiffs = (
<div className="w-4 flex-shrink-0">{prefix}</div> <div className="w-4 flex-shrink-0">{prefix}</div>
<div> <div>
{indent} {indent}
{keyName && <span>{keyDisplay}</span>} {keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
<span>{valueDisplay}</span> <span className={getHighlightClass(isDifferent)}>{valueDisplay}</span>
{comma} {comma}
</div> </div>
</div> </div>
@ -266,8 +269,8 @@ const renderJsonWithDiffs = (
<div className="w-4 flex-shrink-0">{prefix}</div> <div className="w-4 flex-shrink-0">{prefix}</div>
<div> <div>
{indent} {indent}
{keyName && <span>{keyDisplay}</span>} {keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
<span>[]</span> <span className={getHighlightClass(isDifferent)}>[]</span>
{comma} {comma}
</div> </div>
</div> </div>
@ -280,8 +283,8 @@ const renderJsonWithDiffs = (
<div className="w-4 flex-shrink-0">{prefix}</div> <div className="w-4 flex-shrink-0">{prefix}</div>
<div> <div>
{indent} {indent}
{keyName && <span>{keyDisplay}</span>} {keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
<span>{"{}"}</span> <span className={getHighlightClass(isDifferent)}>{"{}"}</span>
{comma} {comma}
</div> </div>
</div> </div>
@ -321,8 +324,12 @@ const renderJsonWithDiffs = (
</div> </div>
<div> <div>
{indent} {indent}
{keyName && <span>{keyDisplay}</span>} {keyName && (
<span>[</span> <span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
{keyDisplay}
</span>
)}
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>[</span>
</div> </div>
</div> </div>
@ -354,7 +361,7 @@ const renderJsonWithDiffs = (
</div> </div>
<div> <div>
{indent} {indent}
<span>]</span> <span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>]</span>
{comma} {comma}
</div> </div>
</div> </div>
@ -373,8 +380,12 @@ const renderJsonWithDiffs = (
</div> </div>
<div> <div>
{indent} {indent}
{keyName && <span>{keyDisplay}</span>} {keyName && (
<span>{"{"}</span> <span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
{keyDisplay}
</span>
)}
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"{"}</span>
</div> </div>
</div> </div>
@ -407,7 +418,7 @@ const renderJsonWithDiffs = (
</div> </div>
<div> <div>
{indent} {indent}
<span>{"}"}</span> <span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"}"}</span>
{comma} {comma}
</div> </div>
</div> </div>
@ -614,21 +625,22 @@ export const SecretVersionDiffView = ({
}; };
return ( return (
<div className="overflow-hidden border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t last:rounded-b last:border-b"> <div className="overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-800">
{showHeader && renderHeader()} {showHeader && renderHeader()}
{!collapsed && ( {!collapsed && (
<div className="border-t border-mineshaft-700 bg-mineshaft-900 p-3 text-mineshaft-100"> <div className="border-t border-mineshaft-700 bg-mineshaft-900 px-6 py-4">
<div className="flex gap-3"> <div className="grid grid-cols-2 gap-4">
<div <div
ref={oldContainerRef} ref={oldContainerRef}
className="thin-scrollbar max-h-96 flex-1 overflow-auto whitespace-pre" className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
> >
{oldVersionContent} {oldVersionContent}
</div> </div>
<div className="max-h-96 w-[0.05rem] self-stretch bg-mineshaft-600" />
<div <div
ref={newContainerRef} ref={newContainerRef}
className="thin-scrollbar max-h-96 flex-1 overflow-auto whitespace-pre" className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
> >
{newVersionContent} {newVersionContent}
</div> </div>

View File

@ -52,7 +52,7 @@ export const CommitsPage = () => {
title="Commits" title="Commits"
description="Track, inspect, and restore your secrets and folders with confidence. View the complete history of changes made to your environment, examine specific modifications at each commit point, and preview the exact impact before rolling back to previous states." description="Track, inspect, and restore your secrets and folders with confidence. View the complete history of changes made to your environment, examine specific modifications at each commit point, and preview the exact impact before rolling back to previous states."
/> />
<NoticeBannerV2 title="Secret Snapshots Update" className="mb-2"> <NoticeBannerV2 title="" className="mb-2">
<p className="my-1 text-sm text-mineshaft-300"> <p className="my-1 text-sm text-mineshaft-300">
Secret Snapshots have been officially renamed to Commits. Going forward, all secret Secret Snapshots have been officially renamed to Commits. Going forward, all secret
changes will be tracked as Commits. If you made changes before this update, you can changes will be tracked as Commits. If you made changes before this update, you can

View File

@ -1,17 +1,29 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
faArrowDownWideShort, faArrowDownWideShort,
faArrowUpWideShort, faArrowUpWideShort,
faCodeCommit,
faCopy, faCopy,
faSearch faSearch
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { Button, ContentLoader, EmptyState, IconButton, Input } from "@app/components/v2"; import { Button, Input, Spinner } from "@app/components/v2";
import { CopyButton } from "@app/components/v2/CopyButton"; import { CopyButton } from "@app/components/v2/CopyButton";
import { Commit, useGetFolderCommitHistory } from "@app/hooks/api/folderCommits"; import { useGetFolderCommitHistory } from "@app/hooks/api/folderCommits";
interface CommitActorMetadata {
email?: string;
name?: string;
}
interface Commit {
id: string;
message: string;
createdAt: string;
actorType: string;
actorMetadata?: CommitActorMetadata;
}
const formatTimeAgo = (timestamp: string): string => { const formatTimeAgo = (timestamp: string): string => {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
@ -28,40 +40,58 @@ const CommitItem = ({
onSelectCommit: (commitId: string, tab: string) => void; onSelectCommit: (commitId: string, tab: string) => void;
}) => { }) => {
return ( return (
<button <div className="border-b border-zinc-800 last:border-b-0">
type="button" <div className="px-4 py-4 transition-colors duration-200 hover:bg-zinc-800">
<div className="flex flex-col sm:flex-row sm:justify-between">
<div className="w-5/6 flex-1">
<div className="flex items-center">
<Button
variant="link"
className="truncate text-left text-white hover:underline"
isFullWidth
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSelectCommit(commit.id, "tab-commit-details"); onSelectCommit(commit.id, "tab-commit-details");
}} }}
className="w-full border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md last:border-b"
> >
<div className="flex gap-2 px-4 py-3 transition-colors duration-200 hover:bg-zinc-800">
<div className="flex min-w-0 flex-1 flex-col items-start">
<p className="block w-full truncate text-left text-sm text-mineshaft-100">
{commit.message} {commit.message}
</p> </Button>
<p className="text-left text-xs text-mineshaft-300"> </div>
{commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType}{" "} <p className="text-white-400 mt-2 flex flex-wrap items-center gap-4 text-sm">
committed <time dateTime={commit.createdAt}>{formatTimeAgo(commit.createdAt)}</time> <span className="flex items-center text-mineshaft-300">
{commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType}
<p className="ml-1 mr-1">committed</p>
<time dateTime={commit.createdAt}>{formatTimeAgo(commit.createdAt)}</time>
</span>
</p> </p>
</div> </div>
<div className="mt-2 flex w-1/6 items-center justify-end sm:mt-0">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-1">
<code className="mt-0.5 font-mono text-xs text-mineshaft-400"> <Button
variant="link"
className="text-white hover:underline"
onClick={(e) => {
e.stopPropagation();
onSelectCommit(commit.id, "tab-commit-details");
}}
>
<code className="text-white-400 px-3 py-1 font-mono text-sm">
{commit.id?.substring(0, 11)} {commit.id?.substring(0, 11)}
</code> </code>
</Button>
<CopyButton <CopyButton
value={commit.id} value={commit.id}
name={commit.id} name={commit.id}
size="xs" size="sm"
variant="plain" variant="plain"
color="text-mineshaft-400" color="text-mineshaft-400"
icon={faCopy} icon={faCopy}
/> />
</div> </div>
</div> </div>
</button> </div>
</div>
</div>
); );
}; };
@ -78,16 +108,24 @@ const DateGroup = ({
onSelectCommit: (commitId: string, tab: string) => void; onSelectCommit: (commitId: string, tab: string) => void;
}) => { }) => {
return ( return (
<div className="mt-4 first:mt-0"> <div className="mb-8 last:mb-0 last:pb-2">
<div className="mb-4 ml-[0.15rem] flex items-center"> <div className="mb-4 flex items-center">
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" /> <div className="relative mr-3 flex h-6 w-6 items-center justify-center">
<h2 className="ml-4 text-sm text-mineshaft-400">Commits on {date}</h2> <div className="z-10 h-3 w-3 rounded-full border-2 border-mineshaft-600 bg-bunker-800" />
<div className="absolute left-0 right-0 top-1/2 h-0.5 -translate-y-1/2 bg-mineshaft-600" />
</div> </div>
<h2 className="text-sm text-white">Commits on {date}</h2>
</div>
<div className="relative"> <div className="relative">
<div className="absolute bottom-0 left-3 top-0 w-[0.1rem] bg-mineshaft-500" /> <div className="absolute bottom-0 left-3 top-0 w-0.5 bg-mineshaft-600" />
<div className="ml-10"> <div className="ml-10">
{commits.map((commit) => ( {commits.map((commit) => (
<CommitItem key={commit.id} commit={commit} onSelectCommit={onSelectCommit} /> <div key={commit.id} className="relative mb-3 pb-1">
<div className="overflow-hidden rounded-md border border-solid border-mineshaft-600">
<CommitItem commit={commit} onSelectCommit={onSelectCommit} />
</div>
</div>
))} ))}
</div> </div>
</div> </div>
@ -109,8 +147,10 @@ export const CommitHistoryTab = ({
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [offset, setOffset] = useState(0);
const [allCommits, setAllCommits] = useState<Commit[]>([]);
const debounceTimeoutRef = useRef<NodeJS.Timeout>(); const debounceTimeoutRef = useRef<NodeJS.Timeout>();
const limit = 10; const limit = 5;
// Debounce search term // Debounce search term
useEffect(() => { useEffect(() => {
@ -130,20 +170,55 @@ export const CommitHistoryTab = ({
}, [searchTerm]); }, [searchTerm]);
const { const {
data: groupedCommits, data: response,
isLoading, isLoading,
fetchNextPage, isFetching
isFetchingNextPage,
hasNextPage
} = useGetFolderCommitHistory({ } = useGetFolderCommitHistory({
workspaceId: projectId, workspaceId: projectId,
environment, environment,
directory: secretPath, directory: secretPath,
offset,
limit, limit,
search: debouncedSearchTerm, search: debouncedSearchTerm,
sort: sortDirection sort: sortDirection
}); });
const commits = response?.commits || [];
const hasMore = response?.hasMore || false;
// Reset accumulated commits when search or sort changes
useEffect(() => {
setAllCommits([]);
setOffset(0);
}, [debouncedSearchTerm, sortDirection]);
// Accumulate commits instead of replacing them
useEffect(() => {
if (commits.length > 0) {
if (offset === 0) {
// First load or after search/sort change - replace all commits
setAllCommits(commits);
} else {
// Subsequent loads - append new commits
setAllCommits((prev) => [...prev, ...commits]);
}
}
}, [commits, offset]);
const groupedCommits = useMemo(() => {
return allCommits.reduce(
(acc, commit) => {
const date = format(new Date(commit.createdAt), "MMM d, yyyy");
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(commit);
return acc;
},
{} as Record<string, Commit[]>
);
}, [allCommits]);
const handleSort = useCallback(() => { const handleSort = useCallback(() => {
setSortDirection((prev) => (prev === "desc" ? "asc" : "desc")); setSortDirection((prev) => (prev === "desc" ? "asc" : "desc"));
}, []); }, []);
@ -152,39 +227,50 @@ export const CommitHistoryTab = ({
setSearchTerm(value); setSearchTerm(value);
}, []); }, []);
const loadMoreCommits = useCallback(() => {
if (hasMore && !isFetching) {
setOffset((prev) => prev + limit);
}
}, [hasMore, isFetching, limit]);
return ( return (
<div className="mt-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="w-full">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Commit History</p>
<div className="mb-4 flex flex-col sm:flex-row sm:justify-end"> <div className="mb-4 flex flex-col sm:flex-row sm:justify-end">
<div className="flex w-full flex-wrap items-center gap-2"> <div className="flex w-full flex-wrap items-center gap-2">
<div className="relative flex-grow"> <div className="relative flex-grow">
<Input <Input
leftIcon={<FontAwesomeIcon icon={faSearch} aria-hidden="true" />}
placeholder="Search commits..." placeholder="Search commits..."
className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20"
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
value={searchTerm} value={searchTerm}
aria-label="Search commits" aria-label="Search commits"
/> />
<div className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400">
<FontAwesomeIcon icon={faSearch} aria-hidden="true" />
</div> </div>
<IconButton </div>
<Button
variant="outline_bg" variant="outline_bg"
size="sm" size="md"
className="flex h-[2.4rem] items-center justify-center gap-2 rounded-md" className="flex h-10 items-center justify-center gap-2 rounded-md bg-zinc-800 px-4 py-2 text-sm text-white transition-colors duration-200 hover:bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
onClick={handleSort} onClick={handleSort}
ariaLabel={`Sort by date ${sortDirection === "desc" ? "ascending" : "descending"}`} aria-label={`Sort by date ${sortDirection === "desc" ? "ascending" : "descending"}`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={sortDirection === "desc" ? faArrowDownWideShort : faArrowUpWideShort} icon={sortDirection === "desc" ? faArrowDownWideShort : faArrowUpWideShort}
aria-hidden="true" aria-hidden="true"
/> />
</IconButton> </Button>
</div> </div>
</div> </div>
{isLoading ? (
<ContentLoader className="h-80" /> {isLoading && offset === 0 ? (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" aria-label="Loading commits" />
</div>
) : ( ) : (
<div> <div className="space-y-8">
{groupedCommits && Object.keys(groupedCommits).length > 0 ? ( {Object.keys(groupedCommits).length > 0 ? (
<> <>
{Object.entries(groupedCommits).map(([date, dateCommits]) => ( {Object.entries(groupedCommits).map(([date, dateCommits]) => (
<DateGroup <DateGroup
@ -196,20 +282,34 @@ export const CommitHistoryTab = ({
))} ))}
</> </>
) : ( ) : (
<EmptyState title="No commits found." icon={faCodeCommit} /> <div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
<FontAwesomeIcon
icon={faSearch}
className="text-white-500 mb-3 text-3xl"
aria-hidden="true"
/>
<p>No matching commits found. Try a different search term.</p>
</div>
)} )}
{hasNextPage && (
{hasMore && (
<div className="flex justify-center pb-2"> <div className="flex justify-center pb-2">
<Button <Button
variant="outline_bg" variant="outline_bg"
size="sm" size="md"
className="ml-10 mt-4 w-full" className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
onClick={() => fetchNextPage()} onClick={loadMoreCommits}
disabled={isFetchingNextPage} disabled={isFetching}
isLoading={isFetchingNextPage}
aria-label="Load more commits" aria-label="Load more commits"
> >
Load More Commits {isFetching ? (
<>
<Spinner size="sm" className="mr-2" />
Loading...
</>
) : (
"Load more commits"
)}
</Button> </Button>
</div> </div>
)} )}