Allow server to make backups

This commit is contained in:
Tom Wiesing 2022-10-07 16:30:07 +02:00
parent aeceae11d5
commit b3a827e042
No known key found for this signature in database
27 changed files with 891 additions and 418 deletions

View file

@ -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"

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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>

View 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
}

View 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
}

View 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
}

View 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")
}

View file

@ -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)
})

View 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
}

View file

@ -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?
}

View file

@ -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}

View file

@ -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()}))}))}))})();

View 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)
})
})

View file

@ -11,3 +11,7 @@
.wisski.stopped {
background-color: #ff7a7a;
}
.remote-action-out {
font-size: small;
}

View file

@ -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';

View 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();
});
});
})

View 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);
});
}

View file

@ -6,6 +6,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/component"
"github.com/FAU-CDI/wisski-distillery/internal/component/control"
"github.com/FAU-CDI/wisski-distillery/internal/component/home"
"github.com/FAU-CDI/wisski-distillery/internal/component/info"
"github.com/FAU-CDI/wisski-distillery/internal/component/instances"
"github.com/FAU-CDI/wisski-distillery/internal/component/resolver"
"github.com/FAU-CDI/wisski-distillery/internal/component/snapshots"
@ -52,7 +53,7 @@ func (dis *Distillery) register(context *component.PoolContext) []component.Comp
r(dis, context, func(resolver *resolver.Resolver) {
resolver.RefreshInterval = time.Minute
}),
ra[*control.Info](dis, context),
ra[*info.Info](dis, context),
}
}