├── .gitignore ├── .travis.yml ├── Makefile ├── README.md ├── cmd └── 9t │ ├── main.go │ └── version.go ├── images ├── 9t.gif └── tailf.gif ├── ninetail.go ├── ninetail_test.go ├── tailer.go └── tailer_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /9t 2 | /9t.test 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.11.x 4 | 5 | branches: 6 | only: 7 | - master 8 | 9 | install: 10 | - go get github.com/hpcloud/tail 11 | - go get github.com/mattn/go-colorable 12 | - go get github.com/mattn/go-runewidth 13 | - go get github.com/mattn/goveralls 14 | 15 | script: 16 | - $GOPATH/bin/goveralls -service=travis-ci 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OWNER = gongo 2 | REPOSITORY = 9t 3 | 4 | COMMAND = 9t 5 | MAIN_DIR = cmd/9t 6 | VERSION = $(shell grep "const Version " $(MAIN_DIR)/version.go | sed -E 's/.*"(.+)"$$/\1/') 7 | 8 | TOP = $(shell pwd) 9 | BUILD_DIR = $(TOP)/pkg 10 | DIST_DIR = $(TOP)/dist 11 | 12 | XC_ARCH = "386 amd64" 13 | XC_OS = "darwin linux windows" 14 | XC_OUTPUT = "$(BUILD_DIR)/{{.OS}}_{{.Arch}}/{{.Dir}}" 15 | 16 | 17 | setup: 18 | go get -u github.com/tcnksm/ghr 19 | 20 | build: 21 | rm -rf $(BUILD_DIR) 22 | mkdir $(BUILD_DIR) 23 | gox -os $(XC_OS) -arch $(XC_ARCH) -output $(XC_OUTPUT) ./$(MAIN_DIR) 24 | 25 | dist: build 26 | rm -rf $(DIST_DIR) 27 | mkdir $(DIST_DIR) 28 | 29 | @for dir in $$(find $(BUILD_DIR) -mindepth 1 -maxdepth 1 -type d); do \ 30 | platform=$$(basename $$dir) ; \ 31 | archive=$(COMMAND)_$(VERSION)_$$platform ;\ 32 | zip -j $(DIST_DIR)/$$archive.zip $$dir/* ;\ 33 | done 34 | 35 | @pushd $(DIST_DIR) ; shasum -a 256 *.zip > ./SHA256SUMS ; popd 36 | 37 | release: 38 | ghr -u $(OWNER) -r $(REPOSITORY) $(VERSION) $(DIST_DIR) 39 | 40 | clean: 41 | rm -rf $(BUILD_DIR) 42 | rm -rf $(DIST_DIR) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 9t 2 | ============================== 3 | 4 | [![Build Status](https://travis-ci.org/gongo/9t.svg?branch=master)](https://travis-ci.org/gongo/9t) 5 | [![Coverage Status](https://coveralls.io/repos/gongo/9t/badge.svg?branch=master)](https://coveralls.io/r/gongo/9t?branch=master) 6 | 7 | 9t (nine-tailed fox in Japanese) is a multi-file tailer (like `tail -f a.log b.log ...`). 8 | 9 | Usage 10 | ------------------------------ 11 | 12 | ``` 13 | $ 9t file1 [file2 ...] 14 | ``` 15 | 16 | ### Demo 17 | 18 | ![Demo](./images/9t.gif) 19 | 20 | 1. Preparation for demo 21 | 22 | ```sh 23 | $ yukari() { echo '世界一かわいいよ!!' } 24 | $ while :; do yukari >> tamura-yukari.log ; sleep 0.2 ; done 25 | $ while :; do echo $RANDOM >> random.log ; sleep 3 ; done 26 | $ while :; do date >> d.log ; sleep 1 ; done 27 | ``` 28 | 29 | 1. Run 30 | 31 | ``` 32 | $ 9t tamura-yukari.log random.log d.log 33 | ``` 34 | 35 | Installation 36 | ------------------------------ 37 | 38 | ``` 39 | $ go get github.com/gongo/9t/cmd/9t 40 | ``` 41 | 42 | Motivation 43 | ------------------------------ 44 | 45 | So far, Multiple file display can be even `tail -f`. 46 | 47 | ![Demo](./images/tailf.gif) 48 | 49 | But, I wanted to see in a similar format as the `heroku logs --tail`. 50 | 51 | ``` 52 | app[web.1]: foo bar baz 53 | app[worker.1]: pizza pizza 54 | app[web.1]: foo bar baz 55 | app[web.2]: just do eat..soso.. 56 | . 57 | . 58 | ``` 59 | 60 | License 61 | ------------------------------ 62 | 63 | MIT License 64 | -------------------------------------------------------------------------------- /cmd/9t/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/gongo/9t" 8 | ) 9 | 10 | func main() { 11 | filenames := os.Args[1:] 12 | 13 | runner, err := ninetail.Runner(filenames, ninetail.Config{Colorize: true}) // TODO use flags!! 14 | if err != nil { 15 | log.Fatal(err) 16 | } 17 | runner.Run() 18 | } 19 | -------------------------------------------------------------------------------- /cmd/9t/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const Version string = "0.0.2" 4 | -------------------------------------------------------------------------------- /images/9t.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gongo/9t/fc98dd15970bfd2202d4ed067b34008650d9e613/images/9t.gif -------------------------------------------------------------------------------- /images/tailf.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gongo/9t/fc98dd15970bfd2202d4ed067b34008650d9e613/images/tailf.gif -------------------------------------------------------------------------------- /ninetail.go: -------------------------------------------------------------------------------- 1 | package ninetail 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "sync" 7 | 8 | "github.com/mattn/go-colorable" 9 | ) 10 | 11 | type NineTail struct { 12 | output io.Writer 13 | tailers []*Tailer 14 | } 15 | 16 | type Config struct { 17 | Colorize bool 18 | } 19 | 20 | func Runner(filenames []string, config Config) (*NineTail, error) { 21 | var output io.Writer 22 | if config.Colorize { 23 | output = colorable.NewColorableStdout() 24 | } else { 25 | output = colorable.NewNonColorable(os.Stdout) 26 | } 27 | 28 | tailers, err := NewTailers(filenames) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return &NineTail{ 34 | output: output, 35 | tailers: tailers, 36 | }, nil 37 | } 38 | 39 | func (n *NineTail) Run() { 40 | var wg sync.WaitGroup 41 | 42 | for _, t := range n.tailers { 43 | wg.Add(1) 44 | go func(t *Tailer) { 45 | t.Do(n.output) 46 | wg.Done() 47 | }(t) 48 | } 49 | 50 | wg.Wait() 51 | } 52 | 53 | func (n *NineTail) Stop() { 54 | var wg sync.WaitGroup 55 | 56 | for _, t := range n.tailers { 57 | wg.Add(1) 58 | go func(t *Tailer) { 59 | t.Stop() 60 | wg.Done() 61 | }(t) 62 | } 63 | 64 | wg.Wait() 65 | } 66 | -------------------------------------------------------------------------------- /ninetail_test.go: -------------------------------------------------------------------------------- 1 | package ninetail 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/mattn/go-colorable" 14 | ) 15 | 16 | func TestRun(t *testing.T) { 17 | tfs, err := newTestFileSet([]string{ 18 | "php.txt", 19 | "吾輩は猫である.txt", 20 | "i_am_a_CAT.txt", 21 | }) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | defer tfs.removeAll() 26 | 27 | tailers, err := NewTailers(tfs.getFilenames()) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | output := new(bytes.Buffer) 33 | target := &NineTail{ 34 | output: colorable.NewNonColorable(output), 35 | tailers: tailers, 36 | } 37 | 38 | var wg sync.WaitGroup 39 | wg.Add(1) 40 | go func(n *NineTail) { 41 | n.Run() 42 | wg.Done() 43 | }(target) 44 | 45 | interval := time.Tick(100 * time.Millisecond) 46 | <-interval 47 | tfs.writeString(0, "PHP(PHP: Hypertext Preprocessor)\n") 48 | <-interval 49 | tfs.writeString(2, "I am a cat. As yet I have no name.\n") 50 | <-interval 51 | tfs.writeString(1, "吾輩は猫である。名前はまだ無い。\n") 52 | <-interval 53 | 54 | for _, t := range target.tailers { 55 | t.Stop() 56 | } 57 | wg.Wait() 58 | 59 | expect := ` php.txt: PHP(PHP: Hypertext Preprocessor) 60 | i_am_a_CAT.txt: I am a cat. As yet I have no name. 61 | 吾輩は猫である.txt: 吾輩は猫である。名前はまだ無い。 62 | ` 63 | actual := output.String() 64 | 65 | if expect != actual { 66 | t.Fatal("Incorrect align") 67 | } 68 | } 69 | 70 | type testFileSet struct { 71 | dir string 72 | files []*os.File 73 | } 74 | 75 | func newTestFileSet(basenames []string) (*testFileSet, error) { 76 | dir, err := ioutil.TempDir("", "ninetail") 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | files := make([]*os.File, len(basenames)) 82 | for i, name := range basenames { 83 | filename := filepath.Join(dir, name) 84 | f, err := os.Create(filename) 85 | if err != nil { 86 | return nil, err 87 | } 88 | files[i] = f 89 | } 90 | 91 | return &testFileSet{ 92 | dir: dir, 93 | files: files, 94 | }, nil 95 | } 96 | 97 | func (tfs *testFileSet) getFilenames() []string { 98 | filenames := make([]string, len(tfs.files)) 99 | for i, file := range tfs.files { 100 | filenames[i] = file.Name() 101 | } 102 | return filenames 103 | } 104 | 105 | func (tfs *testFileSet) writeString(index int, text string) { 106 | fmt.Fprintf(tfs.files[index], text) 107 | } 108 | 109 | func (tfs *testFileSet) removeAll() { 110 | os.RemoveAll(tfs.dir) 111 | } 112 | -------------------------------------------------------------------------------- /tailer.go: -------------------------------------------------------------------------------- 1 | package ninetail 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "strings" 10 | 11 | "github.com/hpcloud/tail" 12 | "github.com/mattn/go-runewidth" 13 | ) 14 | 15 | var ( 16 | // red, green, yellow, magenta, cyan 17 | ansiColorCodes = [...]int{31, 32, 33, 35, 36} 18 | seekInfoOnStart = &tail.SeekInfo{Offset: 0, Whence: os.SEEK_END} 19 | ) 20 | 21 | //Tailer contains watches tailed files and contains per-file output parameters 22 | type Tailer struct { 23 | *tail.Tail 24 | colorCode int 25 | padding string 26 | } 27 | 28 | //NewTailers creates slice of Tailers from file names. 29 | //Colors of file names are cycled through the list. 30 | //maxWidth is a maximum widht of passed file names, for nice alignment 31 | func NewTailers(filenames []string) ([]*Tailer, error) { 32 | maxLength := maximumNameLength(filenames) 33 | ts := make([]*Tailer, len(filenames)) 34 | 35 | for i, filename := range filenames { 36 | t, err := newTailer(filename, getColorCode(i), maxLength) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | ts[i] = t 42 | } 43 | 44 | return ts, nil 45 | } 46 | 47 | func newTailer(filename string, colorCode int, maxWidth int) (*Tailer, error) { 48 | t, err := tail.TailFile(filename, tail.Config{ 49 | Follow: true, 50 | Location: seekInfoOnStart, 51 | Logger: tail.DiscardingLogger, 52 | }) 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | dispNameLength := displayFilenameLength(filename) 59 | 60 | return &Tailer{ 61 | Tail: t, 62 | colorCode: colorCode, 63 | padding: strings.Repeat(" ", maxWidth-dispNameLength), 64 | }, nil 65 | } 66 | 67 | //Do formats, colors and writes to stdout appended lines when they happen, exiting on write error 68 | func (t Tailer) Do(output io.Writer) { 69 | for line := range t.Lines { 70 | _, err := fmt.Fprintf( 71 | output, 72 | "\x1b[%dm%s%s\x1b[0m: %s\n", 73 | t.colorCode, 74 | t.padding, 75 | t.name(), 76 | line.Text, 77 | ) 78 | if err != nil { 79 | return 80 | } 81 | } 82 | } 83 | 84 | func (t Tailer) name() string { 85 | return filepath.Base(t.Filename) 86 | } 87 | 88 | func getColorCode(index int) int { 89 | return ansiColorCodes[index%len(ansiColorCodes)] 90 | } 91 | 92 | func maximumNameLength(filenames []string) int { 93 | max := 0 94 | for _, name := range filenames { 95 | if current := displayFilenameLength(name); current > max { 96 | max = current 97 | } 98 | } 99 | return max 100 | } 101 | 102 | func displayFilename(filename string) string { 103 | return filepath.Base(filename) 104 | } 105 | 106 | func displayFilenameLength(filename string) int { 107 | return runewidth.StringWidth(displayFilename(filename)) 108 | } 109 | -------------------------------------------------------------------------------- /tailer_test.go: -------------------------------------------------------------------------------- 1 | package ninetail 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "path/filepath" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/mattn/go-colorable" 14 | ) 15 | 16 | func TestNewTailers(t *testing.T) { 17 | names := []string{ 18 | "/very/very/long/path/but/base/is/short", 19 | "/path/to/message", 20 | "/p/t/very_very_long_base", 21 | "/p/var/log/production.log", 22 | "/p/var/log/development.log", 23 | "/p/var/log/test.log", 24 | } 25 | 26 | tailers, err := NewTailers(names) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | if len(tailers) != len(names) { 32 | t.Fatalf("Incorrect: tailers count expect(%d) actual(%d)", len(names), len(tailers)) 33 | } 34 | 35 | if tailers[0].colorCode != ansiColorCodes[0] { 36 | t.Fatal("Incorrect color code at 1st file") 37 | } 38 | 39 | if tailers[1].colorCode != ansiColorCodes[1] { 40 | t.Fatal("Incorrect color code at 2nd file") 41 | } 42 | 43 | if tailers[5].colorCode != ansiColorCodes[0] { // Return to first color code 44 | t.Fatal("Incorrect color code at 6th file") 45 | } 46 | } 47 | 48 | func TestTailerDo(t *testing.T) { 49 | file, err := ioutil.TempFile("", "ninetail_tailer_do") 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | defer file.Close() 54 | 55 | basename := filepath.Base(file.Name()) 56 | maxLength := len(basename) + 20 // 20 = padding 57 | 58 | tailer, err := newTailer(file.Name(), 32, maxLength) // 32 = green 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | output := new(bytes.Buffer) 64 | 65 | var wg sync.WaitGroup 66 | wg.Add(1) 67 | go func(t *Tailer, output io.Writer) { 68 | tailer.Do(colorable.NewNonColorable(output)) 69 | wg.Done() 70 | }(tailer, output) 71 | 72 | // Simulate to `echo line >> ninetail_tailer_do` 73 | interval := time.Tick(100 * time.Millisecond) 74 | <-interval 75 | fmt.Fprint(file, "line\n") 76 | <-interval 77 | 78 | tailer.Stop() 79 | wg.Wait() 80 | 81 | expect := fmt.Sprintf(" %s: line\n", basename) 82 | actual := output.String() 83 | 84 | if expect != actual { 85 | t.Fatal("Bad padding or line text") 86 | } 87 | } 88 | --------------------------------------------------------------------------------