feat: initial commit

This commit is contained in:
2023-12-13 20:07:22 +01:00
commit 5d0311b731
79 changed files with 3143 additions and 0 deletions

35
pkg/browser/browser.go Normal file
View File

@ -0,0 +1,35 @@
package browser
import "fmt"
type Status int
const (
StatusUnknown Status = iota
StatusIdle
StatusCasting
)
func (s Status) String() string {
switch s {
case StatusIdle:
return "idle"
case StatusCasting:
return "casting"
default:
return fmt.Sprintf("unknown (%d)", s)
}
}
type Browser interface {
// Cast loads an URL
Load(url string) error
// Reset resets the browser to the given idle URL
Reset(url string) error
// Status returns the browser's current status
Status() (Status, error)
// Title returns the browser's currently loaded page title
Title() (string, error)
// URL returns the browser's currently loaded page URL
URL() (string, error)
}

View File

@ -0,0 +1,52 @@
package dummy
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"gitlab.com/wpetit/goweb/logger"
)
type Browser struct {
status browser.Status
url string
}
// Load implements browser.Browser.
func (b *Browser) Load(url string) error {
logger.Debug(context.Background(), "loading url", logger.F("url", url))
b.status = browser.StatusCasting
b.url = url
return nil
}
// Status implements browser.Browser.
func (b *Browser) Status() (browser.Status, error) {
return b.status, nil
}
// Title implements browser.Browser.
func (b *Browser) Title() (string, error) {
return "", nil
}
// URL implements browser.Browser.
func (b *Browser) URL() (string, error) {
return b.url, nil
}
// Reset implements browser.Browser.
func (b *Browser) Reset(url string) error {
b.status = browser.StatusIdle
b.url = url
return nil
}
func NewBrowser() *Browser {
return &Browser{
status: browser.StatusIdle,
url: "",
}
}
var _ browser.Browser = &Browser{}

View File

@ -0,0 +1,118 @@
package gioui
import (
"context"
"sync"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"gioui.org/app"
"gioui.org/f32"
"gioui.org/layout"
"github.com/gioui-plugins/gio-plugins/webviewer"
"gitlab.com/wpetit/goweb/logger"
)
type Browser struct {
window *app.Window
tag int
url string
changed bool
status browser.Status
title string
mutex sync.Mutex
}
func (b *Browser) Layout(gtx layout.Context) {
b.mutex.Lock()
defer b.mutex.Unlock()
events := gtx.Events(&b.tag)
for _, evt := range events {
switch ev := evt.(type) {
case webviewer.TitleEvent:
b.title = ev.Title
case webviewer.NavigationEvent:
b.url = ev.URL
}
}
ctx := context.Background()
logger.Debug(ctx, "drawing")
webviewer.WebViewOp{Tag: &b.tag}.Push(gtx.Ops)
webviewer.OffsetOp{Point: f32.Point{Y: float32(gtx.Constraints.Max.Y - gtx.Constraints.Max.Y)}}.Add(gtx.Ops)
webviewer.RectOp{Size: f32.Point{X: float32(gtx.Constraints.Max.X), Y: float32(gtx.Constraints.Max.Y)}}.Add(gtx.Ops)
if b.changed {
logger.Debug(ctx, "url changed", logger.F("url", b.url))
webviewer.NavigateOp{URL: b.url}.Add(gtx.Ops)
b.changed = false
}
}
// Load implements browser.Browser.
func (b *Browser) Load(url string) error {
b.mutex.Lock()
defer b.mutex.Unlock()
b.url = url
b.changed = true
b.status = browser.StatusCasting
b.window.Invalidate()
return nil
}
// Status implements browser.Browser.
func (b *Browser) Status() (browser.Status, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.status, nil
}
// Title implements browser.Browser.
func (b *Browser) Title() (string, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.title, nil
}
// URL implements browser.Browser.
func (b *Browser) URL() (string, error) {
b.mutex.Lock()
defer b.mutex.Unlock()
return b.url, nil
}
// Reset implements browser.Browser.
func (b *Browser) Reset(url string) error {
b.mutex.Lock()
defer b.mutex.Unlock()
b.url = url
b.changed = true
b.status = browser.StatusIdle
b.window.Invalidate()
return nil
}
func NewBrowser(window *app.Window) *Browser {
return &Browser{
window: window,
url: "",
changed: true,
status: browser.StatusIdle,
}
}
var _ browser.Browser = &Browser{}

