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

feat: allows containers to be pinned (#2350)

* feat: allows containers to be pinned

* adds animation

* clean up

* updates int test

* fixes tests
This commit is contained in:
Amir Raminfar
2023-08-15 14:16:32 -07:00
committed by GitHub
parent 2ec0d7bd4e
commit 076f62bac7
12 changed files with 110 additions and 21 deletions

View File

@@ -60,6 +60,7 @@ declare global {
const isDefined: typeof import('@vueuse/core')['isDefined'] const isDefined: typeof import('@vueuse/core')['isDefined']
const isMobile: typeof import('./composables/media')['isMobile'] const isMobile: typeof import('./composables/media')['isMobile']
const isObject: typeof import('./utils/index')['isObject'] const isObject: typeof import('./utils/index')['isObject']
const isPinnedContainer: typeof import('./composables/storage')['isPinnedContainer']
const isProxy: typeof import('vue')['isProxy'] const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive'] const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly'] const isReadonly: typeof import('vue')['isReadonly']
@@ -95,6 +96,7 @@ declare global {
const onUpdated: typeof import('vue')['onUpdated'] const onUpdated: typeof import('vue')['onUpdated']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const persistentVisibleKeys: typeof import('./composables/storage')['persistentVisibleKeys'] const persistentVisibleKeys: typeof import('./composables/storage')['persistentVisibleKeys']
const pinnedContainers: typeof import('./composables/storage')['pinnedContainers']
const provide: typeof import('vue')['provide'] const provide: typeof import('vue')['provide']
const reactify: typeof import('@vueuse/core')['reactify'] const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
@@ -139,6 +141,7 @@ declare global {
const toRef: typeof import('vue')['toRef'] const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs'] const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue'] const toValue: typeof import('vue')['toValue']
const togglePinnedContainer: typeof import('./composables/storage')['togglePinnedContainer']
const triggerRef: typeof import('vue')['triggerRef'] const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount'] const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount'] const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
@@ -432,6 +435,7 @@ declare module 'vue' {
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly persistentVisibleKeys: UnwrapRef<typeof import('./composables/storage')['persistentVisibleKeys']> readonly persistentVisibleKeys: UnwrapRef<typeof import('./composables/storage')['persistentVisibleKeys']>
readonly pinnedContainers: UnwrapRef<typeof import('./composables/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']> readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']> readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']> readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
@@ -476,6 +480,7 @@ declare module 'vue' {
readonly toRef: UnwrapRef<typeof import('vue')['toRef']> readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']> readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']> readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly togglePinnedContainer: UnwrapRef<typeof import('./composables/storage')['togglePinnedContainer']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']> readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']> readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']> readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
@@ -763,6 +768,7 @@ declare module '@vue/runtime-core' {
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly persistentVisibleKeys: UnwrapRef<typeof import('./composables/storage')['persistentVisibleKeys']> readonly persistentVisibleKeys: UnwrapRef<typeof import('./composables/storage')['persistentVisibleKeys']>
readonly pinnedContainers: UnwrapRef<typeof import('./composables/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']> readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']> readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']> readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
@@ -807,6 +813,7 @@ declare module '@vue/runtime-core' {
readonly toRef: UnwrapRef<typeof import('vue')['toRef']> readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']> readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']> readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly togglePinnedContainer: UnwrapRef<typeof import('./composables/storage')['togglePinnedContainer']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']> readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']> readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']> readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>

View File

