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:
7
assets/auto-imports.d.ts
vendored
7
assets/auto-imports.d.ts
vendored
@@ -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']>
|
||||||
|
|||||||
2
assets/components.d.ts
vendored
2
assets/components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -128,4 +128,8 @@ useIntervalFn(
|
|||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user