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:
Chaim Lev-Ari
2026-07-10 00:05:38 +03:00
committed by GitHub
parent 7f40b5dd10
commit ba40ae5bf3
17 changed files with 129 additions and 49 deletions
+2
View File
@@ -21413,6 +21413,8 @@ components:
type: string
platform:
$ref: "#/components/schemas/workflows.DeploymentPlatform"
sourceId:
type: integer
status:
$ref: "#/components/schemas/workflows.WorkflowStatusObject"
target:
+2
View File
@@ -9648,6 +9648,8 @@ definitions:
type: string
platform:
$ref: '#/definitions/workflows.DeploymentPlatform'
sourceId:
type: integer
status:
$ref: '#/definitions/workflows.WorkflowStatusObject'
target:
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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,
+4 -3
View File
@@ -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])
+1
View File
@@ -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"`
+1 -1
View File
@@ -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;
+29 -10
View File
@@ -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,
},