Skip to content

Conversation

@Junjiequan
Copy link
Member

@Junjiequan Junjiequan commented Dec 5, 2025

Description

This PR introduces runtime frontend configuration management with a dedicated Admin Dashboard, allowing admin users to edit frontend configuration live.

Motivation

  • Speeds up local development by removing the need to restart the backend for frontend configuration changes
  • Removes downtime for URL, token updates by applying changes at runtime
  • Allows more convenient experimentation with frontend behavior and backup
  • Easier configuration changes via JSON Forms, as predefined options and constraints can be enforced directly in the schema

Changes:

  • Added an Admin Dashboard that is accessible only to admin users
  • Implemented live frontend config editing
    • Edited config takes effect immediately after refresh
  • Integrated JSON Forms for config editing
    • It uses both JSON Schema and UI Schema for content edit
    • Any new frontend config requires updating schema + UI schema
  • Added JSON preview & Export
    • Opens edited config in a JSON viewer
    • Exports the edited config as a JSON file
  • Modified default JSON Forms Group renderer
    • Supports expand/collapse when expandable: true is set
  • Updated frontend config loading
    • loadAppConfig() now fetches from /runtime-config/frontendConfig endpoint
    • It requires backend change to create frontend config record from JSON file in the runtimeConfig collection

Tests included

  • Included for each change/fix?
  • Passing? (Merge will not be approved unless this is checked)

Documentation

  • swagger documentation updated [required]
  • official documentation updated [nice-to-have]

official documentation info

If you have updated the official documentation, please provide PR # and URL of the pages where the updates are included

Backend version

  • Does it require a specific version of the backend
  • which version of the backend is required:

Summary by Sourcery

Introduce an admin area with a JSONForms-based UI for viewing and editing runtime frontend configuration, backed by new NgRx runtime-config state and effects.

New Features:

  • Add an Admin dashboard with tabbed navigation and a configuration editor screen powered by JSONForms for live frontend config editing.
  • Expose an Admin route and menu entry protected by an enhanced AdminGuard that handles login state and redirects appropriately.
  • Provide JSON preview and export capabilities for the frontend configuration from the Admin UI.
  • Introduce expandable group JSONForms renderer and a JSON preview dialog module for richer configuration editing UX.

Bug Fixes:

  • Adjust AdminGuard behavior to wait for login resolution and redirect unauthenticated users to the login page instead of only showing a 401 page.

Enhancements:

  • Load app configuration from the new runtime-config API endpoint and adapt it to the existing AppConfig shape.
  • Extend JSONForms styling and layout to better fit boolean controls and card content, and add a beta badge style for the admin entry.
  • Export additional custom JSONForms renderer components for reuse across the app.

Chores:

  • Add NgRx runtime-config feature state with actions, reducer, selectors, and effects to manage loading and updating runtime configuration data.

