add inspect command, fix deduplication issues, fixes

pull/30/head
Simon Let 6 years ago
parent 968c792f69
commit 15a431cfc3
  1. 9
      Makefile
  2. 8
      cmd/control/cmd/debug.go
  3. 1
      cmd/control/cmd/root.go
  4. 2
      cmd/control/status/status.go
  5. 43
      cmd/daemon/recall.go
  6. 1
      cmd/daemon/run-server.go
  7. 84
      cmd/inspect/main.go
  8. 36
      pkg/histfile/histfile.go
  9. 27
      pkg/histlist/histlist.go
  10. 12
      pkg/msg/msg.go
  11. 11
      pkg/records/records.go
  12. 75
      pkg/sesshist/sesshist.go
  13. 7
      scripts/reshctl.sh

@ -41,7 +41,8 @@ sanitize:
# #
# #
build: submodules bin/resh-session-init bin/resh-collect bin/resh-postcollect bin/resh-daemon bin/resh-evaluate bin/resh-sanitize bin/resh-control bin/resh-config build: submodules bin/resh-session-init bin/resh-collect bin/resh-postcollect bin/resh-daemon\
bin/resh-evaluate bin/resh-sanitize bin/resh-control bin/resh-config bin/resh-inspect
test_go: test_go:
# Running tests # Running tests
@ -92,7 +93,7 @@ install: build submodules/bash-preexec/bash-preexec.sh scripts/shellrc.sh conf/c
@# Deleting zsh completion cache - for future use @# Deleting zsh completion cache - for future use
@# [ ! -e ~/.zcompdump ] || rm ~/.zcompdump @# [ ! -e ~/.zcompdump ] || rm ~/.zcompdump
# Restarting resh daemon ... # Restarting resh daemon ...
-if [ ! -f ~/.resh/resh.pid ]; then\ -if [ -f ~/.resh/resh.pid ]; then\
kill -SIGTERM $$(cat ~/.resh/resh.pid);\ kill -SIGTERM $$(cat ~/.resh/resh.pid);\
rm ~/.resh/resh.pid;\ rm ~/.resh/resh.pid;\
fi fi
@ -136,9 +137,7 @@ uninstall:
# Uninstalling ... # Uninstalling ...
-rm -rf ~/.resh/ -rm -rf ~/.resh/
bin/resh-control: cmd/control/cmd/*.go cmd/control/status/*.go bin/resh-%: cmd/%/*.go pkg/*/*.go VERSION cmd/control/cmd/*.go cmd/control/status/status.go
bin/resh-%: cmd/%/*.go pkg/*/*.go VERSION
go build ${GOFLAGS} -o $@ cmd/$*/*.go go build ${GOFLAGS} -o $@ cmd/$*/*.go
$(HOME)/.resh $(HOME)/.resh/bin $(HOME)/.config $(HOME)/.resh/bash_completion.d $(HOME)/.resh/zsh_completion.d: $(HOME)/.resh $(HOME)/.resh/bin $(HOME)/.config $(HOME)/.resh/bash_completion.d $(HOME)/.resh/zsh_completion.d:

