1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-04 21:35:23 +02:00

refactor(docker): migrate dashboard to react [EE-2191] (#11574)
Some checks are pending
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

This commit is contained in:
Chaim Lev-Ari 2024-05-20 09:34:51 +03:00 committed by GitHub
parent 2669a44d79
commit 014a590704
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1297 additions and 507 deletions

View file

@ -0,0 +1,37 @@
import { GaugeIcon } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Widget } from '@@/Widget';
import { DetailsTable } from '@@/DetailsTable';
import { useAgentNodes } from '../agent/queries/useAgentNodes';
import { useApiVersion } from '../agent/queries/useApiVersion';
import { ClusterVisualizerLink } from './ClusterVisualizerLink';
export function ClusterAgentInfo() {
const environmentId = useEnvironmentId();
const apiVersionQuery = useApiVersion(environmentId);
const nodesCountQuery = useAgentNodes(environmentId, apiVersionQuery.data!, {
select: (data) => data.length,
enabled: apiVersionQuery.data !== undefined,
});
return (
<Widget>
<Widget.Title icon={GaugeIcon} title="Cluster information" />
<Widget.Body className="!px-5 !py-0">
<DetailsTable dataCy="cluster-agent-info">
<DetailsTable.Row label="Nodes in the cluster">
{nodesCountQuery.data}
</DetailsTable.Row>
<ClusterVisualizerLink />
</DetailsTable>
</Widget.Body>
</Widget>
);
}

View file

@ -0,0 +1,25 @@
import { TrelloIcon } from 'lucide-react';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
export function ClusterVisualizerLink() {
return (
<tr>
<td colSpan={2}>
<Button
as={Link}
color="link"
icon={TrelloIcon}
props={{
to: 'docker.swarm.visualizer',
'data-cy': 'cluster-visualizer',
}}
data-cy="cluster-visualizer"
>
Go to cluster visualizer
</Button>
</td>
</tr>
);
}

View file