dependabot bot and others added 4 commits November 25, 2025 11:28
Bumps the types group with 2 updates: [@types/jasmine](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jasmine) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/jasmine` from 5.1.12 to 5.1.13
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jasmine)

Updates `@types/node` from 24.10.0 to 24.10.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/jasmine"
  dependency-version: 5.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: types
- dependency-name: "@types/node"
  dependency-version: 24.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: types
...

Signed-off-by: dependabot[bot] <[email protected]>
Bumps [cypress](https://github.com/cypress-io/cypress) from 15.6.0 to 15.7.0.
- [Release notes](https://github.com/cypress-io/cypress/releases)
- [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md)
- [Commits](cypress-io/cypress@v15.6.0...v15.7.0)

---
updated-dependencies:
- dependency-name: cypress
  dependency-version: 15.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.1 to 3.14.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](nodeca/js-yaml@3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
@Junjiequan Junjiequan requested a review from a team as a code owner December 5, 2025 13:04
@Junjiequan Junjiequan marked this pull request as draft December 5, 2025 13:05
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes and found some issues that need to be addressed.

  • In admin-config-edit.component.html the top/bottom button bars are duplicated and all three actions (JSON preview, Export, Save) are wired to save(), which makes it hard to understand intended behavior; consider removing the duplication and splitting these into separate, clearly named handlers (e.g. onPreview(), onExport(), onSave()) or adding TODOs if not yet implemented.
  • The admin.reducer currently logs every [Admin] action via console.log, which will be noisy in production; consider removing this logging or guarding it behind an environment/feature flag.
  • Several new classes (e.g. AdminDashboardComponent, ExpandGroupRendererComponent) inject or import dependencies that are not used (ChangeDetectorRef, ActivatedRoute, UsersService, MatDialog, JsonFormsAngularService, JsonFormsControl), so it would be good to remove these to keep the code and DI graphs lean.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `admin-config-edit.component.html` the top/bottom button bars are duplicated and all three actions (JSON preview, Export, Save) are wired to `save()`, which makes it hard to understand intended behavior; consider removing the duplication and splitting these into separate, clearly named handlers (e.g. `onPreview()`, `onExport()`, `onSave()`) or adding TODOs if not yet implemented.
- The `admin.reducer` currently logs every `[Admin]` action via `console.log`, which will be noisy in production; consider removing this logging or guarding it behind an environment/feature flag.
- Several new classes (e.g. `AdminDashboardComponent`, `ExpandGroupRendererComponent`) inject or import dependencies that are not used (`ChangeDetectorRef`, `ActivatedRoute`, `UsersService`, `MatDialog`, `JsonFormsAngularService`, `JsonFormsControl`), so it would be good to remove these to keep the code and DI graphs lean.

## Individual Comments

### Comment 1
<location> `src/app/app-routing/admin.guard.ts:39-42` </location>
<code_context>
   ): Observable<boolean> {
-    return this.store.select(selectIsAdmin).pipe<boolean>(
-      map((isAdmin: boolean) => {
+    return this.store.select(selectIsLoggedIn).pipe(
+      filter((isLoggedIn) => isLoggedIn),
+      switchMap(() => this.store.select(selectIsAdmin)),
+      map((isAdmin) => {
         if (!isAdmin) {
           this.router.navigate(["/401"], {
</code_context>

<issue_to_address>
**issue (bug_risk):** Guard waits for a logged-in state but never resolves for anonymous users, which can leave navigation hanging.

Because the stream is filtered to only `true` values, `canActivate` never completes for anonymous users (or if `selectIsLoggedIn` never becomes `true`), so routing can hang instead of redirecting.

Handle the `false` case explicitly so the guard always resolves, e.g.:
- Take the first value from `selectIsLoggedIn` and branch on it, or
- Immediately navigate (e.g. to login/401/403) when `isLoggedIn === false` and return `false`.
</issue_to_address>

### Comment 2
<location> `src/app/_layout/app-header/app-header.component.html:83-85` </location>
<code_context>
   </mat-menu>

   <mat-menu #userMenu="matMenu">
+    <button mat-menu-item routerLink="/admin" data-cy="admin-button">
+      <mat-icon> build</mat-icon>
+      <span> Admin Settings <mat-chip class="beta-badge">Beta</mat-chip></span>
+    </button>
+
</code_context>

<issue_to_address>
**suggestion:** Admin menu entry is always shown, which leads non-admin users into a guarded route ending in a 401.

Since the route is guarded by `AdminGuard`, non-admins can still click this entry and get redirected to `/401`, which is a poor experience. Consider conditionally rendering or disabling this item for non-admin users (e.g. based on an `isAdmin` selector or feature flag), so only eligible users see it in the menu.

Suggested implementation:

```
  <mat-menu #userMenu="matMenu">

  <mat-menu #userMenu="matMenu">
    <button
      *ngIf="isAdmin$ | async"
      mat-menu-item
      routerLink="/admin"
      data-cy="admin-button"
    >
      <mat-icon> build</mat-icon>
      <span> Admin Settings <mat-chip class="beta-badge">Beta</mat-chip></span>
    </button>

    <button mat-menu-item routerLink="/user/" data-cy="setting-button">
      <mat-icon> settings</mat-icon>
      <span>Settings</span>

