This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-8518-08c1ee9c44fa40c35d931552a6ea078c20819fbf
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 5865f2af0b88129144b25eeddef53842a885fed5
Author: Tanishq Gandhi <[email protected]>
AuthorDate: Thu Sep 17 03:44:47 2026 +0000

    feat(frontend): manage curated computing-unit images from the admin page 
(#8518)
    
    ### What changes were proposed in this PR?
    
    **Admin → CU Images**, the page for managing the images #8475 made
    registrable.
    
    Images are listed newest first, each with the digest a unit will
    actually run, and four actions: Add, Log, Refresh, Remove. A row shows
    PENDING, VALIDATING, READY or FAILED. The list polls while a check is
    running and goes quiet once nothing is.
    
    ![The images
    
list](https://raw.githubusercontent.com/tanishqgandhi1908/texera/assets/cu-image-admin-page/screenshots/1-list.png)
    
    **Log is the point of the page.** It is where a refused image explains
    itself — a tag that does not exist, an image that is not a
    computing-unit image — and says what to do instead.
    
    ![The log of a refused
    
image](https://raw.githubusercontent.com/tanishqgandhi1908/texera/assets/cu-image-admin-page/screenshots/2-log.png)
    
    A deployment with curated images switched off is told so plainly. The
    API answers 503 on every endpoint there, so the page can do nothing
    either way; saying that beats an error an administrator cannot tell from
    a broken deployment.
    
    ![A deployment with the feature switched
    
off](https://raw.githubusercontent.com/tanishqgandhi1908/texera/assets/cu-image-admin-page/screenshots/3-disabled.png)
    
    Also routes `/api/cu-image` to the computing-unit manager in the dev
    proxy. Without it a local frontend reaches the webserver instead and
    every call 404s. Production already routes through the gateway, added in
    #8475.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8470
    Part of #8466
    
    Stacked on #8475, whose API this consumes, so GitHub shows that commit
    here too until it merges. This PR's own change is one commit over it,
    frontend only.
    
    ### How was this PR tested?
    
    Nine tests for the page, plus the whole thing run against a real
    backend.
    
    | Case | What it pins |
    | --- | --- |
    | digest shown | the pinned digest reaches the page, not just the tag |
    | feature off | 503 becomes an explanation, not an error |
    | no poll when off | an idle switched-off page makes no requests |
    | polls, then stops | requests while a check runs, none once it settles
    |
    | newest first | ordered by time, not by name |
    | blank fields | no request is made |
    | Refresh while checking | disabled |
    | status colours | READY, FAILED and in-progress are distinguishable |
    
    ```
    yarn --cwd frontend format:ci     clean
    yarn --cwd frontend run build:ci  0 errors
    yarn --cwd frontend run test:ci   29 passed (9 new, plus 
dashboard.component.spec)
    ```
    
    The polling tests were mutation-checked: removing the filter fails
    exactly those two.
    
    **Against a real stack** (`bin/local-dev.sh up`, with the computing-unit
    manager talking to minikube):
    
    ```
    register alpine:3.20                    -> FAILED in 8s, digest 
sha256:d9e853e8...
      log: "Its start command is: [/bin/sh]"
    register texera-cu-sklearn:1.0          -> READY in 15s, digest 
sha256:7e0a8816...
      matches skopeo inspect independently
    refresh                                 -> attempt 1 -> 2, back to READY
    remove                                  -> 204
    duplicate reference / name / blank name -> 400, each naming the reason
    a Docker Hub page address               -> normalised to owner/name:latest
    REGULAR user: GET 200, POST/DELETE/log  -> 403
    ```
    
    Driven through the page itself: typed into the Add form, clicked Add,
    the row appeared VALIDATING and reached its outcome with no further
    interaction. Once settled, 0 requests over 20 idle seconds. The
    screenshots above are that stack, not a mock.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5)
    
    ---------
    
    Co-authored-by: ali risheh <[email protected]>
---
 frontend/proxy.config.json                         |   5 +
 frontend/src/app/app-routing.constant.ts           |   1 +
 frontend/src/app/app-routing.module.ts             |   5 +
 frontend/src/app/app.module.ts                     |   2 +
 .../admin/cu-image/admin-cu-image.component.html   | 166 ++++++++++++
 .../admin/cu-image/admin-cu-image.component.scss   | 110 ++++++++
 .../cu-image/admin-cu-image.component.spec.ts      | 232 +++++++++++++++++
 .../admin/cu-image/admin-cu-image.component.ts     | 280 +++++++++++++++++++++
 .../dashboard/component/dashboard.component.html   |  11 +
 .../component/dashboard.component.spec.ts          |   8 +-
 .../app/dashboard/component/dashboard.component.ts |   2 +
 .../service/admin/cu-image/cu-image.service.ts     |  86 +++++++
 12 files changed, 905 insertions(+), 3 deletions(-)

diff --git a/frontend/proxy.config.json b/frontend/proxy.config.json
index 47988f6b46..483b7a308a 100755
--- a/frontend/proxy.config.json
+++ b/frontend/proxy.config.json
@@ -70,6 +70,11 @@
     "secure": false,
     "changeOrigin": false
   },
+  "/api/cu-image": {
+    "target": "http://localhost:8888";,
+    "secure": false,
+    "changeOrigin": true
+  },
   "/api": {
     "target": "http://localhost:8080";,
     "secure": false,
diff --git a/frontend/src/app/app-routing.constant.ts 
b/frontend/src/app/app-routing.constant.ts
index 0c10a07fb4..072c378f76 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -50,5 +50,6 @@ export const ADMIN_USER = `${ADMIN}/user`;
 export const ADMIN_GMAIL = `${ADMIN}/gmail`;
 export const ADMIN_EXECUTION = `${ADMIN}/execution`;
 export const ADMIN_SETTINGS = `${ADMIN}/settings`;
+export const ADMIN_CU_IMAGE = `${ADMIN}/cu-image`;
 
 export const SEARCH = "/search";
diff --git a/frontend/src/app/app-routing.module.ts 
b/frontend/src/app/app-routing.module.ts
index 05c96925aa..7e0fc4e411 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -47,6 +47,7 @@ import { USER_WORKFLOW } from "./app-routing.constant";
 import { HubSearchResultComponent } from 
"./hub/component/hub-search-result/hub-search-result.component";
 import { EntityType } from "./hub/service/hub.service";
 import { AdminSettingsComponent } from 
"./dashboard/component/admin/settings/admin-settings.component";
+import { AdminCuImageComponent } from 
"./dashboard/component/admin/cu-image/admin-cu-image.component";
 import { OrcidCallbackComponent } from 
"./hub/component/login/orcid-callback.component";
 
 const routes: Routes = [];
@@ -213,6 +214,10 @@ routes.push({
           path: "settings",
           component: AdminSettingsComponent,
         },
+        {
+          path: "cu-image",
+          component: AdminCuImageComponent,
+        },
       ],
     },
     {
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 9b6e7d6170..6c96ab6ade 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -183,6 +183,7 @@ import { NzProgressModule } from "ng-zorro-antd/progress";
 import { ComputingUnitSelectionComponent } from 
"./workspace/component/power-button/computing-unit-selection.component";
 import { NzSliderModule } from "ng-zorro-antd/slider";
 import { AdminSettingsComponent } from 
"./dashboard/component/admin/settings/admin-settings.component";
+import { AdminCuImageComponent } from 
"./dashboard/component/admin/cu-image/admin-cu-image.component";
 import { NzInputNumberModule } from "ng-zorro-antd/input-number";
 import { NzGridModule } from "ng-zorro-antd/grid";
 import { NzCheckboxModule } from "ng-zorro-antd/checkbox";
@@ -352,6 +353,7 @@ registerLocaleData(en);
     HubSearchResultComponent,
     ComputingUnitSelectionComponent,
     AdminSettingsComponent,
+    AdminCuImageComponent,
     RegistrationRequestModalComponent,
     MarkdownDescriptionComponent,
     UserComputingUnitComponent,
diff --git 
a/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.html
 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.html
new file mode 100644
index 0000000000..8c5bf54de3
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.html
@@ -0,0 +1,166 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+
+<nz-card class="section-title">
+  <h2 class="page-title">Computing Unit Images</h2>
+  <div class="help-text">
+    An image a user can start a computing unit from. It must be built 
<code>FROM</code> the Texera computing-unit image.
+  </div>
+</nz-card>
+
+<nz-alert
+  *ngIf="featureDisabled"
+  nzType="info"
+  nzShowIcon
+  nzMessage="Curated images are not enabled on this deployment"
+  nzDescription="Set curatedImages.enabled in the Helm chart to turn this 
on."></nz-alert>
+
+<ng-container *ngIf="!featureDisabled">
+  <nz-card nzTitle="Add an image">
+    <div class="add-row">
+      <div class="add-field">
+        <span class="field-label">Name</span>
+        <input
+          nz-input
+          placeholder="AlphaFold 3"
+          [(ngModel)]="newName"
+          (keyup.enter)="add()" />
+        <div class="field-hint">What users see when choosing an image.</div>
+      </div>
+      <div class="add-field add-field-wide">
+        <span class="field-label">Image reference</span>
+        <input
+          nz-input
+          placeholder="texera/cu-alphafold3:1.0"
+          [(ngModel)]="newSourceRef"
+          (keyup.enter)="add()" />
+        <div class="field-hint">
+          A reference, or a Docker Hub page address. Without a tag, 
<code>latest</code> is used.
+        </div>
+      </div>
+      <button
+        nz-button
+        nzType="primary"
+        [disabled]="submitting"
+        (click)="add()">
+        <span
+          nz-icon
+          nzType="plus"></span>
+        Add
+      </button>
+    </div>
+  </nz-card>
+
+  <nz-card nzTitle="Images">
+    <nz-table
+      #imageTable
+      [nzData]="images"
+      [nzLoading]="loading"
+      [nzShowPagination]="false"
+      nzSize="middle">
+      <thead>
+        <tr>
+          <th>Name</th>
+          <th>Reference</th>
+          <th>Status</th>
+          <th>What a unit runs</th>
+          <th>Actions</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr *ngFor="let image of imageTable.data">
+          <td>{{ image.name }}</td>
+          <td>
+            <code class="ref">{{ image.sourceRef }}</code>
+          </td>
+          <td>
+            <nz-tag [nzColor]="statusColor(image.status)">{{ image.status 
}}</nz-tag>
+            <span
+              *ngIf="inProgress(image)"
+              nz-icon
+              nzType="loading"
+              class="spinner"></span>
+          </td>
+          <td>
+            <code
+              *ngIf="image.imageTag"
+              class="ref digest"
+              [nz-tooltip]="image.imageTag"
+              >{{ shortRef(image.imageTag) }}</code
+            >
+            <span
+              *ngIf="!image.imageTag"
+              class="muted"
+              >—</span
+            >
+          </td>
+          <td class="actions">
+            <button
+              nz-button
+              nzType="link"
+              nzSize="small"
+              nz-tooltip="What the check reported, including why it refused an 
image"
+              (click)="showLog(image)">
+              <span
+                nz-icon
+                nzType="file-text"></span>
+              Log
+            </button>
+            <button
+              nz-button
+              nzType="link"
+              nzSize="small"
+              nz-tooltip="Check the same reference again, picking up a moved 
tag"
+              [disabled]="inProgress(image)"
+              (click)="refresh(image)">
+              <span
+                nz-icon
+                nzType="reload"></span>
+              Refresh
+            </button>
+            <button
+              nz-button
+              nzType="link"
+              nzSize="small"
+              nzDanger
+              nz-popconfirm
+              nzPopconfirmTitle="Remove this image? Units already running on 
it keep running."
+              (nzOnConfirm)="remove(image)">
+              <span
+                nz-icon
+                nzType="delete"></span>
+              Remove
+            </button>
+          </td>
+        </tr>
+      </tbody>
+    </nz-table>
+  </nz-card>
+</ng-container>
+
+<nz-modal
+  [(nzVisible)]="logVisible"
+  [nzTitle]="'Check log — ' + logName"
+  nzWidth="760px"
+  [nzFooter]="null"
+  (nzOnCancel)="closeLog()">
+  <ng-container *nzModalContent>
+    <pre class="log">{{ logText || "No output yet." }}</pre>
+  </ng-container>
+</nz-modal>
diff --git 
a/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.scss
 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.scss
new file mode 100644
index 0000000000..e8af052545
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.scss
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.section-title {
+  margin-bottom: 16px;
+}
+
+.page-title {
+  margin: 0 0 8px;
+  font-size: 20px;
+  font-weight: 600;
+}
+
+.help-text {
+  max-width: 900px;
+  color: rgba(0, 0, 0, 0.6);
+  line-height: 1.6;
+}
+
+nz-alert {
+  margin-bottom: 16px;
+}
+
+nz-card {
+  margin-bottom: 16px;
+}
+
+.add-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 16px;
+  flex-wrap: wrap;
+}
+
+.add-field {
+  display: flex;
+  flex-direction: column;
+  min-width: 220px;
+}
+
+// The longer of the two fields.
+.add-field-wide {
+  flex: 1;
+  min-width: 320px;
+}
+
+.field-label {
+  font-weight: 500;
+  margin-bottom: 4px;
+}
+
+.field-hint {
+  margin-top: 4px;
+  font-size: 12px;
+  color: rgba(0, 0, 0, 0.45);
+}
+
+.add-row button {
+  margin-top: 26px;
+}
+
+.ref {
+  font-family: monospace;
+  font-size: 12px;
+  word-break: break-all;
+}
+
+.digest {
+  color: rgba(0, 0, 0, 0.65);
+}
+
+.muted {
+  color: rgba(0, 0, 0, 0.35);
+}
+
+.spinner {
+  margin-left: 6px;
+  color: #1890ff;
+}
+
+.actions {
+  white-space: nowrap;
+}
+
+.log {
+  max-height: 460px;
+  overflow: auto;
+  background: #f5f5f5;
+  padding: 12px;
+  border-radius: 4px;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
diff --git 
a/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.spec.ts
 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.spec.ts
new file mode 100644
index 0000000000..e6ed7a893f
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.spec.ts
@@ -0,0 +1,232 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } 
from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { NZ_ICONS } from "ng-zorro-antd/icon";
+import {
+  DeleteOutline,
+  FileTextOutline,
+  LoadingOutline,
+  PlusOutline,
+  ReloadOutline,
+} from "@ant-design/icons-angular/icons";
+import { AdminCuImageComponent } from "./admin-cu-image.component";
+import { CuImage } from "../../../service/admin/cu-image/cu-image.service";
+
+describe("AdminCuImageComponent", () => {
+  let component: AdminCuImageComponent;
+  let fixture: ComponentFixture<AdminCuImageComponent>;
+  let httpTestingController: HttpTestingController;
+
+  const CU_IMAGE_URL = "api/cu-image";
+
+  const image = (over: Partial<CuImage> = {}): CuImage => ({
+    iid: 1,
+    name: "Python ML",
+    sourceRef: "tagandhi19/texera-cu-sklearn:1.0",
+    sourceDigest: "sha256:bdeadc3c",
+    status: "READY",
+    imageTag: "tagandhi19/texera-cu-sklearn@sha256:bdeadc3c",
+    attempt: 1,
+    creationTime: 0,
+    updateTime: 0,
+    ...over,
+  });
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [AdminCuImageComponent, HttpClientTestingModule, 
NoopAnimationsModule],
+      providers: [
+        // Registered so nz-icon renders inline instead of fetching each SVG 
over HTTP.
+        {
+          provide: NZ_ICONS,
+          useValue: [PlusOutline, FileTextOutline, ReloadOutline, 
DeleteOutline, LoadingOutline],
+        },
+      ],
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    httpTestingController = TestBed.inject(HttpTestingController);
+    fixture = TestBed.createComponent(AdminCuImageComponent);
+    component = fixture.componentInstance;
+  });
+
+  /** Answers the load() that ngOnInit fires, so each test starts from a known 
list. */
+  const initWith = (images: CuImage[]) => {
+    fixture.detectChanges();
+    httpTestingController.expectOne(CU_IMAGE_URL).flush(images);
+    fixture.detectChanges();
+  };
+
+  it("should create", () => {
+    initWith([]);
+    expect(component).toBeTruthy();
+  });
+
+  it("shows the digest a unit will actually run, not just the tag", () => {
+    // A tag can be moved by its owner, so the digest is the only answer to 
"what runs?".
+    initWith([image()]);
+    const text = fixture.nativeElement.textContent;
+    expect(text).toContain("tagandhi19/texera-cu-sklearn@sha256:bdeadc3c");
+  });
+
+  it("says a deployment has the feature switched off rather than raising an 
error", () => {
+    // Shown as an error, an admin cannot tell an unset switch from a broken 
deployment.
+    fixture.detectChanges();
+    httpTestingController.expectOne(CU_IMAGE_URL).flush(null, { status: 503, 
statusText: "Service Unavailable" });
+    fixture.detectChanges();
+
+    expect(component.featureDisabled).toBe(true);
+    expect(fixture.nativeElement.textContent).toContain("not enabled on this 
deployment");
+  });
+
+  // The API orders by name, which scatters a just-registered image.
+  it("lists the newest image first, whatever its name", () => {
+    initWith([
+      image({ iid: 1, name: "Alpine", creationTime: 100 }),
+      image({ iid: 2, name: "Zebra", creationTime: 300 }),
+      image({ iid: 3, name: "Middle", creationTime: 200 }),
+    ]);
+    expect(component.images.map(i => i.iid)).toEqual([2, 3, 1]);
+  });
+
+  it("refuses to register an image with either field blank", () => {
+    initWith([]);
+    component.newName = "  ";
+    component.newSourceRef = "owner/name:1";
+    component.add();
+    httpTestingController.expectNone({ method: "POST" });
+  });
+
+  // ngOnInit directly, not via detectChanges: a timer scheduled from the 
fixture's NgZone
+  // lands on the real queue, where tick() cannot drive it.
+  it("polls while a check is running and asks for nothing once it settles", 
fakeAsync(() => {
+    // The regression this guards: an earlier version polled forever and 
ignored the answer.
+    component.ngOnInit();
+    httpTestingController
+      .expectOne(CU_IMAGE_URL)
+      .flush([image({ status: "VALIDATING", imageTag: null, sourceDigest: null 
})]);
+
+    tick(3000);
+    httpTestingController.expectOne(CU_IMAGE_URL).flush([image({ status: 
"READY" })]);
+    expect(component.images[0].status).toBe("READY");
+
+    // Settled now, so no further request is made at all.
+    tick(3000);
+    httpTestingController.expectNone(CU_IMAGE_URL);
+
+    discardPeriodicTasks();
+  }));
+
+  it("keeps polling after a request fails", fakeAsync(() => {
+    // An error reaching the outer stream ends the subscription for good, so 
one hiccup
+    // while a check is running would leave the row VALIDATING forever -- and 
Refresh is
+    // disabled in that state, so only a reload would recover it.
+    component.ngOnInit();
+    httpTestingController.expectOne(CU_IMAGE_URL).flush([image({ status: 
"VALIDATING" })]);
+
+    tick(3000);
+    httpTestingController.expectOne(CU_IMAGE_URL).flush(null, { status: 502, 
statusText: "Bad Gateway" });
+
+    tick(3000);
+    httpTestingController.expectOne(CU_IMAGE_URL).flush([image({ status: 
"READY" })]);
+    expect(component.images[0].status).toBe("READY");
+
+    discardPeriodicTasks();
+  }));
+
+  it("sends one request when Enter is pressed twice", () => {
+    // Enter calls add() directly, where the Add button's disabled state does 
not apply.
+    initWith([]);
+    component.newName = "Python ML";
+    component.newSourceRef = "owner/name:1";
+    component.add();
+    component.add();
+    httpTestingController.expectOne(req => req.method === "POST");
+  });
+
+  it("does not poll a deployment that has the feature switched off", 
fakeAsync(() => {
+    component.ngOnInit();
+    httpTestingController.expectOne(CU_IMAGE_URL).flush(null, { status: 503, 
statusText: "Service Unavailable" });
+    expect(component.featureDisabled).toBe(true);
+
+    // Otherwise the page asks for a 503 every three seconds.
+    tick(3000);
+    httpTestingController.expectNone(CU_IMAGE_URL);
+
+    discardPeriodicTasks();
+  }));
+
+  // Refresh is disabled only once load() returns, so a double-click gets two 
requests in
+  // before that -- two validation jobs, one of which may never be reaped.
+  it("sends one request when Refresh is clicked twice", () => {
+    initWith([image()]);
+    component.refresh(image());
+    component.refresh(image());
+    httpTestingController.expectOne(req => req.method === "POST" && 
req.url.endsWith("/refresh"));
+  });
+
+  // The second delete would 404 and pop "No curated image N" right after a 
successful one.
+  it("sends one request when Remove is clicked twice", () => {
+    initWith([image()]);
+    component.remove(image());
+    component.remove(image());
+    httpTestingController.expectOne(req => req.method === "DELETE");
+  });
+
+  // The regression this guards: switchMap cancelled any read slower than the 
interval, and
+  // a read is slowest while a check is running -- exactly when this polls.
+  it("does not cancel a slow poll at the next tick", fakeAsync(() => {
+    component.ngOnInit();
+    httpTestingController.expectOne(CU_IMAGE_URL).flush([image({ status: 
"VALIDATING" })]);
+
+    tick(3000);
+    const slow = httpTestingController.expectOne(CU_IMAGE_URL);
+
+    // A second tick passes while the first read is still outstanding.
+    tick(3000);
+    httpTestingController.expectNone(CU_IMAGE_URL);
+
+    slow.flush([image({ status: "READY" })]);
+    expect(component.images[0].status).toBe("READY");
+
+    discardPeriodicTasks();
+  }));
+
+  it("offers Refresh only when a check is not already running", () => {
+    initWith([image({ status: "VALIDATING" })]);
+    const refresh = 
Array.from(fixture.nativeElement.querySelectorAll("button") as 
NodeListOf<HTMLButtonElement>).find(
+      b => b.textContent?.includes("Refresh")
+    );
+    expect(refresh?.disabled).toBe(true);
+  });
+
+  it("colours a failed check apart from a ready one", () => {
+    expect(component.statusColor("READY")).toBe("green");
+    expect(component.statusColor("FAILED")).toBe("red");
+    expect(component.statusColor("VALIDATING")).toBe("blue");
+  });
+
+  afterEach(() => {
+    httpTestingController.verify();
+  });
+});
diff --git 
a/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.ts
 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.ts
