1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-21 13:23:07 +01:00

feat: show start event for containers (#3014)

This commit is contained in:
Amir Raminfar
2024-06-06 08:26:03 -07:00
committed by GitHub
parent 5169c211f3
commit 220218d44d
12 changed files with 88 additions and 55 deletions

View File

@@ -25,6 +25,7 @@ declare module 'vue' {
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default'] 'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default'] ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default'] ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
ContainerEventLogItem: typeof import('./components/LogViewer/ContainerEventLogItem.vue')['default']
ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default'] ContainerHealth: typeof import('./components/ContainerViewer/ContainerHealth.vue')['default']
ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default'] ContainerLog: typeof import('./components/ContainerViewer/ContainerLog.vue')['default']
ContainerName: typeof import('./components/LogViewer/ContainerName.vue')['default'] ContainerName: typeof import('./components/LogViewer/ContainerName.vue')['default']

View File

@@ -28,13 +28,13 @@
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { DockerEventLogEntry } from "@/models/LogEntry"; import { ContainerEventLogEntry } from "@/models/LogEntry";
const router = useRouter(); const router = useRouter();
const { showToast } = useToast(); const { showToast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const { logEntry } = defineProps<{ const { logEntry } = defineProps<{
logEntry: DockerEventLogEntry; logEntry: ContainerEventLogEntry;
showContainerName?: boolean; showContainerName?: boolean;
}>(); }>();

View File

@@ -6,7 +6,7 @@ import {
type JSONObject, type JSONObject,
LogEntry, LogEntry,
asLogEntry, asLogEntry,
DockerEventLogEntry, ContainerEventLogEntry,
SkippedLogsEntry, SkippedLogsEntry,
} from "@/models/LogEntry"; } from "@/models/LogEntry";
import { Service, Stack } from "@/models/Stack"; import { Service, Stack } from "@/models/Stack";
@@ -162,9 +162,15 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
es = new EventSource(url.value); es = new EventSource(url.value);
es.addEventListener("container-stopped", (e) => { es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string }; const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string };
buffer.push(new DockerEventLogEntry("Container stopped", event.actorId, new Date(), "container-stopped")); const containerEvent = new ContainerEventLogEntry(
event.name == "container-started" ? "Container started" : "Container stopped",
event.actorId,
new Date(),
event.name as "container-stopped" | "container-started",
);
buffer.push(containerEvent);
flushBuffer(); flushBuffer();
flushBuffer.flush(); flushBuffer.flush();

View File

@@ -2,7 +2,7 @@ import { Component, ComputedRef, Ref } from "vue";
import { flattenJSON, getDeep } from "@/utils"; import { flattenJSON, getDeep } from "@/utils";
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue"; import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue"; import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
import DockerEventLogItem from "@/components/LogViewer/DockerEventLogItem.vue"; import ContainerEventLogItem from "@/components/LogViewer/ContainerEventLogItem.vue";
import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue"; import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue";
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>; export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
@@ -107,7 +107,7 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
} }
} }
export class DockerEventLogEntry extends LogEntry<string> { export class ContainerEventLogEntry extends LogEntry<string> {
constructor( constructor(
message: string, message: string,
containerID: string, containerID: string,
@@ -117,7 +117,7 @@ export class DockerEventLogEntry extends LogEntry<string> {
super(message, containerID, date.getTime(), date, "stderr", "info"); super(message, containerID, date.getTime(), date, "stderr", "info");
} }
getComponent(): Component { getComponent(): Component {
return DockerEventLogItem; return ContainerEventLogItem;
} }
} }

View File

@@ -124,7 +124,7 @@ export const useContainerStore = defineStore("container", () => {
...newContainers.map((c) => { ...newContainers.map((c) => {
return new Container( return new Container(
c.id, c.id,
new Date(c.created * 1000), new Date(c.created),
c.image, c.image,
c.name, c.name,
c.command, c.command,

View File

@@ -63,7 +63,7 @@ type DockerCLI interface {
type Client interface { type Client interface {
ListContainers() ([]Container, error) ListContainers() ([]Container, error)
FindContainer(string) (Container, error) FindContainer(string) (Container, error)
ContainerLogs(context.Context, string, string, StdType) (io.ReadCloser, error) ContainerLogs(context.Context, string, *time.Time, StdType) (io.ReadCloser, error)
Events(context.Context, chan<- ContainerEvent) error Events(context.Context, chan<- ContainerEvent) error
ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error) ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error)
ContainerStats(context.Context, string, chan<- ContainerStat) error ContainerStats(context.Context, string, chan<- ContainerStat) error
@@ -177,6 +177,10 @@ func (d *httpClient) FindContainer(id string) (Container, error) {
if json, err := d.cli.ContainerInspect(context.Background(), container.ID); err == nil { if json, err := d.cli.ContainerInspect(context.Background(), container.ID); err == nil {
container.Tty = json.Config.Tty container.Tty = json.Config.Tty
if startedAt, err := time.Parse(time.RFC3339Nano, json.State.StartedAt); err == nil {
utc := startedAt.UTC()
container.StartedAt = &utc
}
} else { } else {
return container, err return container, err
} }
@@ -226,7 +230,7 @@ func (d *httpClient) ListContainers() ([]Container, error) {
Image: c.Image, Image: c.Image,
ImageID: c.ImageID, ImageID: c.ImageID,
Command: c.Command, Command: c.Command,
Created: c.Created, Created: time.Unix(c.Created, 0),
State: c.State, State: c.State,
Status: c.Status, Status: c.Status,
Host: d.host.ID, Host: d.host.ID,
@@ -295,15 +299,12 @@ func (d *httpClient) ContainerStats(ctx context.Context, id string, stats chan<-
} }
} }
func (d *httpClient) ContainerLogs(ctx context.Context, id string, since string, stdType StdType) (io.ReadCloser, error) { func (d *httpClient) ContainerLogs(ctx context.Context, id string, since *time.Time, stdType StdType) (io.ReadCloser, error) {
log.WithField("id", id).WithField("since", since).WithField("stdType", stdType).Debug("streaming logs for container") log.WithField("id", id).WithField("since", since).WithField("stdType", stdType).Debug("streaming logs for container")
if since != "" { sinceQuery := ""
if millis, err := strconv.ParseInt(since, 10, 64); err == nil { if since != nil {
since = time.UnixMicro(millis).Add(time.Millisecond).Format(time.RFC3339Nano) sinceQuery = since.Add(time.Millisecond).Format(time.RFC3339Nano)
} else {
log.WithError(err).Debug("unable to parse since")
}
} }
options := container.LogsOptions{ options := container.LogsOptions{
@@ -312,7 +313,7 @@ func (d *httpClient) ContainerLogs(ctx context.Context, id string, since string,
Follow: true, Follow: true,
Tail: strconv.Itoa(100), Tail: strconv.Itoa(100),
Timestamps: true, Timestamps: true,
Since: since, Since: sinceQuery,
} }
reader, err := d.cli.ContainerLogs(ctx, id, options) reader, err := d.cli.ContainerLogs(ctx, id, options)

View File

@@ -6,6 +6,7 @@ import (
"encoding/binary" "encoding/binary"
"errors" "errors"
"io" "io"
"time"
"testing" "testing"
@@ -149,11 +150,18 @@ func Test_dockerClient_ContainerLogs_happy(t *testing.T) {
b = append(b, []byte(expected)...) b = append(b, []byte(expected)...)
reader := io.NopCloser(bytes.NewReader(b)) reader := io.NopCloser(bytes.NewReader(b))
options := container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "100", Timestamps: true, Since: "since"} since := time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)
options := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
Tail: "100",
Timestamps: true,
Since: "2021-01-01T00:00:00.001Z"}
proxy.On("ContainerLogs", mock.Anything, id, options).Return(reader, nil) proxy.On("ContainerLogs", mock.Anything, id, options).Return(reader, nil)
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}} client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
logReader, _ := client.ContainerLogs(context.Background(), id, "since", STDALL) logReader, _ := client.ContainerLogs(context.Background(), id, &since, STDALL)
actual, _ := io.ReadAll(logReader) actual, _ := io.ReadAll(logReader)
assert.Equal(t, string(b), string(actual), "message doesn't match expected") assert.Equal(t, string(b), string(actual), "message doesn't match expected")
@@ -168,7 +176,7 @@ func Test_dockerClient_ContainerLogs_error(t *testing.T) {
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}} client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
reader, err := client.ContainerLogs(context.Background(), id, "", STDALL) reader, err := client.ContainerLogs(context.Background(), id, nil, STDALL)
assert.Nil(t, reader, "reader should be nil") assert.Nil(t, reader, "reader should be nil")
assert.Error(t, err, "error should have been returned") assert.Error(t, err, "error should have been returned")
@@ -190,7 +198,8 @@ func Test_dockerClient_FindContainer_happy(t *testing.T) {
proxy := new(mockedProxy) proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil) proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
json := types.ContainerJSON{Config: &container.Config{Tty: false}} state := &types.ContainerState{Status: "running", StartedAt: time.Now().Format(time.RFC3339Nano)}
json := types.ContainerJSON{ContainerJSONBase: &types.ContainerJSONBase{State: state}, Config: &container.Config{Tty: false}}
proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil) proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil)
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}} client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
@@ -238,7 +247,10 @@ func Test_dockerClient_ContainerActions_happy(t *testing.T) {
proxy := new(mockedProxy) proxy := new(mockedProxy)
client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}} client := &httpClient{proxy, filters.NewArgs(), &Host{ID: "localhost"}, system.Info{}}
json := types.ContainerJSON{Config: &container.Config{Tty: false}}
state := &types.ContainerState{Status: "running", StartedAt: time.Now().Format(time.RFC3339Nano)}
json := types.ContainerJSON{ContainerJSONBase: &types.ContainerJSONBase{State: state}, Config: &container.Config{Tty: false}}
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil) proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil) proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil)
proxy.On("ContainerStart", mock.Anything, "abcdefghijkl", mock.Anything).Return(nil) proxy.On("ContainerStart", mock.Anything, "abcdefghijkl", mock.Anything).Return(nil)

