mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 10:54:48 +00:00
feat(gitops): link source/artifact status in workflow list [BE-12909] (#3143)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
This commit is contained in:
@@ -21413,6 +21413,8 @@ components:
|
||||
type: string
|
||||
platform:
|
||||
$ref: "#/components/schemas/workflows.DeploymentPlatform"
|
||||
sourceId:
|
||||
type: integer
|
||||
status:
|
||||
$ref: "#/components/schemas/workflows.WorkflowStatusObject"
|
||||
target:
|
||||
|
||||
@@ -9648,6 +9648,8 @@ definitions:
|
||||
type: string
|
||||
platform:
|
||||
$ref: '#/definitions/workflows.DeploymentPlatform'
|
||||
sourceId:
|
||||
type: integer
|
||||
status:
|
||||
$ref: '#/definitions/workflows.WorkflowStatusObject'
|
||||
target:
|
||||
|
||||
@@ -18,6 +18,7 @@ func FetchWorkflows(
|
||||
endpointIDSet set.Set[portainer.EndpointID],
|
||||
) ([]Workflow, error) {
|
||||
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
|
||||
sourceIDs := map[portainer.StackID]portainer.SourceID{}
|
||||
sourcePhases := map[portainer.StackID]WorkflowPhaseStatus{}
|
||||
artifactPhases := map[portainer.StackID]WorkflowPhaseStatus{}
|
||||
|
||||
@@ -78,6 +79,7 @@ func FetchWorkflows(
|
||||
|
||||
if src.Type == portainer.SourceTypeGit {
|
||||
gitConfigs[stack.ID] = MergeSourceAndFile(&src, &f)
|
||||
sourceIDs[stack.ID] = src.ID
|
||||
sourcePhases[stack.ID] = SourceStatusToPhase(f.RefStatus, f.RefError)
|
||||
artifactPhases[stack.ID] = SourceStatusToPhase(f.PathStatus, f.PathError)
|
||||
break outer
|
||||
@@ -106,7 +108,7 @@ func FetchWorkflows(
|
||||
items := make([]Workflow, 0, len(stacks))
|
||||
for _, stack := range stacks {
|
||||
gitConfig := gitConfigs[stack.ID]
|
||||
items = append(items, MapStackToWorkflow(stack, gitConfig, sourcePhases[stack.ID], artifactPhases[stack.ID]))
|
||||
items = append(items, MapStackToWorkflow(stack, sourceIDs[stack.ID], gitConfig, sourcePhases[stack.ID], artifactPhases[stack.ID]))
|
||||
}
|
||||
|
||||
return items, nil
|
||||
|
||||
@@ -33,7 +33,7 @@ func BuildGroupEndpoints(tx dataservices.DataStoreTx, groups []portainer.EdgeGro
|
||||
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
|
||||
// because EE embeds a different GitConfig type that shadows the CE field.
|
||||
// source and artifact are the pre-computed git phase statuses from the caller.
|
||||
func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
func MapStackToWorkflow(s portainer.Stack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
return Workflow{
|
||||
ID: s.WorkflowID,
|
||||
Name: s.Name,
|
||||
@@ -44,6 +44,7 @@ func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig, sourc
|
||||
Artifact: artifact,
|
||||
Target: deriveStackTargetState(s),
|
||||
},
|
||||
SourceID: sourceID,
|
||||
GitConfig: gitConfig,
|
||||
AutoUpdate: s.AutoUpdate,
|
||||
Target: Target{
|
||||
@@ -58,7 +59,7 @@ func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig, sourc
|
||||
// MapEdgeStackToWorkflow converts an edge stack to a Workflow. gitConfig is passed separately
|
||||
// because EE embeds a different GitConfig type that shadows the CE field.
|
||||
// source and artifact are the pre-computed git phase statuses from the caller.
|
||||
func MapEdgeStackToWorkflow(wfID portainer.WorkflowID, es portainer.EdgeStack, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
func MapEdgeStackToWorkflow(wfID portainer.WorkflowID, es portainer.EdgeStack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow {
|
||||
platform := DeploymentPlatformDockerStandalone
|
||||
if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
platform = DeploymentPlatformKubernetes
|
||||
@@ -73,6 +74,7 @@ func MapEdgeStackToWorkflow(wfID portainer.WorkflowID, es portainer.EdgeStack, g
|
||||
Artifact: artifact,
|
||||
Target: deriveEdgeStackTargetState(statuses),
|
||||
},
|
||||
SourceID: sourceID,
|
||||
GitConfig: gitConfig,
|
||||
Target: Target{
|
||||
EdgeGroupIDs: es.EdgeGroups,
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestMapEdgeStackToWorkflow_DockerPlatform(t *testing.T) {
|
||||
}
|
||||
cfg := &gittypes.RepoConfig{URL: "https://github.com/x/repo"}
|
||||
|
||||
w := MapEdgeStackToWorkflow(2, es, cfg, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{1: {10}}, WorkflowPhaseStatus{Status: StatusHealthy}, WorkflowPhaseStatus{Status: StatusHealthy})
|
||||
w := MapEdgeStackToWorkflow(2, es, 7, cfg, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{1: {10}}, WorkflowPhaseStatus{Status: StatusHealthy}, WorkflowPhaseStatus{Status: StatusHealthy})
|
||||
|
||||
require.Equal(t, portainer.WorkflowID(2), w.ID)
|
||||
require.Equal(t, es.Name, w.Name)
|
||||
@@ -171,6 +171,7 @@ func TestMapEdgeStackToWorkflow_DockerPlatform(t *testing.T) {
|
||||
require.Equal(t, DeploymentPlatformDockerStandalone, w.Platform)
|
||||
require.Equal(t, es.CreationDate, w.CreationDate)
|
||||
require.Equal(t, cfg, w.GitConfig)
|
||||
require.Equal(t, portainer.SourceID(7), w.SourceID)
|
||||
require.Equal(t, []portainer.EdgeGroupID{1}, w.Target.EdgeGroupIDs)
|
||||
}
|
||||
|
||||
@@ -184,7 +185,7 @@ func TestMapEdgeStackToWorkflow_KubernetesPlatform(t *testing.T) {
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
}
|
||||
|
||||
w := MapEdgeStackToWorkflow(1, es, nil, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{}, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
|
||||
w := MapEdgeStackToWorkflow(1, es, 0, nil, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{}, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
|
||||
|
||||
require.Equal(t, DeploymentPlatformKubernetes, w.Platform)
|
||||
}
|
||||
@@ -206,7 +207,7 @@ func TestMapEdgeStackToWorkflow_GroupStatusesAndResolvedEndpoints(t *testing.T)
|
||||
EdgeGroups: []portainer.EdgeGroupID{1, 2},
|
||||
}
|
||||
|
||||
w := MapEdgeStackToWorkflow(5, es, nil, statuses, groupEndpoints, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
|
||||
w := MapEdgeStackToWorkflow(5, es, 0, nil, statuses, groupEndpoints, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
|
||||
|
||||
require.Equal(t, StatusHealthy, w.Target.GroupStatus[1])
|
||||
require.Equal(t, StatusError, w.Target.GroupStatus[2])
|
||||
|
||||
@@ -85,6 +85,7 @@ type Workflow struct {
|
||||
Type Type `json:"type" validate:"required"`
|
||||
Platform DeploymentPlatform `json:"platform" validate:"required"`
|
||||
Status WorkflowStatusObject `json:"status" validate:"required"`
|
||||
SourceID portainer.SourceID `json:"sourceId,omitempty"`
|
||||
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
|
||||
AutoUpdate *portainer.AutoUpdateSettings `json:"autoUpdate,omitempty"`
|
||||
Target Target `json:"target" validate:"required"`
|
||||
|
||||
@@ -66,7 +66,7 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
|
||||
cfg.ConfigFilePath = file.Path
|
||||
cfg.ConfigHash = file.Hash
|
||||
}
|
||||
items = append(items, ce.MapStackToWorkflow(stack, cfg, unknown, unknown))
|
||||
items = append(items, ce.MapStackToWorkflow(stack, src.ID, cfg, unknown, unknown))
|
||||
stats.WorkflowCount++
|
||||
if stack.EndpointID != 0 {
|
||||
stats.EndpointIDs.Add(stack.EndpointID)
|
||||
|
||||
@@ -8306,6 +8306,7 @@ export type WorkflowsWorkflow = {
|
||||
lastSyncDate?: number;
|
||||
name: string;
|
||||
platform: WorkflowsDeploymentPlatform;
|
||||
sourceId?: number;
|
||||
status: WorkflowsWorkflowStatusObject;
|
||||
target: WorkflowsTarget;
|
||||
type: WorkflowsType;
|
||||
|
||||
@@ -3374,6 +3374,7 @@ export const zWorkflowsWorkflow = z.object({
|
||||
lastSyncDate: z.int().optional(),
|
||||
name: z.string(),
|
||||
platform: zWorkflowsDeploymentPlatform,
|
||||
sourceId: z.int().optional(),
|
||||
status: zWorkflowsWorkflowStatusObject,
|
||||
target: zWorkflowsTarget,
|
||||
type: zWorkflowsType,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Link } from '@@/Link';
|
||||
|
||||
import { Workflow, WorkflowTarget, WorkflowType } from '../../workflows/types';
|
||||
import { StatusBadge } from '../../components/StatusBadge';
|
||||
import { getWorkflowLink } from '../../workflows/utils';
|
||||
import { effectiveWorkflowStatus } from '../../workflows/status';
|
||||
|
||||
interface Props {
|
||||
@@ -51,11 +52,13 @@ function WorkflowsList({ workflows }: { workflows: Array<Workflow> }) {
|
||||
}
|
||||
|
||||
function WorkflowCard({ item }: { item: Workflow }) {
|
||||
const { to, params } = getWorkflowLink(item);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="group flex items-center gap-3 p-4 text-inherit hover:bg-cyan-4/10 hover:text-current hover:!no-underline"
|
||||
to="portainer.gitops.workflows.item"
|
||||
params={{ id: item.id }}
|
||||
to={to}
|
||||
params={params}
|
||||
data-cy="workflow-item"
|
||||
>
|
||||
<div className="me-3 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-4 text-blue-7">
|
||||
|
||||
@@ -52,13 +52,13 @@ function SourceRow({ query }: { query: WorkflowSourceQuery['query'] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="portainer.gitops.sources.item"
|
||||
params={{ sourceId: source.id }}
|
||||
data-cy={`workflow-source-link-${source.id}`}
|
||||
className="block px-4 py-3 no-underline hover:bg-gray-1 hover:no-underline th-dark:hover:bg-gray-10"
|
||||
>
|
||||
<li className="flex items-center gap-4">
|
||||
<li>
|
||||
<Link
|
||||
to="portainer.gitops.sources.item"
|
||||
params={{ sourceId: source.id }}
|
||||
data-cy={`workflow-source-link-${source.id}`}
|
||||
className="flex items-center gap-4 px-4 py-3 no-underline hover:bg-gray-1 hover:no-underline th-dark:hover:bg-gray-10"
|
||||
>
|
||||
<StatusBadge status={source.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-semibold text-gray-9 th-highcontrast:text-white th-dark:text-white">
|
||||
@@ -68,7 +68,7 @@ function SourceRow({ query }: { query: WorkflowSourceQuery['query'] }) {
|
||||
{source.url}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</Link>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PlatformBadge } from '../../components/StatusBadge';
|
||||
import { TargetCell } from '../ListView/WorkflowSubRow/TargetCell';
|
||||
import { WorkflowArtifact } from '../types';
|
||||
import { Dot } from '../ListView/WorkflowSubRow/Block';
|
||||
import { getArtifactStackLink } from '../utils';
|
||||
import { getDeployedStackLink } from '../utils';
|
||||
|
||||
interface Props {
|
||||
artifacts: WorkflowArtifact[];
|
||||
@@ -34,7 +34,7 @@ export function StacksSection({ artifacts }: Props) {
|
||||
|
||||
function StackRow({ artifact }: { artifact: WorkflowArtifact }) {
|
||||
const status = artifact.status.artifact.status;
|
||||
const stackLink = getArtifactStackLink(artifact);
|
||||
const stackLink = getDeployedStackLink(artifact);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-4 px-4 py-3">
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ActionBarShell } from '@@/ResourceDetailHeader/ActionBarShell';
|
||||
import { Button } from '@@/buttons';
|
||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { WorkflowDetail } from '../types';
|
||||
import { StatusBadge } from '../../components/StatusBadge';
|
||||
@@ -48,18 +47,18 @@ export function WorkflowResourceHeader({ workflow }: Props) {
|
||||
actionBar={
|
||||
<ActionBarShell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
as={Link}
|
||||
props={{
|
||||
to: 'portainer.gitops.workflows.item.edit',
|
||||
params: { workflowId: workflow.id },
|
||||
}}
|
||||
icon={PenBoxIcon}
|
||||
color="light"
|
||||
data-cy="workflow-edit-button"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<TooltipWithChildren message={COMING_SOON_MESSAGE}>
|
||||
<span>
|
||||
<Button
|
||||
icon={PenBoxIcon}
|
||||
color="light"
|
||||
disabled
|
||||
data-cy="workflow-edit-button"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
<TooltipWithChildren message={COMING_SOON_MESSAGE}>
|
||||
<span>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Workflow, WorkflowStatus } from '../../types';
|
||||
import { getDeployedStackLink, getSourceLink } from '../../utils';
|
||||
|
||||
import { Block, Dot } from './Block';
|
||||
import { TargetCell } from './TargetCell';
|
||||
@@ -22,6 +25,7 @@ export function WorkflowSubRow({ item }: { item: Workflow }) {
|
||||
<Td>
|
||||
{item.gitConfig && (
|
||||
<SourceCell
|
||||
sourceId={item.sourceId}
|
||||
name={item.name}
|
||||
url={item.gitConfig.URL}
|
||||
status={item.status.source.status}
|
||||
@@ -31,6 +35,7 @@ export function WorkflowSubRow({ item }: { item: Workflow }) {
|
||||
<Td divider>
|
||||
{item.gitConfig && (
|
||||
<ArtifactCell
|
||||
item={item}
|
||||
path={item.gitConfig.ConfigFilePath}
|
||||
status={item.status.artifact.status}
|
||||
/>
|
||||
@@ -51,15 +56,17 @@ export function WorkflowSubRow({ item }: { item: Workflow }) {
|
||||
}
|
||||
|
||||
function SourceCell({
|
||||
sourceId,
|
||||
name,
|
||||
url,
|
||||
status,
|
||||
}: {
|
||||
sourceId: number | undefined;
|
||||
name: string;
|
||||
url: string;
|
||||
status: WorkflowStatus;
|
||||
}) {
|
||||
return (
|
||||
const content = (
|
||||
<Block status={status} className="flex items-start gap-2">
|
||||
<Dot status={status} className="mt-1.5" />
|
||||
<div className="min-w-0">
|
||||
@@ -72,16 +79,34 @@ function SourceCell({
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
|
||||
if (sourceId === undefined) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const sourceLink = getSourceLink(sourceId);
|
||||
return (
|
||||
<Link
|
||||
to={sourceLink.to}
|
||||
params={sourceLink.params}
|
||||
data-cy={`workflow-source-link-${sourceId}`}
|
||||
className="block no-underline hover:no-underline"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactCell({
|
||||
item,
|
||||
path,
|
||||
status,
|
||||
}: {
|
||||
item: Workflow;
|
||||
path: string;
|
||||
status: WorkflowStatus;
|
||||
}) {
|
||||
return (
|
||||
const content = (
|
||||
<Block status={status} className="flex items-center gap-2">
|
||||
<Dot status={status} />
|
||||
<span className="font-mono text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-4">
|
||||
@@ -89,6 +114,22 @@ function ArtifactCell({
|
||||
</span>
|
||||
</Block>
|
||||
);
|
||||
|
||||
const stackLink = getDeployedStackLink(item);
|
||||
if (!stackLink) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={stackLink.to}
|
||||
params={stackLink.params}
|
||||
data-cy={`workflow-artifact-link-${item.id}`}
|
||||
className="block no-underline hover:no-underline"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, divider }: { children: ReactNode; divider?: boolean }) {
|
||||
|
||||
@@ -36,17 +36,22 @@ async function getWorkflow(id: number): Promise<WorkflowDetail> {
|
||||
return {
|
||||
...artifact,
|
||||
status: toArtifactStatusObj(artifact.status),
|
||||
files:
|
||||
artifact.files?.filter((f) => f.sourceId).map(toArtifactFile) ?? [],
|
||||
files: (artifact.files ?? []).filter(hasSourceId).map(toArtifactFile),
|
||||
};
|
||||
}
|
||||
|
||||
function toArtifactFile(
|
||||
function hasSourceId(
|
||||
file: WorkflowsArtifactFileDetail
|
||||
): file is WorkflowsArtifactFileDetail & { sourceId: number } {
|
||||
return !!file.sourceId;
|
||||
}
|
||||
|
||||
function toArtifactFile(
|
||||
file: WorkflowsArtifactFileDetail & { sourceId: number }
|
||||
): WorkflowArtifactFile {
|
||||
return {
|
||||
...file,
|
||||
sourceId: file.sourceId ?? 0,
|
||||
sourceId: file.sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface Workflow {
|
||||
type: WorkflowType;
|
||||
platform: DeploymentPlatform;
|
||||
status: WorkflowStatusObject;
|
||||
sourceId?: number;
|
||||
gitConfig?: RepoConfigResponse;
|
||||
target: WorkflowTarget;
|
||||
creationDate: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
|
||||
import { Workflow, WorkflowArtifact } from './types';
|
||||
import { DeploymentPlatform, Workflow, WorkflowType } from './types';
|
||||
|
||||
export function getWorkflowLink(item: Workflow): {
|
||||
to: string;
|
||||
@@ -12,28 +12,47 @@ export function getWorkflowLink(item: Workflow): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getArtifactStackLink(
|
||||
artifact: WorkflowArtifact
|
||||
export function getSourceLink(sourceId: number): {
|
||||
to: string;
|
||||
params: object;
|
||||
} {
|
||||
return {
|
||||
to: 'portainer.gitops.sources.item',
|
||||
params: { sourceId },
|
||||
};
|
||||
}
|
||||
|
||||
interface DeployedStack {
|
||||
id: number;
|
||||
name: string;
|
||||
type: WorkflowType;
|
||||
platform?: DeploymentPlatform;
|
||||
target?: { endpointId?: number };
|
||||
}
|
||||
|
||||
/** Links to the actual deployed Stack/EdgeStack a Workflow or WorkflowArtifact represents. */
|
||||
export function getDeployedStackLink(
|
||||
item: DeployedStack
|
||||
): { to: string; params: object } | null {
|
||||
if (artifact.type === 'edgeStack') {
|
||||
return { to: 'edge.stacks.edit', params: { stackId: artifact.id } };
|
||||
if (item.type === 'edgeStack') {
|
||||
return { to: 'edge.stacks.edit', params: { stackId: item.id } };
|
||||
}
|
||||
|
||||
if (artifact.platform === 'kubernetes') {
|
||||
if (item.platform === 'kubernetes') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type =
|
||||
artifact.platform === 'dockerSwarm'
|
||||
item.platform === 'dockerSwarm'
|
||||
? StackType.DockerSwarm
|
||||
: StackType.DockerCompose;
|
||||
|
||||
return {
|
||||
to: 'docker.stacks.stack',
|
||||
params: {
|
||||
endpointId: artifact.target?.endpointId,
|
||||
name: artifact.name,
|
||||
id: artifact.id,
|
||||
endpointId: item.target?.endpointId,
|
||||
name: item.name,
|
||||
id: item.id,
|
||||
type,
|
||||
regular: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user