winglang / wing

A programming language for the cloud ☁️ A unified programming model, combining infrastructure and runtime code into one language ⚡
https://winglang.io
Other
5.05k stars 198 forks source link

Define how a custom resource interaction panel will look like in the Wing Console #4493

Closed ainvoner closed 1 year ago

ainvoner commented 1 year ago

Feature Spec

As part of my custom resource development in Wing, I'm also able to describe how it's interaction panel will look like in Wing Console.

(I prefer Option 2)

Option 1

very general purpose and flexible, but too difficult to use. I think we should be more opinionated

// component-types.ts
type ComponentType = 'label' | 'input' | 'link' | 'group' | 'layout';  //will be added according to our needs

interface BaseMeta {
    type: ComponentType;
}

interface LabelMeta extends BaseMeta {
    label: string;
}

interface InputMeta extends BaseMeta {
    type: 'input';
    value?: string;
    placeholder?: string;
    readonly?: boolean;
}

interface LinkMeta extends BaseMeta {
    type: 'link';
    href: string;
    target?: string;
    rel?: "noopener" | "noreferrer";
}

interface GroupMeta extends BaseMeta {
    type: 'group';
    label?: string; // Label for the group (e.g., "Api URLs")
    components: Meta[];
}

interface LayoutMeta extends BaseMeta {
    type: 'layout';
    layout: 'vertical' | 'horizontal';
    components: GroupMeta[];
}

type Meta = LabelMeta | InputMeta | CheckboxMeta | GroupMeta | LayoutMeta;

Usage in typescript (with LayoutMeta)

// sim/reverse-proxy.ts

...
...

Node.of(this).display.meta = {
    "type": "layout",
    "layout": "vertical",
    "components": [
        {
            "type": "group",
            "label": "urls",
            "components": [
                {
                    "type": "layout",
                    "layout": "horizontal",
                    "components": [
                        {
                            "type": "group",
                            "components": [
                                {
                                    "type": "label",
                                    "label": "main url"
                                },
                                {
                                    "type": "link",
                                    "href": this.urls[0]
                                }
                            ]
                        }
                    ]
                },
                {
                    "type": "layout",
                    "layout": "horizontal",
                    "components": [
                        {
                            "type": "group",
                            "components": [
                                {
                                    "type": "label",
                                    "label": "another url"
                                },
                                {
                                    "type": "link",
                                    "href": this.urls[1]
                                }
                            ]
                        }
                    ]
                }
            ]
        }
    ]
}

Option 2

Opinionated UI structure for each UI component type

// component-types.ts
type ComponentType = 'label' | 'input' | 'link' | 'group'';  //will be added according to our needs

interface BaseMeta {
    type: ComponentType;
    layout: 'horizontal' | 'vertical'
}

// will create a UI component similar to https://tailwindui.com/components/application-ui/forms/input-groups#component-04a268fee12196d74c94e82a43106f8d
interface InputMeta extends BaseMeta {
    type: 'input';
    label?: string;
    layout: 'horizontal' | 'vertical'
    value?: string;
    placeholder?: string;
    readonly?: boolean;
}

interface LinkMeta extends BaseMeta {
    type: 'link';
    label?: string
    layout: 'horizontal' | 'vertical'
    href: string;
    target?: string;
    rel?: "noopener" | "noreferrer";
}

interface GroupMeta extends BaseMeta {
    type: 'group';
    label?: string; // Label for the group (e.g., "Api URLs")
    layout: 'horizontal' | 'vertical'
    components: Meta[];
}

type Meta = LabelMeta | InputMeta | CheckboxMeta | GroupMeta;

Usage in typescript (with LayoutMeta)

// sim/reverse-proxy.ts

...
...

Node.of(this).display.meta =
    {
        "type": "group",
        "label": "urls",
        "layout": "vertical"
        "components": [
            {
                "type": "link",
                "href": this.urls[0],
                "label": "main url",
                "layout": "horizontal"
            },
            {
                "type": "link",
                "href": this.urls[1],
                "label": "secondary url",
                "layout": "horizontal"
            }
        ]
    }

Use Cases

Add ability to interact with new custom resources from the Wing Console

Implementation Notes

No response

Component

SDK, Wing Console

Community Notes

skyrpex commented 1 year ago

The only difference between both options is the layout being in BaseMeta? Anyway, I think it should be implemented in a slightly different way.

I agree in that we need a type: string, but it should be extendable in userland as well. Because of that, we should name our base types with something like io.winglang.input, or cloud.wing.input, or whatever. Userland types will use their own domains for their own types. Not sure how userland types will be implemented in the Console, yet.

Now, I don't think it makes sense to carry the layout property everywhere. I believe Option 2 is the better option, since it's flexible and not hard at all to use IMO.

For starters, I'd like to restrict it to a list of Group[], and remove the Layout type. So, a resource would only provide a list of groups with the other items in it.

The communication between the Console and the Simulator goes both ways (eg, a button will do something inside a resource). I think our meta should allow to be used in the context of a long-running router, like this:

