mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-25 14:05:03 +00:00
Improved frontend for activity logs
This commit is contained in:
@ -49,6 +49,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => {
|
||||
filters.workspace = workspaceId;
|
||||
|
||||
logs = await Log.find(filters)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.populate('actions')
|
||||
|
@ -1,60 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from "next-i18next";
|
||||
import {
|
||||
faAngleDown,
|
||||
faCheck,
|
||||
faDownload,
|
||||
faEye,
|
||||
faPlus,
|
||||
faUpload,
|
||||
faShuffle,
|
||||
faX
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
|
||||
import guidGenerator from '../utilities/randomId';
|
||||
import Button from './buttons/Button';
|
||||
|
||||
interface ListBoxProps {
|
||||
selected: string;
|
||||
select: (event: string) => void;
|
||||
data: string[];
|
||||
text?: string;
|
||||
buttonAction?: () => void;
|
||||
isFull?: boolean;
|
||||
}
|
||||
|
||||
const eventOptions = [
|
||||
{
|
||||
name: 'Secrets Pushed',
|
||||
icon: faUpload
|
||||
name: 'addSecrets',
|
||||
icon: faPlus
|
||||
},
|
||||
{
|
||||
name: 'Secrets Pulled',
|
||||
icon: faDownload
|
||||
name: 'readSecrets',
|
||||
icon: faEye
|
||||
},
|
||||
{
|
||||
name: 'updateSecrets',
|
||||
icon: faShuffle
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* This is the component that we use for drop down lists.
|
||||
* This is the component that we use for the event picker in the activity logs tab.
|
||||
* @param {object} obj
|
||||
* @param {string} obj.selected - the item that is currently selected
|
||||
* @param {function} obj.select - what happends if you select the item inside a list
|
||||
* @param {string[]} obj.data - all the options available
|
||||
* @param {string} obj.text - the text that shows us in front of the select option
|
||||
* @param {function} obj.buttonAction - if there is a button at the bottom of the list, this is the action that happens when you click the button
|
||||
* @param {string} obj.width - button width
|
||||
* @returns
|
||||
* @param {string} obj.selected - the event that is currently selected
|
||||
* @param {function} obj.select - an action that happens when an item is selected
|
||||
*/
|
||||
export default function EventFilter({
|
||||
selected,
|
||||
select,
|
||||
data,
|
||||
text,
|
||||
buttonAction,
|
||||
isFull
|
||||
select
|
||||
}: ListBoxProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Listbox value={selected} onChange={select}>
|
||||
<Listbox value={t("activity:event." + selected)} onChange={select}>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="bg-mineshaft-800 hover:bg-mineshaft-700 duration-200 cursor-pointer rounded-md h-10 flex items-center justify-between pl-4 pr-2 w-52 text-bunker-200 text-sm">
|
||||
{selected != '' ? (
|
||||
@ -84,9 +74,9 @@ export default function EventFilter({
|
||||
<Listbox.Option
|
||||
key={id}
|
||||
className={`px-4 h-10 flex items-center text-sm cursor-pointer hover:bg-mineshaft-700 text-bunker-200 rounded-md ${
|
||||
selected == event.name && 'bg-mineshaft-700'
|
||||
selected == t("activity:event." + event.name) && 'bg-mineshaft-700'
|
||||
}`}
|
||||
value={event.name}
|
||||
value={t("activity:event." + event.name)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
@ -96,7 +86,7 @@ export default function EventFilter({
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={event.icon} className="pr-4" />{' '}
|
||||
{event.name}
|
||||
{t("activity:event." + event.name)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
@ -1,141 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
faAngleDown,
|
||||
faAngleRight,
|
||||
faX
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import guidGenerator from '../../utilities/randomId';
|
||||
|
||||
interface ActivityTableProps {
|
||||
eventName: string;
|
||||
user: string;
|
||||
source: string;
|
||||
time: Date;
|
||||
}
|
||||
|
||||
function timeSince(date: Date) {
|
||||
const seconds = Math.floor(
|
||||
((new Date() as any) - (date as any)) / 1000
|
||||
) as number;
|
||||
|
||||
let interval = seconds / 31536000;
|
||||
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' years ago';
|
||||
}
|
||||
interval = seconds / 2592000;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' months ago';
|
||||
}
|
||||
interval = seconds / 86400;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' days ago';
|
||||
}
|
||||
interval = seconds / 3600;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' hours ago';
|
||||
}
|
||||
interval = seconds / 60;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' minutes ago';
|
||||
}
|
||||
return Math.floor(seconds) + ' seconds ago';
|
||||
}
|
||||
|
||||
const ActivityLogsRow = ({ row }: { row: ActivityTableProps }): JSX.Element => {
|
||||
const [payloadOpened, setPayloadOpened] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<tr key={guidGenerator()} className="bg-bunker-800 duration-100 w-full">
|
||||
<div
|
||||
onClick={() => setPayloadOpened(!payloadOpened)}
|
||||
className="border-mineshaft-700 border-t text-gray-300 flex items-center cursor-pointer"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={payloadOpened ? faAngleDown : faAngleRight}
|
||||
className={`mt-3 ml-6 text-bunker-100 hover:bg-primary-100/[0.15] ${
|
||||
payloadOpened && 'bg-primary-100/10'
|
||||
} p-1 duration-100 h-4 w-4 rounded-md`}
|
||||
/>
|
||||
</div>
|
||||
<td className="py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.eventName}
|
||||
</td>
|
||||
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.user}
|
||||
</td>
|
||||
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.source}
|
||||
</td>
|
||||
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{timeSince(row.time)}
|
||||
</td>
|
||||
{/* <td className="py-2 border-mineshaft-700 border-t">
|
||||
<div className="opacity-50 hover:opacity-100 duration-200 flex items-center">
|
||||
<Button
|
||||
onButtonPressed={() => {}}
|
||||
color="red"
|
||||
size="icon-sm"
|
||||
icon={faX}
|
||||
/>
|
||||
</div>
|
||||
</td> */}
|
||||
</tr>
|
||||
{payloadOpened && (
|
||||
<tr className="w-full h-10 bg-bunker-700 text-bunker-200 col-span-2">
|
||||
<td colSpan={5} className="">
|
||||
<div className="flex flex-row ml-12 py-2 border-mineshaft-700 border-t">
|
||||
<div className="w-96">Timestamp</div>
|
||||
<div className="w-96">2022-12-16T04:02:44.517Z</div>
|
||||
</div>
|
||||
<div className="flex flex-row ml-12 py-2 border-mineshaft-700 border-t">
|
||||
<div className="w-96">Number of Secrets</div>
|
||||
<div className="w-96">32</div>
|
||||
</div>
|
||||
<div className="flex flex-row ml-12 py-2 border-mineshaft-700 border-t">
|
||||
<div className="w-96">IP Address</div>
|
||||
<div className="w-96">159.223.164.24</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the table for activity logs (one of the tabs)
|
||||
* @param {*} props
|
||||
* @returns
|
||||
*/
|
||||
const ActivityTable = ({ data }: { data: ActivityTableProps[] }) => {
|
||||
return (
|
||||
<div className="w-full px-6 mt-8">
|
||||
<div className="table-container w-full bg-bunker rounded-md mb-6 border border-mineshaft-700 relative">
|
||||
<div className="absolute rounded-t-md w-full h-[3.15rem] bg-white/5"></div>
|
||||
<table className="w-full my-1">
|
||||
<thead className="text-bunker-300">
|
||||
<tr>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3"></th>
|
||||
<th className="text-left pt-2.5 pb-3">Event</th>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3">User</th>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3">Source</th>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3">Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, index) => {
|
||||
return <ActivityLogsRow key={index} row={row} />;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTable;
|
43
frontend/ee/api/secrets/GetProjectLogs.ts
Normal file
43
frontend/ee/api/secrets/GetProjectLogs.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import SecurityClient from '~/utilities/SecurityClient';
|
||||
|
||||
|
||||
interface workspaceProps {
|
||||
workspaceId: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
filters: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function fetches the activity logs for a certain project
|
||||
* @param {object} obj
|
||||
* @param {string} obj.workspaceId - workspace id for which we are trying to get project log
|
||||
* @param {object} obj.offset - teh starting point of logs that we want to pull
|
||||
* @param {object} obj.limit - how many logs will we output
|
||||
* @param {object} obj.filters
|
||||
* @returns
|
||||
*/
|
||||
const getProjectLogs = async ({ workspaceId, offset, limit, filters }: workspaceProps) => {
|
||||
return SecurityClient.fetchCall(
|
||||
'/api/v1/workspace/' + workspaceId + '/logs?' +
|
||||
new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
filters: JSON.stringify(filters)
|
||||
}),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
).then(async (res) => {
|
||||
if (res && res.status == 200) {
|
||||
return (await res.json()).logs;
|
||||
} else {
|
||||
console.log('Failed to get project logs');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default getProjectLogs;
|
@ -17,7 +17,7 @@ interface secretVersionProps {
|
||||
*/
|
||||
const getSecretVersions = async ({ secretId, offset, limit }: secretVersionProps) => {
|
||||
return SecurityClient.fetchCall(
|
||||
'/api/v1/secret/' + secretId + '/secret-versions?'+
|
||||
'/api/v1/secret/' + secretId + '/secret-versions?' +
|
||||
new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit)
|
||||
@ -32,7 +32,7 @@ const getSecretVersions = async ({ secretId, offset, limit }: secretVersionProps
|
||||
if (res && res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
console.log('Failed to get project secrets');
|
||||
console.log('Failed to get secret version history');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
80
frontend/ee/components/ActivitySideBar.tsx
Normal file
80
frontend/ee/components/ActivitySideBar.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { faX } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import patienceDiff from 'ee/utilities/findTextDifferences';
|
||||
|
||||
import DashboardInputField from '../../components/dashboard/DashboardInputField';
|
||||
|
||||
|
||||
const secretChanges = [{
|
||||
"oldSecret": "secret1",
|
||||
"newSecret": "ecret2"
|
||||
}, {
|
||||
"oldSecret": "secret1",
|
||||
"newSecret": "sercet2"
|
||||
}, {
|
||||
"oldSecret": "localhosta:8080",
|
||||
"newSecret": "aaaalocalhoats:3000"
|
||||
}]
|
||||
|
||||
|
||||
interface SideBarProps {
|
||||
toggleSidebar: (value: string[]) => void;
|
||||
sidebarData: string[];
|
||||
currentEvent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} obj
|
||||
* @param {function} obj.toggleSidebar - function that opens or closes the sidebar
|
||||
* @param {string[]} obj.secretIds - data of payload
|
||||
* @param {string} obj.currentEvent - the event name for which a sidebar is being displayed
|
||||
* @returns the sidebar with the payload of user activity logs
|
||||
*/
|
||||
const ActivitySideBar = ({
|
||||
toggleSidebar,
|
||||
sidebarData,
|
||||
currentEvent
|
||||
}: SideBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div className='absolute border-l border-mineshaft-500 bg-bunker fixed h-full w-96 top-14 right-0 z-50 shadow-xl flex flex-col justify-between'>
|
||||
<div className='h-min overflow-y-auto'>
|
||||
<div className="flex flex-row px-4 py-3 border-b border-mineshaft-500 justify-between items-center">
|
||||
<p className="font-semibold text-lg text-bunker-200">{t("activity:event." + currentEvent)}</p>
|
||||
<div className='p-1' onClick={() => toggleSidebar([])}>
|
||||
<FontAwesomeIcon icon={faX} className='w-4 h-4 text-bunker-300 cursor-pointer'/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col px-4'>
|
||||
{currentEvent == 'readSecrets' && sidebarData.map((item, id) =>
|
||||
<>
|
||||
<div className='text-sm text-bunker-200 mt-4 pl-1'>Key {id}</div>
|
||||
<DashboardInputField
|
||||
key={id}
|
||||
onChangeHandler={() => {}}
|
||||
type="varName"
|
||||
position={1}
|
||||
value={"a" + item}
|
||||
isDuplicate={false}
|
||||
blurred={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{currentEvent == 'updateSecrets' && sidebarData.map((item, id) =>
|
||||
secretChanges.map(secretChange =>
|
||||
<>
|
||||
<div className='text-sm text-bunker-200 mt-4 pl-1'>Secret Name {id}</div>
|
||||
<div className='text-bunker-100 font-mono rounded-md overflow-hidden'>
|
||||
<div className='bg-red/30 px-2'>- {patienceDiff(secretChange.oldSecret.split(''), secretChange.newSecret.split(''), false).lines.map((character, id) => character.aIndex != -1 && <span key={id} className={`${character.bIndex == -1 && "bg-red-700/80"}`}>{character.line}</span>)}</div>
|
||||
<div className='bg-green-500/30 px-2'>+ {patienceDiff(secretChange.oldSecret.split(''), secretChange.newSecret.split('')).lines.map((character, id) => character.bIndex != -1 && <span key={id} className={`${character.aIndex == -1 && "bg-green-700/80"}`}>{character.line}</span>)}</div>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
};
|
||||
|
||||
export default ActivitySideBar;
|
132
frontend/ee/components/ActivityTable.tsx
Normal file
132
frontend/ee/components/ActivityTable.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from "next-i18next";
|
||||
import {
|
||||
faAngleDown,
|
||||
faAngleRight,
|
||||
faUpRightFromSquare,
|
||||
faX
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import timeSince from 'ee/utilities/timeSince';
|
||||
|
||||
import guidGenerator from '../../components/utilities/randomId';
|
||||
|
||||
|
||||
interface PayloadProps {
|
||||
name: string;
|
||||
secretVersions: string[];
|
||||
}
|
||||
|
||||
interface logData {
|
||||
_id: string;
|
||||
channel: string;
|
||||
createdAt: string;
|
||||
ipAddress: string;
|
||||
user: string;
|
||||
payload: PayloadProps[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param obj
|
||||
* @param {function} obj.setCurrentEvent - specify the name of the event for which the sidebar is being opened
|
||||
* @returns
|
||||
*/
|
||||
const ActivityLogsRow = ({ row, toggleSidebar, setCurrentEvent }: { row: logData, toggleSidebar: (value: string[]) => void; setCurrentEvent: (value: string) => void; }) => {
|
||||
const [payloadOpened, setPayloadOpened] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr key={guidGenerator()} className="bg-bunker-800 duration-100 w-full">
|
||||
<td
|
||||
onClick={() => setPayloadOpened(!payloadOpened)}
|
||||
className="border-mineshaft-700 border-t text-gray-300 flex items-center cursor-pointer"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={payloadOpened ? faAngleDown : faAngleRight}
|
||||
className={`mt-3 ml-6 text-bunker-100 hover:bg-mineshaft-700 ${
|
||||
payloadOpened && 'bg-mineshaft-500'
|
||||
} p-1 duration-100 h-4 w-4 rounded-md`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.payload?.map(action => String(action.secretVersions.length) + " " + t("activity:event." + action.name)).join(" and ")}
|
||||
</td>
|
||||
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.user}
|
||||
</td>
|
||||
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
|
||||
{timeSince(new Date(row.createdAt))}
|
||||
</td>
|
||||
</tr>
|
||||
{payloadOpened &&
|
||||
<tr className='h-9 text-bunker-200 border-mineshaft-700 border-t'>
|
||||
<td></td>
|
||||
<td>Timestamp</td>
|
||||
<td>{row.createdAt}</td>
|
||||
</tr>}
|
||||
{payloadOpened &&
|
||||
row.payload?.map((action, index) =>
|
||||
<tr key={index} className="h-9 text-bunker-200 border-mineshaft-700 border-t">
|
||||
<td></td>
|
||||
<td className="">{t("activity:event." + action.name)}</td>
|
||||
<td className="text-primary-300 cursor-pointer hover:text-primary duration-200" onClick={() => {
|
||||
toggleSidebar(action.secretVersions);
|
||||
setCurrentEvent(action.name);
|
||||
}}>
|
||||
{action.secretVersions.length + (action.secretVersions.length != 1 ? " secrets" : " secret")}
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} className="ml-2 mb-0.5 font-light w-3 h-3"/>
|
||||
</td>
|
||||
</tr>)}
|
||||
{payloadOpened &&
|
||||
<tr className='h-9 text-bunker-200 border-mineshaft-700 border-t'>
|
||||
<td></td>
|
||||
<td>IP Address</td>
|
||||
<td>{row.ipAddress}</td>
|
||||
</tr>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the table for activity logs (one of the tabs)
|
||||
* @param {object} obj
|
||||
* @param {logData} obj.data - data for user activity logs
|
||||
* @param {function} obj.toggleSidebar - function that opens or closes the sidebar
|
||||
* @param {function} obj.setCurrentEvent - specify the name of the event for which the sidebar is being opened
|
||||
* @returns
|
||||
*/
|
||||
const ActivityTable = ({ data, toggleSidebar, setCurrentEvent }: { data: logData[], toggleSidebar: (value: string[]) => void; setCurrentEvent: (value: string) => void; }) => {
|
||||
return (
|
||||
<div className="w-full px-6 mt-8">
|
||||
<div className="table-container w-full bg-bunker rounded-md mb-6 border border-mineshaft-700 relative">
|
||||
<div className="absolute rounded-t-md w-full h-[3.15rem] bg-white/5"></div>
|
||||
<table className="w-full my-1">
|
||||
<thead className="text-bunker-300">
|
||||
<tr>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3"></th>
|
||||
<th className="text-left pt-2.5 pb-3">Event</th>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3">User</th>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3">Source</th>
|
||||
<th className="text-left pl-6 pt-2.5 pb-3">Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.map((row, index) => {
|
||||
return <ActivityLogsRow key={index} row={row} toggleSidebar={toggleSidebar} setCurrentEvent={setCurrentEvent} />;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTable;
|
346
frontend/ee/utilities/findTextDifferences.ts
Normal file
346
frontend/ee/utilities/findTextDifferences.ts
Normal file
@ -0,0 +1,346 @@
|
||||
/**
|
||||
*
|
||||
* @param textOld - old secret
|
||||
* @param textNew - new (updated) secret
|
||||
* @param diffPlusFlag - a flag for whether we want to detect moving segments
|
||||
* - doesn't work in some examples (e.g., when we have a full reverse ordering of the text)
|
||||
* @returns
|
||||
*/
|
||||
function patienceDiff(textOld: string[], textNew: string[], diffPlusFlag?: boolean) {
|
||||
|
||||
/**
|
||||
* findUnique finds all unique values in arr[lo..hi], inclusive. This
|
||||
* function is used in preparation for determining the longest common
|
||||
* subsequence. Specifically, it first reduces the array range in question
|
||||
* to unique values.
|
||||
* @param chars - an array of characters
|
||||
* @param lo
|
||||
* @param hi
|
||||
* @returns - an ordered Map, with the arr[i] value as the Map key and the
|
||||
* array index i as the Map value.
|
||||
*/
|
||||
function findUnique(chars: string[], lo: number, hi: number) {
|
||||
const characterMap = new Map();
|
||||
|
||||
for (let i=lo; i<=hi; i++) {
|
||||
const character = chars[i];
|
||||
|
||||
if (characterMap.has(character)) {
|
||||
characterMap.get(character).count++;
|
||||
characterMap.get(character).index = i;
|
||||
} else {
|
||||
characterMap.set(character, { count: 1, index: i });
|
||||
}
|
||||
}
|
||||
|
||||
characterMap.forEach((val, key, map) => {
|
||||
if (val.count !== 1) {
|
||||
map.delete(key);
|
||||
} else {
|
||||
map.set(key, val.index);
|
||||
}
|
||||
});
|
||||
|
||||
return characterMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param aArray
|
||||
* @param aLo
|
||||
* @param aHi
|
||||
* @param bArray
|
||||
* @param bLo
|
||||
* @param bHi
|
||||
* @returns an ordered Map, with the Map key as the common line between aArray
|
||||
* and bArray, with the Map value as an object containing the array indexes of
|
||||
* the matching unique lines.
|
||||
*
|
||||
*/
|
||||
function uniqueCommon(aArray: string[], aLo: number, aHi: number, bArray: string[], bLo: number, bHi: number) {
|
||||
const ma = findUnique(aArray, aLo, aHi);
|
||||
const mb = findUnique(bArray, bLo, bHi);
|
||||
|
||||
ma.forEach((val, key, map) => {
|
||||
if (mb.has(key)) {
|
||||
map.set(key, {
|
||||
indexA: val,
|
||||
indexB: mb.get(key)
|
||||
});
|
||||
} else {
|
||||
map.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
return ma;
|
||||
}
|
||||
|
||||
/**
|
||||
* longestCommonSubsequence takes an ordered Map from the function uniqueCommon
|
||||
* and determines the Longest Common Subsequence (LCS).
|
||||
* @param abMap
|
||||
* @returns an ordered array of objects containing the array indexes of the
|
||||
* matching lines for a LCS.
|
||||
*/
|
||||
function longestCommonSubsequence(abMap: Map<number, { indexA: number, indexB: number, prev?: number }>) {
|
||||
const ja: any = [];
|
||||
|
||||
// First, walk the list creating the jagged array.
|
||||
abMap.forEach((val, key, map) => {
|
||||
let i = 0;
|
||||
|
||||
while (ja[i] && ja[i][ja[i].length - 1].indexB < val.indexB) {
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!ja[i]) {
|
||||
ja[i] = [];
|
||||
}
|
||||
|
||||
if (0 < i) {
|
||||
val.prev = ja[i-1][ja[i - 1].length - 1];
|
||||
}
|
||||
ja[i].push(val);
|
||||
});
|
||||
|
||||
// Now, pull out the longest common subsequence.
|
||||
let lcs: any[] = [];
|
||||
|
||||
if (0 < ja.length) {
|
||||
const n = ja.length - 1;
|
||||
lcs = [ja[n][ja[n].length - 1]];
|
||||
|
||||
while (lcs[lcs.length - 1].prev) {
|
||||
lcs.push(lcs[lcs.length - 1].prev);
|
||||
}
|
||||
}
|
||||
|
||||
return lcs.reverse();
|
||||
}
|
||||
|
||||
// "result" is the array used to accumulate the textOld that are deleted, the
|
||||
// lines that are shared between textOld and textNew, and the textNew that were
|
||||
// inserted.
|
||||
|
||||
const result: any[] = [];
|
||||
let deleted = 0;
|
||||
let inserted = 0;
|
||||
|
||||
// aMove and bMove will contain the lines that don't match, and will be returned
|
||||
// for possible searching of lines that moved.
|
||||
|
||||
const aMove: any[] = [];
|
||||
const aMoveIndex: any[] = [];
|
||||
const bMove: any[] = [];
|
||||
const bMoveIndex: any[] = [];
|
||||
|
||||
/**
|
||||
* addToResult simply pushes the latest value onto the "result" array. This
|
||||
* array captures the diff of the line, aIndex, and bIndex from the textOld
|
||||
* and textNew array.
|
||||
* @param aIndex
|
||||
* @param bIndex
|
||||
*/
|
||||
function addToResult(aIndex: number, bIndex: number) {
|
||||
if (bIndex < 0) {
|
||||
aMove.push(textOld[aIndex]);
|
||||
aMoveIndex.push(result.length);
|
||||
deleted++;
|
||||
} else if (aIndex < 0) {
|
||||
bMove.push(textNew[bIndex]);
|
||||
bMoveIndex.push(result.length);
|
||||
inserted++;
|
||||
}
|
||||
|
||||
result.push({
|
||||
line: 0 <= aIndex ? textOld[aIndex] : textNew[bIndex],
|
||||
aIndex: aIndex,
|
||||
bIndex: bIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* addSubMatch handles the lines between a pair of entries in the LCS. Thus,
|
||||
* this function might recursively call recurseLCS to further match the lines
|
||||
* between textOld and textNew.
|
||||
* @param aLo
|
||||
* @param aHi
|
||||
* @param bLo
|
||||
* @param bHi
|
||||
*/
|
||||
function addSubMatch(aLo: number, aHi: number, bLo: number, bHi: number) {
|
||||
// Match any lines at the beginning of textOld and textNew.
|
||||
while (aLo <= aHi && bLo <= bHi && textOld[aLo] === textNew[bLo]) {
|
||||
addToResult(aLo++, bLo++);
|
||||
}
|
||||
|
||||
// Match any lines at the end of textOld and textNew, but don't place them
|
||||
// in the "result" array just yet, as the lines between these matches at
|
||||
// the beginning and the end need to be analyzed first.
|
||||
|
||||
const aHiTemp = aHi;
|
||||
while (aLo <= aHi && bLo <= bHi && textOld[aHi] === textNew[bHi]) {
|
||||
aHi--;
|
||||
bHi--;
|
||||
}
|
||||
|
||||
// Now, check to determine with the remaining lines in the subsequence
|
||||
// whether there are any unique common lines between textOld and textNew.
|
||||
//
|
||||
// If not, add the subsequence to the result (all textOld having been
|
||||
// deleted, and all textNew having been inserted).
|
||||
//
|
||||
// If there are unique common lines between textOld and textNew, then let's
|
||||
// recursively perform the patience diff on the subsequence.
|
||||
|
||||
const uniqueCommonMap = uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi);
|
||||
|
||||
if (uniqueCommonMap.size === 0) {
|
||||
while (aLo <= aHi) {
|
||||
addToResult(aLo++, -1);
|
||||
}
|
||||
|
||||
while (bLo <= bHi) {
|
||||
addToResult(-1, bLo++);
|
||||
}
|
||||
} else {
|
||||
recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap);
|
||||
}
|
||||
|
||||
// Finally, let's add the matches at the end to the result.
|
||||
while (aHi < aHiTemp) {
|
||||
addToResult(++aHi, ++bHi);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* recurseLCS finds the longest common subsequence (LCS) between the arrays
|
||||
* textOld[aLo..aHi] and textNew[bLo..bHi] inclusive. Then for each subsequence
|
||||
* recursively performs another LCS search (via addSubMatch), until there are
|
||||
* none found, at which point the subsequence is dumped to the result.
|
||||
* @param aLo
|
||||
* @param aHi
|
||||
* @param bLo
|
||||
* @param bHi
|
||||
* @param uniqueCommonMap
|
||||
*/
|
||||
function recurseLCS(aLo: number, aHi: number, bLo: number, bHi: number, uniqueCommonMap?: any) {
|
||||
const x = longestCommonSubsequence(uniqueCommonMap || uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi));
|
||||
|
||||
if (x.length === 0) {
|
||||
addSubMatch(aLo, aHi, bLo, bHi);
|
||||
} else {
|
||||
if (aLo < x[0].indexA || bLo < x[0].indexB) {
|
||||
addSubMatch(aLo, x[0].indexA - 1, bLo, x[0].indexB - 1);
|
||||
}
|
||||
|
||||
let i;
|
||||
for (i = 0; i < x.length - 1; i++) {
|
||||
addSubMatch(x[i].indexA, x[i+1].indexA - 1, x[i].indexB, x[i+1].indexB - 1);
|
||||
}
|
||||
|
||||
if (x[i].indexA <= aHi || x[i].indexB <= bHi) {
|
||||
addSubMatch(x[i].indexA, aHi, x[i].indexB, bHi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recurseLCS(0, textOld.length - 1, 0, textNew.length - 1);
|
||||
|
||||
if (diffPlusFlag) {
|
||||
return {
|
||||
lines: result,
|
||||
lineCountDeleted: deleted,
|
||||
lineCountInserted: inserted,
|
||||
lineCountMoved: 0,
|
||||
aMove: aMove,
|
||||
aMoveIndex: aMoveIndex,
|
||||
bMove: bMove,
|
||||
bMoveIndex: bMoveIndex,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lines: result,
|
||||
lineCountDeleted: deleted,
|
||||
lineCountInserted: inserted,
|
||||
lineCountMoved: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* use: patienceDiffPlus( textOld[], textNew[] )
|
||||
*
|
||||
* where:
|
||||
* textOld[] contains the original text lines.
|
||||
* textNew[] contains the new text lines.
|
||||
*
|
||||
* returns an object with the following properties:
|
||||
* lines[] with properties of:
|
||||
* line containing the line of text from textOld or textNew.
|
||||
* aIndex referencing the index in aLine[].
|
||||
* bIndex referencing the index in textNew[].
|
||||
* (Note: The line is text from either textOld or textNew, with aIndex and bIndex
|
||||
* referencing the original index. If aIndex === -1 then the line is new from textNew,
|
||||
* and if bIndex === -1 then the line is old from textOld.)
|
||||
* moved is true if the line was moved from elsewhere in textOld[] or textNew[].
|
||||
* lineCountDeleted is the number of lines from textOld[] not appearing in textNew[].
|
||||
* lineCountInserted is the number of lines from textNew[] not appearing in textOld[].
|
||||
* lineCountMoved is the number of lines that moved.
|
||||
*/
|
||||
|
||||
function patienceDiffPlus(textOld: string[], textNew: string[]) {
|
||||
|
||||
const difference = patienceDiff(textOld, textNew, true);
|
||||
|
||||
let aMoveNext = difference.aMove;
|
||||
let aMoveIndexNext = difference.aMoveIndex;
|
||||
let bMoveNext = difference.bMove;
|
||||
let bMoveIndexNext = difference.bMoveIndex;
|
||||
|
||||
delete difference.aMove;
|
||||
delete difference.aMoveIndex;
|
||||
delete difference.bMove;
|
||||
delete difference.bMoveIndex;
|
||||
|
||||
let lastLineCountMoved;
|
||||
|
||||
do {
|
||||
const aMove = aMoveNext;
|
||||
const aMoveIndex = aMoveIndexNext;
|
||||
const bMove = bMoveNext;
|
||||
const bMoveIndex = bMoveIndexNext;
|
||||
|
||||
aMoveNext = [];
|
||||
aMoveIndexNext = [];
|
||||
bMoveNext = [];
|
||||
bMoveIndexNext = [];
|
||||
|
||||
const subDiff = patienceDiff(aMove!, bMove!);
|
||||
|
||||
lastLineCountMoved = difference.lineCountMoved;
|
||||
|
||||
subDiff.lines.forEach((v, i) => {
|
||||
|
||||
if (0 <= v.aIndex && 0 <= v.bIndex) {
|
||||
|
||||
difference.lines[aMoveIndex![v.aIndex]].moved = true;
|
||||
difference.lines[bMoveIndex![v.bIndex]].aIndex = aMoveIndex![v.aIndex];
|
||||
difference.lines[bMoveIndex![v.bIndex]].moved = true;
|
||||
difference.lineCountInserted--;
|
||||
difference.lineCountDeleted--;
|
||||
difference.lineCountMoved++;
|
||||
} else if (v.bIndex < 0) {
|
||||
aMoveNext!.push(aMove![v.aIndex]);
|
||||
aMoveIndexNext!.push(aMoveIndex![v.aIndex]);
|
||||
} else {
|
||||
bMoveNext!.push(bMove![v.bIndex]);
|
||||
bMoveIndexNext!.push(bMoveIndex![v.bIndex]);
|
||||
}
|
||||
});
|
||||
} while (0 < difference.lineCountMoved - lastLineCountMoved);
|
||||
|
||||
return difference;
|
||||
|
||||
}
|
||||
|
||||
export default patienceDiff;
|
35
frontend/ee/utilities/timeSince.ts
Normal file
35
frontend/ee/utilities/timeSince.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Time since a certain date
|
||||
* @param {Date} date - the timestamp got which we want to understand how long ago it happened
|
||||
* @returns {String} text - how much time has passed since a certain timestamp
|
||||
*/
|
||||
function timeSince(date: Date) {
|
||||
const seconds = Math.floor(
|
||||
((new Date() as any) - (date as any)) / 1000
|
||||
) as number;
|
||||
|
||||
let interval = seconds / 31536000;
|
||||
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' years ago';
|
||||
}
|
||||
interval = seconds / 2592000;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' months ago';
|
||||
}
|
||||
interval = seconds / 86400;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' days ago';
|
||||
}
|
||||
interval = seconds / 3600;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' hours ago';
|
||||
}
|
||||
interval = seconds / 60;
|
||||
if (interval > 1) {
|
||||
return Math.floor(interval) + ' minutes ago';
|
||||
}
|
||||
return Math.floor(seconds) + ' seconds ago';
|
||||
}
|
||||
|
||||
export default timeSince;
|
@ -1,137 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from "next-i18next";
|
||||
import ActivitySideBar from 'ee/components/ActivitySideBar';
|
||||
|
||||
import Button from '~/components/basic/buttons/Button';
|
||||
import EventFilter from '~/components/basic/EventFilter';
|
||||
import ActivityTable from '~/components/basic/table/ActivityTable';
|
||||
import NavHeader from '~/components/navigation/NavHeader';
|
||||
import onboardingCheck from '~/components/utilities/checks/OnboardingCheck';
|
||||
import { getTranslatedServerSideProps } from '~/components/utilities/withTranslateProps';
|
||||
|
||||
const data = [
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pulled',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'CLI',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
eventName: 'Secrets Pushed',
|
||||
user: 'matsiiako@gmail.com',
|
||||
source: 'Web',
|
||||
time: new Date()
|
||||
}
|
||||
];
|
||||
import getProjectLogs from '../../ee/api/secrets/GetProjectLogs';
|
||||
import ActivityTable from '../../ee/components/ActivityTable';
|
||||
|
||||
|
||||
interface logData {
|
||||
_id: string;
|
||||
channel: string;
|
||||
createdAt: string;
|
||||
ipAddress: string;
|
||||
user: {
|
||||
email: string;
|
||||
};
|
||||
actions: {
|
||||
name: string;
|
||||
payload: {
|
||||
secretVersions: string[];
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
interface PayloadProps {
|
||||
name: string;
|
||||
secretVersions: string[];
|
||||
}
|
||||
|
||||
interface logDataPoint {
|
||||
_id: string;
|
||||
channel: string;
|
||||
createdAt: string;
|
||||
ipAddress: string;
|
||||
user: string;
|
||||
payload: PayloadProps[];
|
||||
}
|
||||
|
||||
/**
|
||||
* This tab is called Home because in the future it will include some company news,
|
||||
* updates, roadmap, relavant blogs, etc. Currently it only has the setup instruction
|
||||
* for the new users
|
||||
* This is the tab that includes all of the user activity logs
|
||||
*/
|
||||
export default function Activity() {
|
||||
const router = useRouter();
|
||||
const [hasUserClickedSlack, setHasUserClickedSlack] = useState(false);
|
||||
const [hasUserClickedIntro, setHasUserClickedIntro] = useState(false);
|
||||
const [hasUserStarred, setHasUserStarred] = useState(false);
|
||||
const [hasUserPushedSecrets, setHasUserPushedSecrets] = useState(false);
|
||||
const [usersInOrg, setUsersInOrg] = useState(false);
|
||||
const [eventChosen, setEventChosen] = useState('');
|
||||
const [logsData, setLogsData] = useState<logDataPoint[]>([]);
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
const currentLimit = 10;
|
||||
const [sidebarData, toggleSidebar] = useState<string[]>([])
|
||||
const [currentEvent, setCurrentEvent] = useState("");
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
onboardingCheck({
|
||||
setHasUserClickedIntro,
|
||||
setHasUserClickedSlack,
|
||||
setHasUserPushedSecrets,
|
||||
setHasUserStarred,
|
||||
setUsersInOrg
|
||||
});
|
||||
}, []);
|
||||
const getLogData = async () => {
|
||||
const tempLogsData = await getProjectLogs({ workspaceId: String(router.query.id), offset: currentOffset, limit: currentLimit, filters: {} })
|
||||
setLogsData(logsData.concat(tempLogsData.map((log: logData) => {
|
||||
return {
|
||||
_id: log._id,
|
||||
channel: log.channel,
|
||||
createdAt: log.createdAt,
|
||||
ipAddress: log.ipAddress,
|
||||
user: log.user.email,
|
||||
payload: log.actions.map(action => {
|
||||
return {
|
||||
name: action.name,
|
||||
secretVersions: action.payload.secretVersions
|
||||
}
|
||||
})
|
||||
}
|
||||
})))
|
||||
}
|
||||
getLogData();
|
||||
}, [currentLimit, currentOffset]);
|
||||
|
||||
const loadMoreLogs = () => {
|
||||
setCurrentOffset(currentOffset + currentLimit);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-6 lg:mx-0 w-full overflow-y-scroll h-screen">
|
||||
<NavHeader pageName="Project Activity" isProjectRelated={true} />
|
||||
{sidebarData.length > 0 && <ActivitySideBar sidebarData={sidebarData} toggleSidebar={toggleSidebar} currentEvent={currentEvent} />}
|
||||
<div className="flex flex-col justify-between items-start mx-4 mt-6 mb-4 text-xl max-w-5xl px-2">
|
||||
<div className="flex flex-row justify-start items-center text-3xl">
|
||||
<p className="font-semibold mr-4 text-bunker-100">Activity Logs</p>
|
||||
@ -140,22 +93,32 @@ export default function Activity() {
|
||||
Event history limited to the last 12 months.
|
||||
</p>
|
||||
</div>
|
||||
{/* Licence Required
|
||||
<div className="px-6 h-8 mt-2">
|
||||
<EventFilter
|
||||
selected={eventChosen}
|
||||
select={setEventChosen}
|
||||
data={["Secrets Pulled", "Secrets Pushed"]}
|
||||
data={["readSecrets", "updateSecrets", "addSecrets"]}
|
||||
isFull={false}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
<ActivityTable
|
||||
data={data.filter((event) =>
|
||||
eventChosen != '' ? event.eventName == eventChosen : event
|
||||
)}
|
||||
data={logsData!.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.filter((log) =>
|
||||
eventChosen != '' ? log.payload?.map(action => t("activity:event." + action.name)).includes(eventChosen) : true
|
||||
)
|
||||
}
|
||||
toggleSidebar={toggleSidebar}
|
||||
setCurrentEvent={setCurrentEvent}
|
||||
/>
|
||||
<div className='flex justify-center w-full mb-6'>
|
||||
<div className='items-center w-60'>
|
||||
<Button text="View More" textDisabled="End of History" active={logsData.length % 10 == 0 ? true : false} onButtonPressed={loadMoreLogs} size="md" color="mineshaft"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Activity.requireAuth = true;
|
||||
|
||||
export const getServerSideProps = getTranslatedServerSideProps(["activity"]);
|
7
frontend/public/locales/en/activity.json
Normal file
7
frontend/public/locales/en/activity.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"event": {
|
||||
"readSecrets": "Secrets Viewed",
|
||||
"updateSecrets": "Secrets Updated",
|
||||
"addSecrets": "Secrets Added"
|
||||
}
|
||||
}
|
@ -1238,6 +1238,7 @@ module.exports = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
"./ee/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
@ -1382,12 +1383,12 @@ module.exports = {
|
||||
"0%": {
|
||||
transform: "scale(0.2)",
|
||||
opacity: 0,
|
||||
transform: "translateY(120%)",
|
||||
// transform: "translateY(120%)",
|
||||
},
|
||||
"100%": {
|
||||
transform: "scale(1)",
|
||||
opacity: 1,
|
||||
transform: "translateY(100%)",
|
||||
// transform: "translateY(100%)",
|
||||
},
|
||||
},
|
||||
popright: {
|
||||
@ -1410,12 +1411,12 @@ module.exports = {
|
||||
"0%": {
|
||||
transform: "scale(0.2)",
|
||||
opacity: 0,
|
||||
transform: "translateY(80%)",
|
||||
// transform: "translateY(80%)",
|
||||
},
|
||||
"100%": {
|
||||
transform: "scale(1)",
|
||||
opacity: 1,
|
||||
transform: "translateY(100%)",
|
||||
// transform: "translateY(100%)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
Reference in New Issue
Block a user