mirror of
https://github.com/moby/moby.git
synced 2022-11-09 12:21:53 -05:00
Merge pull request #30962 from TheHipbot/30431-implement-format-for-history-with-docs
30431 implement format for history with docs
This commit is contained in:
commit
a3ab46361e
4 changed files with 381 additions and 50 deletions
113
cli/command/formatter/history.go
Normal file
113
cli/command/formatter/history.go
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
package formatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"github.com/docker/docker/pkg/stringutils"
|
||||||
|
units "github.com/docker/go-units"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultHistoryTableFormat = "table {{.ID}}\t{{.CreatedSince}}\t{{.CreatedBy}}\t{{.Size}}\t{{.Comment}}"
|
||||||
|
nonHumanHistoryTableFormat = "table {{.ID}}\t{{.CreatedAt}}\t{{.CreatedBy}}\t{{.Size}}\t{{.Comment}}"
|
||||||
|
|
||||||
|
historyIDHeader = "IMAGE"
|
||||||
|
createdByHeader = "CREATED BY"
|
||||||
|
commentHeader = "COMMENT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewHistoryFormat returns a format for rendering an HistoryContext
|
||||||
|
func NewHistoryFormat(source string, quiet bool, human bool) Format {
|
||||||
|
switch source {
|
||||||
|
case TableFormatKey:
|
||||||
|
switch {
|
||||||
|
case quiet:
|
||||||
|
return defaultQuietFormat
|
||||||
|
case !human:
|
||||||
|
return nonHumanHistoryTableFormat
|
||||||
|
default:
|
||||||
|
return defaultHistoryTableFormat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Format(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryWrite writes the context
|
||||||
|
func HistoryWrite(ctx Context, human bool, histories []image.HistoryResponseItem) error {
|
||||||
|
render := func(format func(subContext subContext) error) error {
|
||||||
|
for _, history := range histories {
|
||||||
|
historyCtx := &historyContext{trunc: ctx.Trunc, h: history, human: human}
|
||||||
|
if err := format(historyCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
historyCtx := &historyContext{}
|
||||||
|
historyCtx.header = map[string]string{
|
||||||
|
"ID": historyIDHeader,
|
||||||
|
"CreatedSince": createdSinceHeader,
|
||||||
|
"CreatedAt": createdAtHeader,
|
||||||
|
"CreatedBy": createdByHeader,
|
||||||
|
"Size": sizeHeader,
|
||||||
|
"Comment": commentHeader,
|
||||||
|
}
|
||||||
|
return ctx.Write(historyCtx, render)
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyContext struct {
|
||||||
|
HeaderContext
|
||||||
|
trunc bool
|
||||||
|
human bool
|
||||||
|
h image.HistoryResponseItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *historyContext) MarshalJSON() ([]byte, error) {
|
||||||
|
return marshalJSON(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *historyContext) ID() string {
|
||||||
|
if c.trunc {
|
||||||
|
return stringid.TruncateID(c.h.ID)
|
||||||
|
}
|
||||||
|
return c.h.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *historyContext) CreatedAt() string {
|
||||||
|
var created string
|
||||||
|
created = units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(c.h.Created), 0)))
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *historyContext) CreatedSince() string {
|
||||||
|
var created string
|
||||||
|
created = units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(c.h.Created), 0)))
|
||||||
|
return created + " ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *historyContext) CreatedBy() string {
|
||||||
|
createdBy := strings.Replace(c.h.CreatedBy, "\t", " ", -1)
|
||||||
|
if c.trunc {
|
||||||
|
createdBy = stringutils.Ellipsis(createdBy, 45)
|
||||||
|
}
|
||||||
|
return createdBy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *historyContext) Size() string {
|
||||||
|
size := ""
|
||||||
|
if c.human {
|
||||||
|
size = units.HumanSizeWithPrecision(float64(c.h.Size), 3)
|
||||||
|
} else {
|
||||||
|
size = strconv.FormatInt(c.h.Size, 10)
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *historyContext) Comment() string {
|
||||||
|
return c.h.Comment
|
||||||
|
}
|
213
cli/command/formatter/history_test.go
Normal file
213
cli/command/formatter/history_test.go
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
package formatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bytes"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"github.com/docker/docker/pkg/stringutils"
|
||||||
|
"github.com/docker/docker/pkg/testutil/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type historyCase struct {
|
||||||
|
historyCtx historyContext
|
||||||
|
expValue string
|
||||||
|
call func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryContext_ID(t *testing.T) {
|
||||||
|
id := stringid.GenerateRandomID()
|
||||||
|
|
||||||
|
var ctx historyContext
|
||||||
|
cases := []historyCase{
|
||||||
|
{
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{ID: id},
|
||||||
|
trunc: false,
|
||||||
|
}, id, ctx.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{ID: id},
|
||||||
|
trunc: true,
|
||||||
|
}, stringid.TruncateID(id), ctx.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ctx = c.historyCtx
|
||||||
|
v := c.call()
|
||||||
|
if strings.Contains(v, ",") {
|
||||||
|
compareMultipleValues(t, v, c.expValue)
|
||||||
|
} else if v != c.expValue {
|
||||||
|
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryContext_CreatedSince(t *testing.T) {
|
||||||
|
unixTime := time.Now().AddDate(0, 0, -7).Unix()
|
||||||
|
expected := "7 days ago"
|
||||||
|
|
||||||
|
var ctx historyContext
|
||||||
|
cases := []historyCase{
|
||||||
|
{
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{Created: unixTime},
|
||||||
|
trunc: false,
|
||||||
|
human: true,
|
||||||
|
}, expected, ctx.CreatedSince,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ctx = c.historyCtx
|
||||||
|
v := c.call()
|
||||||
|
if strings.Contains(v, ",") {
|
||||||
|
compareMultipleValues(t, v, c.expValue)
|
||||||
|
} else if v != c.expValue {
|
||||||
|
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryContext_CreatedBy(t *testing.T) {
|
||||||
|
withTabs := `/bin/sh -c apt-key adv --keyserver hkp://pgp.mit.edu:80 --recv-keys 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 && echo "deb http://nginx.org/packages/mainline/debian/ jessie nginx" >> /etc/apt/sources.list && apt-get update && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates nginx=${NGINX_VERSION} nginx-module-xslt nginx-module-geoip nginx-module-image-filter nginx-module-perl nginx-module-njs gettext-base && rm -rf /var/lib/apt/lists/*`
|
||||||
|
expected := `/bin/sh -c apt-key adv --keyserver hkp://pgp.mit.edu:80 --recv-keys 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 && echo "deb http://nginx.org/packages/mainline/debian/ jessie nginx" >> /etc/apt/sources.list && apt-get update && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates nginx=${NGINX_VERSION} nginx-module-xslt nginx-module-geoip nginx-module-image-filter nginx-module-perl nginx-module-njs gettext-base && rm -rf /var/lib/apt/lists/*`
|
||||||
|
|
||||||
|
var ctx historyContext
|
||||||
|
cases := []historyCase{
|
||||||
|
{
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{CreatedBy: withTabs},
|
||||||
|
trunc: false,
|
||||||
|
}, expected, ctx.CreatedBy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{CreatedBy: withTabs},
|
||||||
|
trunc: true,
|
||||||
|
}, stringutils.Ellipsis(expected, 45), ctx.CreatedBy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ctx = c.historyCtx
|
||||||
|
v := c.call()
|
||||||
|
if strings.Contains(v, ",") {
|
||||||
|
compareMultipleValues(t, v, c.expValue)
|
||||||
|
} else if v != c.expValue {
|
||||||
|
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryContext_Size(t *testing.T) {
|
||||||
|
size := int64(182964289)
|
||||||
|
expected := "183MB"
|
||||||
|
|
||||||
|
var ctx historyContext
|
||||||
|
cases := []historyCase{
|
||||||
|
{
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{Size: size},
|
||||||
|
trunc: false,
|
||||||
|
human: true,
|
||||||
|
}, expected, ctx.Size,
|
||||||
|
}, {
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{Size: size},
|
||||||
|
trunc: false,
|
||||||
|
human: false,
|
||||||
|
}, strconv.Itoa(182964289), ctx.Size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ctx = c.historyCtx
|
||||||
|
v := c.call()
|
||||||
|
if strings.Contains(v, ",") {
|
||||||
|
compareMultipleValues(t, v, c.expValue)
|
||||||
|
} else if v != c.expValue {
|
||||||
|
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryContext_Comment(t *testing.T) {
|
||||||
|
comment := "Some comment"
|
||||||
|
|
||||||
|
var ctx historyContext
|
||||||
|
cases := []historyCase{
|
||||||
|
{
|
||||||
|
historyContext{
|
||||||
|
h: image.HistoryResponseItem{Comment: comment},
|
||||||
|
trunc: false,
|
||||||
|
}, comment, ctx.Comment,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
ctx = c.historyCtx
|
||||||
|
v := c.call()
|
||||||
|
if strings.Contains(v, ",") {
|
||||||
|
compareMultipleValues(t, v, c.expValue)
|
||||||
|
} else if v != c.expValue {
|
||||||
|
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryContext_Table(t *testing.T) {
|
||||||
|
out := bytes.NewBufferString("")
|
||||||
|
unixTime := time.Now().AddDate(0, 0, -1).Unix()
|
||||||
|
histories := []image.HistoryResponseItem{
|
||||||
|
{ID: "imageID1", Created: unixTime, CreatedBy: "/bin/bash ls && npm i && npm run test && karma -c karma.conf.js start && npm start && more commands here && the list goes on", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}},
|
||||||
|
{ID: "imageID2", Created: unixTime, CreatedBy: "/bin/bash echo", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}},
|
||||||
|
{ID: "imageID3", Created: unixTime, CreatedBy: "/bin/bash ls", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}},
|
||||||
|
{ID: "imageID4", Created: unixTime, CreatedBy: "/bin/bash grep", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}},
|
||||||
|
}
|
||||||
|
expectedNoTrunc := `IMAGE CREATED CREATED BY SIZE COMMENT
|
||||||
|
imageID1 24 hours ago /bin/bash ls && npm i && npm run test && karma -c karma.conf.js start && npm start && more commands here && the list goes on 183MB Hi
|
||||||
|
imageID2 24 hours ago /bin/bash echo 183MB Hi
|
||||||
|
imageID3 24 hours ago /bin/bash ls 183MB Hi
|
||||||
|
imageID4 24 hours ago /bin/bash grep 183MB Hi
|
||||||
|
`
|
||||||
|
expectedTrunc := `IMAGE CREATED CREATED BY SIZE COMMENT
|
||||||
|
imageID1 24 hours ago /bin/bash ls && npm i && npm run test && k... 183MB Hi
|
||||||
|
imageID2 24 hours ago /bin/bash echo 183MB Hi
|
||||||
|
imageID3 24 hours ago /bin/bash ls 183MB Hi
|
||||||
|
imageID4 24 hours ago /bin/bash grep 183MB Hi
|
||||||
|
`
|
||||||
|
|
||||||
|
contexts := []struct {
|
||||||
|
context Context
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{Context{
|
||||||
|
Format: NewHistoryFormat("table", false, true),
|
||||||
|
Trunc: true,
|
||||||
|
Output: out,
|
||||||
|
},
|
||||||
|
expectedTrunc,
|
||||||
|
},
|
||||||
|
{Context{
|
||||||
|
Format: NewHistoryFormat("table", false, true),
|
||||||
|
Trunc: false,
|
||||||
|
Output: out,
|
||||||
|
},
|
||||||
|
expectedNoTrunc,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, context := range contexts {
|
||||||
|
HistoryWrite(context.context, true, histories)
|
||||||
|
assert.Equal(t, out.String(), context.expected)
|
||||||
|
// Clean buffer
|
||||||
|
out.Reset()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,19 +1,11 @@
|
||||||
package image
|
package image
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/net/context"
|
"golang.org/x/net/context"
|
||||||
|
|
||||||
"github.com/docker/docker/cli"
|
"github.com/docker/docker/cli"
|
||||||
"github.com/docker/docker/cli/command"
|
"github.com/docker/docker/cli/command"
|
||||||
"github.com/docker/docker/pkg/stringid"
|
"github.com/docker/docker/cli/command/formatter"
|
||||||
"github.com/docker/docker/pkg/stringutils"
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -23,6 +15,7 @@ type historyOptions struct {
|
||||||
human bool
|
human bool
|
||||||
quiet bool
|
quiet bool
|
||||||
noTrunc bool
|
noTrunc bool
|
||||||
|
format string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHistoryCommand creates a new `docker history` command
|
// NewHistoryCommand creates a new `docker history` command
|
||||||
|
@ -44,6 +37,7 @@ func NewHistoryCommand(dockerCli *command.DockerCli) *cobra.Command {
|
||||||
flags.BoolVarP(&opts.human, "human", "H", true, "Print sizes and dates in human readable format")
|
flags.BoolVarP(&opts.human, "human", "H", true, "Print sizes and dates in human readable format")
|
||||||
flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only show numeric IDs")
|
flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only show numeric IDs")
|
||||||
flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Don't truncate output")
|
flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Don't truncate output")
|
||||||
|
flags.StringVar(&opts.format, "format", "", "Pretty-print images using a Go template")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
@ -56,44 +50,15 @@ func runHistory(dockerCli *command.DockerCli, opts historyOptions) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(dockerCli.Out(), 20, 1, 3, ' ', 0)
|
format := opts.format
|
||||||
|
if len(format) == 0 {
|
||||||
if opts.quiet {
|
format = formatter.TableFormatKey
|
||||||
for _, entry := range history {
|
|
||||||
if opts.noTrunc {
|
|
||||||
fmt.Fprintf(w, "%s\n", entry.ID)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(w, "%s\n", stringid.TruncateID(entry.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageID string
|
historyCtx := formatter.Context{
|
||||||
var createdBy string
|
Output: dockerCli.Out(),
|
||||||
var created string
|
Format: formatter.NewHistoryFormat(format, opts.quiet, opts.human),
|
||||||
var size string
|
Trunc: !opts.noTrunc,
|
||||||
|
|
||||||
fmt.Fprintln(w, "IMAGE\tCREATED\tCREATED BY\tSIZE\tCOMMENT")
|
|
||||||
for _, entry := range history {
|
|
||||||
imageID = entry.ID
|
|
||||||
createdBy = strings.Replace(entry.CreatedBy, "\t", " ", -1)
|
|
||||||
if !opts.noTrunc {
|
|
||||||
createdBy = stringutils.Ellipsis(createdBy, 45)
|
|
||||||
imageID = stringid.TruncateID(entry.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.human {
|
|
||||||
created = units.HumanDuration(time.Now().UTC().Sub(time.Unix(entry.Created, 0))) + " ago"
|
|
||||||
size = units.HumanSizeWithPrecision(float64(entry.Size), 3)
|
|
||||||
} else {
|
|
||||||
created = time.Unix(entry.Created, 0).Format(time.RFC3339)
|
|
||||||
size = strconv.FormatInt(entry.Size, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", imageID, created, createdBy, size, entry.Comment)
|
|
||||||
}
|
}
|
||||||
w.Flush()
|
return formatter.HistoryWrite(historyCtx, opts.human, history)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,10 +21,11 @@ Usage: docker history [OPTIONS] IMAGE
|
||||||
Show the history of an image
|
Show the history of an image
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--help Print usage
|
--format string Pretty-print images using a Go template
|
||||||
-H, --human Print sizes and dates in human readable format (default true)
|
--help Print usage
|
||||||
--no-trunc Don't truncate output
|
-H, --human Print sizes and dates in human readable format (default true)
|
||||||
-q, --quiet Only show numeric IDs
|
--no-trunc Don't truncate output
|
||||||
|
-q, --quiet Only show numeric IDs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
@ -54,3 +55,42 @@ IMAGE CREATED CREATED BY
|
||||||
c69cab00d6ef 5 months ago /bin/sh -c #(nop) MAINTAINER Lokesh Mandvekar 0 B
|
c69cab00d6ef 5 months ago /bin/sh -c #(nop) MAINTAINER Lokesh Mandvekar 0 B
|
||||||
511136ea3c5a 19 months ago 0 B Imported from -
|
511136ea3c5a 19 months ago 0 B Imported from -
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Format the output
|
||||||
|
|
||||||
|
The formatting option (`--format`) will pretty print history output
|
||||||
|
using a Go template.
|
||||||
|
|
||||||
|
Valid placeholders for the Go template are listed below:
|
||||||
|
|
||||||
|
| Placeholder | Description|
|
||||||
|
| ---- | ---- |
|
||||||
|
| `.ID` | Image ID |
|
||||||
|
| `.CreatedSince` | Elapsed time since the image was created if --human=true, otherwise timestamp of when image was created |
|
||||||
|
| `.CreatedAt` | Timestamp of when image was created |
|
||||||
|
| `.CreatedBy` | Command that was used to create the image |
|
||||||
|
| `.Size` | Image disk size |
|
||||||
|
| `.Comment` | Comment for image |
|
||||||
|
|
||||||
|
When using the `--format` option, the `history` command will either
|
||||||
|
output the data exactly as the template declares or, when using the
|
||||||
|
`table` directive, will include column headers as well.
|
||||||
|
|
||||||
|
The following example uses a template without headers and outputs the
|
||||||
|
`ID` and `CreatedSince` entries separated by a colon for all images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{% raw %}
|
||||||
|
$ docker images --format "{{.ID}}: {{.Created}} ago"
|
||||||
|
|
||||||
|
cc1b61406712: 2 weeks ago
|
||||||
|
<missing>: 2 weeks ago
|
||||||
|
<missing>: 2 weeks ago
|
||||||
|
<missing>: 2 weeks ago
|
||||||
|
<missing>: 2 weeks ago
|
||||||
|
<missing>: 3 weeks ago
|
||||||
|
<missing>: 3 weeks ago
|
||||||
|
<missing>: 3 weeks ago
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
```
|
Loading…
Reference in a new issue