```

To fully implement this behavior, the component class (`app-header.component.ts`) will need:
1. An `isAdmin$` observable (e.g. `isAdmin$: Observable<boolean>;`) wired to your existing auth/permissions store or service (for example, via an NgRx selector like `this.isAdmin$ = this.store.select(selectIsAdmin)` or an auth service method).
2. Any required imports for the observable and state management/auth service being used.
If your codebase already exposes a different admin flag (e.g. `userIsAdmin$`, `canAccessAdmin$`), adjust the `*ngIf` expression to use that instead.
</issue_to_address>

### Comment 3
<location> `src/app/admin/admin-config-edit/admin-config-edit.component.html:4-10` </location>
<code_context>
+<div style="display: flex; justify-content: space-between; margin-bottom: 10px">
+  <!-- LEFT -->
+  <div>
+    <button mat-button color="primary" (click)="save()">Json Preview</button>
+  </div>
+
+  <!-- RIGHT -->
+  <div>
+    <button mat-button color="primary" (click)="save()">Export</button>
+    <button mat-button color="primary" (click)="save()">Save</button>
+  </div>
+</div>
</code_context>

<issue_to_address>
**issue (bug_risk):** All three actions (Json Preview, Export, Save) are wired to the same `save()` handler, which is confusing and likely incorrect.

Using the same `save()` handler for all three buttons prevents different logic for preview, export, and save, and is probably unintended. Please either give each button a dedicated handler (e.g. `openPreview()`, `export()`, `save()`) or pass a mode into `save` (e.g. `(click)="save('preview')"`) and branch on that inside the method.
</issue_to_address>

### Comment 4
<location> `src/app/admin/admin-config-edit/admin-config-edit.component.ts:45-54` </location>
<code_context>
+  currentData: any = {};
</code_context>

<issue_to_address>
**issue (bug_risk):** Saving before any change sends an empty object instead of the loaded configuration.

Because `currentData` is initialized as `{}` and only updated in `onChange`, calling `save()` before any `dataChange` will persist `{}` and wipe the existing config. Initialize `currentData` from the loaded config (e.g. first emission from `data$`/`config$`), or have `save()` fall back to the latest value from `data$` when `currentData` is still the initial `{}`.
</issue_to_address>

### Comment 5
<location> `src/app/state-management/reducers/admin.reducer.ts:28-29` </location>
<code_context>
+);
+
+export const adminReducer = (state: AdminState | undefined, action: Action) => {
+  if (action.type.indexOf("[Admin]") !== -1) {
+    console.log("Action came in! " + action.type);
+  }
+  return reducer(state, action);
</code_context>

<issue_to_address>
**suggestion (performance):** Reducer logs every admin action to the console, which is noisy and not ideal for production.

If this is for debugging, consider using NgRx store devtools, guarding the `console.log` with an environment/debug flag, or moving the logging into a meta-reducer so it doesn’t run unconditionally in production.

Suggested implementation:

```typescript
export const adminReducer = (state: AdminState | undefined, action: Action) => {
  if (!environment.production && action.type.includes('[Admin]')) {
    // Debug logging for admin actions; disabled in production builds
    // eslint-disable-next-line no-console
    console.log('Admin action dispatched:', action.type, action);
  }
  return reducer(state, action);
};

```

To make this compile, also add an environment import at the top of the same file:

- Add:
`import { environment } from '../../../environments/environment';`

Place it alongside the other imports in `admin.reducer.ts`, respecting the existing import ordering conventions (typically third-party imports first, then application imports).
</issue_to_address>

### Comment 6
<location> `src/app/admin/admin-dashboard/admin-dashboard.component.ts:32` </location>
<code_context>
+    paths: "exact",
+  };
+
+  fetchDataActions: { [tab: string]: { action: any; loaded: boolean } } = {
+    [TAB.configuration]: { action: "", loaded: false },
+    [TAB.usersList]: { action: "", loaded: false },
</code_context>

<issue_to_address>
**issue (complexity):** Consider removing the premature tab-loading abstraction (TAB enum usage, fetchDataActions, and fetchDataForTab) to keep the component focused on its current, simple behavior.

You can simplify this component by removing the unused tab-loading abstraction until it’s actually needed. Right now `TAB`, `fetchDataActions`, and `fetchDataForTab` introduce indirection without behavior.

### 1. Remove `fetchDataActions` and `fetchDataForTab` (for now)

They don’t do anything meaningful yet and just add cognitive load.

```ts
export class AdminDashboardComponent implements OnInit {
  showError = false;
  navLinks: {
    location: string;
    label: string;
    icon: string;
    enabled: boolean;
  }[] = [];

  routerLinkActiveOptions: IsActiveMatchOptions = {
    matrixParams: "ignored",
    queryParams: "ignored",
    fragment: "ignored",
    paths: "exact",
  };

  constructor(
    public appConfigService: AppConfigService,
    private cdRef: ChangeDetectorRef,
    private route: ActivatedRoute,
    private userService: UsersService,
    public dialog: MatDialog,
  ) {}

  ngOnInit(): void {
    this.navLinks = [
      {
        location: "./configuration",
        label: TAB.configuration,
        icon: "menu",
        enabled: true,
      },
      {
        location: "./usersList",
        label: TAB.usersList,
        icon: "data_object",
        enabled: true,
      },
    ];
  }