View File

@@ -2,27 +2,29 @@ package docker
import ( import (
"math" "math"
"time"
"github.com/amir20/dozzle/internal/utils" "github.com/amir20/dozzle/internal/utils"
) )
// Container represents an internal representation of docker containers // Container represents an internal representation of docker containers
type Container struct { type Container struct {
ID string `json:"id"` ID string `json:"id"`
Names []string `json:"names"` Names []string `json:"names"`
Name string `json:"name"` Name string `json:"name"`
Image string `json:"image"` Image string `json:"image"`
ImageID string `json:"imageId"` ImageID string `json:"imageId"`
Command string `json:"command"` Command string `json:"command"`
Created int64 `json:"created"` Created time.Time `json:"created"`
State string `json:"state"` StartedAt *time.Time `json:"startedAt,omitempty"`
Status string `json:"status"` State string `json:"state"`
Health string `json:"health,omitempty"` Status string `json:"status"`
Host string `json:"host,omitempty"` Health string `json:"health,omitempty"`
Tty bool `json:"-"` Host string `json:"host,omitempty"`
Labels map[string]string `json:"labels,omitempty"` Tty bool `json:"-"`
Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"` Labels map[string]string `json:"labels,omitempty"`
Group string `json:"group,omitempty"` Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"`
Group string `json:"group,omitempty"`
} }
// ContainerStat represent stats instant for a container // ContainerStat represent stats instant for a container

