Skip to content

feat(FR-3877): move the system announcement to the domain app config - #9527

Open
ironAiken2 wants to merge 9 commits into
feat/FR-3834-theme-family-brandingfrom
feat/FR-3877-announcement-app-config
Open

feat(FR-3877): move the system announcement to the domain app config#9527
ironAiken2 wants to merge 9 commits into
feat/FR-3834-theme-family-brandingfrom
feat/FR-3877-announcement-app-config

Conversation

@ironAiken2

@ironAiken2 ironAiken2 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Resolves #9526 (FR-3877)

Summary

Top of the FR-1203 app-config stack (#9361#8860#9362 → this). The system announcement moves from the manager's legacy announcement endpoint (/manager/announcement, one markdown string in etcd, first line lifted out as the title) to the domain app config:

// domainConfig (DOMAIN scope, merged into `myAppConfigs(['domainConfig'])`)
{
  "announcement": {
    "enabled": true,
    "title": "Maintenance tonight",          // plain text, shown in every banner state
    "body": "Details in **markdown** …",     // optional, revealed on expand
    "updatedAt": "2026-09-08T05:12:00.000Z"  // keys per-session dismissal
  }
}

Hooks (react/src/hooks/useAppConfig.tsx)

  • useUpdateDomainAppConfig() — the missing DOMAIN-scope setter next to useUpdatePublicDomainAppConfig / useUpdateMyUserAppConfig. (subKey, nextValue): takes the domain uuid from the client (useCurrentDomain().id, stored at login — see below), re-reads the raw DOMAIN-scope domainConfig fragment, replaces one sub-key (undefined removes it) through scopedUpsertAppConfigFragments, then refetches the merged myAppConfigs(['domainConfig']) view so every useDomainAppConfig reader updates — no window.location.reload(), no per-save domainV2 round-trip.
  • Reads go through the existing useDomainAppConfig<T>(subKey) (first consumer).

Banner / editor

  • AnnouncementBanner reads useDomainAppConfig<DomainAnnouncement>('announcement'). Title in every state; the body renders behind the expand toggle; a title past the 120-code-point cutoff collapses like before. Dismissal is keyed by updatedAt, so a re-published announcement resurfaces the banner.
  • AnnouncementEditModal gains a Title field (a BAIFormItem inside Form, required + whitespace, so its error renders like every other form field and an untouched empty title stays error-free; Publish / Save as Draft run validateFields() first) and a Publish split button whose menu offers Save as Draft (enabled: false, which the legacy endpoint could not persist — it stored by presence only); a saved draft is marked by a Draft token in the modal title. Delete stands alone on the left and removes the sub-key. body is trimmed on save. The content suspends on the Relay read behind a skeleton-bodied modal fallback, so the Maintenance page opens it without a page-level Suspense.
  • Deleted: announcementSummary.ts (first-line extraction, markdown-to-prose) and useSuspenseGetAnnouncement.tsx (TanStack). helper/announcement.ts keeps the title cutoff and the visibility / collapsibility predicates, with tests. service.get_announcement / update_announcement stay in backend.ai-client (library API) but have no caller in the app.
  • i18n: summary.AnnouncementTitle, summary.AnnouncementTitleRequired (all 21 locales); summary.AnnouncementMessageRequired removed (no consumer).

Login reads myUserV2 (react/src/helper/loginSessionAuth.ts)

connectViaGQL used to chain keypairusergroup.list and only ever stored the domain name, so the DOMAIN-scope setter above had to resolve the uuid on every save. It now reads myUserV2 once — id, basicInfo, organization { domainName role }, domain { entityId basicInfo { name } }, projects(filter: { isActive: true }) — and stores the uuid as _config.domainId (new useCurrentDomain() hook returning { name, id }; LoginConfigState.domain_id). The read is a module-level Relay graphql tag used through fetchQuery(RelayEnvironment, …): RelayEnvironment's fetch resolves globalThis.backendaiclient at request time, which connectViaGQL installs first, so the response type is the generated loginSessionAuthMyUserQuery rather than a hand-written one.

  • UserV2.projects caps an unpaginated read at the manager's default page size (10), so the login walks the pages with limit: 100 / offset, bounded by the connection's count (a single pagination mode; no cursor to stall on).
  • V2 ids are Relay global ids; toLocalId decodes them for user_uuid and groupIds.
  • Dropped what nothing read: the stored resource_policy and need_password_change, and the fetched-but-unstored username / is_active. groupIds now holds only the user's projects (the legacy map held every group in the domain); its sole reader, current_group_id(), only ever looks up the user's current project.
  • useAppConfigDomainIdQuery is gone with its __generated__ artifact.

myUserV2 is 26.2.0+, below the 26.9.0 this PR already requires, so no gate.

No version fallback

myAppConfigs / scopedUpsertAppConfigFragments need manager 26.9.0+. This ships in the LTS that guarantees it (decision: FR-3877 discussion), so there is no legacy-endpoint fallback — on an older manager the banner query fails inside ErrorBoundaryWithNullFallback and the Maintenance page's Edit modal errors on load.

Verification

  • bash scripts/verify.sh → all checks pass at commit time (Lint, Format, TypeScript ×2, Vite warmup, StyleX, Astryx theme build, Astryx integration, z-index, Agent mappings, Terminology). The Relay check flagged only the two new __generated__ artifacts as uncommitted before this commit; they are committed.

  • vitest run src/helper/announcement.test.ts — 8 tests.

  • vitest run src/helper/loginSessionAuth.test.ts — 11 tests on a createMockEnvironment() stand-in for RelayEnvironment (single read, offset walk bounded by count, role → is_admin/is_superadmin mapping, domain fallback, remembered project, unauthenticated logout, endpoint history); STokenLoginBoundary.test.tsx and useWebUIConfig.test.ts still pass.

  • pnpm relayuseAppConfigDomainRawQuery, loginSessionAuthMyUserQuery generated; useAppConfigDomainIdQuery removed.

  • Not yet verified against a live manager (the 10.82.0.x test network was unreachable from the authoring box). Assumptions to confirm on the first run: domainConfig is allow-listed at DOMAIN scope for the read (myAppConfigs); myUserV2.domain.entityId is the uuid AppConfigScopeRef.scopeId expects; and ProjectV2.id / UserV2.id come back as Relay global ids (base64("ProjectV2:<uuid>")), which is what the manager source declares (NodeID on PydanticNodeMixin). A dev server for this branch is up for that: https://fr-3877.localhost:1355.

  • Review round (7d5d2c7ff): bash scripts/verify.sh=== ALL PASS ===; vitest run src/helper/loginSessionAuth.test.ts src/helper/announcement.test.ts → 2 files / 19 tests passed.

  • Stack rebased onto main (f8d974dd3) and the title moved to the form engine (826d213b5): bash scripts/verify.sh=== ALL PASS ===.

Reviewer checklist

  • 1. As superadmin, Maintenance → Edit Announcement: set a title and a markdown body, Publish. The banner appears at the top with the title and an expand toggle that reveals the body.
  • 2. Dismiss the banner, then re-publish with any change → the banner comes back (dismissal is keyed by updatedAt).
  • 3. Save as Draft (Publish menu) → banner gone, the modal reopens with a Draft token and the saved title/body.
  • 4. Delete → banner gone, the modal opens empty with Delete disabled.
  • 5. Sign in as a non-admin user of the same domain → the banner shows without the Edit button.
  • 6. Sign in as a user who belongs to more than 10 active projects → the project selector lists all of them, and the remembered project survives a reload (login now pages myUserV2.projects).

🤖 Generated with Claude Code

https://claude.ai/code/session_01B13bqRK3V12jiCvia8fm2w

@github-actions github-actions Bot added area:ux UI / UX issue. area:i18n Localization size:XL 500~ LoC labels Sep 8, 2026
@ironAiken2 ironAiken2 changed the title feat/FR 3877 announcement app config feat(FR-3877): move the system announcement to the domain app config Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for react-coverage (./react)

Status Category Percentage Covered / Total
🔵 Lines 17.94% 6642 / 37006
🔵 Statements 15.18% 8198 / 53982
🔵 Functions 16.02% 1068 / 6664
🔵 Branches 10.98% 5614 / 51102
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
react/src/components/AnnouncementBanner.tsx 1.58% 0% 0% 2.77% 36-68
react/src/components/AnnouncementEditModal.tsx 66.44% 65.74% 18.51% 71.76% 77, 85, 86, 87, 91, 97, 98, 133-154, 159-168, 177-183, 204-247, 357-381, 387-404, 423, 426, 430, 431, 432, 437, 438, 441, 442, 445, 446, 449, 450, 453, 454, 458, 460, 467, 468, 471, 475, 476, 479, 483, 484, 488, 490, 493, 494, 497, 501, 502, 505, 509, 510, 513, 515, 527, 535, 536
react/src/helper/announcement.ts 100% 100% 100% 100%
react/src/helper/loginConfig.ts 38.77% 5.55% 25% 39.31% 25-46, 193-615, 624
react/src/helper/loginSessionAuth.ts 32.25% 28.78% 28.57% 33.05% 65-89, 204-398
react/src/hooks/index.tsx 65.74% 54.65% 52.72% 64.11% 65-68, 168, 169, 174-186, 197-198, 206-218, 225-235, 398-399, 404, 509-512, 527-530, 538-566, 574, 584-599, 616, 642-656, 664-693, 696-725
react/src/hooks/useAppConfig.ts 16.25% 20% 0% 21.66% 125-131, 140-142, 153-162, 170-179, 189-196, 237-244, 280-177
Generated in workflow #933 for commit 3654fa6 by the Vitest Coverage Report Action

@ironAiken2
ironAiken2 force-pushed the feat/FR-3877-announcement-app-config branch 2 times, most recently from 78d5257 to b5e9aaa Compare September 8, 2026 05:40
@ironAiken2
ironAiken2 marked this pull request as ready for review September 8, 2026 08:12
@ironAiken2
ironAiken2 force-pushed the feat/FR-3877-announcement-app-config branch from c6d64cf to 88a930d Compare September 8, 2026 10:57

@agatha197 agatha197 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image 1. The title validation error has a block unlike form item. Let's remove the background. 2. Instead of enabled button, let's add a draft publish (replace better representation ;)) to the left of the publish button with primary outlined button. And leave delete button to the left only.