@ -2,63 +2,38 @@ import { Heart, Power } from 'lucide-react';
import { Icon } from '@/react/components/Icon';
import {
DockerContainer,
ContainerStatus as Status,
} from '../containers/types';
interface Props {
containers: DockerContainer[];
stats: {
running: number;
stopped: number;
healthy: number;
unhealthy: number;
};
}
export function useContainerStatusComponent(containers: DockerContainer[]) {
return <ContainerStatus containers={containers} />;
}
export function ContainerStatus({ containers }: Props) {
export function ContainerStatus({ stats }: Props) {
return (
<div className="pull-right">
<div>
<div className="vertical-center space-right pr-5">
<Icon icon={Power} mode="success" size="sm" />
{runningContainersFilter(containers)} running
{stats.running} running
</div>
<div className="vertical-center space-right">
<Icon icon={Power} mode="danger" size="sm" />
{stoppedContainersFilter(containers)} stopped
{stats.stopped} stopped
</div>
</div>
<div>
<div className="vertical-center space-right pr-5">
<Icon icon={Heart} mode="success" size="sm" />
{healthyContainersFilter(containers)} healthy
{stats.healthy} healthy
</div>
<div className="vertical-center space-right">
<Icon icon={Heart} mode="danger" size="sm" />
{unhealthyContainersFilter(containers)} unhealthy
{stats.unhealthy} unhealthy
</div>
</div>
</div>
);
}
function runningContainersFilter(containers: DockerContainer[]) {
return containers.filter(
(container) =>
container.Status === Status.Running || container.Status === Status.Healthy
).length;
}
function stoppedContainersFilter(containers: DockerContainer[]) {
return containers.filter(
(container) =>
container.Status === Status.Exited || container.Status === Status.Stopped
).length;
}
function healthyContainersFilter(containers: DockerContainer[]) {
return containers.filter((container) => container.Status === Status.Healthy)
.length;
}
function unhealthyContainersFilter(containers: DockerContainer[]) {
return containers.filter((container) => container.Status === Status.Unhealthy)
.length;
}

View file

@ -0,0 +1,143 @@
import {
BoxIcon,
CpuIcon,
DatabaseIcon,
LayersIcon,
ListIcon,
NetworkIcon,
ShuffleIcon,
} from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useIsEnvironmentAdmin } from '@/react/hooks/useUser';
import { PageHeader } from '@@/PageHeader';
import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
import { DashboardItem } from '@@/DashboardItem';
import { useIsSwarm, useIsSwarmManager } from '../proxy/queries/useInfo';
import { NonAgentSwarmInfo } from './NonAgentSwarmInfo';
import { ClusterAgentInfo } from './ClusterAgentInfo';
import { EnvironmentInfo } from './EnvironmentInfo';
import { ContainerStatus } from './ContainerStatus';
import { ImagesTotalSize } from './ImagesTotalSize';
import { useDashboard } from './useDashboard';
export function DashboardView() {
const envId = useEnvironmentId();
const envQuery = useCurrentEnvironment();
const isEnvAdminQuery = useIsEnvironmentAdmin();
const isSwarmManager = useIsSwarmManager(envId);
const isStandalone = useIsSwarm(envId);
const dashboardStatsQuery = useDashboard(envId);
if (!envQuery.data || !dashboardStatsQuery.data) {
return null;
}
const env = envQuery.data;
const isStacksVisible = shouldShowStacks();
const dashboardStats = dashboardStatsQuery.data;
return (
<>
<PageHeader title="Dashboard" breadcrumbs="Environment summary" reload />
<div className="mx-4 space-y-6">
<InfoPanels isAgent={isAgentEnvironment(env.Type)} />
<DashboardGrid>
{isStacksVisible && (
<DashboardItem
to="docker.stacks"
icon={LayersIcon}
type="Stack"
value={dashboardStats.stacks}
data-cy="stacks"
/>
)}
{isSwarmManager && (
<DashboardItem
to="docker.services"
icon={ShuffleIcon}
type="Service"
value={dashboardStats.services}
data-cy="services"
/>
)}
<DashboardItem
to="docker.containers"
icon={BoxIcon}
type="Container"
value={dashboardStats.containers.total}
data-cy="containers"
>
<ContainerStatus stats={dashboardStats.containers} />
</DashboardItem>
<DashboardItem
to="docker.images"
icon={ListIcon}
type="Image"
value={dashboardStats.images.total}
data-cy="images"
>
<ImagesTotalSize imagesTotalSize={dashboardStats.images.size} />
</DashboardItem>
<DashboardItem
to="docker.volumes"
icon={DatabaseIcon}
type="Volume"
value={dashboardStats.volumes}
data-cy="volumes"
/>
<DashboardItem
to="docker.networks"
icon={NetworkIcon}
type="Network"
value={dashboardStats.networks}
data-cy="networks"
/>
{env.EnableGPUManagement && isStandalone && (
<DashboardItem
icon={CpuIcon}
type="GPU"
value={env.Gpus?.length}
data-cy="gpus"
/>
)}
</DashboardGrid>
</div>
<div className="pt-6" />
</>
);
function shouldShowStacks() {
return (
env.SecuritySettings.allowStackManagementForRegularUsers ||
isEnvAdminQuery.authorized
);
}
}
function InfoPanels({ isAgent }: { isAgent: boolean }) {
const envId = useEnvironmentId();
const isSwarm = useIsSwarm(envId);
return (
<>
{isSwarm && !isAgent && <NonAgentSwarmInfo />}
{isSwarm && isAgent && <ClusterAgentInfo />}
{(!isSwarm || !isAgent) && <EnvironmentInfo />}
</>
);
}

View file

@ -0,0 +1,32 @@
import { ZapIcon } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Icon } from '@@/Icon';
import { useInfo } from '../proxy/queries/useInfo';
export function DockerInfo({ isAgent }: { isAgent: boolean }) {
const envId = useEnvironmentId();
const infoQuery = useInfo(envId);
if (!infoQuery.data) {
return null;
}
const info = infoQuery.data;
const isSwarm = info.Swarm && info.Swarm.NodeID !== '';
return (
<span className="small text-muted">
{isSwarm ? 'Swarm' : 'Standalone'} {info.ServerVersion}
{isAgent && (
<span className="flex gap-1 items-center">
<Icon icon={ZapIcon} />
Agent
</span>
)}
</span>
);
}

View file

@ -0,0 +1,61 @@
import { DetailsTable } from '@@/DetailsTable';
import { DockerSnapshot } from '../snapshots/types';
export function GpuInfo({
gpus,
snapshot,
}: {
gpus: Array<{ name: string }>;
snapshot?: DockerSnapshot;
}) {
if (!snapshot) {
return null;
}
const gpuUseAll = snapshot.GpuUseAll;
const gpuUseList = snapshot.GpuUseList;
let gpuFreeStr = '';
if (gpuUseAll) {
gpuFreeStr = 'none';
} else {
gpuFreeStr = buildGpusStr(gpuUseList, gpus);
}
return (
<DetailsTable.Row label={gpus.length <= 1 ? 'GPU' : 'GPUs'}>
{gpuFreeStr}
</DetailsTable.Row>
);
function buildGpusStr(
gpuUseList: Array<string>,
gpus: Array<{ name: string }> = []
) {
if (!gpus.length) {
return 'none';
}
const gpuUseSet = new Set(gpuUseList);
const gpusAvailable: Record<string, number> = {};
for (let i = 0; i < gpus.length; i++) {
if (!gpuUseSet.has(gpus[i].name)) {
if (gpusAvailable[gpus[i].name]) {
gpusAvailable[gpus[i].name] += 1;
} else {
gpusAvailable[gpus[i].name] = 1;
}
}
}
const gpusKeys = Object.keys(gpusAvailable);
if (!gpusKeys.length) {
return 'none';
}
return Object.keys(gpusAvailable)
.map((gpuAvailable) => `${gpusAvailable[gpuAvailable]} x ${gpuAvailable}`)
.join(' + ');
}
}

View file

@ -0,0 +1,30 @@
import { CpuIcon } from 'lucide-react';
import { humanize } from '@/portainer/filters/filters';
import memoryIcon from '@/assets/ico/memory.svg?c';
import { Icon } from '@@/Icon';
import { DockerSnapshot } from '../snapshots/types';
export function SnapshotStats({
snapshot,
}: {
snapshot: DockerSnapshot | undefined;
}) {
if (!snapshot) {
return null;
}
return (
<span className="small text-muted flex gap-2">
<span className="flex gap-1 items-center">
<Icon icon={CpuIcon} /> {snapshot.TotalCPU}
</span>
<span className="flex gap-1 items-center">
<Icon icon={memoryIcon} />
{humanize(snapshot.TotalMemory)}
</span>
</span>
);
}

View file

@ -0,0 +1,28 @@
import _ from 'lodash';
import { useTags } from '@/portainer/tags/queries';
import { DetailsTable } from '@@/DetailsTable';
export function TagsInfo({ ids }: { ids: number[] }) {
const tagsQuery = useTags();
if (!tagsQuery.data) {
return null;
}
const tags = tagsQuery.data;
const tagNameList = ids.length
? _.compact(
ids
.map((id) => {
const tag = tags.find((tag) => tag.ID === id);
return tag ? tag.Name : '';
})
.join(', ')
)
: '-';
return <DetailsTable.Row label="Tags">{tagNameList}</DetailsTable.Row>;
}

View file

@ -0,0 +1,70 @@
import { GaugeIcon } from 'lucide-react';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { stripProtocol } from '@/portainer/filters/filters';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import {
isAgentEnvironment,
isEdgeEnvironment,
} from '@/react/portainer/environments/utils';
import { DetailsTable } from '@@/DetailsTable';
import { Widget } from '@@/Widget';
import { useIsSwarmManager } from '../proxy/queries/useInfo';
import { GpuInfo } from './EnvironmentInfo.GpuInfo';
import { SnapshotStats } from './EnvironmentInfo.SnapshotStats';
import { DockerInfo } from './EnvironmentInfo.DockerInfo';
import { TagsInfo } from './EnvironmentInfo.TagsInfo';
import { ClusterVisualizerLink } from './ClusterVisualizerLink';
export function EnvironmentInfo() {
const environmentId = useEnvironmentId();
const envQuery = useCurrentEnvironment();
const isSwarmManager = useIsSwarmManager(environmentId);
if (!envQuery.data) {
return null;
}
const environment = envQuery.data;
const isAgent = isAgentEnvironment(environment.Type);
const isEdgeAgent = isEdgeEnvironment(environment.Type);
const isEnvUrlVisible = !isEdgeAgent;
return (
<Widget>
<Widget.Title icon={GaugeIcon} title="Environment info" />
<Widget.Body className="!px-5 !py-0">
<DetailsTable dataCy="environment-info">
<DetailsTable.Row label="Environment">
<div className="flex items-center gap-2">
{environment.Name}
<SnapshotStats snapshot={environment.Snapshots[0]} />-
<DockerInfo isAgent={isAgent} />
</div>
</DetailsTable.Row>
{isEnvUrlVisible && (
<DetailsTable.Row label="URL">
{stripProtocol(environment.URL)}
</DetailsTable.Row>
)}
<GpuInfo
gpus={environment.Gpus || []}
snapshot={environment.Snapshots[0]}
/>
<TagsInfo ids={environment.TagIds} />
{isSwarmManager && <ClusterVisualizerLink />}
</DetailsTable>
</Widget.Body>
</Widget>
);
}

View file

@ -8,10 +8,6 @@ interface Props {
imagesTotalSize: number;
}
export function useImagesTotalSizeComponent(imagesTotalSize: number) {
return <ImagesTotalSize imagesTotalSize={imagesTotalSize} />;
}
export function ImagesTotalSize({ imagesTotalSize }: Props) {
return (
<div className="vertical-center">

View file

@ -0,0 +1,49 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useInfoPanelState } from '@/react/hooks/useInfoPanelState';
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
import { HelpLink } from '@@/HelpLink';
import { useInfo } from '../proxy/queries/useInfo';
const infoPanelId = 'docker-dashboard-info-01';
export function NonAgentSwarmInfo() {
const { isVisible, dismiss } = useInfoPanelState(infoPanelId);
const envId = useEnvironmentId();
const isManagerQuery = useInfo(envId, {
select: (info) => !!info.Swarm?.ControlAvailable,
});
if (!isVisible || isManagerQuery.isLoading) {
return null;
}
const isManager = isManagerQuery.data;
return (
<InformationPanel title="Information" onDismiss={() => dismiss()}>
<TextTip color="blue">
{isManager ? (
<>
Portainer is connected to a node that is part of a Swarm cluster.
Some resources located on other nodes in the cluster might not be
available for management, have a look at{' '}
<HelpLink
docLink="/admin/environments/add/swarm/agent"
target="_blank"
>
our agent setup
</HelpLink>{' '}
for more details.
</>
) : (
<>
Portainer is connected to a worker node. Swarm management features
will not be available.
</>
)}
</TextTip>
</InformationPanel>
);
}

View file

@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from '../queries/utils';
import { buildDockerUrl } from '../queries/utils/root';
interface DashboardResponse {
containers: {
total: number;
running: number;
stopped: number;
healthy: number;
unhealthy: number;
};
services: number;
images: {
total: number;
size: number;
};
volumes: number;
networks: number;
stacks: number;
}
export function useDashboard(envId: EnvironmentId) {
return useQuery({
queryFn: async () => {
try {
const res = await axios.get<DashboardResponse>(
`${buildDockerUrl(envId)}/dashboard`
);
return res.data;
} catch (error) {
throw parseAxiosError(error);
}
},
queryKey: [...queryKeys.root(envId), 'dashboard'] as const,
});
}

View file

@ -101,17 +101,21 @@ function CreateForm() {
return (
<>
{isDuplicating && (
<InformationPanel title-text="Caution">
<TextTip>
The new container may fail to start if the image is changed, and
settings from the previous container aren&apos;t compatible. Common
causes include entrypoint, cmd or{' '}
<HelpLink docLink="/user/docker/containers/advanced">
other settings
</HelpLink>{' '}
set by an image.
</TextTip>
</InformationPanel>
<div className="row">
<div className="col-sm-12">
<InformationPanel title-text="Caution">
<TextTip>
The new container may fail to start if the image is changed, and
settings from the previous container aren&apos;t compatible.
Common causes include entrypoint, cmd or{' '}
<HelpLink docLink="/user/docker/containers/advanced">
other settings
</HelpLink>{' '}
set by an image.
</TextTip>
</InformationPanel>
</div>
</div>
)}
<Formik

View file

@ -111,7 +111,7 @@ export function ResourcesTab({
<GpuFieldset
values={values.gpu}
onChange={(gpu) => setFieldValue('gpu', gpu)}
gpus={environment.Gpus}
gpus={environment.Gpus || []}
enableGpuManagement={environment.EnableGPUManagement}
usedGpus={gpuUseList}
usedAllGpus={gpuUseAll}

View file

@ -29,19 +29,24 @@ function LogsDisabledInfoPanel() {
} = useCurrentStateAndParams();
return (
<InformationPanel>
<TextTip color="blue">
Logging is disabled for this container. If you want to re-enable
logging, please{' '}
<Link
to="docker.containers.new"
params={{ from: containerId, nodeName }}
data-cy="redeploy-container-link"
>
redeploy your container
</Link>{' '}
and select a logging driver in the &quot;Command & logging&quot; panel.
</TextTip>
</InformationPanel>
<div className="row">
<div className="col-sm-12">
<InformationPanel>
<TextTip color="blue">
Logging is disabled for this container. If you want to re-enable
logging, please{' '}
<Link
to="docker.containers.new"
params={{ from: containerId, nodeName }}
data-cy="redeploy-container-link"
>
redeploy your container
</Link>{' '}
and select a logging driver in the &quot;Command & logging&quot;
panel.
</TextTip>
</InformationPanel>
</div>
</div>
);
}

View file

@ -72,3 +72,11 @@ export function useSystemLimits(environmentId: EnvironmentId) {
return { maxCpu, maxMemory };
}
export function useIsSwarmManager(environmentId: EnvironmentId) {
const query = useInfo(environmentId, {
select: (info) => !!info.Swarm?.NodeID && info.Swarm.ControlAvailable,
});
return !!query.data;
}