View File

@@ -132,7 +132,7 @@ data: []
event: containers-changed event: containers-changed
data: [{"id":"1234","names":null,"name":"test","image":"test","imageId":"","command":"","created":0,"state":"","status":"","stats":[]}] data: [{"id":"1234","names":null,"name":"test","image":"test","imageId":"","command":"","created":"0001-01-01T00:00:00Z","state":"","status":"","stats":[]}]
event: container-start event: container-start
@@ -178,7 +178,7 @@ X-Accel-Buffering: no
data: {"m":"INFO Testing logs...","ts":0,"id":4256192898,"l":"info","s":"stdout","c":"123456"} data: {"m":"INFO Testing logs...","ts":0,"id":4256192898,"l":"info","s":"stdout","c":"123456"}
event: container-stopped event: container-event
data: {"actorId":"123456","name":"container-stopped","host":"localhost"} data: {"actorId":"123456","name":"container-stopped","host":"localhost"}
/* snapshot: Test_handler_streamLogs_happy_container_stopped */ /* snapshot: Test_handler_streamLogs_happy_container_stopped */
@@ -204,5 +204,5 @@ X-Accel-Buffering: no
data: {"m":"INFO Testing logs...","ts":1589396137772,"id":1469707724,"l":"info","s":"stdout","c":"123456"} data: {"m":"INFO Testing logs...","ts":1589396137772,"id":1469707724,"l":"info","s":"stdout","c":"123456"}
id: 1589396137772 id: 1589396137772
event: container-stopped event: container-event
data: {"actorId":"123456","name":"container-stopped","host":"localhost"} data: {"actorId":"123456","name":"container-stopped","host":"localhost"}

View File