new file mode 100644
index 0000000000..dd6e91b190
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/admin/cu-image/admin-cu-image.component.ts
@@ -0,0 +1,280 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { FormsModule } from "@angular/forms";
+import { NgIf, NgFor } from "@angular/common";
+import { HttpErrorResponse } from "@angular/common/http";
+import { NzCardComponent } from "ng-zorro-antd/card";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { ɵNzTransitionPatchDirective } from 
"ng-zorro-antd/core/transition-patch";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import { NzTableModule } from "ng-zorro-antd/table";
+import { NzTagComponent } from "ng-zorro-antd/tag";
+import { NzModalModule } from "ng-zorro-antd/modal";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm";
+import { NzAlertComponent } from "ng-zorro-antd/alert";
+import { EMPTY, timer } from "rxjs";
+import { catchError, exhaustMap, filter } from "rxjs/operators";
+import { CuImage, CuImageService, CuImageStatus, isInProgress } from 
"../../../service/admin/cu-image/cu-image.service";
+import { NotificationService } from 
"../../../../common/service/notification/notification.service";
+import { extractErrorMessage } from "../../../../common/util/error";
+
+/** Fast enough to feel live, slow enough not to hammer the API. */
+const CHECK_POLL_INTERVAL_MS = 3000;
+
+/** Enough of a digest to tell two apart; the tooltip carries the whole one. */
+const DIGEST_CHARACTERS_SHOWN = 12;
+
+@UntilDestroy()
+@Component({
+  selector: "texera-admin-cu-image",
+  templateUrl: "./admin-cu-image.component.html",
+  styleUrls: ["./admin-cu-image.component.scss"],
+  imports: [
+    FormsModule,
+    NgIf,
+    NgFor,
+    NzCardComponent,
+    NzButtonComponent,
+    NzWaveDirective,
+    ɵNzTransitionPatchDirective,
+    NzIconDirective,
+    NzInputDirective,
+    NzTableModule,
+    NzTagComponent,
+    NzModalModule,
+    NzTooltipDirective,
+    NzPopconfirmDirective,
+    NzAlertComponent,
+  ],
+})
+export class AdminCuImageComponent implements OnInit {
+  images: CuImage[] = [];
+  loading = false;
+
+  /** The API answers 503 when the deployment has curated images switched off. 
*/
+  featureDisabled = false;
+
+  newName = "";
+  newSourceRef = "";
+  submitting = false;
+
+  /** Images with a refresh or remove in flight, so a second click is ignored. 
*/
+  private readonly busy = new Set<number>();
+
+  logVisible = false;
+  logIid?: number;
+  logName = "";
+  logText = "";
+
+  constructor(
+    private cuImageService: CuImageService,
+    private notificationService: NotificationService
+  ) {}
+
+  ngOnInit(): void {
+    this.load();
+
+    // A check finishes on the cluster without telling anyone, so poll while 
one is
+    // running. The filter stops it once nothing is, on a page that is usually 
idle.
+    timer(CHECK_POLL_INTERVAL_MS, CHECK_POLL_INTERVAL_MS)
+      .pipe(
+        filter(() => this.anyInProgress(this.images)),
+        // exhaustMap, not switchMap: a read takes longest while a check is 
running, which
+        // is exactly when this polls, and switchMap would cancel each one at 
the next tick.
+        // Caught inside the projection: an error reaching the outer stream 
would end the
+        // subscription, and polling would never resume.
+        exhaustMap(() => this.cuImageService.list().pipe(catchError(() => 
EMPTY))),
+        untilDestroyed(this)
+      )
+      .subscribe(images => {
+        this.images = this.newestFirst(images);
+        // Keep an open log in step with the check it is showing.
+        if (this.logVisible && this.logIid !== undefined) {
+          this.loadLog(this.logIid, false);
+        }
+      });
+  }
+
+  private anyInProgress(images: CuImage[]): boolean {
+    return images.some(isInProgress);
+  }
+
+  /**
+   * Newest first, so a just-registered image is at the top. Sorted here 
rather than in the
+   * API because the same endpoint feeds the unit dropdown, where by-name is 
the useful order.
+   */
+  private newestFirst(images: CuImage[]): CuImage[] {
+    return [...images].sort((a, b) => b.creationTime - a.creationTime);
+  }
+
+  load(): void {
+    this.loading = true;
+    this.cuImageService
+      .list()
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: images => {
+          this.images = this.newestFirst(images);
+          this.featureDisabled = false;
+          this.loading = false;
+        },
+        error: (err: unknown) => {
+          this.loading = false;
+          if (err instanceof HttpErrorResponse && err.status === 503) {
+            this.featureDisabled = true;
+            return;
+          }
+          this.notificationService.error(`Could not load images: 
${extractErrorMessage(err)}`);
+        },
+      });
+  }
+
+  add(): void {
+    // Enter in either field calls this directly, where the button's disabled 
state does not
+    // apply -- two quick presses would otherwise send the same image twice.
+    if (this.submitting) {
+      return;
+    }
+    const name = this.newName.trim();
+    const sourceRef = this.newSourceRef.trim();
+    if (name === "" || sourceRef === "") {
+      this.notificationService.error("Both a name and an image reference are 
required");
+      return;
+    }
+    this.submitting = true;
+    this.cuImageService
+      .create(name, sourceRef)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: () => {
+          this.submitting = false;
+          this.newName = "";
+          this.newSourceRef = "";
+          this.load();
+        },
+        error: (err: unknown) => {
+          this.submitting = false;
+          this.notificationService.error(extractErrorMessage(err));
+        },
+      });
+  }
+
+  /** Re-checks the same reference: picks up a moved tag, retries a failed 
check. */
+  refresh(image: CuImage): void {
+    if (this.busy.has(image.iid)) {
+      return;
+    }
+    this.busy.add(image.iid);
+    this.cuImageService
+      .refresh(image.iid)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: () => this.finish(image.iid),
+        error: (err: unknown) => this.fail(image.iid, err),
+      });
+  }
+
+  remove(image: CuImage): void {
+    if (this.busy.has(image.iid)) {
+      return;
+    }
+    this.busy.add(image.iid);
+    this.cuImageService
+      .delete(image.iid)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: () => this.finish(image.iid),
+        error: (err: unknown) => this.fail(image.iid, err),
+      });
+  }
+
+  private finish(iid: number): void {
+    this.busy.delete(iid);
+    this.load();
+  }
+
+  private fail(iid: number, err: unknown): void {
+    this.busy.delete(iid);
+    this.notificationService.error(extractErrorMessage(err));
+  }
+
+  showLog(image: CuImage): void {
+    this.logIid = image.iid;
+    this.logName = image.name;
+    this.logText = "";
+    this.logVisible = true;
+    this.loadLog(image.iid, true);
+  }
+
+  closeLog(): void {
+    this.logVisible = false;
+    this.logIid = undefined;
+  }
+
+  /** `report` is false when polling, so a blip does not raise a toast. */
+  private loadLog(iid: number, report: boolean): void {
+    this.cuImageService
+      .log(iid)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: log => {
+          if (this.logVisible && this.logIid === iid) {
+            this.logText = log.log;
+          }
+        },
+        error: (err: unknown) => {
+          if (report) {
+            this.notificationService.error(`Could not load the log: 
${extractErrorMessage(err)}`);
+          }
+        },
+      });
+  }
+
+  statusColor(status: CuImageStatus): string {
+    switch (status) {
+      case "READY":
+        return "green";
+      case "FAILED":
+        return "red";
+      default:
+        return "blue";
+    }
+  }
+
+  /** Whether a check is still running. */
+  inProgress(image: CuImage): boolean {
+    return isInProgress(image);
+  }
+
+  /** A 64-character digest would wrap over several lines and crowd the row. */
+  shortRef(imageTag: string): string {
+    const at = imageTag.indexOf("@sha256:");
+    if (at < 0) {
+      return imageTag;
+    }
+    const shown = DIGEST_CHARACTERS_SHOWN + "@sha256:".length;
+    return imageTag.length <= at + shown ? imageTag : `${imageTag.slice(0, at 
+ shown)}…`;
+  }
+}
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html 
b/frontend/src/app/dashboard/component/dashboard.component.html
index 0b1c7a1d56..d12a061193 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.html
+++ b/frontend/src/app/dashboard/component/dashboard.component.html
@@ -208,6 +208,17 @@
               nzType="edit"></span>
             <span>Settings</span>
           </li>
+          <li
+            nz-menu-item
+            nz-tooltip="Images a computing unit can be started from"
+            nzMatchRouter="true"
+            nzTooltipPlacement="right"
+            [routerLink]="ADMIN_CU_IMAGE">
+            <span
+              nz-icon
+              nzType="appstore"></span>
+            <span>CU Images</span>
+          </li>
         </ul>
       </li>
 
diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts 
b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
index 87ebe64b62..d9d564ede8 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
@@ -47,6 +47,7 @@ import {
   ADMIN_EXECUTION,
   ADMIN_GMAIL,
   ADMIN_SETTINGS,
+  ADMIN_CU_IMAGE,
   ADMIN_USER,
   USER_COMPUTING_UNIT,
   USER_DATASET,
@@ -270,6 +271,7 @@ describe("DashboardComponent", () => {
     expect(ADMIN_EXECUTION).toBe("/admin/execution");
     expect(ADMIN_GMAIL).toBe("/admin/gmail");
     expect(ADMIN_SETTINGS).toBe("/admin/settings");
+    expect(ADMIN_CU_IMAGE).toBe("/admin/cu-image");
     expect(ABOUT).toBe("/about");
   });
 
@@ -294,9 +296,9 @@ describe("DashboardComponent", () => {
     };
     fixture.detectChanges();
 
-    // 7 "Your Work" links (incl. Python Venvs and Models) + 4 admin links + 1 
about link
-    // + 1 feedback link = 13
-    
expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(13);
+    // 7 "Your Work" links (incl. Python Venvs and Models) + 5 admin links 
(incl. CU Images)
+    // + 1 about link + 1 feedback link = 14
+    
expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(14);
   });
 
   describe("warehouse tab gating (#6933)", () => {
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts 
b/frontend/src/app/dashboard/component/dashboard.component.ts
index 98d0238514..33f6c37bc9 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -32,6 +32,7 @@ import {
   ADMIN_EXECUTION,
   ADMIN_GMAIL,
   ADMIN_SETTINGS,
+  ADMIN_CU_IMAGE,
   ADMIN_USER,
   USER_COMPUTING_UNIT,
   USER_DATASET,
@@ -130,6 +131,7 @@ export class DashboardComponent implements OnInit {
   protected readonly ADMIN_GMAIL = ADMIN_GMAIL;
   protected readonly ADMIN_EXECUTION = ADMIN_EXECUTION;
   protected readonly ADMIN_SETTINGS = ADMIN_SETTINGS;
+  protected readonly ADMIN_CU_IMAGE = ADMIN_CU_IMAGE;
   protected readonly ABOUT = ABOUT;
   protected readonly String = String;
 
diff --git 
a/frontend/src/app/dashboard/service/admin/cu-image/cu-image.service.ts 
b/frontend/src/app/dashboard/service/admin/cu-image/cu-image.service.ts
new file mode 100644
index 0000000000..07b42708f9
--- /dev/null
+++ b/frontend/src/app/dashboard/service/admin/cu-image/cu-image.service.ts
@@ -0,0 +1,86 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable } from "@angular/core";
+import { HttpClient } from "@angular/common/http";
+import { Observable } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+
+const CU_IMAGE_BASE_URL = `${AppSettings.getApiEndpoint()}/cu-image`;
+
+/** PENDING and VALIDATING mean the check is running; only READY can back a 
unit. */
+export type CuImageStatus = "PENDING" | "VALIDATING" | "READY" | "FAILED";
+
+export interface CuImage {
+  iid: number;
+  /** What users see in the computing-unit dropdown. */
+  name: string;
+  /** The reference the administrator gave, normalised. */
+  sourceRef: string;
+  /** The digest sourceRef resolved to. Null until a check succeeds. */
+  sourceDigest: string | null;
+  status: CuImageStatus;
+  /** sourceRef pinned to its digest: what a unit actually runs. */
+  imageTag: string | null;
+  /** Numbers this image's checks, so a retry is told apart from the one 
before. */
+  attempt: number;
+  creationTime: number;
+  updateTime: number;
+}
+
+export interface CuImageValidationLog {
+  iid: number;
+  status: CuImageStatus;
+  attempt: number;
+  log: string;
+}
+
+/** Whether the list is worth polling. */
+export function isInProgress(image: CuImage): boolean {
+  return image.status === "PENDING" || image.status === "VALIDATING";
+}
+
+@Injectable({ providedIn: "root" })
+export class CuImageService {
+  constructor(private http: HttpClient) {}
+
+  /** Readable by any signed-in user: the unit dropdown is built from it. Rest 
is admin-only. */
+  list(): Observable<CuImage[]> {
+    return this.http.get<CuImage[]>(CU_IMAGE_BASE_URL);
+  }
+
+  /** Registering starts the first check. */
+  create(name: string, sourceRef: string): Observable<CuImage> {
+    return this.http.post<CuImage>(CU_IMAGE_BASE_URL, { name, sourceRef });
+  }
+
+  /** Checks the same reference again: picks up a moved tag, retries a failed 
check. */
+  refresh(iid: number): Observable<CuImage> {
+    return this.http.post<CuImage>(`${CU_IMAGE_BASE_URL}/${iid}/refresh`, {});
+  }
+
+  /** The check's output, where a rejection explains itself. */
+  log(iid: number): Observable<CuImageValidationLog> {
+    return 
this.http.get<CuImageValidationLog>(`${CU_IMAGE_BASE_URL}/${iid}/log`);
+  }
+
+  delete(iid: number): Observable<void> {
+    return this.http.delete<void>(`${CU_IMAGE_BASE_URL}/${iid}`);
+  }
+}

Reply via email to