@ethicdevs/gitfoss | Show object: d5d91d35f3714ae4b15b85287ea9ed17dc471f18 ∙ GitFOSS
feat(pipelines): implement pipelines views (stage / logs)
+ 636
- 190
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1779126421985,
+  "_generatedAtUnix": 1779139833816,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "assets": {

...
@@ -22,7 +22,7 @@
       "pathSourceMap": "./public/.islands/AppRouter.bundle.js.map"
     },
     "Code": {
-      "hash": "4243c08b20c3b66f9613648381e0601f0003c836",
+      "hash": "0536f41cc4d3e3a3245413af7682c26da63384a4",
       "pathSource": "./app/islands/Code.tsx",
       "pathBundle": "./public/.islands/Code.bundle.js",
       "pathSourceMap": "./public/.islands/Code.bundle.js.map"

...
@@ -70,7 +70,7 @@
       "pathSourceMap": "./public/.islands/RepositoryForkForm.bundle.js.map"
     },
     "RepositoryHero": {
-      "hash": "fe354bc526c8e8ea82f257e2b056d0bcffdc4d20",
+      "hash": "b3b123dba534f69ac6fd54e99503226e2bb4328f",
       "pathSource": "./app/islands/RepositoryHero.tsx",
       "pathBundle": "./public/.islands/RepositoryHero.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryHero.bundle.js.map"

...
@@ -126,11 +126,11 @@
       "pathSource": "./app/views/pipelines/PipelineArtefactsView.tsx"
     },
     "PipelineDetailsView": {
-      "hash": "3d5cfe3a4cdb93a153f894e4e452ba01708acff5",
+      "hash": "e02d2e36e1073d13cf2f563850690645aa2efa2d",
       "pathSource": "./app/views/pipelines/PipelineDetailsView.tsx"
     },
     "PipelineStageDetailsView": {
-      "hash": "dfae473d54628e39af86f2e8a53dea541b2e0391",
+      "hash": "ea9da43602fe85435dd58c9386e7d2d417662944",
       "pathSource": "./app/views/pipelines/PipelineStageDetailsView.tsx"
     },
     "PipelineStagesView": {

...
@@ -138,19 +138,19 @@
       "pathSource": "./app/views/pipelines/PipelineStagesView.tsx"
     },
     "PipelinesView": {
-      "hash": "b0b0751c7a869918310db58db9441ac2380fea97",
+      "hash": "b3c337b3baa004c75c223fe271f8a93642f0d907",
       "pathSource": "./app/views/pipelines/PipelinesView.tsx"
     },
     "RepositoryBrowserView": {
-      "hash": "8ad5b2c67b6a65a04c2976cba9079ec2f0da1eee",
+      "hash": "f2f2af5bd2ab4cb6442f3ccb8ecbeda1e28c7da8",
       "pathSource": "./app/views/repository/RepositoryBrowserView.tsx"
     },
     "RepositoryCommitsLogView": {
-      "hash": "fee84b81eded32f58b010fa66941d9df1860fa8b",
+      "hash": "1431635552e8c48897efb3903106936fd7900ac9",
       "pathSource": "./app/views/repository/RepositoryCommitsLogView.tsx"
     },
     "RepositoryCompareView": {
-      "hash": "f88489e021baae8b3e55e3b4dbe2c3e4b0af8a29",
+      "hash": "8b4bf2a7b9d95dbd942d5ee42e3baed31ec23fd6",
       "pathSource": "./app/views/repository/RepositoryCompareView.tsx"
     },
     "RepositoryCreateView": {

...
@@ -158,7 +158,7 @@
       "pathSource": "./app/views/repository/RepositoryCreateView.tsx"
     },
     "RepositoryDetailsView": {
-      "hash": "63df2df0c5ed9838505df2c351aece6e198d17bb",
+      "hash": "f599436ec5a019ce977ca4cddc61ebbff56588e3",
       "pathSource": "./app/views/repository/RepositoryDetailsView.tsx"
     },
     "RepositoryExploreView": {

...
@@ -166,23 +166,23 @@
       "pathSource": "./app/views/repository/RepositoryExploreView.tsx"
     },
     "RepositoryForkView": {
-      "hash": "6c4f238f98aace40dfa8eeff9749d3841349f8e0",
+      "hash": "d991907250e5eb09943b8fee9e61567b3274cf0f",
       "pathSource": "./app/views/repository/RepositoryForkView.tsx"
     },
     "RepositoryShowObjectView": {
-      "hash": "3c5578449f0a838f9a8b679ec2360a212f5a4e4c",
+      "hash": "5f4d8f29fe7fbbcb9c2e60e398372da553abcaf8",
       "pathSource": "./app/views/repository/RepositoryShowObjectView.tsx"
     },
     "RepositoryPullRequestCreateView": {
-      "hash": "f5c39bf5a0ea9ec03a1a684293c7a1f595176c0b",
+      "hash": "e2d88584ca1b78c466d2df6379396c7e3be5c0f8",
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestCreateView.tsx"
     },
     "RepositoryPullRequestDetailsView": {
-      "hash": "9664f6328191169ea5926e23fd63da11be4163c6",
+      "hash": "4f7629d5d1e75b53ce86e245c211526f5d63823f",
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx"
     },
     "RepositoryPullRequestsView": {
-      "hash": "6a5fb0057478583375f6806704ec8206e93f86a5",
+      "hash": "928cc5f89e5411f7e7354c119754b8166431c0b4",
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestsView.tsx"
     },
     "SettingsKeyAddView": {

...
@@ -198,7 +198,7 @@
       "pathSource": "./app/views/settings/SettingsKeysListView.tsx"
     },
     "SettingsView": {
-      "hash": "4d1432779c2a5ddf58e220599cc4042320a16eb8",
+      "hash": "8ce5cced9f229ab999f9e05cd62a82f4c5681285",
       "pathSource": "./app/views/settings/SettingsView.tsx"
     },
     "UserDashboardView": {

...
@@ -206,7 +206,7 @@
       "pathSource": "./app/views/user/UserDashboardView.tsx"
     },
     "UserDetailsView": {
-      "hash": "cdf50c6716c65f78a3d01e834f4f8999c85529ad",
+      "hash": "bf11c0105a7fae171bb18fd12e38b207b2fd39c5",
       "pathSource": "./app/views/user/UserDetailsView.tsx"
     }
   }

