mirror of
https://github.com/amir20/dozzle.git
synced 2025-12-21 21:33:18 +01:00
feat: adds terminal mode to attach or exec shell on a container (#3726)
This commit is contained in:
3
assets/components.d.ts
vendored
3
assets/components.d.ts
vendored
@@ -62,6 +62,7 @@ declare module 'vue' {
|
|||||||
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
|
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
|
||||||
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
|
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
|
||||||
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
|
||||||
|
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
|
||||||
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
||||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||||
@@ -103,6 +104,7 @@ declare module 'vue' {
|
|||||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||||
Popup: typeof import('./components/Popup.vue')['default']
|
Popup: typeof import('./components/Popup.vue')['default']
|
||||||
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
||||||
|
'Ri:terminalWindowFill': typeof import('~icons/ri/terminal-window-fill')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
|
||||||
@@ -121,6 +123,7 @@ declare module 'vue' {
|
|||||||
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
|
||||||
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
||||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||||
|
Terminal: typeof import('./components/Terminal.vue')['default']
|
||||||
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
|
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
|
||||||
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
|
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
|
||||||
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
||||||
|
|||||||
@@ -134,6 +134,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<li class="line"></li>
|
||||||
|
<li>
|
||||||
|
<a @click.prevent="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
|
||||||
|
<ri:terminal-window-fill /> Attach
|
||||||
|
<KeyShortcut char="a" :modifiers="['shift', 'meta']" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a @click.prevent="showDrawer(Terminal, { container, action: 'exec' }, 'lg')">
|
||||||
|
<material-symbols:terminal /> Shell
|
||||||
|
<KeyShortcut char="e" :modifiers="['shift', 'meta']" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -142,6 +156,7 @@
|
|||||||
import { Container } from "@/models/Container";
|
import { Container } from "@/models/Container";
|
||||||
import { allLevels } from "@/composable/logContext";
|
import { allLevels } from "@/composable/logContext";
|
||||||
import LogAnalytics from "../LogViewer/LogAnalytics.vue";
|
import LogAnalytics from "../LogViewer/LogAnalytics.vue";
|
||||||
|
import Terminal from "@/components/Terminal.vue";
|
||||||
|
|
||||||
const { showSearch } = useSearchFilter();
|
const { showSearch } = useSearchFilter();
|
||||||
const { enableActions } = config;
|
const { enableActions } = config;
|
||||||
@@ -161,6 +176,20 @@ onKeyStroke("f", (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onKeyStroke("a", (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||||
|
showDrawer(Terminal, { container, action: "attach" }, "lg");
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onKeyStroke("e", (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||||
|
showDrawer(Terminal, { container, action: "exec" }, "lg");
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const downloadParams = computed(() =>
|
const downloadParams = computed(() =>
|
||||||
Object.entries(toValue(streamConfig))
|
Object.entries(toValue(streamConfig))
|
||||||
.filter(([, value]) => value)
|
.filter(([, value]) => value)
|
||||||
|
|||||||
79
assets/components/Terminal.vue
Normal file
79
assets/components/Terminal.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<aside>
|
||||||
|
<header class="flex items-center gap-4">
|
||||||
|
<material-symbols:terminal class="size-8" />
|
||||||
|
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
|
||||||
|
<h2 class="text-sm">Started <DistanceTime :date="container.created" /></h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col gap-2">
|
||||||
|
<section>
|
||||||
|
<div ref="host" class="shell"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Container } from "@/models/Container";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
const { container, action } = defineProps<{ container: Container; action: "attach" | "exec" }>();
|
||||||
|
|
||||||
|
const { Terminal } = await import("@xterm/xterm");
|
||||||
|
const { WebLinksAddon } = await import("@xterm/addon-web-links");
|
||||||
|
|
||||||
|
const host = useTemplateRef<HTMLDivElement>("host");
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: "block",
|
||||||
|
});
|
||||||
|
terminal.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
terminal.open(host.value!);
|
||||||
|
terminal.resize(100, 40);
|
||||||
|
ws = new WebSocket(withBase(`/api/hosts/${container.host}/containers/${container.id}/${action}`));
|
||||||
|
ws.onopen = () => {
|
||||||
|
terminal.writeln(`Attached to ${container.name} 🚀`);
|
||||||
|
if (action === "attach") {
|
||||||
|
ws?.send("\r");
|
||||||
|
}
|
||||||
|
terminal.onData((data) => {
|
||||||
|
ws?.send(data);
|
||||||
|
});
|
||||||
|
terminal.focus();
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => terminal.write(event.data);
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
terminal.writeln("⚠️ Connection closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
console.log("Closing WebSocket");
|
||||||
|
terminal.dispose();
|
||||||
|
ws?.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
@import "@/main.css" reference;
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
& :deep(.terminal) {
|
||||||
|
@apply overflow-hidden rounded border-1 p-2;
|
||||||
|
&:is(.focus) {
|
||||||
|
@apply border-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& :deep(.xterm-viewport) {
|
||||||
|
@apply bg-base-200!;
|
||||||
|
}
|
||||||
|
|
||||||
|
& :deep(.xterm-rows) {
|
||||||
|
@apply text-base-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
go.mod
1
go.mod
@@ -66,6 +66,7 @@ require (
|
|||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
github.com/google/gofuzz v1.2.0 // indirect
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -90,6 +90,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
|
|||||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
|||||||
@@ -395,6 +395,14 @@ func (c *Client) ContainerAction(ctx context.Context, containerId string, action
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) ContainerAttach(ctx context.Context, containerId string) (io.WriteCloser, io.Reader, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ContainerExec(ctx context.Context, containerId string, cmd []string) (io.WriteCloser, io.Reader, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,4 +38,6 @@ type Client interface {
|
|||||||
Ping(context.Context) error
|
Ping(context.Context) error
|
||||||
Host() Host
|
Host() Host
|
||||||
ContainerActions(ctx context.Context, action ContainerAction, containerID string) error
|
ContainerActions(ctx context.Context, action ContainerAction, containerID string) error
|
||||||
|
ContainerAttach(ctx context.Context, id string) (io.WriteCloser, io.Reader, error)
|
||||||
|
ContainerExec(ctx context.Context, id string, cmd []string) (io.WriteCloser, io.Reader, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ type DockerCLI interface {
|
|||||||
ContainerStart(ctx context.Context, containerID string, options docker.StartOptions) error
|
ContainerStart(ctx context.Context, containerID string, options docker.StartOptions) error
|
||||||
ContainerStop(ctx context.Context, containerID string, options docker.StopOptions) error
|
ContainerStop(ctx context.Context, containerID string, options docker.StopOptions) error
|
||||||
ContainerRestart(ctx context.Context, containerID string, options docker.StopOptions) error
|
ContainerRestart(ctx context.Context, containerID string, options docker.StopOptions) error
|
||||||
|
ContainerAttach(ctx context.Context, containerID string, options docker.AttachOptions) (types.HijackedResponse, error)
|
||||||
|
ContainerExecCreate(ctx context.Context, containerID string, options docker.ExecOptions) (docker.ExecCreateResponse, error)
|
||||||
|
ContainerExecAttach(ctx context.Context, execID string, config docker.ExecAttachOptions) (types.HijackedResponse, error)
|
||||||
|
ContainerExecResize(ctx context.Context, execID string, options docker.ResizeOptions) error
|
||||||
Info(ctx context.Context) (system.Info, error)
|
Info(ctx context.Context) (system.Info, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +301,54 @@ func (d *DockerClient) Host() container.Host {
|
|||||||
return d.host
|
return d.host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DockerClient) ContainerAttach(ctx context.Context, id string) (io.WriteCloser, io.Reader, error) {
|
||||||
|
log.Debug().Str("id", id).Str("host", d.host.Name).Msg("Attaching to container")
|
||||||
|
options := docker.AttachOptions{
|
||||||
|
Stream: true,
|
||||||
|
Stdin: true,
|
||||||
|
Stdout: true,
|
||||||
|
Stderr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
waiter, err := d.cli.ContainerAttach(ctx, id, options)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return waiter.Conn, waiter.Reader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DockerClient) ContainerExec(ctx context.Context, id string, cmd []string) (io.WriteCloser, io.Reader, error) {
|
||||||
|
log.Debug().Str("id", id).Str("host", d.host.Name).Msg("Executing command in container")
|
||||||
|
options := docker.ExecOptions{
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
AttachStdin: true,
|
||||||
|
Cmd: cmd,
|
||||||
|
Tty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
execID, err := d.cli.ContainerExecCreate(ctx, id, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
waiter, err := d.cli.ContainerExecAttach(ctx, execID.ID, docker.ExecAttachOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = d.cli.ContainerExecResize(ctx, execID.ID, docker.ResizeOptions{
|
||||||
|
Width: 100,
|
||||||
|
Height: 40,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return waiter.Conn, waiter.Reader, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newContainer(c docker.Summary, host string) container.Container {
|
func newContainer(c docker.Summary, host string) container.Container {
|
||||||
name := "no name"
|
name := "no name"
|
||||||
if c.Labels["dev.dozzle.name"] != "" {
|
if c.Labels["dev.dozzle.name"] != "" {
|
||||||
|
|||||||
@@ -241,8 +241,15 @@ func (k *K8sClient) Host() container.Host {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *K8sClient) ContainerActions(ctx context.Context, action container.ContainerAction, containerID string) error {
|
func (k *K8sClient) ContainerActions(ctx context.Context, action container.ContainerAction, containerID string) error {
|
||||||
// Implementation for container actions (start, stop, restart, etc.)
|
panic("not implemented")
|
||||||
return nil
|
}
|
||||||
|
|
||||||
|
func (k *K8sClient) ContainerAttach(ctx context.Context, id string) (io.WriteCloser, io.Reader, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *K8sClient) ContainerExec(ctx context.Context, id string, cmd []string) (io.WriteCloser, io.Reader, error) {
|
||||||
|
panic("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to parse pod and container names from container ID
|
// Helper function to parse pod and container names from container ID
|
||||||
|
|||||||
@@ -70,3 +70,11 @@ func (d *agentService) SubscribeContainersStarted(ctx context.Context, container
|
|||||||
func (a *agentService) ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error {
|
func (a *agentService) ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error {
|
||||||
return a.client.ContainerAction(ctx, container.ID, action)
|
return a.client.ContainerAction(ctx, container.ID, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *agentService) Attach(ctx context.Context, container container.Container, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *agentService) Exec(ctx context.Context, container container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ type ClientService interface {
|
|||||||
Host(ctx context.Context) (container.Host, error)
|
Host(ctx context.Context) (container.Host, error)
|
||||||
ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error
|
ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error
|
||||||
LogsBetweenDates(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error)
|
LogsBetweenDates(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error)
|
||||||
RawLogs(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error)
|
RawLogs(context.Context, container.Container, time.Time, time.Time, container.StdType) (io.ReadCloser, error)
|
||||||
|
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
SubscribeStats(ctx context.Context, stats chan<- container.ContainerStat)
|
SubscribeStats(context.Context, chan<- container.ContainerStat)
|
||||||
SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent)
|
SubscribeEvents(context.Context, chan<- container.ContainerEvent)
|
||||||
SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container)
|
SubscribeContainersStarted(context.Context, chan<- container.Container)
|
||||||
|
|
||||||
// Blocking streaming functions that should be used in a goroutine
|
// Blocking streaming functions that should be used in a goroutine
|
||||||
StreamLogs(ctx context.Context, container container.Container, from time.Time, stdTypes container.StdType, events chan<- *container.LogEvent) error
|
StreamLogs(context.Context, container.Container, time.Time, container.StdType, chan<- *container.LogEvent) error
|
||||||
|
|
||||||
|
// Terminal
|
||||||
|
Attach(context.Context, container.Container, io.Reader, io.Writer) error
|
||||||
|
Exec(context.Context, container.Container, []string, io.Reader, io.Writer) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,11 @@ func (c *ContainerService) StreamLogs(ctx context.Context, from time.Time, stdTy
|
|||||||
func (c *ContainerService) Action(ctx context.Context, action container.ContainerAction) error {
|
func (c *ContainerService) Action(ctx context.Context, action container.ContainerAction) error {
|
||||||
return c.clientService.ContainerAction(ctx, c.Container, action)
|
return c.clientService.ContainerAction(ctx, c.Container, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ContainerService) Attach(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
return c.clientService.Attach(ctx, c.Container, stdin, stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ContainerService) Exec(ctx context.Context, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
return c.clientService.Exec(ctx, c.Container, cmd, stdin, stdout)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package docker_support
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/amir20/dozzle/internal/container"
|
"github.com/amir20/dozzle/internal/container"
|
||||||
@@ -109,3 +110,75 @@ func (d *DockerClientService) SubscribeEvents(ctx context.Context, events chan<-
|
|||||||
func (d *DockerClientService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container) {
|
func (d *DockerClientService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container) {
|
||||||
d.store.SubscribeNewContainers(ctx, containers)
|
d.store.SubscribeNewContainers(ctx, containers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DockerClientService) Attach(ctx context.Context, container container.Container, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
cancelCtx, cancel := context.WithCancel(ctx)
|
||||||
|
containerWriter, containerReader, err := d.client.ContainerAttach(cancelCtx, container.ID)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := io.Copy(containerWriter, stdin); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while reading from ws")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
containerWriter.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if container.Tty {
|
||||||
|
if _, err := io.Copy(stdout, containerReader); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while writing to ws")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := stdcopy.StdCopy(stdout, stdout, containerReader); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while writing to ws")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DockerClientService) Exec(ctx context.Context, container container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
cancelCtx, cancel := context.WithCancel(ctx)
|
||||||
|
containerWriter, containerReader, err := d.client.ContainerExec(cancelCtx, container.ID, cmd)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := io.Copy(containerWriter, stdin); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while reading from ws")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
containerWriter.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := stdcopy.StdCopy(stdout, stdout, containerReader); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while writing to ws")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,3 +90,11 @@ func (k *K8sClientService) SubscribeEvents(ctx context.Context, events chan<- co
|
|||||||
func (k *K8sClientService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container) {
|
func (k *K8sClientService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container) {
|
||||||
k.store.SubscribeNewContainers(ctx, containers)
|
k.store.SubscribeNewContainers(ctx, containers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k *K8sClientService) Attach(ctx context.Context, container container.Container, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *K8sClientService) Exec(ctx context.Context, container container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ func createRouter(h *handler) *chi.Mux {
|
|||||||
r.Get("/hosts/{host}/containers/{id}/logs/stream", h.streamContainerLogs)
|
r.Get("/hosts/{host}/containers/{id}/logs/stream", h.streamContainerLogs)
|
||||||
r.Get("/hosts/{host}/logs/stream", h.streamHostLogs)
|
r.Get("/hosts/{host}/logs/stream", h.streamHostLogs)
|
||||||
r.Get("/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates)
|
r.Get("/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates)
|
||||||
|
r.Get("/hosts/{host}/containers/{id}/attach", h.attach)
|
||||||
|
r.Get("/hosts/{host}/containers/{id}/exec", h.exec)
|
||||||
r.Get("/hosts/{host}/logs/mergedStream/{ids}", h.streamLogsMerged)
|
r.Get("/hosts/{host}/logs/mergedStream/{ids}", h.streamLogsMerged)
|
||||||
r.Get("/containers/{hostIds}/download", h.downloadLogs) // formatted as host:container,host:container
|
r.Get("/containers/{hostIds}/download", h.downloadLogs) // formatted as host:container,host:container
|
||||||
r.Get("/stacks/{stack}/logs/stream", h.streamStackLogs)
|
r.Get("/stacks/{stack}/logs/stream", h.streamStackLogs)
|
||||||
|
|||||||
120
internal/web/terminal.go
Normal file
120
internal/web/terminal.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/amir20/dozzle/internal/auth"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) attach(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while trying to upgrade connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
userLabels := h.config.Labels
|
||||||
|
if h.config.Authorization.Provider != NONE {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
if user.ContainerLabels.Exists() {
|
||||||
|
userLabels = user.ContainerLabels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containerService, err := h.hostService.FindContainer(hostKey(r), id, userLabels)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while trying to find container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wsReader := &webSocketReader{conn: conn}
|
||||||
|
wsWriter := &webSocketWriter{conn: conn}
|
||||||
|
if err = containerService.Attach(r.Context(), wsReader, wsWriter); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while trying to attach to container")
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte("🚨 Error while trying to attach to container\r\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) exec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while trying to upgrade connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
userLabels := h.config.Labels
|
||||||
|
if h.config.Authorization.Provider != NONE {
|
||||||
|
user := auth.UserFromContext(r.Context())
|
||||||
|
if user.ContainerLabels.Exists() {
|
||||||
|
userLabels = user.ContainerLabels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containerService, err := h.hostService.FindContainer(hostKey(r), id, userLabels)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while trying to find container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wsReader := &webSocketReader{conn: conn}
|
||||||
|
wsWriter := &webSocketWriter{conn: conn}
|
||||||
|
if err = containerService.Exec(r.Context(), []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}, wsReader, wsWriter); err != nil {
|
||||||
|
log.Error().Err(err).Msg("error while trying to attach to container")
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte("🚨 Error while trying to attach to container\r\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type webSocketWriter struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webSocketWriter) Write(p []byte) (int, error) {
|
||||||
|
err := w.conn.WriteMessage(websocket.TextMessage, p)
|
||||||
|
return len(p), err
|
||||||
|
}
|
||||||
|
|
||||||
|
type webSocketReader struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
buffer []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *webSocketReader) Read(p []byte) (n int, err error) {
|
||||||
|
if len(r.buffer) > 0 {
|
||||||
|
n = copy(p, r.buffer)
|
||||||
|
r.buffer = r.buffer[n:]
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, read a new message
|
||||||
|
_, message, err := r.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n = copy(p, message)
|
||||||
|
|
||||||
|
// If we couldn't copy the entire message, store the rest in our buffer
|
||||||
|
if n < len(message) {
|
||||||
|
r.buffer = message[n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@
|
|||||||
"@vueuse/core": "^13.0.0",
|
"@vueuse/core": "^13.0.0",
|
||||||
"@vueuse/integrations": "^13.0.0",
|
"@vueuse/integrations": "^13.0.0",
|
||||||
"@vueuse/router": "^13.0.0",
|
"@vueuse/router": "^13.0.0",
|
||||||
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"d3-array": "^3.2.4",
|
"d3-array": "^3.2.4",
|
||||||
"d3-ease": "^3.0.1",
|
"d3-ease": "^3.0.1",
|
||||||
@@ -79,6 +81,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@apache-arrow/esnext-esm": "^19.0.1",
|
"@apache-arrow/esnext-esm": "^19.0.1",
|
||||||
|
"@iconify-json/material-symbols-light": "^1.2.17",
|
||||||
|
"@iconify-json/ri": "^1.2.5",
|
||||||
"@pinia/testing": "^1.0.0",
|
"@pinia/testing": "^1.0.0",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.51.1",
|
||||||
"@types/d3-array": "^3.2.1",
|
"@types/d3-array": "^3.2.1",
|
||||||
|
|||||||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
@@ -59,6 +59,12 @@ importers:
|
|||||||
'@vueuse/router':
|
'@vueuse/router':
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.0.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
|
version: 13.0.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
|
||||||
|
'@xterm/addon-web-links':
|
||||||
|
specifier: ^0.11.0
|
||||||
|
version: 0.11.0(@xterm/xterm@5.5.0)
|
||||||
|
'@xterm/xterm':
|
||||||
|
specifier: ^5.5.0
|
||||||
|
version: 5.5.0
|
||||||
ansi-to-html:
|
ansi-to-html:
|
||||||
specifier: ^0.7.2
|
specifier: ^0.7.2
|
||||||
version: 0.7.2
|
version: 0.7.2
|
||||||
@@ -153,6 +159,12 @@ importers:
|
|||||||
'@apache-arrow/esnext-esm':
|
'@apache-arrow/esnext-esm':
|
||||||
specifier: ^19.0.1
|
specifier: ^19.0.1
|
||||||
version: 19.0.1
|
version: 19.0.1
|
||||||
|
'@iconify-json/material-symbols-light':
|
||||||
|
specifier: ^1.2.17
|
||||||
|
version: 1.2.17
|
||||||
|
'@iconify-json/ri':
|
||||||
|
specifier: ^1.2.5
|
||||||
|
version: 1.2.5
|
||||||
'@pinia/testing':
|
'@pinia/testing':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0(pinia@3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))
|
version: 1.0.0(pinia@3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))
|
||||||
@@ -939,6 +951,9 @@ packages:
|
|||||||
'@iconify-json/ic@1.2.2':
|
'@iconify-json/ic@1.2.2':
|
||||||
resolution: {integrity: sha512-QmjwS3lYiOmVWgTCEOTFyGODaR/+689+ajep/VsrCcsUN0Gdle5PmIcibDsdmRyrOsW/E77G41UUijdbjQUofw==}
|
resolution: {integrity: sha512-QmjwS3lYiOmVWgTCEOTFyGODaR/+689+ajep/VsrCcsUN0Gdle5PmIcibDsdmRyrOsW/E77G41UUijdbjQUofw==}
|
||||||
|
|
||||||
|
'@iconify-json/material-symbols-light@1.2.17':
|
||||||
|
resolution: {integrity: sha512-sq8pSITGy15SjJ6FuOr/oH/8emJPcukdSJJeuL9YHZ44KKQD0ITL7ZO44TAQxdOsef0ptZbhPWxSwCqK7ytrxQ==}
|
||||||
|
|
||||||
'@iconify-json/material-symbols@1.2.17':
|
'@iconify-json/material-symbols@1.2.17':
|
||||||
resolution: {integrity: sha512-hKb+Ii5cqLXXefYMxUB2jIc8BNqxixQogud4KU/fn0F4puM1iCdCF2lFV+0U8wnJ6dZIx6E+w8Ree4bIT7To+A==}
|
resolution: {integrity: sha512-hKb+Ii5cqLXXefYMxUB2jIc8BNqxixQogud4KU/fn0F4puM1iCdCF2lFV+0U8wnJ6dZIx6E+w8Ree4bIT7To+A==}
|
||||||
|
|
||||||
@@ -954,6 +969,9 @@ packages:
|
|||||||
'@iconify-json/ph@1.2.2':
|
'@iconify-json/ph@1.2.2':
|
||||||
resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==}
|
resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==}
|
||||||
|
|
||||||
|
'@iconify-json/ri@1.2.5':
|
||||||
|
resolution: {integrity: sha512-kWGimOXMZrlYusjBKKXYOWcKhbOHusFsmrmRGmjS7rH0BpML5A9/fy8KHZqFOwZfC4M6amObQYbh8BqO5cMC3w==}
|
||||||
|
|
||||||
'@iconify-json/simple-icons@1.2.23':
|
'@iconify-json/simple-icons@1.2.23':
|
||||||
resolution: {integrity: sha512-ySyZ0ZXdNveWnR71t7XGV7jhknxSlTtpM2TyIR1cUHTUzZLP36hYHTNqb2pYYsCzH5ed85KTTKz7vYT33FyNIQ==}
|
resolution: {integrity: sha512-ySyZ0ZXdNveWnR71t7XGV7jhknxSlTtpM2TyIR1cUHTUzZLP36hYHTNqb2pYYsCzH5ed85KTTKz7vYT33FyNIQ==}
|
||||||
|
|
||||||
@@ -1882,6 +1900,14 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.0
|
vue: ^3.5.0
|
||||||
|
|
||||||
|
'@xterm/addon-web-links@0.11.0':
|
||||||
|
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^5.0.0
|
||||||
|
|
||||||
|
'@xterm/xterm@5.5.0':
|
||||||
|
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||||
|
|
||||||
abbrev@2.0.0:
|
abbrev@2.0.0:
|
||||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
@@ -4596,6 +4622,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
|
'@iconify-json/material-symbols-light@1.2.17':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/material-symbols@1.2.17':
|
'@iconify-json/material-symbols@1.2.17':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
@@ -4616,6 +4646,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
|
'@iconify-json/ri@1.2.5':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/simple-icons@1.2.23':
|
'@iconify-json/simple-icons@1.2.23':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
@@ -5609,6 +5643,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.13(typescript@5.8.2)
|
vue: 3.5.13(typescript@5.8.2)
|
||||||
|
|
||||||
|
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 5.5.0
|
||||||
|
|
||||||
|
'@xterm/xterm@5.5.0': {}
|
||||||
|
|
||||||
abbrev@2.0.0: {}
|
abbrev@2.0.0: {}
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.14.1):
|
acorn-jsx@5.3.2(acorn@8.14.1):
|
||||||
|
|||||||
Reference in New Issue
Block a user