Allow server to make backups
This commit is contained in:
parent
aeceae11d5
commit
b3a827e042
27 changed files with 891 additions and 418 deletions
|
|
@ -19,7 +19,7 @@ services:
|
|||
# TODO: Mount docker socket properly!
|
||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
- "${CONFIG_PATH}:${CONFIG_PATH}:ro"
|
||||
- "${DEPLOY_ROOT}:${DEPLOY_ROOT}:ro"
|
||||
- "${DEPLOY_ROOT}:${DEPLOY_ROOT}:rw"
|
||||
- "${GLOBAL_AUTHORIZED_KEYS_FILE}:${GLOBAL_AUTHORIZED_KEYS_FILE}:ro"
|
||||
- "${SELF_OVERRIDES_FILE}:${SELF_OVERRIDES_FILE}:ro"
|
||||
- "${SELF_RESOLVER_BLOCK_FILE}:${SELF_RESOLVER_BLOCK_FILE}:ro"
|
||||
|
|
|
|||
|
|
@ -1,207 +0,0 @@
|
|||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/config"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/tkw1536/goprogram/stream"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
component.ComponentBase
|
||||
|
||||
Instances *instances.Instances
|
||||
}
|
||||
|
||||
func (Info) Name() string { return "control-info" }
|
||||
|
||||
func (*Info) Routes() []string { return []string{"/dis/"} }
|
||||
|
||||
func (info *Info) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// handle everything under /dis/!
|
||||
mux.HandleFunc("/dis/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/dis/" {
|
||||
http.Redirect(w, r, "/dis/index", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// render everything
|
||||
mux.Handle("/dis/index", httpx.HTMLHandler[disIndex]{
|
||||
Handler: info.disIndex,
|
||||
Template: indexTemplate,
|
||||
})
|
||||
|
||||
mux.Handle("/dis/instance/", httpx.HTMLHandler[disInstance]{
|
||||
Handler: info.disInstance,
|
||||
Template: instanceTemplate,
|
||||
})
|
||||
|
||||
// api -- for future usage
|
||||
mux.Handle("/dis/api/v1/instance/get/", httpx.JSON(info.getinstance))
|
||||
mux.Handle("/dis/api/v1/instance/all", httpx.JSON(info.allinstances))
|
||||
|
||||
// ensure that everyone is logged in!
|
||||
return httpx.BasicAuth(mux, "WissKI Distillery Admin", func(user, pass string) bool {
|
||||
return user == info.Config.DisAdminUser && pass == info.Config.DisAdminPassword
|
||||
}), nil
|
||||
}
|
||||
|
||||
// disIndex is the context of the "/dis/index" page
|
||||
type disIndex struct {
|
||||
Time time.Time
|
||||
|
||||
Config *config.Config
|
||||
|
||||
Instances []instances.WissKIInfo
|
||||
|
||||
TotalCount int
|
||||
RunningCount int
|
||||
StoppedCount int
|
||||
|
||||
Backups []models.Snapshot
|
||||
}
|
||||
|
||||
func (info *Info) disIndex(r *http.Request) (idx disIndex, err error) {
|
||||
var group errgroup.Group
|
||||
|
||||
group.Go(func() error {
|
||||
// load instances
|
||||
idx.Instances, err = info.allinstances(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// count how many are running and how many are stopped
|
||||
for _, i := range idx.Instances {
|
||||
if i.Running {
|
||||
idx.RunningCount++
|
||||
} else {
|
||||
idx.StoppedCount++
|
||||
}
|
||||
}
|
||||
idx.TotalCount = len(idx.Instances)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// get the log entries
|
||||
group.Go(func() (err error) {
|
||||
idx.Backups, err = info.Instances.SnapshotLogFor("")
|
||||
return
|
||||
})
|
||||
|
||||
// get the static properties
|
||||
idx.Config = info.Config
|
||||
|
||||
// current time
|
||||
idx.Time = time.Now().UTC()
|
||||
|
||||
// wait for everything!
|
||||
group.Wait()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// disInstance is the context of the "/dis/instance/*" page
|
||||
type disInstance struct {
|
||||
Time time.Time
|
||||
|
||||
Instance models.Instance
|
||||
Info instances.WissKIInfo
|
||||
}
|
||||
|
||||
func (info *Info) disInstance(r *http.Request) (is disInstance, err error) {
|
||||
// find the slug as the last component of path!
|
||||
slug := strings.TrimSuffix(r.URL.Path, "/")
|
||||
slug = slug[strings.LastIndex(slug, "/")+1:]
|
||||
|
||||
// find the instance itself!
|
||||
instance, err := info.Instances.WissKI(slug)
|
||||
if err == instances.ErrWissKINotFound {
|
||||
return is, httpx.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return is, err
|
||||
}
|
||||
is.Instance = instance.Instance
|
||||
|
||||
// get some more info about the wisski
|
||||
is.Info, err = instance.Info(false)
|
||||
if err != nil {
|
||||
return is, err
|
||||
}
|
||||
|
||||
// current time
|
||||
is.Time = time.Now().UTC()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//go:embed "html/index.html"
|
||||
var indexTemplateStr string
|
||||
var indexTemplate = template.Must(template.New("index.html").Parse(indexTemplateStr))
|
||||
|
||||
//go:embed "html/instance.html"
|
||||
var instanceTemplateString string
|
||||
var instanceTemplate = template.Must(template.New("instance.html").Parse(instanceTemplateString))
|
||||
|
||||
func (info *Info) getinstance(r *http.Request) (iinfo instances.WissKIInfo, err error) {
|
||||
// find the slug as the last component of path!
|
||||
slug := strings.TrimSuffix(r.URL.Path, "/")
|
||||
slug = slug[strings.LastIndex(slug, "/")+1:]
|
||||
|
||||
// load the wisski instance!
|
||||
wisski, err := info.Instances.WissKI(strings.TrimSuffix(slug, "/"))
|
||||
if err == instances.ErrWissKINotFound {
|
||||
return iinfo, httpx.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return iinfo, err
|
||||
}
|
||||
|
||||
// get info about it!
|
||||
return wisski.Info(false)
|
||||
}
|
||||
|
||||
func (info *Info) allinstances(*http.Request) (infos []instances.WissKIInfo, err error) {
|
||||
var errgroup errgroup.Group
|
||||
|
||||
// list all the instances
|
||||
all, err := info.Instances.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get all of their info!
|
||||
infos = make([]instances.WissKIInfo, len(all))
|
||||
for i, instance := range all {
|
||||
{
|
||||
i := i
|
||||
instance := instance
|
||||
|
||||
errgroup.Go(func() (err error) {
|
||||
infos[i], err = instance.Info(true)
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// wait for the results, and return
|
||||
err = errgroup.Wait()
|
||||
return
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import (
|
|||
|
||||
func (home *Home) updateInstances(ctx context.Context, io stream.IOStream) {
|
||||
timex.SetInterval(ctx, home.RefreshInterval, func(t time.Time) {
|
||||
io.Printf("[%s]: reloading instance list", t.String())
|
||||
io.Printf("[%s]: reloading instance list\n", t.Format(time.Stamp))
|
||||
|
||||
names, _ := home.instanceMap()
|
||||
home.instanceNames.Set(names)
|
||||
|
|
@ -38,7 +38,7 @@ func (home *Home) instanceMap() (map[string]struct{}, error) {
|
|||
|
||||
func (home *Home) updateRender(ctx context.Context, io stream.IOStream) {
|
||||
timex.SetInterval(ctx, home.RefreshInterval, func(t time.Time) {
|
||||
io.Printf("[%s]: reloading home render", t.String())
|
||||
io.Printf("[%s]: reloading home render\n", t.Format(time.Stamp))
|
||||
|
||||
bytes, _ := home.homeRender()
|
||||
home.homeBytes.Set(bytes)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
func (home *Home) updateRedirect(ctx context.Context, io stream.IOStream) {
|
||||
timex.SetInterval(ctx, home.RefreshInterval, func(t time.Time) {
|
||||
io.Printf("[%s]: reloading overrides", t.String())
|
||||
io.Printf("[%s]: reloading overrides\n", t.Format(time.Stamp))
|
||||
redirect, _ := home.loadRedirect()
|
||||
home.redirect.Set(&redirect)
|
||||
})
|
||||
|
|
@ -95,6 +95,10 @@ func (redirect Redirect) Redirect(r *http.Request) string {
|
|||
func (redirect Redirect) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
dest := redirect.Redirect(r)
|
||||
if dest == "" {
|
||||
if redirect.Fallback == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
redirect.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@
|
|||
<hr />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<button class="remote-action" data-action="snapshot" data-param="{{ .Instance.Slug }}" data-target="#snapshot" data-buffer="20">Take a snapshot</button>
|
||||
<pre class="remote-action-out" id="snapshot"></pre>
|
||||
</p>
|
||||
|
||||
<footer>
|
||||
Generated at <code>{{ .Time }}</code>
|
||||
</footer>
|
||||
85
internal/component/info/index.go
Normal file
85
internal/component/info/index.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package info
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/config"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
//go:embed "html/index.html"
|
||||
var indexTemplateStr string
|
||||
var indexTemplate = template.Must(template.New("index.html").Parse(indexTemplateStr))
|
||||
|
||||
type indexPageContext struct {
|
||||
Time time.Time
|
||||
|
||||
Config *config.Config
|
||||
|
||||
Instances []instances.WissKIInfo
|
||||
|
||||
TotalCount int
|
||||
RunningCount int
|
||||
StoppedCount int
|
||||
|
||||
Backups []models.Snapshot
|
||||
}
|
||||
|
||||
func (info *Info) indexPageAPI(r *http.Request) (idx indexPageContext, err error) {
|
||||
var group errgroup.Group
|
||||
|
||||
group.Go(func() error {
|
||||
// list all the instances
|
||||
all, err := info.Instances.All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get all of their info!
|
||||
idx.Instances = make([]instances.WissKIInfo, len(all))
|
||||
for i, instance := range all {
|
||||
{
|
||||
i := i
|
||||
instance := instance
|
||||
|
||||
// store the info for this group!
|
||||
group.Go(func() (err error) {
|
||||
idx.Instances[i], err = instance.Info(true)
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// get the log entries
|
||||
group.Go(func() (err error) {
|
||||
idx.Backups, err = info.Instances.SnapshotLogFor("")
|
||||
return
|
||||
})
|
||||
|
||||
// get the static properties
|
||||
idx.Config = info.Config
|
||||
idx.Time = time.Now().UTC()
|
||||
|
||||
group.Wait()
|
||||
|
||||
// count how many are running and how many are stopped
|
||||
for _, i := range idx.Instances {
|
||||
if i.Running {
|
||||
idx.RunningCount++
|
||||
} else {
|
||||
idx.StoppedCount++
|
||||
}
|
||||
}
|
||||
idx.TotalCount = len(idx.Instances)
|
||||
|
||||
return
|
||||
}
|
||||
59
internal/component/info/info.go
Normal file
59
internal/component/info/info.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package info
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component/snapshots"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/tkw1536/goprogram/stream"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
component.ComponentBase
|
||||
|
||||
SnapshotManager *snapshots.Manager
|
||||
Instances *instances.Instances
|
||||
}
|
||||
|
||||
func (Info) Name() string { return "control-info" }
|
||||
|
||||
func (*Info) Routes() []string { return []string{"/dis/"} }
|
||||
|
||||
func (info *Info) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// handle everything
|
||||
mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == route {
|
||||
http.Redirect(w, r, route+"/index", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// add a handler for the index page
|
||||
mux.Handle(route+"index", httpx.HTMLHandler[indexPageContext]{
|
||||
Handler: info.indexPageAPI,
|
||||
Template: indexTemplate,
|
||||
})
|
||||
|
||||
// add a handler for the instance page
|
||||
mux.Handle(route+"instance/", httpx.HTMLHandler[instancePageContext]{
|
||||
Handler: info.instancePageAPI,
|
||||
Template: instanceTemplate,
|
||||
})
|
||||
|
||||
handler := &httpx.WebSocket{
|
||||
Context: context,
|
||||
Fallback: mux,
|
||||
Handler: info.serveSocket,
|
||||
}
|
||||
|
||||
// ensure that everyone is logged in!
|
||||
return httpx.BasicAuth(handler, "WissKI Distillery Admin", func(user, pass string) bool {
|
||||
return user == info.Config.DisAdminUser && pass == info.Config.DisAdminPassword
|
||||
}), nil
|
||||
}
|
||||
51
internal/component/info/instance.go
Normal file
51
internal/component/info/instance.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package info
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
)
|
||||
|
||||
//go:embed "html/instance.html"
|
||||
var instanceTemplateString string
|
||||
var instanceTemplate = template.Must(template.New("instance.html").Parse(instanceTemplateString))
|
||||
|
||||
type instancePageContext struct {
|
||||
Time time.Time
|
||||
|
||||
Instance models.Instance
|
||||
Info instances.WissKIInfo
|
||||
}
|
||||
|
||||
func (info *Info) instancePageAPI(r *http.Request) (is instancePageContext, err error) {
|
||||
// find the slug as the last component of path!
|
||||
slug := strings.TrimSuffix(r.URL.Path, "/")
|
||||
slug = slug[strings.LastIndex(slug, "/")+1:]
|
||||
|
||||
// find the instance itself!
|
||||
instance, err := info.Instances.WissKI(slug)
|
||||
if err == instances.ErrWissKINotFound {
|
||||
return is, httpx.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return is, err
|
||||
}
|
||||
is.Instance = instance.Instance
|
||||
|
||||
// get some more info about the wisski
|
||||
is.Info, err = instance.Info(false)
|
||||
if err != nil {
|
||||
return is, err
|
||||
}
|
||||
|
||||
// current time
|
||||
is.Time = time.Now().UTC()
|
||||
|
||||
return
|
||||
}
|
||||
73
internal/component/info/socket.go
Normal file
73
internal/component/info/socket.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package info
|
||||
|
||||
import (
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/component/snapshots"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/tkw1536/goprogram/status"
|
||||
"github.com/tkw1536/goprogram/stream"
|
||||
)
|
||||
|
||||
func (info *Info) serveSocket(conn httpx.WebSocketConnection) {
|
||||
// read the next message to act on
|
||||
message, ok := <-conn.Read()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch string(message.Bytes) {
|
||||
case "snapshot":
|
||||
slug, ok := <-conn.Read()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info.serverSocketSnapshot(string(slug.Bytes), info.socketWriter(conn))
|
||||
}
|
||||
}
|
||||
|
||||
func (*Info) socketWriter(conn httpx.WebSocketConnection) *status.LineBuffer {
|
||||
return &status.LineBuffer{
|
||||
Line: func(line string) {
|
||||
<-conn.WriteText(line)
|
||||
},
|
||||
FlushLineOnClose: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (info *Info) serverSocketSnapshot(slug string, writer *status.LineBuffer) {
|
||||
stream := stream.NewIOStream(writer, writer, nil, 0)
|
||||
|
||||
// get the wisski
|
||||
wissKI, err := info.Instances.WissKI(slug)
|
||||
if err != nil {
|
||||
stream.EPrintln(err)
|
||||
return
|
||||
}
|
||||
|
||||
{
|
||||
err := info.SnapshotManager.HandleSnapshotLike(
|
||||
stream,
|
||||
snapshots.SnapshotFlags{
|
||||
Dest: "",
|
||||
Slug: slug,
|
||||
Title: "Snapshot",
|
||||
StagingOnly: false,
|
||||
Do: func(dest string) snapshots.SnapshotLike {
|
||||
snapshot := info.SnapshotManager.NewSnapshot(
|
||||
wissKI,
|
||||
stream,
|
||||
snapshots.SnapshotDescription{
|
||||
Dest: dest,
|
||||
},
|
||||
)
|
||||
return &snapshot
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
stream.EPrintln(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
stream.Println("Done")
|
||||
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import (
|
|||
// updatePrefixes starts updating prefixes
|
||||
func (resolver *Resolver) updatePrefixes(io stream.IOStream, ctx context.Context) {
|
||||
timex.SetInterval(ctx, resolver.RefreshInterval, func(t time.Time) {
|
||||
io.Printf("[%s]: reloading prefixes", t.String())
|
||||
io.Printf("[%s]: reloading prefixes\n", t.Format(time.Stamp))
|
||||
prefixes, _ := resolver.AllPrefixes()
|
||||
resolver.prefixes.Set(prefixes)
|
||||
})
|
||||
|
|
|
|||
134
internal/component/snapshots/iface.go
Normal file
134
internal/component/snapshots/iface.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package snapshots
|
||||
|
||||
import (
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/logging"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/targz"
|
||||
"github.com/tkw1536/goprogram/status"
|
||||
"github.com/tkw1536/goprogram/stream"
|
||||
)
|
||||
|
||||
type SnapshotFlags struct {
|
||||
Dest string
|
||||
Slug string
|
||||
Title string // "Backup" or "Snapshot"
|
||||
StagingOnly bool
|
||||
|
||||
Do func(dest string) SnapshotLike
|
||||
}
|
||||
|
||||
type SnapshotLike interface {
|
||||
LogEntry() models.Snapshot
|
||||
Report(w io.Writer) (int, error)
|
||||
}
|
||||
|
||||
func (manager *Manager) HandleSnapshotLike(context stream.IOStream, flags SnapshotFlags) (err error) {
|
||||
// determine target paths
|
||||
logging.LogMessage(context, "Determining target paths")
|
||||
var stagingDir, archivePath string
|
||||
if flags.StagingOnly {
|
||||
stagingDir = flags.Dest
|
||||
} else {
|
||||
archivePath = flags.Dest
|
||||
}
|
||||
if stagingDir == "" {
|
||||
stagingDir, err = manager.NewStagingDir(flags.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !flags.StagingOnly && archivePath == "" {
|
||||
archivePath = manager.NewArchivePath(flags.Slug)
|
||||
}
|
||||
context.Printf("Staging Directory: %s\n", stagingDir)
|
||||
context.Printf("Archive Path: %s\n", archivePath)
|
||||
|
||||
// create the staging directory
|
||||
logging.LogMessage(context, "Creating staging directory")
|
||||
err = manager.Environment.Mkdir(stagingDir, environment.DefaultDirPerm)
|
||||
if !environment.IsExist(err) && err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if it was requested to not do staging only
|
||||
// we need the staging directory to be deleted at the end
|
||||
if !flags.StagingOnly {
|
||||
defer func() {
|
||||
logging.LogMessage(context, "Removing staging directory")
|
||||
manager.Environment.RemoveAll(stagingDir)
|
||||
}()
|
||||
}
|
||||
|
||||
// create the actual snapshot or backup
|
||||
// write out the report
|
||||
// and retain a log entry
|
||||
var entry models.Snapshot
|
||||
logging.LogOperation(func() error {
|
||||
// do the snapshot!
|
||||
sl := flags.Do(stagingDir)
|
||||
|
||||
// create a log entry
|
||||
entry = sl.LogEntry()
|
||||
|
||||
// find the report path
|
||||
reportPath := filepath.Join(stagingDir, "report.txt")
|
||||
context.Println(reportPath)
|
||||
|
||||
// create the path
|
||||
report, err := manager.Environment.Create(reportPath, environment.DefaultFilePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// and write out the report
|
||||
{
|
||||
_, err := sl.Report(report)
|
||||
return err
|
||||
}
|
||||
}, context, "Generating %s", flags.Title)
|
||||
|
||||
// if we only requested staging
|
||||
// all that is left is to write the log entry
|
||||
if flags.StagingOnly {
|
||||
logging.LogMessage(context, "Writing Log Entry")
|
||||
|
||||
// write out the log entry
|
||||
entry.Path = stagingDir
|
||||
entry.Packed = false
|
||||
manager.Instances.AddSnapshotLog(entry)
|
||||
|
||||
context.Printf("Wrote %s\n", stagingDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// package everything up as an archive!
|
||||
if err := logging.LogOperation(func() error {
|
||||
var count int64
|
||||
defer func() { context.Printf("Wrote %d byte(s) to %s\n", count, archivePath) }()
|
||||
|
||||
st := status.NewWithCompat(context.Stdout, 1)
|
||||
st.Start()
|
||||
defer st.Stop()
|
||||
|
||||
count, err = targz.Package(manager.Environment, archivePath, stagingDir, func(dst, src string) {
|
||||
st.Set(0, dst)
|
||||
})
|
||||
|
||||
return err
|
||||
}, context, "Writing archive"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write out the log entry
|
||||
logging.LogMessage(context, "Writing Log Entry")
|
||||
entry.Path = archivePath
|
||||
entry.Packed = true
|
||||
manager.Instances.AddSnapshotLog(entry)
|
||||
|
||||
// and we're done!
|
||||
return nil
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ import (
|
|||
// SnapshotDescription is a description for a snapshot
|
||||
type SnapshotDescription struct {
|
||||
Dest string // destination path
|
||||
Log bool // should we log the creation of this snapshot?
|
||||
Keepalive bool // should we keep the instance alive while making the snapshot?
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
html{color:#1a1a1a;background-color:#fdfdfd;font-family:Roboto;font-size:20px;line-height:1.5}body{max-width:36em;-webkit-hyphens:auto;hyphens:auto;overflow-wrap:break-word;text-rendering:optimizelegibility;font-kerning:normal;margin:0 auto;padding:50px}@media (max-width:600px){body{padding:1em;font-size:.9em}}h1{margin-top:1.4em}h2,h3{margin-top:1em}code{color:#00f;font-family:Roboto Mono}p{text-align:justify;margin:1em 0}a,a:visited{color:#1a1a1a}footer{text-align:center;border-top:1px solid #1a1a1a;font-size:small}.header-link{opacity:0;font-size:.8em;text-decoration:none;transition:opacity .2s ease-in-out .1s;position:relative;left:.5em}h2:hover .header-link,h3:hover .header-link,h4:hover .header-link,h5:hover .header-link,h6:hover .header-link{opacity:1}.wisski{padding-left:5px}.wisski.running{background-color:#9ada07}.wisski.stopped{background-color:#ff7a7a}
|
||||
html{color:#1a1a1a;background-color:#fdfdfd;font-family:Roboto;font-size:20px;line-height:1.5}body{max-width:36em;-webkit-hyphens:auto;hyphens:auto;overflow-wrap:break-word;text-rendering:optimizelegibility;font-kerning:normal;margin:0 auto;padding:50px}@media (max-width:600px){body{padding:1em;font-size:.9em}}h1{margin-top:1.4em}h2,h3{margin-top:1em}code{color:#00f;font-family:Roboto Mono}p{text-align:justify;margin:1em 0}a,a:visited{color:#1a1a1a}footer{text-align:center;border-top:1px solid #1a1a1a;font-size:small}.header-link{opacity:0;font-size:.8em;text-decoration:none;transition:opacity .2s ease-in-out .1s;position:relative;left:.5em}h2:hover .header-link,h3:hover .header-link,h4:hover .header-link,h5:hover .header-link,h6:hover .header-link{opacity:1}.wisski{padding-left:5px}.wisski.running{background-color:#9ada07}.wisski.stopped{background-color:#ff7a7a}.remote-action-out{font-size:small}
|
||||
|
|
@ -1 +1 @@
|
|||
(()=>{const e=e=>{const t=document.getElementsByTagName("h"+e);Array.from(t).forEach((e=>{void 0!==e.id&&""!==e.id&&e.appendChild((e=>{const t=document.createElement("a");return t.className="header-link",t.href="#"+e,t.innerHTML="#",t})(e.id))}))};new Array(6).fill(0).forEach(((t,n)=>e(n+1)));const t={date:e=>new Date(e.innerText).toISOString(),path:e=>{const t=e.innerText.split("/");return t[t.length-1]},pathbuilders:()=>{const e=window.pathbuilders,t=document.createElement("span");let o=!1;if(Object.keys(e).forEach((r=>{o=!0;const a=r+".xml",c=e[r];t.append(n(a,r,c,"application/xml")),t.append(document.createTextNode(" "))})),!o)return"(none)";const r=document.createElement("small");return r.append(document.createTextNode("(click to download)")),t.append(r),t}},n=(e,t,n,o)=>{const r=new Blob([n],{type:o??"text/plain"}),a=document.createElement("a");return a.target="_blank",a.download=e,a.href=URL.createObjectURL(r),a.append(document.createTextNode(t)),a};Object.keys(t).forEach((e=>{const n=t[e];document.querySelectorAll("code."+e).forEach((e=>{const t=n(e);if("string"==typeof t)return e.innerHTML="",void e.appendChild(document.createTextNode(t));e.parentNode.replaceChild(t,e)}))}))})();
|
||||
(()=>{const e=e=>{const t=document.getElementsByTagName("h"+e);Array.from(t).forEach((e=>{void 0!==e.id&&""!==e.id&&e.appendChild((e=>{const t=document.createElement("a");return t.className="header-link",t.href="#"+e,t.innerHTML="#",t})(e.id))}))};new Array(6).fill(0).forEach(((t,n)=>e(n+1)));const t={date:e=>new Date(e.innerText).toISOString(),path:e=>{const t=e.innerText.split("/");return t[t.length-1]},pathbuilders:()=>{const e=window.pathbuilders,t=document.createElement("span");let o=!1;if(Object.keys(e).forEach((r=>{o=!0;const a=r+".xml",c=e[r];t.append(n(a,r,c,"application/xml")),t.append(document.createTextNode(" "))})),!o)return"(none)";const r=document.createElement("small");return r.append(document.createTextNode("(click to download)")),t.append(r),t}},n=(e,t,n,o)=>{const r=new Blob([n],{type:o??"text/plain"}),a=document.createElement("a");return a.target="_blank",a.download=e,a.href=URL.createObjectURL(r),a.append(document.createTextNode(t)),a};Object.keys(t).forEach((e=>{const n=t[e];document.querySelectorAll("code."+e).forEach((e=>{const t=n(e);if("string"==typeof t)return e.innerHTML="",void e.appendChild(document.createTextNode(t));e.parentNode.replaceChild(t,e)}))}));const o=document.getElementsByClassName("remote-action");Array.from(o).forEach((e=>{const t=e.getAttribute("data-action"),n=e.getAttribute("data-param"),o=document.querySelector(e.getAttribute("data-target")),r=function(){const t=parseInt(e.getAttribute("data-buffer")??"",10)??0;return isFinite(t)&&t>0?t:0}();let a=!1;e.addEventListener("click",(function(c){if(c.preventDefault(),a)return;a=!0,e.setAttribute("disabled","disabled");const d=function(){e.removeAttribute("disabled"),a=!1};o.innerText="";const i=[],s=function(e){0!==r?(i.push(e),i.length>r&&i.splice(0,i.length-r),o.innerText=i.join("\n")):o.innerText+=e+"\n"};var l,u;s("Connecting ..."),(l=e=>{s("Connected"),e.send(t),"string"==typeof n&&e.send(n)},u=e=>{s(e)},new Promise(((e,t)=>{const n=new WebSocket(location.href.replace("http","ws"));n.onclose=e,n.onerror=t,n.onmessage=e=>u(e.data),n.onopen=()=>l(n)}))).then((()=>{s("Connection closed.\n"),d()})).catch((()=>{s("Connection errored.\n"),d()}))}))}))})();
|
||||
65
internal/component/static/src/control/highlight.ts
Normal file
65
internal/component/static/src/control/highlight.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
|
||||
const types: Record<string, (element: HTMLElement) => HTMLElement | string> = {
|
||||
"date": (element) => {
|
||||
return (new Date(element.innerText)).toISOString()
|
||||
},
|
||||
"path": (element) => {
|
||||
const text = element.innerText.split("/");
|
||||
return text[text.length - 1];
|
||||
},
|
||||
"pathbuilders": () => {
|
||||
const pathbuilders: {[name: string]: string} = (window as any).pathbuilders; // must be declared globally on page!
|
||||
const wrapper = document.createElement("span");
|
||||
|
||||
let found_one = false
|
||||
Object.keys(pathbuilders).forEach(name => {
|
||||
found_one = true
|
||||
|
||||
const filename = name + ".xml"
|
||||
const data = pathbuilders[name]
|
||||
const mime = "application/xml"
|
||||
wrapper.append(make_download_link(filename, name, data, mime))
|
||||
wrapper.append(document.createTextNode(" "))
|
||||
})
|
||||
|
||||
if (!found_one) return '(none)';
|
||||
|
||||
const small = document.createElement('small')
|
||||
small.append(document.createTextNode("(click to download)"))
|
||||
wrapper.append(small)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
}
|
||||
|
||||
const make_download_link = (filename: string, title: string, content: string, type: string) => {
|
||||
const blob = new Blob(
|
||||
[content],
|
||||
{
|
||||
type: type ?? "text/plain"
|
||||
}
|
||||
);
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.target = "_blank"
|
||||
link.download = filename
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.append(document.createTextNode(title))
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
Object.keys(types).forEach(key => {
|
||||
const f = types[key];
|
||||
const elements = document.querySelectorAll("code." + key) as NodeListOf<HTMLElement>
|
||||
elements.forEach(element => {
|
||||
const newElement = f(element)
|
||||
if (typeof newElement === 'string') {
|
||||
element.innerHTML = ""
|
||||
element.appendChild(document.createTextNode(newElement))
|
||||
return
|
||||
}
|
||||
|
||||
element.parentNode!.replaceChild(newElement, element)
|
||||
})
|
||||
})
|
||||
|
|
@ -11,3 +11,7 @@
|
|||
.wisski.stopped {
|
||||
background-color: #ff7a7a;
|
||||
}
|
||||
|
||||
.remote-action-out {
|
||||
font-size: small;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,5 @@
|
|||
import '../global.ts';
|
||||
import './index.css';
|
||||
|
||||
|
||||
const types: Record<string, (element: HTMLElement) => HTMLElement | string> = {
|
||||
"date": (element) => {
|
||||
return (new Date(element.innerText)).toISOString()
|
||||
},
|
||||
"path": (element) => {
|
||||
const text = element.innerText.split("/");
|
||||
return text[text.length - 1];
|
||||
},
|
||||
"pathbuilders": () => {
|
||||
const pathbuilders: {[name: string]: string} = (window as any).pathbuilders; // must be declared globally on page!
|
||||
const wrapper = document.createElement("span");
|
||||
|
||||
let found_one = false
|
||||
Object.keys(pathbuilders).forEach(name => {
|
||||
found_one = true
|
||||
|
||||
const filename = name + ".xml"
|
||||
const data = pathbuilders[name]
|
||||
const mime = "application/xml"
|
||||
wrapper.append(make_download_link(filename, name, data, mime))
|
||||
wrapper.append(document.createTextNode(" "))
|
||||
})
|
||||
|
||||
if (!found_one) return '(none)';
|
||||
|
||||
const small = document.createElement('small')
|
||||
small.append(document.createTextNode("(click to download)"))
|
||||
wrapper.append(small)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
}
|
||||
|
||||
const make_download_link = (filename: string, title: string, content: string, type: string) => {
|
||||
const blob = new Blob(
|
||||
[content],
|
||||
{
|
||||
type: type ?? "text/plain"
|
||||
}
|
||||
);
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.target = "_blank"
|
||||
link.download = filename
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.append(document.createTextNode(title))
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
Object.keys(types).forEach(key => {
|
||||
const f = types[key];
|
||||
const elements = document.querySelectorAll("code." + key) as NodeListOf<HTMLElement>
|
||||
elements.forEach(element => {
|
||||
const newElement = f(element)
|
||||
if (typeof newElement === 'string') {
|
||||
element.innerHTML = ""
|
||||
element.appendChild(document.createTextNode(newElement))
|
||||
return
|
||||
}
|
||||
|
||||
element.parentNode!.replaceChild(newElement, element)
|
||||
})
|
||||
})
|
||||
import './highlight.ts';
|
||||
import './remote.ts';
|
||||
|
|
|
|||
62
internal/component/static/src/control/remote.ts
Normal file
62
internal/component/static/src/control/remote.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import connectSocket from '../socket/socket';
|
||||
|
||||
const elements = document.getElementsByClassName('remote-action')
|
||||
Array.from(elements).forEach((element) => {
|
||||
const action = element.getAttribute('data-action') as string;
|
||||
const param = element.getAttribute('data-param') as string | undefined;
|
||||
const target = document.querySelector(element.getAttribute('data-target')!) as HTMLElement;
|
||||
const bufferSize = (function() {
|
||||
const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0;
|
||||
return (isFinite(number) && number > 0) ? number : 0;
|
||||
})()
|
||||
|
||||
let running = false
|
||||
element.addEventListener('click', function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
// already running
|
||||
if (running) return
|
||||
|
||||
running = true
|
||||
element.setAttribute('disabled', 'disabled');
|
||||
const close = function() {
|
||||
element.removeAttribute('disabled');
|
||||
running = false;
|
||||
}
|
||||
|
||||
target.innerText = "";
|
||||
|
||||
const buffer: Array<string> = [];
|
||||
const println = function(line: string) {
|
||||
if(bufferSize === 0) {
|
||||
target.innerText += line + "\n";
|
||||
return;
|
||||
}
|
||||
|
||||
buffer.push(line);
|
||||
if(buffer.length > bufferSize) {
|
||||
buffer.splice(0, buffer.length - bufferSize)
|
||||
}
|
||||
target.innerText = buffer.join("\n");
|
||||
}
|
||||
|
||||
println("Connecting ...")
|
||||
|
||||
// connect to the socket and send the action
|
||||
connectSocket((socket) => {
|
||||
println("Connected")
|
||||
socket.send(action);
|
||||
if (typeof param === 'string') {
|
||||
socket.send(param);
|
||||
}
|
||||
}, (data) => {
|
||||
println(data);
|
||||
}).then(() => {
|
||||
println("Connection closed.\n")
|
||||
close();
|
||||
}).catch(() => {
|
||||
println("Connection errored.\n")
|
||||
close();
|
||||
});
|
||||
});
|
||||
})
|
||||
11
internal/component/static/src/socket/socket.ts
Normal file
11
internal/component/static/src/socket/socket.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export default function connectSocket(onOpen: (socket: WebSocket) => void, onData: (data: any) => void): Promise<CloseEvent> {
|
||||
return new Promise((rs, rj) => {
|
||||
const socket = new WebSocket(location.href.replace('http', 'ws'));
|
||||
|
||||
socket.onclose = rs;
|
||||
socket.onerror = rj;
|
||||
|
||||
socket.onmessage = (ev) => onData(ev.data)
|
||||
socket.onopen = () => onOpen(socket);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue