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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Global Home Assistant connection status indicator in the bottom bar, always visible: green when connected, red when disconnected or not configured. Clicking it opens a popover with the per-service status detail and a button to jump straight to the Home Assistant settings (the External Services page lands pre-filtered on the relevant adapter) (#20).
- Unit-of-measure fields in configuration forms are now selected through a segmented control (e.g. `W` / `kW` / `MW`, `Wh` / `kWh` / `MWh`, `GH/s` / `TH/s` / `PH/s`) instead of a free-text input, preventing inconsistent or invalid values. The available options are inferred automatically from each field, so the control applies to every configuration form (#18).
- Home Assistant entity fields now show a selectable entity-domain prefix (e.g. `sensor.`, `switch.`) as a dropdown next to the input, so the user only types the entity object id. The domain defaults to the one derived from the field's value/default/name and can be overridden, with the prefix now enabled on Forecast Provider and Miner Controller forms too (handling controllers that mix `switch.` and `sensor.` entities) (#39).

Expand Down
230 changes: 211 additions & 19 deletions frontend/src/components/BottomBar.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,142 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { computed, onMounted, onBeforeUnmount, ref, nextTick } from "vue";
import { useRouter } from "vue-router";
import { useExternalServiceStore } from "../core/stores/externalServiceStore";
import { useAppStore } from "../core/stores/appStore";
import type { ExternalServiceStatusType } from "../core/models/externalService";
import { PhHouse, PhArrowRight } from "@phosphor-icons/vue";

type IndicatorStatus = ExternalServiceStatusType | "not_configured" | "unknown";

const store = useExternalServiceStore();
const appStore = useAppStore();
const router = useRouter();

const HA_ADAPTER_PREFIX = "home_assistant";

const badgeClass: Record<ExternalServiceStatusType, string> = {
const badgeClass: Record<IndicatorStatus, string> = {
connected: "border-success text-success bg-success/10",
disconnected: "border-error text-error bg-error/10",
unauthorized: "border-warning text-warning bg-warning/10",
not_configured: "border-error text-error bg-error/10",
unknown: "border-error text-error bg-error/10",
};

const statusClass: Record<ExternalServiceStatusType, string> = {
const statusClass: Record<IndicatorStatus, string> = {
connected: "status-success",
disconnected: "status-error",
unauthorized: "status-warning",
not_configured: "status-error",
unknown: "status-error",
};

const statusLabel: Record<IndicatorStatus, string> = {
connected: "Connected",
disconnected: "Disconnected",
unauthorized: "Unauthorized",
not_configured: "Not configured",
unknown: "Unknown",
};

// Home Assistant services (api and/or mqtt adapters)
const haServices = computed(() =>
store.externalServices.filter((s) => s.adapter_type.startsWith(HA_ADAPTER_PREFIX))
);

interface HaServiceStatus {
name: string;
adapter_type: string;
status: IndicatorStatus;
error_message?: string;
}

// Aggregated HA indicator: always present, even when HA is not configured.
const haIndicator = computed<{ status: IndicatorStatus; services: HaServiceStatus[] }>(() => {
const services: HaServiceStatus[] = haServices.value.map((s) => {
const st = s.id ? store.serviceStatuses.get(String(s.id)) : undefined;
return {
name: s.name,
adapter_type: s.adapter_type,
status: st?.status ?? "unknown",
error_message: st?.error_message,
};
});

let status: IndicatorStatus;
if (services.length === 0) {
status = "not_configured";
} else if (services.every((s) => s.status === "connected")) {
status = "connected";
} else if (services.some((s) => s.status === "unauthorized")) {
status = "unauthorized";
} else {
status = "disconnected";
}

return { status, services };
});

// Non-HA service badges (kept as before).
const otherStatuses = computed(() => {
const haIds = new Set(haServices.value.map((s) => String(s.id)));
return [...store.serviceStatuses.entries()]
.filter(([id]) => !haIds.has(id))
.map(([, status]) => status);
});

// --- Popover (teleported to body so the bottom bar's overflow does not clip it) ---
const showPopover = ref(false);
const triggerRef = ref<HTMLElement | null>(null);
const popoverRef = ref<HTMLElement | null>(null);
const popoverStyle = ref<Record<string, string>>({});

function positionPopover() {
const el = triggerRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
popoverStyle.value = {
position: "fixed",
bottom: `${window.innerHeight - rect.top + 8}px`,
right: `${Math.max(window.innerWidth - rect.right, 8)}px`,
};
}

async function togglePopover() {
if (showPopover.value) {
showPopover.value = false;
return;
}
positionPopover();
showPopover.value = true;
await nextTick();
positionPopover();
}

function handleOutside(event: MouseEvent) {
if (!showPopover.value) return;
const target = event.target as Node;
if (triggerRef.value?.contains(target) || popoverRef.value?.contains(target)) return;
showPopover.value = false;
}

function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") showPopover.value = false;
}

function openHaSettings() {
showPopover.value = false;
const adapter = haServices.value[0]?.adapter_type;
router.push({
name: "settings.externalServices",
query: adapter ? { adapter } : {},
});
}

onMounted(async () => {
document.addEventListener("click", handleOutside, true);
document.addEventListener("keydown", handleKeydown);
window.addEventListener("resize", positionPopover);

if (store.externalServices.length === 0) {
await store.loadExternalServices();
}
Expand All @@ -29,27 +146,49 @@ onMounted(async () => {
appStore.fetchVersion();
}
});

onBeforeUnmount(() => {
document.removeEventListener("click", handleOutside, true);
document.removeEventListener("keydown", handleKeydown);
window.removeEventListener("resize", positionPopover);
});
</script>

<template>
<footer
class="h-8 flex-shrink-0 flex items-center justify-end gap-2 px-4 border-t border-base-300/40 bg-base-100 overflow-hidden"
>
<template v-if="store.serviceStatuses.size > 0">
<span
v-for="[, status] in store.serviceStatuses"
:key="status.name"
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs"
:class="badgeClass[status.status] ?? 'border-base-300 text-base-300'"
:title="status.error_message ?? status.status"
>
<div class="inline-grid *:[grid-area:1/1]">
<div v-if="status.status === 'connected'" class="status" :class="statusClass[status.status] + ' animate-ping'"></div>
<div class="status" :class="statusClass[status.status]"></div>
</div>
{{ status.name }}
</span>
</template>
<!-- Other external service statuses -->
<span
v-for="status in otherStatuses"
:key="status.name"
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs"
:class="badgeClass[status.status] ?? 'border-base-300 text-base-300'"
:title="status.error_message ?? status.status"
>
<div class="inline-grid *:[grid-area:1/1]">
<div v-if="status.status === 'connected'" class="status" :class="statusClass[status.status] + ' animate-ping'"></div>
<div class="status" :class="statusClass[status.status]"></div>
</div>
{{ status.name }}
</span>

<!-- Home Assistant connection indicator (always visible) -->
<button
ref="triggerRef"
type="button"
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs cursor-pointer"
:class="badgeClass[haIndicator.status]"
:title="`Home Assistant: ${statusLabel[haIndicator.status]}`"
@click="togglePopover"
>
<div class="inline-grid *:[grid-area:1/1]">
<div v-if="haIndicator.status === 'connected'" class="status" :class="statusClass[haIndicator.status] + ' animate-ping'"></div>
<div class="status" :class="statusClass[haIndicator.status]"></div>
</div>
<PhHouse :size="14" weight="bold" />
Home Assistant
</button>

<span
class="inline-flex items-center px-2 py-0.5 rounded border border-primary/40 text-primary/70 bg-primary/10 text-xs"
Expand All @@ -59,6 +198,59 @@ onMounted(async () => {
<template v-else>v?</template>
</span>
</footer>

<!-- Teleported popover -->
<Teleport to="body">
<Transition name="popover-fade">
<div
v-if="showPopover"
ref="popoverRef"
class="z-[100] w-72 rounded-box border border-base-300/40 bg-base-200 shadow-lg p-3"
:style="popoverStyle"
>
<div class="flex items-center gap-2 mb-2">
<PhHouse :size="16" weight="bold" class="text-sky-400" />
<span class="text-sm font-semibold">Home Assistant</span>
</div>

<p v-if="haIndicator.status === 'not_configured'" class="text-xs text-base-content/60 mb-3">
No Home Assistant service is configured yet.
</p>

<ul v-else class="space-y-2 mb-3">
<li
v-for="svc in haIndicator.services"
:key="svc.name"
class="flex items-start gap-2 text-xs"
>
<div class="inline-grid *:[grid-area:1/1] mt-0.5">
<div class="status" :class="statusClass[svc.status]"></div>
</div>
<div class="min-w-0">
<div class="font-medium truncate">{{ svc.name }}</div>
<div class="text-base-content/60">{{ statusLabel[svc.status] }}</div>
<div v-if="svc.error_message" class="text-error/80 break-words">{{ svc.error_message }}</div>
</div>
</li>
</ul>

<button class="btn btn-primary btn-sm w-full gap-2" @click="openHaSettings">
Open Home Assistant settings
<PhArrowRight :size="14" weight="bold" />
</button>
</div>
</Transition>
</Teleport>
</template>

<style scoped></style>
<style scoped>
.popover-fade-enter-active,
.popover-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.popover-fade-enter-from,
.popover-fade-leave-to {
opacity: 0;
transform: translateY(4px);
}
</style>
9 changes: 8 additions & 1 deletion frontend/src/views/settings/ExternalServicesSettingsView.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { useExternalServiceStore } from "../../core/stores/externalServiceStore";
import ExternalServiceCard from "../../components/externalServices/ExternalServiceCard.vue";
import ExternalServiceFormModal from "../../components/externalServices/ExternalServiceFormModal.vue";
Expand All @@ -16,6 +17,7 @@ import {
} from "@phosphor-icons/vue";

const externalServiceStore = useExternalServiceStore();
const route = useRoute();

// Modal state
const showModal = ref(false);
Expand Down Expand Up @@ -79,6 +81,11 @@ const stats = computed(() => {
});

onMounted(() => {
const adapterQuery = route.query.adapter;
if (typeof adapterQuery === "string" && adapterQuery) {
selectedAdapterFilter.value = adapterQuery;
}

externalServiceStore.loadExternalServices().then(() => {
externalServiceStore.loadServicesStatus();
});
Expand Down Expand Up @@ -347,7 +354,7 @@ function getFilterCount(adapterType: string): number {
.stat-value {
font-size: 1.25rem;
}

.stat-type-count {
font-size: 0.875rem;
}
Expand Down
Loading