mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 22:05: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
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:
parent
2669a44d79
commit
014a590704
54 changed files with 1297 additions and 507 deletions
|
@ -34,7 +34,7 @@ export function DashboardView() {
|
|||
<DashboardGrid>
|
||||
<DashboardItem
|
||||
value={subscriptionsCount as number}
|
||||
dataCy="subscriptions-count"
|
||||
data-cy="subscriptions-count"
|
||||
isLoading={subscriptionsQuery.isLoading}
|
||||
isRefetching={subscriptionsQuery.isRefetching}
|
||||
icon={Subscription}
|
||||
|
@ -43,7 +43,7 @@ export function DashboardView() {
|
|||
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
|
||||
<DashboardItem
|
||||
value={resourceGroupsCount}
|
||||
dataCy="resource-groups-count"
|
||||
data-cy="resource-groups-count"
|
||||
isLoading={resourceGroupsQuery.isLoading}
|
||||
icon={Package}
|
||||
type="Resource group"
|
||||
|
|
|
@ -24,7 +24,7 @@ function Template({ value, icon, type }: StoryProps) {
|
|||
value={value}
|
||||
icon={icon}
|
||||
type={type}
|
||||
dataCy="data-cy-example"
|
||||
data-cy="data-cy-example"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ export function WithLink() {
|
|||
value={1}
|
||||
icon={List}
|
||||
type="Example resource"
|
||||
dataCy="data-cy-example"
|
||||
data-cy="data-cy-example"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
@ -55,7 +55,7 @@ export function WithChildren() {
|
|||
value={1}
|
||||
icon={List}
|
||||
type="Example resource"
|
||||
dataCy="data-cy-example"
|
||||
data-cy="data-cy-example"
|
||||
>
|
||||
<div>Children</div>
|
||||
</DashboardItem>
|
||||
|
|
|
@ -27,6 +27,6 @@ test('should have accessibility label created from the provided resource type',
|
|||
|
||||
function renderComponent(value = 0, icon = User, type = '') {
|
||||
return render(
|
||||
<DashboardItem value={value} icon={icon} type={type} dataCy="example" />
|
||||
<DashboardItem value={value} icon={icon} type={type} data-cy="example" />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,10 +4,11 @@ import { Loader2 } from 'lucide-react';
|
|||
|
||||
import { Icon, IconProps } from '@/react/components/Icon';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props extends IconProps {
|
||||
interface Props extends IconProps, AutomationTestingProps {
|
||||
type: string;
|
||||
pluralType?: string; // in case the pluralise function isn't suitable
|
||||
isLoading?: boolean;
|
||||
|
@ -16,7 +17,6 @@ interface Props extends IconProps {
|
|||
to?: string;
|
||||
params?: object;
|
||||
children?: ReactNode;
|
||||
dataCy: string;
|
||||
}
|
||||
|
||||
export function DashboardItem({
|
||||
|
@ -29,7 +29,7 @@ export function DashboardItem({
|
|||
to,
|
||||
params,
|
||||
children,
|
||||
dataCy,
|
||||
'data-cy': dataCy,
|
||||
}: Props) {
|
||||
const Item = (
|
||||
<div
|
||||
|
|
|
@ -19,33 +19,29 @@ export function InformationPanel({
|
|||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody className={bodyClassName}>
|
||||
<div style={wrapperStyle}>
|
||||
{title && (
|
||||
<div className="form-section-title">
|
||||
<span>{title}</span>
|
||||
{!!onDismiss && (
|
||||
<span className="small" style={{ float: 'right' }}>
|
||||
<Button
|
||||
color="link"
|
||||
icon={X}
|
||||
onClick={() => onDismiss()}
|
||||
data-cy="dismiss-information-panel-button"
|
||||
>
|
||||
dismiss
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Widget>
|
||||
<WidgetBody className={bodyClassName}>
|
||||
<div style={wrapperStyle}>
|
||||
{title && (
|
||||
<div className="form-section-title">
|
||||
<span>{title}</span>
|
||||
{!!onDismiss && (
|
||||
<span className="small" style={{ float: 'right' }}>
|
||||
<Button
|
||||
color="link"
|
||||
icon={X}
|
||||
onClick={() => onDismiss()}
|
||||
data-cy="dismiss-information-panel-button"
|
||||
>
|
||||
dismiss
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
|
37
app/react/docker/DashboardView/ClusterAgentInfo.tsx
Normal file
37
app/react/docker/DashboardView/ClusterAgentInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
25
app/react/docker/DashboardView/ClusterVisualizerLink.tsx
Normal file
25
app/react/docker/DashboardView/ClusterVisualizerLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
143
app/react/docker/DashboardView/DashboardView.tsx
Normal file
143
app/react/docker/DashboardView/DashboardView.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
61
app/react/docker/DashboardView/EnvironmentInfo.GpuInfo.tsx
Normal file
61
app/react/docker/DashboardView/EnvironmentInfo.GpuInfo.tsx
Normal 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(' + ');
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
28
app/react/docker/DashboardView/EnvironmentInfo.TagsInfo.tsx
Normal file
28
app/react/docker/DashboardView/EnvironmentInfo.TagsInfo.tsx
Normal 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>;
|
||||
}
|
70
app/react/docker/DashboardView/EnvironmentInfo.tsx
Normal file
70
app/react/docker/DashboardView/EnvironmentInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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">
|
||||
|
|
49
app/react/docker/DashboardView/NonAgentSwarmInfo.tsx
Normal file
49
app/react/docker/DashboardView/NonAgentSwarmInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
41
app/react/docker/DashboardView/useDashboard.ts
Normal file
41
app/react/docker/DashboardView/useDashboard.ts
Normal 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,
|
||||
});
|
||||
}
|
|
@ -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'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'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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 "Command & logging" 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 "Command & logging"
|
||||
panel.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -22,13 +22,17 @@ function WaitingRoomView() {
|
|||
reload
|
||||
/>
|
||||
|
||||
<InformationPanel>
|
||||
<TextTip color="blue">
|
||||
Only environments generated from the AEEC script will appear here,
|
||||
manually added environments and edge devices will bypass the waiting
|
||||
room.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<InformationPanel>
|
||||
<TextTip color="blue">
|
||||
Only environments generated from the AEEC script will appear here,
|
||||
manually added environments and edge devices will bypass the
|
||||
waiting room.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{licenseOverused && (
|
||||
<div className="row">
|
||||
|
|
|
@ -8,12 +8,16 @@ export function ListView() {
|
|||
<>
|
||||
<PageHeader title="Edge Jobs" breadcrumbs="Edge Jobs" reload />
|
||||
|
||||
<InformationPanel title="Information">
|
||||
<p className="small text-muted">
|
||||
Edge Jobs requires Docker Standalone and a cron implementation that
|
||||
reads jobs from <code>/etc/cron.d</code>
|
||||
</p>
|
||||
</InformationPanel>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<InformationPanel title="Information">
|
||||
<p className="small text-muted">
|
||||
Edge Jobs requires Docker Standalone and a cron implementation
|
||||
that reads jobs from <code>/etc/cron.d</code>
|
||||
</p>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EdgeJobsDatatable />
|
||||
</>
|
||||
|
|
16
app/react/hooks/useInfoPanelState.tsx
Normal file
16
app/react/hooks/useInfoPanelState.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { useUIState } from '@/react/hooks/useUIState';
|
||||
|
||||
export function useInfoPanelState(panelId: string) {
|
||||
const uiStateStore = useUIState();
|
||||
|
||||
const isVisible = !uiStateStore.dismissedInfoPanels[panelId];
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
dismiss,
|
||||
};
|
||||
|
||||
function dismiss() {
|
||||
uiStateStore.dismissInfoPanel(panelId);
|
||||
}
|
||||
}
|
|
@ -70,7 +70,7 @@ export function DashboardView() {
|
|||
icon={Layers}
|
||||
to="kubernetes.resourcePools"
|
||||
type="Namespace"
|
||||
dataCy="dashboard-namespace"
|
||||
data-cy="dashboard-namespace"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={applications?.length}
|
||||
|
@ -81,7 +81,7 @@ export function DashboardView() {
|
|||
icon={Box}
|
||||
to="kubernetes.applications"
|
||||
type="Application"
|
||||
dataCy="dashboard-application"
|
||||
data-cy="dashboard-application"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={services?.length}
|
||||
|
@ -92,7 +92,7 @@ export function DashboardView() {
|
|||
icon={Shuffle}
|
||||
to="kubernetes.services"
|
||||
type="Service"
|
||||
dataCy="dashboard-service"
|
||||
data-cy="dashboard-service"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={ingresses?.length}
|
||||
|
@ -104,7 +104,7 @@ export function DashboardView() {
|
|||
to="kubernetes.ingresses"
|
||||
type="Ingress"
|
||||
pluralType="Ingresses"
|
||||
dataCy="dashboard-ingress"
|
||||
data-cy="dashboard-ingress"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={configMaps?.length}
|
||||
|
@ -116,7 +116,7 @@ export function DashboardView() {
|
|||
to="kubernetes.configurations"
|
||||
params={{ tab: 'configmaps' }}
|
||||
type="ConfigMap"
|
||||
dataCy="dashboard-configmaps"
|
||||
data-cy="dashboard-configmaps"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={secrets?.length}
|
||||
|
@ -128,7 +128,7 @@ export function DashboardView() {
|
|||
to="kubernetes.configurations"
|
||||
params={{ tab: 'secrets' }}
|
||||
type="Secret"
|
||||
dataCy="dashboard-secrets"
|
||||
data-cy="dashboard-secrets"
|
||||
/>
|
||||
<DashboardItem
|
||||
value={pvcs?.length}
|
||||
|
@ -139,7 +139,7 @@ export function DashboardView() {
|
|||
icon={Database}
|
||||
to="kubernetes.volumes"
|
||||
type="Volume"
|
||||
dataCy="dashboard-volume"
|
||||
data-cy="dashboard-volume"
|
||||
/>
|
||||
</DashboardGrid>
|
||||
</div>
|
||||
|
|
|
@ -16,16 +16,21 @@ export function BackupFailedPanel() {
|
|||
}
|
||||
|
||||
return (
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
The latest automated backup has failed at {isoDate(status.TimestampUTC)}
|
||||
. For details please see the log files and have a look at the{' '}
|
||||
<Link to="portainer.settings" data-cy="backup-failed-settings-link">
|
||||
settings
|
||||
</Link>{' '}
|
||||
to verify the backup configuration.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
The latest automated backup has failed at{' '}
|
||||
{isoDate(status.TimestampUTC)}. For details please see the log files
|
||||
and have a look at the{' '}
|
||||
<Link to="portainer.settings" data-cy="backup-failed-settings-link">
|
||||
settings
|
||||
</Link>{' '}
|
||||
to verify the backup configuration.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,26 +4,30 @@ import { TextTip } from '@@/Tip/TextTip';
|
|||
|
||||
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
{isAdmin ? (
|
||||
<span>
|
||||
No environment available for management. Please head over the{' '}
|
||||
<Link
|
||||
to="portainer.wizard.endpoints"
|
||||
data-cy="wizard-add-environments-link"
|
||||
>
|
||||
environment wizard
|
||||
</Link>{' '}
|
||||
to add an environment.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
You do not have access to any environment. Please contact your
|
||||
administrator.
|
||||
</span>
|
||||
)}
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
{isAdmin ? (
|
||||
<span>
|
||||
No environment available for management. Please head over the{' '}
|
||||
<Link
|
||||
to="portainer.wizard.endpoints"
|
||||
data-cy="wizard-add-environments-link"
|
||||
>
|
||||
environment wizard
|
||||
</Link>{' '}
|
||||
to add an environment.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
You do not have access to any environment. Please contact your
|
||||
administrator.
|
||||
</span>
|
||||
)}
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import _ from 'lodash';
|
||||
import sanitize from 'sanitize-html';
|
||||
|
||||
import { useUIState } from '@/react/hooks/useUIState';
|
||||
|
||||
|
@ -23,17 +24,21 @@ export function MotdPanel() {
|
|||
return (
|
||||
<>
|
||||
{!!motd.Style && <style>{motd.Style}</style>}
|
||||
<InformationPanel
|
||||
onDismiss={() => onDismiss(motd.Hash)}
|
||||
title={motd.Title}
|
||||
wrapperStyle={camelCaseKeys(motd.ContentLayout)}
|
||||
bodyClassName="motd-body"
|
||||
>
|
||||
<span className="text-muted">
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<p dangerouslySetInnerHTML={{ __html: motd.Message }} />
|
||||
</span>
|
||||
</InformationPanel>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<InformationPanel
|
||||
onDismiss={() => onDismiss(motd.Hash)}
|
||||
title={motd.Title}
|
||||
wrapperStyle={camelCaseKeys(motd.ContentLayout)}
|
||||
bodyClassName="motd-body"
|
||||
>
|
||||
<span className="text-muted">
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<p dangerouslySetInnerHTML={{ __html: sanitize(motd.Message) }} />
|
||||
</span>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
@ -144,7 +144,7 @@ export type Environment = {
|
|||
AMTDeviceGUID?: string;
|
||||
Edge: EnvironmentEdge;
|
||||
SecuritySettings: EnvironmentSecuritySettings;
|
||||
Gpus: { name: string; value: string }[];
|
||||
Gpus?: { name: string; value: string }[];
|
||||
EnableImageNotification: boolean;
|
||||
LocalTimeZone?: string;
|
||||
|
||||
|
|
|
@ -8,12 +8,16 @@ export function ListView() {
|
|||
<>
|
||||
<PageHeader title="Registries" breadcrumbs="Registry management" reload />
|
||||
|
||||
<InformationPanel title="Information">
|
||||
<span className="small text-muted">
|
||||
View registries via an environment to manage access for user(s) and/or
|
||||
team(s)
|
||||
</span>
|
||||
</InformationPanel>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<InformationPanel title="Information">
|
||||
<span className="small text-muted">
|
||||
View registries via an environment to manage access for user(s)
|
||||
and/or team(s)
|
||||
</span>
|
||||
</InformationPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegistriesDatatable />
|
||||
</>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue