Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **Installable PWA support** (Roadmap Phase 6.9). Drydock is now an installable Progressive Web App via `vite-plugin-pwa`: a web app manifest (`Drydock`, standalone display, theme/background color matched to the One Dark default `--dd-bg`, 192/512 icons plus dedicated maskable variants with safe-zone padding) and an auto-updating service worker (`registerType: 'autoUpdate'`) that precaches the SPA shell so the dashboard boots offline. `/api/**` is explicitly excluded from all service-worker handling — no navigation fallback, no runtime caching — so a live dashboard never serves stale API data from cache; those requests always hit the network and surface a normal error if it's unreachable. A dismissible install banner (new `InstallBanner` component, following the existing `AnnouncementBanner` pattern) listens for the browser's `beforeinstallprompt` event and offers a one-click install, with the dismissal persisted under a versioned localStorage key. iOS home-screen install is supported via `apple-mobile-web-app-capable` and the existing `apple-touch-icon`. The backend's static UI server now serves `sw.js` with `Cache-Control: no-cache` so a new deploy is never masked by a browser-cached service worker script.
- **Clickable port links in the container list and detail views.** Each host-published port in a container's `details.ports` now renders as a link (opened in a new tab, `rel="noopener noreferrer"`) instead of inert text — in the side panel, the full-page detail tabs, and new opt-in "Ports" columns/rows in the table and card views. The scheme is auto-detected from the container-side port (`443`/`8443` → `https://`, everything else → `http://`); the link target host prefers the port's own bound `HostIp` when it's a real address (not `0.0.0.0`/`::`/`::0`), falling back to the agent's configured host for agent-watched containers, or the browser's own hostname otherwise. Internal-only (unpublished) ports still render as plain text. A new `dd.port.label` container label lets you attach a friendly name to a specific port (`dd.port.label=80=Web UI,443=Admin Console`) shown in place of the raw `hostPort->containerPort/protocol` mapping.
- **Container uptime, with a live-refreshing display.** The existing `details.startedAt` field (from Docker's `State.StartedAt`) now also drives an opt-in "Uptime" tooltip showing the exact start timestamp in the container list, and a live "Up …" indicator in the card view's footer — both refresh on a timer and update immediately on SSE container-state changes, matching the full-page detail view's existing uptime display.
- **Keyboard shortcuts.** `/` focuses the search bar from anywhere (unless focus is already in a text input), `Escape` closes the search bar, and `?` opens a new shortcut-reference overlay listing the available shortcuts. A `/` hint now sits next to the existing `⌘K` hint on the sidebar search button.

### Removed

Expand Down
2 changes: 2 additions & 0 deletions app/model/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export interface Container {
tagFamily?: string;
tagPinInfo?: boolean;
linkTemplate?: string;
portLabel?: string;
link?: string;
actionTriggerInclude?: string;
actionTriggerExclude?: string;
Expand Down Expand Up @@ -348,6 +349,7 @@ const schema = joi.object({
tagFamily: joi.string(),
tagPinInfo: joi.boolean(),
linkTemplate: joi.string(),
portLabel: joi.string(),
link: joi.string(),
actionTriggerInclude: joi.string(),
actionTriggerExclude: joi.string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ describe('Docker Watcher', () => {
expect(result).toHaveLength(0);
});

test('should pass dd.port.label through to the container as portLabel', async () => {
const containers = [
{
Id: 'dd-port-label-1',
Labels: { 'dd.watch': 'true', 'dd.port.label': '80=Web UI' },
Names: ['/dd-port-label-test'],
},
];
mockDockerApi.listContainers.mockResolvedValue(containers);
docker.addImageDetailsToContainer = vi.fn().mockResolvedValue({ id: 'dd-port-label-1' });

await docker.register('watcher', 'docker', 'test', {
watchbydefault: false,
});
await docker.getContainers();

expect(docker.addImageDetailsToContainer.mock.calls[0][1].portLabel).toBe('80=Web UI');
});

test('should prefer dd.tag.include over wud.tag.include label', async () => {
const containers = [
{
Expand Down
2 changes: 2 additions & 0 deletions app/watchers/providers/docker/Docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
ddDisplayIcon,
ddDisplayName,
ddLinkTemplate,
ddPortLabel,
ddRegistryLookupImage,
ddRegistryLookupUrl,
ddTagExclude,
Expand Down Expand Up @@ -1288,6 +1289,7 @@ class Docker extends Watcher<DockerWatcherConfiguration> {
tagFamily: getLabel(container.Labels, ddTagFamily),
tagPinInfo: getLabel(container.Labels, ddTagPinInfo),
linkTemplate: getLabel(container.Labels, ddLinkTemplate),
portLabel: getLabel(container.Labels, ddPortLabel),
displayName: getLabel(container.Labels, ddDisplayName),
displayIcon: getLabel(container.Labels, ddDisplayIcon),
...resolveTriggerLabelOverrides(container.Labels),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,14 @@ describe('container-init coverage', () => {
expect(container.linkTemplate).toBe('https://example.com/${major}');
});

test('derives portLabel from dd.port.label label', () => {
const container = makeContainer();
applyDerivedLabelFieldsToContainer(container, {
'dd.port.label': '80=Web UI,443/tcp=Admin Console',
});
expect(container.portLabel).toBe('80=Web UI,443/tcp=Admin Console');
});

test('derives triggerInclude from dd.action.include label', () => {
const container = makeContainer();
applyDerivedLabelFieldsToContainer(container, { 'dd.action.include': 'my-action' });
Expand Down
9 changes: 9 additions & 0 deletions app/watchers/providers/docker/container-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ddLinkTemplate,
ddNotificationExclude,
ddNotificationInclude,
ddPortLabel,
ddRegistryLookupImage,
ddRegistryLookupUrl,
ddTagExclude,
Expand Down Expand Up @@ -67,6 +68,7 @@ interface ResolvedContainerLabelOverrides {
inspectTagPath?: string;
inspectTagVersionOnly?: string;
linkTemplate?: string;
portLabel?: string;
displayName?: string;
displayIcon?: string;
actionTriggerInclude?: string;
Expand Down Expand Up @@ -178,6 +180,11 @@ const containerLabelOverrideMappings = [
ddKey: ddLinkTemplate,
overrideKey: 'linkTemplate',
},
{
key: 'portLabel',
ddKey: ddPortLabel,
overrideKey: 'portLabel',
},
{ key: 'displayName', ddKey: ddDisplayName, overrideKey: 'displayName' },
{ key: 'displayIcon', ddKey: ddDisplayIcon, overrideKey: 'displayIcon' },
// Trigger include/exclude are NOT in this generic table: dd.action.*/dd.notification.*/
Expand Down Expand Up @@ -837,6 +844,7 @@ export function applyDerivedLabelFieldsToContainer(
const tagPinInfo = getContainerConfigBooleanValue(resolved.tagPinInfo);
container.tagPinInfo = tagPinInfo ?? tagPolicyFallbacks.tagPinInfo;
container.linkTemplate = resolved.linkTemplate;
container.portLabel = resolved.portLabel;
container.actionTriggerInclude = resolved.actionTriggerInclude;
container.actionTriggerExclude = resolved.actionTriggerExclude;
container.notificationTriggerInclude = resolved.notificationTriggerInclude;
Expand Down Expand Up @@ -995,6 +1003,7 @@ export function mergeConfigWithImgset(
matchingImgset?.inspectTagPath,
),
inspectTagVersionOnly: labelOverrides.inspectTagVersionOnly,
portLabel: labelOverrides.portLabel,
watchDigest: getContainerConfigValue(
getLabel(containerLabels, ddWatchDigest),
matchingImgset?.watchDigest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface ContainerLabelOverrides {
tagFamily?: string;
tagPinInfo?: string;
linkTemplate?: string;
portLabel?: string;
displayName?: string;
displayIcon?: string;
actionTriggerInclude?: string;
Expand Down Expand Up @@ -110,6 +111,7 @@ interface ResolvedContainerLabelOverrides {
tagFamily?: string;
tagPinInfo?: string;
linkTemplate?: string;
portLabel?: string;
displayName?: string;
displayIcon?: string;
actionTriggerInclude?: string;
Expand All @@ -130,6 +132,7 @@ interface ResolvedContainerConfig {
tagFamily?: string;
tagPinInfo?: boolean;
linkTemplate?: string;
portLabel?: string;
displayName?: string;
displayIcon?: string;
actionTriggerInclude?: string;
Expand Down Expand Up @@ -851,6 +854,7 @@ export async function addImageDetailsToContainerOrchestration(
tagFamily: resolvedConfig.tagFamily,
tagPinInfo: resolvedConfig.tagPinInfo,
linkTemplate: resolvedConfig.linkTemplate,
portLabel: resolvedConfig.portLabel,
displayName: getContainerDisplayName(
dockerContainerName,
parsedImage.path,
Expand Down
7 changes: 7 additions & 0 deletions app/watchers/providers/docker/label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export const ddDisplayName = 'dd.display.name';
*/
export const ddDisplayIcon = 'dd.display.icon';

/**
* Optional custom label for a specific published port, shown in the UI's
* clickable port links. Comma-separated `<port>=<label>` pairs, e.g.
* `80=Web UI,443/tcp=Admin Console`. Bare port numbers default to /tcp.
*/
export const ddPortLabel = 'dd.port.label';

/**
* Optional list of triggers to include
*/
Expand Down
31 changes: 31 additions & 0 deletions content/docs/current/configuration/watchers/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,7 @@ To fine-tune the behaviour of drydock _per container_, you can add labels on the
| `dd.display.icon` | ⚪ | Custom display icon for the container | Valid [Fontawesome Icon](https://fontawesome.com/), [Homarr Labs Icon](https://dashboardicons.com/), [Selfh.st Icon](https://selfh.st/icons/), or [Simple Icon](https://simpleicons.org/) (see details below). `mdi:` icons are auto-resolved but not recommended. | `fab fa-docker` |
| `dd.display.picture` | ⚪ | Custom entity picture URL for Home Assistant MQTT integration. When set to an HTTP/HTTPS URL, overrides the icon-derived `entity_picture` in HASS discovery payloads. | Valid HTTP or HTTPS URL | |
| `dd.display.name` | ⚪ | Custom display name for the container | Valid String | Container name |
| `dd.port.label` | ⚪ | Custom label for a specific published port, shown on the clickable port links in the UI's container list and detail views | Comma-separated `<port>=<label>` pairs, e.g. `80=Web UI,443/tcp=Admin Console`. Bare port numbers default to `/tcp`. | Raw port mapping (e.g. `8080->80/tcp`) |
| `dd.group` | ⚪ | Durable server-side group name for stack/group views (falls back to `com.docker.compose.project`, then `com.docker.stack.namespace`). A browser-only manual override can temporarily supersede this presentation without changing the label. | Valid String | |
| `dd.inspect.tag.path` | ⚪ | Docker inspect path used to derive a local semver tag. The extracted value overwrites the image tag (for update detection) **and** is written to `image.softwareVersion` (for the Version column). Use `dd.inspect.tag.version-only=true` to route it to `image.softwareVersion` only. | Slash-separated path in `docker inspect` output | |
| `dd.inspect.tag.version-only` | ⚪ | When `dd.inspect.tag.path` is set, route the extracted value to `image.softwareVersion` only instead of overwriting the image tag. Default behavior (tag overwrite) is unchanged when this label is absent or `false`. | `true`, `false` | `false` |
Expand Down Expand Up @@ -1206,6 +1207,33 @@ docker run -d --name mariadb --label 'dd.display.name=Maria DB' --label 'dd.disp
</Tab>
</Tabs>

### Label a specific port

You can attach a custom label to one of a container's published ports. It's shown on the clickable port link in the container list and detail views instead of the raw `hostPort->containerPort/protocol` mapping. Keys are `<port>=<label>` pairs, comma-separated for multiple ports; a bare port number matches `/tcp` by default.

<Tabs items={["Docker Compose (Port Label)", "Docker (Port Label)"]}>
<Tab value="Docker Compose (Port Label)">

```yaml
services:

traefik:
image: traefik:3
ports:
- "80:80"
- "8443:443"
labels:
- dd.port.label=80=Web UI,443=Admin Console
```

</Tab>
<Tab value="Docker (Port Label)">
```bash
docker run -d --name traefik -p 80:80 -p 8443:443 --label 'dd.port.label=80=Web UI,443=Admin Console' traefik:3
```
</Tab>
</Tabs>

### Assign different triggers to containers

You can assign different triggers and thresholds on a per container basis.
Expand Down Expand Up @@ -1256,6 +1284,9 @@ Each monitored container exposes runtime details sourced from Docker inspect and
| `details.ports` | `string[]` | Published port mappings (e.g. `8080->80/tcp`, `443/tcp`) |
| `details.volumes` | `string[]` | Volume and bind mounts (e.g. `myvolume:/data`, `/host/path:/container/path:ro`) |
| `details.env` | `{ key, value }[]` | Environment variables set on the container |
| `details.startedAt` | `string (ISO 8601)` | When the container's current run started (`State.StartedAt` from Docker inspect). Drives the live "Up …" uptime display in the container list and detail views; absent for stopped containers. |

In the UI, each host-published port in `details.ports` renders as a clickable link (opened in a new tab). The target scheme is auto-detected from the container-side port — `443` and `8443` get `https://`, everything else gets `http://` — and the host is the published `HostIp` when it's a real bindable address, falling back to the agent's configured host for agent-watched containers, or the browser's own hostname otherwise. Internal-only (unpublished) ports render as plain text. Use [`dd.port.label`](#labels) to give a port a friendly display name instead of the raw mapping.

## Container Status Fields

Expand Down
1 change: 1 addition & 0 deletions ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ useGlobalUpdateToast();
<template>
<router-view />
<ConfirmDialog />
<KeyboardShortcutsOverlay />
<AppToast />
</template>
92 changes: 92 additions & 0 deletions ui/src/components/KeyboardShortcutsOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useFocusTrap } from '../composables/useFocusTrap';
import { useShortcutsOverlay } from '../composables/useShortcutsOverlay';

const { t } = useI18n();

const { visible, close } = useShortcutsOverlay();
const dialogTitleId = 'keyboard-shortcuts-title';
const dialogRef = ref<HTMLElement | null>(null);

useFocusTrap(dialogRef, visible);

const shortcuts = computed(() => [
{ keys: ['/'], description: t('appShell.layout.shortcuts.focusSearch') },
{ keys: ['Esc'], description: t('appShell.layout.shortcuts.closeSearch') },
{ keys: ['?'], description: t('appShell.layout.shortcuts.showHelp') },
{ keys: ['⌘', 'K'], description: t('appShell.layout.shortcuts.toggleSearch') },
]);
</script>

<template>
<Teleport to="body">
<Transition name="shortcuts-fade">
<div v-if="visible"
class="fixed inset-0 z-overlay bg-black/50 backdrop-blur-sm flex items-start justify-center pt-[20vh]"
@pointerdown.self="close">
<div ref="dialogRef"
class="relative w-full max-w-[var(--dd-layout-dialog-max-width)] min-w-[var(--dd-layout-dialog-min-width)] mx-4 dd-rounded-lg overflow-hidden"
data-test="keyboard-shortcuts-overlay"
role="dialog"
tabindex="-1"
aria-modal="true"
:aria-labelledby="dialogTitleId"
:style="{
backgroundColor: 'var(--dd-bg-card)',
border: '1px solid var(--dd-border-strong)',
boxShadow: 'var(--dd-shadow-modal)',
}">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<!-- Header -->
<div class="px-5 pt-4 pb-3"
:style="{ borderBottom: '1px solid var(--dd-border)' }">
<span :id="dialogTitleId" class="text-xs-plus font-semibold dd-text">{{ t('appShell.layout.shortcuts.title') }}</span>
</div>

<!-- Body -->
<div class="px-5 py-4.5 flex flex-col gap-2.5">
<div v-for="shortcut in shortcuts" :key="shortcut.description"
class="flex items-center justify-between gap-4 text-xs dd-text-secondary">
<span>{{ shortcut.description }}</span>
<span class="flex items-center gap-1 shrink-0">
<kbd v-for="key in shortcut.keys" :key="key"
class="px-1.5 py-0.5 dd-rounded-sm text-2xs font-medium dd-text-secondary" style="background: var(--dd-border);">
{{ key }}
</kbd>
</span>
</div>
</div>

<!-- Footer -->
<div class="px-5 pt-3 pb-4.5 flex items-center justify-end"
:style="{ borderTop: '1px solid var(--dd-border)' }">
<AppButton size="none" variant="plain" weight="none"
class="px-4 py-1.5 dd-rounded text-2xs-plus font-semibold transition-colors cursor-pointer"
data-test="keyboard-shortcuts-close"
:aria-label="t('appShell.layout.shortcuts.close')"
:style="{
backgroundColor: 'var(--dd-bg-inset)',
border: '1px solid var(--dd-border-strong)',
color: 'var(--dd-text)',
}"
@click="close">
{{ t('appShell.layout.shortcuts.close') }}
</AppButton>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

<style scoped>
.shortcuts-fade-enter-active,
.shortcuts-fade-leave-active {
transition: opacity var(--dd-duration-fast) ease;
}
.shortcuts-fade-enter-from,
.shortcuts-fade-leave-to {
opacity: 0;
}
</style>
17 changes: 15 additions & 2 deletions ui/src/components/containers/ContainerFullPageOverviewTab.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import AppBadge from '@/components/AppBadge.vue';
import AppButton from '../AppButton.vue';
import SuggestedTagBadge from './SuggestedTagBadge.vue';
import ContainerLinkActions from './ContainerLinkActions.vue';
import ContainerPortEntry from './ContainerPortEntry.vue';
import NoUpdateReasonBadge from './NoUpdateReasonBadge.vue';
import { getUpdateKindLabel as resolveUpdateKindLabel } from '../../utils/update-kind-labels';
import { useContainersViewTemplateContext } from './containersViewTemplateContext';
import type { Container } from '../../types/container';
import { enrichContainerPorts } from '../../utils/ports';
import { useAgentHosts } from '../../composables/useAgentHosts';

const { t } = useI18n();

Expand Down Expand Up @@ -47,6 +51,15 @@ const {
registryLabel,
updateKindColor,
} = useContainersViewTemplateContext();

const { resolveHost } = useAgentHosts();
const enrichedPorts = computed(() =>
enrichContainerPorts(
selectedContainer.value.details.ports,
selectedContainer.value.portLabel,
resolveHost(selectedContainer.value.agent, window.location.hostname),
),
);
</script>

<template>
Expand All @@ -61,11 +74,11 @@ const {
</div>
<div class="p-4">
<div v-if="selectedContainer.details.ports.length > 0" class="space-y-1.5">
<div v-for="port in selectedContainer.details.ports" :key="port"
<div v-for="entry in enrichedPorts" :key="entry.raw"
class="flex items-center gap-2 px-3 py-2 dd-rounded text-xs font-mono"
:style="{ backgroundColor: 'var(--dd-bg-inset)' }">
<AppIcon name="network" :size="10" class="dd-text-muted" />
<span class="dd-text">{{ port }}</span>
<ContainerPortEntry :href="entry.href" :label="entry.label" />
</div>
</div>
<p v-else class="text-2xs-plus dd-text-muted italic">{{ t('containerComponents.fullPageOverview.noPortsExposed') }}</p>
Expand Down
Loading
Loading