@ -25,6 +25,14 @@ var debugReloadCmd = &cobra.Command{
}, },
} }
var debugInspectCmd = &cobra.Command{
Use: "inspect",
Short: "inspect session history",
Run: func(cmd *cobra.Command, args []string) {
exitCode = status.InspectSessionHistory
},
}
var debugOutputCmd = &cobra.Command{ var debugOutputCmd = &cobra.Command{
Use: "output", Use: "output",
Short: "shows output from last runs of resh", Short: "shows output from last runs of resh",

@ -30,6 +30,7 @@ func Execute() status.Code {
rootCmd.AddCommand(debugCmd) rootCmd.AddCommand(debugCmd)
debugCmd.AddCommand(debugReloadCmd) debugCmd.AddCommand(debugReloadCmd)
debugCmd.AddCommand(debugInspectCmd)
debugCmd.AddCommand(debugOutputCmd) debugCmd.AddCommand(debugOutputCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Println(err) fmt.Println(err)

@ -14,4 +14,6 @@ const (
DisableArrowKeyBindings = 111 DisableArrowKeyBindings = 111
// ReloadRcFiles exit code - tells reshctl() wrapper to reload shellrc resh file // ReloadRcFiles exit code - tells reshctl() wrapper to reload shellrc resh file
ReloadRcFiles = 200 ReloadRcFiles = 200
// InspectSessionHistory exit code - tells reshctl() wrapper to take current sessionID and send /inspect request to daemon
InspectSessionHistory = 201
) )

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"github.com/curusarn/resh/pkg/collect" "github.com/curusarn/resh/pkg/collect"
"github.com/curusarn/resh/pkg/msg"
"github.com/curusarn/resh/pkg/records" "github.com/curusarn/resh/pkg/records"
"github.com/curusarn/resh/pkg/sesshist" "github.com/curusarn/resh/pkg/sesshist"
) )
@ -52,3 +53,45 @@ func (h *recallHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write(jsn) w.Write(jsn)
log.Println("/recall END - sess id:", rec.SessionID, " - histno:", rec.RecallHistno, " -> ", cmd) log.Println("/recall END - sess id:", rec.SessionID, " - histno:", rec.RecallHistno, " -> ", cmd)
} }
type inspectHandler struct {
sesshistDispatch *sesshist.Dispatch
}
func (h *inspectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Println("/inspect START")
log.Println("/inspect reading body ...")
jsn, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Println("Error reading the body", err)
return
}
mess := msg.InspectMsg{}
log.Println("/inspect unmarshaling record ...")
err = json.Unmarshal(jsn, &mess)
if err != nil {
log.Println("Decoding error:", err)
log.Println("Payload:", jsn)
return
}
log.Println("/inspect recalling ...")
cmds, err := h.sesshistDispatch.Inspect(mess.SessionID, int(mess.Count))
if err != nil {
log.Println("/inspect - sess id:", mess.SessionID, " - count:", mess.Count, " -> ERROR")
log.Println("Inspect error:", err)
return
}
resp := msg.MultiResponse{CmdLines: cmds}
log.Println("/inspect marshaling response ...")
jsn, err = json.Marshal(&resp)
if err != nil {
log.Println("Encoding error:", err)
log.Println("Response:", resp)
return
}
// log.Println(string(jsn))
log.Println("/inspect writing response ...")
w.Write(jsn)
log.Println("/inspect END - sess id:", mess.SessionID, " - count:", mess.Count)
}

@ -54,6 +54,7 @@ func runServer(config cfg.Config, historyPath string) {
mux.Handle("/record", &recordHandler{subscribers: recordSubscribers}) mux.Handle("/record", &recordHandler{subscribers: recordSubscribers})
mux.Handle("/session_init", &sessionInitHandler{subscribers: sessionInitSubscribers}) mux.Handle("/session_init", &sessionInitHandler{subscribers: sessionInitSubscribers})
mux.Handle("/recall", &recallHandler{sesshistDispatch: sesshistDispatch}) mux.Handle("/recall", &recallHandler{sesshistDispatch: sesshistDispatch})
mux.Handle("/inspect", &inspectHandler{sesshistDispatch: sesshistDispatch})
server := &http.Server{Addr: ":" + strconv.Itoa(config.Port), Handler: mux} server := &http.Server{Addr: ":" + strconv.Itoa(config.Port), Handler: mux}
go server.ListenAndServe() go server.ListenAndServe()

@ -0,0 +1,84 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"github.com/BurntSushi/toml"
"github.com/curusarn/resh/pkg/cfg"
"github.com/curusarn/resh/pkg/msg"
"os/user"
"path/filepath"
"strconv"
)
// Version from git set during build
var Version string
// Revision from git set during build
var Revision string
func main() {
usr, _ := user.Current()
dir := usr.HomeDir
configPath := filepath.Join(dir, "/.config/resh.toml")
var config cfg.Config
if _, err := toml.DecodeFile(configPath, &config); err != nil {
log.Fatal("Error reading config:", err)
}
sessionID := flag.String("sessionID", "", "resh generated session id")
count := flag.Uint("count", 10, "Number of cmdLines to return")
flag.Parse()
if *sessionID == "" {
fmt.Println("Error: you need to specify sessionId")
}
m := msg.InspectMsg{SessionID: *sessionID, Count: *count}
resp := SendInspectMsg(m, strconv.Itoa(config.Port))
for _, cmdLine := range resp.CmdLines {
fmt.Println("`" + cmdLine + "'")
}
}
// SendInspectMsg to daemon
func SendInspectMsg(m msg.InspectMsg, port string) msg.MultiResponse {
recJSON, err := json.Marshal(m)
if err != nil {
log.Fatal("send err 1", err)
}
req, err := http.NewRequest("POST", "http://localhost:"+port+"/inspect",
bytes.NewBuffer(recJSON))
if err != nil {
log.Fatal("send err 2", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Fatal("resh-daemon is not running :(")
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal("read response error")
}
// log.Println(string(body))
response := msg.MultiResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
log.Fatal("unmarshal resp error: ", err)
}
return response
}

