You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/07/27 01:20:30 UTC

(airavata-custos) 14/18: Show the cluster username in the access status panel

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

lahirujayathilake pushed a commit to branch pearc26
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 9da7089614e798c61d55e600871d94bb3ce51be3
Author: lahiruj <la...@gmail.com>
AuthorDate: Sat Jul 25 04:01:39 2026 -0400

    Show the cluster username in the access status panel
---
 internal/server/access_status.go                   | 28 +++++++-----
 internal/server/access_status_integration_test.go  | 38 ++++++++++++++++
 pkg/service/access_status.go                       | 51 ++++++++++++++++------
 .../__tests__/AllocationAccessBand.test.tsx        |  3 ++
 .../components/AllocationAccessBand.tsx            | 15 +++++--
 web/src/features/core/access-status/schemas.ts     |  2 +
 6 files changed, 108 insertions(+), 29 deletions(-)

diff --git a/internal/server/access_status.go b/internal/server/access_status.go
index 5ddfc697c..0997eeca5 100644
--- a/internal/server/access_status.go
+++ b/internal/server/access_status.go
@@ -51,15 +51,17 @@ type accessCheckEventResponse struct {
 }
 
 type accessStatusResponse struct {
-	Checks []accessCheckResponse      `json:"checks"`
-	Events []accessCheckEventResponse `json:"events"`
+	Checks        []accessCheckResponse      `json:"checks"`
+	Events        []accessCheckEventResponse `json:"events"`
+	LocalUsername string                     `json:"local_username,omitempty"`
 }
 
 type memberAccessStatusResponse struct {
-	UserID      string                `json:"user_id"`
-	DisplayName string                `json:"display_name"`
-	Email       string                `json:"email"`
-	Checks      []accessCheckResponse `json:"checks"`
+	UserID        string                `json:"user_id"`
+	DisplayName   string                `json:"display_name"`
+	Email         string                `json:"email"`
+	LocalUsername string                `json:"local_username,omitempty"`
+	Checks        []accessCheckResponse `json:"checks"`
 }
 
 type allocationAccessMembersResponse struct {
@@ -87,10 +89,11 @@ func (s *Server) getAllocationAccessStatus(w http.ResponseWriter, r *http.Reques
 		now := time.Now().UTC()
 		for _, m := range rows {
 			resp.Members = append(resp.Members, memberAccessStatusResponse{
-				UserID:      m.UserID,
-				DisplayName: m.DisplayName,
-				Email:       m.Email,
-				Checks:      s.checkResponses(m.Checks, now),
+				UserID:        m.UserID,
+				DisplayName:   m.DisplayName,
+				Email:         m.Email,
+				LocalUsername: m.LocalUsername,
+				Checks:        s.checkResponses(m.Checks, now),
 			})
 		}
 		common.WriteJSON(w, http.StatusOK, resp)
@@ -104,8 +107,9 @@ func (s *Server) getAllocationAccessStatus(w http.ResponseWriter, r *http.Reques
 	}
 	now := time.Now().UTC()
 	resp := accessStatusResponse{
-		Checks: s.checkResponses(status.Checks, now),
-		Events: make([]accessCheckEventResponse, 0, len(status.Events)),
+		Checks:        s.checkResponses(status.Checks, now),
+		Events:        make([]accessCheckEventResponse, 0, len(status.Events)),
+		LocalUsername: status.LocalUsername,
 	}
 	typeByCheckID := make(map[string]models.AccessCheckType, len(status.Checks))
 	for _, c := range status.Checks {
diff --git a/internal/server/access_status_integration_test.go b/internal/server/access_status_integration_test.go
index c55a2adf8..7db2db36c 100644
--- a/internal/server/access_status_integration_test.go
+++ b/internal/server/access_status_integration_test.go
@@ -119,6 +119,7 @@ func TestAccessStatus_SelfViewWithDerivedAndRealChecks(t *testing.T) {
 			CheckType string `json:"check_type"`
 			EventType string `json:"event_type"`
 		} `json:"events"`
+		LocalUsername string `json:"local_username"`
 	}
 	if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
 		t.Fatalf("decode: %v", err)
@@ -143,6 +144,43 @@ func TestAccessStatus_SelfViewWithDerivedAndRealChecks(t *testing.T) {
 	}
 }
 
+func TestAccessStatus_CarriesLocalUsername(t *testing.T) {
+	fx := setupAccessStatusFixture(t)
+	var clusterID string
+	if err := fx.db.Get(&clusterID,
+		`SELECT compute_cluster_id FROM compute_allocations WHERE id = ?`, fx.allocID); err != nil {
+		t.Fatalf("cluster id: %v", err)
+	}
+	if _, err := fx.db.Exec(
+		`INSERT INTO compute_cluster_users (id, compute_cluster_id, user_id, local_username, provisioned_at)
+		 VALUES (?, ?, ?, 'memberlocal', NOW(6))`, uuid.NewString(), clusterID, fx.member); err != nil {
+		t.Fatalf("seed cluster user: %v", err)
+	}
+
+	rr := fx.get(t, "/compute-allocations/"+fx.allocID+"/access-status", fx.member)
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d", rr.Code)
+	}
+	var got struct {
+		LocalUsername string `json:"local_username"`
+		Checks        []struct {
+			Type    string `json:"type"`
+			UIState string `json:"ui_state"`
+		} `json:"checks"`
+	}
+	if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if got.LocalUsername != "memberlocal" {
+		t.Errorf("local_username: got %q, want memberlocal", got.LocalUsername)
+	}
+	for _, c := range got.Checks {
+		if c.Type == "SIGN_IN" && c.UIState != "ok" {
+			t.Errorf("provisioned account should derive sign-in ok, got %q", c.UIState)
+		}
+	}
+}
+
 func TestAccessStatus_StuckDerivation(t *testing.T) {
 	fx := setupAccessStatusFixture(t)
 	rec := func(ok bool) {
diff --git a/pkg/service/access_status.go b/pkg/service/access_status.go
index dea3189ed..2a0b933db 100644
--- a/pkg/service/access_status.go
+++ b/pkg/service/access_status.go
@@ -31,18 +31,21 @@ import (
 const DefaultAccessCheckStuckAfter = time.Hour
 
 // AccessStatus is a user's checks on one allocation plus their milestone
-// history, newest first.
+// history, newest first. LocalUsername is the account the user signs in
+// with on the allocation's cluster, empty until one is assigned.
 type AccessStatus struct {
-	Checks []models.AccessCheck
-	Events []models.AccessCheckEvent
+	Checks        []models.AccessCheck
+	Events        []models.AccessCheckEvent
+	LocalUsername string
 }
 
 // MemberAccessStatus is one allocation member's checks, for the manager view.
 type MemberAccessStatus struct {
-	UserID      string
-	DisplayName string
-	Email       string
-	Checks      []models.AccessCheck
+	UserID        string
+	DisplayName   string
+	Email         string
+	LocalUsername string
+	Checks        []models.AccessCheck
 }
 
 // SetAccessCheckStuckAfter overrides the failing-to-stuck threshold.
@@ -182,12 +185,17 @@ func (s *Service) AccessStatusForUser(ctx context.Context, allocationID, userID
 	if err != nil {
 		return nil, err
 	}
-	checks, realIDs := s.fillDefaultChecks(ctx, alloc, allocationID, userID, checks)
+	cu := s.clusterUserFor(ctx, alloc, userID)
+	checks, realIDs := fillDefaultChecks(allocationID, userID, cu, checks)
 	events, err := s.accessChecks.FindEventsByChecks(ctx, realIDs)
 	if err != nil {
 		return nil, err
 	}
-	return &AccessStatus{Checks: checks, Events: events}, nil
+	status := &AccessStatus{Checks: checks, Events: events}
+	if cu != nil {
+		status.LocalUsername = cu.LocalUsername
+	}
+	return status, nil
 }
 
 // AccessStatusForAllocation returns every ACTIVE member's checks, for the
@@ -217,20 +225,35 @@ func (s *Service) AccessStatusForAllocation(ctx context.Context, allocationID st
 		if m.MembershipStatus != models.ACTIVE {
 			continue
 		}
-		mc, _ := s.fillDefaultChecks(ctx, alloc, allocationID, m.UserID, byUser[m.UserID])
-		out = append(out, MemberAccessStatus{
+		cu := s.clusterUserFor(ctx, alloc, m.UserID)
+		mc, _ := fillDefaultChecks(allocationID, m.UserID, cu, byUser[m.UserID])
+		row := MemberAccessStatus{
 			UserID:      m.UserID,
 			DisplayName: m.DisplayName,
 			Email:       m.Email,
 			Checks:      mc,
-		})
+		}
+		if cu != nil {
+			row.LocalUsername = cu.LocalUsername
+		}
+		out = append(out, row)
 	}
 	return out, nil
 }
 
+// clusterUserFor returns the user's account on the allocation's cluster, or
+// nil when none is assigned yet (or the lookup fails, which reads the same).
+func (s *Service) clusterUserFor(ctx context.Context, alloc *models.ComputeAllocation, userID string) *models.ComputeClusterUser {
+	cu, err := s.clusterUsers.FindByPair(ctx, alloc.ComputeClusterID, userID)
+	if err != nil {
+		return nil
+	}
+	return cu
+}
+
 // fillDefaultChecks appends synthetic checks (empty ID, no history) for the
 // types no probe has reported yet, and returns the real check ids.
-func (s *Service) fillDefaultChecks(ctx context.Context, alloc *models.ComputeAllocation, allocationID, userID string, checks []models.AccessCheck) ([]models.AccessCheck, []string) {
+func fillDefaultChecks(allocationID, userID string, cu *models.ComputeClusterUser, checks []models.AccessCheck) ([]models.AccessCheck, []string) {
 	have := make(map[models.AccessCheckType]bool, len(checks))
 	realIDs := make([]string, 0, len(checks))
 	for _, c := range checks {
@@ -244,7 +267,7 @@ func (s *Service) fillDefaultChecks(ctx context.Context, alloc *models.ComputeAl
 			CheckType: models.AccessCheckSignIn, Status: models.AccessCheckPending,
 			LastCheckedAt: now,
 		}
-		if cu, err := s.clusterUsers.FindByPair(ctx, alloc.ComputeClusterID, userID); err == nil && cu != nil && cu.ProvisionedAt != nil {
+		if cu != nil && cu.ProvisionedAt != nil {
 			c.Status = models.AccessCheckOK
 			c.LastCheckedAt = *cu.ProvisionedAt
 			c.LastOKAt = cu.ProvisionedAt
diff --git a/web/src/features/core/access-status/__tests__/AllocationAccessBand.test.tsx b/web/src/features/core/access-status/__tests__/AllocationAccessBand.test.tsx
index a6afd8934..054d12a34 100644
--- a/web/src/features/core/access-status/__tests__/AllocationAccessBand.test.tsx
+++ b/web/src/features/core/access-status/__tests__/AllocationAccessBand.test.tsx
@@ -57,6 +57,7 @@ describe("<AllocationAccessBand />", () => {
     renderBand({
       checks: [check("SIGN_IN", "ok"), check("JOB_SUBMISSION", "ok")],
       events: [{ check_type: "SIGN_IN", event_type: "ONLINE", occurred_at: now }],
+      local_username: "lahirujay",
     });
     expect(screen.getByText("Your access is live")).toBeInTheDocument();
     expect(screen.queryByText("Cluster sign-in")).not.toBeInTheDocument();
@@ -64,6 +65,8 @@ describe("<AllocationAccessBand />", () => {
     fireEvent.click(screen.getByRole("button", { name: "View checks" }));
     expect(screen.getByText("Cluster sign-in")).toBeInTheDocument();
     expect(screen.getByText("Job submission")).toBeInTheDocument();
+    expect(screen.getByText("lahirujay")).toBeInTheDocument();
+    expect(screen.getByText(/Signing in over SSH as/)).toBeInTheDocument();
     expect(screen.getByText("Cluster sign-in came online")).toBeInTheDocument();
   });
 
diff --git a/web/src/features/core/access-status/components/AllocationAccessBand.tsx b/web/src/features/core/access-status/components/AllocationAccessBand.tsx
index 804210f52..6193be674 100644
--- a/web/src/features/core/access-status/components/AllocationAccessBand.tsx
+++ b/web/src/features/core/access-status/components/AllocationAccessBand.tsx
@@ -105,7 +105,7 @@ export function AllocationAccessBand({
           <p className="text-sm text-muted-foreground">{verdictBody(overall, checks)}</p>
           <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
             {checks.map((c) => (
-              <CheckRow key={c.type} check={c} />
+              <CheckRow key={c.type} check={c} localUsername={query.data?.local_username} />
             ))}
           </div>
           {events.length > 0 ? <Timeline events={events} /> : null}
@@ -115,13 +115,22 @@ export function AllocationAccessBand({
   );
 }
 
-function CheckRow({ check }: { check: AccessCheck }) {
+function CheckRow({ check, localUsername }: { check: AccessCheck; localUsername?: string | null }) {
   const chip = CHECK_CHIP[check.ui_state];
   return (
     <div className="flex items-start justify-between gap-3 rounded-lg border border-border p-3">
       <div className="space-y-0.5">
         <div className="text-sm font-medium">{CHECK_NAME[check.type]}</div>
-        <div className="text-xs text-muted-foreground">{CHECK_DESCRIPTION[check.type]}</div>
+        <div className="text-xs text-muted-foreground">
+          {check.type === "SIGN_IN" && localUsername ? (
+            <>
+              Signing in over SSH as{" "}
+              <code className="rounded bg-muted px-1 font-mono text-[11px]">{localUsername}</code>.
+            </>
+          ) : (
+            CHECK_DESCRIPTION[check.type]
+          )}
+        </div>
         {check.ui_state === "retrying" ? (
           <div className="text-xs text-[color:var(--tone-warn-fg)]">
             Retrying automatically. Usually clears within a few minutes.
diff --git a/web/src/features/core/access-status/schemas.ts b/web/src/features/core/access-status/schemas.ts
index b885dc7f2..bde4a000d 100644
--- a/web/src/features/core/access-status/schemas.ts
+++ b/web/src/features/core/access-status/schemas.ts
@@ -43,6 +43,7 @@ export type AccessCheckEvent = z.infer<typeof accessCheckEventSchema>;
 export const accessStatusSchema = z.object({
   checks: z.array(accessCheckSchema),
   events: z.array(accessCheckEventSchema),
+  local_username: z.string().nullish(),
 });
 export type AccessStatus = z.infer<typeof accessStatusSchema>;
 
@@ -50,6 +51,7 @@ export const memberAccessStatusSchema = z.object({
   user_id: z.string(),
   display_name: z.string(),
   email: z.string(),
+  local_username: z.string().nullish(),
   checks: z.array(accessCheckSchema),
 });
 export type MemberAccessStatus = z.infer<typeof memberAccessStatusSchema>;