1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-24 22:39:18 +01:00

feat: handles container exits better and uses the real time when container has exited (#3504)

This commit is contained in:
Amir Raminfar
2025-01-03 13:09:36 -08:00
committed by GitHub
parent cc620ad429
commit 46fb09e2f7
16 changed files with 191 additions and 90 deletions

View File

@@ -1,15 +1,25 @@
<template>
<div>
<span class="font-light capitalize"> RUNNING </span>
<span class="font-light capitalize"> STATE </span>
<span class="font-semibold uppercase"> {{ container.state }} </span>
</div>
<div v-if="container.startedAt.getFullYear() > 0">
<span class="font-light capitalize"> STARTED </span>
<span class="font-semibold">
<DistanceTime :date="container.created" strict :suffix="false" />
<DistanceTime :date="container.startedAt" strict />
</span>
</div>
<div>
<div v-if="container.state != 'running' && container.finishedAt.getFullYear() > 0">
<span class="font-light capitalize"> Finished </span>
<span class="font-semibold">
<DistanceTime :date="container.finishedAt" strict />
</span>
</div>
<div v-if="container.state == 'running'">
<span class="font-light capitalize"> Load </span>
<span class="font-semibold"> {{ container.stat.cpu.toFixed(2) }}% </span>
</div>
<div>
<div v-if="container.state == 'running'">
<span class="font-light capitalize"> MEM </span>
<span class="font-semibold"> {{ formatBytes(container.stat.memoryUsage) }} </span>
</div>

View File

@@ -31,9 +31,45 @@ function createFuzzySearchModal() {
initialState: {
container: {
containers: [
new Container("123", new Date(), "image", "test", "command", "host", {}, "running", []),
new Container("345", new Date(), "image", "foo bar", "command", "host", {}, "running", []),
new Container("567", new Date(), "image", "baz", "command", "host", {}, "running", []),
new Container(
"123",
new Date(),
new Date(),
new Date(),
"image",
"test",
"command",
"host",
{},
"running",
[],
),
new Container(
"345",
new Date(),
new Date(),
new Date(),
"image",
"foo bar",
"command",
"host",
{},
"running",
[],
),
new Container(
"567",
new Date(),
new Date(),
new Date(),
"image",
"baz",
"command",
"host",
{},
"running",
[],
),
],
},
},

View File

@@ -96,7 +96,7 @@
</span>
</router-link>
<template #content>
<ContainerPopup :container="item as Container" />
<ContainerPopup :container="item" />
</template>
</Popup>
</li>

View File

@@ -94,7 +94,19 @@ describe("<ContainerEventSource />", () => {
},
props: {
streamSource: useContainerStream,
entity: new Container("abc", new Date(), "image", "name", "command", "localhost", {}, "created", []),
entity: new Container(
"abc",
new Date(), // created
new Date(), // started
new Date(), // finished
"image",
"name",
"command",
"localhost",
{},
"created",
[],
),
},
});
}

View File

@@ -137,11 +137,12 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
const event = JSON.parse((e as MessageEvent).data) as {
actorId: string;
name: "container-stopped" | "container-started";
time: string;
};
const containerEvent = new ContainerEventLogEntry(
event.name == "container-started" ? "Container started" : "Container stopped",
event.actorId,
new Date(),
new Date(event.time),
event.name,
);

View File

@@ -30,6 +30,8 @@ export class Container {
constructor(
public readonly id: string,
public readonly created: Date,
public startedAt: Date,
public finishedAt: Date,
public readonly image: string,
name: string,
public readonly command: string,

View File

@@ -61,12 +61,13 @@ export const useContainerStore = defineStore("container", () => {
}
});
es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string };
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string; time: string };
const container = allContainersById.value[event.actorId];
if (container) {
switch (event.name) {
case "die":
container.state = "exited";
container.finishedAt = new Date(event.time);
break;
case "destroy":
container.state = "deleted";
@@ -75,6 +76,18 @@ export const useContainerStore = defineStore("container", () => {
}
});
es.addEventListener("container-updated", (e) => {
const container = JSON.parse((e as MessageEvent).data) as ContainerJson;
const existing = allContainersById.value[container.id];
if (existing) {
existing.name = container.name;
existing.state = container.state;
existing.health = container.health;
existing.startedAt = new Date(container.startedAt);
existing.finishedAt = new Date(container.finishedAt);
}
});
es.addEventListener("update-host", (e) => {
const host = JSON.parse((e as MessageEvent).data) as Host;
updateHost(host);
@@ -133,6 +146,8 @@ export const useContainerStore = defineStore("container", () => {
return new Container(
c.id,
new Date(c.created),
new Date(c.startedAt),
new Date(c.finishedAt),
c.image,
c.name,
c.command,

View File

@@ -7,7 +7,9 @@ export interface ContainerStat {
export type ContainerJson = {
readonly id: string;
readonly created: number;
readonly created: string;
readonly startedAt: string;
readonly finishedAt: string;
readonly image: string;
readonly name: string;
readonly command: string;

View File

@@ -385,12 +385,16 @@ func newContainerFromJSON(c types.ContainerJSON, host string) Container {
Tty: c.Config.Tty,
}
if createdAt, err := time.Parse(time.RFC3339Nano, c.Created); err == nil {
container.Created = createdAt.UTC()
}
if startedAt, err := time.Parse(time.RFC3339Nano, c.State.StartedAt); err == nil {
container.StartedAt = startedAt.UTC()
}
if createdAt, err := time.Parse(time.RFC3339Nano, c.Created); err == nil {
container.Created = createdAt.UTC()
if stoppedAt, err := time.Parse(time.RFC3339Nano, c.State.FinishedAt); err == nil {
container.FinishedAt = stoppedAt.UTC()
}
if c.State.Health != nil {

View File

@@ -154,6 +154,34 @@ func (s *ContainerStore) FindContainer(id string, filter ContainerFilter) (Conta
}
if container, ok := s.containers.Load(id); ok {
if container.StartedAt.IsZero() {
log.Debug().Str("id", id).Msg("container doesn't have detailed information, fetching it")
if newContainer, ok := s.containers.Compute(id, func(c *Container, loaded bool) (*Container, bool) {
if loaded {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if newContainer, err := s.client.FindContainer(ctx, id); err == nil {
return &newContainer, false
}
}
return c, false
}); ok {
event := ContainerEvent{
Name: "update",
Host: s.client.Host().ID,
ActorID: id,
}
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
select {
case events <- event:
case <-c.Done():
s.subscribers.Delete(c)
}
return true
})
return *newContainer, nil
}
}
return *container, nil
} else {
log.Warn().Str("id", id).Msg("container not found")

View File

@@ -12,19 +12,20 @@ import (
// Container represents an internal representation of docker containers
type Container struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Command string `json:"command"`
Created time.Time `json:"created"`
StartedAt time.Time `json:"startedAt,omitempty"`
State string `json:"state"`
Health string `json:"health,omitempty"`
Host string `json:"host,omitempty"`
Tty bool `json:"-"`
Labels map[string]string `json:"labels,omitempty"`
Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"`
Group string `json:"group,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Command string `json:"command"`
Created time.Time `json:"created"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
State string `json:"state"`
Health string `json:"health,omitempty"`
Host string `json:"host,omitempty"`
Tty bool `json:"-"`
Labels map[string]string `json:"labels,omitempty"`
Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"`
Group string `json:"group,omitempty"`
}
// ContainerStat represent stats instant for a container
@@ -41,6 +42,7 @@ type ContainerEvent struct {
Host string `json:"host"`
ActorID string `json:"actorId"`
ActorAttributes map[string]string `json:"actorAttributes,omitempty"`
Time time.Time `json:"time"`
}
type ContainerFilter map[string][]string

View File

@@ -81,23 +81,15 @@ Content-Type: text/html
<pre>dev</pre>
/* snapshot: Test_handler_between_dates */
HTTP/1.1 200 OK
Connection: close
Content-Security-Policy: default-src 'self' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://*.duckdb.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: application/x-jsonl; charset=UTF-8
{"m":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout","c":"123456"}
{"m":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"}
/* snapshot: Test_handler_between_dates_with_fill */
HTTP/1.1 200 OK
Connection: close
Content-Security-Policy: default-src 'self' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://*.duckdb.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: application/x-jsonl; charset=UTF-8
{"m":"INFO Testing stdout logs...","ts":1589396137772,"id":466600245,"l":"info","s":"stdout","c":"123456"}
{"m":"INFO Testing stderr logs...","ts":1589396197772,"id":1101501603,"l":"info","s":"stderr","c":"123456"}
/* snapshot: Test_handler_download_logs */
INFO Testing logs...
@@ -145,7 +137,7 @@ data: []
event: container-event
data: {"name":"start","host":"localhost","actorId":"1234"}
data: {"name":"start","host":"localhost","actorId":"1234","time":"0001-01-01T00:00:00Z"}
/* snapshot: Test_handler_streamLogs_error_finding_container */
HTTP/1.1 404 Not Found
@@ -157,16 +149,9 @@ X-Content-Type-Options: nosniff
error finding container
/* snapshot: Test_handler_streamLogs_error_reading */
HTTP/1.1 200 OK
Connection: close
Cache-Control: no-transform
Cache-Control: no-cache
Connection: keep-alive
Content-Security-Policy: default-src 'self' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://*.duckdb.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/event-stream
X-Accel-Buffering: no
:ping
:ping
/* snapshot: Test_handler_streamLogs_error_std */
HTTP/1.1 400 Bad Request
@@ -178,48 +163,27 @@ X-Content-Type-Options: nosniff
stdout or stderr is required
/* snapshot: Test_handler_streamLogs_happy */
HTTP/1.1 200 OK
Connection: close
Cache-Control: no-transform
Cache-Control: no-cache
Connection: keep-alive
Content-Security-Policy: default-src 'self' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://*.duckdb.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/event-stream
X-Accel-Buffering: no
:ping
data: {"m":"INFO Testing logs...","ts":0,"id":4256192898,"l":"info","s":"stdout","c":"123456"}
event: container-event
data: {"name":"container-stopped","host":"localhost","actorId":"123456"}
data: {"name":"container-stopped","host":"localhost","actorId":"123456","time":"<removed>"}
/* snapshot: Test_handler_streamLogs_happy_container_stopped */
HTTP/1.1 200 OK
Connection: close
Cache-Control: no-transform
Cache-Control: no-cache
Connection: keep-alive
Content-Security-Policy: default-src 'self' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://*.duckdb.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/event-stream
X-Accel-Buffering: no
:ping
event: container-event
data: {"name":"container-stopped","host":"localhost","actorId":"123456"}
data: {"name":"container-stopped","host":"localhost","actorId":"123456","time":"<removed>"}
/* snapshot: Test_handler_streamLogs_happy_with_id */
HTTP/1.1 200 OK
Connection: close
Cache-Control: no-transform
Cache-Control: no-cache
Connection: keep-alive
Content-Security-Policy: default-src 'self' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://*.duckdb.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Content-Type: text/event-stream
X-Accel-Buffering: no
:ping
data: {"m":"INFO Testing logs...","ts":1589396137772,"id":1469707724,"l":"info","s":"stdout","c":"123456"}
@@ -227,4 +191,4 @@ id: 1589396137772
event: container-event
data: {"name":"container-stopped","host":"localhost","actorId":"123456"}
data: {"name":"container-stopped","host":"localhost","actorId":"123456","time":"<removed>"}

View File

@@ -86,6 +86,14 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
return
}
case "update":
log.Debug().Str("id", event.ActorID).Msg("container updated")
if containerService, err := h.multiHostService.FindContainer(event.Host, event.ActorID, usersFilter); err == nil {
if err := sseWriter.Event("container-updated", containerService.Container); err != nil {
log.Error().Err(err).Msg("error writing event to event stream")
return
}
}
case "health_status: healthy", "health_status: unhealthy":
log.Debug().Str("container", event.ActorID).Str("health", event.Name).Msg("container health status")
healthy := "unhealthy"

View File

@@ -342,12 +342,22 @@ func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request
log.Error().Err(err).Msg("error while finding container")
return
}
container = containerService.Container
start := utils.Max(absoluteTime, container.StartedAt)
err = containerService.StreamLogs(r.Context(), start, stdTypes, liveLogs)
if err != nil {
if errors.Is(err, io.EOF) {
log.Debug().Str("container", container.ID).Msg("streaming ended")
events <- &docker.ContainerEvent{ActorID: container.ID, Name: "container-stopped", Host: container.Host}
finishedAt := container.FinishedAt
if container.FinishedAt.IsZero() {
finishedAt = time.Now()
}
events <- &docker.ContainerEvent{
ActorID: container.ID,
Name: "container-stopped",
Host: container.Host,
Time: finishedAt,
}
} else if !errors.Is(err, context.Canceled) {
log.Error().Err(err).Str("container", container.ID).Msg("unknown error while streaming logs")
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/binary"
"errors"
"io"
"regexp"
"time"
"net/http"
@@ -60,7 +61,8 @@ func Test_handler_streamLogs_happy(t *testing.T) {
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
reader := strings.NewReader(regexp.MustCompile(`"time":"[^"]*"`).ReplaceAllString(rr.Body.String(), `"time":"<removed>"`))
abide.AssertReader(t, t.Name(), reader)
mockedClient.AssertExpectations(t)
}
@@ -105,7 +107,8 @@ func Test_handler_streamLogs_happy_with_id(t *testing.T) {
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
reader := strings.NewReader(regexp.MustCompile(`"time":"[^"]*"`).ReplaceAllString(rr.Body.String(), `"time":"<removed>"`))
abide.AssertReader(t, t.Name(), reader)
mockedClient.AssertExpectations(t)
}
@@ -141,7 +144,8 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
reader := strings.NewReader(regexp.MustCompile(`"time":"[^"]*"`).ReplaceAllString(rr.Body.String(), `"time":"<removed>"`))
abide.AssertReader(t, t.Name(), reader)
mockedClient.AssertExpectations(t)
}
@@ -178,7 +182,8 @@ func Test_handler_streamLogs_error_reading(t *testing.T) {
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
reader := strings.NewReader(regexp.MustCompile(`"time":"[^"]*"`).ReplaceAllString(rr.Body.String(), `"time":"<removed>"`))
abide.AssertReader(t, t.Name(), reader)
mockedClient.AssertExpectations(t)
}
@@ -243,7 +248,8 @@ func Test_handler_between_dates(t *testing.T) {
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
reader := strings.NewReader(regexp.MustCompile(`"time":"[^"]*"`).ReplaceAllString(rr.Body.String(), `"time":"<removed>"`))
abide.AssertReader(t, t.Name(), reader)
mockedClient.AssertExpectations(t)
}
@@ -289,7 +295,8 @@ func Test_handler_between_dates_with_fill(t *testing.T) {
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
reader := strings.NewReader(regexp.MustCompile(`"time":"[^"]*"`).ReplaceAllString(rr.Body.String(), `"time":"<removed>"`))
abide.AssertReader(t, t.Name(), reader)
mockedClient.AssertExpectations(t)
}

View File

@@ -30,11 +30,6 @@ export default defineConfig(() => ({
target: "esnext",
},
plugins: [
VueMacros({
plugins: {
vue: Vue(),
},
}),
VueRouter({
routesFolder: {
src: "./assets/pages",
@@ -42,6 +37,11 @@ export default defineConfig(() => ({
dts: "./assets/typed-router.d.ts",
importMode: "sync",
}),
VueMacros({
plugins: {
vue: Vue(),
},
}),
Icons({
autoInstall: true,
}),