@agatha197 agatha197 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the two login-path issues below (loginSessionAuth.ts:119 and :124) — both are unverified assumptions sitting on the app's only entry path, and both are cheap to guard. The remaining comments are minor cleanups.

Everything else checks out: logic and Relay patterns follow the sibling hooks, i18n is in sync across all 21 locales, regenerated Relay artifacts show no drift, and the projects connection uses a single pagination mode (first+after).

Comment thread react/src/helper/loginSessionAuth.ts
Comment thread react/src/helper/loginSessionAuth.ts Outdated
Comment thread react/src/helper/loginSessionAuth.ts Outdated
Comment thread react/src/helper/loginSessionAuth.ts Outdated
Comment thread react/src/components/AnnouncementEditModal.tsx Outdated
Comment thread react/src/components/AnnouncementEditModal.tsx Outdated
Comment thread react/src/components/AnnouncementEditModal.tsx
@ironAiken2

Copy link
Copy Markdown
Contributor Author

Addressed the review-body feedback from @agatha197 in 7d5d2c7ff:

  1. The title validation message now uses TextInput's detached status placement, so it reads like the form items (no bordered block), and it appears only after the title has been edited.
  2. The Enabled checkbox is gone. Publish is a split button (same shape as the session launcher's Launch) whose menu offers Save as Draft — same document, enabled: false — and Delete stands alone on the left. A saved draft is marked with a Draft token in the modal title.

@ironAiken2
ironAiken2 requested a review from agatha197 September 9, 2026 05:53
@ironAiken2
ironAiken2 force-pushed the feat/FR-3877-announcement-app-config branch from 7d5d2c7 to e75bf6e Compare September 9, 2026 07:06
@ironAiken2

Copy link
Copy Markdown
Contributor Author

@agatha197 agatha197 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image Could you change “quote” into something easier to understand? At the moment, the color is so faint that it is hard to distinguish.

Collapsed version - centered
Image
Expanded version - move to the top
Image
Please change it so that it always come to the center.

ironAiken2 added a commit that referenced this pull request Sep 10, 2026
ironAiken2 added a commit that referenced this pull request Sep 10, 2026
@ironAiken2

Copy link
Copy Markdown
Contributor Author

Addressed the review from @agatha197 in ccdfd431f:

  1. Quote legibility — a blockquote inside the announcement body now draws its rule in the band's info colour and its text in the primary text colour (.webui-announcement-body .astryx-blockquote, applied to both the banner and the editor preview), so it no longer sinks into the info tint.
  2. Centering — Banner only centres its icon and actions while it has no description, which is why they jumped to the top once expanded; the banner header now keeps align-items: center in both states.

Measured on the dev server: icon and Edit sit at the header's vertical centre collapsed and expanded (offset ≤ 0.01px); the quote rule is rgb(2, 141, 242) with primary text.

Collapsed Expanded
collapsed expanded

@ironAiken2
ironAiken2 force-pushed the feat/FR-3877-announcement-app-config branch from ccdfd43 to 31836c1 Compare September 11, 2026 00:40
@ironAiken2
ironAiken2 force-pushed the feat/FR-3877-announcement-app-config branch from 31836c1 to 5d4ff5b Compare September 11, 2026 04:19
@ironAiken2
ironAiken2 force-pushed the feat/FR-3877-announcement-app-config branch from 5d4ff5b to ec35b71 Compare September 11, 2026 04:27
@agatha197

Copy link
Copy Markdown
Contributor

ironAiken2 and others added 9 commits September 11, 2026 06:21
The announcement lived in the manager's legacy announcement endpoint
(`/manager/announcement`, etcd) as one markdown string whose first line
the banner lifted out as a title. It now lives in the domain app config
as `domainConfig.announcement = { enabled, title, body, updatedAt }`, in
line with the other app-config migrations under FR-1203.

- `useUpdateDomainAppConfig()` joins the app-config hooks: resolves the
  domain uuid through `domainV2.entityId` (DOMAIN scope is addressed by
  uuid, the client only knows the name), re-reads the raw DOMAIN-scope
  `domainConfig` fragment, replaces one sub-key (`undefined` removes it)
  through `scopedUpsertAppConfigFragments`, then refetches the merged
  `myAppConfigs(['domainConfig'])` view so `useDomainAppConfig` readers
  update without a reload.
- `AnnouncementBanner` reads `useDomainAppConfig('announcement')`. The
  title shows in every state; the body (markdown) renders behind the
  expand toggle. Dismissal is keyed by `updatedAt` instead of the message
  text, so a re-published announcement resurfaces the banner.
- `AnnouncementEditModal` gains a Title field and the Enabled checkbox
  the legacy endpoint could not persist (it stored by presence only);
  Delete removes the sub-key. The content suspends on the Relay read
  behind a skeleton-bodied modal fallback.
- `announcementSummary.ts` (first-line extraction, markdown-to-prose) and
  `useSuspenseGetAnnouncement` (TanStack) go away with the derived title;
  `announcement.ts` keeps the code-point title cutoff and the visibility
  and collapsibility predicates. `service.get_announcement` /
  `update_announcement` stay in the client library but have no caller.

No manager-version fallback: the app-config reads need 26.9.0+, which the
LTS this ships in guarantees.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B13bqRK3V12jiCvia8fm2w
…p the per-save domainV2 lookup

`useUpdateDomainAppConfig` resolved the DOMAIN scope uuid with a `domainV2`
round-trip on every save because the client only stored the domain *name*
(`_config.domainName`, from the legacy `user { domain_name }` read).

The login GQL connection now reads `myUserV2` once — user, role, domain
(name + `entityId`) and active projects — instead of the `keypair` → `user`
→ `group.list` chain, and stores the uuid as `_config.domainId`. The setter
reads it through the new `useCurrentDomainId` hook; its unused `domainName`
override and the `useAppConfigDomainIdQuery` artifact go away.

`UserV2.projects` caps an unpaginated read at the manager's default page
size (10), so the login walks the cursor with `first: 100`. V2 ids are Relay
global ids, decoded with `toLocalId` for `user_uuid` / `groupIds`.

Dropped what nothing read: the stored `resource_policy` and
`need_password_change`, and the fetched-but-unstored `username` /
`is_active`. `groupIds` now holds only the user's projects (the legacy map
held every group in the domain); its sole reader, `current_group_id()`,
only ever looks up the user's current project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX2RuEAvsXWdxgi2MdaMsz
Fold `useCurrentDomainId` into `useCurrentDomain`, which returns the
domain name and uuid together, instead of a second single-value hook next
to `useCurrentDomainValue`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX2RuEAvsXWdxgi2MdaMsz
…se type is generated

