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>
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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`));
|
||||
|
||||
@@ -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>
|
||||
@@ -31,6 +31,7 @@ export interface Profile {
|
||||
visibleKeys?: Map<string, Map<string[], boolean>>;
|
||||
releaseSeen?: string;
|
||||
collapsedGroups?: Set<string>;
|
||||
collapsedHostGroups?: Set<string>;
|
||||
cloudWelcomeShown?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Host = {
|
||||
available: boolean;
|
||||
dockerVersion: string;
|
||||
agentVersion: string;
|
||||
group?: string;
|
||||
};
|
||||
|
||||
const hosts = ref(
|
||||
|
||||
@@ -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]'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 클립보드를 사용할 수 없습니다. 아래 링크를 복사하세요
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: Буфер обмена недоступен. Скопируйте ссылку ниже
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 剪貼簿不可用。請複製下方連結
|
||||
|
||||
@@ -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: 剪贴板不可用。请复制下方链接
|
||||
|
||||