// sim/reverse-proxy.ts

...
...

Node.of(this).display.meta = {
    "type": "group",
    "label": "urls",
    "layout": "vertical"
    "components": [
        {
            "type": "link",
            // Can be accessed anytime.
            "href": () => this.urls[0],
            "label": "main url",
        },
        {
            "type": "input",
            "value": () => this.urls[1],
            onEdit: (value) => {
                // do something. this is inflight.
            },
            "label": "secondary url"
        }
    ]
}

Finally, instead of having the developers to use this format directly, we should offer a small SDK that can construct it instead. For example:

Node.of(this).display.meta = [
    meta.group({
        "label": "urls",
        "components": [
            meta.link({
                // Can be accessed anytime.
                "href": () => this.urls[0],
                "label": "main url",
            }),
            meta.input({
                "value": () => this.urls[1],
                onEdit: (value) => {
                    // do something. this is inflight.
                },
                "label": "secondary url",
            }),
        ],
    }),
];

Before deciding anything, I'd suggest we read how Laravel Nova works: it allows developers to provide an admin panel for the Laravel PHP backend. It's the same concept as the Console for the simulated resources.

ainvoner commented 1 year ago

@skyrpex I don't really follow this part:

I agree in that we need a type: string, but it should be extendable in userland as well

I think we should define the building blocks as part of our design system. Do you think others should be able to use their UI components as well? How can we guarantee it will look good in the Console?

ainvoner commented 1 year ago

I do agree that a variation of Option 2 would be better

ainvoner commented 1 year ago

react - json standards: https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema/ https://github.com/rjsf-team/react-jsonschema-form https://www.npmjs.com/package/@rjsf/core

some references: https://developer.android.com/jetpack/compose/layouts/basics https://nova.laravel.com/docs/4.0/resources/ https://github.com/facebook/yoga

ainvoner commented 1 year ago

I do feel that the interfaces should be defined according to our design system components.

For example: Design System component: Link = (label, url) => {<Link Component>} Wing SDK: Display.addLink(label, url); Compile to sim target: we can follow uiSchema or create something simpler.

