├── .gitattributes ├── .gitignore ├── fixtures ├── basic │ ├── go.mod │ └── main.go ├── timed │ ├── go.mod │ └── main.go ├── signals │ ├── go.mod │ └── main.go ├── winsize │ ├── go.mod │ ├── go.sum │ └── main.go └── detect_tty │ ├── go.mod │ ├── main.go │ └── go.sum ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── release.yml ├── Makefile ├── go.mod ├── tools └── update-readme.sh ├── Formula └── ets.rb ├── .goreleaser.yml ├── COPYING ├── go.sum ├── timestamper.go ├── assets ├── logo.svg └── animation.svg ├── ets.1 ├── main.go ├── main_test.go └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | *.svg binary 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /ets 3 | -------------------------------------------------------------------------------- /fixtures/basic/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zmwangx/ets/fxitures/basic 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /fixtures/timed/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zmwangx/ets/fxitures/timed 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /fixtures/signals/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zmwangx/ets/fxitures/signals 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /fixtures/winsize/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zmwangx/ets/fxitures/winsize 2 | 3 | go 1.14 4 | 5 | require github.com/creack/pty v1.1.11 6 | -------------------------------------------------------------------------------- /fixtures/detect_tty/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zmwangx/ets/fxitures/detect_tty 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/mattn/go-isatty v0.0.12 7 | ) 8 | -------------------------------------------------------------------------------- /fixtures/winsize/go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= 2 | github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | timezone: "Asia/Shanghai" 8 | time: "19:30" 9 | -------------------------------------------------------------------------------- /fixtures/detect_tty/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mattn/go-isatty" 8 | ) 9 | 10 | func main() { 11 | if isatty.IsTerminal(os.Stdout.Fd()) { 12 | fmt.Println("tty") 13 | } else { 14 | fmt.Println("pipe") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /fixtures/timed/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func main() { 9 | time.Sleep(time.Second) 10 | fmt.Println("out1") 11 | time.Sleep(time.Second) 12 | fmt.Println("out2") 13 | time.Sleep(time.Second) 14 | fmt.Println("out3") 15 | } 16 | -------------------------------------------------------------------------------- /fixtures/winsize/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/creack/pty" 9 | ) 10 | 11 | func main() { 12 | rows, cols, err := pty.Getsize(os.Stdin) 13 | if err != nil { 14 | log.Println("not a tty") 15 | } else { 16 | fmt.Printf("%dx%d\n", cols, rows) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all build test shorttest update-readme clean 2 | 3 | all: build 4 | 5 | build: 6 | go build 7 | 8 | test: 9 | go test -v 10 | 11 | shorttest: 12 | go test -v -short 13 | 14 | snapshot: 15 | goreleaser --snapshot --skip-publish --rm-dist 16 | 17 | update-readme: 18 | tools/update-readme.sh 19 | doctoc README.md 20 | 21 | clean: 22 | @- $(RM) ets 23 | -------------------------------------------------------------------------------- /fixtures/detect_tty/go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 2 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 3 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= 4 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: '0 0 * * 6' 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Setup Go 13 | uses: actions/setup-go@v4 14 | with: 15 | go-version: ^1.23 16 | - name: Test 17 | run: | 18 | make test 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zmwangx/ets 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/creack/pty v1.1.24 7 | github.com/lestrrat-go/strftime v1.0.6 8 | github.com/mattn/go-runewidth v0.0.16 9 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab 10 | github.com/spf13/pflag v1.0.5 11 | ) 12 | 13 | require ( 14 | github.com/pkg/errors v0.9.1 // indirect 15 | github.com/rivo/uniseg v0.4.7 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /fixtures/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | exitcode := flag.Int("exitcode", 0, "exit with this code") 11 | flag.Parse() 12 | 13 | fmt.Println("out1") 14 | fmt.Fprintln(os.Stderr, "err1") 15 | fmt.Println("out2") 16 | fmt.Fprintln(os.Stderr, "err2") 17 | fmt.Println("out3") 18 | fmt.Fprintln(os.Stderr, "err3") 19 | 20 | os.Exit(*exitcode) 21 | } 22 | -------------------------------------------------------------------------------- /tools/update-readme.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | # Updated rendered manpage content in README.md. 4 | # 5 | # Requires GNU sed. 6 | 7 | setopt errexit 8 | 9 | here=$0:A:h 10 | root=$here:h 11 | 12 | tmpfile=$(mktemp) 13 | echo '\n```' >>$tmpfile 14 | man $root/ets.1 | sed -r 's/.\x08//g' >>$tmpfile 15 | echo '```\n' >>$tmpfile 16 | 17 | sed -i "//,// { 18 | // { 19 | r $tmpfile 20 | n 21 | } 22 | //!d 23 | }" $root/README.md 24 | -------------------------------------------------------------------------------- /Formula/ets.rb: -------------------------------------------------------------------------------- 1 | class Ets < Formula 2 | desc "Command output timestamper" 3 | homepage "https://github.com/zmwangx/ets" 4 | url "https://github.com/zmwangx/ets/archive/v0.2.2.tar.gz" 5 | sha256 "e1c0575c1b96ecf34cbd047efacf78b57a03fc1d4ac805f5f59e4e75e51f78d0" 6 | 7 | depends_on "go" => :build 8 | 9 | def install 10 | system "go", "build", *std_go_args, "-ldflags", "-X main.version=#{version}" 11 | end 12 | 13 | test do 14 | assert_match "[00:00:00]", shell_output("#{bin}/ets -s echo hello").chomp 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | - name: Set up Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: ^1.23 17 | - name: Run GoReleaser 18 | uses: goreleaser/goreleaser-action@v6 19 | with: 20 | version: '~> v2' 21 | args: release --clean 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /fixtures/signals/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | "time" 9 | ) 10 | 11 | func main() { 12 | sigs := make(chan os.Signal, 1) 13 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 14 | exit := make(chan bool, 1) 15 | go func() { 16 | for sig := range sigs { 17 | switch sig { 18 | case syscall.SIGINT: 19 | fmt.Println("ignored SIGINT") 20 | case syscall.SIGTERM: 21 | fmt.Println("shutting down after receiving SIGTERM") 22 | exit <- true 23 | return 24 | } 25 | } 26 | }() 27 | done := false 28 | for !done { 29 | select { 30 | case <-exit: 31 | done = true 32 | case <-time.After(200 * time.Millisecond): 33 | fmt.Println("busy waiting") 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: ets 3 | before: 4 | hooks: 5 | - go mod download 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | ldflags: 10 | - -s -w -X main.version={{.Version}} 11 | goos: 12 | - darwin 13 | - linux 14 | goarch: 15 | - 386 16 | - amd64 17 | - arm 18 | - arm64 19 | goarm: 20 | - 6 21 | - 7 22 | ignore: 23 | - goos: darwin 24 | goarch: 386 25 | archives: 26 | - files: 27 | - COPYING 28 | - ets.1 29 | - README.md 30 | nfpms: 31 | - formats: 32 | - deb 33 | - rpm 34 | homepage: https://github.com/zmwangx/ets/ 35 | maintainer: Zhiming Wang 36 | description: > 37 | command output timestamper 38 | 39 | ets prefixes each line of a command's output with a timestamp. 40 | license: MIT 41 | contents: 42 | - src: ./ets.1 43 | dst: /usr/share/man/man1/ets.1 44 | release: 45 | draft: true 46 | prerelease: auto 47 | changelog: 48 | sort: asc 49 | filters: 50 | exclude: 51 | - '^docs:' 52 | - '^test:' 53 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Zhiming Wang 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 2 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8= 6 | github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= 7 | github.com/lestrrat-go/strftime v1.0.6 h1:CFGsDEt1pOpFNU+TJB0nhz9jl+K0hZSLE205AhTIGQQ= 8 | github.com/lestrrat-go/strftime v1.0.6/go.mod h1:f7jQKgV5nnJpYgdEasS+/y7EsTb8ykN2z68n3TtcTaw= 9 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 10 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 11 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 12 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 16 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 17 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 18 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= 19 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= 20 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 21 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 22 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 24 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 25 | -------------------------------------------------------------------------------- /timestamper.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "math" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/lestrrat-go/strftime" 11 | ) 12 | 13 | type TimestampMode int 14 | 15 | const ( 16 | AbsoluteTimeMode TimestampMode = iota 17 | ElapsedTimeMode 18 | IncrementalTimeMode 19 | ) 20 | 21 | type Timestamper struct { 22 | Mode TimestampMode 23 | TZ *time.Location 24 | Formatter *strftime.Strftime 25 | StartTimestamp time.Time 26 | LastTimestamp time.Time 27 | } 28 | 29 | func NewTimestamper(format string, mode TimestampMode, timezone *time.Location) (*Timestamper, error) { 30 | formatter, err := strftime.New(format, 31 | strftime.WithMilliseconds('L'), 32 | strftime.WithUnixSeconds('s'), 33 | strftime.WithSpecification('f', microseconds)) 34 | if err != nil { 35 | return nil, err 36 | } 37 | now := time.Now() 38 | return &Timestamper{ 39 | Mode: mode, 40 | TZ: timezone, 41 | Formatter: formatter, 42 | StartTimestamp: now, 43 | LastTimestamp: now, 44 | }, nil 45 | } 46 | 47 | func (t *Timestamper) CurrentTimestampString() string { 48 | now := time.Now() 49 | var s string 50 | switch t.Mode { 51 | case AbsoluteTimeMode: 52 | s = t.Formatter.FormatString(time.Now().In(t.TZ)) 53 | case ElapsedTimeMode: 54 | s = formatDuration(t.Formatter, now.Sub(t.StartTimestamp)) 55 | case IncrementalTimeMode: 56 | s = formatDuration(t.Formatter, now.Sub(t.LastTimestamp)) 57 | default: 58 | log.Panic("unknown mode ", t.Mode) 59 | } 60 | t.LastTimestamp = now 61 | return s 62 | } 63 | 64 | func formatDuration(formatter *strftime.Strftime, duration time.Duration) string { 65 | return formatter.FormatString(time.Unix(0, duration.Nanoseconds()).UTC()) 66 | } 67 | 68 | var microseconds strftime.Appender 69 | 70 | func init() { 71 | microseconds = strftime.AppendFunc(func(b []byte, t time.Time) []byte { 72 | microsecond := int(t.Nanosecond()) / int(time.Microsecond) 73 | if microsecond == 0 { 74 | return append(b, "000000"...) 75 | } else { 76 | length := int(math.Log10(float64(microsecond))) + 1 77 | b = append(b, bytes.Repeat([]byte("0"), 6-length)...) 78 | return append(b, strconv.Itoa(microsecond)...) 79 | } 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ets.1: -------------------------------------------------------------------------------- 1 | .Dd December 24, 2024 2 | .Dt ETS 1 3 | .Sh NAME 4 | .Nm ets 5 | .Nd command output timestamper 6 | .Sh SYNOPSIS 7 | .Nm 8 | .Op Fl s | i 9 | .Op Fl f Ar format 10 | .Op Fl u | z Ar timezone 11 | .Ar command 12 | .Op Ar arg ... 13 | .Nm 14 | .Op options 15 | .Ar shell_command 16 | .Nm 17 | .Op options 18 | .Sh DESCRIPTION 19 | .Nm 20 | prefixes each line of a command's output with a timestamp. Lines are delimited 21 | by CR, LF, or CRLF. 22 | .Pp 23 | The three forms in 24 | .Sx SYNOPSIS 25 | correspond to three command execution modes: 26 | .Bl -bullet -width "" 27 | .It 28 | If given a single command without whitespace(s), or a command and its arguments, 29 | execute the command with exec in a pty; 30 | .It 31 | If given a single command with whitespace(s), the command is treated as a shell 32 | command and executed as 33 | .Sq SHELL -c Ar shell_command Ns , 34 | where SHELL is the current user's login shell, or sh if login shell cannot be 35 | determined; 36 | .It 37 | If given no command, output is read from stdin, and the user is responsible for 38 | piping in a command's output. 39 | .El 40 | .Pp 41 | There are three mutually exclusive timestamp modes: 42 | .Bl -bullet -width "" 43 | .It 44 | The default is absolute time mode, where timestamps from the wall clock are 45 | shown; 46 | .It 47 | .Fl s, -elapsed 48 | turns on elapsed time mode, where every timestamp is the time elapsed from the 49 | start of the command (using a monotonic clock); 50 | .It 51 | .Fl i, -incremental 52 | turns on incremental time mode, where every timestamp is the time elapsed since 53 | the last timestamp (using a monotonic clock). 54 | .El 55 | .Pp 56 | The default format of the prefixed timestamps depends on the timestamp mode 57 | active. Users may supply a custom format string with the 58 | .Fl f, -format 59 | option. 60 | .Pp 61 | The timezone for absolute timestamps can be controlled via the 62 | .Fl u, -utc 63 | and 64 | .Fl z, -timezone 65 | options. Local time is used by default. 66 | .Pp 67 | The full list of options: 68 | .Bl -tag -width -indent 69 | .It Fl s, -elapsed 70 | Run in elapsed time mode. 71 | .It Fl i, -incremental 72 | Run in incremental time mode. 73 | .It Fl f, -format Ar format 74 | Use custom 75 | .Xr strftime 3 Ns -style 76 | format string 77 | .Ar format 78 | for prefixed timestamps. 79 | .Pp 80 | The default is 81 | .Dq [%Y-%m-%d %H:%M:%S] 82 | for absolute time mode and 83 | .Dq [%H:%M:%S] 84 | for elapsed and incremental time modes. 85 | .Pp 86 | See 87 | .Sx FORMATTING DIRECTIVES 88 | for details. 89 | .It Fl u, -utc 90 | Use UTC for absolute timestamps instead of local time. 91 | .Pp 92 | This option is mutually exclusive with 93 | .Fl -z, -timezone Ns . 94 | .It Fl z, -timezone Ar timezone 95 | Use 96 | .Ar timezone 97 | for absolute timestamps instead of local time. 98 | .Ar timezone 99 | is an IANA time zone name, e.g. 100 | .Dq America/Los_Angeles Ns . 101 | .Pp 102 | This option is mutually exclusive with 103 | .Fl u, -utc Ns . 104 | .It Fl c, -color 105 | Print timestamps in color. 106 | .El 107 | .Sh FORMATTING DIRECTIVES 108 | Formatting directives largely match 109 | .Xr strftime 3 Ns 's directives 110 | on FreeBSD and macOS, with the following differences: 111 | .Bl -bullet -width "" 112 | .It 113 | Additional directives 114 | .Sy %f 115 | for microsecond and 116 | .Sy %L 117 | for millisecond are supported. 118 | .It 119 | POSIX locale extensions 120 | .Sy %E* 121 | and 122 | .Sy %O* 123 | are not supported; 124 | .It 125 | glibc extensions 126 | .Sy %-*, 127 | .Sy %_*, 128 | and 129 | .Sy %0* 130 | are not supported; 131 | .It 132 | Directives 133 | .Sy %G, 134 | .Sy %g, 135 | and 136 | .Sy %+ 137 | are not supported. 138 | .El 139 | .Pp 140 | Below is the full list of supported directives: 141 | .Bl -tag -width "xxxx" 142 | .It Cm \&%A 143 | is replaced by national representation of the full weekday name. 144 | .It Cm %a 145 | is replaced by national representation of 146 | the abbreviated weekday name. 147 | .It Cm \&%B 148 | is replaced by national representation of the full month name. 149 | .It Cm %b 150 | is replaced by national representation of 151 | the abbreviated month name. 152 | .It Cm \&%C 153 | is replaced by (year / 100) as decimal number; single 154 | digits are preceded by a zero. 155 | .It Cm %c 156 | is replaced by national representation of time and date. 157 | .It Cm \&%D 158 | is equivalent to 159 | .Dq Li %m/%d/%y . 160 | .It Cm %d 161 | is replaced by the day of the month as a decimal number (01-31). 162 | .It Cm %e 163 | is replaced by the day of the month as a decimal number (1-31); single 164 | digits are preceded by a blank. 165 | .It Cm \&%F 166 | is equivalent to 167 | .Dq Li %Y-%m-%d . 168 | .It Cm \&%f 169 | is replaced by the microsecond as a decimal number (000000-999999). 170 | .It Cm \&%H 171 | is replaced by the hour (24-hour clock) as a decimal number (00-23). 172 | .It Cm %h 173 | the same as 174 | .Cm %b . 175 | .It Cm \&%I 176 | is replaced by the hour (12-hour clock) as a decimal number (01-12). 177 | .It Cm %j 178 | is replaced by the day of the year as a decimal number (001-366). 179 | .It Cm %k 180 | is replaced by the hour (24-hour clock) as a decimal number (0-23); 181 | single digits are preceded by a blank. 182 | .It Cm \&%L 183 | is replaced by the millisecond as a decimal number (000-999). 184 | .It Cm %l 185 | is replaced by the hour (12-hour clock) as a decimal number (1-12); 186 | single digits are preceded by a blank. 187 | .It Cm \&%M 188 | is replaced by the minute as a decimal number (00-59). 189 | .It Cm %m 190 | is replaced by the month as a decimal number (01-12). 191 | .It Cm %n 192 | is replaced by a newline. 193 | .It Cm %p 194 | is replaced by national representation of either 195 | "ante meridiem" (a.m.) 196 | or 197 | "post meridiem" (p.m.) 198 | as appropriate. 199 | .It Cm \&%R 200 | is equivalent to 201 | .Dq Li %H:%M . 202 | .It Cm %r 203 | is equivalent to 204 | .Dq Li %I:%M:%S %p . 205 | .It Cm \&%S 206 | is replaced by the second as a decimal number (00-60). 207 | .It Cm %s 208 | is replaced by the number of seconds since the Epoch, UTC (see 209 | .Xr mktime 3 ) . 210 | .It Cm \&%T 211 | is equivalent to 212 | .Dq Li %H:%M:%S . 213 | .It Cm %t 214 | is replaced by a tab. 215 | .It Cm \&%U 216 | is replaced by the week number of the year (Sunday as the first day of 217 | the week) as a decimal number (00-53). 218 | .It Cm %u 219 | is replaced by the weekday (Monday as the first day of the week) 220 | as a decimal number (1-7). 221 | .It Cm \&%V 222 | is replaced by the week number of the year (Monday as the first day of 223 | the week) as a decimal number (01-53). 224 | If the week containing January 225 | 1 has four or more days in the new year, then it is week 1; otherwise 226 | it is the last week of the previous year, and the next week is week 1. 227 | .It Cm %v 228 | is equivalent to 229 | .Dq Li %e-%b-%Y . 230 | .It Cm \&%W 231 | is replaced by the week number of the year (Monday as the first day of 232 | the week) as a decimal number (00-53). 233 | .It Cm %w 234 | is replaced by the weekday (Sunday as the first day of the week) 235 | as a decimal number (0-6). 236 | .It Cm \&%X 237 | is replaced by national representation of the time. 238 | .It Cm %x 239 | is replaced by national representation of the date. 240 | .It Cm \&%Y 241 | is replaced by the year with century as a decimal number. 242 | .It Cm %y 243 | is replaced by the year without century as a decimal number (00-99). 244 | .It Cm \&%Z 245 | is replaced by the time zone name. 246 | .It Cm %z 247 | is replaced by the time zone offset from UTC; a leading plus sign stands for 248 | east of UTC, a minus sign for west of UTC, hours and minutes follow 249 | with two digits each and no delimiter between them (common form for 250 | RFC 822 date headers). 251 | .It Cm %% 252 | is replaced by 253 | .Ql % . 254 | .El 255 | .Sh SEE ALSO 256 | .Xr ts 1 , 257 | .Xr strftime 3 258 | .Sh HISTORY 259 | The name 260 | .Nm 261 | comes from 262 | .Dq enhanced ts Ns , 263 | referring to moreutils 264 | .Xr ts 1 Ns . 265 | .Sh AUTHORS 266 | .An Zhiming Wang Aq Mt i@zhimingwang.org 267 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "regexp" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/creack/pty" 17 | "github.com/mattn/go-runewidth" 18 | "github.com/riywo/loginshell" 19 | flag "github.com/spf13/pflag" 20 | ) 21 | 22 | var version = "unknown" 23 | 24 | // Regexp to strip ANSI escape sequences from string. Credit: 25 | // https://github.com/chalk/ansi-regex/blob/2b56fb0c7a07108e5b54241e8faec160d393aedb/index.js#L4-L7 26 | // https://github.com/acarl005/stripansi/blob/5a71ef0e047df0427e87a79f27009029921f1f9b/stripansi.go#L7 27 | var ansiEscapes = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))") 28 | 29 | func printStreamWithTimestamper(r io.Reader, timestamper *Timestamper) { 30 | scanner := bufio.NewScanner(r) 31 | // Split on \r\n|\r|\n, and return the line as well as the line ending (\r 32 | // or \n is preserved, \r\n is collapsed to \n). Adaptation of 33 | // bufio.ScanLines. 34 | scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { 35 | if atEOF && len(data) == 0 { 36 | return 0, nil, nil 37 | } 38 | lfpos := bytes.IndexByte(data, '\n') 39 | crpos := bytes.IndexByte(data, '\r') 40 | if crpos >= 0 { 41 | if lfpos < 0 || lfpos > crpos+1 { 42 | // We have a CR-terminated "line". 43 | return crpos + 1, data[0 : crpos+1], nil 44 | } 45 | if lfpos == crpos+1 { 46 | // We have a CRLF-terminated line. 47 | return lfpos + 1, append(data[0:crpos], '\n'), nil 48 | } 49 | } 50 | if lfpos >= 0 { 51 | // We have a LF-terminated line. 52 | return lfpos + 1, data[0 : lfpos+1], nil 53 | } 54 | // If we're at EOF, we have a final, non-terminated line. Return it. 55 | if atEOF { 56 | return len(data), data, nil 57 | } 58 | // Request more data. 59 | return 0, nil, nil 60 | }) 61 | for scanner.Scan() { 62 | fmt.Print(timestamper.CurrentTimestampString(), " ", scanner.Text()) 63 | } 64 | } 65 | 66 | func runCommandWithTimestamper(args []string, timestamper *Timestamper) error { 67 | // Calculate optimal pty size, taking into account horizontal space taken up by timestamps. 68 | getPtyWinsize := func() *pty.Winsize { 69 | winsize, err := pty.GetsizeFull(os.Stdin) 70 | if err != nil { 71 | // Most likely stdin isn't a tty, in which case we don't care. 72 | return winsize 73 | } 74 | totalCols := winsize.Cols 75 | plainTimestampString := ansiEscapes.ReplaceAllString(timestamper.CurrentTimestampString(), "") 76 | // Timestamp width along with one space character. 77 | occupiedWidth := uint16(runewidth.StringWidth(plainTimestampString)) + 1 78 | var effectiveCols uint16 = 0 79 | if occupiedWidth < totalCols { 80 | effectiveCols = totalCols - occupiedWidth 81 | } 82 | winsize.Cols = effectiveCols 83 | // Best effort estimate of the effective width in pixels. 84 | if totalCols > 0 { 85 | winsize.X = winsize.X * effectiveCols / totalCols 86 | } 87 | return winsize 88 | } 89 | 90 | command := exec.Command(args[0], args[1:]...) 91 | ptmx, err := pty.StartWithSize(command, getPtyWinsize()) 92 | if err != nil { 93 | return err 94 | } 95 | defer func() { _ = ptmx.Close() }() 96 | 97 | sigs := make(chan os.Signal, 1) 98 | signal.Notify(sigs, syscall.SIGWINCH, syscall.SIGINT, syscall.SIGTERM) 99 | go func() { 100 | for sig := range sigs { 101 | switch sig { 102 | case syscall.SIGWINCH: 103 | if err := pty.Setsize(ptmx, getPtyWinsize()); err != nil { 104 | log.Println("error resizing pty:", err) 105 | } 106 | 107 | case syscall.SIGINT: 108 | _ = syscall.Kill(-command.Process.Pid, syscall.SIGINT) 109 | 110 | case syscall.SIGTERM: 111 | _ = syscall.Kill(-command.Process.Pid, syscall.SIGTERM) 112 | 113 | default: 114 | } 115 | } 116 | }() 117 | sigs <- syscall.SIGWINCH 118 | 119 | go func() { _, _ = io.Copy(ptmx, os.Stdin) }() 120 | 121 | printStreamWithTimestamper(ptmx, timestamper) 122 | 123 | return command.Wait() 124 | } 125 | 126 | func main() { 127 | log.SetFlags(log.Flags() &^ (log.Ldate | log.Ltime)) 128 | 129 | var elapsedMode = flag.BoolP("elapsed", "s", false, "show elapsed timestamps") 130 | var incrementalMode = flag.BoolP("incremental", "i", false, "show incremental timestamps") 131 | var format = flag.StringP("format", "f", "", "show timestamps in this format") 132 | var utc = flag.BoolP("utc", "u", false, "show absolute timestamps in UTC") 133 | var timezoneName = flag.StringP("timezone", "z", "", "show absolute timestamps in this timezone, e.g. America/New_York") 134 | var color = flag.BoolP("color", "c", false, "show timestamps in color") 135 | var printHelp = flag.BoolP("help", "h", false, "print help and exit") 136 | var printVersion = flag.BoolP("version", "v", false, "print version and exit") 137 | flag.CommandLine.SortFlags = false 138 | flag.SetInterspersed(false) 139 | flag.Usage = func() { 140 | fmt.Fprintf(os.Stderr, ` 141 | ets -- command output timestamper 142 | 143 | ets prefixes each line of a command's output with a timestamp. Lines are 144 | delimited by CR, LF, or CRLF. 145 | 146 | Usage: 147 | 148 | %s [-s | -i] [-f format] [-u | -z timezone] command [arg ...] 149 | %s [options] shell_command 150 | %s [options] 151 | 152 | The three usage strings correspond to three command execution modes: 153 | 154 | * If given a single command without whitespace(s), or a command and its 155 | arguments, execute the command with exec in a pty; 156 | 157 | * If given a single command with whitespace(s), the command is treated as 158 | a shell command and executed as SHELL -c shell_command, where SHELL is 159 | the current user's login shell, or sh if login shell cannot be determined; 160 | 161 | * If given no command, output is read from stdin, and the user is 162 | responsible for piping in a command's output. 163 | 164 | There are three mutually exclusive timestamp modes: 165 | 166 | * The default is absolute time mode, where timestamps from the wall clock 167 | are shown; 168 | 169 | * -s, --elapsed turns on elapsed time mode, where every timestamp is the 170 | time elapsed from the start of the command (using a monotonic clock); 171 | 172 | * -i, --incremental turns on incremental time mode, where every timestamp is 173 | the time elapsed since the last timestamp (using a monotonic clock). 174 | 175 | The default format of the prefixed timestamps depends on the timestamp mode 176 | active. Users may supply a custom format string with the -f, --format option. 177 | The format string is basically a strftime(3) format string; see the man page 178 | or README for details on supported formatting directives. 179 | 180 | The timezone for absolute timestamps can be controlled via the -u, --utc 181 | and -z, --timezone options. --timezone accepts IANA time zone names, e.g., 182 | America/Los_Angeles. Local time is used by default. 183 | 184 | Options: 185 | `, os.Args[0], os.Args[0], os.Args[0]) 186 | flag.PrintDefaults() 187 | } 188 | flag.Parse() 189 | 190 | if *printHelp { 191 | flag.Usage() 192 | os.Exit(0) 193 | } 194 | 195 | if *printVersion { 196 | fmt.Println(version) 197 | os.Exit(0) 198 | } 199 | 200 | mode := AbsoluteTimeMode 201 | if *elapsedMode && *incrementalMode { 202 | log.Fatal("conflicting flags --elapsed and --incremental") 203 | } 204 | if *elapsedMode { 205 | mode = ElapsedTimeMode 206 | } 207 | if *incrementalMode { 208 | mode = IncrementalTimeMode 209 | } 210 | if *format == "" { 211 | if mode == AbsoluteTimeMode { 212 | *format = "[%F %T]" 213 | } else { 214 | *format = "[%T]" 215 | } 216 | } 217 | timezone := time.Local 218 | if *utc && *timezoneName != "" { 219 | log.Fatal("conflicting flags --utc and --timezone") 220 | } 221 | if *utc { 222 | timezone = time.UTC 223 | } 224 | if *timezoneName != "" { 225 | location, err := time.LoadLocation(*timezoneName) 226 | if err != nil { 227 | log.Fatal(err) 228 | } 229 | timezone = location 230 | } 231 | if *color { 232 | *format = "\x1b[32m" + *format + "\x1b[0m" 233 | } 234 | args := flag.Args() 235 | 236 | timestamper, err := NewTimestamper(*format, mode, timezone) 237 | if err != nil { 238 | log.Fatal(err) 239 | } 240 | 241 | exitCode := 0 242 | if len(args) == 0 { 243 | printStreamWithTimestamper(os.Stdin, timestamper) 244 | } else { 245 | if len(args) == 1 { 246 | arg0 := args[0] 247 | if matched, _ := regexp.MatchString(`\s`, arg0); matched { 248 | shell, err := loginshell.Shell() 249 | if err != nil { 250 | shell = "sh" 251 | } 252 | args = []string{shell, "-c", arg0} 253 | } 254 | } 255 | if err = runCommandWithTimestamper(args, timestamper); err != nil { 256 | if exitErr, ok := err.(*exec.ExitError); ok { 257 | exitCode = exitErr.ExitCode() 258 | } else { 259 | log.Fatal(err) 260 | } 261 | } 262 | } 263 | os.Exit(exitCode) 264 | } 265 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "reflect" 10 | "regexp" 11 | "runtime" 12 | "strconv" 13 | "strings" 14 | "syscall" 15 | "testing" 16 | "time" 17 | 18 | "github.com/creack/pty" 19 | ) 20 | 21 | var rootdir string 22 | var tempdir string 23 | var executable string 24 | 25 | func init() { 26 | _, currentFile, _, _ := runtime.Caller(0) 27 | rootdir = path.Dir(currentFile) 28 | } 29 | 30 | func compile(moduledir string, output string) { 31 | cmd := exec.Command("go", "build", "-o", output) 32 | cmd.Dir = moduledir 33 | if err := cmd.Run(); err != nil { 34 | log.Fatalf("failed to compile %s: %s", moduledir, err) 35 | } 36 | } 37 | 38 | func TestMain(m *testing.M) { 39 | var retcode int 40 | var err error 41 | 42 | defer func() { os.Exit(retcode) }() 43 | 44 | tempdir, err = ioutil.TempDir("", "*") 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | defer os.RemoveAll(tempdir) 49 | 50 | executable = path.Join(tempdir, "ets") 51 | 52 | // Build ets and test fixtures to tempdir. 53 | compile(rootdir, executable) 54 | fixturesdir := path.Join(rootdir, "fixtures") 55 | content, err := ioutil.ReadDir(fixturesdir) 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | for _, entry := range content { 60 | if entry.IsDir() { 61 | name := entry.Name() 62 | compile(path.Join(fixturesdir, name), path.Join(tempdir, name)) 63 | } 64 | } 65 | 66 | err = os.Chdir(tempdir) 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | 71 | retcode = m.Run() 72 | } 73 | 74 | type parsedLine struct { 75 | raw string 76 | prefix string 77 | output string 78 | captures map[string]string 79 | } 80 | 81 | func parseOutput(output []byte, prefixPattern string) []*parsedLine { 82 | linePattern := regexp.MustCompile(`^(?P` + prefixPattern + `) (?P.*)$`) 83 | lines := strings.Split(string(output), "\n") 84 | if lines[len(lines)-1] == "" { 85 | lines = lines[:len(lines)-1] // Drop final empty line. 86 | } 87 | parsed := make([]*parsedLine, 0) 88 | for _, line := range lines { 89 | // Drop final CR if there is one. 90 | if line != "" && line[len(line)-1] == '\r' { 91 | line = line[:len(line)-1] 92 | } 93 | m := linePattern.FindStringSubmatch(line) 94 | if m == nil { 95 | parsed = append(parsed, &parsedLine{ 96 | raw: line, 97 | prefix: "", 98 | output: "", 99 | captures: nil, 100 | }) 101 | } else { 102 | captures := make(map[string]string) 103 | for i, name := range linePattern.SubexpNames() { 104 | if i != 0 && name != "" { 105 | captures[name] = m[i] 106 | } 107 | } 108 | parsed = append(parsed, &parsedLine{ 109 | raw: line, 110 | prefix: captures["prefix"], 111 | output: captures["output"], 112 | captures: captures, 113 | }) 114 | } 115 | } 116 | return parsed 117 | } 118 | 119 | func TestBasic(t *testing.T) { 120 | defaultOutputs := []string{"out1", "err1", "out2", "err2", "out3", "err3"} 121 | tests := []struct { 122 | name string 123 | args []string 124 | prefixPattern string 125 | expectedOutputs []string 126 | }{ 127 | { 128 | "basic", 129 | []string{"./basic"}, 130 | `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`, 131 | defaultOutputs, 132 | }, 133 | { 134 | "basic-format", 135 | []string{"-f", "%m/%d/%y %T:", "./basic"}, 136 | `\d{2}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}:`, 137 | defaultOutputs, 138 | }, 139 | { 140 | "basic-elapsed", 141 | []string{"-s", "./basic"}, 142 | `\[00:00:00\]`, 143 | defaultOutputs, 144 | }, 145 | { 146 | "basic-elapsed-format", 147 | []string{"-s", "-f", "%T.%f", "./basic"}, 148 | `00:00:00\.\d{6}`, 149 | defaultOutputs, 150 | }, 151 | { 152 | "basic-incremental", 153 | []string{"-i", "./basic"}, 154 | `\[00:00:00\]`, 155 | defaultOutputs, 156 | }, 157 | { 158 | "basic-incremental-format", 159 | []string{"-i", "-f", "%T.%f", "./basic"}, 160 | `00:00:00\.\d{6}`, 161 | defaultOutputs, 162 | }, 163 | { 164 | "basic-utc-format", 165 | []string{"-u", "-f", "[%F %T%z]", "./basic"}, 166 | `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\+0000\]`, 167 | defaultOutputs, 168 | }, 169 | { 170 | "basic-timezone-format", 171 | []string{"-z", "America/Los_Angeles", "-f", "[%F %T %Z]", "./basic"}, 172 | `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} P[DS]T\]`, 173 | defaultOutputs, 174 | }, 175 | { 176 | "basic-shell", 177 | []string{"./basic 2>/dev/null | nl -w1 -s' '"}, 178 | `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`, 179 | []string{"1 out1", "2 out2", "3 out3"}, 180 | }, 181 | } 182 | for _, test := range tests { 183 | t.Run(test.name, func(t *testing.T) { 184 | cmd := exec.Command("./ets", test.args...) 185 | output, err := cmd.Output() 186 | if err != nil { 187 | t.Fatalf("command failed: %s", err) 188 | } 189 | parsed := parseOutput(output, test.prefixPattern) 190 | outputs := make([]string, 0) 191 | for _, pl := range parsed { 192 | if pl.prefix == "" { 193 | t.Errorf("unexpected line: %s", pl.raw) 194 | } 195 | outputs = append(outputs, pl.output) 196 | } 197 | if !reflect.DeepEqual(outputs, test.expectedOutputs) { 198 | t.Fatalf("wrong outputs: expected %#v, got %#v", test.expectedOutputs, outputs) 199 | } 200 | }) 201 | } 202 | } 203 | 204 | func TestCR(t *testing.T) { 205 | cmd := exec.Command("./ets", "-f", "[timestamp]", "echo '1\r2'") 206 | expectedOutput := "[timestamp] 1\r[timestamp] 2\n" 207 | output, err := cmd.Output() 208 | if err != nil { 209 | t.Fatalf("command failed: %s", err) 210 | } 211 | if string(output) != expectedOutput { 212 | t.Fatalf("wrong output: expected %#v, got %#v", expectedOutput, string(output)) 213 | } 214 | } 215 | 216 | func TestStdin(t *testing.T) { 217 | input := "out1\nout2\nout3\n" 218 | expectedOutputs := []string{"out1", "out2", "out3"} 219 | cmd := exec.Command("./ets") 220 | stdin, _ := cmd.StdinPipe() 221 | go func() { 222 | defer stdin.Close() 223 | _, _ = stdin.Write([]byte(input)) 224 | }() 225 | output, err := cmd.Output() 226 | if err != nil { 227 | t.Fatalf("command failed: %s", err) 228 | } 229 | parsed := parseOutput(output, `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`) 230 | outputs := make([]string, 0) 231 | for _, pl := range parsed { 232 | if pl.prefix == "" { 233 | t.Errorf("unexpected line: %s", pl.raw) 234 | } 235 | outputs = append(outputs, pl.output) 236 | } 237 | if !reflect.DeepEqual(outputs, expectedOutputs) { 238 | t.Fatalf("wrong outputs: expected %#v, got %#v", expectedOutputs, outputs) 239 | } 240 | } 241 | 242 | func TestElapsedMode(t *testing.T) { 243 | if testing.Short() { 244 | t.Skip("skipping slow test in short mode") 245 | } 246 | expectedOutput := "[1] out1\n[2] out2\n[3] out3\n" 247 | cmd := exec.Command("./ets", "-s", "-f", "[%s]", "./timed") 248 | output, err := cmd.Output() 249 | if err != nil { 250 | t.Fatalf("command failed: %s", err) 251 | } 252 | if string(output) != expectedOutput { 253 | t.Fatalf("wrong output: expected %#v, got %#v", expectedOutput, string(output)) 254 | } 255 | } 256 | 257 | func TestIncrementalMode(t *testing.T) { 258 | if testing.Short() { 259 | t.Skip("skipping slow test in short mode") 260 | } 261 | expectedOutput := "[1] out1\n[1] out2\n[1] out3\n" 262 | cmd := exec.Command("./ets", "-i", "-f", "[%s]", "./timed") 263 | output, err := cmd.Output() 264 | if err != nil { 265 | t.Fatalf("command failed: %s", err) 266 | } 267 | if string(output) != expectedOutput { 268 | t.Fatalf("wrong output: expected %#v, got %#v", expectedOutput, string(output)) 269 | } 270 | } 271 | 272 | func TestExitCode(t *testing.T) { 273 | for code := 1; code < 6; code++ { 274 | t.Run("exitcode-"+strconv.Itoa(code), func(t *testing.T) { 275 | cmd := exec.Command("./ets", "./basic", "-exitcode", strconv.Itoa(code)) 276 | err := cmd.Run() 277 | errExit, ok := err.(*exec.ExitError) 278 | if !ok { 279 | t.Fatalf("expected ExitError, got %#v", err) 280 | } 281 | if errExit.ExitCode() != code { 282 | t.Fatalf("expected exit code %d, got %d", code, errExit.ExitCode()) 283 | } 284 | }) 285 | } 286 | } 287 | 288 | func TestSignals(t *testing.T) { 289 | if testing.Short() { 290 | t.Skip("skipping slow test in short mode") 291 | } 292 | cmd := exec.Command("./ets", "./signals") 293 | go func() { 294 | time.Sleep(time.Second) 295 | _ = cmd.Process.Signal(syscall.SIGINT) 296 | time.Sleep(time.Second) 297 | _ = cmd.Process.Signal(syscall.SIGTERM) 298 | }() 299 | output, err := cmd.Output() 300 | if err != nil { 301 | t.Fatalf("command failed: %s", err) 302 | } 303 | parsed := parseOutput(output, `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`) 304 | outputs := make([]string, 0) 305 | for _, pl := range parsed { 306 | if pl.prefix == "" { 307 | t.Errorf("unexpected line: %s", pl.raw) 308 | } 309 | outputs = append(outputs, pl.output) 310 | } 311 | for _, expectedOutput := range []string{ 312 | "busy waiting", 313 | "ignored SIGINT", 314 | "shutting down after receiving SIGTERM", 315 | } { 316 | found := false 317 | for _, output := range outputs { 318 | if output == expectedOutput { 319 | found = true 320 | break 321 | } 322 | } 323 | if !found { 324 | t.Errorf("expected output %#v not found in outputs %#v", expectedOutput, outputs) 325 | } 326 | } 327 | } 328 | 329 | func TestWindowSize(t *testing.T) { 330 | tests := []struct { 331 | name string 332 | args []string 333 | prefixPattern string 334 | rows uint16 335 | cols uint16 336 | expectedOutput string 337 | }{ 338 | { 339 | "default", 340 | []string{"./winsize"}, 341 | `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`, 342 | 24, 343 | 80, 344 | "58x24", 345 | }, 346 | { 347 | "color", 348 | []string{"-f", "\x1b[32m[%Y-%m-%d %H:%M:%S]\x1b[0m", "./winsize"}, 349 | `\x1b\[32m\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\x1b\[0m`, 350 | 24, 351 | 80, 352 | "58x24", 353 | }, 354 | { 355 | "wide-chars", 356 | []string{"-f", "[时间 %Y-%m-%d %H:%M:%S]", "./winsize"}, 357 | `\[时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`, 358 | 24, 359 | 80, 360 | "53x24", 361 | }, 362 | { 363 | "narrow-terminal", 364 | []string{"./winsize"}, 365 | `\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]`, 366 | 24, 367 | 10, 368 | "0x24", 369 | }, 370 | } 371 | for _, test := range tests { 372 | t.Run(test.name, func(t *testing.T) { 373 | expectedOutputs := []string{test.expectedOutput} 374 | cmd := exec.Command("./ets", test.args...) 375 | ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: test.rows, Cols: test.cols, X: 0, Y: 0}) 376 | if err != nil { 377 | t.Fatalf("failed to start command in pty: %s", err) 378 | } 379 | defer func() { _ = ptmx.Close() }() 380 | output, err := ioutil.ReadAll(ptmx) 381 | // TODO: figure out why we get &os.PathError{Op:"read", Path:"/dev/ptmx", Err:0x5} on Linux. 382 | // https://github.com/creack/pty/issues/100 383 | if len(output) == 0 && err != nil { 384 | t.Fatalf("failed to read pty output: %s", err) 385 | } 386 | parsed := parseOutput(output, test.prefixPattern) 387 | outputs := make([]string, 0) 388 | for _, pl := range parsed { 389 | if pl.prefix == "" { 390 | t.Errorf("unexpected line: %s", pl.raw) 391 | } 392 | outputs = append(outputs, pl.output) 393 | } 394 | if !reflect.DeepEqual(outputs, expectedOutputs) { 395 | t.Fatalf("wrong outputs: expected %#v, got %#v", expectedOutputs, outputs) 396 | } 397 | }) 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

ets

2 | 3 |

4 | GitHub release 5 | Build status 6 |

7 | 8 |

ets

9 |

ets

10 | 11 | `ets` is a command output timestamper — it prefixes each line of a command's output with a timestamp. 12 | 13 | The purpose of `ets` is similar to that of moreutils [`ts(1)`](https://manpages.ubuntu.com/manpages/focal/en/man1/ts.1.html), but `ets` differentiates itself from similar offerings by running commands directly within ptys, hence solving thorny issues like pipe buffering and commands disabling color and interactive features when detecting a pipe as output. (`ets` does provide a reading-from-stdin mode if you insist.) `ets` also recognizes carriage return as a line seperator, so it doesn't choke if your command prints a progress bar. A more detailed comparison of `ets` and `ts` can be found [below](#comparison-to-moreutils-ts). 14 | 15 | `ets` currently supports macOS, Linux, and various other *ix variants. 16 | 17 | 18 | 19 | 20 | 21 | - [Examples](#examples) 22 | - [Installation](#installation) 23 | - [Usage](#usage) 24 | - [Comparison to moreutils ts](#comparison-to-moreutils-ts) 25 | - [License](#license) 26 | 27 | 28 | 29 | ## Examples 30 | 31 | Run a command with `ets`: 32 | 33 | ```console 34 | $ ets ping localhost 35 | [2020-06-16 17:13:03] PING localhost (127.0.0.1): 56 data bytes 36 | [2020-06-16 17:13:03] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.036 ms 37 | [2020-06-16 17:13:04] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.077 ms 38 | [2020-06-16 17:13:05] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.037 ms 39 | ... 40 | ``` 41 | 42 | Run a shell command: 43 | 44 | ```console 45 | $ ets 'ping localhost | grep icmp' 46 | [2020-06-16 17:13:03] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.036 ms 47 | [2020-06-16 17:13:04] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.077 ms 48 | [2020-06-16 17:13:05] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.037 ms 49 | ... 50 | ``` 51 | 52 | Pipe command output into stdin: 53 | 54 | ```console 55 | $ ping localhost | grep icmp | ets 56 | [2020-06-16 17:13:03] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.036 ms 57 | [2020-06-16 17:13:04] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.077 ms 58 | [2020-06-16 17:13:05] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.037 ms 59 | ... 60 | ``` 61 | 62 | Show elapsed time: 63 | 64 | ```console 65 | $ ets -s ping -i5 localhost 66 | [00:00:00] PING localhost (127.0.0.1): 56 data bytes 67 | [00:00:00] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.039 ms 68 | [00:00:05] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.031 ms 69 | [00:00:10] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.030 ms 70 | [00:00:15] 64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.045 ms 71 | ... 72 | ``` 73 | 74 | Show incremental time (since last timestamp): 75 | 76 | ```console 77 | $ ets -i ping -i5 localhost 78 | [00:00:00] PING localhost (127.0.0.1): 56 data bytes 79 | [00:00:00] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.043 ms 80 | [00:00:05] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.047 ms 81 | [00:00:05] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.116 ms 82 | [00:00:05] 64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.071 ms 83 | ... 84 | ``` 85 | 86 | Use a different timestamp format: 87 | 88 | ```console 89 | $ ets -f '%b %d %T|' ping localhost 90 | Jun 16 17:13:03| PING localhost (127.0.0.1): 56 data bytes 91 | Jun 16 17:13:03| 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.036 ms 92 | Jun 16 17:13:04| 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.077 ms 93 | Jun 16 17:13:05| 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.037 ms 94 | ... 95 | ``` 96 | 97 | Millisecond precision (microsecond available too): 98 | 99 | ```console 100 | $ ets -s -f '[%T.%L]' ping -i 0.1 localhost 101 | [00:00:00.004] PING localhost (127.0.0.1): 56 data bytes 102 | [00:00:00.004] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.032 ms 103 | [00:00:00.108] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.038 ms 104 | [00:00:00.209] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.051 ms 105 | [00:00:00.311] 64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.049 ms 106 | ... 107 | ``` 108 | 109 | Use a different timezone: 110 | 111 | ```console 112 | $ ets ping localhost # UTC 113 | [2020-06-16 09:13:03] PING localhost (127.0.0.1): 56 data bytes 114 | [2020-06-16 09:13:03] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.036 ms 115 | [2020-06-16 09:13:04] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.077 ms 116 | [2020-06-16 09:13:05] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.037 ms 117 | ``` 118 | 119 | ```console 120 | $ ets -z America/Los_Angeles -f '[%F %T%z]' ping localhost 121 | [2020-06-16 02:13:03-0700] PING localhost (127.0.0.1): 56 data bytes 122 | [2020-06-16 02:13:03-0700] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.036 ms 123 | [2020-06-16 02:13:04-0700] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.077 ms 124 | [2020-06-16 02:13:05-0700] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.037 ms 125 | ``` 126 | 127 | Color the timestamps: 128 | 129 | ```console 130 | $ ets -c ping localhost 131 | ... 132 | ``` 133 | 134 | ## Installation 135 | 136 | - On macOS you can install ets with Homebrew: 137 | 138 | ``` 139 | brew tap zmwangx/ets https://github.com/zmwangx/ets 140 | brew install zmwangx/ets/ets 141 | ``` 142 | 143 | - On macOS and Linux you get download a prebuilt tarball/package from the [release page](https://github.com/zmwangx/ets/releases). 144 | 145 | - On Arch Linux you can install the [ets-bin](https://aur.archlinux.org/packages/ets-bin/) binary package from AUR: 146 | 147 | ```sh 148 | pacman -S ets-bin 149 | # or 150 | yay -S ets-bin 151 | ``` 152 | 153 | - On a supported platform, if you have the Go toolchain installed, you may install with 154 | 155 | ``` 156 | go get github.com/zmwangx/ets 157 | ``` 158 | 159 | ## Usage 160 | 161 | 162 | 163 | ``` 164 | 165 | ETS(1) BSD General Commands Manual ETS(1) 166 | 167 | NAME 168 | ets -- command output timestamper 169 | 170 | SYNOPSIS 171 | ets [-s | -i] [-f format] [-u | -z timezone] command [arg ...] 172 | ets [options] shell_command 173 | ets [options] 174 | 175 | DESCRIPTION 176 | ets prefixes each line of a command's output with a timestamp. Lines are 177 | delimited by CR, LF, or CRLF. 178 | 179 | The three forms in SYNOPSIS correspond to three command execution modes: 180 | 181 | o If given a single command without whitespace(s), or a command and its 182 | arguments, execute the command with exec in a pty; 183 | 184 | o If given a single command with whitespace(s), the command is treated as 185 | a shell command and executed as `SHELL -c shell_command', where SHELL 186 | is the current user's login shell, or sh if login shell cannot be 187 | determined; 188 | 189 | o If given no command, output is read from stdin, and the user is respon- 190 | sible for piping in a command's output. 191 | 192 | There are three mutually exclusive timestamp modes: 193 | 194 | o The default is absolute time mode, where timestamps from the wall clock 195 | are shown; 196 | 197 | o -s, --elapsed turns on elapsed time mode, where every timestamp is the 198 | time elapsed from the start of the command (using a monotonic clock); 199 | 200 | o -i, --incremental turns on incremental time mode, where every timestamp 201 | is the time elapsed since the last timestamp (using a monotonic clock). 202 | 203 | The default format of the prefixed timestamps depends on the timestamp 204 | mode active. Users may supply a custom format string with the -f, 205 | --format option. 206 | 207 | The timezone for absolute timestamps can be controlled via the -u, --utc 208 | and -z, --timezone options. Local time is used by default. 209 | 210 | The full list of options: 211 | 212 | -s, --elapsed 213 | Run in elapsed time mode. 214 | 215 | -i, --incremental 216 | Run in incremental time mode. 217 | 218 | -f, --format format 219 | Use custom strftime(3)-style format string format for prefixed 220 | timestamps. 221 | 222 | The default is ``[%Y-%m-%d %H:%M:%S]'' for absolute time mode 223 | and ``[%H:%M:%S]'' for elapsed and incremental time modes. 224 | 225 | See FORMATTING DIRECTIVES for details. 226 | 227 | -u, --utc 228 | Use UTC for absolute timestamps instead of local time. 229 | 230 | This option is mutually exclusive with --z, --timezone. 231 | 232 | -z, --timezone timezone 233 | Use timezone for absolute timestamps instead of local time. 234 | timezone is an IANA time zone name, e.g. 235 | ``America/Los_Angeles''. 236 | 237 | This option is mutually exclusive with -u, --utc. 238 | 239 | -c, --color 240 | Print timestamps in color. 241 | 242 | FORMATTING DIRECTIVES 243 | Formatting directives largely match strftime(3)'s directives on FreeBSD 244 | and macOS, with the following differences: 245 | 246 | o Additional directives %f for microsecond and %L for millisecond are 247 | supported. 248 | 249 | o POSIX locale extensions %E* and %O* are not supported; 250 | 251 | o glibc extensions %-*, %_*, and %0* are not supported; 252 | 253 | o Directives %G, %g, and %+ are not supported. 254 | 255 | Below is the full list of supported directives: 256 | 257 | %A is replaced by national representation of the full weekday name. 258 | 259 | %a is replaced by national representation of the abbreviated weekday 260 | name. 261 | 262 | %B is replaced by national representation of the full month name. 263 | 264 | %b is replaced by national representation of the abbreviated month 265 | name. 266 | 267 | %C is replaced by (year / 100) as decimal number; single digits are 268 | preceded by a zero. 269 | 270 | %c is replaced by national representation of time and date. 271 | 272 | %D is equivalent to ``%m/%d/%y''. 273 | 274 | %d is replaced by the day of the month as a decimal number (01-31). 275 | 276 | %e is replaced by the day of the month as a decimal number (1-31); 277 | single digits are preceded by a blank. 278 | 279 | %F is equivalent to ``%Y-%m-%d''. 280 | 281 | %f is replaced by the microsecond as a decimal number (000000-999999). 282 | 283 | %H is replaced by the hour (24-hour clock) as a decimal number 284 | (00-23). 285 | 286 | %h the same as %b. 287 | 288 | %I is replaced by the hour (12-hour clock) as a decimal number 289 | (01-12). 290 | 291 | %j is replaced by the day of the year as a decimal number (001-366). 292 | 293 | %k is replaced by the hour (24-hour clock) as a decimal number (0-23); 294 | single digits are preceded by a blank. 295 | 296 | %L is replaced by the millisecond as a decimal number (000-999). 297 | 298 | %l is replaced by the hour (12-hour clock) as a decimal number (1-12); 299 | single digits are preceded by a blank. 300 | 301 | %M is replaced by the minute as a decimal number (00-59). 302 | 303 | %m is replaced by the month as a decimal number (01-12). 304 | 305 | %n is replaced by a newline. 306 | 307 | %p is replaced by national representation of either "ante meridiem" 308 | (a.m.) or "post meridiem" (p.m.) as appropriate. 309 | 310 | %R is equivalent to ``%H:%M''. 311 | 312 | %r is equivalent to ``%I:%M:%S %p''. 313 | 314 | %S is replaced by the second as a decimal number (00-60). 315 | 316 | %s is replaced by the number of seconds since the Epoch, UTC (see 317 | mktime(3)). 318 | 319 | %T is equivalent to ``%H:%M:%S''. 320 | 321 | %t is replaced by a tab. 322 | 323 | %U is replaced by the week number of the year (Sunday as the first day 324 | of the week) as a decimal number (00-53). 325 | 326 | %u is replaced by the weekday (Monday as the first day of the week) as 327 | a decimal number (1-7). 328 | 329 | %V is replaced by the week number of the year (Monday as the first day 330 | of the week) as a decimal number (01-53). If the week containing 331 | January 1 has four or more days in the new year, then it is week 1; 332 | otherwise it is the last week of the previous year, and the next 333 | week is week 1. 334 | 335 | %v is equivalent to ``%e-%b-%Y''. 336 | 337 | %W is replaced by the week number of the year (Monday as the first day 338 | of the week) as a decimal number (00-53). 339 | 340 | %w is replaced by the weekday (Sunday as the first day of the week) as 341 | a decimal number (0-6). 342 | 343 | %X is replaced by national representation of the time. 344 | 345 | %x is replaced by national representation of the date. 346 | 347 | %Y is replaced by the year with century as a decimal number. 348 | 349 | %y is replaced by the year without century as a decimal number 350 | (00-99). 351 | 352 | %Z is replaced by the time zone name. 353 | 354 | %z is replaced by the time zone offset from UTC; a leading plus sign 355 | stands for east of UTC, a minus sign for west of UTC, hours and 356 | minutes follow with two digits each and no delimiter between them 357 | (common form for RFC 822 date headers). 358 | 359 | %% is replaced by `%'. 360 | 361 | SEE ALSO 362 | ts(1), strftime(3) 363 | 364 | HISTORY 365 | The name ets comes from ``enhanced ts'', referring to moreutils ts(1). 366 | 367 | AUTHORS 368 | Zhiming Wang 369 | 370 | December 24, 2024 371 | ``` 372 | 373 | 374 | 375 | ## Comparison to moreutils ts 376 | 377 | Advantages: 378 | 379 | - Runs commands in ptys, making ets mostly transparent and avoiding pipe-related issues like buffering and lost coloring and interactivity. 380 | - Recognizes carriage return as line separator, does not choke on progress bars. 381 | - Has better operating defaults (uses monotonic clock where appropriate) and better formatting defaults (subjective). 382 | - Supports alternative time zones. 383 | - Is written in Go, not Perl, so you install a single executable, not script plus modules. 384 | - Has an executable name that doesn't conflict with other known packages. moreutils as a whole is a conflicting hell, and ts alone conflicts with at least task-spooler. 385 | 386 | Disadvantages: 387 | 388 | - Needs an additional `-f` for format string, because ets reserves positional arguments for its core competency. Hopefully offset by better default. 389 | - Does not support the `-r` mode of ts. It's a largely unrelated mode of operation and I couldn't even get `ts -r` to work anywhere, maybe because optional dependencies aren't satisfied, or maybe I misunderstood the feature altogether. Anyway, not interested. 390 | - Supports fewer formatting directives. Let me know if this is actually an issue, it could be fixable. 391 | 392 | ## License 393 | 394 | Copyright © 2020 Zhiming Wang 395 | 396 | The project is distributed under [the MIT license](https://opensource.org/licenses/MIT). 397 | 398 | Special thanks to DinosoftLab on None Project for the [hourglass icon](https://thenounproject.com/term/hourglass/1674538/) used in the logo, and [termtosvg](https://github.com/nbedos/termtosvg) for the animated terminal recording. 399 | -------------------------------------------------------------------------------- /assets/animation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 64 | 87 | 88 | 89 | 90 | 91 | 92 | % % e % et % ets % ets 'ping localhost | lolcat' % ets 'ping localhost | lolcat' [2020-06-16 16:57:49] PING localhost (127.0.0.1): 56 data bytes[2020-06-16 16:57:49] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.038 ms[2020-06-16 16:57:50] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.068 ms[2020-06-16 16:57:51] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.058 ms[2020-06-16 16:57:52] 64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.033 ms[2020-06-16 16:57:53] 64 bytes from 127.0.0.1: icmp_seq=4 ttl=64 time=0.080 ms[2020-06-16 16:57:54] 64 bytes from 127.0.0.1: icmp_seq=5 ttl=64 time=0.115 ms[2020-06-16 16:57:55] 64 bytes from 127.0.0.1: icmp_seq=6 ttl=64 time=0.041 ms[2020-06-16 16:57:56] 64 bytes from 127.0.0.1: icmp_seq=7 ttl=64 time=0.051 ms[2020-06-16 16:57:57] 64 bytes from 127.0.0.1: icmp_seq=8 ttl=64 time=0.067 ms[2020-06-16 16:57:58] 64 bytes from 127.0.0.1: icmp_seq=9 ttl=64 time=0.053 ms[2020-06-16 16:57:59] 64 bytes from 127.0.0.1: icmp_seq=10 ttl=64 time=0.033 ms[2020-06-16 16:58:00] 64 bytes from 127.0.0.1: icmp_seq=11 ttl=64 time=0.048 ms[2020-06-16 16:58:01] 64 bytes from 127.0.0.1: icmp_seq=12 ttl=64 time=0.050 ms[2020-06-16 16:58:02] 64 bytes from 127.0.0.1: icmp_seq=13 ttl=64 time=0.049 ms[2020-06-16 16:58:03] 64 bytes from 127.0.0.1: icmp_seq=14 ttl=64 time=0.043 ms[2020-06-16 16:58:04] 64 bytes from 127.0.0.1: icmp_seq=15 ttl=64 time=0.039 ms[2020-06-16 16:58:05] 64 bytes from 127.0.0.1: icmp_seq=16 ttl=64 time=0.051 ms[2020-06-16 16:58:06] 64 bytes from 127.0.0.1: icmp_seq=17 ttl=64 time=0.047 ms[2020-06-16 16:58:07] % 93 | --------------------------------------------------------------------------------