Crontab installation support
This commit is contained in:
parent
e1fa2f35f2
commit
12ae1616ec
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1 +1,5 @@
|
|||
bin/
|
||||
dist/
|
||||
sonar.pi/
|
||||
media/
|
||||
crontab.in/
|
||||
|
|
20
Makefile
20
Makefile
|
@ -18,6 +18,26 @@ build:
|
|||
gofmt -w .
|
||||
go build $(LDFLAGS) -i -o bin/sonar cmd/sonar/main.go
|
||||
|
||||
# `make dist` makes a distribution bundle.
|
||||
.PHONY: dist
|
||||
dist:
|
||||
rm -rf dist
|
||||
mkdir dist
|
||||
cp -r crontab.in www dist/
|
||||
go build $(LDFLAGS) -i -o dist/sonar cmd/sonar/main.go
|
||||
cd dist
|
||||
tar -czvf sonar-$(VERSION).tar.gz *
|
||||
mv *.tar.gz ../
|
||||
|
||||
# `make pi` makes a distribution for the Raspberry Pi.
|
||||
.PHONY: pi
|
||||
pi:
|
||||
rm -rf sonar.pi
|
||||
mkdir sonar.pi
|
||||
cp -r crontab.in www sonar.pi/
|
||||
GOOS=linux GOARCH=arm go build $(LDFLAGS) -i -o sonar.pi/sonar cmd/sonar/main.go
|
||||
zip -r sonar-pi.zip sonar.pi
|
||||
|
||||
# `make run` to run it in debug mode.
|
||||
.PHONY: run
|
||||
run:
|
||||
|
|
142
README.md
142
README.md
|
@ -1,3 +1,143 @@
|
|||
# sonar
|
||||
|
||||
Raspberry Pi alarm clock server.
|
||||
Sonar is an alarm clock server designed to run on a Raspberry Pi, but it could
|
||||
just as well work anywhere.
|
||||
|
||||
For the alarm clock it plays a list of media files from the filesystem. By
|
||||
default it will use the `mplayer` command.
|
||||
|
||||
# Usage
|
||||
|
||||
```
|
||||
./sonar [-listen 127.0.0.1:8000]
|
||||
```
|
||||
|
||||
Sonar has no authentication system. It listens on localhost by default and you
|
||||
should put a proxy like nginx in front with HTTP Basic Auth or whatever.
|
||||
|
||||
It will create its config on first startup.
|
||||
|
||||
# Features
|
||||
|
||||
It listens on an HTTP service and shows a GUI on the homepage where you can
|
||||
toggle the volume settings, start/stop the alarm clock playlist, and see/change
|
||||
the scheduled alarm times.
|
||||
|
||||
The clock is controlled over simple RESTful API. You just post to these
|
||||
endpoints:
|
||||
|
||||
* `/volume/higher`: increase volume by 5% (default)
|
||||
* `/volume/lower`: lower volume by 5%
|
||||
* `/volume/mute`: toggle mute status
|
||||
* `/playlist/start`: start the playlist (doesn't stop automatically!)
|
||||
* `/playlist/stop`: stop the playlist
|
||||
|
||||
Example to start the playlist via `curl`:
|
||||
|
||||
```bash
|
||||
$ curl -X POST http://localhost:8000/playlist/start
|
||||
```
|
||||
|
||||
# Makefile
|
||||
|
||||
* `make setup` to fetch dependencies.
|
||||
* `make build` to build the binary to `bin/`
|
||||
* `make dist` to build a distribution for your current setup
|
||||
* `make run` to run it in debug mode
|
||||
* `make watch` to run it in debug mode, auto-reloading (sometimes flaky control over mplayer tho!)
|
||||
* `make pi` to build a zipped distribution for Raspberry Pi.
|
||||
See [Cross Compile for Raspberry Pi](#cross-compile-for-raspberry-pi)
|
||||
|
||||
# Crontab
|
||||
|
||||
The schedule system installs into the user's local crontab. The cron entries
|
||||
just post back to the API service, like:
|
||||
|
||||
```cron
|
||||
30 5 * * * curl -X POST http://127.0.0.1:8000/playlist/start
|
||||
30 6 * * * curl -X POST http://127.0.0.1:8000/playlist/stop
|
||||
```
|
||||
|
||||
The stop command is installed one hour after the start.
|
||||
|
||||
The user's local crontab is **overwritten** by the one Sonar installs. To keep
|
||||
custom crontab entries, place them into the `crontab.in/` directory.
|
||||
|
||||
All custom user crontabs are concatenated together ahead of Sonar's cron entries.
|
||||
The `000-header.cron` is the standard Debian cron header and tends to be installed
|
||||
on top.
|
||||
|
||||
# Configuration
|
||||
|
||||
The config file will be in your system's native location, which is
|
||||
`~/.config/sonar.json` on Linux environments.
|
||||
|
||||
After running the app once, it will save its default configuration to disk.
|
||||
The defaults are fine for PulseAudio setups but you may want to revise it to
|
||||
be sure.
|
||||
|
||||
A default config file looks like this, annotated:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"cookieName": "session", // name of HTTP session cookie
|
||||
"mediaPath": "./media", // path of media files (like .mp3) to shuffle and play
|
||||
"mediaCommand": [
|
||||
// The command to actually play the media. Use %s where the filename goes.
|
||||
"mplayer",
|
||||
"%s"
|
||||
],
|
||||
"volUpCommand": [
|
||||
"pactl",
|
||||
"set-sink-volume",
|
||||
"0", // Sink number, from `pactl list-sinks`
|
||||
"+5%" // 5% step
|
||||
],
|
||||
"volDnCommand": [
|
||||
"pactl",
|
||||
"set-sink-volume",
|
||||
"0", // Sink number
|
||||
"-5%"
|
||||
],
|
||||
"volMuteCommand": [
|
||||
"pactl",
|
||||
"set-sink-mute",
|
||||
"0",
|
||||
"toggle"
|
||||
],
|
||||
"volStatusCommand": [
|
||||
// How to get the volume status. The command
|
||||
// should output just a value like: 56%
|
||||
"bash",
|
||||
"-c",
|
||||
"pacmd dump-volumes | grep \"Sink 0\" | egrep -o '([0-9]+)%' | head -1"
|
||||
],
|
||||
// scheduled alarm time by default
|
||||
"hour": 6,
|
||||
"minute": 30,
|
||||
"days": [
|
||||
"1", "2", "3", "4", "5"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cross Compile for Raspberry Pi
|
||||
|
||||
Use the `make pi` command to build a distribution for Raspberry Pi.
|
||||
|
||||
If you get permission errors when trying to download the standard library for
|
||||
ARM64, make and chown the folders as a workaround:
|
||||
|
||||
```bash
|
||||
sudo mkdir /usr/lib/golang/pkg/linux_arm
|
||||
sudo chown kirsle:kirsle /usr/lib/golang/pkg/linux_arm
|
||||
make pi
|
||||
```
|
||||
|
||||
It outputs a `sonar-pi.zip` that you can scp over and run.
|
||||
|
||||
# License
|
||||
|
||||
Noah Petherbridge © 2018
|
||||
|
||||
GPLv2.
|
||||
|
|
|
@ -2,25 +2,38 @@ package main
|
|||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/sonar"
|
||||
"github.com/kirsle/golog"
|
||||
)
|
||||
|
||||
// Build hash.
|
||||
var Build = "N/A"
|
||||
|
||||
var debug bool
|
||||
var listen string
|
||||
var version bool
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
flag.StringVar(&listen, "listen", "127.0.0.1:8000", "Interface to listen on, default localhost only")
|
||||
flag.BoolVar(&debug, "debug", false, "Debug level logging")
|
||||
flag.BoolVar(&version, "version", false, "Version number")
|
||||
flag.BoolVar(&version, "v", false, "Version number (alias)")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if version {
|
||||
fmt.Printf("sonar v%s build %s\n", sonar.Version, Build)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if debug {
|
||||
log := golog.GetLogger("sonar")
|
||||
log.Config.Level = golog.DebugLevel
|
||||
|
|
11
config.go
11
config.go
|
@ -23,6 +23,11 @@ type Config struct {
|
|||
VolumeDownCommand []string `json:"volDnCommand"`
|
||||
VolumeMuteCommand []string `json:"volMuteCommand"`
|
||||
VolumeStatusCommand []string `json:"volStatusCommand"`
|
||||
|
||||
// Scheduled cron tab.
|
||||
Hour int `json:"hour"`
|
||||
Minute int `json:"minute"`
|
||||
Days []string `json:"days"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default config.
|
||||
|
@ -32,6 +37,11 @@ func DefaultConfig() *Config {
|
|||
MediaPath: "./media",
|
||||
MediaCommand: []string{"mplayer", "%s"},
|
||||
|
||||
// Default schedule.
|
||||
Hour: 6,
|
||||
Minute: 30,
|
||||
Days: []string{"1", "2", "3", "4", "5"}, // M-F
|
||||
|
||||
// PulseAudio pactl commands.
|
||||
// pactl set-sink-volume <SINK> -<INT>%
|
||||
// Find the sink number from: pactl list
|
||||
|
@ -65,6 +75,7 @@ func LoadConfig() *Config {
|
|||
}
|
||||
defer fh.Close()
|
||||
|
||||
log.Info("Reading config from %s", filepath)
|
||||
var c *Config
|
||||
decoder := json.NewDecoder(fh)
|
||||
decoder.Decode(&c)
|
||||
|
|
78
cron.go
Normal file
78
cron.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
package sonar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetSchedule installs the crontab.
|
||||
func (s *Sonar) SetSchedule(tod time.Time, days []string) error {
|
||||
// Assemble our new crontab entries.
|
||||
entries := []string{
|
||||
// m h dom mon dow command
|
||||
fmt.Sprintf("%d %d * * %s %s",
|
||||
tod.Minute(),
|
||||
tod.Hour(),
|
||||
strings.Join(days, ","),
|
||||
"curl -X POST http://"+s.url+"/playlist/start",
|
||||
),
|
||||
fmt.Sprintf("%d %d * * %s %s",
|
||||
tod.Minute(),
|
||||
tod.Add(1*time.Hour).Hour(),
|
||||
strings.Join(days, ","),
|
||||
"curl -X POST http://"+s.url+"/playlist/stop",
|
||||
),
|
||||
"",
|
||||
}
|
||||
|
||||
// Concat all the user's custom crontab entries.
|
||||
crons, err := BuildCrontab()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetSchedule: BuildCrontab: %s", err)
|
||||
}
|
||||
|
||||
// Final cron file is the user's custom crons plus the new ones.
|
||||
crontab := strings.Join(append(crons, entries...), "\n")
|
||||
fmt.Println(crontab)
|
||||
|
||||
// Install the crontab.
|
||||
cmd := exec.Command("crontab", "-")
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdin = strings.NewReader(crontab)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetSchedule: crontab -: %s\nSTDOUT: %s\nSTDERR: %s", err, out.String(), stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildCrontab concatenates all the crontabs from crontab.in/
|
||||
func BuildCrontab() ([]string, error) {
|
||||
var crontab []string
|
||||
err := filepath.Walk("./crontab.in", func(path string, info os.FileInfo, err error) error {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ReadAll(%s): %s", path, err)
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
crontab = append(crontab, lines...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return crontab, nil
|
||||
}
|
22
crontab.in/000-header.cron
Normal file
22
crontab.in/000-header.cron
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Edit this file to introduce tasks to be run by cron.
|
||||
#
|
||||
# Each task to run has to be defined through a single line
|
||||
# indicating with different fields when the task will be run
|
||||
# and what command to run for the task
|
||||
#
|
||||
# To define the time you can provide concrete values for
|
||||
# minute (m), hour (h), day of month (dom), month (mon),
|
||||
# and day of week (dow) or use '*' in these fields (for 'any').#
|
||||
# Notice that tasks will be started based on the cron's system
|
||||
# daemon's notion of time and timezones.
|
||||
#
|
||||
# Output of the crontab jobs (including errors) is sent through
|
||||
# email to the user the crontab file belongs to (unless redirected).
|
||||
#
|
||||
# For example, you can run a backup of all your user accounts
|
||||
# at 5 a.m every week with:
|
||||
# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
|
||||
#
|
||||
# For more information see the manual pages of crontab(5) and cron(8)
|
||||
#
|
||||
# m h dom mon dow command
|
6
etc/supervisor.conf
Normal file
6
etc/supervisor.conf
Normal file
|
@ -0,0 +1,6 @@
|
|||
[program:sonar]
|
||||
command = /home/kirsle/sonar.pi/sonar
|
||||
directory = /home/kirsle/sonar.pi
|
||||
user = kirsle
|
||||
|
||||
# vim:ft=dosini
|
8
music.go
8
music.go
|
@ -3,6 +3,7 @@ package sonar
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
@ -136,9 +137,10 @@ func (s *Sonar) StartPlaylist() error {
|
|||
|
||||
// Shuffle the files.
|
||||
log.Debug("StartPlaylist: shuffled %d media files", len(files))
|
||||
// rand.Shuffle(len(files), func(i, j int) {
|
||||
// files[i], files[j] = files[j], files[i]
|
||||
// })
|
||||
for i := range files {
|
||||
j := rand.Intn(i + 1)
|
||||
files[i], files[j] = files[j], files[i]
|
||||
}
|
||||
|
||||
// Install the playlist and go.
|
||||
s.Player.Run(files)
|
||||
|
|
37
routes.go
37
routes.go
|
@ -3,6 +3,7 @@ package sonar
|
|||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
@ -23,6 +24,11 @@ func (s *Sonar) Register() *mux.Router {
|
|||
"Flashes": flashes,
|
||||
"Volume": s.Volume(),
|
||||
"PlaylistStatus": s.Player.Status(),
|
||||
|
||||
// Current schedule
|
||||
"Hour": s.Config.Hour,
|
||||
"Minute": s.Config.Minute,
|
||||
"Days": NewSet(s.Config.Days),
|
||||
}
|
||||
templ.ExecuteTemplate(w, "index.gohtml", v)
|
||||
})
|
||||
|
@ -46,6 +52,37 @@ func (s *Sonar) Register() *mux.Router {
|
|||
s.FlashAndRedirect(r, w, "/", "Playlist stopped!")
|
||||
}
|
||||
})
|
||||
POST.HandleFunc("/playlist/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
timestamp = r.FormValue("time")
|
||||
days = r.Form["day"]
|
||||
)
|
||||
|
||||
// Validate the inputs.
|
||||
dt, err := time.Parse("15:04", timestamp)
|
||||
if err != nil {
|
||||
s.Abort(r, w, "Invalid time stamp.")
|
||||
return
|
||||
}
|
||||
if len(days) == 0 {
|
||||
s.Abort(r, w, "Select one or more days!")
|
||||
return
|
||||
}
|
||||
|
||||
// Save the intended schedule to our config on disk.
|
||||
s.Config.Hour = dt.Hour()
|
||||
s.Config.Minute = dt.Minute()
|
||||
s.Config.Days = days
|
||||
SaveConfig(s.Config)
|
||||
|
||||
// Save the crontab to disk.
|
||||
err = s.SetSchedule(dt, days)
|
||||
if err != nil {
|
||||
s.Abort(r, w, err.Error())
|
||||
} else {
|
||||
s.FlashAndRedirect(r, w, "/", "Schedule saved!")
|
||||
}
|
||||
})
|
||||
POST.HandleFunc("/volume/higher", func(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.VolumeUp()
|
||||
if err != nil {
|
||||
|
|
16
sessions.go
16
sessions.go
|
@ -1,10 +1,20 @@
|
|||
package sonar
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Abort with an error.
|
||||
func (s *Sonar) Abort(r *http.Request, w http.ResponseWriter, message string, v ...interface{}) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, message, v...)
|
||||
}
|
||||
|
||||
// FlashAndRedirect to flash and redirect away.
|
||||
func (s *Sonar) FlashAndRedirect(r *http.Request, w http.ResponseWriter, url string, flash string, v ...string) {
|
||||
s.Flash(r, w, flash, v...)
|
||||
func (s *Sonar) FlashAndRedirect(r *http.Request, w http.ResponseWriter, url string, flash string, v ...interface{}) {
|
||||
log.Debug("Flash: %s", fmt.Sprintf(flash, v...))
|
||||
s.Flash(r, w, fmt.Sprintf(flash, v...))
|
||||
w.Header().Add("Location", url)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
|
10
sonar.go
10
sonar.go
|
@ -19,6 +19,7 @@ type Sonar struct {
|
|||
Debug bool
|
||||
Player *Player
|
||||
Config *Config
|
||||
url string
|
||||
|
||||
n *negroni.Negroni
|
||||
router *mux.Router
|
||||
|
@ -50,11 +51,10 @@ func New() *Sonar {
|
|||
|
||||
// ListenAndServe starts the HTTP app.
|
||||
func (s *Sonar) ListenAndServe(address string) error {
|
||||
label := address
|
||||
if strings.HasPrefix(label, ":") {
|
||||
label = "0.0.0.0" + label
|
||||
s.url = address
|
||||
if strings.HasPrefix(s.url, ":") {
|
||||
s.url = "0.0.0.0" + s.url
|
||||
}
|
||||
log.Info("Listening at http://%s", label)
|
||||
|
||||
log.Info("Listening at http://%s", s.url)
|
||||
return http.ListenAndServe(address, s.n)
|
||||
}
|
||||
|
|
43
utils.go
Normal file
43
utils.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
package sonar
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Set is a lookup set of strings.
|
||||
type Set map[string]interface{}
|
||||
|
||||
// NewSet makes a new Set from a list of strings.
|
||||
func NewSet(input []string) Set {
|
||||
set := Set{}
|
||||
for _, v := range input {
|
||||
set[v] = nil
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// In queries if the value is in the set.
|
||||
func (s Set) In(v string) bool {
|
||||
_, ok := s[v]
|
||||
return ok
|
||||
}
|
||||
|
||||
// SliceToSet makes a slice into a set map.
|
||||
func SliceToSet(slice []string) map[string]bool {
|
||||
var result = make(map[string]bool)
|
||||
for _, v := range slice {
|
||||
result[v] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Str2IntSlice converts a string slice to an int slice.
|
||||
func Str2IntSlice(str []string) ([]int, error) {
|
||||
result := make([]int, len(str))
|
||||
for i, literal := range str {
|
||||
val, err := strconv.Atoi(literal)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result[i] = val
|
||||
}
|
||||
return result, nil
|
||||
}
|
|
@ -19,8 +19,6 @@
|
|||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1>time.caskir.net</h1>
|
||||
|
||||
{{ range .Flashes }}
|
||||
<div class="alert alert-info">{{ . }}</div>
|
||||
{{ end }}
|
||||
|
@ -60,7 +58,7 @@
|
|||
<div class="col">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
Alan Watts Alarm Clock
|
||||
Alarm Clock Playlist
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
|
@ -90,52 +88,52 @@
|
|||
Schedule
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
<a href="/crontab.txt">See the current schedule</a>
|
||||
</p>
|
||||
<form method="POST" action="/playlist/schedule">
|
||||
|
||||
<label for="time">Time:</label>
|
||||
<input type="time" name="time" class="form-control mb-2" value="05:30">
|
||||
<input type="time" name="time" class="form-control mb-2" value="{{ printf "%02d" .Hour }}:{{ .Minute }}">
|
||||
|
||||
<label>Days of week:</label>
|
||||
<ul class="list-unstyled">
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="day" value="0"> Sunday
|
||||
<input type="checkbox" name="day" value="0"{{ if .Days.In "0" }} checked{{ end }}> Sunday
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="day" value="1" checked> Monday
|
||||
<input type="checkbox" name="day" value="1"{{ if .Days.In "1" }} checked{{ end }}> Monday
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="day" value="2" checked> Tuesday
|
||||
<input type="checkbox" name="day" value="2"{{ if .Days.In "2" }} checked{{ end }}> Tuesday
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="day" value="3" checked> Wednesday
|
||||
<input type="checkbox" name="day" value="3"{{ if .Days.In "3" }} checked{{ end }}> Wednesday
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="day" value="4" checked> Thursday
|
||||
<input type="checkbox" name="day" value="4"{{ if .Days.In "4" }} checked{{ end }}> Thursday
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="day" value="5" checked> Friday
|
||||
<input type="checkbox" name="day" value="5"{{ if .Days.In "5" }} checked{{ end }}> Friday
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" name="day" value="6"> Saturday
|
||||
<input type="checkbox" name="day" value="6"{{ if .Days.In "6" }} checked{{ end }}> Saturday
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button name="action" value="schedule" class="form-control btn btn-primary">Set Schedule</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Reference in New Issue
Block a user