@@ -11,6 +11,8 @@ declare module 'vue' {
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default'] 'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default'] 'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default'] 'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
'Carbon:star': typeof import('~icons/carbon/star')['default']
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default'] 'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default']
'Cil:circle': typeof import('~icons/cil/circle')['default'] 'Cil:circle': typeof import('~icons/cil/circle')['default']
'Cil:columns': typeof import('~icons/cil/columns')['default'] 'Cil:columns': typeof import('~icons/cil/columns')['default']

View File

@@ -1,11 +1,15 @@
<template> <template>
<div class="columns is-marginless has-text-weight-bold is-family-monospace"> <div class="columns is-marginless has-text-weight-bold is-family-monospace">
<div class="column is-narrow is-hidden-mobile" v-if="container.health">
<container-health :health="container.health"></container-health>
</div>
<div class="column is-ellipsis"> <div class="column is-ellipsis">
{{ container.name }}<span v-if="container.isSwarm">{{ container.swarmId }}</span> <container-health :health="container.health" v-if="container.health"></container-health>
<span class="name">
{{ container.name }}<span v-if="container.isSwarm" class="swarm-id">{{ container.swarmId }}</span>
</span>
<tag class="is-hidden-mobile">{{ container.image.replace(/@sha.*/, "") }}</tag> <tag class="is-hidden-mobile">{{ container.image.replace(/@sha.*/, "") }}</tag>
<span class="icon is-clickable" @click="togglePinnedContainer(container.storageKey)">
<carbon:star-filled v-if="pinned" />
<carbon:star v-else />
</span>
</div> </div>
</div> </div>
</template> </template>
@@ -15,10 +19,23 @@ import { Container } from "@/models/Container";
import { type ComputedRef } from "vue"; import { type ComputedRef } from "vue";
const container = inject("container") as ComputedRef<Container>; const container = inject("container") as ComputedRef<Container>;
const pinned = computed(() => pinnedContainers.value.has(container.value.storageKey));
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.icon { .icon {
vertical-align: middle; vertical-align: middle;
} }
.name {
.swarm-id {
display: none;
}
&:hover {
.swarm-id {
display: inline;
}
}
}
</style> </style>

View File

@@ -5,9 +5,6 @@
<li> <li>
<a href="#" @click.prevent="setHost(null)">{{ hosts[sessionHost].name }}</a> <a href="#" @click.prevent="setHost(null)">{{ hosts[sessionHost].name }}</a>
</li> </li>
<li class="is-active">
<a href="#" aria-current="page">{{ $t("label.containers") }}</a>
</li>
</ul> </ul>
<ul v-else> <ul v-else>
<li>Hosts</li> <li>Hosts</li>
@@ -19,9 +16,12 @@
<a @click.prevent="setHost(host.id)">{{ host.name }}</a> <a @click.prevent="setHost(host.id)">{{ host.name }}</a>
</li> </li>
</ul> </ul>
<ul class="menu-list" v-else> <transition-group tag="ul" name="list" class="menu-list" v-else>
<li v-for="item in sortedContainers" :key="item.id" :class="item.state"> <li v-for="item in menuItems" :key="item.id" :class="item.state" :data-label="item.id">
<popup> <div class="menu-label mt-4 mb-3" v-if="isLabel(item)">
{{ item.label }}
</div>
<popup v-else>
<router-link <router-link
:to="{ name: 'container-id', params: { id: item.id } }" :to="{ name: 'container-id', params: { id: item.id } }"
active-class="is-active" active-class="is-active"
@@ -51,7 +51,7 @@
</template> </template>
</popup> </popup>
</li> </li>
</ul> </transition-group>
</transition> </transition>
</div> </div>
<ul class="menu-list is-hidden-mobile has-light-opacity" v-else> <ul class="menu-list is-hidden-mobile has-light-opacity" v-else>
@@ -63,6 +63,8 @@
import { Container } from "@/models/Container"; import { Container } from "@/models/Container";
import { sessionHost } from "@/composables/storage"; import { sessionHost } from "@/composables/storage";
const { t } = useI18n();
const store = useContainerStore(); const store = useContainerStore();
const { activeContainers, visibleContainers, ready } = storeToRefs(store); const { activeContainers, visibleContainers, ready } = storeToRefs(store);
@@ -71,6 +73,7 @@ function setHost(host: string | null) {
sessionHost.value = host; sessionHost.value = host;
} }
const debouncedIds = debouncedRef(pinnedContainers, 200);
const sortedContainers = computed(() => const sortedContainers = computed(() =>
visibleContainers.value visibleContainers.value
.filter((c) => c.host === sessionHost.value) .filter((c) => c.host === sessionHost.value)
@@ -85,6 +88,36 @@ const sortedContainers = computed(() =>
}), }),
); );
const groupedContainers = computed(() =>
sortedContainers.value.reduce(
(acc, item) => {
if (debouncedIds.value.has(item.storageKey)) {
acc.pinned.push(item);
} else {
acc.unpinned.push(item);
}
return acc;
},
{ pinned: [] as Container[], unpinned: [] as Container[] },
),
);
type MenuLabel = { label: string; id: string; state: string };
const pinnedLabel = { label: t("label.pinned"), id: "pinned", state: "label" } as MenuLabel;
const allLabel = { label: t("label.containers"), id: "all", state: "label" } as MenuLabel;
function isLabel(item: Container | MenuLabel): item is MenuLabel {
return (item as MenuLabel).label !== undefined;
}
const menuItems = computed(() => {
if (groupedContainers.value.pinned.length > 0) {
return [pinnedLabel, ...groupedContainers.value.pinned, allLabel, ...groupedContainers.value.unpinned];
} else {
return [allLabel, ...groupedContainers.value.unpinned];
}
});
const hosts = computed(() => const hosts = computed(() =>
config.hosts.reduce( config.hosts.reduce(
(acc, item) => { (acc, item) => {
@@ -110,10 +143,6 @@ const activeContainersById = computed(() =>
opacity: 0.5; opacity: 0.5;
} }
.has-light-opacity {
opacity: 0.5;
}
li.exited a, li.exited a,
li.dead a { li.dead a {
color: #777; color: #777;
@@ -165,4 +194,20 @@ a {
opacity: 0; opacity: 0;
transform: translateX(100%); transform: translateX(100%);
} }
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.19s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
position: absolute;
}
</style> </style>

View File

@@ -1,13 +1,22 @@
import { Container } from "@/models/Container"; import { Container } from "@/models/Container";
const sessionHost = useSessionStorage<string | null>("host", null); export const sessionHost = useSessionStorage<string | null>("host", null);
if (config.hosts.length === 1 && !sessionHost.value) { if (config.hosts.length === 1 && !sessionHost.value) {
sessionHost.value = config.hosts[0].id; sessionHost.value = config.hosts[0].id;
} }
function persistentVisibleKeys(container: ComputedRef<Container>) { export function persistentVisibleKeys(container: ComputedRef<Container>) {
return computed(() => useStorage(stripVersion(container.value.image) + ":" + container.value.command, [])); return computed(() => useStorage(container.value.storageKey, []));
} }
export { sessionHost, persistentVisibleKeys }; const DOZZLE_PINNED_CONTAINERS = "DOZZLE_PINNED_CONTAINERS";
export const pinnedContainers = useStorage(DOZZLE_PINNED_CONTAINERS, new Set<string>());
export function togglePinnedContainer(id: string) {
if (pinnedContainers.value.has(id)) {
pinnedContainers.value.delete(id);
} else {
pinnedContainers.value.add(id);
}
}

View File

@@ -49,6 +49,10 @@ export class Container {
return unref(this._stat); return unref(this._stat);
} }
get storageKey() {
return `${stripVersion(this.image)}:${this.command}`;
}
public updateStat(stat: Stat) { public updateStat(stat: Stat) {
if (isRef(this._stat)) { if (isRef(this._stat)) {
this._stat.value = stat; this._stat.value = stat;

View File

@@ -128,4 +128,8 @@ useIntervalFn(
padding-top: 1em; padding-top: 1em;
padding-bottom: 1em; padding-bottom: 1em;
} }
.section + .section {
padding-top: 0;
}
</style> </style>

View File

@@ -5,5 +5,5 @@ test("authentication", async ({ page }) => {
await page.locator('input[name="username"]').fill("foo"); await page.locator('input[name="username"]').fill("foo");
await page.locator('input[name="password"]').fill("bar"); await page.locator('input[name="password"]').fill("bar");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator(".menu-label [aria-current]")).toHaveText("Containers"); await expect(page.locator("[data-label=all].label")).toHaveText("Containers");
}); });

View File

@@ -34,6 +34,6 @@ test.describe("es locale", () => {
test.use({ locale: "es" }); test.use({ locale: "es" });
test("translated text", async ({ page }) => { test("translated text", async ({ page }) => {
await expect(page.locator(".menu-label [aria-current]").getByText("Contenedores")).toBeVisible(); await expect(page.locator("[data-label=all].label")).toHaveText("Contenedores");
}); });
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -19,6 +19,7 @@ label:
created: Created created: Created
avg-cpu: Avg. CPU (%) avg-cpu: Avg. CPU (%)
avg-mem: Avg. MEM (%) avg-mem: Avg. MEM (%)
pinned: Pinned
tooltip: tooltip:
search: Search containers (⌘ + k, ⌃k) search: Search containers (⌘ + k, ⌃k)
pin-column: Pin as column pin-column: Pin as column