View File

@ -0,0 +1,100 @@
package lorca
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/pkg/errors"
"github.com/zserge/lorca"
"gitlab.com/wpetit/goweb/logger"
)
type Browser struct {
ui lorca.UI
status browser.Status
opts *Options
}
func (b *Browser) Start() error {
logger.Debug(context.Background(), "starting browser", logger.F("opts", b.opts))
ui, err := lorca.New("", "", b.opts.Width, b.opts.Height, b.opts.ChromeArgs...)
if err != nil {
return errors.WithStack(err)
}
b.ui = ui
return nil
}
func (b *Browser) Stop() error {
if err := b.ui.Close(); err != nil {
return errors.WithStack(err)
}
b.ui = nil
return nil
}
func (b *Browser) Wait() {
<-b.ui.Done()
}
// Load implements browser.Browser.
func (b *Browser) Load(url string) error {
if err := b.ui.Load(url); err != nil {
return errors.WithStack(err)
}
b.status = browser.StatusCasting
return nil
}
// Unload implements browser.Browser.
func (b *Browser) Reset(url string) error {
if err := b.ui.Load(url); err != nil {
return errors.WithStack(err)
}
b.status = browser.StatusIdle
return nil
}
// Status implements browser.Browser.
func (b *Browser) Status() (browser.Status, error) {
return b.status, nil
}
// Title implements browser.Browser.
func (b *Browser) Title() (string, error) {
result := b.ui.Eval("document.title.toString()")
if err := result.Err(); err != nil {
return "", errors.WithStack(err)
}
return result.String(), nil
}
// URL implements browser.Browser.
func (b *Browser) URL() (string, error) {
result := b.ui.Eval("window.location.toString()")
if err := result.Err(); err != nil {
return "", errors.WithStack(err)
}
return result.String(), nil
}
func NewBrowser(funcs ...OptionsFunc) *Browser {
opts := NewOptions(funcs...)
return &Browser{
status: browser.StatusIdle,
opts: opts,
}
}
var _ browser.Browser = &Browser{}

View File

