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:
Marie
2026-05-04 17:06:07 +02:00
committed by GitHub
parent 1cd983a330
commit 4852ac401a
49 changed files with 2100 additions and 213 deletions
@@ -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>
);
};
@@ -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
}
}
}
`;
@@ -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 />
</>
);
@@ -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])}
/>
);
};
@@ -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"
/>
);
};
@@ -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>
);
};
@@ -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"
/>
);
};
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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` };
}
};
@@ -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;
};
@@ -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;
};