Defining a more complex Layout and Group components compositions can be done in the future (I don't see any need at the moment)

skyrpex commented 1 year ago

I agree. Maybe for complex UIs such as the File Manager component for Buckets, we could use a very specific component for now, and then think later if we want to define more specific components such as Layouts, Groups, etc.

BTW I'll have a look at the uiSchema

Edit: I'd prefer to start simple, without uiSchema, something similar to your Option 2.

Chriscbr commented 1 year ago

Sharing some ideas from a conversation I had with @skyrpex:

I feel like a lot of users might want to have a way to configure resource interactions without specifying how they're displayed (like layouts or formatting). For example, maybe it could be handy to be able to define some "quick actions" and labeled information for my resource that users can interact with in the Wing Console, without the library author needing to think about "will this information fit?" or "what order should the information be laid out?". Here's a sketch of what a high-level API could look like in Wing.

For example in my made-up Widget service, I might want to configure some simple actions and fields like this:

class WidgetService {
  init() {
    // ...

    this.display.addAction(kind: Action.Button, fn: this.redriveFailures);
    this.display.addAction(kind: Action.Form, fn: this.makeWidget);
    this.display.addField(label: "Widgets produced in the last hour", getter: this.getWidgets, refreshEvery: 5s);

    // future (P2)
    this.display.addReactView("./widget-interaction.tsx"); // ???
  }

  inflight redriveFailures() {
    // ...
  }

  inflight makeWidget(name: str) {
    // ...
  }

  inflight getWidgets(): str {
    // ...
  }
}

The Wing Console has access to the simulator, so it could be pretty straightforward to integrate the logic there. For example, the list of actions and fields would be stored in simulator.json or tree.json, and the Wing Console will access it via a set of APIs, e.g.

simulator.listResourceInteractions("root/Stack1/WidgetService")
simulator.describeResourceInteraction("root/Stack1/WidgetService", "makeWidget")
simulator.runResourceInteraction("root/Stack1/WidgetService", "makeWidget", options)

Maybe an API like this could offer a lot of flexibility for redesigning the Wing Console UI in the future, while simplifying the experience for Wing users. Anyway these are just some ideas I wanted to put out there, excited to see what y'all come up with 👍

ainvoner commented 1 year ago

This is what I came up with: A new IDisplayableResource interface:

export interface IDisplayableResource {
    /**
     * Returns a visual model for this resource.
     * @returns a visual model for this resource.
     * @inflight
     */
    visualModel(): Promise<VisualModel | undefined>;
}

That every resource can implement. For example

website.inflight.ts

export class Website
  implements IWebsiteClient, ISimulatorResourceInstance, IDisplayableResource
{
...
...
  public async visualModel(): Promise<VisualModel | undefined> {
    const builder = new VisualModelBuilder();
    builder.addLink(this.url, "Cool Website URL");
    return builder.buildVisualModel();
  }

In the console, for each resource we will be able to get its' visual model and display whatever in its' interaction panel.

          const simulator = await ctx.simulator();
          const client = simulator.getResource(
            input.resourcePath,
          ) as IDisplayableResource;
          if (!client) {
            return;
          }
          return await client.visualModel();

Now, this is working great when added to an sdk sim resources (full E2E).

The problems start when trying to create a custom resource using winglang in a different repository.

Let's take the following custom resource for example:

main.w

class MyWebsite {
    w: cloud.Website;
    init() {
        this.w = new cloud.Website(path: "path/to/website");
    }

    pub inflight visualModel(): std.VisualModel {
        let model = new std.VisualModelBuilder();
        model.addLink(this.w.url, "Google");
        return model.buildVisualModel();
    }
}

Since the Simulator doesn't know this resource and doesn't create a client for it - there is no way to call any of its' inflight methods...

Do I also need to implement ISimulatorResource and add the following methods for each custom resource? (doesn't make sense obviously....)

  public bind(host: IInflightHost, ops: string[]): void {
    bindSimulatorResource(__filename, this, host);
    super.bind(host, ops);
  }

  /** @internal */
  public _toInflight(): string {
    return makeSimulatorJsClient(__filename, this);
  }

Any hints for how to approach this issue? perhaps it is the wrong solution... @Chriscbr @eladb ?

eladb commented 1 year ago

@ainvoner wrote:

the simulator doesn't know this resource

Yes, it seems like simulator.json currently only includes objects that implement toSimulator() (see code.

I think we should include all nodes in simulator.json and provide a default inflight client implementation that will be able to call visualModel for example.

Chriscbr commented 1 year ago

@ainvoner I'm curious why is the visual model defined inflight? I'm not sure if there's a need to perform asynchronous work or access runtime state in order to decide what components a resource has in its interaction panel.

I was imagining perhaps the UX could be more responsive for users if the visual model (text fields, links, buttons, other inputs) are all defined in preflight (at compile time), only allowing each component to reference inflight code if something needs to be dynamically fetched or run.

For example, let's say in the Website class, I want the interaction panel to show a link to the website's URL, and a graph of its metrics. As soon as I click on a Website in my app in the Wing Console, I want to see all of the available interactions, and the URL to show immediately - I'm okay if the graph loads later. If the Wing Console does not have information about interaction view until the inflight method completes, then then any one component could become a bottleneck:

    pub inflight visualModel(): std.VisualModel {
        let model = new std.VisualModelBuilder();
        model.addLink("Website URL", this.url);
        model.addImage("Page views", this.generateGraph()); // may take a few seconds to render
        return model.buildVisualModel();
    }
eladb commented 1 year ago

The visual model might include inflight information. Think number of objects in a bucket.

Chriscbr commented 1 year ago

The actual UI components would still be fixed, no? In your example I might model it like

class Bucket {
  init() {
    // ...
    this.visualModel.addField("Number of objects", inflight (): str {
      return "${this.list().length}";
    });
    this.visualModel.addTable("Objects", this.tableEntries, pagination: true);
  }

  inflight tableEntries(pageIndex: num): std.DisplayTableRow[] {
    // display keys and sizes of objects
  }
} 
eladb commented 1 year ago

This is not really supported at the moment as far as I know.

Also - this implies that the model itself is not serializable because we can't serialize functions (as of yet).

Chriscbr commented 1 year ago

This is not really supported at the moment as far as I know.

You're right, for now you'd have to create an anonymous closure like:

inflight (pageIdx) => { return this.tableEntries(pageIdx); }

just so that the compiler could turn it into a class with a toInflight() implementation. But I think we can address that separately.

Also - this implies that the model itself is not serializable because we can't serialize functions (as of yet).

Since the visual model would be configured in preflight, the code for it would be compiled and emitted as JavaScript, and runnable through the simulator. Check out this PR: https://github.com/winglang/wing/pull/4605

In the future, instead of modeling the information inside simulator.json, we might refactor things over to tree.json (a file we generate for all targets) so that the same resource interaction panel works when you're viewing your simulated app and your production app.

eladb commented 1 year ago

the code for it would be compiled and emitted as JavaScript,

This would require some compute container like a cloud.Function...

At any rate - I think making this a simple inflight API is a reasonable first step.

Chriscbr commented 1 year ago

@ainvoner and I chatted about this offline. We acknowledged the two approaches (defining the visual model preflight vs inflight) would have implications for how the interaction panel is rendered: if the collection of visual components is defined in preflight, each component can be re-rendered/updated separately by Wing Console, and if the list of components is defined inflight, the whole interaction panel needs to be re-rendered/updated together each time.

But Shai raised to my attention that calling inflight methods on user-defined resources is a capability that has been missing from the Wing Simulator for a long time now, so adding that would be useful for the console (both for implementing this Visual API MVP, and other console features).

Another requirement we discussed was the visual model cannot be a flat list of components, as the components should be composable (say you want to group multiple interactive things into a Layout, or a Tab, etc.).

ainvoner commented 1 year ago

https://github.com/winglang/wing/pull/4605