feat: host grouping with bug fixes and hardening (#4662)

Co-authored-by: Mikhail Gorbachev <31391617+nomah4@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-04-27 12:57:48 -07:00
committed by GitHub
parent 2478cd16d6
commit 84c4d1144f
37 changed files with 525 additions and 39 deletions
-5
View File
@@ -49,11 +49,6 @@ shared_cert.pem: shared_key.pem
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
@rm shared_request.csr
.PHONY: push
push: docker
@docker tag amir20/dozzle:local amir20/dozzle:local-test
@docker push amir20/dozzle:local-test
.PHONY: run
run: docker
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
+159 -20
View File
@@ -36,9 +36,10 @@
<div v-else class="w-4"></div>
{{ $t("label.show-all-containers") }}
</a>
<a class="text-sm capitalize" @click="collapseAll()">
<material-symbols-light:collapse-all class="w-4" />
{{ $t("label.collapse-all") }}
<a v-if="hasCollapsible" class="text-sm capitalize" @click="collapseAll()">
<material-symbols-light:expand-all class="w-4" v-if="allCollapsed" />
<material-symbols-light:collapse-all class="w-4" v-else />
{{ allCollapsed ? $t("label.expand-all") : $t("label.collapse-all") }}
</a>
</li>
</ul>
@@ -49,13 +50,68 @@
<SlideTransition :slide-right="!!sessionHost">
<template #left>
<ul class="menu p-0">
<li v-for="host in hosts" :key="host.id">
<a @click.prevent="setHost(host.id)" :class="{ 'text-base-content/50 pointer-events-none': !host.available }">
<HostIcon :type="host.type" />
{{ host.name }}
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
<template v-if="!hasHostGroups">
<li v-for="host in hosts" :key="host.id">
<a
@click.prevent="setHost(host.id)"
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
>
<HostIcon :type="host.type" />
{{ host.name }}
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
</template>
<template v-else v-for="[groupName, groupHosts] in groupedHostEntries" :key="groupName || '__ungrouped__'">
<li v-if="groupName" class="host-group">
<details :open="!collapsedHostGroups.has(groupName)" @toggle="updateCollapsedHostGroups($event, groupName)">
<summary class="host-group-summary">
<span class="truncate">{{ groupName }}</span>
<router-link
:to="{ name: '/host-group/[name]', params: { name: groupName } }"
class="btn btn-square btn-outline btn-primary btn-xs"
:title="$t('tooltip.merge-all')"
@click.stop
>
<ph:arrows-merge />
</router-link>
<button
v-if="!collapsedHostGroups.has(groupName)"
type="button"
class="btn btn-square btn-outline btn-primary btn-xs"
:title="$t('label.collapse-group')"
@click.stop.prevent="collapseHostGroup(groupName)"
>
<material-symbols-light:collapse-all />
</button>
</summary>
<ul>
<li v-for="host in groupHosts" :key="host.id">
<a
@click.prevent="setHost(host.id)"
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
>
<HostIcon :type="host.type" />
{{ host.name }}
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
</ul>
</details>
</li>
<template v-else>
<li v-for="host in groupHosts" :key="host.id">
<a
@click.prevent="setHost(host.id)"
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
>
<HostIcon :type="host.type" />
{{ host.name }}
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
</template>
</template>
</ul>
</template>
<template #right>
@@ -146,7 +202,30 @@ const { hosts } = useHosts();
const setHost = (host: string | null) => (sessionHost.value = host);
const hasHostGroups = computed(() => Object.values(hosts.value).some((h) => h.group));
const groupedHostEntries = computed(() => {
const groups: Record<string, (typeof hosts.value)[string][]> = {};
const ungrouped: (typeof hosts.value)[string][] = [];
for (const host of Object.values(hosts.value)) {
if (host.group) {
groups[host.group] ||= [];
groups[host.group].push(host);
} else {
ungrouped.push(host);
}
}
const entries = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)) as [string, typeof ungrouped][];
if (ungrouped.length > 0) {
entries.push(["", ungrouped]);
}
return entries;
});
const collapsedGroups = useProfileStorage("collapsedGroups", new Set<string>());
const collapsedHostGroups = useProfileStorage("collapsedHostGroups", new Set<string>());
const updateCollapsedGroups = (event: Event, label: string) => {
const details = event.target as HTMLDetailsElement;
if (details.open) {
@@ -159,10 +238,51 @@ const updateCollapsedGroups = (event: Event, label: string) => {
}
};
const updateCollapsedHostGroups = (event: Event, groupName: string) => {
const details = event.target as HTMLDetailsElement;
if (details.open) {
collapsedHostGroups.value.delete(groupName);
} else {
collapsedHostGroups.value.add(groupName);
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const collapseHostGroup = (groupName: string) => {
collapsedHostGroups.value.add(groupName);
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const hasCollapsible = computed(
() => menuItems.value.length > 0 || groupedHostEntries.value.some(([groupName]) => groupName),
);
const allCollapsed = computed(() => {
const containerGroups = menuItems.value;
const hostGroups = groupedHostEntries.value.filter(([groupName]) => groupName);
if (containerGroups.length === 0 && hostGroups.length === 0) return false;
return (
containerGroups.every(({ label }) => collapsedGroups.value.has(label)) &&
hostGroups.every(([groupName]) => collapsedHostGroups.value.has(groupName))
);
});
const collapseAll = () => {
menuItems.value.forEach(({ label }) => {
collapsedGroups.value.add(label);
});
if (allCollapsed.value) {
menuItems.value.forEach(({ label }) => collapsedGroups.value.delete(label));
groupedHostEntries.value.forEach(([groupName]) => {
if (groupName) collapsedHostGroups.value.delete(groupName);
});
} else {
menuItems.value.forEach(({ label }) => collapsedGroups.value.add(label));
groupedHostEntries.value.forEach(([groupName]) => {
if (groupName) collapsedHostGroups.value.add(groupName);
});
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
@@ -232,14 +352,18 @@ const menuItems = computed(() => {
const route = useRoute("/container/[id]");
watchEffect(() => {
if (route.name === "/container/[id]") {
const container = containerStore.findContainerById(route.params.id);
if (container) {
setHost(container.host);
watch(
[() => route.name, () => route.params.id],
([name, id]) => {
if (name === "/container/[id]") {
const container = containerStore.findContainerById(id as string);
if (container) {
setHost(container.host);
}
}
}
});
},
{ immediate: true },
);
const toggleShowAllContainers = () => (showAllContainers.value = !showAllContainers.value);
</script>
@@ -248,6 +372,21 @@ const toggleShowAllContainers = () => (showAllContainers.value = !showAllContain
@apply text-[0.95rem];
}
.host-group-summary {
display: grid;
grid-template-columns: minmax(0, auto) max-content max-content max-content;
align-items: center;
justify-content: start;
gap: 0.5rem;
padding-left: 0;
padding-right: 0.25rem;
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
}
.host-group-summary::after {
margin-left: 0.25rem;
}
li.exited {
@apply opacity-75;
}
@@ -0,0 +1,61 @@
<template>
<div v-if="groupHosts.length === 0" class="hero min-h-[50vh]">
<div class="hero-content text-center">
<p class="text-base-content/70 text-lg">{{ $t("error.host-group-not-found", { name }) }}</p>
</div>
</div>
<ScrollableView :scrollable="scrollable" v-else>
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="flex flex-1 items-center gap-1.5 truncate md:gap-2">
<ph:computer-tower />
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ name }}</div>
</div>
<Tag class="font-mono max-md:hidden" size="small">
{{ $t("label.host-count", groupHosts.length) }}
</Tag>
<Tag class="font-mono max-md:hidden" size="small">
{{ $t("label.container", containers.length) }}
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
<ViewerWithSource
ref="viewer"
:stream-source="useHostGroupStream"
:entity="groupRef"
:visible-keys="visibleKeys"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
import { useHostGroupStream } from "@/composable/eventStreams";
import { ComponentExposed } from "vue-component-type-helpers";
const { name, scrollable = false } = defineProps<{
name: string;
scrollable?: boolean;
}>();
const { hosts } = useHosts();
const store = useContainerStore();
const { containersByHost } = storeToRefs(store);
const groupHosts = computed(() => Object.values(hosts.value).filter((h) => h.group === name));
const groupRef = computed(() => ({ name }));
const visibleKeys = new Map<string[], boolean>();
const containers = computed(() =>
groupHosts.value.flatMap((h) => containersByHost.value?.[h.id]?.filter((c) => c.state === "running") ?? []),
);
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
provideLoggingContext(containers, { showContainerName: true, showHostname: true });
</script>
+4
View File
@@ -28,6 +28,10 @@ export function useHostStream(host: Ref<Host>): LogStreamSource {
return useLogStream(computed(() => `/api/hosts/${host.value.id}/logs/stream`));
}
export function useHostGroupStream(group: Ref<{ name: string }>): LogStreamSource {
return useLogStream(computed(() => `/api/host-groups/${encodeURIComponent(group.value.name)}/logs/stream`));
}
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
const labels = computed(() => `com.docker.stack.namespace:${stack.value.name}`);
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
+20
View File
@@ -0,0 +1,20 @@
<template>
<Search />
<HostGroupLog :name="route.params.name" :scrollable="pinnedLogs.length > 0" />
</template>
<script lang="ts" setup>
const route = useRoute("/host-group/[name]");
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
watchEffect(() => {
setTitle(route.params.name + " group");
});
</script>
<route lang="yaml">
meta:
menu: host
</route>
+1
View File
@@ -31,6 +31,7 @@ export interface Profile {
visibleKeys?: Map<string, Map<string[], boolean>>;
releaseSeen?: string;
collapsedGroups?: Set<string>;
collapsedHostGroups?: Set<string>;
cloudWelcomeShown?: boolean;
}
+1
View File
@@ -8,6 +8,7 @@ export type Host = {
available: boolean;
dockerVersion: string;
agentVersion: string;
group?: string;
};
const hosts = ref(
+13
View File
@@ -65,6 +65,13 @@ declare module 'vue-router/auto-routes' {
{ name: ParamValue<false> },
| never
>,
'/host-group/[name]': RouteRecordInfo<
'/host-group/[name]',
'/host-group/:name',
{ name: ParamValue<true> },
{ name: ParamValue<false> },
| never
>,
'/host/[id]': RouteRecordInfo<
'/host/[id]',
'/host/:id',
@@ -178,6 +185,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'assets/pages/host-group/[name].vue': {
routes:
| '/host-group/[name]'
views:
| never
}
'assets/pages/host/[id].vue': {
routes:
| '/host/[id]'
+49
View File
@@ -72,6 +72,55 @@ Note that it is not necessary to mount the local Docker socket when connecting t
> [!TIP]
> You can connect to multiple agents by providing multiple `DOZZLE_REMOTE_AGENT` environment variables. For example, `DOZZLE_REMOTE_AGENT=agent1:7007,agent2:7007`.
## Host Groups
When managing many agents across different environments, you can assign each agent to a named group. Groups appear as collapsible sections in the sidebar, and each group has a "merge all" button to view combined logs from every host in the group.
The connection string format is `endpoint|name|group` — all three parts are optional:
| Format | Result |
|--------|--------|
| `agent:7007` | No name override, no group |
| `agent:7007\|web-1` | Name override, no group |
| `agent:7007\|web-1\|Production` | Name override + group |
| `agent:7007\|\|Production` | Default hostname + group |
::: code-group
```sh
docker run -p 8080:8080 amir20/dozzle:latest \
--remote-agent agent1:7007|web-1|Production \
--remote-agent agent2:7007|web-2|Production \
--remote-agent agent3:7007|dev-1|Development
```
```yaml [docker-compose.yml]
services:
dozzle:
image: amir20/dozzle:latest
environment:
- DOZZLE_REMOTE_AGENT=agent1:7007|web-1|Production,agent2:7007|web-2|Production,agent3:7007|dev-1|Development
ports:
- 8080:8080
```
:::
The sidebar will display:
```
▾ Production
web-1
web-2
▾ Development
dev-1
ungrouped-host ← agents without a group appear below
```
Clicking the merge icon next to a group name opens a combined log view streaming from all hosts in that group. The merged view is also available directly at `/host-group/<group-name>`.
Agents without a group continue to work exactly as before and appear below grouped sections.
## Common Issues
### Agent Not Showing Up
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+51 -8
View File
@@ -12,6 +12,7 @@ import (
"time"
"encoding/json"
"strings"
"github.com/amir20/dozzle/internal/agent/pb"
"github.com/amir20/dozzle/internal/container"
@@ -28,12 +29,19 @@ import (
)
type Client struct {
client pb.AgentServiceClient
conn *grpc.ClientConn
endpoint string
client pb.AgentServiceClient
conn *grpc.ClientConn
endpoint string
nameOverride string
group string
}
func NewClient(endpoint string, certificates tls.Certificate, opts ...grpc.DialOption) (*Client, error) {
endpoint, nameOverride, group, err := ParseEndpoint(endpoint)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
c, err := x509.ParseCertificate(certificates.Certificate[0])
if err != nil {
@@ -66,12 +74,35 @@ func NewClient(endpoint string, certificates tls.Certificate, opts ...grpc.DialO
client := pb.NewAgentServiceClient(conn)
return &Client{
client: client,
conn: conn,
endpoint: endpoint,
client: client,
conn: conn,
endpoint: endpoint,
nameOverride: nameOverride,
group: group,
}, nil
}
// ParseEndpoint splits an agent endpoint of the form "address|name|group" into
// its parts. Name and group are optional; address is required.
func ParseEndpoint(endpoint string) (string, string, string, error) {
parts := strings.Split(endpoint, "|")
if len(parts) > 3 || parts[0] == "" {
return "", "", "", fmt.Errorf("invalid agent endpoint: %s", endpoint)
}
name := ""
if len(parts) >= 2 {
name = parts[1]
}
group := ""
if len(parts) == 3 {
group = parts[2]
}
return parts[0], name, group, nil
}
func rpcErrToErr(err error) error {
if err == nil {
return nil
@@ -329,14 +360,20 @@ func (c *Client) ListContainers(ctx context.Context, labels container.ContainerL
func (c *Client) Host(ctx context.Context) (container.Host, error) {
info, err := c.client.HostInfo(ctx, &pb.HostInfoRequest{})
if err != nil {
name := c.nameOverride
if name == "" {
name = c.endpoint
}
return container.Host{
Endpoint: c.endpoint,
Name: name,
Group: c.group,
Type: "agent",
Available: false,
}, err
}
return container.Host{
host := container.Host{
ID: info.Host.Id,
Name: info.Host.Name,
NCPU: int(info.Host.CpuCores),
@@ -345,7 +382,13 @@ func (c *Client) Host(ctx context.Context) (container.Host, error) {
Type: "agent",
DockerVersion: info.Host.DockerVersion,
AgentVersion: info.Host.AgentVersion,
}, nil
Group: c.group,
}
if c.nameOverride != "" {
host.Name = c.nameOverride
}
return host, nil
}
func (c *Client) ContainerAction(ctx context.Context, containerId string, action container.ContainerAction) error {
+67 -1
View File
@@ -37,7 +37,7 @@ func (m *mockNotificationHandler) HandleNotificationConfig(subscriptions []types
}
func (m *mockNotificationHandler) SetCloudDispatcher(d dispatcher.Dispatcher) {}
func (m *mockNotificationHandler) ClearCloudDispatcher() {}
func (m *mockNotificationHandler) ClearCloudDispatcher() {}
func (m *mockNotificationHandler) GetNotificationStats() []types.SubscriptionStats {
return nil
@@ -185,3 +185,69 @@ func TestListContainers(t *testing.T) {
wantedContainer,
}, containers)
}
func TestHostWithAgentMetadata(t *testing.T) {
rpc, err := NewClient("passthrough://bufnet|Web-1|Production", certs, grpc.WithContextDialer(bufDialer))
if err != nil {
t.Fatal(err)
}
host, err := rpc.Host(context.Background())
assert.NoError(t, err)
assert.Equal(t, "passthrough://bufnet", host.Endpoint)
assert.Equal(t, "Web-1", host.Name)
assert.Equal(t, "Production", host.Group)
}
func TestHostWithAgentGroupAndDefaultName(t *testing.T) {
rpc, err := NewClient("passthrough://bufnet||Production", certs, grpc.WithContextDialer(bufDialer))
if err != nil {
t.Fatal(err)
}
host, err := rpc.Host(context.Background())
assert.NoError(t, err)
assert.Equal(t, "passthrough://bufnet", host.Endpoint)
assert.Equal(t, "local", host.Name)
assert.Equal(t, "Production", host.Group)
}
func TestNewClientRejectsInvalidAgentEndpoint(t *testing.T) {
_, err := NewClient("passthrough://bufnet|Web-1|Production|extra", certs, grpc.WithContextDialer(bufDialer))
assert.Error(t, err)
}
func TestParseEndpoint(t *testing.T) {
tests := []struct {
name string
input string
wantAddr string
wantName string
wantGroup string
wantErr bool
}{
{name: "address only", input: "host:7007", wantAddr: "host:7007"},
{name: "address and name", input: "host:7007|web-1", wantAddr: "host:7007", wantName: "web-1"},
{name: "address, name, group", input: "host:7007|web-1|prod", wantAddr: "host:7007", wantName: "web-1", wantGroup: "prod"},
{name: "trailing empty group", input: "host:7007|web-1|", wantAddr: "host:7007", wantName: "web-1"},
{name: "empty name with group", input: "host:7007||prod", wantAddr: "host:7007", wantGroup: "prod"},
{name: "empty address rejected", input: "|web-1|prod", wantErr: true},
{name: "too many segments rejected", input: "host:7007|web-1|prod|extra", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addr, name, group, err := ParseEndpoint(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantAddr, addr)
assert.Equal(t, tt.wantName, name)
assert.Equal(t, tt.wantGroup, group)
})
}
}
+9 -2
View File
@@ -26,6 +26,7 @@ type Host struct {
Type string `json:"type"`
Available bool `json:"available"`
Swarm bool `json:"-"`
Group string `json:"group,omitempty"`
}
func (h Host) String() string {
@@ -34,7 +35,7 @@ func (h Host) String() string {
func ParseConnection(connection string) (Host, error) {
parts := strings.Split(connection, "|")
if len(parts) > 2 {
if len(parts) > 3 || parts[0] == "" {
return Host{}, fmt.Errorf("invalid connection string: %s", connection)
}
@@ -44,10 +45,15 @@ func ParseConnection(connection string) (Host, error) {
}
name := remoteUrl.Hostname()
if len(parts) == 2 {
if len(parts) >= 2 && parts[1] != "" {
name = parts[1]
}
group := ""
if len(parts) == 3 {
group = parts[2]
}
basePath, err := filepath.Abs("./certs")
if err != nil {
return Host{}, err
@@ -79,6 +85,7 @@ func ParseConnection(connection string) (Host, error) {
KeyPath: keyPath,
ValidCerts: hasCerts,
Endpoint: remoteUrl.String(),
Group: group,
}, nil
}
+6 -1
View File
@@ -94,6 +94,11 @@ func FromProto(c *pb.Container) Container {
labels = make(map[string]string)
}
env := c.Env
if env == nil {
env = []string{}
}
return Container{
ID: c.Id,
Name: c.Name,
@@ -112,7 +117,7 @@ func FromProto(c *pb.Container) Container {
MemoryLimit: c.MemoryLimit,
CPULimit: c.CpuLimit,
FullyLoaded: c.FullyLoaded,
Env: c.Env,
Env: env,
Ports: c.Ports,
Mounts: c.Mounts,
RestartPolicy: c.RestartPolicy,
@@ -217,12 +217,21 @@ func (m *RetriableClientManager) Hosts(ctx context.Context) []container.Host {
})
for _, endpoint := range m.failedAgents {
addr, name, group, err := agent.ParseEndpoint(endpoint)
if err != nil {
log.Warn().Err(err).Str("endpoint", endpoint).Msg("skipping malformed agent endpoint")
continue
}
if name == "" {
name = addr
}
hosts = append(hosts, container.Host{
ID: endpoint,
Name: endpoint,
Endpoint: endpoint,
Name: name,
Endpoint: addr,
Available: false,
Type: "agent",
Group: group,
})
}
+21
View File
@@ -14,6 +14,7 @@ import (
"io"
"net/http"
"net/url"
"runtime"
"time"
@@ -302,6 +303,26 @@ func (h *handler) streamGroupedLogs(w http.ResponseWriter, r *http.Request) {
})
}
func (h *handler) streamHostGroupLogs(w http.ResponseWriter, r *http.Request) {
group, err := url.PathUnescape(chi.URLParam(r, "group"))
if err != nil || group == "" {
http.Error(w, "invalid group", http.StatusBadRequest)
return
}
hostIDs := make(map[string]struct{})
for _, host := range h.hostService.Hosts() {
if host.Group == group {
hostIDs[host.ID] = struct{}{}
}
}
h.streamLogsForContainers(w, r, func(c *container.Container) bool {
_, ok := hostIDs[c.Host]
return c.State == "running" && ok
})
}
func (h *handler) streamHostLogs(w http.ResponseWriter, r *http.Request) {
host := hostKey(r)
h.streamLogsForContainers(w, r, func(container *container.Container) bool {
+1
View File
@@ -146,6 +146,7 @@ func createRouter(h *handler) *chi.Mux {
r.Get("/containers/{hostIds}/download", h.downloadLogs) // formatted as host:container,host:container
r.Get("/labels/{labels}/logs/stream", h.streamLogsWithLabels)
r.Get("/groups/{group}/logs/stream", h.streamGroupedLogs)
r.Get("/host-groups/{group}/logs/stream", h.streamHostGroupLogs)
r.Get("/events/stream", h.streamEvents)
// Action
+3
View File
@@ -54,12 +54,15 @@ label:
no-logs: Container har ingen logs endnu
show-all-containers: Vis alle containere
collapse-all: Kollaps alle
collapse-group: Kollaps gruppe
expand-all: Udvid alle
tooltip:
search: Søg containere (⌘ + k, ⌃k)
pin-column: Fastgør som kolonne
merge-all: Sammenlæg alt i én stream
error:
page-not-found: Denne side eksisterer ikke
host-group-not-found: 'Ingen værter fundet i gruppen "{name}"'
invalid-auth: Brugernavn eller kodeord er ikke gyldig
copy-not-supported: Kopiering til udklipsholder understøttes ikke i din browser
copy-not-supported-hint: Udklipsholder ikke tilgængelig. Kopiér linket nedenfor
+3
View File
@@ -54,12 +54,15 @@ label:
no-logs: Container hat noch keine Logs
show-all-containers: Zeige alle Container
collapse-all: Alle einklappen
collapse-group: Gruppe einklappen
expand-all: Alle ausklappen
tooltip:
search: Suche Container (⌘ + k, ⌃k)
pin-column: Als Spalte anheften
merge-all: Alles in einen Stream zusammenführen
error:
page-not-found: Diese Seite existiert nicht.
host-group-not-found: 'Keine Hosts in der Gruppe "{name}" gefunden'
invalid-auth: Benutzername und Passwort sind ungültig.
copy-not-supported: Kopieren in die Zwischenablage wird von Ihrem Browser nicht unterstützt
copy-not-supported-hint: Zwischenablage nicht verfügbar. Link unten kopieren
+3
View File
@@ -56,12 +56,15 @@ label:
no-logs: Container has no logs yet
show-all-containers: Show all containers
collapse-all: Collapse all
collapse-group: Collapse group
expand-all: Expand all
tooltip:
search: Search containers (⌘ + k, ⌃k)
pin-column: Pin as column
merge-all: Merge all into one stream
error:
page-not-found: This page does not exist
host-group-not-found: 'No hosts found in group "{name}"'
invalid-auth: Username or password are not valid
copy-not-supported: Copy to clipboard is not supported in your browser
copy-not-supported-hint: Clipboard unavailable. Copy the link below
+3
View File
@@ -54,12 +54,15 @@ label:
no-logs: El contenedor aún no tiene registros
show-all-containers: Mostrar todos los contenedores
collapse-all: Colapsar todo
collapse-group: Colapsar grupo
expand-all: Expandir todo
tooltip:
search: Buscar contenedores (⌘ + K, CTRL + K)
pin-column: Anclar como columna
merge-all: Fusionar todo en un flujo
error:
page-not-found: Esta página no existe.
host-group-not-found: 'No se encontraron hosts en el grupo "{name}"'
invalid-auth: El nombre de usuario y la contraseña no son válidos.
copy-not-supported: Copiar al portapapeles no está soportado en su navegador
copy-not-supported-hint: Portapapeles no disponible. Copie el enlace a continuación
+3
View File
@@ -54,12 +54,15 @@ label:
no-logs: Le conteneur n'a pas encore de logs
show-all-containers: Afficher tous les conteneurs
collapse-all: Réduire tout
collapse-group: Réduire le groupe
expand-all: Développer tout
tooltip:
search: Recherche de conteneurs (⌘ + k, ⌃k)
pin-column: Epinglé en colonne
merge-all: Fusionner tout dans un flux
error:
page-not-found: Cette page n'existe pas
host-group-not-found: 'Aucun hôte trouvé dans le groupe « {name} »'
invalid-auth: Nom d'utilisateur ou mot de passe non valides
copy-not-supported: La copie dans le presse-papiers n'est pas prise en charge par votre navigateur
copy-not-supported-hint: Presse-papiers indisponible. Copiez le lien ci-dessous
+3
View File
@@ -55,6 +55,8 @@ label:
no-logs: Kontainer belum memiliki log
show-all-containers: Tampilkan semua kontainer
collapse-all: Tutup semua
collapse-group: Tutup grup
expand-all: Buka semua
tooltip:
search: Cari kontainer (⌘ + k, ⌃k)
@@ -63,6 +65,7 @@ tooltip:
error:
page-not-found: Halaman ini tidak ada
host-group-not-found: 'Tidak ada host yang ditemukan di grup "{name}"'
invalid-auth: Nama pengguna atau kata sandi tidak valid
copy-not-supported: Salin ke clipboard tidak didukung di browser Anda
copy-not-supported-hint: Clipboard tidak tersedia. Salin tautan di bawah ini
+3
View File
@@ -54,12 +54,15 @@ label:
no-logs: Il container non ha ancora log
show-all-containers: Mostra tutti i container
collapse-all: Comprimi tutto
collapse-group: Comprimi gruppo
expand-all: Espandi tutto
tooltip:
search: Ricerca container (⌘ + k, ⌃k)
pin-column: Blocca come colonna
merge-all: Unisci tutto in un flusso
error:
page-not-found: Questa pagina non esiste
host-group-not-found: 'Nessun host trovato nel gruppo "{name}"'
invalid-auth: Username o password non valide
copy-not-supported: La copia negli appunti non è supportata dal tuo browser
copy-not-supported-hint: Appunti non disponibili. Copia il link qui sotto
+3
View File
@@ -53,12 +53,15 @@ label:
no-logs: 아직 로그가 없습니다
show-all-containers: 전체 컨테이너 보기
collapse-all: 전체 접기
collapse-group: 그룹 접기
expand-all: 전체 펼치기
tooltip:
search: 컨테이너 검색 (⌘ + k, ⌃k)
pin-column: 열로 고정
merge-all: 모두 하나의 스트림으로 병합
error:
page-not-found: 페이지를 찾을 수 없습니다
host-group-not-found: '"{name}" 그룹에 호스트가 없습니다'
invalid-auth: 사용자 이름 또는 비밀번호가 올바르지 않습니다
copy-not-supported: 클립보드 복사는 브라우저에서 지원되지 않습니다
copy-not-supported-hint: 클립보드를 사용할 수 없습니다. 아래 링크를 복사하세요
+3
View File
@@ -54,12 +54,15 @@ label:
no-logs: Container heeft nog geen logbestanden
show-all-containers: Toon alle containers
collapse-all: Alles inklappen
collapse-group: Groep inklappen
expand-all: Alles uitklappen
tooltip:
search: Zoek containers (⌘ + k, ⌃k)
pin-column: Vastzetten als kolom
merge-all: Voeg alles samen in één stream
error:
page-not-found: Deze pagina bestaat niet
host-group-not-found: 'Geen hosts gevonden in groep "{name}"'
invalid-auth: Gebruikersnaam of wachtwoord is ongeldig
copy-not-supported: Kopiëren naar klembord wordt niet ondersteund in je browser
copy-not-supported-hint: Klembord niet beschikbaar. Kopieer de link hieronder
+3
View File
@@ -58,12 +58,15 @@ label:
no-logs: Kontener nie ma jeszcze logów
show-all-containers: Pokaż wszystkie kontenery
collapse-all: Zwiń wszystkie
collapse-group: Zwiń grupę
expand-all: Rozwiń wszystkie
tooltip:
search: Przeszukaj kontenery (⌘ + k, ⌃k)
pin-column: Przypnij jako kolumna
merge-all: Scal wszystko w jeden strumień
error:
page-not-found: Ta strona nie istnieje
host-group-not-found: 'Nie znaleziono hostów w grupie „{name}”'
invalid-auth: Nazwa użytkownika lub hasło są niepoprawne
copy-not-supported: Kopiowanie do schowka nie jest obsługiwane w twojej przeglądarce
copy-not-supported-hint: Schowek niedostępny. Skopiuj poniższy link
+3
View File
@@ -57,12 +57,15 @@ label:
no-logs: O contentor ainda não tem logs
show-all-containers: Mostrar todos os contentores
collapse-all: Recolher tudo
collapse-group: Recolher grupo
expand-all: Expandir tudo
tooltip:
search: Pesquisar contentores (⌘ + K, CTRL + K)
pin-column: Alfinete como coluna
merge-all: Mesclar tudo em um fluxo
error:
page-not-found: Esta página não existe.
host-group-not-found: 'Nenhum host encontrado no grupo "{name}"'
invalid-auth: O nome de usuário e a senha não são válidos.
copy-not-supported: Copiar para a área de transferência não é suportado no seu navegador
copy-not-supported-hint: Área de transferência indisponível. Copie o link abaixo
+3
View File
@@ -53,12 +53,15 @@ label:
no-logs: O container ainda não tem logs
show-all-containers: Mostrar todos os containers
collapse-all: Recolher tudo
collapse-group: Recolher grupo
expand-all: Expandir tudo
tooltip:
search: Pesquisar containers (⌘ + k, ⌃k)
pin-column: Fixar como coluna
merge-all: Unir tudo em um fluxo
error:
page-not-found: Esta página não existe
host-group-not-found: 'Nenhum host encontrado no grupo "{name}"'
invalid-auth: Usuário ou senha inválidos
copy-not-supported: Copiar para a área de transferência não é suportado no seu navegador
copy-not-supported-hint: Área de transferência indisponível. Copie o link abaixo
+3
View File
@@ -54,12 +54,15 @@ label:
no-logs: У контейнера еще нет логов
show-all-containers: Показать все контейнеры
collapse-all: Свернуть все
collapse-group: Свернуть группу
expand-all: Развернуть все
tooltip:
search: Поиск контейнеров (⌘ + k, ⌃k)
pin-column: Закрепить столбец
merge-all: Объединить все в один поток
error:
page-not-found: Эта страница не доступна.
host-group-not-found: 'Хосты в группе «{name}» не найдены'
invalid-auth: Имя пользователя или пароль неверны.
copy-not-supported: Копирование в буфер обмена не поддерживается вашим браузером
copy-not-supported-hint: Буфер обмена недоступен. Скопируйте ссылку ниже
+3
View File
@@ -48,6 +48,8 @@ label:
group-menu: Skupine po meri
show-all-containers: Prikaži vse zabojnike
collapse-all: Strni vse
collapse-group: Strni skupino
expand-all: Razširi vse
container-name: Ime zabojnika
container: Ni zabojnikov | 1 zabojnik | {count} zabojnikov
services: Storitve
@@ -59,6 +61,7 @@ tooltip:
merge-all: Združi vse v en tok
error:
page-not-found: Ta stran ne obstaja
host-group-not-found: 'V skupini "{name}" ni najdenih gostiteljev'
invalid-auth: Uporabniško ime ali geslo nista veljavna
copy-not-supported: Kopiranje v odložišče ni podprto v vašem brskalniku
copy-not-supported-hint: Odložišče ni na voljo. Kopirajte spodnjo povezavo
+3
View File
@@ -59,12 +59,15 @@ label:
no-logs: Konteyner henüz log içermiyor
show-all-containers: Tüm konteynerleri göster
collapse-all: Hepsini daralt
collapse-group: Grubu daralt
expand-all: Hepsini genişlet
tooltip:
search: Konteynerlerde ara (⌘ + k, ⌃k)
pin-column: Sütun olarak sabitle
merge-all: Tümünü tek bir akışta birleştir
error:
page-not-found: Bu sayfa bulunamadı
host-group-not-found: '"{name}" grubunda ana bilgisayar bulunamadı'
invalid-auth: Kullanıcı adı veya şifre geçersiz
copy-not-supported: Panoya kopyalama tarayıcınızda desteklenmiyor
copy-not-supported-hint: Pano kullanılamıyor. Aşağıdaki bağlantıyı kopyalayın
+3
View File
@@ -40,6 +40,8 @@ label:
no-logs: 容器尚無日誌
show-all-containers: 顯示所有容器
collapse-all: 摺疊全部
collapse-group: 摺疊群組
expand-all: 展開全部
host: 主機
hosts: 主機
password: 密碼
@@ -60,6 +62,7 @@ tooltip:
merge-all: 將所有內容合併到一個流中
error:
page-not-found: 此頁面不存在
host-group-not-found: '在群組「{name}」中找不到主機'
invalid-auth: 使用者名稱或密碼不正確
copy-not-supported: 您的瀏覽器不支援複製到剪貼簿
copy-not-supported-hint: 剪貼簿不可用。請複製下方連結
+3
View File
@@ -40,6 +40,8 @@ label:
no-logs: 容器尚无日志
show-all-containers: 显示所有容器
collapse-all: 折叠全部
collapse-group: 折叠组
expand-all: 展开全部
host: 主机
hosts: 主机
password: 密码
@@ -60,6 +62,7 @@ tooltip:
merge-all: 将所有内容合并到一个流中
error:
page-not-found: 此页面不存在。
host-group-not-found: '在组 "{name}" 中未找到主机'
invalid-auth: 用户名和密码无效。
copy-not-supported: 您的浏览器不支持复制到剪贴板
copy-not-supported-hint: 剪贴板不可用。请复制下方链接