@@ -21,6 +21,7 @@ interface CodeProps {
   code: string;
   language: string;
   style?: CSSProperties;
+  whiteSpace?: "pre-wrap" | "pre" | "normal" | "nowrap";
   [x: string]: unknown;
 }
 

...
@@ -48,6 +49,7 @@ const Code: ReactIsland<CodeProps & WithThemeSchemeProp> = ({
   language,
   themeScheme,
   style,
+  whiteSpace,
   ...props
 }) => {
   const { before: lineStartAt } = useMemo(

...
@@ -176,7 +178,7 @@ const Code: ReactIsland<CodeProps & WithThemeSchemeProp> = ({
         data-start={lineStartAt}
         className={` language-${language} line-numbers`}
         themeScheme={themeScheme}
-        style={{ counterReset: "linenumber 0", ...(style || {}) }}
+        style={{ counterReset: "linenumber 0", ...(style || {}), whiteSpace }}
       >
         <StyledCodeTag {...props} dangerouslySetInnerHTML={innerHtml} />
       </StylePreTag>

app/islands/RepositoryHero.tsx
@@ -53,121 +53,126 @@ const RepositoryHero: ReactIsland<
   return (
     <Grid.Col
       fluid
-      gap={16}
       style={{
+        margin: "0 -16px",
+        padding: "12px 16px",
+        width: "calc(100% + 32px)",
+        background: NamedColors.HEADER[themeScheme],
         borderBottom: `1px solid ${NamedColors.BORDER_DEFAULT[themeScheme]}`,
       }}
     >
-      <Grid.Row fluid nowrap alignItems={"flex-start"}>
-        <StyledActionIconButtonAnchor
-          themeScheme={themeScheme}
-          onClick={
-            typeof window !== "undefined"
-              ? () => window.history.back()
-              : undefined
-          }
-          visible
-        >
-          <BackIcon color={NamedColors.TEXT_DEFAULT[themeScheme]} size={24} />
-        </StyledActionIconButtonAnchor>
-        <Grid.Col
-          nowrap
-          flex={"1 0 300px"}
-          style={{ minWidth: 300, marginBottom: 12, marginLeft: 12 }}
-        >
-          <h2 style={{ margin: 0, fontSize: 20 }}>
-            <a
-              style={{ whiteSpace: "nowrap" }}
-              href={buildRouteLink(AppRoute.ORGANIZATION_DETAILS, {
-                orgSlug: parentOrg.slug,
-              })}
-            >
-              {parentOrg.displayName || parentOrg.slug}
-            </a>
-            {" / "}
-            <a
-              style={{ whiteSpace: "nowrap" }}
-              href={buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
+      <Grid.Col fluid gap={16}>
+        <Grid.Row fluid nowrap alignItems={"center"}>
+          <StyledActionIconButtonAnchor
+            themeScheme={themeScheme}
+            onClick={
+              typeof window !== "undefined"
+                ? () => window.history.back()
+                : undefined
+            }
+            visible
+          >
+            <BackIcon color={NamedColors.TEXT_DEFAULT[themeScheme]} size={24} />
+          </StyledActionIconButtonAnchor>
+          <Grid.Col
+            nowrap
+            flex={"1 0 270px"}
+            style={{ minWidth: 270, marginLeft: 12 }}
+          >
+            <h2 style={{ margin: 0, fontSize: 20 }}>
+              <a
+                style={{ whiteSpace: "nowrap" }}
+                href={buildRouteLink(AppRoute.ORGANIZATION_DETAILS, {
+                  orgSlug: parentOrg.slug,
+                })}
+              >
+                {parentOrg.displayName || parentOrg.slug}
+              </a>
+              {" / "}
+              <a
+                style={{ whiteSpace: "nowrap" }}
+                href={buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
+                  orgSlug: parentOrg.slug,
+                  repoSlug: repo.slug,
+                })}
+              >
+                {repo.displayName || repo.slug}
+              </a>
+            </h2>
+            <div style={{ flex: 1 }}>
+              <h3
+                style={{
+                  whiteSpace: "nowrap",
+                  margin: 0,
+                  marginTop: 4,
+                  fontSize: 16,
+                  fontWeight: "700",
+                  fontFamily: "monospace",
+                }}
+              >
+                {path || "Files"}
+              </h3>
+            </div>
+            <div style={{ flex: 1 }}>
+              {repo.isFork && forkedFromRepo != null && (
+                <h5 style={{ margin: 0, marginTop: 8 }}>
+                  <span>Forked From</span>
+                  {" ∙ "}
+                  <a
+                    href={buildRouteLink(AppRoute.ORGANIZATION_DETAILS, {
+                      orgSlug: forkedFromRepo.organization.slug,
+                    })}
+                  >
+                    {forkedFromRepo.organization.displayName ||
+                      forkedFromRepo.organization.slug}
+                  </a>
+                  {" / "}
+                  <a
+                    href={buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
+                      orgSlug: forkedFromRepo.organization.slug,
+                      repoSlug: forkedFromRepo.slug,
+                    })}
+                  >
+                    {forkedFromRepo.displayName || forkedFromRepo.slug}
+                  </a>
+                </h5>
+              )}
+            </div>
+          </Grid.Col>
+          {showForkButton && (
+            <ButtonAnchor
+              href={buildRouteLink(AppRoute.REPOSITORY_FORK, {
                 orgSlug: parentOrg.slug,
                 repoSlug: repo.slug,
               })}
             >
-              {repo.displayName || repo.slug}
-            </a>
-          </h2>
-          <div style={{ flex: 1 }}>
-            <h3
-              style={{
-                whiteSpace: "nowrap",
-                margin: 0,
-                marginTop: 4,
-                fontSize: 16,
-                fontWeight: "700",
-                fontFamily: "monospace",
-              }}
-            >
-              {path || "Files"}
-            </h3>
-          </div>
-          <div style={{ flex: 1 }}>
-            {repo.isFork && forkedFromRepo != null && (
-              <h5 style={{ margin: 0, marginTop: 8 }}>
-                <span>Forked From</span>
-                {" ∙ "}
-                <a
-                  href={buildRouteLink(AppRoute.ORGANIZATION_DETAILS, {
-                    orgSlug: forkedFromRepo.organization.slug,
-                  })}
-                >
-                  {forkedFromRepo.organization.displayName ||
-                    forkedFromRepo.organization.slug}
-                </a>
-                {" / "}
-                <a
-                  href={buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
-                    orgSlug: forkedFromRepo.organization.slug,
-                    repoSlug: forkedFromRepo.slug,
-                  })}
-                >
-                  {forkedFromRepo.displayName || forkedFromRepo.slug}
-                </a>
-              </h5>
-            )}
-          </div>
-        </Grid.Col>
-        {showForkButton && (
-          <ButtonAnchor
-            href={buildRouteLink(AppRoute.REPOSITORY_FORK, {
-              orgSlug: parentOrg.slug,
-              repoSlug: repo.slug,
-            })}
-          >
-            <Grid.Row
-              nowrap
-              justifyContent={"center"}
-              alignItems={"center"}
-              gap={8}
-            >
-              <GitForkIcon color={Colors.WHITE_01} size={24} />
-              <span>Fork</span>
-              <span
-                style={{
-                  padding: "2px 6px",
-                  minWidth: 20,
-                  background: "rgba(0,0,0,0.1)",
-                  borderRadius: 8,
-                  fontSize: 12,
-                }}
+              <Grid.Row
+                nowrap
+                justifyContent={"center"}
+                alignItems={"center"}
+                gap={8}
               >
-                {forksCount}
-              </span>
-            </Grid.Row>
-          </ButtonAnchor>
-        )}
-        {showNewButton && (
-          <ButtonAnchor href={newButtonUrl}>{newButtonText}</ButtonAnchor>
-        )}
-      </Grid.Row>
+                <GitForkIcon color={Colors.WHITE_01} size={24} />
+                <span>Fork</span>
+                <span
+                  style={{
+                    padding: "2px 6px",
+                    minWidth: 20,
+                    background: "rgba(0,0,0,0.1)",
+                    borderRadius: 8,
+                    fontSize: 12,
+                  }}
+                >
+                  {forksCount}
+                </span>
+              </Grid.Row>
+            </ButtonAnchor>
+          )}
+          {showNewButton && (
+            <ButtonAnchor href={newButtonUrl}>{newButtonText}</ButtonAnchor>
+          )}
+        </Grid.Row>
+      </Grid.Col>
     </Grid.Col>
   );
 };

