Combine stack tasks with archives

This commit is contained in:
cuigh 2018-04-16 17:21:20 +08:00
parent c624b1f9bd
commit f2a4b7209c
25 changed files with 713 additions and 405 deletions

View File

@ -2813,4 +2813,111 @@ var Swirl;
Volume.NewPage = NewPage;
})(Volume = Swirl.Volume || (Swirl.Volume = {}));
})(Swirl || (Swirl = {}));
var Swirl;
(function (Swirl) {
var Stack;
(function (Stack) {
var Validator = Swirl.Core.Validator;
var Notification = Swirl.Core.Notification;
class ContentRequiredRule {
validate($form, $input, arg) {
let el = $input[0];
if ($("#type-" + arg).prop("checked")) {
console.log(el.value);
return { ok: el.checkValidity ? el.checkValidity() : true, error: el.validationMessage };
}
return { ok: true };
}
}
class EditPage {
constructor() {
Validator.register("content", new ContentRequiredRule(), "");
this.editor = CodeMirror.fromTextArea($("#txt-content")[0], { lineNumbers: true });
$("#file-content").change(e => {
let file = e.target;
if (file.files.length > 0) {
$('#filename').text(file.files[0].name);
}
});
$("#type-input,#type-upload").click(e => {
let type = $(e.target).val();
$("#div-input").toggle(type == "input");
$("#div-upload").toggle(type == "upload");
});
$("#btn-submit").click(this.submit.bind(this));
}
submit(e) {
this.editor.save();
let results = Validator.bind("#div-form").validate();
if (results.length > 0) {
return;
}
let data = new FormData();
data.append('name', $("#name").val());
if ($("#type-input").prop("checked")) {
data.append('content', $('#txt-content').val());
}
else {
let file = $('#file-content')[0];
data.append('content', file.files[0]);
}
let url = $(e.target).data("url") || "";
$ajax.post(url, data).encoder("none").trigger(e.target).json((r) => {
if (r.success) {
location.href = "/stack/";
}
else {
Notification.show("danger", `FAILED: ${r.message}`);
}
});
}
}
Stack.EditPage = EditPage;
})(Stack = Swirl.Stack || (Swirl.Stack = {}));
})(Swirl || (Swirl = {}));
var Swirl;
(function (Swirl) {
var Stack;
(function (Stack) {
var Modal = Swirl.Core.Modal;
var Dispatcher = Swirl.Core.Dispatcher;
class ListPage {
constructor() {
let dispatcher = Dispatcher.bind("#table-items");
dispatcher.on("deploy-stack", this.deployStack.bind(this));
dispatcher.on("shutdown-stack", this.shutdownStack.bind(this));
dispatcher.on("delete-stack", this.deleteStack.bind(this));
}
deployStack(e) {
let $tr = $(e.target).closest("tr");
let name = $tr.find("td:first").text().trim();
Modal.confirm(`Are you sure to deploy stack: <strong>${name}</strong>?`, "Deploy stack", (dlg, e) => {
$ajax.post(`${name}/deploy`).trigger(e.target).json(r => {
location.reload();
});
});
}
shutdownStack(e) {
let $tr = $(e.target).closest("tr");
let name = $tr.find("td:first").text().trim();
Modal.confirm(`Are you sure to shutdown stack: <strong>${name}</strong>?`, "Shutdown stack", (dlg, e) => {
$ajax.post(`${name}/shutdown`).trigger(e.target).json(r => {
location.reload();
});
});
}
deleteStack(e) {
let $tr = $(e.target).closest("tr");
let name = $tr.find("td:first").text().trim();
Modal.confirm(`Are you sure to remove archive: <strong>${name}</strong>?`, "Delete stack", (dlg, e) => {
$ajax.post(`${name}/delete`).trigger(e.target).json(r => {
$tr.remove();
dlg.close();
});
});
}
}
Stack.ListPage = ListPage;
})(Stack = Swirl.Stack || (Swirl.Stack = {}));
})(Swirl || (Swirl = {}));
//# sourceMappingURL=swirl.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,70 @@
///<reference path="../core/core.ts" />
namespace Swirl.Stack {
import Validator = Swirl.Core.Validator;
import AjaxResult = Swirl.Core.AjaxResult;
import Notification = Swirl.Core.Notification;
import ValidationRule = Swirl.Core.ValidationRule;
class ContentRequiredRule implements ValidationRule {
validate($form: JQuery, $input: JQuery, arg?: string): {ok: boolean, error?: string} {
let el = <HTMLInputElement>$input[0];
if ($("#type-" + arg).prop("checked")) {
console.log(el.value);
return {ok: el.checkValidity ? el.checkValidity() : true, error: el.validationMessage};
}
return {ok: true}
}
}
export class EditPage {
private editor: any;
constructor() {
Validator.register("content", new ContentRequiredRule(), "");
this.editor = CodeMirror.fromTextArea($("#txt-content")[0], {lineNumbers: true});
$("#file-content").change(e => {
let file = <HTMLInputElement>e.target;
if (file.files.length > 0) {
$('#filename').text(file.files[0].name);
}
});
$("#type-input,#type-upload").click(e => {
let type = $(e.target).val();
$("#div-input").toggle(type == "input");
$("#div-upload").toggle(type == "upload");
});
$("#btn-submit").click(this.submit.bind(this))
}
private submit(e: JQueryEventObject) {
this.editor.save();
let results = Validator.bind("#div-form").validate();
if (results.length > 0) {
return;
}
let data = new FormData();
data.append('name', $("#name").val());
if ($("#type-input").prop("checked")) {
data.append('content', $('#txt-content').val());
} else {
let file = <HTMLInputElement>$('#file-content')[0];
data.append('content', file.files[0]);
}
let url = $(e.target).data("url") || "";
$ajax.post(url, data).encoder("none").trigger(e.target).json((r: AjaxResult) => {
if (r.success) {
location.href = "/stack/"
} else {
Notification.show("danger", `FAILED: ${r.message}`);
}
})
}
}
}
declare var CodeMirror: any;

View File