@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"sync" "sync"
"github.com/curusarn/resh/pkg/histlist"
"github.com/curusarn/resh/pkg/records" "github.com/curusarn/resh/pkg/records"
) )
@ -16,10 +17,10 @@ type Histfile struct {
sessions map[string]records.Record sessions map[string]records.Record
historyPath string historyPath string
recentMutex sync.Mutex recentMutex sync.Mutex
recentRecords []records.Record recentRecords []records.Record
recentCmdLines []string // deduplicated
cmdLinesLastIndex map[string]int cmdLines histlist.Histlist
} }
// New creates new histfile and runs two gorutines on it // New creates new histfile and runs two gorutines on it
@ -27,9 +28,9 @@ func New(input chan records.Record, historyPath string, initHistSize int, sessio
signals chan os.Signal, shutdownDone chan string) *Histfile { signals chan os.Signal, shutdownDone chan string) *Histfile {
hf := Histfile{ hf := Histfile{
sessions: map[string]records.Record{}, sessions: map[string]records.Record{},
historyPath: historyPath, historyPath: historyPath,
cmdLinesLastIndex: map[string]int{}, cmdLines: histlist.New(),
} }
go hf.loadHistory(initHistSize) go hf.loadHistory(initHistSize)
go hf.writer(input, signals, shutdownDone) go hf.writer(input, signals, shutdownDone)
@ -40,7 +41,9 @@ func New(input chan records.Record, historyPath string, initHistSize int, sessio
func (h *Histfile) loadHistory(initHistSize int) { func (h *Histfile) loadHistory(initHistSize int) {
h.recentMutex.Lock() h.recentMutex.Lock()
defer h.recentMutex.Unlock() defer h.recentMutex.Unlock()
h.recentCmdLines = records.LoadCmdLinesFromFile(h.historyPath, initHistSize) log.Println("histfile: Loading history from file ...")
h.cmdLines = records.LoadCmdLinesFromFile(h.historyPath, initHistSize)
log.Println("histfile: History loaded - cmdLine count:", len(h.cmdLines.List))
} }
// sessionGC reads sessionIDs from channel and deletes them from histfile struct // sessionGC reads sessionIDs from channel and deletes them from histfile struct
@ -121,12 +124,12 @@ func (h *Histfile) mergeAndWriteRecord(part1, part2 records.Record) {
defer h.recentMutex.Unlock() defer h.recentMutex.Unlock()
h.recentRecords = append(h.recentRecords, part1) h.recentRecords = append(h.recentRecords, part1)
cmdLine := part1.CmdLine cmdLine := part1.CmdLine
idx, found := h.cmdLinesLastIndex[cmdLine] idx, found := h.cmdLines.LastIndex[cmdLine]
if found { if found {
h.recentCmdLines = append(h.recentCmdLines[:idx], h.recentCmdLines[idx+1:]...) h.cmdLines.List = append(h.cmdLines.List[:idx], h.cmdLines.List[idx+1:]...)
} }
h.cmdLinesLastIndex[cmdLine] = len(h.recentCmdLines) h.cmdLines.LastIndex[cmdLine] = len(h.cmdLines.List)
h.recentCmdLines = append(h.recentCmdLines, cmdLine) h.cmdLines.List = append(h.cmdLines.List, cmdLine)
}() }()
writeRecord(part1, h.historyPath) writeRecord(part1, h.historyPath)
@ -153,6 +156,11 @@ func writeRecord(rec records.Record, outputPath string) {
} }
// GetRecentCmdLines returns recent cmdLines // GetRecentCmdLines returns recent cmdLines
func (h *Histfile) GetRecentCmdLines(limit int) []string { func (h *Histfile) GetRecentCmdLines(limit int) histlist.Histlist {
return h.recentCmdLines h.recentMutex.Lock()
defer h.recentMutex.Unlock()
log.Println("histfile: History requested ...")
hl := histlist.Copy(h.cmdLines)
log.Println("histfile: History copied - cmdLine count:", len(hl.List))
return hl
} }