app/services/pipelines/getPipeline.ts
@@ -1,20 +1,50 @@
 // 1st-party
 import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
 // auto-generated via script [prisma:generate]
-import type { Pipeline } from "@prisma/client";
+import type {
+  Artefact,
+  Pipeline,
+  Repository,
+  Stage,
+  User,
+} from "@prisma/client";
 // app
 import type { PipelineServiceDeps } from "./types";
 
 const makeGetPipeline: ServiceMethodFactory<
   PipelineServiceDeps,
   [string],
-  Promise<Pipeline | null>
+  Promise<
+    | (Pipeline & {
+        stages: Stage[];
+        triggeredByUser: Omit<User, "hashedPassword" | "email"> | null;
+        artefacts: Artefact[];
+        repo: Repository;
+      })
+    | null
+  >
 > = ({ request }) => {
   return async (pipelineId: string) => {
     return request.prisma.pipeline.findUnique({
       where: {
         id: pipelineId,
       },
+      include: {
+        stages: true,
+        triggeredByUser: {
+          select: {
+            id: true,
+            username: true,
+            createdAt: true,
+            updatedAt: true,
+            avatarUri: true,
+            displayName: true,
+            role: true,
+          },
+        },
+        artefacts: true,
+        repo: true,
+      },
     });
   };
 };

app/services/pipelines/listByRepo.ts
@@ -1,12 +1,26 @@
 // 1st-party
 import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
-import { Pipeline, PipelineStatus } from "@prisma/client";
+import {
+  Artefact,
+  Pipeline,
+  PipelineStatus,
+  Repository,
+  Stage,
+  User,
+} from "@prisma/client";
 import type { PipelineServiceDeps, PipelinesFilter } from "./types";
 
 const makeListByRepo: ServiceMethodFactory<
   PipelineServiceDeps,
   [string, string],
-  Promise<Pipeline[]>
+  Promise<
+    (Pipeline & {
+      stages: Stage[];
+      triggeredByUser: Omit<User, "hashedPassword" | "email"> | null;
+      artefacts: Artefact[];
+      repo: Repository;
+    })[]
+  >
 > = ({ request }) => {
   return async (
     orgSlug: string,

...
@@ -33,6 +47,22 @@ const makeListByRepo: ServiceMethodFactory<
             : undefined,
       },
       orderBy: { createdAt: "desc" },
+      include: {
+        stages: true,
+        triggeredByUser: {
+          select: {
+            id: true,
+            username: true,
+            avatarUri: true,
+            createdAt: true,
+            updatedAt: true,
+            displayName: true,
+            role: true,
+          },
+        },
+        artefacts: true,
+        repo: true,
+      },
     });
   };
 };

app/services/pipelines/types.ts
@@ -1,7 +1,13 @@
 // 3rd-party
 import type { FastifyRequest } from "fastify";
 // generated via script [prisma:generate]
-import type { Artefact, Pipeline, Stage } from "@prisma/client";
+import type {
+  Artefact,
+  Pipeline,
+  Repository,
+  Stage,
+  User,
+} from "@prisma/client";
 
 // Lightweight shared types for pipeline domain to minimize coupling in MVP.
 export type Manifest = { manifest: string; version?: string };

...
@@ -28,7 +34,14 @@ export type PipelineServiceAPI = {
     orgSlug: string,
     repoSlug: string,
     filter?: PipelinesFilter,
-  ): Promise<Pipeline[]>;
+  ): Promise<
+    (Pipeline & {
+      stages: Stage[];
+      triggeredByUser: Omit<User, "hashedPassword" | "email"> | null;
+      artefacts: Artefact[];
+      repo: Repository;
+    })[]
+  >;
   listByRepoId(repoId: string): Promise<Pipeline[]>;
   getRepoPipelineManifest(orgSlug: string, repoSlug: string): Promise<Manifest>;
   parsePipelineManifest(manifestJsonOrYml: string): Manifest;

...
@@ -39,7 +52,14 @@ export type PipelineServiceAPI = {
     stageId: string,
   ): Promise<Stage["logs"][]>;
   getPipelineArtefacts(pipelineId: string): Promise<Artefact[]>;
