Rework actions to be loaded dynamically
This commit is contained in:
parent
e49f89d4ee
commit
08ab7b4383
22 changed files with 934 additions and 81 deletions
|
|
@ -2,91 +2,43 @@ package socket
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/provision"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
func (sockets *Sockets) Actions() ActionMap {
|
||||
return map[string]Action{
|
||||
// generic actions
|
||||
"backup": sockets.Generic(scopes.ScopeUserAdmin, "", 0, func(ctx context.Context, sockets *Sockets, in io.Reader, out io.Writer, params ...string) error {
|
||||
return sockets.dependencies.Exporter.MakeExport(
|
||||
ctx,
|
||||
out,
|
||||
exporter.ExportTask{
|
||||
Dest: "",
|
||||
Instance: nil,
|
||||
actions := make(ActionMap, len(sockets.dependencies.Actions)+len(sockets.dependencies.IActions))
|
||||
|
||||
StagingOnly: false,
|
||||
},
|
||||
)
|
||||
}),
|
||||
"provision": sockets.Generic(scopes.ScopeUserAdmin, "", 1, func(ctx context.Context, sockets *Sockets, in io.Reader, out io.Writer, params ...string) error {
|
||||
// read the flags of the instance to be provisioned
|
||||
var flags provision.Flags
|
||||
if err := json.Unmarshal([]byte(params[0]), &flags); err != nil {
|
||||
return err
|
||||
}
|
||||
// setup basic actions
|
||||
for _, a := range sockets.dependencies.Actions {
|
||||
a := a
|
||||
meta := a.Action()
|
||||
actions[meta.Name] = Action{
|
||||
NumParams: meta.NumParams,
|
||||
Scope: meta.Scope,
|
||||
ScopeParam: meta.ScopeParam,
|
||||
|
||||
instance, err := sockets.dependencies.Provision.Provision(
|
||||
out,
|
||||
ctx,
|
||||
flags,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "URL: %s\n", instance.URL().String())
|
||||
fmt.Fprintf(out, "Username: %s\n", instance.DrupalUsername)
|
||||
fmt.Fprintf(out, "Password: %s\n", instance.DrupalPassword)
|
||||
|
||||
return nil
|
||||
}),
|
||||
|
||||
// instance-specific actions!
|
||||
|
||||
"snapshot": sockets.Instance(scopes.ScopeUserAdmin, "", 0, func(ctx context.Context, socket *Sockets, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return socket.dependencies.Exporter.MakeExport(
|
||||
ctx,
|
||||
out,
|
||||
exporter.ExportTask{
|
||||
Dest: "",
|
||||
Instance: instance,
|
||||
|
||||
StagingOnly: false,
|
||||
},
|
||||
)
|
||||
}),
|
||||
"rebuild": sockets.Instance(scopes.ScopeUserAdmin, "", 1, func(ctx context.Context, _ *Sockets, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
// read the flags of the instance to be provisioned
|
||||
var system models.System
|
||||
if err := json.Unmarshal([]byte(params[0]), &system); err != nil {
|
||||
return err
|
||||
}
|
||||
return instance.SystemManager().Apply(ctx, out, system, true)
|
||||
}),
|
||||
"update": sockets.Instance(scopes.ScopeUserAdmin, "", 0, func(ctx context.Context, _ *Sockets, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return instance.Composer().Update(ctx, out)
|
||||
}),
|
||||
"cron": sockets.Instance(scopes.ScopeUserAdmin, "", 0, func(ctx context.Context, _ *Sockets, instance *wisski.WissKI, in io.Reader, str io.Writer, params ...string) error {
|
||||
return instance.Drush().Cron(ctx, str)
|
||||
}),
|
||||
"start": sockets.Instance(scopes.ScopeUserAdmin, "", 0, func(ctx context.Context, _ *Sockets, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return instance.Barrel().Stack().Up(ctx, out)
|
||||
}),
|
||||
"stop": sockets.Instance(scopes.ScopeUserAdmin, "", 0, func(ctx context.Context, _ *Sockets, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return instance.Barrel().Stack().Down(ctx, out)
|
||||
}),
|
||||
"purge": sockets.Instance(scopes.ScopeUserAdmin, "", 0, func(ctx context.Context, sockets *Sockets, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return sockets.dependencies.Purger.Purge(ctx, out, instance.Slug)
|
||||
}),
|
||||
Handle: a.Act,
|
||||
}
|
||||
}
|
||||
|
||||
// setup instance actions
|
||||
for _, a := range sockets.dependencies.IActions {
|
||||
a := a
|
||||
meta := a.Action()
|
||||
actions[meta.Name] = Action{
|
||||
NumParams: meta.NumParams + 1,
|
||||
Scope: meta.Scope,
|
||||
ScopeParam: meta.ScopeParam,
|
||||
|
||||
Handle: func(ctx context.Context, in io.Reader, out io.Writer, params ...string) error {
|
||||
instance, err := sockets.dependencies.Instances.WissKI(ctx, params[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.Act(ctx, instance, in, out, params[1:]...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
// Routeable is a component that is servable
|
||||
type WebsocketAction interface {
|
||||
component.Component
|
||||
|
||||
Action() Action
|
||||
Act(ctx context.Context, in io.Reader, out io.Writer, params ...string) error
|
||||
}
|
||||
|
||||
type WebsocketInstanceAction interface {
|
||||
component.Component
|
||||
|
||||
Action() InstanceAction
|
||||
Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error
|
||||
}
|
||||
|
||||
// Action represents information about an action
|
||||
type Action struct {
|
||||
Name string
|
||||
|
||||
Scope scopes.Scope
|
||||
ScopeParam string
|
||||
NumParams int
|
||||
}
|
||||
|
||||
type InstanceAction struct {
|
||||
Action
|
||||
}
|
||||
42
internal/dis/component/server/admin/socket/actions/backup.go
Normal file
42
internal/dis/component/server/admin/socket/actions/backup.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
||||
)
|
||||
|
||||
type Backup struct {
|
||||
component.Base
|
||||
dependencies struct {
|
||||
Exporter *exporter.Exporter
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketAction = (*Backup)(nil)
|
||||
)
|
||||
|
||||
func (*Backup) Action() Action {
|
||||
return Action{
|
||||
Name: "backup",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backup) Act(ctx context.Context, in io.Reader, out io.Writer, params ...string) error {
|
||||
return b.dependencies.Exporter.MakeExport(
|
||||
ctx,
|
||||
out,
|
||||
exporter.ExportTask{
|
||||
Dest: "",
|
||||
Instance: nil,
|
||||
|
||||
StagingOnly: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
32
internal/dis/component/server/admin/socket/actions/cron.go
Normal file
32
internal/dis/component/server/admin/socket/actions/cron.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
type Cron struct {
|
||||
component.Base
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketInstanceAction = (*Cron)(nil)
|
||||
)
|
||||
|
||||
func (*Cron) Action() InstanceAction {
|
||||
return InstanceAction{
|
||||
Action: Action{
|
||||
Name: "cron",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cron) Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return instance.Drush().Cron(ctx, out)
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/provision"
|
||||
)
|
||||
|
||||
type Provision struct {
|
||||
component.Base
|
||||
dependencies struct {
|
||||
Provision *provision.Provision
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketAction = (*Provision)(nil)
|
||||
)
|
||||
|
||||
func (*Provision) Action() Action {
|
||||
return Action{
|
||||
Name: "provision",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provision) Act(ctx context.Context, in io.Reader, out io.Writer, params ...string) error {
|
||||
// read the flags of the instance to be provisioned
|
||||
var flags provision.Flags
|
||||
if err := json.Unmarshal([]byte(params[0]), &flags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
instance, err := p.dependencies.Provision.Provision(
|
||||
out,
|
||||
ctx,
|
||||
flags,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "URL: %s\n", instance.URL().String())
|
||||
fmt.Fprintf(out, "Username: %s\n", instance.DrupalUsername)
|
||||
fmt.Fprintf(out, "Password: %s\n", instance.DrupalPassword)
|
||||
|
||||
return nil
|
||||
}
|
||||
36
internal/dis/component/server/admin/socket/actions/purge.go
Normal file
36
internal/dis/component/server/admin/socket/actions/purge.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances/purger"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
type Purge struct {
|
||||
component.Base
|
||||
dependencies struct {
|
||||
Purger *purger.Purger
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketInstanceAction = (*Stop)(nil)
|
||||
)
|
||||
|
||||
func (*Purge) Action() InstanceAction {
|
||||
return InstanceAction{
|
||||
Action: Action{
|
||||
Name: "purge",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Purge) Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return p.dependencies.Purger.Purge(ctx, out, instance.Slug)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
type Rebuild struct {
|
||||
component.Base
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketInstanceAction = (*Rebuild)(nil)
|
||||
)
|
||||
|
||||
func (*Rebuild) Action() InstanceAction {
|
||||
return InstanceAction{
|
||||
Action: Action{
|
||||
Name: "rebuild",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Rebuild) Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
// read the flags of the instance to be provisioned
|
||||
var system models.System
|
||||
if err := json.Unmarshal([]byte(params[0]), &system); err != nil {
|
||||
return err
|
||||
}
|
||||
return instance.SystemManager().Apply(ctx, out, system, true)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
component.Base
|
||||
dependencies struct {
|
||||
Exporter *exporter.Exporter
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketInstanceAction = (*Snapshot)(nil)
|
||||
)
|
||||
|
||||
func (*Snapshot) Action() InstanceAction {
|
||||
return InstanceAction{
|
||||
Action: Action{
|
||||
Name: "snapshot",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Snapshot) Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return s.dependencies.Exporter.MakeExport(
|
||||
ctx,
|
||||
out,
|
||||
exporter.ExportTask{
|
||||
Dest: "",
|
||||
Instance: instance,
|
||||
|
||||
StagingOnly: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
32
internal/dis/component/server/admin/socket/actions/start.go
Normal file
32
internal/dis/component/server/admin/socket/actions/start.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
type Start struct {
|
||||
component.Base
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketInstanceAction = (*Start)(nil)
|
||||
)
|
||||
|
||||
func (*Start) Action() InstanceAction {
|
||||
return InstanceAction{
|
||||
Action: Action{
|
||||
Name: "start",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (*Start) Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return instance.Barrel().Stack().Up(ctx, out)
|
||||
}
|
||||
32
internal/dis/component/server/admin/socket/actions/stop.go
Normal file
32
internal/dis/component/server/admin/socket/actions/stop.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
type Stop struct {
|
||||
component.Base
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketInstanceAction = (*Stop)(nil)
|
||||
)
|
||||
|
||||
func (*Stop) Action() InstanceAction {
|
||||
return InstanceAction{
|
||||
Action: Action{
|
||||
Name: "stop",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (*Stop) Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return instance.Barrel().Stack().Down(ctx, out)
|
||||
}
|
||||
32
internal/dis/component/server/admin/socket/actions/update.go
Normal file
32
internal/dis/component/server/admin/socket/actions/update.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
)
|
||||
|
||||
type Update struct {
|
||||
component.Base
|
||||
}
|
||||
|
||||
var (
|
||||
_ WebsocketInstanceAction = (*Update)(nil)
|
||||
)
|
||||
|
||||
func (*Update) Action() InstanceAction {
|
||||
return InstanceAction{
|
||||
Action: Action{
|
||||
Name: "update",
|
||||
Scope: scopes.ScopeUserAdmin,
|
||||
NumParams: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Update) Act(ctx context.Context, instance *wisski.WissKI, in io.Reader, out io.Writer, params ...string) error {
|
||||
return instance.Composer().Update(ctx, out)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances/purger"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/provision"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/admin/socket/actions"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/tkw1536/pkglib/httpx"
|
||||
|
|
@ -24,6 +25,9 @@ type Sockets struct {
|
|||
actions lazy.Lazy[ActionMap]
|
||||
|
||||
dependencies struct {
|
||||
Actions []actions.WebsocketAction
|
||||
IActions []actions.WebsocketInstanceAction
|
||||
|
||||
Provision *provision.Provision
|
||||
Instances *instances.Instances
|
||||
Exporter *exporter.Exporter
|
||||
|
|
|
|||
1
internal/dis/component/ws.go
Normal file
1
internal/dis/component/ws.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package component
|
||||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/admin"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/admin/socket"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/admin/socket/actions"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/cron"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/home"
|
||||
|
|
@ -209,7 +210,6 @@ func (dis *Distillery) allComponents(context *lifetime.RegisterContext[component
|
|||
resolver.RefreshInterval = time.Minute
|
||||
})
|
||||
lifetime.Place[*admin.Admin](context) // TODO: Remove analytics
|
||||
lifetime.Place[*socket.Sockets](context)
|
||||
lifetime.Place[*legal.Legal](context)
|
||||
lifetime.Place[*news.News](context)
|
||||
|
||||
|
|
@ -217,6 +217,18 @@ func (dis *Distillery) allComponents(context *lifetime.RegisterContext[component
|
|||
lifetime.Place[*logo.Logo](context)
|
||||
lifetime.Place[*templating.Templating](context)
|
||||
|
||||
// Websockets
|
||||
lifetime.Place[*socket.Sockets](context)
|
||||
lifetime.Place[*actions.Backup](context)
|
||||
lifetime.Place[*actions.Provision](context)
|
||||
lifetime.Place[*actions.Snapshot](context)
|
||||
lifetime.Place[*actions.Rebuild](context)
|
||||
lifetime.Place[*actions.Update](context)
|
||||
lifetime.Place[*actions.Cron](context)
|
||||
lifetime.Place[*actions.Start](context)
|
||||
lifetime.Place[*actions.Stop](context)
|
||||
lifetime.Place[*actions.Purge](context)
|
||||
|
||||
// Cron
|
||||
lifetime.Place[*cron.Cron](context)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue