feat(home): change layout of env tile [EE-4479] (#8061)

pull/8180/head
Chaim Lev-Ari 2022-12-07 16:51:20 +02:00 committed by GitHub
parent b48aa1274d
commit eba5879ec8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 717 additions and 445 deletions

View File

@ -205,7 +205,6 @@ input[type='checkbox'] {
.blocklist-item { .blocklist-item {
padding: 10px; padding: 10px;
margin-bottom: 10px; margin-bottom: 10px;
cursor: pointer;
border: 1px solid var(--border-blocklist); border: 1px solid var(--border-blocklist);
border-radius: 8px; border-radius: 8px;
margin-right: 10px; margin-right: 10px;
@ -222,8 +221,9 @@ input[type='checkbox'] {
color: var(--text-blocklist-item-selected-color); color: var(--text-blocklist-item-selected-color);
} }
.blocklist-item:hover { .blocklist-item:not(.blocklist-item-not-interactive):hover {
@apply border border-blue-7; @apply border border-blue-7;
cursor: pointer;
background-color: var(--bg-blocklist-hover-color); background-color: var(--bg-blocklist-hover-color);
color: var(--text-blocklist-hover-color); color: var(--text-blocklist-hover-color);
@ -233,10 +233,6 @@ input[type='checkbox'] {
display: flex; display: flex;
} }
.blocklist-item-line.endpoint-item {
padding: 4px;
}
.blocklist-item-line { .blocklist-item-line {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -248,24 +244,11 @@ input[type='checkbox'] {
max-height: 60px; max-height: 60px;
} }
.blocklist-item-logo.endpoint-item {
margin: 10px 4px 0 6px;
}
.blocklist-item-logo.endpoint-item.azure {
margin: 0 0 0 10px;
}
.blocklist-item-title { .blocklist-item-title {
font-size: 1.8em; font-size: 1.8em;
font-weight: bold; font-weight: bold;
} }
.blocklist-item-title.endpoint-item {
font-size: 1em;
font-weight: bold;
}
.blocklist-item-subtitle { .blocklist-item-subtitle {
font-size: 0.9em; font-size: 0.9em;
padding-right: 1em; padding-right: 1em;

View File

@ -4,7 +4,7 @@
border-radius: 8px; border-radius: 8px;
display: inline-flex; display: inline-flex;
justify-content: space-around; justify-content: center;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
} }

View File

@ -1,6 +1,8 @@
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
export type UserId = number; import { UserId } from './types/user-id';
export { type UserId };
export enum Role { export enum Role {
Admin = 1, Admin = 1,

View File

@ -0,0 +1 @@
export type UserId = number;

View File

@ -78,6 +78,7 @@ export function createMockEnvironment(): Environment {
AllowNoneIngressClass: false, AllowNoneIngressClass: false,
}, },
}, },
Nomad: { Snapshots: [] },
EdgeKey: '', EdgeKey: '',
Id: 3, Id: 3,
UserTrusted: false, UserTrusted: false,

View File

@ -1,9 +1,10 @@
import clsx from 'clsx'; import { Activity } from 'lucide-react';
import { isoDateFromTimestamp } from '@/portainer/filters/filters'; import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { useHasHeartbeat } from '@/react/edge/hooks/useHasHeartbeat';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { PublicSettingsViewModel } from '@/portainer/models/settings'; import { EnvironmentStatusBadgeItem } from './EnvironmentStatusBadgeItem';
interface Props { interface Props {
showLastCheckInDate?: boolean; showLastCheckInDate?: boolean;
@ -15,100 +16,46 @@ export function EdgeIndicator({
showLastCheckInDate = false, showLastCheckInDate = false,
}: Props) { }: Props) {
const associated = !!environment.EdgeID; const isValid = useHasHeartbeat(environment);
const isValid = useHasHeartbeat(environment, associated);
if (isValid === null) { if (isValid === null) {
return null; return null;
} }
const associated = !!environment.EdgeID;
if (!associated) { if (!associated) {
return ( return (
<span role="status" aria-label="edge-status"> <span role="status" aria-label="edge-status">
<span className="label label-default" aria-label="unassociated"> <EnvironmentStatusBadgeItem aria-label="unassociated">
<s>associated</s> <s>associated</s>
</span> </EnvironmentStatusBadgeItem>
</span> </span>
); );
} }
return ( return (
<span role="status" aria-label="edge-status">
<span <span
className={clsx('label', { role="status"
'label-danger': !isValid, aria-label="edge-status"
'label-success': isValid, className="flex items-center gap-1"
})} >
<EnvironmentStatusBadgeItem
color={isValid ? 'success' : 'danger'}
aria-label="edge-heartbeat" aria-label="edge-heartbeat"
> >
heartbeat heartbeat
</span> </EnvironmentStatusBadgeItem>
{showLastCheckInDate && !!environment.LastCheckInDate && ( {showLastCheckInDate && !!environment.LastCheckInDate && (
<span <span
className="space-left small text-muted" className="small text-muted vertical-center"
aria-label="edge-last-checkin" aria-label="edge-last-checkin"
title="Last edge check-in"
> >
<Activity className="icon icon-sm space-right" aria-hidden="true" />
{isoDateFromTimestamp(environment.LastCheckInDate)} {isoDateFromTimestamp(environment.LastCheckInDate)}
</span> </span>
)} )}
</span> </span>
); );
} }
function useHasHeartbeat(environment: Environment, associated: boolean) {
const settingsQuery = usePublicSettings({ enabled: associated });
if (!associated) {
return false;
}
const { LastCheckInDate, QueryDate } = environment;
const settings = settingsQuery.data;
if (!settings) {
return null;
}
const checkInInterval = getCheckinInterval(environment, settings);
if (checkInInterval && QueryDate && LastCheckInDate) {
return QueryDate - LastCheckInDate <= checkInInterval * 2 + 20;
}
return false;
}
function getCheckinInterval(
environment: Environment,
settings: PublicSettingsViewModel
) {
const asyncMode = environment.Edge.AsyncMode;
if (asyncMode) {
const intervals = [
environment.Edge.PingInterval > 0
? environment.Edge.PingInterval
: settings.Edge.PingInterval,
environment.Edge.SnapshotInterval > 0
? environment.Edge.SnapshotInterval
: settings.Edge.SnapshotInterval,
environment.Edge.CommandInterval > 0
? environment.Edge.CommandInterval
: settings.Edge.CommandInterval,
].filter((n) => n > 0);
return intervals.length > 0 ? Math.min(...intervals) : 60;
}
if (
!environment.EdgeCheckinInterval ||
environment.EdgeCheckinInterval === 0
) {
return settings.Edge.CheckinInterval;
}
return environment.EdgeCheckinInterval;
}

View File

@ -0,0 +1,21 @@
import { CheckCircle, XCircle } from 'lucide-react';
import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { EnvironmentStatusBadgeItem } from './EnvironmentStatusBadgeItem';
interface Props {
status: EnvironmentStatus;
}
export function EnvironmentStatusBadge({ status }: Props) {
return status === EnvironmentStatus.Up ? (
<EnvironmentStatusBadgeItem color="success" icon={CheckCircle}>
Up
</EnvironmentStatusBadgeItem>
) : (
<EnvironmentStatusBadgeItem color="danger" icon={XCircle}>
Down
</EnvironmentStatusBadgeItem>
);
}

View File

@ -0,0 +1,48 @@
import clsx from 'clsx';
import { AriaAttributes, PropsWithChildren } from 'react';
import { Icon, IconProps } from '@@/Icon';
export function EnvironmentStatusBadgeItem({
className,
children,
color = 'default',
icon,
...aria
}: PropsWithChildren<
{
className?: string;
color?: 'success' | 'danger' | 'default';
icon?: IconProps['icon'];
} & AriaAttributes
>) {
return (
<span
className={clsx(
'flex items-center gap-1',
'border-2 border-solid rounded',
'w-fit py-px px-1',
'text-xs font-semibold text-gray-7',
{
'border-green-3 bg-green-2': color === 'success',
'border-error-3 bg-error-2': color === 'danger',
},
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...aria}
>
{icon && (
<Icon
icon={icon}
className={clsx({
'!text-green-7': color === 'success',
'!text-error-7': color === 'danger',
})}
/>
)}
{children}
</span>
);
}

View File

@ -0,0 +1,33 @@
import { ComponentProps } from 'react';
import { Button } from './buttons';
import { Link } from './Link';
export function LinkButton({
to,
params,
disabled,
children,
...props
}: ComponentProps<typeof Button> & ComponentProps<typeof Link>) {
const button = (
<Button
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
size="medium"
disabled={disabled}
>
{children}
</Button>
);
if (disabled) {
return button;
}
return (
<Link to={to} params={params} className="text-inherit hover:no-underline">
{button}
</Link>
);
}

View File

@ -9,20 +9,19 @@ interface Props extends IconProps {
iconClass?: string; iconClass?: string;
} }
export function EnvironmentStatsItem({ export function StatsItem({
value, value,
icon, icon,
children, children,
iconClass, iconClass,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<span className="vertical-center space-right"> <span className="flex gap-1 items-center">
<Icon <Icon className={clsx('icon icon-sm', iconClass)} icon={icon} />
className={clsx('icon icon-sm space-right', iconClass)}
icon={icon}
/>
<span>{value}</span> <span>{value}</span>
{children && <span className="space-left">{children}</span>} {children && (
<span className="ml-1 flex gap-2 items-center">{children}</span>
)}
</span> </span>
); );
} }

View File

@ -0,0 +1,26 @@
import { DockerContainer } from '@/react/docker/containers/types';
export type DockerSnapshotRaw = {
Containers: DockerContainer[];
SnapshotTime: string;
};
export interface DockerSnapshot {
TotalCPU: number;
TotalMemory: number;
NodeCount: number;
ImageCount: number;
VolumeCount: number;
RunningContainerCount: number;
StoppedContainerCount: number;
HealthyContainerCount: number;
UnhealthyContainerCount: number;
Time: number;
StackCount: number;
ServiceCount: number;
Swarm: boolean;
DockerVersion: string;
GpuUseAll: boolean;
GpuUseList: string[];
SnapshotRaw: DockerSnapshotRaw;
}

View File

@ -1,8 +1,8 @@
import { CellProps, Column } from 'react-table'; import { CellProps, Column } from 'react-table';
import clsx from 'clsx';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
import { useHasHeartbeat } from '@/react/edge/hooks/useHasHeartbeat';
import { EdgeIndicator } from '@@/EdgeIndicator';
export const heartbeat: Column<Environment> = { export const heartbeat: Column<Environment> = {
Header: 'Heartbeat', Header: 'Heartbeat',
@ -18,3 +18,36 @@ export function StatusCell({
}: CellProps<Environment>) { }: CellProps<Environment>) {
return <EdgeIndicator environment={environment} />; return <EdgeIndicator environment={environment} />;
} }
function EdgeIndicator({ environment }: { environment: Environment }) {
const isValid = useHasHeartbeat(environment);
if (isValid === null) {
return null;
}
const associated = !!environment.EdgeID;
if (!associated) {
return (
<span role="status" aria-label="edge-status">
<span className="label label-default" aria-label="unassociated">
<s>associated</s>
</span>
</span>
);
}
return (
<span role="status" aria-label="edge-status">
<span
className={clsx('label', {
'label-danger': !isValid,
'label-success': isValid,
})}
aria-label="edge-heartbeat"
>
heartbeat
</span>
</span>
);
}

View File

@ -0,0 +1,61 @@
import { Environment } from '@/react/portainer/environments/types';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { PublicSettingsViewModel } from '@/portainer/models/settings';
export function useHasHeartbeat(environment: Environment) {
const associated = !!environment.EdgeID;
const settingsQuery = usePublicSettings({ enabled: associated });
if (!associated) {
return false;
}
const { LastCheckInDate, QueryDate } = environment;
const settings = settingsQuery.data;
if (!settings) {
return null;
}
const checkInInterval = getCheckinInterval(environment, settings);
if (checkInInterval && QueryDate && LastCheckInDate) {
return QueryDate - LastCheckInDate <= checkInInterval * 2 + 20;
}
return false;
}
function getCheckinInterval(
environment: Environment,
settings: PublicSettingsViewModel
) {
const asyncMode = environment.Edge.AsyncMode;
if (asyncMode) {
const intervals = [
environment.Edge.PingInterval > 0
? environment.Edge.PingInterval
: settings.Edge.PingInterval,
environment.Edge.SnapshotInterval > 0
? environment.Edge.SnapshotInterval
: settings.Edge.SnapshotInterval,
environment.Edge.CommandInterval > 0
? environment.Edge.CommandInterval
: settings.Edge.CommandInterval,
].filter((n) => n > 0);
return intervals.length > 0 ? Math.min(...intervals) : 60;
}
if (
!environment.EdgeCheckinInterval ||
environment.EdgeCheckinInterval === 0
) {
return settings.Edge.CheckinInterval;
}
return environment.EdgeCheckinInterval;
}

View File

@ -1,6 +1,6 @@
import { Power } from 'lucide-react'; import { Power } from 'lucide-react';
import { Icon } from '@@/Icon'; import { StatsItem } from '@@/StatsItem';
interface Props { interface Props {
running: number; running: number;
@ -11,11 +11,19 @@ export function RunningStatus({ running, stopped }: Props) {
return ( return (
<div> <div>
<div> <div>
<Icon icon={Power} mode="success" /> <StatsItem
value={`${running || '-'} running`}
icon={Power}
iconClass="icon-success"
/>
{`${running || '-'} running`} {`${running || '-'} running`}
</div> </div>
<div> <div>
<Icon icon={Power} mode="danger" /> <StatsItem
value={`${stopped || '-'} stopped`}
icon={Power}
iconClass="icon-danger"
/>
{`${stopped || '-'} stopped`} {`${stopped || '-'} stopped`}
</div> </div>
</div> </div>

View File

@ -18,9 +18,8 @@ export function AgentVersionTag({ type, version }: Props) {
return ( return (
<span className="space-x-1"> <span className="space-x-1">
<span>
<Zap className="icon icon-xs vertical-center" aria-hidden="true" /> <Zap className="icon icon-xs vertical-center" aria-hidden="true" />
</span>
<span>{isEdgeEnvironment(type) ? 'Edge Agent' : 'Agent'}</span> <span>{isEdgeEnvironment(type) ? 'Edge Agent' : 'Agent'}</span>
<span>{version}</span> <span>{version}</span>

View File

@ -0,0 +1,93 @@
import { Edit2, Settings } from 'lucide-react';
import { ReactNode } from 'react';
import clsx from 'clsx';
import { useUser } from '@/react/hooks/useUser';
import {
Environment,
PlatformType,
} from '@/react/portainer/environments/types';
import {
isEdgeAsync as checkEdgeAsync,
getPlatformType,
} from '@/react/portainer/environments/utils';
import { LinkButton } from '@@/LinkButton';
export function EditButtons({ environment }: { environment: Environment }) {
const { isAdmin } = useUser();
const isEdgeAsync = checkEdgeAsync(environment);
const configRoute = getConfigRoute(environment);
return (
<ButtonsGrid className="w-11 -m-[11px] ml-3">
<LinkButton
disabled={!isAdmin}
to="portainer.endpoints.endpoint"
params={{ id: environment.Id }}
color="none"
icon={Edit2}
size="medium"
className="w-full h-full !ml-0 hover:bg-gray-3 !rounded-none"
title="Edit"
/>
<LinkButton
disabled={!configRoute || isEdgeAsync || !isAdmin}
to={configRoute}
params={{ endpointId: environment.Id }}
color="none"
icon={Settings}
size="medium"
className="w-full h-full !ml-0 hover:bg-gray-3 !rounded-none"
title="Configuration"
/>
</ButtonsGrid>
);
}
function getConfigRoute(environment: Environment) {
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Docker:
return getDockerConfigRoute(environment);
case PlatformType.Kubernetes:
return 'kubernetes.cluster';
default:
return '';
}
}
function getDockerConfigRoute(environment: Environment) {
const snapshot = environment.Snapshots?.[0];
if (!snapshot) {
return '';
}
return snapshot.Swarm ? 'docker.swarm' : 'docker.host';
}
function ButtonsGrid({
children,
className,
}: {
children: ReactNode[];
className?: string;
}) {
return (
<div
className={clsx(
'grid grid-rows-3 border border-solid border-gray-5 rounded-r-lg',
className
)}
>
<div>{children[0] || null}</div>
<div className="border-x-0 border-y border-gray-5 border-solid">
{children[1] || null}
</div>
<div>{children[2] || null}</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { DockerSnapshot } from '@/react/docker/snapshots/types';
import {
Environment,
PlatformType,
KubernetesSnapshot,
} from '@/react/portainer/environments/types';
import { getPlatformType } from '@/react/portainer/environments/utils';
export function EngineVersion({ environment }: { environment: Environment }) {
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Docker:
return <DockerEngineVersion snapshot={environment.Snapshots[0]} />;
case PlatformType.Kubernetes:
return (
<KubernetesEngineVersion
snapshot={environment.Kubernetes.Snapshots?.[0]}
/>
);
default:
return null;
}
}
function DockerEngineVersion({ snapshot }: { snapshot?: DockerSnapshot }) {
if (!snapshot) {
return null;
}
return (
<span className="small text-muted vertical-center">
{snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion}
</span>
);
}
function KubernetesEngineVersion({
snapshot,
}: {
snapshot?: KubernetesSnapshot;
}) {
if (!snapshot) {
return null;
}
return (
<span className="small text-muted vertical-center">
Kubernetes {snapshot.KubernetesVersion}
</span>
);
}

View File

@ -0,0 +1,66 @@
import { Wifi, WifiOff } from 'lucide-react';
import ClockRewind from '@/assets/ico/clock-rewind.svg?c';
import { Environment } from '@/react/portainer/environments/types';
import {
getDashboardRoute,
isEdgeAsync as checkEdgeAsync,
} from '@/react/portainer/environments/utils';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Icon } from '@@/Icon';
import { LinkButton } from '@@/LinkButton';
export function EnvironmentBrowseButtons({
environment,
onClickBrowse,
isActive,
}: {
environment: Environment;
onClickBrowse(): void;
isActive: boolean;
}) {
const isEdgeAsync = checkEdgeAsync(environment);
return (
<div className="flex flex-col gap-1 ml-auto [&>*]:flex-1">
{isBE && (
<LinkButton
icon={ClockRewind}
disabled={!isEdgeAsync}
to="edge.browse.dashboard"
params={{
environmentId: environment.Id,
}}
color="light"
className="w-full py-1"
>
Browse snapshot
</LinkButton>
)}
<LinkButton
icon={Wifi}
disabled={isEdgeAsync}
to={getDashboardRoute(environment)}
params={{
endpointId: environment.Id,
}}
onClick={onClickBrowse}
color="primary"
className="w-full py-1"
>
Live connect
</LinkButton>
{!isActive ? (
<div className="min-h-[30px] vertical-center justify-center">
<Icon icon={WifiOff} />
Disconnected
</div>
) : (
<div className="min-h-[30px]" />
)}
</div>
);
}

View File

@ -1,24 +0,0 @@
.root {
position: relative;
}
.wrapperButton {
width: 100%;
border: 0;
margin: 0;
padding: 0;
background: transparent;
}
.item {
display: block;
text-decoration: none;
outline: initial;
color: inherit;
}
.edit-button {
position: absolute;
right: 0;
top: 5px;
}

View File

@ -1,10 +1,8 @@
import clsx from 'clsx';
import _ from 'lodash'; import _ from 'lodash';
import { Edit2, Tag, Cpu } from 'lucide-react'; import { Tag, Globe, Activity } from 'lucide-react';
import { import {
isoDateFromTimestamp, isoDateFromTimestamp,
humanize,
stripProtocol, stripProtocol,
} from '@/portainer/filters/filters'; } from '@/portainer/filters/filters';
import { import {
@ -12,24 +10,22 @@ import {
PlatformType, PlatformType,
} from '@/react/portainer/environments/types'; } from '@/react/portainer/environments/types';
import { import {
getDashboardRoute,
getPlatformType, getPlatformType,
isDockerEnvironment,
isEdgeEnvironment, isEdgeEnvironment,
} from '@/react/portainer/environments/utils'; } from '@/react/portainer/environments/utils';
import type { TagId } from '@/portainer/tags/types'; import type { TagId } from '@/portainer/tags/types';
import { useTags } from '@/portainer/tags/queries'; import { useTags } from '@/portainer/tags/queries';
import { useUser } from '@/react/hooks/useUser';
import Memory from '@/assets/ico/memory.svg?c';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
import { EdgeIndicator } from '@@/EdgeIndicator'; import { EdgeIndicator } from '@@/EdgeIndicator';
import { EnvironmentStatusBadge } from '@@/EnvironmentStatusBadge';
import { Link } from '@@/Link';
import { EnvironmentIcon } from './EnvironmentIcon'; import { EnvironmentIcon } from './EnvironmentIcon';
import { EnvironmentStats } from './EnvironmentStats'; import { EnvironmentStats } from './EnvironmentStats';
import styles from './EnvironmentItem.module.css'; import { EngineVersion } from './EngineVersion';
import { EnvironmentStatusBadge } from './EnvironmentStatusBadge'; import { AgentVersionTag } from './AgentVersionTag';
import { EditButtons } from './EditButtons';
interface Props { interface Props {
environment: Environment; environment: Environment;
@ -38,118 +34,102 @@ interface Props {
} }
export function EnvironmentItem({ environment, onClick, groupName }: Props) { export function EnvironmentItem({ environment, onClick, groupName }: Props) {
const { isAdmin } = useUser();
const isEdge = isEdgeEnvironment(environment.Type); const isEdge = isEdgeEnvironment(environment.Type);
const snapshotTime = getSnapshotTime(environment); const snapshotTime = getSnapshotTime(environment);
const tags = useEnvironmentTagNames(environment.TagIds); const tags = useEnvironmentTagNames(environment.TagIds);
const route = getRoute(environment); const route = getDashboardRoute(environment);
return ( return (
<div className={styles.root}>
<button <button
type="button" type="button"
onClick={() => onClick(environment)} onClick={() => onClick(environment)}
className={styles.wrapperButton} className="bg-transparent border-0 !p-0 !m-0"
> >
<Link <Link
className={clsx('blocklist-item no-link', styles.item)} className="blocklist-item flex no-link overflow-hidden min-h-[100px]"
to={route} to={route}
params={{ params={{
endpointId: environment.Id, endpointId: environment.Id,
id: environment.Id, id: environment.Id,
}} }}
> >
<div className="blocklist-item-box"> <div className="ml-2 self-center flex justify-center">
<span className={clsx('blocklist-item-logo', 'endpoint-item')}>
<EnvironmentIcon type={environment.Type} /> <EnvironmentIcon type={environment.Type} />
</span> </div>
<span className="col-sm-12"> <div className="ml-3 mr-auto flex justify-center gap-3 flex-col items-start">
<div className="blocklist-item-line endpoint-item"> <div className="space-x-3 flex items-center">
<span> <span className="font-bold">{environment.Name}</span>
<span className="blocklist-item-title endpoint-item">
{environment.Name}
</span>
<span className="space-left blocklist-item-subtitle">
{isEdge ? ( {isEdge ? (
<EdgeIndicator <EdgeIndicator environment={environment} showLastCheckInDate />
environment={environment}
showLastCheckInDate
/>
) : ( ) : (
<> <>
<EnvironmentStatusBadge status={environment.Status} /> <EnvironmentStatusBadge status={environment.Status} />
<span className="space-left small text-muted"> {snapshotTime && (
<span
className="space-left small text-muted vertical-center"
title="Last snapshot time"
>
<Activity
className="icon icon-sm space-right"
aria-hidden="true"
/>
{snapshotTime} {snapshotTime}
</span> </span>
)}
</> </>
)} )}
</span>
</span> <EngineVersion environment={environment} />
{groupName && (
<span className="small space-right">
<span>Group: </span>
<span>{groupName}</span>
</span>
)}
</div>
<EnvironmentStats environment={environment} />
<div className="blocklist-item-line endpoint-item">
<span className="small text-muted space-x-2">
{isDockerEnvironment(environment.Type) && (
<span>
{environment.Snapshots.length > 0 && (
<span className="small text-muted vertical-center">
<Cpu
className="icon icon-sm space-right"
aria-hidden="true"
/>
{environment.Snapshots[0].TotalCPU} CPU
<Icon
icon={Memory}
className="icon icon-sm space-right"
/>
{humanize(environment.Snapshots[0].TotalMemory)} RAM
<Cpu
className="icon icon-sm space-right"
aria-hidden="true"
/>
{environment.Gpus?.length} GPU
</span>
)}
</span>
)}
<span className="vertical-center">
<Tag
className="icon icon-sm space-right"
aria-hidden="true"
/>
{tags}
</span>
</span>
{!isEdge && ( {!isEdge && (
<span className="small text-muted"> <span className="text-muted small vertical-center">
{stripProtocol(environment.URL)} {stripProtocol(environment.URL)}
</span> </span>
)} )}
</div> </div>
<div className="small text-muted space-x-2 vertical-center">
{groupName && (
<span className="font-semibold">
<span>Group: </span>
<span>{groupName}</span>
</span> </span>
</div> )}
</Link>
</button> <span className="vertical-center">
{isAdmin && ( <Tag className="icon icon-sm space-right" aria-hidden="true" />
<Link {tags}
to="portainer.endpoints.endpoint" </span>
params={{ id: environment.Id }}
className={styles.editButton} {isEdge && (
> <>
<Button color="link"> <AgentVersionTag
<Edit2 className="icon icon-md" aria-hidden="true" /> type={environment.Type}
</Button> version={environment.Agent.Version}
</Link> />
{environment.Edge.AsyncMode && (
<span className="vertical-center gap-1">
<Globe
className="icon icon-sm space-right"
aria-hidden="true"
/>
Async Environment
</span>
)}
</>
)} )}
</div> </div>
<EnvironmentStats environment={environment} />
</div>
<EditButtons environment={environment} />
</Link>
</button>
); );
} }
@ -193,22 +173,3 @@ function getSnapshotTime(environment: Environment) {
return null; return null;
} }
} }
function getRoute(environment: Environment) {
if (isEdgeEnvironment(environment.Type) && !environment.EdgeID) {
return 'portainer.endpoints.endpoint';
}
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Azure:
return 'azure.dashboard';
case PlatformType.Docker:
return 'docker.dashboard';
case PlatformType.Kubernetes:
return 'kubernetes.dashboard';
default:
return '';
}
}

View File

@ -6,6 +6,7 @@ import { getPlatformType } from '@/react/portainer/environments/utils';
import { EnvironmentStatsDocker } from './EnvironmentStatsDocker'; import { EnvironmentStatsDocker } from './EnvironmentStatsDocker';
import { EnvironmentStatsKubernetes } from './EnvironmentStatsKubernetes'; import { EnvironmentStatsKubernetes } from './EnvironmentStatsKubernetes';
import { EnvironmentStatsNomad } from './EnvironmentStatsNomad';
interface Props { interface Props {
environment: Environment; environment: Environment;
@ -13,28 +14,31 @@ interface Props {
export function EnvironmentStats({ environment }: Props) { export function EnvironmentStats({ environment }: Props) {
const platform = getPlatformType(environment.Type); const platform = getPlatformType(environment.Type);
const component = getComponent(platform, environment);
return (
<span className="blocklist-item-desc flex items-center gap-10">
{component}
</span>
);
}
function getComponent(platform: PlatformType, environment: Environment) {
switch (platform) { switch (platform) {
case PlatformType.Kubernetes: case PlatformType.Kubernetes:
return ( return (
<EnvironmentStatsKubernetes <EnvironmentStatsKubernetes
snapshots={environment.Kubernetes.Snapshots || []} snapshot={environment.Kubernetes.Snapshots?.[0]}
type={environment.Type}
agentVersion={environment.Agent.Version}
/> />
); );
case PlatformType.Docker: case PlatformType.Docker:
return <EnvironmentStatsDocker snapshot={environment.Snapshots?.[0]} />;
case PlatformType.Nomad:
return ( return (
<EnvironmentStatsDocker <EnvironmentStatsNomad snapshot={environment.Nomad.Snapshots?.[0]} />
snapshots={environment.Snapshots}
type={environment.Type}
agentVersion={environment.Agent.Version}
/>
); );
default: default:
return ( return null;
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc">-</span>
</div>
);
} }
} }

View File

@ -9,46 +9,29 @@ import {
Heart, Heart,
} from 'lucide-react'; } from 'lucide-react';
import {
DockerSnapshot,
EnvironmentType,
} from '@/react/portainer/environments/types';
import { addPlural } from '@/portainer/helpers/strings'; import { addPlural } from '@/portainer/helpers/strings';
import { DockerSnapshot } from '@/react/docker/snapshots/types';
import { AgentVersionTag } from './AgentVersionTag'; import { StatsItem } from '@@/StatsItem';
import { EnvironmentStatsItem } from './EnvironmentStatsItem';
interface Props { interface Props {
snapshots: DockerSnapshot[]; snapshot?: DockerSnapshot;
type: EnvironmentType;
agentVersion: string;
} }
export function EnvironmentStatsDocker({ export function EnvironmentStatsDocker({ snapshot }: Props) {
snapshots = [], if (!snapshot) {
type, return <>No snapshot available</>;
agentVersion,
}: Props) {
if (snapshots.length === 0) {
return (
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc">No snapshot available</span>
</div>
);
} }
const snapshot = snapshots[0];
return ( return (
<div className="blocklist-item-line endpoint-item"> <>
<span className="blocklist-item-desc"> <StatsItem
<EnvironmentStatsItem
value={addPlural(snapshot.StackCount, 'stack')} value={addPlural(snapshot.StackCount, 'stack')}
icon={Layers} icon={Layers}
/> />
{!!snapshot.Swarm && ( {!!snapshot.Swarm && (
<EnvironmentStatsItem <StatsItem
value={addPlural(snapshot.ServiceCount, 'service')} value={addPlural(snapshot.ServiceCount, 'service')}
icon={Shuffle} icon={Shuffle}
/> />
@ -60,29 +43,19 @@ export function EnvironmentStatsDocker({
healthy={snapshot.HealthyContainerCount} healthy={snapshot.HealthyContainerCount}
unhealthy={snapshot.UnhealthyContainerCount} unhealthy={snapshot.UnhealthyContainerCount}
/> />
<EnvironmentStatsItem <StatsItem
value={addPlural(snapshot.VolumeCount, 'volume')} value={addPlural(snapshot.VolumeCount, 'volume')}
icon={Database} icon={Database}
/> />
<EnvironmentStatsItem <StatsItem value={addPlural(snapshot.ImageCount, 'image')} icon={List} />
value={addPlural(snapshot.ImageCount, 'image')}
icon={List}
/>
</span>
<span className="small text-muted space-x-2 vertical-center">
<span>
{snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion}
</span>
{snapshot.Swarm && ( {snapshot.Swarm && (
<EnvironmentStatsItem <StatsItem
value={addPlural(snapshot.NodeCount, 'node')} value={addPlural(snapshot.NodeCount, 'node')}
icon={HardDrive} icon={HardDrive}
/> />
)} )}
<AgentVersionTag version={agentVersion} type={type} /> </>
</span>
</div>
); );
} }
@ -102,34 +75,15 @@ function ContainerStats({
const containersCount = running + stopped; const containersCount = running + stopped;
return ( return (
<EnvironmentStatsItem <StatsItem value={addPlural(containersCount, 'container')} icon={Box}>
value={addPlural(containersCount, 'container')}
icon={Box}
>
{containersCount > 0 && ( {containersCount > 0 && (
<span className="space-x-2 space-right"> <>
<EnvironmentStatsItem <StatsItem value={running} icon={Power} iconClass="icon-success" />
value={running} <StatsItem value={stopped} icon={Power} iconClass="icon-danger" />
icon={Power} <StatsItem value={healthy} icon={Heart} iconClass="icon-success" />
iconClass="icon-success" <StatsItem value={unhealthy} icon={Heart} iconClass="icon-warning" />
/> </>
<EnvironmentStatsItem
value={stopped}
icon={Power}
iconClass="icon-danger"
/>
<EnvironmentStatsItem
value={healthy}
icon={Heart}
iconClass="icon-success"
/>
<EnvironmentStatsItem
value={unhealthy}
icon={Heart}
iconClass="icon-warning"
/>
</span>
)} )}
</EnvironmentStatsItem> </StatsItem>
); );
} }

View File

@ -1,56 +1,34 @@
import { Cpu, HardDrive } from 'lucide-react'; import { Cpu, HardDrive } from 'lucide-react';
import { import { KubernetesSnapshot } from '@/react/portainer/environments/types';
EnvironmentType,
KubernetesSnapshot,
} from '@/react/portainer/environments/types';
import { humanize } from '@/portainer/filters/filters'; import { humanize } from '@/portainer/filters/filters';
import { addPlural } from '@/portainer/helpers/strings'; import { addPlural } from '@/portainer/helpers/strings';
import Memory from '@/assets/ico/memory.svg?c'; import Memory from '@/assets/ico/memory.svg?c';
import { AgentVersionTag } from './AgentVersionTag'; import { StatsItem } from '@@/StatsItem';
import { EnvironmentStatsItem } from './EnvironmentStatsItem';
interface Props { interface Props {
snapshots?: KubernetesSnapshot[]; snapshot?: KubernetesSnapshot;
type: EnvironmentType;
agentVersion: string;
} }
export function EnvironmentStatsKubernetes({ export function EnvironmentStatsKubernetes({ snapshot }: Props) {
snapshots = [], if (!snapshot) {
type, return <>No snapshot available</>;
agentVersion,
}: Props) {
if (snapshots.length === 0) {
return (
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc">No snapshot available</span>
</div>
);
} }
const snapshot = snapshots[0];
return ( return (
<div className="blocklist-item-line endpoint-item"> <>
<span className="blocklist-item-desc space-x-1"> <StatsItem icon={Cpu} value={`${snapshot.TotalCPU} CPU`} />
<EnvironmentStatsItem icon={Cpu} value={`${snapshot.TotalCPU} CPU`} />
<EnvironmentStatsItem <StatsItem
icon={Memory} icon={Memory}
value={`${humanize(snapshot.TotalMemory)} RAM`} value={`${humanize(snapshot.TotalMemory)} RAM`}
/> />
</span>
<span className="small text-muted space-x-2 vertical-center"> <StatsItem
<span>Kubernetes {snapshot.KubernetesVersion}</span>
<EnvironmentStatsItem
value={addPlural(snapshot.NodeCount, 'node')} value={addPlural(snapshot.NodeCount, 'node')}
icon={HardDrive} icon={HardDrive}
/> />
<AgentVersionTag type={type} version={agentVersion} /> </>
</span>
</div>
); );
} }

View File

@ -0,0 +1,44 @@
import { Box, Dice4, HardDrive, List, Power } from 'lucide-react';
import { NomadSnapshot } from '@/react/portainer/environments/types';
import { addPlural } from '@/portainer/helpers/strings';
import { StatsItem } from '@@/StatsItem';
interface Props {
snapshot?: NomadSnapshot;
}
export function EnvironmentStatsNomad({ snapshot }: Props) {
if (!snapshot) {
return <>No snapshot available</>;
}
return (
<>
<StatsItem value={addPlural(snapshot.JobCount, 'job')} icon={List} />
<StatsItem value={addPlural(snapshot.GroupCount, 'group')} icon={Dice4} />
<StatsItem value={addPlural(snapshot.TaskCount, 'task')} icon={Box}>
{snapshot.TaskCount > 0 && (
<>
<StatsItem
value={snapshot.RunningTaskCount}
icon={Power}
iconClass="icon-success"
/>
<StatsItem
value={snapshot.TaskCount - snapshot.RunningTaskCount}
icon={Power}
iconClass="icon-danger"
/>
</>
)}
</StatsItem>
<StatsItem
value={addPlural(snapshot.NodeCount, 'node')}
icon={HardDrive}
/>
</>
);
}

View File

@ -1,22 +0,0 @@
import clsx from 'clsx';
import { EnvironmentStatus } from '@/react/portainer/environments/types';
interface Props {
status: EnvironmentStatus;
}
export function EnvironmentStatusBadge({ status }: Props) {
return (
<span className={clsx('label', `label-${environmentStatusBadge(status)}`)}>
{status === EnvironmentStatus.Up ? 'up' : 'down'}
</span>
);
}
function environmentStatusBadge(status: EnvironmentStatus) {
if (status === EnvironmentStatus.Down) {
return 'danger';
}
return 'success';
}

View File

@ -69,7 +69,6 @@ const storageKey = 'home_endpoints';
export function EnvironmentList({ onClickItem, onRefresh }: Props) { export function EnvironmentList({ onClickItem, onRefresh }: Props) {
const { isAdmin } = useUser(); const { isAdmin } = useUser();
const [platformTypes, setPlatformTypes] = useHomePageFilter< const [platformTypes, setPlatformTypes] = useHomePageFilter<
Filter<PlatformType>[] Filter<PlatformType>[]
>('platformType', []); >('platformType', []);

View File

@ -1,5 +1,5 @@
import { TeamId } from '@/react/portainer/users/teams/types'; import { TeamId } from '@/react/portainer/users/teams/types';
import { UserId } from '@/portainer/users/types'; import { UserId } from '@/portainer/users/types/user-id';
export type ResourceControlId = number; export type ResourceControlId = number;

View File

@ -1,5 +1,7 @@
import { TagId } from '@/portainer/tags/types'; import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { Job } from '@/react/nomad/types';
import { DockerSnapshot } from '@/react/docker/snapshots/types';
export type EnvironmentId = number; export type EnvironmentId = number;
@ -25,30 +27,14 @@ export enum EnvironmentType {
export const EdgeTypes = [ export const EdgeTypes = [
EnvironmentType.EdgeAgentOnDocker, EnvironmentType.EdgeAgentOnDocker,
EnvironmentType.EdgeAgentOnKubernetes, EnvironmentType.EdgeAgentOnKubernetes,
EnvironmentType.EdgeAgentOnNomad,
] as const; ] as const;
export enum EnvironmentStatus { export enum EnvironmentStatus {
Up = 1, Up = 1,
Down, Down,
} Provisioning,
Error,
export interface DockerSnapshot {
TotalCPU: number;
TotalMemory: number;
NodeCount: number;
ImageCount: number;
VolumeCount: number;
RunningContainerCount: number;
StoppedContainerCount: number;
HealthyContainerCount: number;
UnhealthyContainerCount: number;
Time: number;
StackCount: number;
ServiceCount: number;
Swarm: boolean;
DockerVersion: string;
GpuUseAll: boolean;
GpuUseList: string[];
} }
export interface KubernetesSnapshot { export interface KubernetesSnapshot {
@ -80,6 +66,20 @@ export interface KubernetesSettings {
Configuration: KubernetesConfiguration; Configuration: KubernetesConfiguration;
} }
export interface NomadSnapshot {
JobCount: number;
GroupCount: number;
TaskCount: number;
RunningTaskCount: number;
NodeCount: number;
Time: number;
Jobs: Job[];
}
export interface NomadSettings {
Snapshots: NomadSnapshot[];
}
export type EnvironmentEdge = { export type EnvironmentEdge = {
AsyncMode: boolean; AsyncMode: boolean;
PingInterval: number; PingInterval: number;
@ -124,6 +124,7 @@ export type Environment = {
URL: string; URL: string;
Snapshots: DockerSnapshot[]; Snapshots: DockerSnapshot[];
Kubernetes: KubernetesSettings; Kubernetes: KubernetesSettings;
Nomad: NomadSettings;
PublicURL?: string; PublicURL?: string;
IsEdgeDevice?: boolean; IsEdgeDevice?: boolean;
UserTrusted: boolean; UserTrusted: boolean;

View File

@ -51,6 +51,10 @@ export function isEdgeEnvironment(envType: EnvironmentType) {
].includes(envType); ].includes(envType);
} }
export function isEdgeAsync(env?: Environment | null) {
return !!env && env.Edge.AsyncMode;
}
export function isUnassociatedEdgeEnvironment(env: Environment) { export function isUnassociatedEdgeEnvironment(env: Environment) {
return isEdgeEnvironment(env.Type) && !env.EdgeID; return isEdgeEnvironment(env.Type) && !env.EdgeID;
} }