@ -0,0 +1,40 @@
package lorca
type Options struct {
Width int
Height int
ChromeArgs []string
}
type OptionsFunc func(opts *Options)
var DefaultChromeArgs = []string{
"--remote-allow-origins=*",
}
func NewOptions(funcs ...OptionsFunc) *Options {
opts := &Options{
Width: 800,
Height: 600,
ChromeArgs: DefaultChromeArgs,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithWindowSize(width, height int) OptionsFunc {
return func(opts *Options) {
opts.Width = width
opts.Height = height
}
}
func WithAdditionalChromeArgs(args ...string) OptionsFunc {
return func(opts *Options) {
opts.ChromeArgs = append(args, DefaultChromeArgs...)
}
}

81
pkg/client/api.go Normal file
View File

@ -0,0 +1,81 @@
package client
import (
"bytes"
"context"
"encoding/json"
"net/http"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (c *Client) apiGet(ctx context.Context, url string, result any, funcs ...HTTPOptionFunc) error {
if err := c.apiDo(ctx, http.MethodGet, url, nil, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiPost(ctx context.Context, url string, payload any, result any, funcs ...HTTPOptionFunc) error {
if err := c.apiDo(ctx, http.MethodPost, url, payload, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDelete(ctx context.Context, url string, payload any, result any, funcs ...HTTPOptionFunc) error {
if err := c.apiDo(ctx, http.MethodDelete, url, payload, result, funcs...); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Client) apiDo(ctx context.Context, method string, url string, payload any, response any, funcs ...HTTPOptionFunc) error {
opts := NewHTTPOptions(funcs...)
logger.Debug(
ctx, "new http request",
logger.F("method", method),
logger.F("url", url),
logger.F("payload", payload),
)
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
if err := encoder.Encode(payload); err != nil {
return errors.WithStack(err)
}
req, err := http.NewRequest(method, url, &buf)
if err != nil {
return errors.WithStack(err)
}
for key, values := range opts.Headers {
for _, v := range values {
req.Header.Add(key, v)
}
}
res, err := c.http.Do(req)
if err != nil {
return errors.WithStack(err)
}
defer res.Body.Close()
decoder := json.NewDecoder(res.Body)
if err := decoder.Decode(&api.Response{Data: &response}); err != nil {
return errors.WithStack(err)
}
return nil
}

27
pkg/client/cast.go Normal file
View File

@ -0,0 +1,27 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
)
type Status = server.StatusResponse
func (c *Client) Cast(ctx context.Context, addr string, url string) (*Status, error) {
endpoint := fmt.Sprintf("http://%s/api/v1/cast", addr)
req := &server.CastRequest{
URL: url,
}
res := &server.StatusResponse{}
if err := c.apiPost(ctx, endpoint, &req, &res); err != nil {
return nil, errors.WithStack(err)
}
return res, nil
}

15
pkg/client/client.go Normal file
View File

@ -0,0 +1,15 @@
package client
import (
"net/http"
)
type Client struct {
http *http.Client
}
func New() *Client {
return &Client{
http: &http.Client{},
}
}

43
pkg/client/options.go Normal file
View File

@ -0,0 +1,43 @@
package client
import "net/http"
type HTTPOptions struct {
Headers http.Header
}
type HTTPOptionFunc func(opts *HTTPOptions)
func NewHTTPOptions(funcs ...HTTPOptionFunc) *HTTPOptions {
opts := &HTTPOptions{}
for _, fn := range funcs {
fn(opts)
}
return opts
}
type ScanOptions struct {
PlayerIDs []string
}
func NewScanOptions(funcs ...ScanOptionFunc) *ScanOptions {
opts := &ScanOptions{
PlayerIDs: make([]string, 0),
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
type ScanOptionFunc func(opts *ScanOptions)
func WithPlayerIDs(ids ...string) ScanOptionFunc {
return func(opts *ScanOptions) {
opts.PlayerIDs = ids
}
}

11
pkg/client/player.go Normal file
View File

@ -0,0 +1,11 @@
package client
import (
"net"
)
type Player struct {
ID string
IPs []net.IP
Port int
}

21
pkg/client/reset.go Normal file
View File

@ -0,0 +1,21 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
)
func (c *Client) Reset(ctx context.Context, addr string) (*Status, error) {
endpoint := fmt.Sprintf("http://%s/api/v1/cast", addr)
res := &server.StatusResponse{}
if err := c.apiDelete(ctx, endpoint, nil, &res); err != nil {
return nil, errors.WithStack(err)
}
return res, nil
}

69
pkg/client/scan.go Normal file
View File

@ -0,0 +1,69 @@
package client
import (
"context"
"slices"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/grandcat/zeroconf"
"github.com/pkg/errors"
)
func (c *Client) Scan(ctx context.Context, funcs ...ScanOptionFunc) ([]*Player, error) {
opts := NewScanOptions(funcs...)
resolver, err := zeroconf.NewResolver(nil)
if err != nil {
return nil, errors.WithStack(err)
}
done := make(chan struct{})
players := make([]*Player, 0)
entries := make(chan *zeroconf.ServiceEntry)
go func(results <-chan *zeroconf.ServiceEntry) {
defer close(done)
for entry := range results {
addPlayer := func() {
players = append(players, &Player{
ID: entry.Instance,
IPs: entry.AddrIPv4,
Port: entry.Port,
})
}
searchedLen := len(opts.PlayerIDs)
if searchedLen == 0 {
addPlayer()
continue
}
if slices.Contains(opts.PlayerIDs, entry.Instance) {
addPlayer()
}
if searchedLen == len(players) {
break
}
}
done <- struct{}{}
}(entries)
err = resolver.Browse(ctx, server.MDNSService, server.MDNSDomain, entries)
if err != nil {
return nil, errors.WithStack(err)
}
select {
case <-done:
return players, nil
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return players, errors.WithStack(err)
}
return players, nil
}
}

21
pkg/client/status.go Normal file
View File

@ -0,0 +1,21 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/arcad/arcast/pkg/server"
"github.com/pkg/errors"
)
func (c *Client) Status(ctx context.Context, addr string) (*Status, error) {
endpoint := fmt.Sprintf("http://%s/api/v1/status", addr)
res := &server.StatusResponse{}
if err := c.apiGet(ctx, endpoint, res); err != nil {
return nil, errors.WithStack(err)
}
return res, nil
}

191
pkg/server/http.go Normal file
View File

@ -0,0 +1,191 @@
package server
import (
"context"
"fmt"
"html/template"
"net"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
)
var (
//go:embed templates/idle.html.gotmpl
rawIdleTemplate []byte
idleTemplate *template.Template
)
func init() {
tmpl, err := template.New("").Parse(string(rawIdleTemplate))
if err != nil {
panic(errors.Wrap(err, "could not parse idle template"))
}
idleTemplate = tmpl
}
func (s *Server) startHTTPServer(ctx context.Context) error {
router := chi.NewRouter()
router.Get("/", s.handleHome)
router.Post("/api/v1/cast", s.handleCast)
router.Delete("/api/v1/cast", s.handleReset)
router.Get("/api/v1/status", s.handleStatus)
server := http.Server{
Addr: s.address,
Handler: router,
}
listener, err := net.Listen("tcp", s.address)
if err != nil {
return errors.WithStack(err)
}
host, rawPort, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
return errors.WithStack(err)
}
port, err := strconv.ParseInt(rawPort, 10, 32)
if err != nil {
return errors.Wrapf(err, "could not parse listening port '%v'", rawPort)
}
logger.Debug(ctx, "listening for tcp connections", logger.F("port", port), logger.F("host", host))
s.port = int(port)
go func() {
logger.Debug(ctx, "starting http server")
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error(ctx, "could not listen", logger.CapturedE(errors.WithStack(err)))
}
}()
go func() {
<-ctx.Done()
logger.Debug(ctx, "closing http server")
if err := server.Close(); err != nil {
logger.Error(ctx, "could not close http server", logger.CapturedE(errors.WithStack(err)))
}
}()
if err := s.resetBrowser(); err != nil {
return errors.WithStack(err)
}
return nil
}
type CastRequest struct {
URL string `json:"url" validate:"required"`
}
func (s *Server) handleCast(w http.ResponseWriter, r *http.Request) {
req := &CastRequest{}
if ok := api.Bind(w, r, req); !ok {
return
}
if err := s.browser.Load(req.URL); err != nil {
logger.Error(r.Context(), "could not load url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if err := s.resetBrowser(); err != nil {
logger.Error(r.Context(), "could not unload url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
http.Redirect(w, r, "/api/v1/status", http.StatusSeeOther)
}
func (s *Server) resetBrowser() error {
idleURL := fmt.Sprintf("http://localhost:%d", s.port)
if err := s.browser.Reset(idleURL); err != nil {
return errors.WithStack(err)
}
return nil
}
type StatusResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Status string `json:"status"`
Title string `json:"title"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
url, err := s.browser.URL()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser url", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
status, err := s.browser.Status()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser status", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
title, err := s.browser.Title()
if err != nil {
logger.Error(r.Context(), "could not retrieve browser page title", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, &StatusResponse{
ID: s.instanceID,
URL: url,
Status: status.String(),
Title: title,
})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
type templateData struct {
IPs []string
Port int
ID string
}
ips, err := getLANIPv4Addrs()
if err != nil {
logger.Error(r.Context(), "could not retrieve lan ip addresses", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
d := templateData{
ID: s.instanceID,
IPs: ips,
Port: s.port,
}
if err := idleTemplate.Execute(w, d); err != nil {
logger.Error(r.Context(), "could not render idle page", logger.CapturedE(errors.WithStack(err)))
}
}

43
pkg/server/mdns.go Normal file
View File

@ -0,0 +1,43 @@
package server
import (
"context"
"github.com/grandcat/zeroconf"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
"gitlab.com/wpetit/goweb/logger"
)
const (
MDNSService = "_arcast._http._tcp"
MDNSDomain = "local."
)
func (s *Server) startMDNServer(ctx context.Context) error {
logger.Debug(ctx, "starting mdns server")
ifaces, err := anet.Interfaces()
if err != nil {
return errors.WithStack(err)
}
ips, err := getLANIPv4Addrs()
if err != nil {
return errors.WithStack(err)
}
logger.Debug(ctx, "advertising ips", logger.F("ips", ips))
server, err := zeroconf.RegisterProxy(s.instanceID, MDNSService, MDNSDomain, s.port, s.instanceID, ips, []string{}, ifaces)
if err != nil {
return errors.WithStack(err)
}
go func() {
<-ctx.Done()
logger.Debug(ctx, "closing mdns server")
server.Shutdown()
}()
return nil
}

60
pkg/server/network.go Normal file
View File

@ -0,0 +1,60 @@
package server
import (
"net"
"github.com/pkg/errors"
"github.com/wlynxg/anet"
)
var (
_, lanA, _ = net.ParseCIDR("10.0.0.0/8")
_, lanB, _ = net.ParseCIDR("172.16.0.0/12")
_, lanC, _ = net.ParseCIDR("192.168.0.0/16")
)
func getLANIPv4Addrs() ([]string, error) {
ips := make([]string, 0)
addrs, err := anet.InterfaceAddrs()
if err != nil {
return nil, errors.WithStack(err)
}
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
ipv4 := ipnet.IP.To4()
if ipv4 == nil {
continue
}
isLAN := lanA.Contains(ipv4) || lanB.Contains(ipv4) || lanC.Contains(ipv4)
if !isLAN {
continue
}
ips = append(ips, ipv4.String())
}
}
return ips, nil
}
func FindPreferredLocalAddress(ips ...net.IP) (string, error) {
localAddrs, err := anet.InterfaceAddrs()
if err != nil {
return "", errors.WithStack(err)
}
for _, addr := range localAddrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
for _, ip := range ips {
if ipnet.Contains(ip) {
return ip.String(), nil
}
}
}
}
return ips[0].String(), nil
}

61
pkg/server/options.go Normal file
View File

@ -0,0 +1,61 @@
package server
import (
"github.com/jaevor/go-nanoid"
"github.com/pkg/errors"
)
var newRandomInstanceID func() string
func init() {
generator, err := nanoid.Standard(21)
if err != nil {
panic(errors.Wrap(err, "could not generate random instance id"))
}
newRandomInstanceID = generator
}
type Options struct {
InstanceID string
Address string
DisableServiceDiscovery bool
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
InstanceID: NewRandomInstanceID(),
Address: ":",
DisableServiceDiscovery: false,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithAddress(addr string) OptionFunc {
return func(opts *Options) {
opts.Address = addr
}
}
func WithInstanceID(id string) OptionFunc {
return func(opts *Options) {
opts.InstanceID = id
}
}
func WithServiceDiscoveryDisabled(disabled bool) OptionFunc {
return func(opts *Options) {
opts.DisableServiceDiscovery = disabled
}
}
func NewRandomInstanceID() string {
return newRandomInstanceID()
}

76
pkg/server/server.go Normal file
View File

@ -0,0 +1,76 @@
package server
import (
"context"
"forge.cadoles.com/arcad/arcast/pkg/browser"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Server struct {
browser browser.Browser
instanceID string
address string
port int
disableServiceDiscovery bool
ctx context.Context
cancel context.CancelFunc
}
func (s *Server) Start() error {
serverCtx, cancelServer := context.WithCancel(context.Background())
s.cancel = cancelServer
s.ctx = serverCtx
httpServerCtx, cancelHTTPServer := context.WithCancel(serverCtx)
if err := s.startHTTPServer(httpServerCtx); err != nil {
cancelHTTPServer()
return errors.WithStack(err)
}
if !s.disableServiceDiscovery {
mdnsServerCtx, cancelMDNSServer := context.WithCancel(serverCtx)
if err := s.startMDNServer(mdnsServerCtx); err != nil {
cancelHTTPServer()
cancelMDNSServer()
return errors.WithStack(err)
}
} else {
logger.Info(serverCtx, "service discovery disabled")
}
return nil
}
func (s *Server) Stop() error {
if s.cancel != nil {
s.cancel()
}
return nil
}
func (s *Server) Wait() error {
<-s.ctx.Done()
if err := s.ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
func New(browser browser.Browser, funcs ...OptionFunc) *Server {
opts := NewOptions(funcs...)
return &Server{
browser: browser,
instanceID: opts.InstanceID,
address: opts.Address,
disableServiceDiscovery: opts.DisableServiceDiscovery,
}
}

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Idle</title>
<style>
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
width: 100%;
height: 100%;
background-color: #e1e1e1;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
ol,
ul {
list-style: none;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.panel {
display: block;
background-color: #fff;
border-radius: 5px;
padding: 10px 20px;
box-shadow: 2px 2px #3333331d;
}
.panel p, .panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<h1>Arcast</h1>
<p>Instance ID:</p>
<p class="text-centered text-small">
<code>{{ .ID }}</code>
</p>
<p>Addresses:</p>
<ul class="text-italic text-small">
{{ $port := .Port }}
{{range .IPs}}
<li><code>{{ . }}:{{ $port }}</code></li>
{{end}}
</ul>
</div>
</div>
</body>
</html>