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
6 changes: 4 additions & 2 deletions admin/ui/e2e/specs/dashboard.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ test.describe('Fleet Dashboard', () => {

const cardCount = await page.getByTestId('instance-card').count();

// Click remove on last card
await page.getByTestId('instance-card').last().getByTestId('instance-delete').click();
// Open the actions menu on the last card and click Remove
const lastCard = page.getByTestId('instance-card').last();
await lastCard.getByTestId('instance-actions').click();
await lastCard.getByTestId('instance-delete').click();

// Confirm dialog
await expect(page.getByTestId('confirm-ok')).toBeVisible();
Expand Down
Binary file added admin/ui/src/assets/cloudblue-logo-white.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion admin/ui/src/components/FleetKpiPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,22 @@ const scopeNote = computed(() => {

.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}

@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

@media (max-width: 520px) {
.grid {
grid-template-columns: 1fr;
}
}

.scope {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
Expand Down
163 changes: 163 additions & 0 deletions admin/ui/src/components/InstanceActionMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<template>
<div ref="root" :class="$style.menu">
<BaseButton
:class="$style.trigger"
size="sm"
variant="ghost"
type="button"
data-testid="instance-actions"
:aria-label="`More actions for ${label}`"
aria-haspopup="menu"
:aria-expanded="open ? 'true' : 'false'"
@click.stop="toggleMenu"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="3" cy="8" r="1" fill="currentColor" stroke="none" />
<circle cx="8" cy="8" r="1" fill="currentColor" stroke="none" />
<circle cx="13" cy="8" r="1" fill="currentColor" stroke="none" />
</svg>
</BaseButton>
<div v-if="open" :class="$style.panel" role="menu">
<button
:class="$style.item"
type="button"
role="menuitem"
data-testid="instance-delete"
@click.stop="handleRemove"
>
Remove
</button>
</div>
</div>
</template>

<script setup>
import { onBeforeUnmount, ref, watch } from 'vue';
import BaseButton from './BaseButton.vue';

defineProps({
label: { type: String, required: true },
});

const emit = defineEmits(['remove']);

const open = ref(false);
const root = ref(null);

function closeMenu() {
open.value = false;
}

function toggleMenu() {
open.value = !open.value;
}

function handleRemove() {
closeMenu();
emit('remove');
}

function handleDocumentClick(event) {
if (!open.value || root.value?.contains(event.target)) {
return;
}

closeMenu();
}

function handleDocumentKeydown(event) {
if (event.key === 'Escape') {
closeMenu();
}
}

watch(open, (isOpen) => {
if (typeof document === 'undefined') {
return;
}

const method = isOpen ? 'addEventListener' : 'removeEventListener';
document[method]('click', handleDocumentClick);
document[method]('keydown', handleDocumentKeydown);
});

onBeforeUnmount(() => {
if (typeof document === 'undefined') {
return;
}

document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keydown', handleDocumentKeydown);
});
</script>

<style module>
.menu {
position: relative;
display: inline-flex;
align-items: center;
}

.trigger {
width: 28px;
height: 28px;
padding: 0;
border-color: var(--color-border);
color: var(--color-text-secondary);
background-color: var(--color-bg-surface);
border-radius: var(--radius-md);
flex-shrink: 0;
}

.trigger:hover:not(:disabled),
.trigger[aria-expanded='true'] {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
border-color: var(--color-border);
}

.panel {
position: absolute;
top: calc(100% + var(--space-2));
right: 0;
min-width: 132px;
padding: var(--space-1);
background-color: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
z-index: 10;
display: flex;
flex-direction: column;
gap: 2px;
}

.item {
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-error);
text-align: left;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
}

.item:hover,
.item:focus-visible {
background-color: var(--color-error-bg);
outline: none;
}
</style>
43 changes: 29 additions & 14 deletions admin/ui/src/components/InstanceCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
>
<div :class="$style.header">
<div :class="$style.titleRow">
<h3 :class="$style.name">{{ instance.name }}</h3>
<h3 :class="$style.name">
<span :class="$style.nameLink">{{ instance.name }}</span>
</h3>
<StatusIndicator
:status="instance.status"
:label="getStatusLabel(instance.status)"
Expand All @@ -20,14 +22,14 @@
<div :class="$style.address">{{ instance.address }}</div>
</div>
<div :class="$style.meta">
<div v-if="instance.version" :class="$style.metaItem">
<div :class="$style.metaItem">
<span :class="$style.metaLabel">Version</span>
<span :class="$style.metaValue">{{ instance.version }}</span>
<span :class="$style.metaValue">{{ instance.version || '—' }}</span>
</div>
<div v-if="instance.last_seen_at" :class="$style.metaItem">
<div :class="$style.metaItem">
<span :class="$style.metaLabel">Last seen</span>
<span :class="$style.metaValue">{{
formatTime(instance.last_seen_at)
formatTime(instance.last_seen_at) || '—'
}}</span>
</div>
</div>
Expand All @@ -40,21 +42,18 @@
>
Edit
</BaseButton>
<BaseButton
size="sm"
variant="ghost"
data-testid="instance-delete"
@click.stop="$emit('delete', instance)"
>
Remove
</BaseButton>
<InstanceActionMenu
:label="instance.name"
@remove="$emit('delete', instance)"
/>
</div>
</BaseCard>
</template>

<script setup>
import BaseCard from './BaseCard.vue';
import BaseButton from './BaseButton.vue';
import InstanceActionMenu from './InstanceActionMenu.vue';
import StatusIndicator from './StatusIndicator.vue';
import { formatTime, getStatusLabel } from '../utils/instance.js';

Expand Down Expand Up @@ -98,6 +97,19 @@ function onCardKeydown(e) {
margin: 0;
}

.nameLink {
color: var(--color-accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
transition: color 0.15s;
}

.card:hover .nameLink,
.card:focus-visible .nameLink {
color: var(--color-accent-hover);
}

.address {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
Expand All @@ -106,7 +118,8 @@ function onCardKeydown(e) {
}

.meta {
display: flex;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-5);
margin-bottom: var(--space-3);
}
Expand All @@ -131,6 +144,8 @@ function onCardKeydown(e) {

.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-light);
Expand Down
47 changes: 25 additions & 22 deletions admin/ui/src/components/InstanceTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,30 @@
@keydown.space="onRowKeydown($event, inst)"
>
<td :class="$style.td">
<StatusIndicator :status="inst.status" />
<StatusIndicator
:status="inst.status"
:label="getStatusLabel(inst.status)"
/>
</td>
<td :class="[$style.td, $style.name]">{{ inst.name }}</td>
<td :class="[$style.td, $style.mono]">{{ inst.address }}</td>
<td :class="$style.td">{{ inst.version || '—' }}</td>
<td :class="$style.td">{{ formatTime(inst.last_seen_at) || '—' }}</td>
<td :class="[$style.td, $style.actionsCol]">
<BaseButton
size="sm"
variant="secondary"
@click.stop="$emit('edit', inst)"
>
Edit
</BaseButton>
<BaseButton
size="sm"
variant="ghost"
@click.stop="$emit('delete', inst)"
>
Remove
</BaseButton>
<div :class="$style.actionGroup">
<BaseButton
size="sm"
variant="secondary"
@click.stop="$emit('edit', inst)"
>
Edit
</BaseButton>
<InstanceActionMenu
:label="inst.name"
@click.stop
@remove="$emit('delete', inst)"
/>
</div>
</td>
</tr>
</tbody>
Expand All @@ -53,9 +56,10 @@
</template>

<script setup>
import InstanceActionMenu from './InstanceActionMenu.vue';
import StatusIndicator from './StatusIndicator.vue';
import BaseButton from './BaseButton.vue';
import { formatTime } from '../utils/instance.js';
import { formatTime, getStatusLabel } from '../utils/instance.js';

defineProps({
instances: { type: Array, required: true },
Expand Down Expand Up @@ -128,11 +132,10 @@ function onRowKeydown(e, inst) {
white-space: nowrap;
}

.actionsCol :global(button) {
margin-left: var(--space-2);
}

.actionsCol :global(button:first-child) {
margin-left: 0;
.actionGroup {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
}
</style>
Loading
Loading