Add server upgrade status on admin panel (#20107)
## Summary Adds an admin upgrade-status panel that surfaces per-instance and per-workspace migration health, backed by a Redis-cached aggregate to keep the page snappy on large fleets. <img width="827" height="880" alt="Screenshot 2026-04-28 at 10 21 03" src="https://github.com/user-attachments/assets/8f88baa9-7268-4eff-bf6a-906a7f06ca91" /> <img width="804" height="892" alt="Screenshot 2026-04-28 at 10 21 11" src="https://github.com/user-attachments/assets/1e6decf8-766a-4d0e-96b1-03a9962bba3c" /> ## Computed metrics **Instance** (`InstanceUpgradeStatus`) - `inferredVersion` — version derived from the latest non-initial instance command name - `health` — `upToDate` | `behind` | `failed`, derived from the latest attempt vs. the last expected instance step in the upgrade sequence - `latestCommand` — `{ name, status, executedByVersion, errorMessage, createdAt }` from the most recent attempt **Per-workspace** (`WorkspaceUpgradeStatus`) - `workspaceId`, `displayName` - `inferredVersion`, `health`, `latestCommand` (same shape as instance), computed against the latest expected step in the sequence **Aggregate** (`AllWorkspacesUpgradeStatus`, only across `ACTIVE` / `SUSPENDED` workspaces) - `instanceUpgradeStatus` - `totalCount`, `upToDateCount`, `behindCount`, `failedCount` - `workspacesBehindIds[]`, `workspacesFailedIds[]` - `computedAt` ## Fetching strategy All reads go through `UpgradeStatusCacheService` (cache namespace: `EngineHealth`). - **Aggregate read** (`getAllWorkspacesStatus` → `getAllWorkspacesUpgradeStatus` query): reads summary + behind-ids + failed-ids in parallel; if any of the three keys is missing, full recompute (`recomputeAllWorkspaces`) is triggered, which also primes per-workspace entries. - **Per-workspace read** (`getWorkspacesStatus(ids)` → `getUpgradeStatus(ids)` query): `mget` on workspace keys; misses are recomputed individually (`recomputeWorkspace`), and aggregates are reconciled in place (count + id list deltas) without a full recompute. - **Recompute on demand**: `refreshUpgradeStatus` mutation calls `recomputeAllWorkspaces` to bypass cache and rewrite all keys. - **Auto-invalidation**: `InstanceCommandRunnerService` (fast + slow paths) and `WorkspaceCommandRunnerService` invalidate after every run via `safeInvalidateUpgradeStatusCache()` (`flushByPattern('upgrade-status:*')`). Failures in cache invalidation are swallowed and logged so they never break the migration runner. - **TTL**: `60 * 60 * 1000` ms (1 hour) on every key — protects against stale data even if a runner crashes before invalidating. ## Introduced cache keys All under the `EngineHealth` cache-storage namespace: | Key | Type | Purpose | | --- | --- | --- | | `upgrade-status:all-workspaces:summary` | `CachedAllWorkspacesStatusSummary` | Counts + instance status + `computedAt` | | `upgrade-status:all-workspaces:behind-ids` | `string[]` | Workspace ids in `behind` state | | `upgrade-status:all-workspaces:failed-ids` | `string[]` | Workspace ids in `failed` state | | `upgrade-status:workspace:<workspaceId>` | `CachedWorkspaceUpgradeStatus` | Per-workspace status (one key per workspace) | Full invalidation uses the pattern `upgrade-status:*`. ## Index added on `upgradeMigration` (already added on prod) Migration `2-2-instance-command-fast-1777308014234-addUpgradeMigrationWorkspaceIdIndex.ts`: ```sql CREATE INDEX "IDX_upgradeMigration_workspaceId_name_attempt" ON "core"."upgradeMigration" ("workspaceId", "name", "attempt") WHERE "workspaceId" IS NOT NULL;
This commit is contained in:
+96
-1
@@ -1,16 +1,22 @@
|
||||
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
|
||||
import { type WorkspaceInfo } from '@/settings/admin-panel/types/WorkspaceInfo';
|
||||
import { getUpgradeHealthStatusBadge } from '@/settings/admin-panel/utils/getUpgradeHealthStatusBadge';
|
||||
import { getWorkspaceSchemaName } from '@/settings/admin-panel/utils/getWorkspaceSchemaName';
|
||||
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
|
||||
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { UserContext } from '@/users/contexts/UserContext';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useContext } from 'react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import {
|
||||
formatUpgradeCommandName,
|
||||
getImageAbsoluteURI,
|
||||
getSettingsPath,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { type GetUpgradeStatusQuery } from '~/generated-admin/graphql';
|
||||
import { AvatarOrIcon, LinkChip } from 'twenty-ui/components';
|
||||
import {
|
||||
H2Title,
|
||||
@@ -20,13 +26,18 @@ import {
|
||||
IconLink,
|
||||
IconStatusChange,
|
||||
IconUser,
|
||||
OverflowingTextWithTooltip,
|
||||
Status,
|
||||
} from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
|
||||
|
||||
type SettingsAdminWorkspaceContentProps = {
|
||||
activeWorkspace: WorkspaceInfo | undefined;
|
||||
workspaceUpgradeStatus?: GetUpgradeStatusQuery['getUpgradeStatus'][number];
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -38,13 +49,28 @@ const StyledContainer = styled.div`
|
||||
|
||||
export const SettingsAdminWorkspaceContent = ({
|
||||
activeWorkspace,
|
||||
workspaceUpgradeStatus,
|
||||
}: SettingsAdminWorkspaceContentProps) => {
|
||||
const { t } = useLingui();
|
||||
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
|
||||
const { localeCatalog } = useAtomStateValue(dateLocaleState);
|
||||
|
||||
const formattedLastUpdated = formatDateTimeString({
|
||||
value: workspaceUpgradeStatus?.latestCommand?.createdAt,
|
||||
timeZone,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
localeCatalog: localeCatalog,
|
||||
});
|
||||
|
||||
const getWorkspaceUrl = (workspaceUrls: WorkspaceInfo['workspaceUrls']) => {
|
||||
return workspaceUrls.customUrl ?? workspaceUrls.subdomainUrl;
|
||||
};
|
||||
|
||||
const upgradeHealthStatusBadge = getUpgradeHealthStatusBadge(
|
||||
workspaceUpgradeStatus?.health,
|
||||
);
|
||||
|
||||
const workspaceInfoItems = [
|
||||
{
|
||||
Icon: IconHome,
|
||||
@@ -125,6 +151,75 @@ export const SettingsAdminWorkspaceContent = ({
|
||||
gridAutoColumns="1fr 4fr"
|
||||
/>
|
||||
</Section>
|
||||
{workspaceUpgradeStatus && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Upgrade Status`}
|
||||
description={t`Workspace upgrade health`}
|
||||
/>
|
||||
<SettingsTableCard
|
||||
items={[
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Status`,
|
||||
value: (
|
||||
<Status
|
||||
color={upgradeHealthStatusBadge.color}
|
||||
text={upgradeHealthStatusBadge.label}
|
||||
weight="medium"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
Icon: IconId,
|
||||
label: t`Inferred version`,
|
||||
value: workspaceUpgradeStatus.inferredVersion ?? t`Unknown`,
|
||||
},
|
||||
{
|
||||
Icon: IconCalendar,
|
||||
label: t`Last command`,
|
||||
value: (
|
||||
<OverflowingTextWithTooltip
|
||||
text={
|
||||
workspaceUpgradeStatus.latestCommand?.name
|
||||
? formatUpgradeCommandName(
|
||||
workspaceUpgradeStatus.latestCommand.name,
|
||||
)
|
||||
: t`None`
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
Icon: IconCalendar,
|
||||
label: t`Last updated`,
|
||||
value: isNonEmptyString(formattedLastUpdated)
|
||||
? formattedLastUpdated
|
||||
: t`N/A`,
|
||||
},
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Last command result`,
|
||||
value: workspaceUpgradeStatus.latestCommand?.status
|
||||
? workspaceUpgradeStatus.latestCommand.status === 'completed'
|
||||
? t`Completed`
|
||||
: t`Failed`
|
||||
: t`N/A`,
|
||||
},
|
||||
...(workspaceUpgradeStatus.latestCommand?.errorMessage
|
||||
? [
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Last error`,
|
||||
value: workspaceUpgradeStatus.latestCommand.errorMessage,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
gridAutoColumns="2fr 3fr"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_UPGRADE_STATUS = gql`
|
||||
query GetUpgradeStatus($workspaceIds: [UUID!]!) {
|
||||
getUpgradeStatus(workspaceIds: $workspaceIds) {
|
||||
workspaceId
|
||||
displayName
|
||||
inferredVersion
|
||||
health
|
||||
latestCommand {
|
||||
name
|
||||
status
|
||||
executedByVersion
|
||||
errorMessage
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+27
-5
@@ -1,13 +1,17 @@
|
||||
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
|
||||
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
|
||||
import { SettingsAdminHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatusListCard';
|
||||
import { SettingsAdminMaintenanceModeFetchEffect } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceModeFetchEffect';
|
||||
import { SettingsAdminUpgradeStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminUpgradeStatusListCard';
|
||||
import { SettingsAdminMaintenanceMode } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode';
|
||||
import { SettingsAdminMaintenanceModeFetchEffect } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceModeFetchEffect';
|
||||
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { GetSystemHealthStatusDocument } from '~/generated-admin/graphql';
|
||||
import {
|
||||
GetInstanceAndAllWorkspacesUpgradeStatusDocument,
|
||||
GetSystemHealthStatusDocument,
|
||||
} from '~/generated-admin/graphql';
|
||||
|
||||
export const SettingsAdminHealthStatus = () => {
|
||||
const apolloAdminClient = useApolloAdminClient();
|
||||
@@ -18,10 +22,19 @@ export const SettingsAdminHealthStatus = () => {
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
);
|
||||
const { data: upgradeStatusData, loading: loadingUpgradeStatus } = useQuery(
|
||||
GetInstanceAndAllWorkspacesUpgradeStatusDocument,
|
||||
{
|
||||
client: apolloAdminClient,
|
||||
fetchPolicy: 'network-only',
|
||||
},
|
||||
);
|
||||
|
||||
const services = data?.getSystemHealthStatus.services ?? [];
|
||||
const upgradeStatus =
|
||||
upgradeStatusData?.getInstanceAndAllWorkspacesUpgradeStatus;
|
||||
|
||||
if (loadingHealthStatus) {
|
||||
if (loadingHealthStatus || loadingUpgradeStatus) {
|
||||
return <SettingsSectionSkeletonLoader />;
|
||||
}
|
||||
|
||||
@@ -38,6 +51,15 @@ export const SettingsAdminHealthStatus = () => {
|
||||
loading={loadingHealthStatus}
|
||||
/>
|
||||
</Section>
|
||||
{upgradeStatus && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Upgrade Status`}
|
||||
description={t`Upgrade health across instance and workspaces`}
|
||||
/>
|
||||
<SettingsAdminUpgradeStatusListCard upgradeStatus={upgradeStatus} />
|
||||
</Section>
|
||||
)}
|
||||
<SettingsAdminMaintenanceMode />
|
||||
</>
|
||||
);
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { SettingsAdminUpgradeStatusRightContainer } from '@/settings/admin-panel/health-status/components/SettingsAdminUpgradeStatusRightContainer';
|
||||
import { SettingsListCard } from '@/settings/components/SettingsListCard';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconId,
|
||||
IconProgressCheck,
|
||||
IconStatusChange,
|
||||
type IconComponent,
|
||||
} from 'twenty-ui/display';
|
||||
import { type GetInstanceAndAllWorkspacesUpgradeStatusQuery } from '~/generated-admin/graphql';
|
||||
|
||||
type InstanceAndAllWorkspacesUpgradeStatus =
|
||||
GetInstanceAndAllWorkspacesUpgradeStatusQuery['getInstanceAndAllWorkspacesUpgradeStatus'];
|
||||
|
||||
export type UpgradeStatusRowKind =
|
||||
| 'inferred-version'
|
||||
| 'instance-status'
|
||||
| 'workspaces-status';
|
||||
|
||||
export type UpgradeStatusRow = {
|
||||
id: UpgradeStatusRowKind;
|
||||
kind: UpgradeStatusRowKind;
|
||||
label: string;
|
||||
inferredVersion: string | null | undefined;
|
||||
instanceHealth: InstanceAndAllWorkspacesUpgradeStatus['instanceUpgradeStatus']['health'];
|
||||
behindCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
|
||||
const ROW_ICON_BY_KIND: Record<UpgradeStatusRowKind, IconComponent> = {
|
||||
'inferred-version': IconId,
|
||||
'instance-status': IconProgressCheck,
|
||||
'workspaces-status': IconStatusChange,
|
||||
};
|
||||
|
||||
const SETTINGS_PATH_BY_KIND: Record<UpgradeStatusRowKind, SettingsPath> = {
|
||||
'inferred-version': SettingsPath.AdminPanelInferredVersion,
|
||||
'instance-status': SettingsPath.AdminPanelInstanceStatus,
|
||||
'workspaces-status': SettingsPath.AdminPanelWorkspacesStatus,
|
||||
};
|
||||
|
||||
type SettingsAdminUpgradeStatusListCardProps = {
|
||||
upgradeStatus: Pick<
|
||||
InstanceAndAllWorkspacesUpgradeStatus,
|
||||
'instanceUpgradeStatus' | 'workspacesBehind' | 'workspacesFailed'
|
||||
>;
|
||||
};
|
||||
|
||||
export const SettingsAdminUpgradeStatusListCard = ({
|
||||
upgradeStatus,
|
||||
}: SettingsAdminUpgradeStatusListCardProps) => {
|
||||
const sharedRowProps = {
|
||||
inferredVersion: upgradeStatus.instanceUpgradeStatus.inferredVersion,
|
||||
instanceHealth: upgradeStatus.instanceUpgradeStatus.health,
|
||||
behindCount: upgradeStatus.workspacesBehind.length,
|
||||
failedCount: upgradeStatus.workspacesFailed.length,
|
||||
};
|
||||
|
||||
const items: UpgradeStatusRow[] = [
|
||||
{
|
||||
id: 'inferred-version',
|
||||
kind: 'inferred-version',
|
||||
label: t`Inferred version`,
|
||||
...sharedRowProps,
|
||||
},
|
||||
{
|
||||
id: 'instance-status',
|
||||
kind: 'instance-status',
|
||||
label: t`Instance status`,
|
||||
...sharedRowProps,
|
||||
},
|
||||
{
|
||||
id: 'workspaces-status',
|
||||
kind: 'workspaces-status',
|
||||
label: t`Workspaces status`,
|
||||
...sharedRowProps,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsListCard<UpgradeStatusRow>
|
||||
items={items}
|
||||
rounded={true}
|
||||
RowIconFn={(item) => ROW_ICON_BY_KIND[item.kind]}
|
||||
getItemLabel={(item) => item.label}
|
||||
RowRightComponent={SettingsAdminUpgradeStatusRightContainer}
|
||||
to={(item) => getSettingsPath(SETTINGS_PATH_BY_KIND[item.kind])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { type UpgradeStatusRow } from '@/settings/admin-panel/health-status/components/SettingsAdminUpgradeStatusListCard';
|
||||
import { getUpgradeHealthStatusBadge } from '@/settings/admin-panel/utils/getUpgradeHealthStatusBadge';
|
||||
import { getWorkspacesUpgradeHealth } from '@/settings/admin-panel/utils/getWorkspacesUpgradeHealth';
|
||||
import { getWorkspacesUpgradeHealthText } from '@/settings/admin-panel/utils/getWorkspacesUpgradeHealthText';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Status } from 'twenty-ui/display';
|
||||
|
||||
export const SettingsAdminUpgradeStatusRightContainer = ({
|
||||
item,
|
||||
}: {
|
||||
item: UpgradeStatusRow;
|
||||
}) => {
|
||||
if (item.kind === 'inferred-version') {
|
||||
return (
|
||||
<Status
|
||||
color="gray"
|
||||
text={item.inferredVersion ?? t`Unknown`}
|
||||
weight="medium"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'instance-status') {
|
||||
const badge = getUpgradeHealthStatusBadge(item.instanceHealth);
|
||||
|
||||
return <Status color={badge.color} text={badge.label} weight="medium" />;
|
||||
}
|
||||
|
||||
const workspacesUpgradeHealth = getWorkspacesUpgradeHealth(
|
||||
item.behindCount,
|
||||
item.failedCount,
|
||||
);
|
||||
const workspacesUpgradeHealthBadge = getUpgradeHealthStatusBadge(
|
||||
workspacesUpgradeHealth,
|
||||
);
|
||||
const workspacesUpgradeHealthText = getWorkspacesUpgradeHealthText(
|
||||
item.behindCount,
|
||||
item.failedCount,
|
||||
workspacesUpgradeHealthBadge.label,
|
||||
);
|
||||
|
||||
return (
|
||||
<Status
|
||||
color={workspacesUpgradeHealthBadge.color}
|
||||
text={workspacesUpgradeHealthText}
|
||||
weight="medium"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconChevronDown, IconChevronRight } from 'twenty-ui/display';
|
||||
import { AnimatedExpandableContainer, Card } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledAccordionHeaderButton = styled.button`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
justify-content: space-between;
|
||||
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledAccordionHeaderButtonDisabled = styled(StyledAccordionHeaderButton)`
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
const StyledAccordionContent = styled.div`
|
||||
border-top: 1px solid ${themeCssVariables.border.color.medium};
|
||||
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledWorkspaceList = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
const StyledWorkspaceListItem = styled.li`
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledWorkspaceLink = styled(Link)`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
display: block;
|
||||
padding: ${themeCssVariables.spacing[2]} 0;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
type WorkspaceUpgradeRefItem = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
type SettingsAdminWorkspacesByHealthAccordionProps = {
|
||||
filledLabel: string;
|
||||
emptyLabel: string;
|
||||
workspaces: WorkspaceUpgradeRefItem[];
|
||||
defaultExpanded?: boolean;
|
||||
};
|
||||
|
||||
export const SettingsAdminWorkspacesByHealthAccordion = ({
|
||||
filledLabel,
|
||||
emptyLabel,
|
||||
workspaces,
|
||||
defaultExpanded = false,
|
||||
}: SettingsAdminWorkspacesByHealthAccordionProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const hasWorkspaces = workspaces.length > 0;
|
||||
|
||||
return (
|
||||
<Card rounded={true}>
|
||||
{hasWorkspaces ? (
|
||||
<StyledAccordionHeaderButton
|
||||
onClick={() => setIsExpanded((currentValue) => !currentValue)}
|
||||
>
|
||||
<span>{filledLabel}</span>
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronRight size={16} />
|
||||
)}
|
||||
</StyledAccordionHeaderButton>
|
||||
) : (
|
||||
<StyledAccordionHeaderButtonDisabled>
|
||||
<span>{emptyLabel}</span>
|
||||
</StyledAccordionHeaderButtonDisabled>
|
||||
)}
|
||||
{hasWorkspaces && (
|
||||
<AnimatedExpandableContainer
|
||||
isExpanded={isExpanded}
|
||||
dimension="height"
|
||||
mode="scroll-height"
|
||||
>
|
||||
<StyledAccordionContent>
|
||||
<StyledWorkspaceList>
|
||||
{workspaces.map((workspace) => (
|
||||
<StyledWorkspaceListItem key={workspace.id}>
|
||||
<StyledWorkspaceLink
|
||||
to={getSettingsPath(
|
||||
SettingsPath.AdminPanelWorkspaceDetail,
|
||||
{ workspaceId: workspace.id },
|
||||
)}
|
||||
>
|
||||
{workspace.name ?? t`Unknown workspace`}
|
||||
{' - '}
|
||||
{workspace.id}
|
||||
</StyledWorkspaceLink>
|
||||
</StyledWorkspaceListItem>
|
||||
))}
|
||||
</StyledWorkspaceList>
|
||||
</StyledAccordionContent>
|
||||
</AnimatedExpandableContainer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { getUpgradeHealthStatusBadge } from '@/settings/admin-panel/utils/getUpgradeHealthStatusBadge';
|
||||
import { getWorkspacesUpgradeHealth } from '@/settings/admin-panel/utils/getWorkspacesUpgradeHealth';
|
||||
import { getWorkspacesUpgradeHealthText } from '@/settings/admin-panel/utils/getWorkspacesUpgradeHealthText';
|
||||
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { UserContext } from '@/users/contexts/UserContext';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconClock,
|
||||
IconStatusChange,
|
||||
IconX,
|
||||
Status,
|
||||
} from 'twenty-ui/display';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
|
||||
|
||||
type SettingsAdminWorkspacesStatusSummaryCardProps = {
|
||||
behindCount: number;
|
||||
failedCount: number;
|
||||
computedAt: string | undefined;
|
||||
};
|
||||
|
||||
export const SettingsAdminWorkspacesStatusSummaryCard = ({
|
||||
behindCount,
|
||||
failedCount,
|
||||
computedAt,
|
||||
}: SettingsAdminWorkspacesStatusSummaryCardProps) => {
|
||||
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
|
||||
const { localeCatalog } = useAtomStateValue(dateLocaleState);
|
||||
|
||||
const workspacesUpgradeHealth = getWorkspacesUpgradeHealth(
|
||||
behindCount,
|
||||
failedCount,
|
||||
);
|
||||
const workspacesUpgradeHealthBadge = getUpgradeHealthStatusBadge(
|
||||
workspacesUpgradeHealth,
|
||||
);
|
||||
const workspacesUpgradeHealthText = getWorkspacesUpgradeHealthText(
|
||||
behindCount,
|
||||
failedCount,
|
||||
workspacesUpgradeHealthBadge.label,
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsTableCard
|
||||
items={[
|
||||
{
|
||||
Icon: IconStatusChange,
|
||||
label: t`Upgrade health`,
|
||||
value: (
|
||||
<Status
|
||||
color={workspacesUpgradeHealthBadge.color}
|
||||
text={workspacesUpgradeHealthText}
|
||||
weight="medium"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
Icon: IconAlertTriangle,
|
||||
label: t`Behind`,
|
||||
value: behindCount,
|
||||
},
|
||||
{
|
||||
Icon: IconX,
|
||||
label: t`Failed`,
|
||||
value: failedCount,
|
||||
},
|
||||
{
|
||||
Icon: IconClock,
|
||||
label: t`Computed at`,
|
||||
value:
|
||||
formatDateTimeString({
|
||||
value: computedAt,
|
||||
timeZone,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
localeCatalog,
|
||||
}) || t`N/A`,
|
||||
},
|
||||
]}
|
||||
gridAutoColumns="3fr 4fr"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REFRESH_UPGRADE_STATUS = gql`
|
||||
mutation RefreshUpgradeStatus {
|
||||
refreshUpgradeStatus {
|
||||
instanceUpgradeStatus {
|
||||
inferredVersion
|
||||
health
|
||||
latestCommand {
|
||||
name
|
||||
status
|
||||
executedByVersion
|
||||
errorMessage
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
workspacesBehind {
|
||||
id
|
||||
name
|
||||
}
|
||||
workspacesFailed {
|
||||
id
|
||||
name
|
||||
}
|
||||
computedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_INSTANCE_AND_ALL_WORKSPACES_UPGRADE_STATUS = gql`
|
||||
query GetInstanceAndAllWorkspacesUpgradeStatus {
|
||||
getInstanceAndAllWorkspacesUpgradeStatus {
|
||||
instanceUpgradeStatus {
|
||||
inferredVersion
|
||||
health
|
||||
latestCommand {
|
||||
name
|
||||
status
|
||||
executedByVersion
|
||||
errorMessage
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
workspacesBehind {
|
||||
id
|
||||
name
|
||||
}
|
||||
workspacesFailed {
|
||||
id
|
||||
name
|
||||
}
|
||||
computedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type ThemeColor } from 'twenty-ui/theme';
|
||||
import { UpgradeHealth } from '~/generated-admin/graphql';
|
||||
|
||||
type UpgradeHealthStatusBadge = {
|
||||
color: ThemeColor;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const getUpgradeHealthStatusBadge = (
|
||||
health: UpgradeHealth | undefined,
|
||||
): UpgradeHealthStatusBadge => {
|
||||
switch (health) {
|
||||
case UpgradeHealth.UP_TO_DATE:
|
||||
return { color: 'green', label: t`Up to date` };
|
||||
case UpgradeHealth.BEHIND:
|
||||
return { color: 'orange', label: t`Behind` };
|
||||
case UpgradeHealth.FAILED:
|
||||
return { color: 'red', label: t`Failed` };
|
||||
default:
|
||||
return { color: 'gray', label: t`Unknown` };
|
||||
}
|
||||
};
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { UpgradeHealth } from '~/generated-admin/graphql';
|
||||
|
||||
export const getWorkspacesUpgradeHealth = (
|
||||
behindCount: number,
|
||||
failedCount: number,
|
||||
): UpgradeHealth => {
|
||||
if (failedCount > 0) {
|
||||
return UpgradeHealth.FAILED;
|
||||
}
|
||||
|
||||
if (behindCount > 0) {
|
||||
return UpgradeHealth.BEHIND;
|
||||
}
|
||||
|
||||
return UpgradeHealth.UP_TO_DATE;
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { plural } from '@lingui/core/macro';
|
||||
|
||||
export const getWorkspacesUpgradeHealthText = (
|
||||
behindCount: number,
|
||||
failedCount: number,
|
||||
upToDateLabel: string,
|
||||
): string => {
|
||||
if (failedCount > 0 && behindCount > 0) {
|
||||
return plural(failedCount + behindCount, {
|
||||
one: '# workspace failed or behind',
|
||||
other: '# workspaces failed or behind',
|
||||
});
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
return plural(failedCount, {
|
||||
one: '# workspace failed',
|
||||
other: '# workspaces failed',
|
||||
});
|
||||
}
|
||||
|
||||
if (behindCount > 0) {
|
||||
return plural(behindCount, {
|
||||
one: '# workspace behind',
|
||||
other: '# workspaces behind',
|
||||
});
|
||||
}
|
||||
|
||||
return upToDateLabel;
|
||||
};
|
||||
Reference in New Issue
Block a user