@ -0,0 +1,27 @@
package histlist
// Histlist is a deduplicated list of cmdLines
type Histlist struct {
// list of commands lines (deduplicated)
List []string
// lookup: cmdLine -> last index
LastIndex map[string]int
}
// New Histlist
func New() Histlist {
return Histlist{LastIndex: make(map[string]int)}
}
// Copy Histlist
func Copy(hl Histlist) Histlist {
newHl := New()
// copy list
newHl.List = make([]string, len(hl.List))
copy(newHl.List, hl.List)
// copy map
for k, v := range hl.LastIndex {
newHl.LastIndex[k] = v
}
return newHl
}

@ -0,0 +1,12 @@
package msg
// InspectMsg struct
type InspectMsg struct {
SessionID string `json:"sessionId"`
Count uint `json:"count"`
}
// MultiResponse struct
type MultiResponse struct {
CmdLines []string `json:"cmdlines"`
}

@ -10,6 +10,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/curusarn/resh/pkg/histlist"
"github.com/mattn/go-shellwords" "github.com/mattn/go-shellwords"
) )
@ -455,7 +456,7 @@ func (r *EnrichedRecord) DistanceTo(r2 EnrichedRecord, p DistParams) float64 {
} }
// LoadCmdLinesFromFile loads limit cmdlines from file // LoadCmdLinesFromFile loads limit cmdlines from file
func LoadCmdLinesFromFile(fname string, limit int) []string { func LoadCmdLinesFromFile(fname string, limit int) histlist.Histlist {
recs := LoadFromFile(fname, limit*3) // assume that at least 1/3 of commands is unique recs := LoadFromFile(fname, limit*3) // assume that at least 1/3 of commands is unique
var cmdLines []string var cmdLines []string
cmdLinesSet := map[string]bool{} cmdLinesSet := map[string]bool{}
@ -470,11 +471,17 @@ func LoadCmdLinesFromFile(fname string, limit int) []string {
break break
} }
} }
return cmdLines hl := histlist.New()
hl.List = cmdLines
for idx, cmdLine := range cmdLines {
hl.LastIndex[cmdLine] = idx
}
return hl
} }
// LoadFromFile loads at most 'limit' records from 'fname' file // LoadFromFile loads at most 'limit' records from 'fname' file
func LoadFromFile(fname string, limit int) []Record { func LoadFromFile(fname string, limit int) []Record {
// NOTE: limit does nothing atm
file, err := os.Open(fname) file, err := os.Open(fname)
if err != nil { if err != nil {
log.Fatal("Open() resh history file error:", err) log.Fatal("Open() resh history file error:", err)

@ -8,6 +8,7 @@ import (
"sync" "sync"
"github.com/curusarn/resh/pkg/histfile" "github.com/curusarn/resh/pkg/histfile"
"github.com/curusarn/resh/pkg/histlist"
"github.com/curusarn/resh/pkg/records" "github.com/curusarn/resh/pkg/records"
) )
@ -80,8 +81,7 @@ func (s *Dispatch) initSession(sessionID string) error {
defer s.mutex.Unlock() defer s.mutex.Unlock()
// init sesshist and populate it with history loaded from file // init sesshist and populate it with history loaded from file
s.sessions[sessionID] = &sesshist{ s.sessions[sessionID] = &sesshist{
recentCmdLines: historyCmdLines, recentCmdLines: historyCmdLines,
cmdLinesLastIndex: map[string]int{},
} }
log.Println("sesshist: session init done - " + sessionID) log.Println("sesshist: session init done - " + sessionID)
return nil return nil
@ -105,8 +105,11 @@ func (s *Dispatch) dropSession(sessionID string) error {
// AddRecent record to session // AddRecent record to session
func (s *Dispatch) addRecentRecord(sessionID string, record records.Record) error { func (s *Dispatch) addRecentRecord(sessionID string, record records.Record) error {
log.Println("sesshist: Adding a record, RLocking main lock ...")
s.mutex.RLock() s.mutex.RLock()
log.Println("sesshist: Getting a session ...")
session, found := s.sessions[sessionID] session, found := s.sessions[sessionID]
log.Println("sesshist: RUnlocking main lock ...")
s.mutex.RUnlock() s.mutex.RUnlock()
if found == false { if found == false {
@ -114,20 +117,27 @@ func (s *Dispatch) addRecentRecord(sessionID string, record records.Record) erro
s.initSession(sessionID) s.initSession(sessionID)
return s.addRecentRecord(sessionID, record) return s.addRecentRecord(sessionID, record)
} }
log.Println("sesshist: RLocking session lock (w/ defer) ...")
session.mutex.Lock() session.mutex.Lock()
defer session.mutex.Unlock() defer session.mutex.Unlock()
session.recentRecords = append(session.recentRecords, record) session.recentRecords = append(session.recentRecords, record)
// remove previous occurance of record // remove previous occurance of record
log.Println("sesshist: Looking for duplicate cmdLine ...")
cmdLine := record.CmdLine cmdLine := record.CmdLine
idx, found := session.cmdLinesLastIndex[cmdLine] // trim spaces to have less duplicates in the sesshist
cmdLine = strings.TrimRight(cmdLine, " ")
idx, found := session.recentCmdLines.LastIndex[cmdLine]
if found { if found {
session.recentCmdLines = append(session.recentCmdLines[:idx], session.recentCmdLines[idx+1:]...) log.Println("sesshist: Removing duplicate cmdLine at index:", idx, " out of", len(session.recentCmdLines.List), "...")
session.recentCmdLines.List = append(session.recentCmdLines.List[:idx], session.recentCmdLines.List[idx+1:]...)
} }
session.cmdLinesLastIndex[cmdLine] = len(session.recentCmdLines) log.Println("sesshist: Updating last index ...")
session.recentCmdLines.LastIndex[cmdLine] = len(session.recentCmdLines.List)
// append new record // append new record
session.recentCmdLines = append(session.recentCmdLines, cmdLine) log.Println("sesshist: Appending cmdLine ...")
session.recentCmdLines.List = append(session.recentCmdLines.List, cmdLine)
log.Println("sesshist: record:", record.CmdLine, "; added to session:", sessionID, log.Println("sesshist: record:", record.CmdLine, "; added to session:", sessionID,
"; session len:", len(session.recentCmdLines), "; session len w/ dups:", len(session.recentRecords)) "; session len:", len(session.recentCmdLines.List), "; session len (records):", len(session.recentRecords))
return nil return nil
} }
@ -154,11 +164,38 @@ func (s *Dispatch) Recall(sessionID string, histno int, prefix string) (string,
return session.searchRecordByPrefix(prefix, histno) return session.searchRecordByPrefix(prefix, histno)
} }
// Inspect commands in recent session history
func (s *Dispatch) Inspect(sessionID string, count int) ([]string, error) {
prefix := ""
log.Println("sesshist - inspect: RLocking main lock ...")
s.mutex.RLock()
log.Println("sesshist - inspect: Getting session history struct ...")
session, found := s.sessions[sessionID]
s.mutex.RUnlock()
if found == false {
// go s.initSession(sessionID)
return nil, errors.New("sesshist ERROR: No session history for SessionID " + sessionID + " - should we create one?")
}
log.Println("sesshist - inspect: Locking session lock ...")
session.mutex.Lock()
defer session.mutex.Unlock()
if prefix == "" {
log.Println("sesshist - inspect: Getting records by histno ...")
idx := len(session.recentCmdLines.List) - count
if idx < 0 {
idx = 0
}
return session.recentCmdLines.List[idx:], nil
}
log.Println("sesshist - inspect: Searching for records by prefix ... ERROR - Not implemented")
return nil, errors.New("sesshist ERROR: Inspect - Searching for records by prefix Not implemented yet")
}
type sesshist struct { type sesshist struct {
recentRecords []records.Record mutex sync.Mutex
recentCmdLines []string // deduplicated recentRecords []records.Record
cmdLinesLastIndex map[string]int recentCmdLines histlist.Histlist
mutex sync.Mutex
} }
func (s *sesshist) getRecordByHistno(histno int) (string, error) { func (s *sesshist) getRecordByHistno(histno int) (string, error) {
@ -170,11 +207,11 @@ func (s *sesshist) getRecordByHistno(histno int) (string, error) {
if histno < 0 { if histno < 0 {
return "", errors.New("sesshist ERROR: 'histno < 0' is a command from future (not supperted yet)") return "", errors.New("sesshist ERROR: 'histno < 0' is a command from future (not supperted yet)")
} }
index := len(s.recentCmdLines) - histno index := len(s.recentCmdLines.List) - histno
if index < 0 { if index < 0 {
return "", errors.New("sesshist ERROR: 'histno > number of commands in the session' (" + strconv.Itoa(len(s.recentCmdLines)) + ")") return "", errors.New("sesshist ERROR: 'histno > number of commands in the session' (" + strconv.Itoa(len(s.recentCmdLines.List)) + ")")
} }
return s.recentCmdLines[index], nil return s.recentCmdLines.List[index], nil
} }
func (s *sesshist) searchRecordByPrefix(prefix string, histno int) (string, error) { func (s *sesshist) searchRecordByPrefix(prefix string, histno int) (string, error) {
@ -184,14 +221,14 @@ func (s *sesshist) searchRecordByPrefix(prefix string, histno int) (string, erro
if histno < 0 { if histno < 0 {
return "", errors.New("sesshist ERROR: 'histno < 0' is a command from future (not supperted yet)") return "", errors.New("sesshist ERROR: 'histno < 0' is a command from future (not supperted yet)")
} }
index := len(s.recentCmdLines) - histno index := len(s.recentCmdLines.List) - histno
if index < 0 { if index < 0 {
return "", errors.New("sesshist ERROR: 'histno > number of commands in the session' (" + strconv.Itoa(len(s.recentCmdLines)) + ")") return "", errors.New("sesshist ERROR: 'histno > number of commands in the session' (" + strconv.Itoa(len(s.recentCmdLines.List)) + ")")
} }
cmdLines := []string{} cmdLines := []string{}
for i := len(s.recentCmdLines) - 1; i >= 0; i-- { for i := len(s.recentCmdLines.List) - 1; i >= 0; i-- {
if strings.HasPrefix(s.recentCmdLines[i], prefix) { if strings.HasPrefix(s.recentCmdLines.List[i], prefix) {
cmdLines = append(cmdLines, s.recentCmdLines[i]) cmdLines = append(cmdLines, s.recentCmdLines.List[i])
if len(cmdLines) >= histno { if len(cmdLines) >= histno {
break break
} }

@ -78,6 +78,7 @@ reshctl() {
# modify current shell session based on exit status # modify current shell session based on exit status
local _status=$? local _status=$?
# echo $_status
# unexport current shell # unexport current shell
unset __RESH_ctl_shell unset __RESH_ctl_shell
case "$_status" in case "$_status" in
@ -112,6 +113,12 @@ reshctl() {
. ~/.resh/shellrc . ~/.resh/shellrc
return 0 return 0
;; ;;
201)
# inspect session history
# reshctl debug inspect N
resh-inspect --sessionID "$__RESH_SESSION_ID" --count "${3-10}"
return 0
;;
*) *)
echo "reshctl() FATAL ERROR: unknown status" >&2 echo "reshctl() FATAL ERROR: unknown status" >&2
return "$_status" return "$_status"

Loading…
Cancel
Save