The login read was a raw `client.query` string with a hand-written response
type. `RelayEnvironment`'s fetch function resolves `globalThis.backendaiclient`
at request time — the same signed `/admin/gql` request `client.query` makes —
and `connectViaGQL` installs the client before its first read, so the query
can be a `graphql` tag inside the function: Relay compiles it and generates
`loginSessionAuthMyUserQuery`, and the hand-written type goes away.

The test mocks the `RelayEnvironment` module with `createMockEnvironment()`
and queues one payload per page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX2RuEAvsXWdxgi2MdaMsz
…cement modal

- connectViaGQL walks `UserV2.projects` by `limit`/`offset`, bounded by the
  connection's `count`, so a manager that never ends its cursor cannot hang
  login; the query is a module constant like the sibling hooks.
- The announcement modal shows the required-title error only after the
  title was edited, with the detached status placement (no bordered block).
- The Enabled checkbox is replaced by a Publish split button whose menu
  offers Save as Draft (enabled: false); Delete stands alone on the left,
  and a saved draft is marked by a Draft token in the modal title.
- `body` is trimmed on save (an empty body is dropped from the document).
- i18n: `button.SaveAsDraft` and `summary.AnnouncementDraft` added,
  `summary.AnnouncementEnabled` removed, in all 21 locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCmZSe5Brr6F5CqB4XpHiS
The title field is a `BAIFormItem` inside `Form`, so its required error
renders like every other form field (plain text under the input) instead
of Astryx `TextInput`'s tinted status box, and the form's validateTrigger
keeps an untouched empty title error-free. Publish / Save as Draft call
`validateFields()` first; the custom `summary.AnnouncementTitleRequired`
key is dropped in favour of the global required template.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCmZSe5Brr6F5CqB4XpHiS
…legible

Banner centres its icon and actions only while it has no description, so
the expanded body pushed them to the top; the banner's header now keeps
`align-items: center` in both states. Markdown blockquotes inside the info
band used Blockquote's grey rule and secondary text, which the tint
swallowed; the announcement body (banner and editor preview, same class)
colours the rule with the band's info colour and the text with the primary
text colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCmZSe5Brr6F5CqB4XpHiS
The committed artifact is a function of the whole tree, so rebasing this
stack onto main left it stale on this layer. Regenerated with
`pnpm run search-index`; no source change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCmZSe5Brr6F5CqB4XpHiS
main added a second loading phase to this modal — the body mounts hidden so
Monaco's lazy chunk loads behind a Skeleton, and nothing is publishable until
the editor reports ready — while this branch was rewriting the same markup for
the domain app config. Resolving that conflict took the rewrite and lost the
gate. It is restored on the new shape: Suspense still covers the Relay read,
`isEditorReady` covers Monaco, and the test now stubs the app-config hooks
instead of the retired REST query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCmZSe5Brr6F5CqB4XpHiS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app-config area:i18n Localization area:ux UI / UX issue. size:XL 500~ LoC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App config: move the system announcement to the domain app config

2 participants