  onTabSelected(tab: string) {
    // For now, if specific tabs need loading, handle them directly here:
    if (tab === TAB.configuration) {
      // load configuration data
    } else if (tab === TAB.usersList) {
      // load users list data
    }
  }
}
```

When you actually need generic tab-loading, you can reintroduce a minimal and concrete abstraction informed by real use-cases (e.g., only once multiple tabs share the same loading pattern).

### 2. Optionally simplify label handling

If `TAB` is only used for labels and not for any logic, you can also drop the enum and use literals directly to reduce indirection:

```ts
ngOnInit(): void {
  this.navLinks = [
    {
      location: "./configuration",
      label: "Configuration",
      icon: "menu",
      enabled: true,
    },
    {
      location: "./usersList",
      label: "Users List",
      icon: "data_object",
      enabled: true,
    },
  ];
}
```

You can keep the enum if you expect these labels to be reused elsewhere soon, but otherwise literals keep the component straightforward.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Junjiequan Junjiequan changed the title Swap 5087 scicat fe poc render frontend config with json fo feat(POC): implement live frontend config editing UI Jan 6, 2026
@Junjiequan Junjiequan marked this pull request as ready for review January 7, 2026 08:56
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 5 issues, and left some high level feedback:

  • In AdminDashboardComponent.ngOnInit, the this.route.firstChild?.url.subscribe(...).unsubscribe() pattern will unsubscribe immediately and likely never react to the URL; consider using pipe(take(1)) or storing/cleaning up the subscription properly.
  • The AdminGuard is applied both on the /admin route in app-routing.module.ts and again on the parent route in AdminRoutingModule, which is redundant; you can remove one of these to simplify the routing configuration.
  • The runtimeConfigReducer logs every [RunTimeConfig] action with console.log, which can be noisy in production; consider removing this or wrapping it in an environment/debug flag.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `AdminDashboardComponent.ngOnInit`, the `this.route.firstChild?.url.subscribe(...).unsubscribe()` pattern will unsubscribe immediately and likely never react to the URL; consider using `pipe(take(1))` or storing/cleaning up the subscription properly.
- The `AdminGuard` is applied both on the `/admin` route in `app-routing.module.ts` and again on the parent route in `AdminRoutingModule`, which is redundant; you can remove one of these to simplify the routing configuration.
- The `runtimeConfigReducer` logs every `[RunTimeConfig]` action with `console.log`, which can be noisy in production; consider removing this or wrapping it in an environment/debug flag.

## Individual Comments

### Comment 1
<location> `src/app/admin/admin-config-edit/admin-config-edit.component.ts:67-68` </location>
<code_context>
+    );
+  }
+
+  onChange(event: any) {
+    this.currentData = event;
+  }
+
</code_context>

<issue_to_address>
**issue (bug_risk):** The `dataChange` handler likely needs `event.data` rather than the raw event object.

In JSONForms, `(dataChange)` usually emits an object like `{ data, errors }`, so `this.currentData = event` will store the wrapper instead of the actual form data. Please change this to `this.currentData = event.data` (and update the type) so `currentData` always holds the plain form data used by `toApiData`.
</issue_to_address>

### Comment 2
<location> `src/app/admin/admin-config-edit/admin-config-edit.component.ts:93-97` </location>
<code_context>
+
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = `frontend-config-${new Date().toLocaleString("sv-SE")}.json`;
+    a.click();
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Using `toLocaleString("sv-SE")` in the filename may introduce characters invalid on some filesystems.

This will produce values with spaces and colons (e.g. `2026-01-07 13:45:00`), which are invalid in filenames on some platforms (e.g. Windows). Prefer a sanitized, filesystem-safe timestamp such as `new Date().toISOString().replace(/[:.]/g, "-")` or a custom `YYYYMMDD_HHmmss` format.

```suggestion
    const blob = new Blob([json], { type: "application/json;charset=utf-8" });
    const url = URL.createObjectURL(blob);

    const a = document.createElement("a");
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    a.href = url;
    a.download = `frontend-config-${timestamp}.json`;
    a.click();
    URL.revokeObjectURL(url);
