Skip to content
Snippets Groups Projects
Commit 76d2f2fc authored by ale's avatar ale
Browse files

add a very simple status dashboard

parent 8f0de3a9
No related branches found
No related tags found
No related merge requests found
......@@ -4,8 +4,6 @@ import (
"flag"
"fmt"
"log"
"net/http"
"time"
"git.autistici.org/ale/radioai"
"git.autistici.org/ale/radioai/fe"
......@@ -17,6 +15,9 @@ var (
httpPort = flag.Int("http-port", 80, "HTTP port")
publicIp = flag.String("ip", "127.0.0.1", "Public IP for this machine")
staticDir = flag.String("static-dir", "./static", "Static content directory")
templateDir = flag.String("template-dir", "./templates", "HTML templates directory")
// Default DNS TTL (seconds).
dnsTtl = 5
)
......@@ -30,18 +31,10 @@ func main() {
client := radioai.NewEtcdClient()
api := radioai.NewRadioAPI(client)
red := fe.NewHttpRedirector(api)
dnsRed := fe.NewDnsRedirector(api, *domain, *publicIp, dnsTtl)
dnsRed.Run(fmt.Sprintf(":%d", *dnsPort))
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", *httpPort),
Handler: red,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Printf("starting HTTP server on %s/tcp", httpServer.Addr)
log.Fatal(httpServer.ListenAndServe())
red := fe.NewHttpRedirector(api, *domain)
red.Run(fmt.Sprintf(":%d", *httpPort), *staticDir, *templateDir)
}
package fe
import (
"compress/gzip"
"io"
"net/http"
"strings"
)
// Slightly modified by ale@incal.net, based on:
// https://github.com/PuerkitoBio/ghost
// Thanks to Andrew Gerrand for inspiration:
// https://groups.google.com/d/msg/golang-nuts/eVnTcMwNVjM/4vYU8id9Q2UJ
//
// Also, node's Connect library implementation of the compress middleware:
// https://github.com/senchalabs/connect/blob/master/lib/middleware/compress.js
//
// And StackOverflow's explanation of Vary: Accept-Encoding header:
// http://stackoverflow.com/questions/7848796/what-does-varyaccept-encoding-mean
// Internal gzipped writer that satisfies both the (body) writer in gzipped format,
// and maintains the rest of the ResponseWriter interface for header manipulation.
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
r *http.Request // Keep a hold of the Request, for the filter function
filtered bool // Has the request been run through the filter function?
dogzip bool // Should we do GZIP compression for this request?
filterFn func(http.ResponseWriter, *http.Request) bool
}
// Make sure the filter function is applied.
func (w *gzipResponseWriter) applyFilter() {
if !w.filtered {
if w.dogzip = w.filterFn(w, w.r); w.dogzip {
setGzipHeaders(w.Header())
}
w.filtered = true
}
}
// Unambiguous Write() implementation (otherwise both ResponseWriter and Writer
// want to claim this method).
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
w.applyFilter()
if w.dogzip {
// Write compressed
return w.Writer.Write(b)
}
// Write uncompressed
return w.ResponseWriter.Write(b)
}
// Intercept the WriteHeader call to correctly set the GZIP headers.
func (w *gzipResponseWriter) WriteHeader(code int) {
w.applyFilter()
w.ResponseWriter.WriteHeader(code)
}
// Implement WrapWriter interface
func (w *gzipResponseWriter) WrappedWriter() http.ResponseWriter {
return w.ResponseWriter
}
var (
defaultFilterTypes = [...]string{
"text/",
"javascript",
"json",
}
)
// Default filter to check if the response should be GZIPped.
// By default, all text (html, css, xml, ...), javascript and json
// content types are candidates for GZIP.
func defaultFilter(w http.ResponseWriter, r *http.Request) bool {
hdr := w.Header()
for _, tp := range defaultFilterTypes {
ok := headerMatch(hdr, "Content-Type", tp)
if ok {
return true
}
}
return false
}
// GZIPHandlerFunc is the same as GZIPHandler, it is just a convenience
// signature that accepts a func(http.ResponseWriter, *http.Request) instead of
// a http.Handler interface. It saves the boilerplate http.HandlerFunc() cast.
func GZIPHandlerFunc(h http.HandlerFunc, filterFn func(http.ResponseWriter, *http.Request) bool) http.HandlerFunc {
return GZIPHandler(h, filterFn)
}
// Gzip compression HTTP handler. If the client supports it, it compresses the response
// written by the wrapped handler. The filter function is called when the response is about
// to be written to determine if compression should be applied. If this argument is nil,
// the default filter will GZIP only content types containing /json|text|javascript/.
func GZIPHandler(h http.Handler, filterFn func(http.ResponseWriter, *http.Request) bool) http.HandlerFunc {
if filterFn == nil {
filterFn = defaultFilter
}
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := getGzipWriter(w); ok {
// Self-awareness, gzip handler is already set up
h.ServeHTTP(w, r)
return
}
hdr := w.Header()
setVaryHeader(hdr)
// Do nothing on a HEAD request
if r.Method == "HEAD" {
h.ServeHTTP(w, r)
return
}
if !acceptsGzip(r.Header) {
// No gzip support from the client, return uncompressed
h.ServeHTTP(w, r)
return
}
// Prepare a gzip response container
gz := gzip.NewWriter(w)
gzw := &gzipResponseWriter{
Writer: gz,
ResponseWriter: w,
r: r,
filterFn: filterFn,
}
h.ServeHTTP(gzw, r)
// Iff the handler completed successfully (no panic) and GZIP was indeed used, close the gzip writer,
// which seems to generate a Write to the underlying writer.
if gzw.dogzip {
gz.Close()
}
}
}
// Add the vary by "accept-encoding" header if it is not already set.
func setVaryHeader(hdr http.Header) {
if !headerMatch(hdr, "Vary", "accept-encoding") {
hdr.Add("Vary", "Accept-Encoding")
}
}
// Checks if the client accepts GZIP-encoded responses.
func acceptsGzip(hdr http.Header) bool {
ok := headerMatch(hdr, "Accept-Encoding", "gzip")
if !ok {
ok = headerEquals(hdr, "Accept-Encoding", "*")
}
return ok
}
func setGzipHeaders(hdr http.Header) {
// The content-type will be explicitly set somewhere down the path of handlers
hdr.Set("Content-Encoding", "gzip")
hdr.Del("Content-Length")
}
// Helper function to retrieve the gzip writer.
func getGzipWriter(w http.ResponseWriter) (*gzipResponseWriter, bool) {
gz, ok := GetResponseWriter(w, func(tst http.ResponseWriter) bool {
_, ok := tst.(*gzipResponseWriter)
return ok
})
if ok {
return gz.(*gzipResponseWriter), true
}
return nil, false
}
func headerMatch(hdr http.Header, name, s string) bool {
return strings.Contains(hdr.Get(name), s)
}
func headerEquals(hdr http.Header, name, s string) bool {
return hdr.Get(name) == s
}
// This interface can be implemented by an augmented ResponseWriter, so that
// it doesn't hide other augmented writers in the chain.
type WrapWriter interface {
http.ResponseWriter
WrappedWriter() http.ResponseWriter
}
// Helper function to retrieve a specific ResponseWriter.
func GetResponseWriter(w http.ResponseWriter,
predicate func(http.ResponseWriter) bool) (http.ResponseWriter, bool) {
for {
// Check if this writer is the one we're looking for
if w != nil && predicate(w) {
return w, true
}
// If it is a WrapWriter, move back the chain of wrapped writers
ww, ok := w.(WrapWriter)
if !ok {
return nil, false
}
w = ww.WrappedWriter()
}
}
package fe
import (
"bytes"
"fmt"
"html/template"
"io"
"log"
"math/rand"
"net/http"
"net/http/httputil"
"path/filepath"
"strconv"
"strings"
"time"
......@@ -23,12 +27,15 @@ import (
// a .m3u file directly pointing at the relays.
//
type HttpRedirector struct {
client *radioai.RadioAPI
domain string
client *radioai.RadioAPI
template *template.Template
}
func NewHttpRedirector(client *radioai.RadioAPI) *HttpRedirector {
func NewHttpRedirector(client *radioai.RadioAPI, domain string) *HttpRedirector {
return &HttpRedirector{
client: client,
domain: domain,
}
}
......@@ -97,10 +104,55 @@ func (h *HttpRedirector) serveSource(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}
func (h *HttpRedirector) serveStatusPage(w http.ResponseWriter, r *http.Request) {
nodes, _ := h.client.GetNodes()
mounts, _ := h.client.ListMounts()
ctx := struct {
Domain string
Nodes []string
Mounts []*radioai.Mount
}{h.domain, nodes, mounts}
var buf bytes.Buffer
if err := h.template.ExecuteTemplate(&buf, "index.html", ctx); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(buf.Bytes())
}
func (h *HttpRedirector) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "SOURCE" {
if r.URL.Path == "" || r.URL.Path == "/" {
h.serveStatusPage(w, r)
} else if r.Method == "SOURCE" {
h.serveSource(w, r)
} else {
h.serveRelay(w, r)
}
}
func (h *HttpRedirector) Run(addr, staticDir, templateDir string) {
h.template = template.Must(
template.ParseGlob(
filepath.Join(templateDir, "*.html")))
mux := http.NewServeMux()
mux.HandleFunc(
"/static/",
GZIPHandler(
http.StripPrefix(
"/static/",
http.FileServer(http.Dir(staticDir))),
nil))
mux.Handle("/", h)
httpServer := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Printf("starting HTTP server on %s/tcp", httpServer.Addr)
log.Fatal(httpServer.ListenAndServe())
}
/**
* jQuery.fullBg
* Version 1.0
* Copyright (c) 2010 c.bavota - http://bavotasan.com
* Dual licensed under MIT and GPL.
* Date: 02/23/2010
**/
(function($) {
$.fn.fullBg = function(){
var bgImg = $(this);
function resizeImg() {
var imgwidth = bgImg.width();
var imgheight = bgImg.height();
var winwidth = $(window).width();
var winheight = $(window).height();
var widthratio = winwidth / imgwidth;
var heightratio = winheight / imgheight;
var widthdiff = heightratio * imgwidth;
var heightdiff = widthratio * imgheight;
var newwidth, newheight;
if(heightdiff>winheight) {
newwidth = winwidth;
newheight = heightdiff;
} else {
newwidth = widthdiff;
newheight = winheight;
}
var xoffset = (newwidth - winwidth) / 2,
yoffset = (newheight - winheight) / 2;
bgImg.css({
width: newwidth+'px',
height: newheight+'px',
top: '-'+yoffset+'px',
left: '-'+xoffset+'px'
});
}
resizeImg();
$(window).resize(function() {
resizeImg();
});
return this;
};
})(jQuery)
fe/static/radiomast_bw.jpg

116 KiB

/* Space out content a bit */
body {
padding-top: 80px;
padding-bottom: 20px;
}
/* Everything gets side spacing for mobile first views */
.header,
.mainitems,
.footer {
padding-left: 15px;
padding-right: 15px;
}
/* Custom page header */
.header {
border-bottom: 1px solid #e5e5e5;
}
/* Make the masthead heading the same height as the navigation */
.header h3 {
margin-top: 0;
margin-bottom: 0;
line-height: 40px;
padding-bottom: 19px;
}
/* Custom page footer */
.footer {
padding-top: 19px;
color: #777;
border-top: 1px solid #e5e5e5;
}
/* Customize container */
@media (min-width: 768px) {
.container {
max-width: 730px;
}
}
.container-narrow > hr {
margin: 30px 0;
}
.mainitems {
margin: 40px 0;
}
.mainitems p + h4 {
margin-top: 28px;
}
/* Responsive: Portrait tablets and up */
@media screen and (min-width: 768px) {
/* Remove the padding we set earlier */
.header,
.mainitems,
.footer {
padding-left: 0;
padding-right: 0;
}
/* Space out the masthead */
.header {
margin-bottom: 30px;
}
}
#bgImg {
display: none;
position: fixed;
top: 0;
left: 0;
overflow: hidden;
z-index: -100;
opacity: 0.3;
}
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
<title>stream.{{.Domain}}</title>
<link rel="stylesheet"
href="//netdna.bootstrapcdn.com/bootstrap/3.0.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/style.css">
<link rel="shortcut icon" href="/static/favicon.png">
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>
<script src="/static/fullbg.js"></script>
</head>
<body>
<div class="container">
<div class="page-header">
<h1>{{.Domain}}
<small>
streaming network
</small>
</h1>
</div>
<div class="row mainitems">
<div class="col-lg-6">
<h4>Streams</h4>
{{$domain := .Domain}}
{{range .Mounts}}
<p>
<a href="http://stream.{{$domain}}{{.Name}}.m3u">{{.Name}}</a>
</p>
{{end}}
</div>
<div class="col-lg-6">
<h4>Nodes</h4>
{{range .Nodes}}
<p>{{.}}</p>
{{end}}
</div>
</div>
<div class="footer">
powered by
<a href="https://git.autistici.org/public/ale/radioai">
radioai 0.1
</a>
</div>
</div>
<img src="/static/radiomast_bw.jpg" id="bgImg">
<script type="text/javascript">
$(function() {
$('#bgImg').fullBg().show();
});
</script>
</body>
</html>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment