Skip to content

Commit 837fb92

Browse files
authored
feat: 2.0.0 - Merged multi-Zigbee2MQTT (#176)
1 parent a054335 commit 837fb92

File tree

88 files changed

+2801
-1614
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+2801
-1614
lines changed

.github/workflows/ci.yml

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,44 @@ jobs:
4141

4242
# build container when triggered by release (push on tag)
4343
- name: Log in to the GitHub container registry
44-
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
44+
if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
4545
uses: docker/login-action@v3
4646
with:
4747
registry: ${{ env.REGISTRY }}
4848
username: ${{ github.repository_owner }}
4949
password: ${{ secrets.GITHUB_TOKEN }}
5050

5151
- name: Docker setup - QEMU
52-
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
52+
if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
5353
uses: docker/setup-qemu-action@v3
5454

5555
- name: Docker setup - Buildx
56-
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
56+
if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && github.event_name == 'push'
5757
uses: docker/setup-buildx-action@v3
5858

59+
- name: edge - Docker meta
60+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
61+
uses: docker/metadata-action@v5
62+
id: meta_edge
63+
with:
64+
images: |
65+
ghcr.io/nerivec/zigbee2mqtt-windfront
66+
tags: |
67+
type=edge
68+
labels: |
69+
org.opencontainers.image.authors=Nerivec
70+
org.opencontainers.image.documentation=https://github.com/Nerivec/zigbee2mqtt-windfront/wiki
71+
72+
- name: edge - Docker build and push
73+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
74+
uses: docker/build-push-action@v6
75+
with:
76+
context: .
77+
file: Dockerfile
78+
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
79+
tags: ${{ steps.meta_edge.outputs.tags }}
80+
push: true
81+
5982
- name: Docker meta
6083
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
6184
uses: docker/metadata-action@v5
@@ -67,6 +90,9 @@ jobs:
6790
type=semver,pattern={{version}}
6891
type=semver,pattern={{major}}.{{minor}}
6992
type=semver,pattern={{major}}
93+
labels: |
94+
org.opencontainers.image.authors=Nerivec
95+
org.opencontainers.image.documentation=https://github.com/Nerivec/zigbee2mqtt-windfront/wiki
7096
7197
- name: Docker build and push
7298
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'
@@ -77,9 +103,6 @@ jobs:
77103
platforms: linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/riscv64,linux/386
78104
tags: ${{ steps.meta.outputs.tags }}
79105
push: true
80-
build-args: |
81-
VERSION=${{ github.ref_name }}
82-
DATE=${{ github.event.repository.updated_at }}
83106

84107
- name: Publish package to npmjs.org
85108
if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push'

Dockerfile

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,5 @@
11
FROM nginx:alpine-slim AS prod
22

3-
ARG DATE
4-
ARG VERSION
5-
6-
LABEL org.opencontainers.image.authors="Nerivec"
7-
LABEL org.opencontainers.image.title="Zigbee2MQTT WindFront"
8-
LABEL org.opencontainers.image.description="Open Source frontend for Zigbee2MQTT"
9-
LABEL org.opencontainers.image.url="https://github.com/Nerivec/zigbee2mqtt-windfront"
10-
LABEL org.opencontainers.image.documentation="https://github.com/Nerivec/zigbee2mqtt-windfront/wiki"
11-
LABEL org.opencontainers.image.source="https://github.com/Nerivec/zigbee2mqtt-windfront"
12-
LABEL org.opencontainers.image.licenses="GPL-3.0-or-later"
13-
LABEL org.opencontainers.image.created=${DATE}
14-
LABEL org.opencontainers.image.version=${VERSION}
15-
163
EXPOSE 80
174

185
COPY .docker/scripts/ /docker-entrypoint.d/

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zigbee2mqtt-windfront",
3-
"version": "1.8.1",
3+
"version": "2.0.0",
44
"license": "GPL-3.0-or-later",
55
"type": "module",
66
"main": "index.js",

src/Main.tsx

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import React, { lazy, Suspense } from "react";
33
import { I18nextProvider, useTranslation } from "react-i18next";
44
import { HashRouter, Route, Routes } from "react-router";
55
import { AuthForm } from "./components/modal/components/AuthModal.js";
6+
import Notifications from "./components/Notifications.js";
67
import NavBar from "./components/navbar/NavBar.js";
78
import ScrollToTop from "./components/ScrollToTop.js";
8-
import Toasts from "./components/toasts/Toasts.js";
9+
import Toasts from "./components/Toasts.js";
910
import { ErrorBoundary } from "./ErrorBoundary.js";
1011
import i18n from "./i18n/index.js";
1112
import { WebSocketApiRouter } from "./WebSocketApiRouter.js";
@@ -21,6 +22,7 @@ const OtaPage = lazy(async () => await import("./pages/OtaPage.js"));
2122
const TouchlinkPage = lazy(async () => await import("./pages/TouchlinkPage.js"));
2223
const LogsPage = lazy(async () => await import("./pages/LogsPage.js"));
2324
const SettingsPage = lazy(async () => await import("./pages/SettingsPage.js"));
25+
const FrontendSettingsPage = lazy(async () => await import("./pages/FrontendSettingsPage.js"));
2426

2527
export function Main() {
2628
const { t } = useTranslation("common");
@@ -34,34 +36,44 @@ export function Main() {
3436
<HashRouter>
3537
<ScrollToTop />
3638
<WebSocketApiRouter>
37-
<NavBar />
38-
<main className="pt-3 px-2">
39-
<Suspense
40-
fallback={
41-
<>
42-
<div className="flex flex-row justify-center items-center gap-2">
43-
<span className="loading loading-infinity loading-xl" />
44-
</div>
45-
<div className="flex flex-row justify-center items-center gap-2">{t("loading")}</div>
46-
</>
47-
}
48-
>
49-
<Routes>
50-
<Route path="/ota" element={<OtaPage />} />
51-
<Route path="/network" element={<NetworkPage />} />
52-
<Route path="/device/:deviceId/:tab?" element={<DevicePage />} />
53-
<Route path="/settings/:tab?" element={<SettingsPage />} />
54-
<Route path="/groups" element={<GroupsPage />} />
55-
<Route path="/group/:groupId/:tab?" element={<GroupPage />} />
56-
57-
<Route path="/logs" element={<LogsPage />} />
58-
<Route path="/touchlink" element={<TouchlinkPage />} />
59-
<Route path="/dashboard" element={<DashboardPage />} />
60-
<Route path="/devices" element={<DevicesPage />} />
61-
<Route path="/" element={<HomePage />} />
62-
</Routes>
63-
</Suspense>
64-
</main>
39+
<div className="drawer drawer-end">
40+
<input id="notifications-drawer" type="checkbox" className="drawer-toggle" />
41+
<div className="drawer-content">
42+
<NavBar />
43+
<main className="pt-3 px-2">
44+
<Suspense
45+
fallback={
46+
<>
47+
<div className="flex flex-row justify-center items-center gap-2">
48+
<span className="loading loading-infinity loading-xl" />
49+
</div>
50+
<div className="flex flex-row justify-center items-center gap-2">{t("loading")}</div>
51+
</>
52+
}
53+
>
54+
<Routes>
55+
<Route path="/dashboard" element={<DashboardPage />} />
56+
<Route path="/devices" element={<DevicesPage />} />
57+
<Route path="/device/:sourceIdx/:deviceId/:tab?" element={<DevicePage />} />
58+
<Route path="/groups" element={<GroupsPage />} />
59+
<Route path="/group/:sourceIdx/:groupId/:tab?" element={<GroupPage />} />
60+
<Route path="/touchlink" element={<TouchlinkPage />} />
61+
<Route path="/ota" element={<OtaPage />} />
62+
<Route path="/network/:sourceIdx?" element={<NetworkPage />} />
63+
<Route path="/logs/:sourceIdx?" element={<LogsPage />} />
64+
<Route path="/settings/:sourceIdx?/:tab?" element={<SettingsPage />} />
65+
<Route path="/frontend-settings" element={<FrontendSettingsPage />} />
66+
<Route path="/" element={<HomePage />} />
67+
<Route path="*" element={<HomePage />} />
68+
</Routes>
69+
</Suspense>
70+
</main>
71+
</div>
72+
<div className="drawer-side z-99">
73+
<label htmlFor="notifications-drawer" aria-label={t("close_notifications")} className="drawer-overlay" />
74+
<Notifications />
75+
</div>
76+
</div>
6577
<Toasts />
6678
</WebSocketApiRouter>
6779
</HashRouter>

src/WebSocketApiRouterContext.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { createContext } from "react";
2+
import { ReadyState } from "react-use-websocket";
23
import type { useApiWebSocket } from "./hooks/useApiWebSocket.js";
4+
import { API_URLS } from "./store.js";
35

46
export const WebSocketApiRouterContext = createContext<ReturnType<typeof useApiWebSocket>>({
5-
sendMessage: async (_topic, _payload) => {},
6-
readyState: -1,
7-
apiUrls: [],
8-
apiUrl: "",
9-
setApiUrl: (_value) => {},
7+
sendMessage: async (_sourceIdx, _topic, _payload) => {},
8+
transactionPrefixes: API_URLS.map(() => ""),
9+
readyStates: API_URLS.map(() => ReadyState.UNINSTANTIATED),
1010
});

src/components/Notifications.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { faClose, faPowerOff, faServer } from "@fortawesome/free-solid-svg-icons";
2+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import { memo, type RefObject, useCallback, useContext, useMemo, useRef } from "react";
4+
import { useTranslation } from "react-i18next";
5+
import { Link } from "react-router";
6+
import { ReadyState } from "react-use-websocket";
7+
import { useShallow } from "zustand/react/shallow";
8+
import { LOG_LEVELS_CMAP } from "../consts.js";
9+
import { API_NAMES, API_URLS, useAppStore } from "../store.js";
10+
import type { LogMessage } from "../types.js";
11+
import { WebSocketApiRouterContext } from "../WebSocketApiRouterContext.js";
12+
import Button from "./Button.js";
13+
import ConfirmButton from "./ConfirmButton.js";
14+
import SourceDot from "./SourceDot.js";
15+
16+
type NotificationProps = {
17+
log: LogMessage;
18+
onClick: (ref: RefObject<HTMLDivElement | null>) => void;
19+
};
20+
21+
type NotificationsProps = { sourceIdx: number; readyState: ReadyState };
22+
23+
const CONNECTION_STATUS = {
24+
[ReadyState.CONNECTING]: "text-info",
25+
[ReadyState.OPEN]: "text-success",
26+
[ReadyState.CLOSING]: "text-warning",
27+
[ReadyState.CLOSED]: "text-error",
28+
[ReadyState.UNINSTANTIATED]: "text-error",
29+
};
30+
31+
const Notification = memo(({ log, onClick }: NotificationProps) => {
32+
const alertRef = useRef<HTMLDivElement | null>(null);
33+
34+
return (
35+
<div ref={alertRef} className={`alert ${LOG_LEVELS_CMAP[log.level]} break-all gap-1 p-2 pe-0.5`} title={log.timestamp}>
36+
<span>{log.message}</span>
37+
<div className="justify-self-end">
38+
<Button item={alertRef} onClick={onClick} className="btn btn-xs btn-square">
39+
<FontAwesomeIcon icon={faClose} />
40+
</Button>
41+
</div>
42+
</div>
43+
);
44+
});
45+
46+
const SourceNotifications = memo(({ sourceIdx, readyState }: NotificationsProps) => {
47+
const { t } = useTranslation(["navbar", "common"]);
48+
const { sendMessage, transactionPrefixes } = useContext(WebSocketApiRouterContext);
49+
const notifications = useAppStore(useShallow((state) => state.notifications[sourceIdx]));
50+
const restartRequired = useAppStore(useShallow((state) => state.bridgeInfo[sourceIdx].restart_required));
51+
const clearNotifications = useAppStore((state) => state.clearNotifications);
52+
53+
const restart = useCallback(async () => await sendMessage(sourceIdx, "bridge/request/restart", ""), [sourceIdx, sendMessage]);
54+
const onNotificationClick = useCallback((ref: RefObject<HTMLDivElement | null>) => {
55+
if (ref?.current) {
56+
ref.current.className += " hidden";
57+
}
58+
}, []);
59+
const onClearClick = useCallback(() => clearNotifications(sourceIdx), [sourceIdx, clearNotifications]);
60+
61+
return (
62+
<li>
63+
<details open={sourceIdx === 0}>
64+
<summary>
65+
<span title={`${sourceIdx} | ${t("transaction_prefix")}: ${transactionPrefixes[sourceIdx]}`}>
66+
{API_URLS.length > 1 ? (
67+
<>
68+
<SourceDot idx={sourceIdx} /> {API_NAMES[sourceIdx]}
69+
</>
70+
) : (
71+
"Zigbee2MQTT"
72+
)}
73+
</span>
74+
<span className="ml-auto">
75+
{restartRequired && (
76+
<ConfirmButton
77+
className="btn btn-xs btn-square btn-error animate-pulse"
78+
onClick={restart}
79+
title={t("restart")}
80+
modalDescription={t("common:dialog_confirmation_prompt")}
81+
modalCancelLabel={t("common:cancel")}
82+
>
83+
<FontAwesomeIcon icon={faPowerOff} />
84+
</ConfirmButton>
85+
)}
86+
<span title={`${t("websocket_status")}: ${ReadyState[readyState]}`}>
87+
<FontAwesomeIcon icon={faServer} className={CONNECTION_STATUS[readyState]} />
88+
</span>
89+
</span>
90+
</summary>
91+
{notifications.map((log, idx) => (
92+
<Notification key={`${idx}-${log.timestamp}`} log={log} onClick={onNotificationClick} />
93+
))}
94+
{notifications.length > 0 && (
95+
<div className="flex flex-row justify-between mt-3 mb-1">
96+
<Link to={`/logs/${sourceIdx}`} className="btn btn-sm btn-primary btn-outline" title={t("common:more")}>
97+
{t("common:more")}
98+
</Link>
99+
<Button className="btn btn-sm btn-error btn-outline" onClick={onClearClick} title={t("common:clear")}>
100+
{t("common:clear")}
101+
</Button>
102+
</div>
103+
)}
104+
</details>
105+
</li>
106+
);
107+
});
108+
109+
const Notifications = memo(() => {
110+
const { t } = useTranslation("common");
111+
const { readyStates } = useContext(WebSocketApiRouterContext);
112+
const clearAllNotifications = useAppStore((state) => state.clearAllNotifications);
113+
114+
const sourceNotifications = useMemo(
115+
() =>
116+
API_URLS.map((_v, idx) => (
117+
// biome-ignore lint/suspicious/noArrayIndexKey: static
118+
<SourceNotifications key={`${idx}`} sourceIdx={idx} readyState={readyStates[idx]} />
119+
)),
120+
[readyStates],
121+
);
122+
123+
return (
124+
<aside className="bg-base-100 min-h-screen w-80">
125+
<ul className="menu w-full px-1 py-0">
126+
{sourceNotifications}
127+
{API_URLS.length > 1 && (
128+
<ConfirmButton
129+
className="btn btn-sm btn-error btn-outline mt-5"
130+
onClick={clearAllNotifications}
131+
title={t("clear_all")}
132+
modalDescription={t("dialog_confirmation_prompt")}
133+
modalCancelLabel={t("cancel")}
134+
>
135+
{t("clear_all")}
136+
</ConfirmButton>
137+
)}
138+
</ul>
139+
</aside>
140+
);
141+
});
142+
143+
export default Notifications;

src/components/PopoverDropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const PopoverDropdown = memo((props: PopoverDropdownProps) => {
3333
{children}
3434
</ul>
3535
<Button
36-
className={`btn ${typeof buttonChildren === "string" ? "" : "btn-square"}${buttonStyle ? ` ${buttonStyle}` : ""}`}
36+
className={`btn${buttonStyle ? ` ${buttonStyle}` : ""}`}
3737
popoverTarget={popoverId}
3838
style={{ anchorName: anchorName } as CSSProperties}
3939
disabled={buttonDisabled}

0 commit comments

Comments
 (0)