@ -0,0 +1,46 @@
///<reference path="../core/core.ts" />
namespace Swirl.Stack {
import Modal = Swirl.Core.Modal;
import AjaxResult = Swirl.Core.AjaxResult;
import Dispatcher = Swirl.Core.Dispatcher;
export class ListPage {
constructor() {
let dispatcher = Dispatcher.bind("#table-items");
dispatcher.on("deploy-stack", this.deployStack.bind(this));
dispatcher.on("shutdown-stack", this.shutdownStack.bind(this));
dispatcher.on("delete-stack", this.deleteStack.bind(this));
}
private deployStack(e: JQueryEventObject) {
let $tr = $(e.target).closest("tr");
let name = $tr.find("td:first").text().trim();
Modal.confirm(`Are you sure to deploy stack: <strong>${name}</strong>?`, "Deploy stack", (dlg, e) => {
$ajax.post(`${name}/deploy`).trigger(e.target).json<AjaxResult>(r => {
location.reload();
})
});
}
private shutdownStack(e: JQueryEventObject) {
let $tr = $(e.target).closest("tr");
let name = $tr.find("td:first").text().trim();
Modal.confirm(`Are you sure to shutdown stack: <strong>${name}</strong>?`, "Shutdown stack", (dlg, e) => {
$ajax.post(`${name}/shutdown`).trigger(e.target).json<AjaxResult>(r => {
location.reload();
})
});
}
private deleteStack(e: JQueryEventObject) {
let $tr = $(e.target).closest("tr");
let name = $tr.find("td:first").text().trim();
Modal.confirm(`Are you sure to remove archive: <strong>${name}</strong>?`, "Delete stack", (dlg, e) => {
$ajax.post(`${name}/delete`).trigger(e.target).json<AjaxResult>(r => {
$tr.remove();
dlg.close();
})
});
}
}
}

View File

@ -18,7 +18,7 @@ import (
const stackLabel = "com.docker.stack.namespace"
// StackList return all stacks.
func StackList() (stacks []*model.StackListInfo, err error) {
func StackList() (stacks []*model.Stack, err error) {
err = mgr.Do(func(ctx context.Context, cli *client.Client) (err error) {
var services []swarm.Service
opts := types.ServiceListOptions{
@ -30,7 +30,7 @@ func StackList() (stacks []*model.StackListInfo, err error) {
return
}
m := make(map[string]*model.StackListInfo)
m := make(map[string]*model.Stack)
for _, service := range services {
labels := service.Spec.Labels
name, ok := labels[stackLabel]
@ -42,7 +42,7 @@ func StackList() (stacks []*model.StackListInfo, err error) {
if stack, ok := m[name]; ok {
stack.Services = append(stack.Services, service.Spec.Name)
} else {
m[name] = &model.StackListInfo{
m[name] = &model.Stack{
Name: name,
Services: []string{service.Spec.Name},
}
@ -125,7 +125,8 @@ func StackRemove(name string) error {
}
if len(services)+len(networks)+len(secrets)+len(configs) == 0 {
return fmt.Errorf("nothing found in stack: %s", name)
//return fmt.Errorf("nothing found in stack: %s", name)
return nil
}
// Remove services

View File

@ -89,9 +89,9 @@ func (b *eventBiz) CreateVolume(action model.EventAction, name string, user web.
b.Create(event)
}
func (b *eventBiz) CreateStackTask(action model.EventAction, name string, user web.User) {
func (b *eventBiz) CreateStack(action model.EventAction, name string, user web.User) {
event := &model.Event{
Type: model.EventTypeStackTask,
Type: model.EventTypeStack,
Action: action,
Code: name,
Name: name,
@ -101,18 +101,6 @@ func (b *eventBiz) CreateStackTask(action model.EventAction, name string, user w
b.Create(event)
}
func (b *eventBiz) CreateStackArchive(action model.EventAction, id, name string, user web.User) {
event := &model.Event{
Type: model.EventTypeStackArchive,
Action: action,
Code: id,
Name: name,
UserID: user.ID(),
Username: user.Name(),
}
b.Create(event)
}
func (b *eventBiz) CreateSecret(action model.EventAction, name string, user web.User) {
event := &model.Event{
Type: model.EventTypeSecret,

View File

@ -1,60 +1,128 @@
package biz
import (
"strings"
"github.com/cuigh/auxo/net/web"
"github.com/cuigh/swirl/biz/docker"
"github.com/cuigh/swirl/dao"
"github.com/cuigh/swirl/model"
)
// Stack return a stack biz instance.
var Archive = &archiveBiz{}
var Stack = &stackBiz{}
type archiveBiz struct {
type stackBiz struct {
}
func (b *archiveBiz) List(args *model.ArchiveListArgs) (archives []*model.Archive, count int, err error) {
func (b *stackBiz) List(args *model.StackListArgs) (stacks []*model.Stack, err error) {
var (
upStacks, internalStacks []*model.Stack
upMap = make(map[string]*model.Stack)
)
// load real stacks
upStacks, err = docker.StackList()
if err != nil {
return
}
for _, stack := range upStacks {
upMap[stack.Name] = stack
}
// load stack definitions
do(func(d dao.Interface) {
archives, count, err = d.ArchiveList(args)
internalStacks, err = d.StackList()
})
return
}
if err != nil {
return
}
func (b *archiveBiz) Create(archive *model.Archive) (err error) {
do(func(d dao.Interface) {
err = d.ArchiveCreate(archive)
//if err == nil {
// Event.CreateStackArchive(model.EventActionCreate, archive.ID, archive.Name, ctx.User())
//}
})
return
}
func (b *archiveBiz) Delete(id string, user web.User) (err error) {
do(func(d dao.Interface) {
var archive *model.Archive
archive, err = d.ArchiveGet(id)
if err != nil {
return
// merge stacks and definitions
for _, stack := range internalStacks {
stack.Internal = true
if s, ok := upMap[stack.Name]; ok {
stack.Services = s.Services
delete(upMap, stack.Name)
}
if !b.filter(stack, args) {
stacks = append(stacks, stack)
}
}
for _, stack := range upMap {
if !b.filter(stack, args) {
stacks = append(stacks, stack)
}
}
return
}
err = d.ArchiveDelete(id)
func (b *stackBiz) filter(stack *model.Stack, args *model.StackListArgs) bool {
if args.Name != "" {
if !strings.Contains(strings.ToLower(stack.Name), strings.ToLower(args.Name)) {
return true
}
}
switch args.Filter {
case "up":
if len(stack.Services) == 0 {
return true
}
case "internal":
if !stack.Internal {
return true
}
case "external":
if stack.Internal {
return true
}
}
return false
}
func (b *stackBiz) Create(stack *model.Stack, user web.User) (err error) {
do(func(d dao.Interface) {
err = d.StackCreate(stack)
if err == nil {
Event.CreateStackArchive(model.EventActionDelete, id, archive.Name, user)
Event.CreateStack(model.EventActionCreate, stack.Name, user)
}
})
return
}
func (b *archiveBiz) Get(id string) (archives *model.Archive, err error) {
func (b *stackBiz) Get(name string) (stack *model.Stack, err error) {
do(func(d dao.Interface) {
archives, err = d.ArchiveGet(id)
stack, err = d.StackGet(name)
})
return
}
func (b *archiveBiz) Update(archive *model.Archive) (err error) {
func (b *stackBiz) Update(stack *model.Stack, user web.User) (err error) {
do(func(d dao.Interface) {
err = d.ArchiveUpdate(archive)
err = d.StackUpdate(stack)
if err == nil {
Event.CreateStack(model.EventActionUpdate, stack.Name, user)
}
})
return
}
func (b *stackBiz) Delete(name string, user web.User) (err error) {
do(func(d dao.Interface) {
err = d.StackDelete(name)
if err == nil {
Event.CreateStack(model.EventActionDelete, name, user)
}
})
return
}
// Migrate migrates old archives to stack collection.
func (b *stackBiz) Migrate() {
do(func(d dao.Interface) {
d.StackMigrate()
})
return
}

View File

@ -10,6 +10,7 @@ button.submit: Submit
button.cancel: Cancel
button.confirm: Confirm
button.delete: Delete
button.remove: Remove
button.prune: Prune
button.new: New
button.add: Add
@ -150,6 +151,7 @@ service.template.button.create: Create service
stack.title: Stack
stack.description: A stack is a logical grouping of related services that are usually deployed together and require each other to work as intended.
stack.button.deploy: Deploy
stack.button.shutdown: Shutdown
# task pages
task.title: Task

View File

@ -10,6 +10,7 @@ button.submit: 提交
button.cancel: 取消
button.confirm: 确定
button.delete: 删除
button.remove: 移除
button.prune: 清理
button.new: 新建
button.add: 添加
@ -149,7 +150,8 @@ service.template.button.create: 创建服务
# stack pages
stack.title: 编排
stack.description: 编排是相关服务的一个逻辑分组,这些服务通常互相依赖,需要一块部署。
stack.button.deploy: 部署
stack.button.deploy: 发布
stack.button.shutdown: 发布
# task pages
task.title: 任务

View File

@ -10,136 +10,126 @@ import (
// StackController is a controller of docker stack(compose)
type StackController struct {
TaskList web.HandlerFunc `path:"/task/" name:"stack.task.list" authorize:"!" desc:"stack task list page"`
TaskDelete web.HandlerFunc `path:"/task/delete" method:"post" name:"stack.task.delete" authorize:"!" desc:"delete stack task"`
ArchiveList web.HandlerFunc `path:"/archive/" name:"stack.archive.list" authorize:"!" desc:"stack archive list page"`
ArchiveDetail web.HandlerFunc `path:"/archive/:id/detail" name:"stack.archive.detail" authorize:"!" desc:"stack archive detail page"`
ArchiveEdit web.HandlerFunc `path:"/archive/:id/edit" name:"stack.archive.edit" authorize:"!" desc:"stack archive edit page"`
ArchiveUpdate web.HandlerFunc `path:"/archive/:id/update" method:"post" name:"stack.archive.update" authorize:"!" desc:"update stack archive"`
ArchiveDelete web.HandlerFunc `path:"/archive/delete" method:"post" name:"stack.archive.delete" authorize:"!" desc:"delete stack archive"`
ArchiveDeploy web.HandlerFunc `path:"/archive/deploy" method:"post" name:"stack.archive.deploy" authorize:"!" desc:"deploy stack archive"`
ArchiveNew web.HandlerFunc `path:"/archive/new" name:"stack.archive.new" authorize:"!" desc:"new stack.archive page"`
ArchiveCreate web.HandlerFunc `path:"/archive/new" method:"post" name:"stack.archive.create" authorize:"!" desc:"create stack.archive"`
List web.HandlerFunc `path:"/" name:"stack.list" authorize:"!" desc:"stack list page"`
New web.HandlerFunc `path:"/new" name:"stack.new" authorize:"!" desc:"new stack page"`
Create web.HandlerFunc `path:"/new" method:"post" name:"stack.create" authorize:"!" desc:"create stack"`
Detail web.HandlerFunc `path:"/:name/detail" name:"stack.detail" authorize:"!" desc:"stack detail page"`
Edit web.HandlerFunc `path:"/:name/edit" name:"stack.edit" authorize:"!" desc:"stack edit page"`
Update web.HandlerFunc `path:"/:name/update" method:"post" name:"stack.update" authorize:"!" desc:"update stack"`
Deploy web.HandlerFunc `path:"/:name/deploy" method:"post" name:"stack.deploy" authorize:"!" desc:"deploy stack"`
Shutdown web.HandlerFunc `path:"/:name/shutdown" method:"post" name:"stack.shutdown" authorize:"!" desc:"shutdown stack"`
Delete web.HandlerFunc `path:"/:name/delete" method:"post" name:"stack.delete" authorize:"!" desc:"delete stack"`
}
// Stack creates an instance of StackController
func Stack() (c *StackController) {
return &StackController{
TaskList: stackTaskList,
TaskDelete: stackTaskDelete,
ArchiveList: stackArchiveList,
ArchiveDetail: stackArchiveDetail,
ArchiveEdit: stackArchiveEdit,
ArchiveUpdate: stackArchiveUpdate,
ArchiveDelete: stackArchiveDelete,
ArchiveDeploy: stackArchiveDeploy,
ArchiveNew: stackArchiveNew,
ArchiveCreate: stackArchiveCreate,
List: stackList,
New: stackNew,
Create: stackCreate,
Detail: stackDetail,
Edit: stackEdit,
Update: stackUpdate,
Deploy: stackDeploy,
Shutdown: stackShutdown,
Delete: stackDelete,
}
}
func stackTaskList(ctx web.Context) error {
stacks, err := docker.StackList()
if err != nil {
return err
}
m := newModel(ctx).Set("Stacks", stacks)
return ctx.Render("stack/task/list", m)
}
func stackTaskDelete(ctx web.Context) error {
name := ctx.F("name")
err := docker.StackRemove(name)
if err == nil {
biz.Event.CreateStackTask(model.EventActionDelete, name, ctx.User())
}
return ajaxResult(ctx, err)
}
func stackArchiveList(ctx web.Context) error {
args := &model.ArchiveListArgs{}
func stackList(ctx web.Context) error {
args := &model.StackListArgs{}
err := ctx.Bind(args)
if err != nil {
return err
}
args.PageSize = model.PageSize
if args.PageIndex == 0 {
args.PageIndex = 1
}
archives, totalCount, err := biz.Archive.List(args)
stacks, err := biz.Stack.List(args)
if err != nil {
return err
}
m := newPagerModel(ctx, totalCount, model.PageSize, args.PageIndex).
m := newModel(ctx).Set("Stacks", stacks).
Set("Name", args.Name).
Set("Archives", archives)
return ctx.Render("stack/archive/list", m)
Set("Filter", args.Filter)
return ctx.Render("stack/list", m)
}
func stackArchiveDetail(ctx web.Context) error {
id := ctx.P("id")
archive, err := biz.Archive.Get(id)
func stackNew(ctx web.Context) error {
m := newModel(ctx)
return ctx.Render("stack/new", m)
}
func stackCreate(ctx web.Context) error {
stack := &model.Stack{}
err := ctx.Bind(stack)
if err != nil {
return err
}
if archive == nil {
return web.ErrNotFound
}
m := newModel(ctx).Set("Archive", archive)
return ctx.Render("stack/archive/detail", m)
}
func stackArchiveEdit(ctx web.Context) error {
id := ctx.P("id")
archive, err := biz.Archive.Get(id)
// Validate format
_, err = compose.Parse(stack.Name, stack.Content)
if err != nil {
return err
}
if archive == nil {
stack.CreatedBy = ctx.User().ID()
stack.UpdatedBy = stack.CreatedBy
err = biz.Stack.Create(stack, ctx.User())
return ajaxResult(ctx, err)
}
func stackDetail(ctx web.Context) error {
name := ctx.P("name")
stack, err := biz.Stack.Get(name)
if err != nil {
return err
}
if stack == nil {
return web.ErrNotFound
}
m := newModel(ctx).Set("Archive", archive)
return ctx.Render("stack/archive/edit", m)
m := newModel(ctx).Set("Stack", stack)
return ctx.Render("stack/detail", m)
}
func stackArchiveUpdate(ctx web.Context) error {
archive := &model.Archive{}
err := ctx.Bind(archive)
func stackEdit(ctx web.Context) error {
name := ctx.P("name")
stack, err := biz.Stack.Get(name)
if err != nil {
return err
}
if stack == nil {
return web.ErrNotFound
}
m := newModel(ctx).Set("Stack", stack)
return ctx.Render("stack/edit", m)
}
func stackUpdate(ctx web.Context) error {
stack := &model.Stack{}
err := ctx.Bind(stack)
if err == nil {
// Validate format
_, err = compose.Parse(archive.Name, archive.Content)
_, err = compose.Parse(stack.Name, stack.Content)
if err != nil {
return err
}
archive.UpdatedBy = ctx.User().ID()
err = biz.Archive.Update(archive)
}
if err == nil {
biz.Event.CreateStackArchive(model.EventActionUpdate, archive.ID, archive.Name, ctx.User())
stack.UpdatedBy = ctx.User().ID()
err = biz.Stack.Update(stack, ctx.User())
}
return ajaxResult(ctx, err)
}
func stackArchiveDelete(ctx web.Context) error {
id := ctx.F("id")
err := biz.Archive.Delete(id, ctx.User())
return ajaxResult(ctx, err)
}
func stackArchiveDeploy(ctx web.Context) error {
id := ctx.F("id")
archive, err := biz.Archive.Get(id)
func stackDeploy(ctx web.Context) error {
name := ctx.P("name")
stack, err := biz.Stack.Get(name)
if err != nil {
return err
}
cfg, err := compose.Parse(archive.Name, archive.Content)
cfg, err := compose.Parse(stack.Name, stack.Content)
if err != nil {
return err
}
@ -161,30 +151,27 @@ func stackArchiveDeploy(ctx web.Context) error {
}
}
err = docker.StackDeploy(archive.Name, archive.Content, authes)
err = docker.StackDeploy(stack.Name, stack.Content, authes)
if err == nil {
biz.Event.CreateStack(model.EventActionDeploy, name, ctx.User())
}
return ajaxResult(ctx, err)
}
func stackArchiveNew(ctx web.Context) error {
m := newModel(ctx)
return ctx.Render("stack/archive/new", m)
}
func stackArchiveCreate(ctx web.Context) error {
archive := &model.Archive{}
err := ctx.Bind(archive)
if err != nil {
return err
func stackShutdown(ctx web.Context) error {
name := ctx.P("name")
err := docker.StackRemove(name)
if err == nil {
biz.Event.CreateStack(model.EventActionShutdown, name, ctx.User())
}
return ajaxResult(ctx, err)
}
func stackDelete(ctx web.Context) error {
name := ctx.P("name")
err := docker.StackRemove(name)
if err == nil {
err = biz.Stack.Delete(name, ctx.User())
}
// Validate format
_, err = compose.Parse(archive.Name, archive.Content)
if err != nil {
return err
}
archive.CreatedBy = ctx.User().ID()
archive.UpdatedBy = archive.CreatedBy
err = biz.Archive.Create(archive)
return ajaxResult(ctx, err)
}

View File

@ -47,6 +47,14 @@ type Interface interface {
ArchiveUpdate(archive *model.Archive) error
ArchiveDelete(id string) error
StackList() (stacks []*model.Stack, err error)
StackGet(name string) (*model.Stack, error)
StackCreate(stack *model.Stack) error
StackUpdate(stack *model.Stack) error
StackDelete(name string) error
// StackMigrate migrates stacks from old archive collection. This method will removed after v0.8.
StackMigrate()
TemplateList(args *model.TemplateListArgs) (tpls []*model.Template, count int, err error)
TemplateGet(id string) (*model.Template, error)
TemplateCreate(tpl *model.Template) error

View File

@ -23,9 +23,6 @@ var (
"session": {
mgo.Index{Key: []string{"token"}, Unique: true},
},
"archive": {
mgo.Index{Key: []string{"name"}, Unique: true},
},
"event": {
mgo.Index{Key: []string{"type"}},
mgo.Index{Key: []string{"name"}},

View File

@ -3,6 +3,8 @@ package mongo
import (
"time"
"github.com/cuigh/auxo/app"
"github.com/cuigh/auxo/log"
"github.com/cuigh/swirl/misc"
"github.com/cuigh/swirl/model"
"github.com/globalsign/mgo"
@ -73,3 +75,107 @@ func (d *Dao) ArchiveDelete(id string) (err error) {
})
return
}
//===============================
func (d *Dao) StackList() (stacks []*model.Stack, err error) {
d.do(func(db *database) {
stacks = []*model.Stack{}
err = db.C("stack").Find(nil).All(&stacks)
})
return
}
func (d *Dao) StackCreate(stack *model.Stack) (err error) {
stack.CreatedAt = time.Now()
stack.UpdatedAt = stack.CreatedAt
d.do(func(db *database) {
err = db.C("stack").Insert(stack)
})
return
}
func (d *Dao) StackGet(name string) (stack *model.Stack, err error) {
d.do(func(db *database) {
stack = &model.Stack{}
err = db.C("stack").FindId(name).One(stack)
if err == mgo.ErrNotFound {
stack, err = nil, nil
} else if err != nil {
stack = nil
}
})
return
}
func (d *Dao) StackUpdate(stack *model.Stack) (err error) {
d.do(func(db *database) {
update := bson.M{
"$set": bson.M{
"content": stack.Content,
"updated_by": stack.UpdatedBy,
"updated_at": time.Now(),
},
}
err = db.C("stack").UpdateId(stack.Name, update)
})
return
}
func (d *Dao) StackDelete(name string) (err error) {
d.do(func(db *database) {
err = db.C("stack").RemoveId(name)
})
return
}
// StackMigrate migrates stacks from old archive collection.
func (d *Dao) StackMigrate() {
d.do(func(db *database) {
logger := log.Get(app.Name)
archiveColl := db.C("archive")
// check collection is exists.
if _, err := archiveColl.Indexes(); err != nil {
return
}
archives := make([]*model.Archive, 0)
err := archiveColl.Find(nil).All(&archives)
if err != nil {
logger.Warn("Failed to migrate archives: ", err)
return
}
var errs []error
stackColl := db.C("stack")
for _, archive := range archives {
stack := &model.Stack{
Name: archive.Name,
Content: archive.Content,
CreatedBy: archive.CreatedBy,
CreatedAt: archive.CreatedAt,
UpdatedBy: archive.UpdatedBy,
UpdatedAt: archive.UpdatedAt,
}
err = stackColl.Insert(stack)
if err == nil || mgo.IsDup(err) {
archiveColl.RemoveId(archive.ID)
} else {
logger.Warnf("Failed to migrate archive '%s': %v", archive.Name, err)
errs = append(errs, err)
}
}
// drop archive collection
if len(errs) == 0 {
err = archiveColl.DropCollection()
if err != nil {
logger.Warn("Failed to drop archive collection: ", err)
return
}
}
})
return
}

View File

@ -43,6 +43,8 @@ func main() {
os.Exit(1)
}
biz.Stack.Migrate()
scaler.Start()
app.Run(server(setting))
}

View File

@ -91,11 +91,6 @@ func (opts Options) Compress() Options {
return opts
}
type StackListInfo struct {
Name string
Services []string
}
type ServiceListInfo struct {
Name string
Image string

View File

@ -13,17 +13,14 @@ const (
EventTypeNetwork EventType = "Network"
EventTypeService EventType = "Service"
EventTypeServiceTemplate EventType = "Service Template"
EventTypeStackTask EventType = "Stack Task"
EventTypeStackArchive EventType = "Stack Archive"
EventTypeStack EventType = "Stack"
EventTypeSecret EventType = "Secret"
EventTypeConfig EventType = "Config"
EventTypeVolume EventType = "Volume"
EventTypeAuthentication EventType = "Authentication"
EventTypeRole EventType = "Role"
EventTypeUser EventType = "User"
EventTypeSetting EventType = "Setting"
EventTypeVolume EventType = "Volume"
EventTypeAuthentication EventType = "Authentication"
EventTypeRole EventType = "Role"
EventTypeUser EventType = "User"
EventTypeSetting EventType = "Setting"
)
type EventAction string
@ -38,6 +35,8 @@ const (
EventActionRollback EventAction = "Rollback"
EventActionRestart EventAction = "Restart"
EventActionDisconnect EventAction = "Disconnect"
EventActionDeploy EventAction = "Deploy"
EventActionShutdown EventAction = "Shutdown"
)
type Event struct {
@ -61,8 +60,8 @@ func (e *Event) URL(et EventType, code string) string {
return fmt.Sprintf("/network/%s/detail", code)
case EventTypeService:
return fmt.Sprintf("/service/%s/detail", code)
case EventTypeStackArchive:
return fmt.Sprintf("/stack/archive/%s/detail", code)
case EventTypeStack:
return fmt.Sprintf("/stack/%s/detail", code)
case EventTypeVolume:
return fmt.Sprintf("/volume/%s/detail", code)
case EventTypeRole:

View File

@ -18,6 +18,22 @@ type ArchiveListArgs struct {
PageSize int `bind:"size"`
}
type Stack struct {
Name string `bson:"_id" json:"name,omitempty"`
Content string `bson:"content" json:"content,omitempty" bind:"content=form,content=file"`
CreatedBy string `bson:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at,omitempty"`
UpdatedBy string `bson:"updated_by" json:"updated_by,omitempty"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at,omitempty"`
Services []string `bson:"-" json:"services,omitempty"`
Internal bool `bson:"-" json:"internal"`
}
type StackListArgs struct {
Name string `bind:"name"`
Filter string `bind:"filter"`
}
type Template struct {
ID string `bson:"_id" json:"id,omitempty"`
Name string `bson:"name" json:"name,omitempty"`

View File

@ -109,16 +109,15 @@ var Perms = []PermGroup{
{
Name: "Stack",
Perms: []Perm{
{Key: "stack.task.list", Text: "View task list"},
{Key: "stack.task.delete", Text: "Delete task"},
{Key: "stack.archive.list", Text: "View archive list"},
{Key: "stack.archive.new", Text: "View archive new"},
{Key: "stack.archive.detail", Text: "View archive detail"},
{Key: "stack.archive.edit", Text: "View archive edit"},
{Key: "stack.archive.delete", Text: "Delete archive"},
{Key: "stack.archive.create", Text: "Create archive"},
{Key: "stack.archive.update", Text: "Update archive"},
{Key: "stack.archive.deploy", Text: "Deploy archive"},
{Key: "stack.list", Text: "View list"},
{Key: "stack.new", Text: "View new"},
{Key: "stack.detail", Text: "View detail"},
{Key: "stack.edit", Text: "View edit"},
{Key: "stack.create", Text: "Create"},
{Key: "stack.update", Text: "Update"},
{Key: "stack.deploy", Text: "Deploy"},
{Key: "stack.shutdown", Text: "Shutdown"},
{Key: "stack.delete", Text: "Delete"},
},
},
{

View File

@ -43,7 +43,7 @@
<a class="navbar-item" href="/network/">{{ i18n("menu.network") }}</a>
<a class="navbar-item" href="/service/">{{ i18n("menu.service") }}</a>
<a class="navbar-item" href="/task/">{{ i18n("menu.task") }}</a>
<a class="navbar-item" href="/stack/task/">{{ i18n("menu.stack") }}</a>
<a class="navbar-item" href="/stack/">{{ i18n("menu.stack") }}</a>
<a class="navbar-item" href="/secret/">{{ i18n("menu.secret") }}</a>
<a class="navbar-item" href="/config/">{{ i18n("menu.config") }}</a>
</div>

View File

@ -1,88 +0,0 @@
{{ extends "../../_layouts/default" }}
{{ import "../../_modules/pager" }}
{{ block script() }}
<script>$(() => new Swirl.Stack.Archive.ListPage())</script>
{{ end }}
{{ block body() }}
<section class="hero is-info">
<div class="hero-body">
<div class="container has-text-centered">
<h1 class="title is-2 is-uppercase">{{ i18n("stack.title") }}</h1>
<h2 class="subtitle is-5">{{ i18n("stack.description") }}</h2>
</div>
</div>
<div class="hero-foot">
<div class="container">
<nav class="tabs is-boxed">
<ul>
<li>
<a href="/stack/task/">{{ i18n("menu.stack.task") }}</a>
</li>
<li class="is-active">
<a href="/stack/archive/">{{ i18n("menu.stack.archive") }}</a>
</li>
</ul>
</nav>
</div>
</div>
</section>
<section class="section">
<nav class="level">
<!-- Left side -->
<div class="level-left">
<div class="level-item">
<form>
<div class="field has-addons">
<p class="control">
<input name="name" value="{{.Name}}" class="input" type="text" placeholder="Search by name">
</p>
<p class="control">
<button type="submit" class="button is-primary">{{ i18n("button.search") }}</button>
</p>
</div>
</form>
</div>
<div class="level-item">
<p class="subtitle is-5">
<strong>{{len(.Archives)}}</strong>
<span class="is-lowercase">{{ i18n("menu.stack.archive") }}</span>
</p>
</div>
</div>
<!-- Right side -->
<div class="level-right">
<p class="level-item">
<a href="new" class="button is-success"><span class="icon"><i class="fas fa-plus"></i></span><span>{{ i18n("button.new") }}</span></a>
</p>
</div>
</nav>
<table id="table-items" class="table is-bordered is-striped is-narrow is-fullwidth">
<thead>
<tr>
<th>{{ i18n("field.name") }}</th>
<th>{{ i18n("field.created-at") }}</th>
<th>{{ i18n("field.updated-at") }}</th>
<th>{{ i18n("field.action") }}</th>
</tr>
</thead>
<tbody>
{{range .Archives}}
<tr data-id="{{.ID}}">
<td><a href="{{.ID}}/detail">{{.Name}}</a></td>
<td>{{time(.CreatedAt)}}</td>
<td>{{time(.UpdatedAt)}}</td>
<td>
<a href="{{.ID}}/edit" class="button is-small is-dark is-outlined">{{ i18n("button.edit") }}</a>
<button type="button" class="button is-small is-info is-outlined" data-action="deploy-archive">{{ i18n("stack.button.deploy") }}</button>
<button type="button" class="button is-small is-danger is-outlined" data-action="delete-archive">{{ i18n("button.delete") }}</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{ yield pager(info=.Pager) }}
</section>
{{ end }}

View File

@ -1,4 +1,4 @@
{{ extends "../../_layouts/default" }}
{{ extends "../_layouts/default" }}
{{ block style() }}
<link rel="stylesheet" href="/assets/highlight/highlight.css?v=9.12">
@ -17,27 +17,13 @@
<h2 class="subtitle is-5">{{ i18n("stack.description") }}</h2>
</div>
</div>
<div class="hero-foot">
<div class="container">
<nav class="tabs is-boxed">
<ul>
<li>
<a href="/stack/task/">{{ i18n("menu.stack.task") }}</a>
</li>
<li class="is-active">
<a href="/stack/archive/">{{ i18n("menu.stack.archive") }}</a>
</li>
</ul>
</nav>
</div>
</div>
</section>
<div class="container">
<nav class="breadcrumb has-succeeds-separator is-small is-marginless" aria-label="breadcrumbs">
<ul>
<li><a href="/">{{ i18n("menu.home") }}</a></li>
<li><a href="/stack/archive/">{{ i18n("menu.stack.archive") }}</a></li>
<li><a href="/stack/">{{ i18n("menu.stack") }}</a></li>
<li class="is-active"><a>{{ i18n("menu.detail") }}</a></li>
</ul>
</nav>
@ -46,7 +32,7 @@
<div class="hero-body">
<div class="container">
<h2 class="title is-2">
{{ .Archive.Name }}
{{ .Stack.Name }}
</h2>
</div>
</div>
@ -55,8 +41,8 @@
<nav class="navbar has-shadow">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item is-tab is-active" href="/stack/archive/{{.Archive.ID}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab" href="/stack/archive/{{.Archive.ID}}/edit">{{ i18n("menu.edit") }}</a>
<a class="navbar-item is-tab is-active" href="/stack/{{.Stack.Name}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab" href="/stack/{{.Stack.Name}}/edit">{{ i18n("menu.edit") }}</a>
</div>
</div>
</nav>
@ -65,13 +51,13 @@
<div class="container">
<dl>
<dt>{{ i18n("field.created-at") }}</dt>
<dd>{{ time(.Archive.CreatedAt) }}</dd>
<dd>{{ time(.Stack.CreatedAt) }}</dd>
<dt>{{ i18n("field.updated-at") }}</dt>
<dd>{{ time(.Archive.UpdatedAt) }}</dd>
<dd>{{ time(.Stack.UpdatedAt) }}</dd>
<dt>Content</dt>
<dd class="content"><pre class="is-paddingless"><code class="yaml">{{ .Archive.Content }}</code></pre></dd>
<dd class="content"><pre class="is-paddingless"><code class="yaml">{{ .Stack.Content }}</code></pre></dd>
</dl>
<a href="/stack/archive/" class="button is-primary">
<a href="/stack/" class="button is-primary">
<span class="icon"><i class="fas fa-reply"></i></span>
<span>{{ i18n("button.return") }}</span>
</a>

View File

@ -1,5 +1,5 @@
{{ extends "../../_layouts/default" }}
{{ import "../../_modules/form" }}
{{ extends "../_layouts/default" }}
{{ import "../_modules/form" }}
{{ block style() }}
<link rel="stylesheet" href="/assets/codemirror/codemirror.css?v=5.30">
@ -8,7 +8,7 @@
{{ block script() }}
<script src="/assets/codemirror/codemirror.js?v=5.30"></script>
<script src="/assets/codemirror/mode/yaml.js?v=5.30"></script>
<script>$(() => new Swirl.Stack.Archive.EditPage())</script>
<script>$(() => new Swirl.Stack.EditPage())</script>
{{ end }}
{{ block body() }}
@ -19,27 +19,13 @@
<h2 class="subtitle is-5">{{ i18n("stack.description") }}</h2>
</div>
</div>
<div class="hero-foot">
<div class="container">
<nav class="tabs is-boxed">
<ul>
<li>
<a href="/stack/task/">{{ i18n("menu.stack.task") }}</a>
</li>
<li class="is-active">
<a href="/stack/archive/">{{ i18n("menu.stack.archive") }}</a>
</li>
</ul>
</nav>
</div>
</div>
</section>
<div class="container">
<nav class="breadcrumb has-succeeds-separator is-small is-marginless" aria-label="breadcrumbs">
<ul>
<li><a href="/">{{ i18n("menu.home") }}</a></li>
<li><a href="/stack/archive/">{{ i18n("menu.stack.archive") }}</a></li>
<li><a href="/stack/">{{ i18n("menu.stack") }}</a></li>
<li class="is-active"><a>{{ i18n("menu.edit") }}</a></li>
</ul>
</nav>
@ -49,7 +35,7 @@
<div class="hero-body">
<div class="container">
<h2 class="title is-2">
{{ .Archive.Name }}
{{ .Stack.Name }}
</h2>
</div>
</div>
@ -58,8 +44,8 @@
<nav class="navbar has-shadow">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item is-tab" href="/stack/archive/{{.Archive.ID}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab is-active" href="/stack/archive/{{.Archive.ID}}/edit">{{ i18n("menu.edit") }}</a>
<a class="navbar-item is-tab" href="/stack/{{.Stack.Name}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab is-active" href="/stack/{{.Stack.Name}}/edit">{{ i18n("menu.edit") }}</a>
</div>
</div>
</nav>
@ -69,7 +55,7 @@
<div class="field">
<label class="label">{{ i18n("field.name") }}</label>
<div class="control">
<input id="name" name="name" class="input" value="{{ .Archive.Name }}" type="text" placeholder="" data-v-rule="native;regex" data-v-arg-regex="^[a-z0-9_-]+$" data-v-msg-regex="Name can contain only letters, digits, '_' and '-'." required>
<input id="name" name="name" class="input" value="{{ .Stack.Name }}" type="text" placeholder="" data-v-rule="native;regex" data-v-arg-regex="^[a-z0-9_-]+$" data-v-msg-regex="Name can contain only letters, digits, '_' and '-'." required>
</div>
</div>
<div class="field">
@ -81,7 +67,7 @@
<div id="div-input" class="field">
<label class="label">Content</label>
<div class="control">
<textarea id="txt-content" name="content" class="textarea" rows="20" placeholder="Compose file content" data-v-rule="content" data-v-arg-content="input" required>{{ .Archive.Content }}</textarea>
<textarea id="txt-content" name="content" class="textarea" rows="20" placeholder="Compose file content" data-v-rule="content" data-v-arg-content="input" required>{{ .Stack.Content }}</textarea>
</div>
</div>
<div id="div-upload" class="field" style="display: none">
@ -104,7 +90,7 @@
<button id="btn-submit" type="submit" class="button is-primary" data-url="update">{{ i18n("button.submit") }}</button>
</div>
<div class="control">
<a href="/stack/archive/" class="button is-link">{{ i18n("button.cancel") }}</a>
<a href="/stack/" class="button is-link">{{ i18n("button.cancel") }}</a>
</div>
</div>
</div>

114
views/stack/list.jet Normal file
View File

@ -0,0 +1,114 @@
{{ extends "../_layouts/default" }}
{{ block script() }}
<script>$(() => new Swirl.Stack.ListPage())</script>
{{ end }}
{{ block body() }}
<section class="hero is-info">
<div class="hero-body">
<div class="container has-text-centered">
<h1 class="title is-2 is-uppercase">{{ i18n("stack.title") }}</h1>
<h2 class="subtitle is-5">{{ i18n("stack.description") }}</h2>
</div>
</div>
</section>
<section class="section">
<nav class="level">
<!-- Left side -->
<div class="level-left">
<div class="level-item">
<form>
<div class="field has-addons">
<p class="control">
<input name="name" value="{{.Name}}" class="input" type="text" placeholder="Search by name">
</p>
<p class="control">
<button type="submit" class="button is-primary">{{ i18n("button.search") }}</button>
</p>
</div>
</form>
</div>
<div class="level-item">
<p class="subtitle is-5">
<strong>{{len(.Stacks)}}</strong>
<span class="is-lowercase">{{ i18n("menu.stack") }}</span>
</p>
</div>
</div>
<!-- Right side -->
<div class="level-right">
<p class="level-item">
{{if .Filter == ""}}
<strong>All</strong>
{{else}}
<a href="/stack/">All</a>
{{end}}
</p>
<p class="level-item">
{{if .Filter == "up"}}
<strong>Up</strong>
{{else}}
<a href="?filter=up">Up</a>
{{end}}
</p>
<p class="level-item">
{{if .Filter == "internal"}}
<strong>Internal</strong>
{{else}}
<a href="?filter=internal">Internal</a>
{{end}}
</p>
<p class="level-item">
{{if .Filter == "external"}}
<strong>External</strong>
{{else}}
<a href="?filter=external">External</a>
{{end}}
</p>
<p class="level-item">
<a href="new" class="button is-success"><span class="icon"><i class="fas fa-plus"></i></span><span>{{ i18n("button.new") }}</span></a>
</p>
</div>
</nav>
<table id="table-items" class="table is-bordered is-striped is-narrow is-fullwidth">
<thead>
<tr>
<th>{{ i18n("field.name") }}</th>
<th>Services</th>
<th>{{ i18n("field.created-at") }}</th>
<th>{{ i18n("field.updated-at") }}</th>
<th>{{ i18n("field.action") }}</th>
</tr>
</thead>
<tbody>
{{range .Stacks}}
<tr>
<td><a href="{{.Name}}/detail">{{.Name}}</a>{{ if !.Internal }}<span class="icon has-text-danger tooltip is-tooltip-right" data-tooltip="External stack, can't be edited by Swirl"><i class="fas fa-exclamation-circle"></i></span>{{ end }}</td>
<td>
<div class="tags">
{{range .Services}}
<a href="/service/{{.}}/detail" class="tag is-success">{{.}}</a>
{{end}}
</div>
</td>
<td>{{time(.CreatedAt)}}</td>
<td>{{time(.UpdatedAt)}}</td>
<td>
{{ if .Internal }}
<a href="{{ .Name }}/edit" class="button is-small is-dark is-outlined">{{ i18n("button.edit") }}</a>
<button class="button is-small is-info is-outlined" data-action="deploy-stack">{{ i18n("stack.button.deploy") }}</button>
{{ end }}
{{ if .Services }}
<button class="button is-small is-danger is-outlined" data-action="shutdown-stack">{{ i18n("stack.button.shutdown") }}</button>
{{ end }}
{{ if .Internal }}
<button class="button is-small is-danger is-outlined" data-action="delete-stack">{{ i18n("button.delete") }}</button>
{{ end }}
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{ end }}

View File

@ -1,5 +1,5 @@
{{ extends "../../_layouts/default" }}
{{ import "../../_modules/form" }}
{{ extends "../_layouts/default" }}
{{ import "../_modules/form" }}
{{ block style() }}
<link rel="stylesheet" href="/assets/codemirror/codemirror.css?v=5.30">
@ -8,7 +8,7 @@
{{ block script() }}
<script src="/assets/codemirror/codemirror.js?v=5.30"></script>
<script src="/assets/codemirror/mode/yaml.js?v=5.30"></script>
<script>$(() => new Swirl.Stack.Archive.EditPage())</script>
<script>$(() => new Swirl.Stack.EditPage())</script>
{{ end }}
{{ block body() }}
@ -19,20 +19,6 @@
<h2 class="subtitle is-5">{{ i18n("stack.description") }}</h2>
</div>
</div>
<div class="hero-foot">
<div class="container">
<nav class="tabs is-boxed">
<ul>
<li>
<a href="/stack/task/">{{ i18n("menu.stack.task") }}</a>
</li>
<li class="is-active">
<a href="/stack/archive/">{{ i18n("menu.stack.archive") }}</a>
</li>
</ul>
</nav>
</div>
</div>
</section>
<section class="section">
<div id="div-form" class="container">
@ -76,7 +62,7 @@
<button id="btn-submit" type="submit" class="button is-primary">{{ i18n("button.submit") }}</button>
</div>
<div class="control">
<a href="/stack/archive/" class="button is-link">{{ i18n("button.cancel") }}</a>
<a href="/stack/" class="button is-link">{{ i18n("button.cancel") }}</a>
</div>
</div>
</div>

View File

@ -1,69 +0,0 @@
{{ extends "../../_layouts/default" }}
{{ block script() }}
<script>$(() => new Swirl.Stack.Task.ListPage())</script>
{{ end }}
{{ block body() }}
<section class="hero is-info">
<div class="hero-body">
<div class="container has-text-centered">
<h1 class="title is-2 is-uppercase">{{ i18n("stack.title") }}</h1>
<h2 class="subtitle is-5">{{ i18n("stack.description") }}</h2>
</div>
</div>
<div class="hero-foot">
<div class="container">
<nav class="tabs is-boxed">
<ul>
<li class="is-active">
<a href="/stack/task/">{{ i18n("menu.stack.task") }}</a>
</li>
<li>
<a href="/stack/archive/">{{ i18n("menu.stack.archive") }}</a>
</li>
</ul>
</nav>
</div>
</div>
</section>
<section class="section">
<nav class="level">
<!-- Left side -->
<div class="level-left">
<div class="level-item">
<p class="subtitle is-5">
<strong>{{len(.Stacks)}}</strong>
<span class="is-lowercase">{{ i18n("menu.stack") }}</span>
</p>
</div>
</div>
</nav>
<table id="table-items" class="table is-bordered is-striped is-narrow is-fullwidth">
<thead>
<tr>
<th>{{ i18n("field.name") }}</th>
<th>Services</th>
<th>{{ i18n("field.action") }}</th>
</tr>
</thead>
<tbody>
{{range .Stacks}}
<tr>
<td>{{.Name}}</td>
<td>
<div class="tags">
{{range .Services}}
<a href="/service/{{.}}/detail" class="tag is-success">{{.}}</a>
{{end}}
</div>
</td>
<td>
<button class="button is-small is-danger is-outlined" data-action="delete-stack">{{ i18n("button.delete") }}</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{ end }}