```
</issue_to_address>

### Comment 3
<location> `src/app/state-management/reducers/runtime-config.reducer.ts:31-32` </location>
<code_context>
+  state: RuntimeConfigState | undefined,
+  action: Action,
+) => {
+  if (action.type.indexOf("[RunTimeConfig]") !== -1) {
+    console.log("Action came in! " + action.type);
+  }
+  return reducer(state, action);
</code_context>

<issue_to_address>
**suggestion (performance):** Reducer-level `console.log` for every runtime-config action can be noisy and impact performance.

Since reducers run on every matching action, this log will execute frequently, clutter the console, and slightly degrade performance in production. If it’s only for debugging, consider removing it, wrapping it in an environment check, or relying on NgRx Store DevTools to keep the reducer pure and free of side effects.
</issue_to_address>

### Comment 4
<location> `src/app/app-config.service.ts:222-227` </location>
<code_context>
     try {
-      const config = await this.http
-        .get("/api/v3/admin/config")
+      const res = await this.http
+        .get("/api/v3/runtime-config/data/frontendConfig")
         .pipe(timeout(2000))
         .toPromise();
+
+      const config = (res as OutputRuntimeConfigDto).data;
       this.appConfig = Object.assign({}, this.appConfig, config);
     } catch (err) {
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Narrowing `res` via a cast assumes the runtime shape without validation, which may hide integration issues.

The cast `(res as OutputRuntimeConfigDto).data` assumes the response always matches `OutputRuntimeConfigDto` and will fail silently at compile time if the endpoint changes or returns a different shape (e.g. error payload). Prefer typing the request as `this.http.get<OutputRuntimeConfigDto>(...)` so the compiler enforces the contract, and add a check for `res?.data` before merging into `appConfig` to avoid runtime errors.
</issue_to_address>

### Comment 5
<location> `src/app/admin/admin-dashboard/admin-dashboard.component.ts:7` </location>
<code_context>
+import { Store } from "@ngrx/store";
+import { AppConfigService } from "app-config.service";
+import { loadConfiguration } from "state-management/actions/runtime-config.action";
+enum TAB {
+  configuration = "Configuration",
+  usersList = "Users List",
</code_context>

<issue_to_address>
**issue (complexity):** Consider consolidating tab configuration, data loading, and routing into a single tab config structure while simplifying subscriptions and removing unused dependencies to reduce indirection and duplication.

You can simplify this without losing any current or future functionality.

**1. Use a single source of truth for tabs (config array)**

Instead of the `TAB` enum + `navLinks` + `fetchDataActions`, store everything in one array, including any lazy-load metadata:

```ts
type AdminTabId = 'configuration' | 'usersList';

interface AdminTabConfig {
  id: AdminTabId;               // route path segment
  label: string;                // display label
  icon: string;
  enabled: boolean;
  loaded?: boolean;
  loadAction?: (payload?: any) => any;
}

tabs: AdminTabConfig[] = [
  {
    id: 'configuration',
    label: 'Configuration',
    icon: 'settings',
    enabled: true,
    loaded: false,
    loadAction: loadConfiguration,
  },
  // {
  //   id: 'usersList',
  //   label: 'Users List',
  //   icon: 'people',
  //   enabled: true,
  //   loaded: false,
  //   loadAction: loadUsersList, // future
  // },
];
```

Then `navLinks` can be derived (or you can use `tabs` directly in the template), so you don’t maintain multiple structures:

```ts
navLinks = this.tabs.map(tab => ({
  location: `./${tab.id}`,
  label: tab.label,
  icon: tab.icon,
  enabled: tab.enabled,
}));
```

**2. Remove the enum indirection and the `fetchDataActions` map**

With the config above, you don’t need `TAB` or `fetchDataActions`. The fetch logic becomes a simple lookup on `id`:

```ts
private fetchDataForTabId(tabId: AdminTabId) {
  const tab = this.tabs.find(t => t.id === tabId);
  if (!tab || !tab.loadAction || tab.loaded) {
    return;
  }

  tab.loaded = true;

  // Per-tab payload is still easy to customize:
  if (tab.id === 'configuration') {
    this.store.dispatch(tab.loadAction({ id: 'frontendConfig' }));
  } else {
    this.store.dispatch(tab.loadAction());
  }
}
```

`onTabSelected` can pass the tab id (route segment) instead of the label:

```ts
onTabSelected(tabId: AdminTabId) {
  this.fetchDataForTabId(tabId);
}
```

**3. Simplify the route URL subscription**

Avoid subscribe-then-immediately-unsubscribe; use `take(1)` and work with route paths directly:

```ts
import { take } from 'rxjs/operators';

ngOnInit(): void {
  this.route.firstChild?.url
    .pipe(take(1))
    .subscribe(childUrl => {
      const tabId = (childUrl.length === 1 ? childUrl[0].path : 'configuration') as AdminTabId;
      this.fetchDataForTabId(tabId);
    });
}
```

**4. Drop unused injected dependencies**

If `ChangeDetectorRef` and `MatDialog` are not (yet) used, remove them to reduce noise; you can add them back when needed:

```ts
constructor(
  public appConfigService: AppConfigService,
  private route: ActivatedRoute,
  private store: Store,
) {}
```

This keeps all current behavior (lazy loading the configuration tab exactly once) while reducing indirection and duplication, and it still scales cleanly when you add more tabs.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Junjiequan
Copy link
Member Author

Screenshare.-.2026-01-07.11_32_40.AM.mp4
Screenshare.-.2026-01-07.11_44_01.AM.mp4

@Junjiequan Junjiequan added the ESS label Jan 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants