Initial status page

This commit is contained in:
Tom Wiesing 2022-09-16 17:54:40 +02:00
parent a3511b1bfc
commit a1f35b97d3
No known key found for this signature in database
17 changed files with 618 additions and 83 deletions

View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<link rel="stylesheet" href="/dis/static/dis.css">
<link rel="stylesheet" href="/dis/static/autolink.css">
<title>Distillery Status Page</title>
<h1 id="top">Distillery Status Page</h1>
<h2 id="overview">Overview</h2>
<p>
<b>Domain:</b> <code>{{.Config.DefaultDomain}}</code> <br />
<b>Legacy Domain(s):</b> <code>{{.Config.SelfExtraDomains}}</code><br />
<b>HTTPS Email:</b> <code>{{.Config.CertbotEmail}}</code><br />
<hr />
<b>Homepage Redirect:</b><a href="{{.Config.SelfRedirect}}" target="_blank" rel="noopener noreferrer">{{.Config.SelfRedirect}}</a><br />
<hr />
<b>Backup Age:</b> <code>{{.Config.MaxBackupAge}}</code> Day(s)<br />
<hr />
<b>Base Directory:</b> <code>{{.Config.DeployRoot}}</code><br />
<b>Configuration File:</b> <code>{{.Config.ConfigPath}}</code><br />
<b>Authorized_Keys File:</b> <code>{{.Config.GlobalAuthorizedKeysFile}}</code><br />
<hr />
<b>MySQL User Prefix:</b> <code>{{.Config.MysqlUserPrefix}}</code><br />
<b>MySQL Database Prefix:</b> <code>{{.Config.MysqlDatabasePrefix}}</code><br />
<b>GraphDB User Prefix:</b> <code>{{.Config.GraphDBUserPrefix}}</code><br />
<b>GraphDB Database Prefix:</b> <code>{{.Config.GraphDBRepoPrefix}}</code><br />
<hr />
<b>Bookkeeping Database:</b> <code>{{.Config.DistilleryBookkeepingDatabase}}</code><br />
<b>Bookkeeping Table:</b> <code>{{.Config.DistilleryBookkeepingTable}}</code><br />
</p>
<h2 id="instances">Instances</h2>
<p>
<code>{{ .TotalCount }}</code> instance(s) = <code>{{ .RunningCount }}</code> running + <code>{{ .StoppedCount }}</code> stopped<br />
</p>
{{range .Instances}}
<div class="wisski {{ if .Running }}running{{ else }}stopped{{ end }}">
<h3 id="instance-{{.Slug}}">{{.Slug}}{{ if not .Running }}&nbsp;<small>not running</small>{{ end }}</h3>
<p>
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a><br />
<small>
<a href="/dis/instance/{{.Slug}}">More Details</a>
</small>
</p>
</div>
{{end}}
<footer>
Generated at <code>{{ .Time }}</code>
</footer>
<script src="/dis/static/autolink.js"></script>

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<link rel="stylesheet" href="/dis/static/dis.css">
<title>Distillery Status Page - {{ .Info.Slug }}</title>
<h1 id="top">Distillery Status Page - {{ .Info.Slug }}</h1>
<p>
<a href="/dis/index">Back to index</a>
</p>
<pre>{{ . }}</pre>
<footer>
Generated at <code>{{ .Time }}</code>
</footer>
<script src="/dis/static/autolink.js"></script>

View file

@ -0,0 +1,20 @@
.header-link {
position: relative;
left: 0.5em;
opacity: 0;
font-size: 0.8em;
-webkit-transition: opacity 0.2s ease-in-out 0.1s;
-moz-transition: opacity 0.2s ease-in-out 0.1s;
-ms-transition: opacity 0.2s ease-in-out 0.1s;
text-decoration: none;
}
h2:hover .header-link,
h3:hover .header-link,
h4:hover .header-link,
h5:hover .header-link,
h6:hover .header-link {
opacity: 1;
}

View file

@ -0,0 +1,24 @@
/** adding links to each item, see http://blog.parkermoore.de/2014/08/01/header-anchor-links-in-vanilla-javascript-for-github-pages-and-jekyll/ */
var anchorForId = function (id) {
var anchor = document.createElement("a");
anchor.className = "header-link";
anchor.href = "#" + id;
anchor.innerHTML = "#";
return anchor;
};
var linkifyAnchors = function (level) {
var headers = document.getElementsByTagName("h" + level);
for (var h = 0; h < headers.length; h++) {
var header = headers[h];
if (typeof header.id !== "undefined" && header.id !== "") {
header.appendChild(anchorForId(header.id));
}
}
};
for (var level = 1; level <= 6; level++) {
linkifyAnchors(level);
}

