Skip to content

Commit e91b4fe

Browse files
jikim-msftJosmithrCopilot
authored
feat(devtools-view): Add interactive UI for devtools-view (#25189)
#### Description Part 3 of [24885](#24885) This PR primarily adds intuitive UI effects for the `devtools-view` package. Changes include: - Render a red icon on the left of the Container item to show the item with the latest change in underlying data - Render Container state icon (disconnect & closed) - Supports removingt the container instance with closed state - Added the data-visualization `reason` in `devtools-core` package to distinguish between user clicking the devtools-pane and the actual data change # [Demo Video](https://microsoft-my.sharepoint-df.com/:v:/p/jihyungkim/Efefvu4-1ExNpEuNOnYodDIBWkACRi6BZTmsGHx_Ppfn8g?e=MG4jqI&nav=eyJyZWZlcnJhbEluZm8iOnsicmVmZXJyYWxBcHAiOiJTdHJlYW1XZWJBcHAiLCJyZWZlcnJhbFZpZXciOiJTaGFyZURpYWxvZy1MaW5rIiwicmVmZXJyYWxBcHBQbGF0Zm9ybSI6IldlYiIsInJlZmVycmFsTW9kZSI6InZpZXcifX0%3D) --------- Co-authored-by: Joshua Smithrud <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 5632bb7 commit e91b4fe

File tree

12 files changed

+635
-84
lines changed

12 files changed

+635
-84
lines changed

packages/tools/devtools/devtools-core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,10 @@
117117
"@fluidframework/cell": "workspace:~",
118118
"@fluidframework/container-definitions": "workspace:~",
119119
"@fluidframework/container-loader": "workspace:~",
120+
"@fluidframework/container-runtime": "workspace:~",
120121
"@fluidframework/container-runtime-definitions": "workspace:~",
121122
"@fluidframework/core-interfaces": "workspace:~",
123+
"@fluidframework/core-utils": "workspace:~",
122124
"@fluidframework/counter": "workspace:~",
123125
"@fluidframework/datastore-definitions": "workspace:~",
124126
"@fluidframework/driver-definitions": "workspace:~",

packages/tools/devtools/devtools-core/src/BaseDevtools.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export abstract class BaseDevtools<TContainer extends DecomposedContainer>
124124
return {
125125
containerKey: this.containerKey,
126126
attachState: this.container.attachState,
127+
isReadOnly: this.container.readOnlyInfo?.readonly,
127128
connectionState: this.container.connectionState,
128129
closed: this.container.closed,
129130
clientId: this.container.clientId,
@@ -223,7 +224,11 @@ export abstract class BaseDevtools<TContainer extends DecomposedContainer>
223224

224225
protected readonly dataUpdateHandler = (visualization: FluidObjectNode): void => {
225226
// This is called when actual data changes occur - should trigger blinking
226-
this.postDataVisualization(visualization.fluidObjectId, visualization);
227+
this.postDataVisualization(
228+
visualization.fluidObjectId,
229+
visualization,
230+
DataVisualization.UpdateReason.DataChanged,
231+
);
227232
};
228233

229234
// #endregion
@@ -274,7 +279,11 @@ export abstract class BaseDevtools<TContainer extends DecomposedContainer>
274279
if (message.data.containerKey === this.containerKey) {
275280
const visualization = await this.getDataVisualization(message.data.fluidObjectId);
276281
// This is a user-requested visualization - should NOT trigger blinking
277-
this.postDataVisualization(message.data.fluidObjectId, visualization);
282+
this.postDataVisualization(
283+
message.data.fluidObjectId,
284+
visualization,
285+
DataVisualization.UpdateReason.UserRequested,
286+
);
278287
return true;
279288
}
280289
return false;
@@ -371,13 +380,15 @@ export abstract class BaseDevtools<TContainer extends DecomposedContainer>
371380
protected readonly postDataVisualization = (
372381
fluidObjectId: FluidObjectId,
373382
visualization: FluidObjectNode | undefined,
383+
reason: DataVisualization.UpdateReason,
374384
): void => {
375385
postMessagesToWindow(
376386
this.messageLoggingOptions,
377387
DataVisualization.createMessage({
378388
containerKey: this.containerKey,
379389
fluidObjectId,
380390
visualization,
391+
reason,
381392
}),
382393
);
383394
};

packages/tools/devtools/devtools-core/src/ContainerMetadata.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export interface ContainerStateMetadata extends HasContainerKey {
2929
*/
3030
connectionState: ConnectionState;
3131

32+
/**
33+
* Whether or not the Container is in read-only mode.
34+
*
35+
* @remarks Will be undefined if the readonly state is not yet known.
36+
*/
37+
isReadOnly?: boolean;
38+
3239
/**
3340
* {@inheritDoc @fluidframework/container-definitions#IContainer.clientId}
3441
*/

packages/tools/devtools/devtools-core/src/ContainerRuntimeDevtools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import type {
1010
IContainerEvents,
1111
} from "@fluidframework/container-definitions/internal";
1212
import { ConnectionState } from "@fluidframework/container-loader";
13+
import { ContainerRuntime } from "@fluidframework/container-runtime/internal";
1314
import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal";
1415
import type { IFluidLoadable } from "@fluidframework/core-interfaces";
16+
import { assert } from "@fluidframework/core-utils/internal";
1517

1618
import { BaseDevtools } from "./BaseDevtools.js";
1719
import type { ContainerKey } from "./CommonInterfaces.js";
@@ -155,10 +157,20 @@ export class DecomposedContainerForContainerRuntime
155157
}
156158

157159
public get connectionState(): ConnectionState {
160+
// Normal connection state logic - readonly state is handled separately via readOnlyInfo
158161
return this.runtime.connected ? ConnectionState.Connected : ConnectionState.Disconnected;
159162
}
160163

161164
public get closed(): boolean {
162165
return this._disposed; // IContainerRuntime doesn't have a "closed" state - only "disconnected" (reconnectable) and "disposed" (permanent)
163166
}
167+
168+
public get readOnlyInfo(): { readonly readonly?: boolean } {
169+
// IContainerRuntime doesn't expose readonly in its interface, but the implementation has isReadOnly()
170+
assert(
171+
this.runtime instanceof ContainerRuntime,
172+
"DecomposedContainerForContainerRuntime is not a ContainerRuntime",
173+
);
174+
return { readonly: this.runtime.isReadOnly() };
175+
}
164176
}

packages/tools/devtools/devtools-core/src/DecomposedContainer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,9 @@ export interface DecomposedContainer extends IEventProvider<IContainerEvents> {
5656
* {@inheritDoc @fluidframework/container-definitions#IContainer.close}
5757
*/
5858
close?(error?: ICriticalContainerError): void;
59+
60+
/**
61+
* {@inheritDoc @fluidframework/container-definitions#IContainer.readOnlyInfo}
62+
*/
63+
readonly readOnlyInfo?: { readonly readonly?: boolean };
5964
}

packages/tools/devtools/devtools-core/src/messaging/container-devtools-messages/DataVisualization.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ export namespace DataVisualization {
2020
*/
2121
export const MessageType = "DATA_VISUALIZATION";
2222

23+
/**
24+
* Reasons why a DataVisualization message is being sent.
25+
*
26+
* @internal
27+
*/
28+
export const enum UpdateReason {
29+
/**
30+
* Visualization was requested by user interaction (e.g., clicking to expand tree).
31+
*/
32+
UserRequested = "userRequested",
33+
34+
/**
35+
* Visualization updated due to actual data changes in the underlying shared object.
36+
*/
37+
DataChanged = "dataChanged",
38+
}
39+
2340
/**
2441
* Message data format used by {@link DataVisualization.Message}.
2542
*
@@ -33,6 +50,11 @@ export namespace DataVisualization {
3350
* {@link HasFluidObjectId.fluidObjectId | ID}.
3451
*/
3552
visualization: FluidObjectNode | undefined;
53+
54+
/**
55+
* Reason for this visualization update.
56+
*/
57+
reason: UpdateReason;
3658
}
3759

3860
/**

packages/tools/devtools/devtools-view/src/ContainerFeatureFlagHelper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const ContainerFeatureFlagContext = React.createContext<
2929
export function useContainerFeaturesContext(): ContainerFeatureFlagContextData {
3030
const context = React.useContext(ContainerFeatureFlagContext);
3131
if (context === undefined) {
32-
throw new Error("TODO");
32+
throw new Error("ContainerFeatureFlagContext not found");
3333
}
3434
return context;
3535
}

packages/tools/devtools/devtools-view/src/DevtoolsView.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { FluentProvider, makeStyles, shorthands, tokens } from "@fluentui/react-components";
77
import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces";
88
import {
9+
CloseContainer,
910
type ContainerKey,
1011
ContainerList,
1112
type DevtoolsFeatureFlags,
@@ -275,13 +276,28 @@ function _DevtoolsView(props: _DevtoolsViewProps): React.ReactElement {
275276

276277
const styles = useDevtoolsStyles();
277278

279+
/**
280+
* Handles removing a container from the list when the dismiss button is clicked.
281+
*/
282+
const handleRemoveContainer = React.useCallback(
283+
(containerKey: ContainerKey): void => {
284+
// Send message to client to remove container from tracking
285+
messageRelay.postMessage(CloseContainer.createMessage({ containerKey }));
286+
287+
// Update local state to remove container from view
288+
setContainers((prev) => prev?.filter((key) => key !== containerKey));
289+
},
290+
[messageRelay],
291+
);
292+
278293
return (
279294
<div className={styles.root}>
280295
<Menu
281296
currentSelection={menuSelection}
282297
setSelection={setMenuSelection}
283298
containers={containers}
284299
supportedFeatures={supportedFeatures}
300+
onRemoveContainer={handleRemoveContainer}
285301
/>
286302
<div style={{ width: "1px", backgroundColor: tokens.colorNeutralForeground1 }}></div>
287303
<View menuSelection={menuSelection} containers={containers} />

packages/tools/devtools/devtools-view/src/components/ContainerSummaryView.tsx

Lines changed: 60 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -139,44 +139,53 @@ function DataRow(props: DataRowProps): React.ReactElement {
139139
);
140140
}
141141

142+
function getStatusBadgeColor(status: string): "success" | "warning" | "danger" | "subtle" {
143+
switch (status) {
144+
case "Closed":
145+
case "Detached":
146+
case "Disconnected": {
147+
return "danger";
148+
}
149+
case "Attaching":
150+
case "Establishing connection":
151+
case "Catching up": {
152+
return "warning";
153+
}
154+
case "Connected":
155+
case "Attached": {
156+
return "success";
157+
}
158+
case "Read-only": {
159+
return "subtle";
160+
}
161+
default: {
162+
return "subtle";
163+
}
164+
}
165+
}
166+
142167
function containerStatusValueCell(statusComponents: string[]): React.ReactElement {
143-
return (
144-
<TableCellLayout
145-
media={((): JSX.Element => {
146-
switch (statusComponents[0]) {
147-
case AttachState.Attaching: {
148-
return (
149-
<Badge shape="rounded" color="warning">
150-
{statusComponents[0]}
151-
</Badge>
152-
);
153-
}
154-
case AttachState.Detached: {
155-
return (
156-
<Badge shape="rounded" color="danger">
157-
{statusComponents[0]}
158-
</Badge>
159-
);
160-
}
161-
default: {
162-
return (
163-
<Badge shape="rounded" color="success">
164-
{statusComponents[0]}
165-
</Badge>
166-
);
167-
}
168-
}
169-
})()}
170-
>
171-
{statusComponents[1] === "Connected" ? (
172-
<Badge shape="rounded" color="success">
173-
{statusComponents[1]}
168+
// Show all states simultaneously in a single container
169+
if (statusComponents.length === 0) {
170+
return (
171+
<TableCellLayout>
172+
<Badge shape="rounded" color="subtle">
173+
Unknown
174174
</Badge>
175-
) : (
176-
<Badge shape="rounded" color="danger">
177-
{statusComponents[1]}
178-
</Badge>
179-
)}
175+
</TableCellLayout>
176+
);
177+
}
178+
179+
// Create a flex container to show all status badges side by side
180+
return (
181+
<TableCellLayout>
182+
<div style={{ display: "flex", gap: "4px", flexWrap: "wrap" }}>
183+
{statusComponents.map((status, index) => (
184+
<Badge key={index} shape="rounded" color={getStatusBadgeColor(status)}>
185+
{status}
186+
</Badge>
187+
))}
188+
</div>
180189
</TableCellLayout>
181190
);
182191
}
@@ -295,22 +304,28 @@ export function ContainerSummaryView(props: ContainerSummaryViewProps): React.Re
295304
usageLogger?.sendTelemetryEvent({ eventName: "CloseContainerButtonClicked" });
296305
}
297306

298-
// Build up status string
307+
// Build up status string - show all states simultaneously
299308
const statusComponents: string[] = [];
300-
if (closed) {
309+
if (containerState.closed) {
301310
statusComponents.push("Closed");
302311
} else {
312+
// Always show attach state
303313
statusComponents.push(containerState.attachState);
304-
if (containerState.attachState === AttachState.Attached) {
314+
315+
// Show connection state if applicable (regardless of readonly state)
316+
if (
317+
containerState.attachState === AttachState.Attached &&
318+
containerState.connectionState !== undefined
319+
) {
305320
statusComponents.push(connectionStateToString(containerState.connectionState));
306-
} else {
307-
/*
308-
* If the container is not attached, it is not connected
309-
* TODO: If the container is detached, it is advisable to disable the action buttons
310-
* since Fluid will consistently fail to establish a connection with a detached container.
311-
*/
321+
} else if (containerState.attachState !== AttachState.Attached) {
322+
// If not attached, show disconnected state
312323
statusComponents.push(connectionStateToString(ConnectionState.Disconnected));
313324
}
325+
326+
if (containerState.isReadOnly === true) {
327+
statusComponents.push("Read-only");
328+
}
314329
}
315330

316331
return (

0 commit comments

Comments
 (0)