fix(catalyst): qa-loop iter-7 Cluster — KC group idempotency + apps env chip + dashboard breadcrumb (Fix #38)
Three independent regressions surfaced by qa-loop iter-7 against
omantel.biz, all closed in a single PR per the brief's "ONE PR with
all 3 fixes" mandate.
TC-141 — Keycloak group create idempotency
- HandleKeycloakGroupsCreate now treats keycloak.ErrGroupAlreadyExists
(raised on KC's 409 Conflict) as success: re-fetches the existing
group via FindGroupByPath (top-level) or parent's children list
(sub-group) and returns 201 with the canonical representation.
- Exported ErrGroupAlreadyExists from internal/keycloak so handlers
can detect the sentinel without depending on string matching;
kept errGroupAlreadyExists as an alias so EnsureGroup + existing
package tests compile unchanged.
- Added FindGroupByPath to the KeycloakAdminClient interface so the
handler-side recovery path is testable via the existing fake.
- Three new handler tests cover the top-level + sub-group + 502-on-
resolve-empty branches.
TC-090 — AppsPage environment chip
- Added Environment field to sovereignAppItem; the BE handler now
lists apps.openova.io/v1 Application CRs and joins by slug onto
the existing apps response. Falls back to defaultSovereignEnvironment
("dev") when no Application CR matches — single-environment
Sovereigns (the common case) always render a chip.
- Added .chip-env to the AppsPage CSS + per-card environment chip
rendered first in .app-chips so the chip is impossible to miss.
- FE caches environmentById from the live /sovereign/apps response;
DEFAULT_APP_ENVIRONMENT mirrors the BE constant so cold loads
still render a chip.
- Three new BE tests cover: default-dev fallback, CR-driven
environment, helper fallback order.
TC-383 — DashboardPage breadcrumb restoring "Dashboard" literal
- Added a <nav aria-label="Breadcrumb"> above the H1 with
"Dashboard / Sovereign Fleet" so the EPIC-6 redesign keeps its
"Sovereign Fleet" title while the matrix's anti-regression
contract (page MUST contain "Dashboard") stays satisfied.
- New DashboardPage.test.tsx asserts: literal "Dashboard" text in
the breadcrumb, H1 unchanged, ARIA labelling correct,
aria-current=page on the leaf.
Quality:
- All three fixes are target-state per feedback_no_mvp_no_workarounds.md
— no "for now", no deferral, no scope narrowing. Each closes the
matrix row in full, with unit tests covering the path.
- No local builds (Go/npm/helm/docker) per
feedback_machine_saturation_3rd_violation.md — CI is the only
build path.
Closes qa-loop iter-7 TC-141, TC-090, TC-383.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1cbbca83b9
commit
764f5320c8
@ -144,9 +144,15 @@ type KeycloakAdminClient interface {
|
||||
|
||||
// ListGroups — GET /admin/realms/{realm}/groups
|
||||
ListGroups(ctx context.Context) ([]keycloak.Group, error)
|
||||
// CreateGroup — top-level POST.
|
||||
// CreateGroup — top-level POST. Returns
|
||||
// keycloak.ErrGroupAlreadyExists on 409 so the handler can recover
|
||||
// the existing group via FindGroupByPath and respond idempotently
|
||||
// (qa-loop iter-7 TC-141).
|
||||
CreateGroup(ctx context.Context, g keycloak.Group) (string, error)
|
||||
// CreateSubGroup — POST /admin/realms/{realm}/groups/{parentUuid}/children.
|
||||
// Returns keycloak.ErrGroupAlreadyExists on 409 so the handler can
|
||||
// recover the existing sub-group from the parent's children list
|
||||
// and respond idempotently.
|
||||
CreateSubGroup(ctx context.Context, parentUUID string, g keycloak.Group) (string, error)
|
||||
// UpdateGroup — PUT (g.ID required).
|
||||
UpdateGroup(ctx context.Context, g keycloak.Group) error
|
||||
@ -154,6 +160,11 @@ type KeycloakAdminClient interface {
|
||||
DeleteGroup(ctx context.Context, uuid string) error
|
||||
// GetGroup — used after Create to fetch back the freshly-created group.
|
||||
GetGroup(ctx context.Context, uuid string) (keycloak.Group, error)
|
||||
// FindGroupByPath — GET /admin/realms/{realm}/group-by-path/{path}.
|
||||
// Used by HandleKeycloakGroupsCreate's idempotency path to recover
|
||||
// the existing group's representation when CreateGroup returns
|
||||
// ErrGroupAlreadyExists. Returns the empty Group + nil error on miss.
|
||||
FindGroupByPath(ctx context.Context, path string) (keycloak.Group, error)
|
||||
|
||||
// ListRealmRoles — GET /admin/realms/{realm}/roles.
|
||||
ListRealmRoles(ctx context.Context) ([]keycloak.RealmRole, error)
|
||||
@ -315,15 +326,52 @@ func (h *Handler) HandleKeycloakGroupsCreate(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
g := keycloak.Group{Name: body.Name, Attributes: body.Attributes}
|
||||
parentID := strings.TrimSpace(body.ParentID)
|
||||
var (
|
||||
uuid string
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(body.ParentID) != "" {
|
||||
uuid, err = kc.CreateSubGroup(r.Context(), body.ParentID, g)
|
||||
if parentID != "" {
|
||||
uuid, err = kc.CreateSubGroup(r.Context(), parentID, g)
|
||||
} else {
|
||||
uuid, err = kc.CreateGroup(r.Context(), g)
|
||||
}
|
||||
// Idempotency (qa-loop iter-7 TC-141): a second POST of the same
|
||||
// group name MUST recover the existing group and return success,
|
||||
// not bubble Keycloak's 409 up as a 502. Without this, every
|
||||
// re-run of the QA matrix breaks once iter-1 has populated the
|
||||
// realm. Same shape callers see on first create, just with the
|
||||
// pre-existing UUID.
|
||||
if errors.Is(err, keycloak.ErrGroupAlreadyExists) {
|
||||
existing, lookupErr := h.lookupExistingGroup(r.Context(), kc, parentID, body.Name)
|
||||
if lookupErr != nil {
|
||||
h.log.Warn("keycloak.groups.create: 409 but re-find failed", "depId", depID, "name", body.Name, "err", lookupErr)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "keycloak-create-conflict-resolve-failed",
|
||||
"detail": lookupErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if existing.ID == "" {
|
||||
// 409 from Keycloak but the group can't be re-found — KC
|
||||
// state contradicts itself. Surface as 502 so the operator
|
||||
// gets a clear signal something's wrong upstream.
|
||||
h.log.Warn("keycloak.groups.create: 409 but path-resolve empty", "depId", depID, "name", body.Name)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "keycloak-create-conflict-resolve-empty",
|
||||
"detail": "Keycloak returned 409 but the existing group could not be resolved",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Return 201 with the existing group's representation. POST is
|
||||
// modelled as upsert here — same status code on first create
|
||||
// AND idempotent re-create — so callers don't need to branch
|
||||
// on the response status to know the group exists. The body
|
||||
// always carries the canonical KC group shape (id/name/path/
|
||||
// attributes), which is what every caller actually consumes.
|
||||
writeJSON(w, http.StatusCreated, kcGroupToResult(existing))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.log.Warn("keycloak.groups.create: failed", "depId", depID, "err", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
@ -345,6 +393,43 @@ func (h *Handler) HandleKeycloakGroupsCreate(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusCreated, kcGroupToResult(created))
|
||||
}
|
||||
|
||||
// lookupExistingGroup resolves the Keycloak group representation for
|
||||
// the (parentID, name) pair returned by HandleKeycloakGroupsCreate's
|
||||
// 409 path. For top-level groups (parentID empty) it uses
|
||||
// /group-by-path/{name} — Keycloak's canonical "find by path" endpoint.
|
||||
// For sub-groups it walks the parent's GET response and matches by
|
||||
// name on the SubGroups slice.
|
||||
//
|
||||
// This is the find half of the find-or-create idempotency contract
|
||||
// the HTTP API exposes. The keycloak package's EnsureGroup uses the
|
||||
// same shape for the controller-driven path; this function is the
|
||||
// HTTP-handler-driven equivalent so both call sites share the same
|
||||
// guarantees end-users see (qa-loop iter-7 TC-141).
|
||||
func (h *Handler) lookupExistingGroup(ctx context.Context, kc KeycloakAdminClient, parentID, name string) (keycloak.Group, error) {
|
||||
if strings.TrimSpace(parentID) == "" {
|
||||
// Top-level group: /group-by-path/{name} returns the canonical
|
||||
// representation including UUID, attributes, and full path.
|
||||
return kc.FindGroupByPath(ctx, "/"+name)
|
||||
}
|
||||
// Sub-group: KC's GET /groups/{parentId} returns the parent with
|
||||
// its children inline. Walk the SubGroups slice to find the leaf.
|
||||
parent, err := kc.GetGroup(ctx, parentID)
|
||||
if err != nil {
|
||||
return keycloak.Group{}, err
|
||||
}
|
||||
for _, child := range parent.SubGroups {
|
||||
if child.Name == name {
|
||||
return child, nil
|
||||
}
|
||||
}
|
||||
// Sub-group missing under parent — return empty (not an error).
|
||||
// Caller treats empty UUID as "couldn't resolve" and 502s; this
|
||||
// shouldn't happen in practice because Keycloak's 409 implies the
|
||||
// child IS present, but an empty return is safer than fabricating
|
||||
// a missing UUID.
|
||||
return keycloak.Group{}, nil
|
||||
}
|
||||
|
||||
// HandleKeycloakGroupsUpdate — PUT /api/v1/sovereigns/{id}/keycloak/groups/{groupId}
|
||||
//
|
||||
// Replaces the group's attributes. Name remains immutable per KC
|
||||
|
||||
@ -49,6 +49,13 @@ type fakeKCAdminClient struct {
|
||||
// DeleteGroup
|
||||
deleteErr error
|
||||
deletedID string
|
||||
// FindGroupByPath — used by the idempotency path of
|
||||
// HandleKeycloakGroupsCreate when CreateGroup returns
|
||||
// keycloak.ErrGroupAlreadyExists. The handler MUST recover the
|
||||
// existing group representation rather than 502'ing.
|
||||
findByPathGroup keycloak.Group
|
||||
findByPathErr error
|
||||
lastFindByPathPath string
|
||||
// ListRealmRoles
|
||||
roles []keycloak.RealmRole
|
||||
listRolesErr error
|
||||
@ -114,6 +121,14 @@ func (f *fakeKCAdminClient) GetGroup(_ context.Context, _ string) (keycloak.Grou
|
||||
return f.getGroup, nil
|
||||
}
|
||||
|
||||
func (f *fakeKCAdminClient) FindGroupByPath(_ context.Context, path string) (keycloak.Group, error) {
|
||||
f.lastFindByPathPath = path
|
||||
if f.findByPathErr != nil {
|
||||
return keycloak.Group{}, f.findByPathErr
|
||||
}
|
||||
return f.findByPathGroup, nil
|
||||
}
|
||||
|
||||
func (f *fakeKCAdminClient) ListRealmRoles(_ context.Context) ([]keycloak.RealmRole, error) {
|
||||
if f.listRolesErr != nil {
|
||||
return nil, f.listRolesErr
|
||||
@ -378,6 +393,120 @@ func TestKeycloakGroupsCreate_SubGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeycloakGroupsCreate_IdempotentTopLevel proves the qa-loop iter-7
|
||||
// TC-141 fix: a SECOND POST of the same group name MUST recover the
|
||||
// existing group's representation and return 201, NOT bubble Keycloak's
|
||||
// 409 up as a 502. This is the canonical idempotency contract for the
|
||||
// HTTP API surface (mirrors keycloak.EnsureGroup's controller-side
|
||||
// contract).
|
||||
//
|
||||
// Sequence under test:
|
||||
// 1. POST {"name":"qa-test-group"} → 201, body {id, name, path}
|
||||
// 2. POST {"name":"qa-test-group"} again → 201, SAME id in body
|
||||
func TestKeycloakGroupsCreate_IdempotentTopLevel(t *testing.T) {
|
||||
h, dep, stub := newKCProxyHandler(t)
|
||||
|
||||
// First create — succeeds normally.
|
||||
stub.createUUID = "first-uuid"
|
||||
stub.getGroup = keycloak.Group{ID: "first-uuid", Name: "qa-test-group", Path: "/qa-test-group"}
|
||||
r := chi.NewRouter()
|
||||
registerKCProxyRoutes(r, h)
|
||||
rec := httptest.NewRecorder()
|
||||
body := `{"name":"qa-test-group"}`
|
||||
r.ServeHTTP(rec, reqWithClaims(http.MethodPost,
|
||||
"/api/v1/sovereigns/"+dep.ID+"/keycloak/groups", body, adminClaims()))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("first POST status: got %d want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var first kcGroupResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &first); err != nil {
|
||||
t.Fatalf("first POST decode: %v", err)
|
||||
}
|
||||
if first.ID != "first-uuid" {
|
||||
t.Fatalf("first POST id: got %q want first-uuid", first.ID)
|
||||
}
|
||||
|
||||
// Second create — KC returns 409, handler must recover.
|
||||
stub.createErr = keycloak.ErrGroupAlreadyExists
|
||||
stub.findByPathGroup = keycloak.Group{
|
||||
ID: "first-uuid", Name: "qa-test-group", Path: "/qa-test-group",
|
||||
Attributes: map[string][]string{},
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec2, reqWithClaims(http.MethodPost,
|
||||
"/api/v1/sovereigns/"+dep.ID+"/keycloak/groups", body, adminClaims()))
|
||||
if rec2.Code != http.StatusCreated {
|
||||
t.Fatalf("second POST status: got %d want 201; body=%s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
var second kcGroupResult
|
||||
if err := json.Unmarshal(rec2.Body.Bytes(), &second); err != nil {
|
||||
t.Fatalf("second POST decode: %v", err)
|
||||
}
|
||||
if second.ID != "first-uuid" {
|
||||
t.Fatalf("second POST id: got %q want first-uuid (idempotency violated)", second.ID)
|
||||
}
|
||||
if second.Name != "qa-test-group" {
|
||||
t.Errorf("second POST name: got %q want qa-test-group", second.Name)
|
||||
}
|
||||
// Sanity: handler asked for the group by its full path, not by name.
|
||||
if stub.lastFindByPathPath != "/qa-test-group" {
|
||||
t.Errorf("FindGroupByPath called with %q; want /qa-test-group", stub.lastFindByPathPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeycloakGroupsCreate_IdempotentSubGroup proves the same idempotency
|
||||
// contract for the sub-group path (POST with parentId set). Recovery
|
||||
// uses GET /groups/{parentId} → walk SubGroups by name, since
|
||||
// /group-by-path doesn't accept partial parent traversal.
|
||||
func TestKeycloakGroupsCreate_IdempotentSubGroup(t *testing.T) {
|
||||
h, dep, stub := newKCProxyHandler(t)
|
||||
// Simulate KC returning 409 on the create AND the parent's GET
|
||||
// returning the existing child inline.
|
||||
stub.createErr = keycloak.ErrGroupAlreadyExists
|
||||
stub.getGroup = keycloak.Group{
|
||||
ID: "parent-uuid", Name: "platform", Path: "/platform",
|
||||
SubGroups: []keycloak.Group{
|
||||
{ID: "existing-child", Name: "sre", Path: "/platform/sre"},
|
||||
},
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
registerKCProxyRoutes(r, h)
|
||||
rec := httptest.NewRecorder()
|
||||
body := `{"name":"sre","parentId":"parent-uuid"}`
|
||||
r.ServeHTTP(rec, reqWithClaims(http.MethodPost,
|
||||
"/api/v1/sovereigns/"+dep.ID+"/keycloak/groups", body, adminClaims()))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status: got %d want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got kcGroupResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.ID != "existing-child" {
|
||||
t.Fatalf("recovered child id: got %q want existing-child", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeycloakGroupsCreate_409ResolveEmpty exercises the defensive
|
||||
// branch where Keycloak returns 409 but the existing group can't be
|
||||
// found via FindGroupByPath. The handler MUST surface a 502 (rather
|
||||
// than 201 with an empty body) so the operator gets a clear signal
|
||||
// that KC state is inconsistent.
|
||||
func TestKeycloakGroupsCreate_409ResolveEmpty(t *testing.T) {
|
||||
h, dep, stub := newKCProxyHandler(t)
|
||||
stub.createErr = keycloak.ErrGroupAlreadyExists
|
||||
stub.findByPathGroup = keycloak.Group{} // empty — not found
|
||||
r := chi.NewRouter()
|
||||
registerKCProxyRoutes(r, h)
|
||||
rec := httptest.NewRecorder()
|
||||
body := `{"name":"phantom"}`
|
||||
r.ServeHTTP(rec, reqWithClaims(http.MethodPost,
|
||||
"/api/v1/sovereigns/"+dep.ID+"/keycloak/groups", body, adminClaims()))
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status: got %d want 502; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeycloakGroupsCreate_RejectsEmptyName(t *testing.T) {
|
||||
h, dep, _ := newKCProxyHandler(t)
|
||||
r := chi.NewRouter()
|
||||
|
||||
@ -117,6 +117,31 @@ var helmReleaseGVR = schema.GroupVersionResource{
|
||||
Resource: "helmreleases",
|
||||
}
|
||||
|
||||
// applicationGVR — apps.openova.io/v1 Application CR. Each per-Sovereign
|
||||
// workload has an Application record carrying the `spec.environmentRef`
|
||||
// field that drives the AppsPage card's environment chip (qa-loop iter-7
|
||||
// TC-090). Catalyst joins the catalog/HelmRelease rows with Application
|
||||
// CRs by slug — when an Application matches, its environmentRef is
|
||||
// surfaced on the sovereignAppItem; otherwise the row falls back to the
|
||||
// defaultSovereignEnvironment chip ("dev" out of the box).
|
||||
//
|
||||
// Resolved via the canonical ApplicationGVR() helper in applications.go
|
||||
// so the version stays aligned with the rest of the handler suite (the
|
||||
// chart's qa-fixtures Application uses apps.openova.io/v1; the
|
||||
// k8scache kinds.go entry still references v1alpha1 for legacy reasons,
|
||||
// but the live CRD is v1).
|
||||
var applicationGVR = ApplicationGVR()
|
||||
|
||||
// defaultSovereignEnvironment is the chip the AppsPage renders for any
|
||||
// app row that has no matching Application CR with a populated
|
||||
// spec.environmentRef. Single-environment Sovereigns (the common case
|
||||
// today, including omantel.biz) ship with everything in "dev"; multi-
|
||||
// environment Sovereigns surface the per-app environment as soon as
|
||||
// their Application CRs declare it. The matrix's TC-090 expectation
|
||||
// (Apps page must contain "dev") locks this default in place — the
|
||||
// chip MUST always render with a usable environment label.
|
||||
const defaultSovereignEnvironment = "dev"
|
||||
|
||||
// httpRouteGVR — Gateway API HTTPRoute. The canonical Sovereign
|
||||
// install uses Cilium Gateway as the only ingress; Console / SME /
|
||||
// per-blueprint front-doors all surface as HTTPRoutes. We list both
|
||||
@ -477,6 +502,16 @@ type sovereignAppItem struct {
|
||||
Status string `json:"status"` // installed | available | bootstrap
|
||||
BootstrapKit bool `json:"bootstrapKit"`
|
||||
|
||||
// Environment — deployment environment this app row is associated
|
||||
// with on this Sovereign. Sourced from the matching Application
|
||||
// CR's spec.environmentRef when present (apps.openova.io/v1alpha1);
|
||||
// falls back to "dev" otherwise so single-environment Sovereigns
|
||||
// (the common case) always display a usable chip on the AppsPage
|
||||
// card. The FE renders this verbatim as the environment chip
|
||||
// alongside FREE/BOOTSTRAP. Closes qa-loop iter-7 TC-090 (Apps
|
||||
// page lost the environment chip during the SME-publish redesign).
|
||||
Environment string `json:"environment,omitempty"`
|
||||
|
||||
// MarketplacePublished — null when the slug isn't in the SME
|
||||
// catalog (bootstrap components, or marketplace not deployed on
|
||||
// this Sovereign). When non-null, the FE renders a Publish chip
|
||||
@ -529,6 +564,14 @@ func (h *Handler) HandleSovereignApps(w http.ResponseWriter, r *http.Request) {
|
||||
// no in-cluster client (CI) gets "available" for every entry,
|
||||
// which is the safe, non-misleading fallback.
|
||||
installed := map[string]string{} // hr name → "installed" | "installing"
|
||||
// Map slug → environmentRef harvested from Application CRs. When
|
||||
// an app row's slug matches, the chip on the FE displays the
|
||||
// per-Application environment (e.g. "prod", "staging"); otherwise
|
||||
// the chip falls back to defaultSovereignEnvironment ("dev").
|
||||
// Best-effort: missing CR / RBAC / list error → empty map →
|
||||
// every row falls back, which is the target-state behaviour for
|
||||
// single-environment Sovereigns.
|
||||
envBySlug := map[string]string{}
|
||||
deps, depsErr := h.sovereignDepsFor()
|
||||
if depsErr == nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
@ -543,6 +586,30 @@ func (h *Handler) HandleSovereignApps(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Application CR pass — separate List so a missing CRD or
|
||||
// RBAC denial on apps.openova.io doesn't break the HR pass
|
||||
// above (which is the load-bearing one for status).
|
||||
if appList, err := deps.dyn.Resource(applicationGVR).Namespace("").List(ctx, metav1.ListOptions{}); err == nil {
|
||||
for _, app := range appList.Items {
|
||||
env, _, _ := unstructured.NestedString(app.Object, "spec", "environmentRef")
|
||||
if env == "" {
|
||||
continue
|
||||
}
|
||||
// Application CR's name is the slug; spec.blueprintRef
|
||||
// could also drive the join but the name is the canonical
|
||||
// per-app identifier in the wizard catalog.
|
||||
slug := app.GetName()
|
||||
if existing, ok := envBySlug[slug]; ok && existing != env {
|
||||
// Multiple Applications for the same slug across
|
||||
// environments — keep the first deterministic
|
||||
// non-empty hit (alphabetical iteration over
|
||||
// namespaces). Multi-env-per-slug surfaces in a
|
||||
// later UI iteration via the cross-Sov view.
|
||||
continue
|
||||
}
|
||||
envBySlug[slug] = env
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bootstrapIDs := map[string]bool{}
|
||||
@ -566,6 +633,7 @@ func (h *Handler) HandleSovereignApps(w http.ResponseWriter, r *http.Request) {
|
||||
Summary: "",
|
||||
Status: "bootstrap",
|
||||
BootstrapKit: true,
|
||||
Environment: resolveAppEnvironment(envBySlug, b.Slug),
|
||||
})
|
||||
}
|
||||
|
||||
@ -591,6 +659,7 @@ func (h *Handler) HandleSovereignApps(w http.ResponseWriter, r *http.Request) {
|
||||
Depends: b.Depends,
|
||||
Status: status,
|
||||
BootstrapKit: false,
|
||||
Environment: resolveAppEnvironment(envBySlug, b.Slug),
|
||||
})
|
||||
}
|
||||
|
||||
@ -619,6 +688,19 @@ func (h *Handler) HandleSovereignApps(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// resolveAppEnvironment returns the environment chip the FE renders on
|
||||
// the AppsPage card for a given slug. Uses the per-Application CR
|
||||
// environmentRef when populated; otherwise falls back to
|
||||
// defaultSovereignEnvironment ("dev"). Always returns a non-empty string
|
||||
// — the matrix's TC-090 contract requires every app card to display an
|
||||
// environment chip.
|
||||
func resolveAppEnvironment(envBySlug map[string]string, slug string) string {
|
||||
if env, ok := envBySlug[slug]; ok && env != "" {
|
||||
return env
|
||||
}
|
||||
return defaultSovereignEnvironment
|
||||
}
|
||||
|
||||
// HandleSovereignAppPublish — PATCH /api/v1/sovereign/apps/{slug}/publish.
|
||||
//
|
||||
// Operator-admin toggle to publish/unpublish a SaaS app on the
|
||||
|
||||
@ -50,6 +50,7 @@ func newSovereignHandler(t *testing.T, coreObjs []runtime.Object, dynObjs []runt
|
||||
gvrToList := map[schema.GroupVersionResource]string{
|
||||
helmReleaseGVR: "HelmReleaseList",
|
||||
httpRouteGVR: "HTTPRouteList",
|
||||
applicationGVR: "ApplicationList",
|
||||
{Group: "cert-manager.io", Version: "v1", Resource: "certificates"}: "CertificateList",
|
||||
}
|
||||
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToList, dynObjs...)
|
||||
@ -100,6 +101,26 @@ func makeHTTPRoute(name, ns string, hosts []string) *unstructured.Unstructured {
|
||||
return u
|
||||
}
|
||||
|
||||
// makeApplication builds an apps.openova.io/v1 Application CR with the
|
||||
// given name (= slug) and spec.environmentRef. Used to seed the dynamic
|
||||
// client for /api/v1/sovereign/apps environment-chip tests (qa-loop
|
||||
// iter-7 TC-090). Version matches ApplicationGVR() in applications.go
|
||||
// and the qa-fixtures chart.
|
||||
func makeApplication(name, ns, environmentRef string) *unstructured.Unstructured {
|
||||
u := &unstructured.Unstructured{}
|
||||
u.SetGroupVersionKind(schema.GroupVersionKind{
|
||||
Group: "apps.openova.io",
|
||||
Version: "v1",
|
||||
Kind: "Application",
|
||||
})
|
||||
u.SetName(name)
|
||||
u.SetNamespace(ns)
|
||||
if environmentRef != "" {
|
||||
_ = unstructured.SetNestedField(u.Object, environmentRef, "spec", "environmentRef")
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func mustGet[T any](t *testing.T, h http.Handler, path string) T {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
@ -266,6 +287,110 @@ func TestSovereignApps_StatusJoinHelmReleases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSovereignApps_EnvironmentChipDefaultsToDev proves the qa-loop
|
||||
// iter-7 TC-090 fix: every app row in the /sovereign/apps response
|
||||
// MUST carry a non-empty Environment field. With no Application CR
|
||||
// seeded, every row falls back to defaultSovereignEnvironment ("dev")
|
||||
// so the AppsPage card always renders an environment chip — matrix
|
||||
// expectation `must_contain: ["dev"]` on the Apps page.
|
||||
func TestSovereignApps_EnvironmentChipDefaultsToDev(t *testing.T) {
|
||||
dynObjs := []runtime.Object{
|
||||
makeHR("bp-cilium", "flux-system", "True"),
|
||||
}
|
||||
h := newSovereignHandler(t, nil, dynObjs)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/sovereign/apps", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSovereignApps(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var got sovereignAppsResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got.Apps) == 0 {
|
||||
t.Fatal("expected at least one app, got 0")
|
||||
}
|
||||
for _, a := range got.Apps {
|
||||
if a.Environment == "" {
|
||||
t.Errorf("app %q has empty Environment; every row must carry a chip", a.ID)
|
||||
}
|
||||
if a.Environment != defaultSovereignEnvironment {
|
||||
t.Errorf("app %q Environment = %q; want default %q", a.ID, a.Environment, defaultSovereignEnvironment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSovereignApps_EnvironmentChipFromApplicationCR proves that when
|
||||
// an Application CR with spec.environmentRef matches the slug, the
|
||||
// chip surfaces THAT environment instead of the default. Multi-env
|
||||
// Sovereigns rely on this — without it every row would render "dev"
|
||||
// regardless of where the workload actually runs.
|
||||
func TestSovereignApps_EnvironmentChipFromApplicationCR(t *testing.T) {
|
||||
dynObjs := []runtime.Object{
|
||||
makeHR("bp-cilium", "flux-system", "True"),
|
||||
// Match by the bp-cilium slug ("cilium" — Catalog.Slug is
|
||||
// the slug WITHOUT the bp- prefix).
|
||||
makeApplication("cilium", "default", "prod"),
|
||||
}
|
||||
h := newSovereignHandler(t, nil, dynObjs)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/sovereign/apps", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSovereignApps(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var got sovereignAppsResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
byID := map[string]sovereignAppItem{}
|
||||
for _, a := range got.Apps {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
cilium, ok := byID["bp-cilium"]
|
||||
if !ok {
|
||||
t.Fatalf("bp-cilium missing from response")
|
||||
}
|
||||
if cilium.Environment != "prod" {
|
||||
t.Errorf("bp-cilium Environment = %q; want prod (from Application CR)", cilium.Environment)
|
||||
}
|
||||
// Sibling rows with no matching Application CR still default to dev.
|
||||
for _, a := range got.Apps {
|
||||
if a.ID == "bp-cilium" {
|
||||
continue
|
||||
}
|
||||
if a.Environment != defaultSovereignEnvironment {
|
||||
t.Errorf("non-matched app %q Environment = %q; want default %q", a.ID, a.Environment, defaultSovereignEnvironment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAppEnvironment_FallbackOrder unit-tests the helper in
|
||||
// isolation so the fallback semantics are explicit.
|
||||
func TestResolveAppEnvironment_FallbackOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
envs map[string]string
|
||||
slug string
|
||||
expected string
|
||||
}{
|
||||
{name: "nil-map → dev", envs: nil, slug: "anything", expected: "dev"},
|
||||
{name: "empty-map → dev", envs: map[string]string{}, slug: "x", expected: "dev"},
|
||||
{name: "miss → dev", envs: map[string]string{"a": "prod"}, slug: "b", expected: "dev"},
|
||||
{name: "empty-value → dev", envs: map[string]string{"a": ""}, slug: "a", expected: "dev"},
|
||||
{name: "hit → returns", envs: map[string]string{"a": "stg"}, slug: "a", expected: "stg"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := resolveAppEnvironment(tc.envs, tc.slug)
|
||||
if got != tc.expected {
|
||||
t.Errorf("got %q want %q", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── /cloud ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSovereignCloud_NodesAndNamespaces(t *testing.T) {
|
||||
|
||||
@ -37,9 +37,23 @@ import (
|
||||
// ErrGroupNotFound is returned by GetGroup / DeleteGroup on 404.
|
||||
var ErrGroupNotFound = errors.New("keycloak: group not found")
|
||||
|
||||
// errGroupAlreadyExists is the internal sentinel for the EnsureGroup
|
||||
// 409 race path.
|
||||
var errGroupAlreadyExists = errors.New("keycloak: group already exists")
|
||||
// ErrGroupAlreadyExists is returned by CreateGroup / CreateSubGroup when
|
||||
// Keycloak responds with 409 Conflict (a group with the same name/path
|
||||
// already exists in the target scope). Callers performing a one-shot
|
||||
// create can detect this and treat it as a no-op (the desired end state
|
||||
// — group exists — is already true), then re-fetch via FindGroupByPath
|
||||
// to recover the existing UUID. EnsureGroup uses this internally to
|
||||
// implement find-or-create semantics; HandleKeycloakGroupsCreate uses
|
||||
// it to expose the same idempotency to the HTTP API surface — POSTing
|
||||
// the same group name twice MUST return the existing group, never a
|
||||
// 502 wrapping the upstream 409 (qa-loop iter-7 TC-141).
|
||||
var ErrGroupAlreadyExists = errors.New("keycloak: group already exists")
|
||||
|
||||
// errGroupAlreadyExists keeps the lowercase name alive as an alias so
|
||||
// pre-existing internal call sites that imported the unexported sentinel
|
||||
// (admin_groups.go EnsureGroup, admin_groups_test.go) continue to compile
|
||||
// without a sweeping rename. New code SHOULD use ErrGroupAlreadyExists.
|
||||
var errGroupAlreadyExists = ErrGroupAlreadyExists
|
||||
|
||||
// Group is the slice of fields catalyst-api consumes/sets when
|
||||
// reconciling per-Org groups. Mirrors the upstream GroupRepresentation
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* DashboardPage.test.tsx — anti-regression coverage for the qa-loop
|
||||
* iter-7 TC-383 fix.
|
||||
*
|
||||
* The matrix locks the literal string "Dashboard" into the page body
|
||||
* (matrix `must_contain: ["Dashboard"]`). The EPIC-6 redesign re-titled
|
||||
* the page to "Sovereign Fleet" — the breadcrumb above the H1 restores
|
||||
* the literal so the redesign and the contract co-exist.
|
||||
*
|
||||
* What this file proves:
|
||||
* - The breadcrumb renders with the literal text "Dashboard".
|
||||
* - The page H1 still reads "Sovereign Fleet" (no redesign rollback).
|
||||
* - The breadcrumb has the correct `aria-label` and stable testid.
|
||||
*
|
||||
* Routing: TanStack Router renders <Link to="/wizard"> via the provider.
|
||||
* We mount the bare DashboardPage with stub routes for /wizard and
|
||||
* /dashboard/applications so the Link components don't blow up.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
createMemoryHistory,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { DashboardPage } from './DashboardPage'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
function makeRouter() {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const dashRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/dashboard',
|
||||
component: DashboardPage,
|
||||
})
|
||||
const wizardRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/wizard',
|
||||
component: () => <div>wizard</div>,
|
||||
})
|
||||
const crossRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/dashboard/applications',
|
||||
component: () => <div>cross-sov</div>,
|
||||
})
|
||||
return createRouter({
|
||||
routeTree: rootRoute.addChildren([dashRoute, wizardRoute, crossRoute]),
|
||||
history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
|
||||
})
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const router = makeRouter()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<RouterProvider router={router as never} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DashboardPage breadcrumb (qa-loop iter-7 TC-383)', () => {
|
||||
it('renders the literal "Dashboard" label in the breadcrumb', () => {
|
||||
renderPage()
|
||||
const crumb = screen.getByTestId('dashboard-breadcrumb')
|
||||
expect(crumb).toBeInTheDocument()
|
||||
// The literal "Dashboard" string MUST be present — the matrix's
|
||||
// anti-regression test will fail otherwise.
|
||||
expect(within(crumb).getByText('Dashboard')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the H1 as "Sovereign Fleet" so the redesign is preserved', () => {
|
||||
renderPage()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 1, name: 'Sovereign Fleet' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes the breadcrumb with aria-label="Breadcrumb" for AT users', () => {
|
||||
renderPage()
|
||||
const crumb = screen.getByLabelText('Breadcrumb')
|
||||
expect(crumb.tagName.toLowerCase()).toBe('nav')
|
||||
})
|
||||
|
||||
it('marks the current page in the trail with aria-current', () => {
|
||||
renderPage()
|
||||
const current = screen.getByText('Sovereign Fleet', { selector: 'li' })
|
||||
expect(current).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
})
|
||||
@ -65,6 +65,37 @@ export function DashboardPage() {
|
||||
className="flex items-start justify-between gap-4 flex-wrap"
|
||||
>
|
||||
<div>
|
||||
{/*
|
||||
* Breadcrumb — the literal "Dashboard" label sits above the
|
||||
* H1 ("Sovereign Fleet") so navigation context is preserved
|
||||
* after the EPIC-6 redesign that re-titled the page. The
|
||||
* matrix's TC-383 (anti-regression: /app/dashboard MUST
|
||||
* contain the literal string "Dashboard") locks this in;
|
||||
* removing the breadcrumb without restoring the literal
|
||||
* elsewhere on the page would re-open the regression.
|
||||
*
|
||||
* Rendered as a <nav> with `aria-label="Breadcrumb"` so AT
|
||||
* users get the same context, and as `<ol>/<li>` so future
|
||||
* deeper routes (e.g. Dashboard › Sovereign › Apps) can
|
||||
* extend the trail without restructuring the markup.
|
||||
*/}
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
data-testid="dashboard-breadcrumb"
|
||||
className="text-xs text-[oklch(50%_0.01_250)] mb-1.5"
|
||||
>
|
||||
<ol className="flex items-center gap-1.5">
|
||||
<li>
|
||||
<span className="font-medium text-[oklch(75%_0.01_250)]">Dashboard</span>
|
||||
</li>
|
||||
<li aria-hidden="true" className="text-[oklch(35%_0.01_250)]">
|
||||
/
|
||||
</li>
|
||||
<li aria-current="page" className="text-[oklch(60%_0.01_250)]">
|
||||
Sovereign Fleet
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 className="text-xl font-semibold text-[oklch(92%_0.01_250)]">Sovereign Fleet</h1>
|
||||
<p className="mt-1 text-sm text-[oklch(50%_0.01_250)]">
|
||||
Manage every OpenOva Sovereign across providers, regions, and Organizations.
|
||||
|
||||
@ -91,6 +91,14 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
|
||||
interface LiveAppsData {
|
||||
statusById: Record<string, ApplicationStatus>
|
||||
publishedBySlug: Record<string, boolean>
|
||||
/**
|
||||
* environmentById — per-app environment label (e.g. "dev", "prod",
|
||||
* "staging") sourced from the Application CR's spec.environmentRef
|
||||
* by the BE handler. Always populated for every row (BE falls back
|
||||
* to "dev" when no CR matches), so the FE always has a chip to
|
||||
* render. Closes qa-loop iter-7 TC-090.
|
||||
*/
|
||||
environmentById: Record<string, string>
|
||||
}
|
||||
const liveAppsQuery = useQuery<LiveAppsData>({
|
||||
queryKey: ['sovereign-apps-live'],
|
||||
@ -107,10 +115,12 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
|
||||
slug?: string
|
||||
status?: string
|
||||
marketplacePublished?: boolean
|
||||
environment?: string
|
||||
}>
|
||||
}
|
||||
const statusById: Record<string, ApplicationStatus> = {}
|
||||
const publishedBySlug: Record<string, boolean> = {}
|
||||
const environmentById: Record<string, string> = {}
|
||||
for (const a of body.apps ?? []) {
|
||||
if (!a.id) continue
|
||||
switch (a.status) {
|
||||
@ -134,15 +144,24 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
|
||||
if (a.slug && typeof a.marketplacePublished === 'boolean') {
|
||||
publishedBySlug[a.slug] = a.marketplacePublished
|
||||
}
|
||||
if (typeof a.environment === 'string' && a.environment !== '') {
|
||||
environmentById[a.id] = a.environment
|
||||
}
|
||||
}
|
||||
return { statusById, publishedBySlug }
|
||||
return { statusById, publishedBySlug, environmentById }
|
||||
},
|
||||
retry: false,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
const liveAppStatus = liveAppsQuery.data?.statusById ?? {}
|
||||
const publishedBySlug = liveAppsQuery.data?.publishedBySlug ?? {}
|
||||
const environmentById = liveAppsQuery.data?.environmentById ?? {}
|
||||
const refetchLive = liveAppsQuery.refetch
|
||||
// DEFAULT_APP_ENVIRONMENT mirrors the BE's defaultSovereignEnvironment
|
||||
// so even when the live API hasn't responded yet (cold load) every
|
||||
// card still renders an environment chip — the matrix's TC-090
|
||||
// contract is invariant to fetch state. Closes qa-loop iter-7 TC-090.
|
||||
const DEFAULT_APP_ENVIRONMENT = 'dev'
|
||||
|
||||
const isFailed = streamStatus === 'failed' || streamStatus === 'unreachable'
|
||||
const failureMessage = streamError ?? snapshot?.error ?? null
|
||||
@ -560,10 +579,12 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
|
||||
const published = Object.prototype.hasOwnProperty.call(publishedBySlug, slug)
|
||||
? publishedBySlug[slug]
|
||||
: null
|
||||
const environment = environmentById[app.id] ?? DEFAULT_APP_ENVIRONMENT
|
||||
return (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
environment={environment}
|
||||
status={(() => {
|
||||
// Live API status wins when present.
|
||||
const live = liveAppStatus[app.id]
|
||||
@ -629,6 +650,14 @@ interface AppCardProps {
|
||||
* Application surface.
|
||||
*/
|
||||
isService: boolean
|
||||
/**
|
||||
* Environment label rendered as a chip beside the FREE/BOOTSTRAP
|
||||
* chips. Sourced from the BE's per-Application `environmentRef`,
|
||||
* with "dev" as the always-on fallback so single-environment
|
||||
* Sovereigns still display a usable chip. Closes qa-loop iter-7
|
||||
* TC-090.
|
||||
*/
|
||||
environment: string
|
||||
/**
|
||||
* Marketplace publish state for this app's slug. `null` ⇒ this app
|
||||
* isn't in the SME marketplace catalog (bootstrap component, or
|
||||
@ -640,7 +669,7 @@ interface AppCardProps {
|
||||
onPublishedChange?: (next: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
function AppCard({ app, status, isService, marketplacePublished, slug, onPublishedChange }: AppCardProps) {
|
||||
function AppCard({ app, status, isService, environment, marketplacePublished, slug, onPublishedChange }: AppCardProps) {
|
||||
const stateClass = `state-${status}`
|
||||
// Chroot-aware target: on the mother monitor surface
|
||||
// (console.openova.io/sovereign/provision/<id>/...) every link MUST stay
|
||||
@ -675,6 +704,14 @@ function AppCard({ app, status, isService, marketplacePublished, slug, onPublish
|
||||
</div>
|
||||
<p className="app-desc">{app.description || app.familyName}</p>
|
||||
<div className="app-chips">
|
||||
<span
|
||||
className="chip chip-env"
|
||||
title={`Environment: ${environment}`}
|
||||
data-testid={`sov-app-env-${app.id}`}
|
||||
data-environment={environment}
|
||||
>
|
||||
{environment}
|
||||
</span>
|
||||
<span className="chip chip-free">FREE</span>
|
||||
{app.bootstrapKit ? (
|
||||
<span className="chip chip-dep" title="Bootstrap-kit component (always installed)">
|
||||
@ -955,6 +992,20 @@ const APPS_PAGE_CSS = `
|
||||
}
|
||||
.chip-free { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); }
|
||||
.chip-dep { background: color-mix(in srgb, var(--color-accent) 12%, transparent); color: var(--color-accent); font-weight: 500; }
|
||||
/*
|
||||
* .chip-env — per-app environment chip ("dev", "prod", etc.).
|
||||
* Distinct hue from FREE/BOOTSTRAP so the operator can scan environment
|
||||
* affinity at a glance. Placed first in .app-chips so it always renders
|
||||
* even when the BE response is delayed (FE falls back to "dev"). Closes
|
||||
* qa-loop iter-7 TC-090.
|
||||
*/
|
||||
.chip-env {
|
||||
background: color-mix(in srgb, var(--color-text-strong) 12%, transparent);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.status-corner { position: absolute; bottom: 0.5rem; right: 0.55rem; display: flex; gap: 0.4rem; align-items: center; }
|
||||
.publish-chip {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user