View file

@ -0,0 +1,67 @@
html {
line-height: 1.5;
font-family: Roboto;
font-size: 20px;
color: #1a1a1a;
background-color: #fdfdfd;
}
body {
margin: 0 auto;
max-width: 36em;
padding-left: 50px;
padding-right: 50px;
padding-top: 50px;
padding-bottom: 50px;
hyphens: auto;
overflow-wrap: break-word;
text-rendering: optimizeLegibility;
font-kerning: normal;
}
@media (max-width: 600px) {
body {
font-size: 0.9em;
padding: 1em;
}
}
h1 {
margin-top: 1.4em;
}
h2,h3 {
margin-top: 1em;
}
code {
font-family: Roboto Mono;
color: blue;
}
p {
margin: 1em 0;
text-align: justify;
}
a, a:visited {
color: #1a1a1a;
}
footer {
border-top: 1px solid #1a1a1a;
font-size: small;
text-align: center;
}
.wisski {
padding-left: 5px;
}
.wisski.running {
background-color: green;
}
.wisski.stopped {
background-color: red;
}

View file

@ -1,70 +1,198 @@
package dis
import (
"encoding/json"
"embed"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
"github.com/FAU-CDI/wisski-distillery/internal/bookkeeping"
"github.com/FAU-CDI/wisski-distillery/internal/component/instances"
"github.com/FAU-CDI/wisski-distillery/internal/config"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/tkw1536/goprogram/stream"
"golang.org/x/sync/errgroup"
_ "embed"
)
func (dis *Dis) info(io stream.IOStream) (http.Handler, error) {
return http.HandlerFunc(dis.handleDis), nil
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)
})
// static stuff
static, err := dis.disStatic()
if err != nil {
return nil, err
}
mux.Handle("/dis/static/", static)
// render everything
mux.Handle("/dis/index", httpx.HTMLHandler[disIndex]{
Handler: dis.disIndex,
Template: indexTemplate,
})
mux.Handle("/dis/instance/", httpx.HTMLHandler[disInstance]{
Handler: dis.disInstance,
Template: instanceTemplate,
})
// api -- for future usage
mux.Handle("/dis/api/v1/instance/get/", httpx.JSON(dis.getinstance))
mux.Handle("/dis/api/v1/instance/all", httpx.JSON(dis.allinstances))
// ensure that everyone is logged in!
return httpx.BasicAuth(mux, "WissKI Distillery Admin", func(user, pass string) bool {
return user == dis.Config.DisAdminUser && pass == dis.Config.DisAdminPassword
}), nil
}
const disLimit = 2
// disIndex is the context of the "/dis/index" page
type disIndex struct {
Time time.Time
func (dis *Dis) handleDis(w http.ResponseWriter, r *http.Request) {
// make sure the user is authorized
if !dis.authDis(r) {
w.Header().Add("WWW-Authenticate", `Basic realm="WissKI Distillery Admin"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
Config *config.Config
Instances []instances.Info
TotalCount int
RunningCount int
StoppedCount int
}
func (dis *Dis) disIndex(r *http.Request) (idx disIndex, err error) {
// load instances
idx.Instances, err = dis.allinstances(r)
if err != nil {
return
}
// create a new error group
// 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)
// get the static properties
idx.Config = dis.Config
// current time
idx.Time = time.Now()
return
}
// disInstance is the context of the "/dis/instance/*" page
type disInstance struct {
Time time.Time
Instance bookkeeping.Instance
Info instances.Info
}
func (dis *Dis) 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 := dis.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()
return
}
//go:embed html/static
var htmlStaticFS embed.FS
func (*Dis) disStatic() (http.Handler, error) {
fs, err := fs.Sub(htmlStaticFS, "html/static")
if err != nil {
return nil, err
}
return http.StripPrefix("/dis/static/", http.FileServer(http.FS(fs))), nil
}
//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 (dis *Dis) getinstance(r *http.Request) (info instances.Info, 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 := dis.Instances.WissKI(strings.TrimSuffix(slug, "/"))
if err == instances.ErrWissKINotFound {
return info, httpx.ErrNotFound
}
if err != nil {
return info, err
}
// get info about it!
return wisski.Info(false)
}
func (dis *Dis) allinstances(*http.Request) (infos []instances.Info, err error) {
var errgroup errgroup.Group
errgroup.SetLimit(disLimit)
// list all the instances
all, err := dis.Instances.All()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
return nil, err
}
// get all of their info!
infos := make([]instances.Info, len(all))
infos = make([]instances.Info, len(all))
for i, instance := range all {
{
i := i
instance := instance
errgroup.Go(func() (err error) {
infos[i], err = instance.Info()
infos[i], err = instance.Info(true)
return err
})
}
}
// if some info call failed
if err := errgroup.Wait(); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
w.Write([]byte("\n"))
return
}
// and return the json
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(infos)
}
func (dis *Dis) authDis(r *http.Request) bool {
user, pass, ok := r.BasicAuth()
return ok && user == dis.Config.DisAdminUser && pass == dis.Config.DisAdminPassword
// wait for the results, and return
err = errgroup.Wait()
return
}

View file

@ -2,4 +2,4 @@ FROM docker.io/library/docker:20.10-cli
COPY wdcli /wdcli
EXPOSE 8888
CMD ["/wdcli","--internal-in-docker","--config","${CONFIG_PATH}","server","--bind","0.0.0.0:8888"]
CMD ["/wdcli","--internal-in-docker","--config", "${CONFIG_PATH}","server","--bind","0.0.0.0:8888"]

View file

@ -1,17 +1,33 @@
<?php
/**
* This script will list all the URIs that this system is aware of.
* This works by listing all the default graph uris of all the adapters.
*/
use Drupal\wisski_pathbuilder\Entity\WisskiPathEntity;
// load all the pathbuilders
$pbs = \Drupal::entityTypeManager()->getStorage('wisski_pathbuilder')->loadMultiple();
/** all_xml lists all pathbuilders, and returns the corresponding xml */
function all_xml(): object {
$all = \Drupal::entityTypeManager()->getStorage('wisski_pathbuilder')->loadMultiple();
return (object)array_map("entity_to_xml", $all);
}
// map over the pathbuilders
$xmls = array_map(function($pb) {
/** all_list lists the ids of all pathbuilders */
function all_list(): Array {
return array_keys(\Drupal::entityQuery('wisski_pathbuilder')->execute());
}
/** one_xml serializes a single pathbuilder as xml */
function one_xml(string $id): string {
$pb = \Drupal::entityTypeManager()->getStorage('wisski_pathbuilder')->load($id);
if ($pb === NULL) {
return "";
}
return entity_to_xml($pb);
}
// =================================================================================
// =================================================================================
function entity_to_xml($pb) {
$xml = new \SimpleXMLElement("<pathbuilderinterface></pathbuilderinterface>");
$paths = $pb->getAllPaths();
@ -58,6 +74,4 @@ $xmls = array_map(function($pb) {
$dom = dom_import_simplexml($xml)->ownerDocument;
$dom->formatOutput = TRUE;
return $dom->saveXML();
}, $pbs);
echo json_encode($xmls);
}

View file

@ -1,34 +1,45 @@
package instances
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
_ "embed"
"github.com/tkw1536/goprogram/stream"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
var errPathbuildersExecFailed = errors.New("ExportPathbuilders: Failed to call export_pathbuilder")
//go:embed php/export_pathbuilder.php
var exportPathbuilderPHP string
// Pathbuilders returns the ids of all pathbuilders in consistent order.
func (wisski *WissKI) Pathbuilders() (ids []string, err error) {
err = wisski.ExecPHPScript(stream.FromNil(), &ids, exportPathbuilderPHP, "all_list")
slices.Sort(ids)
return
}
// Pathbuilder returns a single pathbuilder as xml.
// If it does not exist, it returns the empty string and nil error.
func (wisski *WissKI) Pathbuilder(id string) (xml string, err error) {
err = wisski.ExecPHPScript(stream.FromNil(), &xml, exportPathbuilderPHP, "one_xml", id)
return
}
// AllPathbuilders returns all pathbuilders serialized as xml
func (wisski *WissKI) AllPathbuilders() (pathbuilders map[string]string, err error) {
err = wisski.ExecPHPScript(stream.FromNil(), &pathbuilders, exportPathbuilderPHP, "all_xml")
return
}
// ExportPathbuilders writes pathbuilders into the directory dest
func (wisski *WissKI) ExportPathbuilders(dest string) error {
// export all the pathbuilders into the buffer
var buffer bytes.Buffer
wu := stream.NewIOStream(&buffer, nil, nil, 0)
code, err := wisski.Barrel().Exec(wu, "barrel", "/bin/bash", "/user_shell.sh", "-c", "drush php:script /wisskiutils/export_pathbuilder.php")
if err != nil || code != 0 {
return errPathbuildersExecFailed
}
// decode them as a json array
var pathbuilders map[string]string
if err := json.NewDecoder(&buffer).Decode(&pathbuilders); err != nil {
pathbuilders, err := wisski.AllPathbuilders()
if err != nil {
return err
}

View file

@ -13,11 +13,12 @@ var ErrExecInvalidCode = errors.New("invalid code to execute")
var ErrExecNonZero = errors.New("script returned non-zero code")
// ExecPHPScript executes the PHP code as a script within the wisski instance.
// The script should define a function "main", and may define additional functions.
// The script should define a function called entrypoint, and may define additional functions.
//
// Code must start with "<?php" and may not contain a closing tag.
// Code is expected not to mess with PHPs output buffer.
// Code should not contain user input.
// Code breaking these conventions may or may not result in an error.
//
// It's arguments are encoded as json using [json.Marshal] and decoded within php.
//
@ -25,10 +26,10 @@ var ErrExecNonZero = errors.New("script returned non-zero code")
//
// Standard input and output streams should not be used.
// Standard error is redirected to io.
func (wisski *WissKI) ExecPHPScript(io stream.IOStream, code string, args ...any) (any, error) {
func (wisski *WissKI) ExecPHPScript(io stream.IOStream, result any, code string, entrypoint string, args ...any) error {
// make sure the beginning is right
if !strings.HasPrefix(code, "<?php") {
return nil, ErrExecInvalidCode
return ErrExecInvalidCode
}
// make sure that args is not nil, but an array of length 0!
@ -36,15 +37,20 @@ func (wisski *WissKI) ExecPHPScript(io stream.IOStream, code string, args ...any
args = []any{}
}
// encode code and args!
// encode code, args and entrypoint!
codeEscape, err := marshalPHP("?>" + code)
if err != nil {
return nil, err
return err
}
entrypointEscape, err := marshalPHP(entrypoint)
if err != nil {
return err
}
argsEscape, err := marshalPHP(args)
if err != nil {
return nil, err
return err
}
// assemble the script
@ -55,7 +61,7 @@ func (wisski *WissKI) ExecPHPScript(io stream.IOStream, code string, args ...any
call_user_func(function(){
ob_start(null, 0, PHP_OUTPUT_HANDLER_CLEANABLE);
$result = call_user_func_array("main", ` + argsEscape + `);
$result = call_user_func_array(` + entrypointEscape + `, ` + argsEscape + `);
ob_end_clean();
echo json_encode($result);
});
@ -65,22 +71,19 @@ func (wisski *WissKI) ExecPHPScript(io stream.IOStream, code string, args ...any
var output bytes.Buffer
res, err := wisski.Shell(io.Streams(&output, nil, strings.NewReader(script), 0), "-c", "drush php:script -")
if res != 0 {
return nil, ErrExecNonZero
return ErrExecNonZero
}
if err != nil {
return nil, err
return err
}
// did not request to receive a result
if result == nil {
return nil
}
// decode the output
var result any
err = json.NewDecoder(&output).Decode(&result)
return result, err
}
// EvalPHP is similar to ExecPHPScript, except that it evaluates a single line of php.
// A single parameter may be passed, which can be accessed using the name $arg inside the expression.
func (wisski *WissKI) EvalPHP(expr string, arg any) (any, error) {
return wisski.ExecPHPScript(stream.FromEnv(), "function main($arg){return "+expr+";}", arg)
return json.NewDecoder(&output).Decode(result)
}
const marshalRune = 'F' // press to pay respect

View file

@ -8,25 +8,36 @@ import (
// Info represents some info about this WissKI
type Info struct {
Slug string // The slug of the instance
URL string // The public URL of this instance
Running bool // is the instance running?
DrupalVersion interface{} // version of drupal being used
Running bool // is the instance running?
Pathbuilders []string // list of pathbuilders
}
// Info returns information about this WissKI instance.
func (wisski *WissKI) Info() (info Info, err error) {
func (wisski *WissKI) Info(quick bool) (info Info, err error) {
// static properties
info.Slug = wisski.Slug
info.URL = wisski.URL().String()
// dynamic properties, TODO: Add more properties here!
var group errgroup.Group
// quick check if this wisski is running
group.Go(func() (err error) {
info.Running, err = wisski.Alive()
return
})
// slower checks for extra properties.
// these execute php code
if !quick {
group.Go(func() (err error) {
info.Pathbuilders, err = wisski.Pathbuilders()
return
})
}
err = group.Wait()
return
}