@@ -343,11 +343,13 @@ func streamLogsForContainers(w http.ResponseWriter, r *http.Request, clients map
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
logs := make(chan *docker.LogEvent) logs := make(chan *docker.LogEvent)
events := make(chan *docker.ContainerEvent) events := make(chan *docker.ContainerEvent, 1)
ticker := time.NewTicker(5 * time.Second) ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop() defer ticker.Stop()
started := time.Now()
loop: loop:
for { for {
select { select {
@@ -366,8 +368,11 @@ loop:
fmt.Fprintf(w, ":ping \n\n") fmt.Fprintf(w, ":ping \n\n")
f.Flush() f.Flush()
case container := <-containers: case container := <-containers:
if container.StartedAt != nil && container.StartedAt.After(started) {
events <- &docker.ContainerEvent{ActorID: container.ID, Name: "container-started", Host: container.Host}
}
go func(container docker.Container) { go func(container docker.Container) {
reader, err := clients[container.Host].ContainerLogs(r.Context(), container.ID, "", stdTypes) reader, err := clients[container.Host].ContainerLogs(r.Context(), container.ID, container.StartedAt, stdTypes)
if err != nil { if err != nil {
return return
} }
@@ -395,7 +400,7 @@ loop:
if buf, err := json.Marshal(event); err != nil { if buf, err := json.Marshal(event); err != nil {
log.Errorf("json encoding error while streaming %v", err.Error()) log.Errorf("json encoding error while streaming %v", err.Error())
} else { } else {
fmt.Fprintf(w, "event: container-stopped\ndata: %s\n\n", buf) fmt.Fprintf(w, "event: container-event\ndata: %s\n\n", buf)
f.Flush() f.Flush()
} }

View File

@@ -36,8 +36,10 @@ func Test_handler_streamLogs_happy(t *testing.T) {
data := makeMessage("INFO Testing logs...", docker.STDOUT) data := makeMessage("INFO Testing logs...", docker.STDOUT)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Tty: false, Host: "localhost"}, nil) now := time.Now()
mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil).
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Tty: false, Host: "localhost", StartedAt: &now}, nil)
mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, &now, docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil).
Run(func(args mock.Arguments) { Run(func(args mock.Arguments) {
go func() { go func() {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
@@ -67,8 +69,10 @@ func Test_handler_streamLogs_happy_with_id(t *testing.T) {
data := makeMessage("2020-05-13T18:55:37.772853839Z INFO Testing logs...", docker.STDOUT) data := makeMessage("2020-05-13T18:55:37.772853839Z INFO Testing logs...", docker.STDOUT)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil) started := time.Date(2020, time.May, 13, 18, 55, 37, 772853839, time.UTC)
mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil).
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost", StartedAt: &started}, nil)
mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, &started, docker.STDALL).Return(io.NopCloser(bytes.NewReader(data)), nil).
Run(func(args mock.Arguments) { Run(func(args mock.Arguments) {
go func() { go func() {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
@@ -94,9 +98,10 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
started := time.Date(2020, time.May, 13, 18, 55, 37, 772853839, time.UTC)
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil) mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost", StartedAt: &started}, nil)
mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF). mockedClient.On("ContainerLogs", mock.Anything, id, &started, docker.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF).
Run(func(args mock.Arguments) { Run(func(args mock.Arguments) {
go func() { go func() {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
@@ -150,9 +155,10 @@ func Test_handler_streamLogs_error_reading(t *testing.T) {
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
started := time.Date(2020, time.May, 13, 18, 55, 37, 772853839, time.UTC)
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost"}, nil) mockedClient.On("FindContainer", id).Return(docker.Container{ID: id, Host: "localhost", StartedAt: &started}, nil)
mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), errors.New("test error")). mockedClient.On("ContainerLogs", mock.Anything, id, &started, docker.STDALL).Return(io.NopCloser(strings.NewReader("")), errors.New("test error")).
Run(func(args mock.Arguments) { Run(func(args mock.Arguments) {
go func() { go func() {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)

View File

@@ -36,7 +36,7 @@ func (m *MockedClient) ListContainers() ([]docker.Container, error) {
return args.Get(0).([]docker.Container), args.Error(1) return args.Get(0).([]docker.Container), args.Error(1)
} }
func (m *MockedClient) ContainerLogs(ctx context.Context, id string, since string, stdType docker.StdType) (io.ReadCloser, error) { func (m *MockedClient) ContainerLogs(ctx context.Context, id string, since *time.Time, stdType docker.StdType) (io.ReadCloser, error) {
args := m.Called(ctx, id, since, stdType) args := m.Called(ctx, id, since, stdType)
return args.Get(0).(io.ReadCloser), args.Error(1) return args.Get(0).(io.ReadCloser), args.Error(1)
} }