-  getPipeline(pipelineId: string): Promise<Pipeline>;
+  getPipeline(pipelineId: string): Promise<
+    Pipeline & {
+      stages: Stage[];
+      triggeredByUser: Omit<User, "hashedPassword" | "email"> | null;
+      artefacts: Artefact[];
+      repo: Repository;
+    }
+  >;
   setPipeline(pipelineId: string, data: Partial<Pipeline>): Promise<Pipeline>;
   rmPipeline(pipelineId: string, reason: string): Promise<Pipeline>;
   initRunnerForRepo(

app/views/pipelines/PipelineDetailsView.tsx
@@ -3,14 +3,24 @@ import type { ReactView } from "@ethicdevs/react-monolith";
 // 3rd-party
 import React from "react";
 // generated via script[prisma:generate]
-import type { Pipeline } from "@prisma/client";
+import type { Pipeline, Stage } from "@prisma/client";
 // app
 import type { CommonProps } from "../../types";
 import { Layout } from "../../components/Layout";
 import { PageWrapper } from "../../components/PageWrapper";
+import { Grid } from "../../components/Grid";
+import { Card } from "../../components/Card.styled";
+import { Chip } from "../../components/Chip";
+import { IslandWrapper } from "../../components/IslandWrapper.styled";
+import { Colors, NamedColors } from "../../utils/style";
+import { AppRoute } from "../../routes.defs";
+import { buildRouteLink } from "../../utils/shared";
+// app islands
+import Code from "../../islands/Code";
+import RepositoryHero from "../../islands/RepositoryHero";
 
 export interface PipelineDetailsViewProps extends CommonProps {
-  pipeline: Pipeline;
+  pipeline: Pipeline & { stages: Stage[] };
   orgSlug: string;
   repoSlug: string;
 }

...
@@ -22,10 +32,178 @@ const PipelineDetailsView: ReactView<PipelineDetailsViewProps> = ({
   repoSlug,
 }) => {
   return (
-    <Layout {...commonProps} orgSlug={orgSlug} repoSlug={repoSlug}>
-      <PageWrapper>
-        <h2>Pipeline: {pipeline.name ?? pipeline.id}</h2>
-        <p>Status: {pipeline.status ?? "unknown"}</p>
+    <Layout
+      {...commonProps}
+      showDrawerPrimary
+      orgSlug={orgSlug}
+      repoSlug={repoSlug}
+    >
+      <PageWrapper style={{ gap: 16 }}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
+          <RepositoryHero
+            themeScheme={commonProps.themeScheme}
+            // forkedFromRepo={repo.forkedFromRepo}
+            // forksCount={repo.forks.length}
+            // parentOrg={parentOrg}
+            // repo={repo}
+            path={`Pipeline > #${pipeline.name ?? "Unknown"}`}
+            showForkButton={false}
+            showNewButton
+            newButtonText={"Download Artifacts"}
+            newButtonUrl={buildRouteLink(
+              AppRoute.REPOSITORY_PIPELINE_ARTEFACTS,
+              {
+                orgSlug: orgSlug,
+                repoSlug: repoSlug,
+                pipelineId: pipeline.id,
+              },
+            )}
+            parentOrg={{
+              id: "",
+              createdAt: new Date(Date.now()),
+              updatedAt: new Date(Date.now()),
+              slug: orgSlug,
+              ownerId: "",
+              kind: "PERSONAL",
+              visibility: "PUBLIC",
+              avatarUri: null,
+              displayName: orgSlug,
+              websiteUrl: null,
+            }}
+            repo={{
+              id: "",
+              forkedFromRepoId: null,
+              organizationId: "",
+              slug: repoSlug,
+              createdAt: new Date(Date.now()),
+              updatedAt: new Date(Date.now()),
+              lastPushedAt: null,
+              visibility: "PUBLIC",
+              isFork: false,
+              keywords: [],
+              avatarUri: null,
+              displayName: repoSlug,
+              shortDescription: null,
+              websiteUrl: null,
+            }}
+          />
+        </IslandWrapper>
+        <Grid.Row fluid nowrap gap={8} alignItems={"center"}>
+          <Chip
+            themeScheme={commonProps.themeScheme}
+            color={
+              {
+                PENDING: Colors.PRIMARY_01,
+                RUNNING: Colors.CYAN_01,
+                PASSED: Colors.GREEN_01,
+                FAILED: Colors.RED_01,
+                CANCELED: Colors.BLACK_01,
+              }[pipeline.status]
+            }
+          >
+            {
+              {
+                PENDING: "Pending",
+                RUNNING: "Running",
+                PASSED: "Passed",
+                FAILED: "Failed",
+                CANCELED: "Canceled",
+              }[pipeline.status]
+            }
+          </Chip>
+        </Grid.Row>
+        <Grid.Row fluid gap={4} alignItems={"flex-start"}>
+          {pipeline.stages
+            .sort((a, b) => a.order - b.order)
+            .map((stage) => (
+              <Card
+                themeScheme={commonProps.themeScheme}
+                style={{ flex: 1, padding: 4 }}
+              >
+                <Grid.Col key={stage.id} fluid gap={4}>
+                  <h3 style={{ margin: 0 }}>
+                    <a
+                      href={buildRouteLink(
+                        AppRoute.REPOSITORY_PIPELINE_STAGE_DETAILS,
+                        {
+                          orgSlug,
+                          repoSlug,
+                          pipelineId: pipeline.id,
+                          stageId: stage.id,
+                        },
+                      )}
+                    >
+                      {stage.name ?? stage.id}
+                    </a>
+                  </h3>
+                  <Grid.Row fluid nowrap gap={4} alignItems={"center"}>
+                    <div
+                      style={{
+                        fontSize: 11,
+                        color: NamedColors.TEXT_MUTED[commonProps.themeScheme],
+                      }}
+                    >
+                      #{stage.order}
+                    </div>
+                    <div
+                      style={{
+                        fontSize: 11,
+                        color: NamedColors.TEXT_MUTED[commonProps.themeScheme],
+                      }}
+                    >
+                      {stage.updatedAt.toLocaleString()}
+                    </div>
+                  </Grid.Row>
+                  <Chip
+                    themeScheme={commonProps.themeScheme}
+                    color={
+                      {
+                        PENDING: Colors.PRIMARY_01,
+                        RUNNING: Colors.CYAN_01,
+                        PASSED: Colors.GREEN_01,
+                        FAILED: Colors.RED_01,
+                        CANCELED: Colors.BLACK_01,
+                      }[stage.status]
+                    }
+                  >
+                    {
+                      {
+                        PENDING: "Pending",
+                        RUNNING: "Running",
+                        PASSED: "Passed",
+                        FAILED: "Failed",
+                        CANCELED: "Canceled",
+                      }[stage.status]
+                    }
+                  </Chip>
+                  {/*<code>{stage.logs}</code>*/}
+                </Grid.Col>
+              </Card>
+            ))}
+        </Grid.Row>
+        {pipeline.manifest && (
+          <Grid.Col fluid nowrap gap={4}>
+            <IslandWrapper data-islandid={`${Code.name}$$0`}>
+              <Code
+                themeScheme={commonProps.themeScheme}
+                language={"shell"}
+                code={`${orgSlug}/${repoSlug}/.gitfoss.ci`}
+                whiteSpace={"pre-wrap"}
+              />
+            </IslandWrapper>
+            <IslandWrapper data-islandid={`${Code.name}$$1`}>
+              <Code
+                themeScheme={commonProps.themeScheme}
+                language={"yaml"}
+                code={pipeline.manifest}
+                whiteSpace={"pre-wrap"}
+              />
+            </IslandWrapper>
+          </Grid.Col>
+        )}
       </PageWrapper>
     </Layout>
   );

app/views/pipelines/PipelineStageDetailsView.tsx
@@ -8,28 +8,103 @@ import type { Pipeline, Stage } from "@prisma/client";
 import type { CommonProps } from "../../types";
 import { Layout } from "../../components/Layout";
 import { PageWrapper } from "../../components/PageWrapper";
+import { IslandWrapper } from "../../components/IslandWrapper.styled";
+import { AppRoute } from "../../routes.defs";
+import { buildRouteLink } from "../../utils/shared";
+// app islands
+import Code from "../../islands/Code";
+import RepositoryHero from "../../islands/RepositoryHero";
 
 export interface PipelineStageDetailsViewProps extends CommonProps {
   pipeline: Pipeline;
   stage: Stage | null;
-  logs: Stage["logs"][];
+  logs: Stage["logs"];
   orgSlug: string;
   repoSlug: string;
 }
 
 const PipelineStageDetailsView: ReactView<PipelineStageDetailsViewProps> = ({
   commonProps,
-  // pipeline,
+  pipeline,
   stage,
   logs,
   orgSlug,
   repoSlug,
 }) => {
   return (
-    <Layout {...commonProps} orgSlug={orgSlug} repoSlug={repoSlug}>
-      <PageWrapper>
-        <h2>Stage: {stage?.name ?? stage?.id ?? "Unknown"}</h2>
-        <div>Logs: {logs?.length ?? 0}</div>
+    <Layout
+      {...commonProps}
+      showDrawerPrimary
+      orgSlug={orgSlug}
+      repoSlug={repoSlug}
+    >
+      <PageWrapper style={{ gap: 8 }}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
+          <RepositoryHero
+            themeScheme={commonProps.themeScheme}
+            // forkedFromRepo={repo.forkedFromRepo}
+            // forksCount={repo.forks.length}
+            // parentOrg={parentOrg}
+            // repo={repo}
+            path={`Pipeline > ${pipeline.name ?? "Unknown"} > #${stage?.order ?? 0} - ${stage?.name ?? "Unknown"}`}
+            showForkButton={false}
+            showNewButton
+            newButtonText={"Download Artifacts"}
+            newButtonUrl={buildRouteLink(
+              AppRoute.REPOSITORY_PIPELINE_ARTEFACTS,
+              {
+                orgSlug: orgSlug,
+                repoSlug: repoSlug,
+                pipelineId: pipeline.id,
+              },
+            )}
+            parentOrg={{
+              id: "",
+              createdAt: new Date(Date.now()),
+              updatedAt: new Date(Date.now()),
+              slug: orgSlug,
+              ownerId: "",
+              kind: "PERSONAL",
+              visibility: "PUBLIC",
+              avatarUri: null,
+              displayName: orgSlug,
+              websiteUrl: null,
+            }}
+            repo={{
+              id: "",
+              forkedFromRepoId: null,
+              organizationId: "",
+              slug: repoSlug,
+              createdAt: new Date(Date.now()),
+              updatedAt: new Date(Date.now()),
+              lastPushedAt: null,
+              visibility: "PUBLIC",
+              isFork: false,
+              keywords: [],
+              avatarUri: null,
+              displayName: repoSlug,
+              shortDescription: null,
+              websiteUrl: null,
+            }}
+          />
+        </IslandWrapper>
+        <IslandWrapper data-islandid={`${Code.name}$$0`}>
+          <Code
+            themeScheme={commonProps.themeScheme}
+            code={logs || ""}
+            language={"shell"}
+            whiteSpace={"pre-wrap"}
+            style={{
+              minHeight: "70vh",
+              overflowY: "scroll",
+              // backgroundColor: "black !important",
+              // color: "white",
+            }}
+          />
+        </IslandWrapper>
       </PageWrapper>
     </Layout>
   );

app/views/pipelines/PipelinesView.tsx
@@ -3,7 +3,14 @@ import type { ReactView } from "@ethicdevs/react-monolith";
 // 3rd-party
 import React from "react";
 // generated via script[generate:prisma]
-import type { Organization, Pipeline } from "@prisma/client";
+import type {
+  Artefact,
+  Organization,
+  Pipeline,
+  Repository,
+  Stage,
+  User,
+} from "@prisma/client";
 // app
 import type { CommonProps, RepositoryWithForkedFromRepo } from "../../types";
 import { AppRoute } from "../../routes.defs";

...
@@ -14,7 +21,7 @@ import { Grid } from "../../components/Grid";
 import { Layout } from "../../components/Layout";
 import { PageWrapper } from "../../components/PageWrapper";
 import { buildRouteLink } from "../../utils/shared";
-import { Colors } from "../../utils/style";
+import { Colors, NamedColors } from "../../utils/style";
 // app islands
 import { IslandWrapper } from "../../components/IslandWrapper.styled";
 import RepositoryHero from "../../islands/RepositoryHero";

...
@@ -29,7 +36,12 @@ type PipelinesFilter =
 
 export interface PipelinesViewProps extends CommonProps {
   parentOrg: Organization;
-  pipelines: Pipeline[];
+  pipelines: (Pipeline & {
+    stages: Stage[];
+    triggeredByUser: Omit<User, "hashedPassword" | "email"> | null;
+    artefacts: Artefact[];
+    repo: Repository;
+  })[];
   repo: RepositoryWithForkedFromRepo;
   pipelinesFilter?: PipelinesFilter;
 }

...
@@ -49,7 +61,10 @@ const PipelinesView: ReactView<PipelinesViewProps> = ({
       repoSlug={repo.slug}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

...
@@ -164,9 +179,17 @@ const PipelinesView: ReactView<PipelinesViewProps> = ({
                     })}
                   >
                     <Grid.Row fluid alignItems={"center"} gap={4}>
-                      <span>id: {pipeline.id}</span>
+                      <span>{pipeline.name}</span>
                       <span>&bull;</span>
-                      <span style={{ flex: 1 }}>{pipeline.name}</span>
+                      <span
+                        style={{
+                          flex: 1,
+                          color:
+                            NamedColors.TEXT_MUTED[commonProps.themeScheme],
+                        }}
+                      >
+                        {pipeline.id}
+                      </span>
                       <Chip
                         themeScheme={commonProps.themeScheme}
                         color={

...
@@ -191,21 +214,44 @@ const PipelinesView: ReactView<PipelinesViewProps> = ({
                       </Chip>
                     </Grid.Row>
                   </a>
-                  <Grid.Row
+                  {pipeline.triggeredByUser != null && (
+                    <Grid.Row
+                      fluid
+                      nowrap
+                      gap={8}
+                      alignItems={"center"}
+                      style={{ opacity: 0.67 }}
+                    >
+                      triggered by
+                      <a
+                        href={buildRouteLink(
+                          AppRoute.USER_DETAILS,
+                          { username: `@${pipeline.triggeredByUser.username}` },
+                          { encodeURIComponent: false },
+                        )}
+                        style={{ textTransform: "none" }}
+                      >
+                        @{pipeline.triggeredByUser.username}
+                      </a>
+                    </Grid.Row>
+                  )}
+                  <Grid.Col
                     fluid
-                    nowrap
-                    gap={8}
-                    alignItems={"center"}
-                    style={{ opacity: 0.67 }}
+                    gap={4}
+                    style={{ opacity: 0.67, marginTop: 4 }}
                   >
-                    triggered by
-                    <Chip
-                      themeScheme={commonProps.themeScheme}
-                      style={{ textTransform: "none" }}
-                    >
-                      <code>{""}</code>
-                    </Chip>
-                  </Grid.Row>
+                    {new Date(pipeline.createdAt).getTime() <=
+                      new Date(pipeline.updatedAt).getTime() && (
+                      <span>
+                        {`opened on ${new Date(pipeline.createdAt).toLocaleString()}`}
+                      </span>
+                    )}
+                    {pipeline.closedAt != null && (
+                      <span>
+                        {`closed on ${new Date(pipeline.closedAt).toLocaleString()}`}
+                      </span>
+                    )}
+                  </Grid.Col>
                 </Grid.Col>
               </Card>
             ))

app/views/repository/RepositoryBrowserView.tsx
@@ -66,7 +66,10 @@ const RepositoryBrowserView: ReactView<RepositoryBrowserViewProps> = ({
       path={path}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

app/views/repository/RepositoryCommitsLogView.tsx
@@ -44,7 +44,10 @@ const RepositoryCommitsLogView: ReactView<RepositoryCommitsLogViewProps> = ({
       currentRef={currentRef}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

app/views/repository/RepositoryCompareView.tsx
@@ -41,7 +41,10 @@ const RepositoryCompareView: ReactView<RepositoryCompareViewProps> = ({
       currentRef={refA}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

app/views/repository/RepositoryDetailsView.tsx
@@ -74,7 +74,10 @@ const RepositoryDetailsView: ReactView<RepositoryDetailsViewProps> = ({
       currentRef={currentRef}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={forkedFromRepo}

app/views/repository/RepositoryForkView.tsx
@@ -42,7 +42,10 @@ const RepositoryForkView: ReactView<RepositoryForkViewProps> = ({
       repoSlug={sourceRepo.slug}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={sourceRepo.forkedFromRepo}

app/views/repository/RepositoryShowObjectView.tsx
@@ -59,7 +59,10 @@ const RepositoryShowObjectView: ReactView<RepositoryShowObjectViewProps> = ({
       currentRef={currentRef}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

app/views/repositoryPullRequests/RepositoryPullRequestCreateView.tsx
@@ -32,7 +32,10 @@ const RepositoryPullRequestCreateView: ReactView<
       repoSlug={repo.slug}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx
@@ -85,7 +85,10 @@ const RepositoryPullRequestDetailsView: ReactView<
       repoSlug={repo.slug}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

app/views/repositoryPullRequests/RepositoryPullRequestsView.tsx
@@ -45,7 +45,10 @@ const RepositoryPullRequestsView: ReactView<
       repoSlug={repo.slug}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             forkedFromRepo={repo.forkedFromRepo}

app/views/settings/SettingsView.tsx
@@ -32,7 +32,10 @@ const SettingsView: ReactView<SettingsViewProps> = ({
       username={commonProps.currentUserUsername!}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             parentOrg={{ slug: commonProps.currentUserUsername! } as any}

app/views/user/UserDetailsView.tsx
@@ -30,7 +30,10 @@ const UserDetailsView: ReactView<UserDetailsViewProps> = ({
       username={user.username!}
     >
       <PageWrapper>
-        <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>
+        <IslandWrapper
+          style={{ position: "sticky", top: 64 }}
+          data-islandid={`${RepositoryHero.name}$$0`}
+        >
           <RepositoryHero
             themeScheme={commonProps.themeScheme}
             parentOrg={{ slug: user.username } as any}

new file
db/migrations/20260518194943_add_missing_pipeline_fields/migration.sql
@@ -0,0 +1,16 @@
+-- AlterTable
+ALTER TABLE "Artefact" ADD COLUMN     "stageId" TEXT NOT NULL DEFAULT '';
+
+-- AlterTable
+ALTER TABLE "Pipeline" ADD COLUMN     "closedAt" TIMESTAMP(3),
+ADD COLUMN     "triggeredByUserId" TEXT;
+
+-- AlterTable
+ALTER TABLE "Stage" ADD COLUMN     "closedAt" TIMESTAMP(3),
+ADD COLUMN     "exitCode" INTEGER;
+
+-- AddForeignKey
+ALTER TABLE "Pipeline" ADD CONSTRAINT "Pipeline_triggeredByUserId_fkey" FOREIGN KEY ("triggeredByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Artefact" ADD CONSTRAINT "Artefact_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@@ -147,6 +147,7 @@ model User {
   organizationMemberships        OrganizationMembership[] @relation("OneOrganizationMembershipToOneUser")
   pullRequestsWhereAuthor        PullRequest[]            @relation("OnePullRequestToOneUser")
   pullRequestCommentsWhereAuthor PullRequestComment[]     @relation("OnePullRequestToOnePRCommenterUser")
+  pipelinesWhereTriggeredByUser  Pipeline[]               @relation("PipelineTriggeredByUser")
 }
 
 model UserSSHKey {

...
@@ -166,33 +167,40 @@ model UserSSHKey {
 }
 
 model Pipeline {
-  id        String      @id @default(cuid())
-  createdAt DateTime    @default(now())
-  updatedAt DateTime    @updatedAt
-
-  name      String
-  status    PipelineStatus @default(PENDING)
-  manifest  String?
-
-  repoId    String
-  repo      Repository @relation("RepositoryPipelines",fields: [repoId], references: [id])
-
-  stages    Stage[]    @relation("PipelineStages")
-  artefacts Artefact[] @relation("PipelineArtefacts")
+  id                String          @id @default(cuid())
+  createdAt         DateTime        @default(now())
+  updatedAt         DateTime        @updatedAt
+  closedAt          DateTime?
+
+  name              String
+  status            PipelineStatus  @default(PENDING)
+  manifest          String?
+  triggeredByUserId String?
+  triggeredByUser   User?           @relation("PipelineTriggeredByUser", fields: [triggeredByUserId], references: [id])
+
+  repoId            String
+  repo              Repository      @relation("RepositoryPipelines", fields: [repoId], references: [id])
+
+  stages            Stage[]         @relation("PipelineStages")
+  artefacts         Artefact[]      @relation("PipelineArtefacts")
 }
 
 model Stage {
-  id         String   @id @default(cuid())
-  createdAt  DateTime @default(now())
-  updatedAt  DateTime @updatedAt
+  id         String      @id @default(cuid())
+  createdAt  DateTime    @default(now())
+  updatedAt  DateTime    @updatedAt
+  closedAt   DateTime?
 
   name       String
   order      Int
   status     StageStatus @default(PENDING)
   logs       String?
+  exitCode   Int?
 
   pipelineId String
-  pipeline   Pipeline @relation("PipelineStages", fields: [pipelineId], references: [id])
+  pipeline   Pipeline    @relation("PipelineStages", fields: [pipelineId], references: [id])
+
+  artefacts  Artefact[]  @relation("PipelineStageArtefacts")
 }
 
 model Artefact {

...
@@ -205,6 +213,9 @@ model Artefact {
 
   pipelineId String
   pipeline   Pipeline @relation("PipelineArtefacts", fields: [pipelineId], references: [id])
+
+  stageId    String   @default("")
+  stage      Stage    @relation("PipelineStageArtefacts", fields: [stageId], references: [id])
 }
 
 enum GlobalRole {