mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-07 13:45:34 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c07cc59bdf | ||
|
|
6933d3b469 | ||
|
|
7add046bba |
@@ -79,5 +79,17 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
});
|
||||
hass.mockWS("frontend/get_system_data", () => ({ value: null }));
|
||||
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
|
||||
hass.mockWS("subscribe_system_state", (_msg, _currentHass, onChange) => {
|
||||
onChange?.({
|
||||
home_assistant_restart_dismissed: false,
|
||||
home_assistant_restart_required: false,
|
||||
home_assistant_restart_sources: [],
|
||||
host_reboot_dismissed: false,
|
||||
host_reboot_required: false,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
hass.mockWS("dismiss_system_state", () => null);
|
||||
hass.mockWS("frontend/get_themes", (_msg, currentHass) => currentHass.themes);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import type { PersistentNotification } from "../data/persistent_notification";
|
||||
import { subscribeNotifications } from "../data/persistent_notification";
|
||||
import { subscribeRepairsIssueRegistry } from "../data/repairs";
|
||||
import { subscribeSystemState } from "../data/system_state";
|
||||
import type { UpdateEntity } from "../data/update";
|
||||
import { updateCanInstall } from "../data/update";
|
||||
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
|
||||
@@ -178,6 +179,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
|
||||
@state() private _issuesCount = 0;
|
||||
|
||||
// A pending restart or reboot counts as one thing to take care of.
|
||||
@state() private _restartRequiredCount = 0;
|
||||
|
||||
@state() private _panelOrder?: string[];
|
||||
|
||||
@state() private _hiddenPanels?: string[];
|
||||
@@ -217,6 +221,13 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
(issue) => !issue.ignored
|
||||
).length;
|
||||
}),
|
||||
subscribeSystemState(this.hass.connection!, (systemState) => {
|
||||
this._restartRequiredCount =
|
||||
systemState.home_assistant_restart_required ||
|
||||
systemState.host_reboot_required
|
||||
? 1
|
||||
: 0;
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
@@ -246,6 +257,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
changedProps.has("alwaysExpand") ||
|
||||
changedProps.has("_updatesCount") ||
|
||||
changedProps.has("_issuesCount") ||
|
||||
changedProps.has("_restartRequiredCount") ||
|
||||
changedProps.has("_notifications") ||
|
||||
changedProps.has("_hiddenPanels") ||
|
||||
changedProps.has("_panelOrder") ||
|
||||
@@ -472,6 +484,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
return html`<div class="spacer" disabled></div>`;
|
||||
}
|
||||
|
||||
private get _configBadgeCount(): number {
|
||||
return this._updatesCount + this._issuesCount + this._restartRequiredCount;
|
||||
}
|
||||
|
||||
private _renderConfiguration(selectedPanel: string) {
|
||||
if (!this.hass.user?.is_admin) {
|
||||
return nothing;
|
||||
@@ -485,10 +501,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
|
||||
${
|
||||
this._updatesCount > 0 || this._issuesCount > 0
|
||||
this._configBadgeCount > 0
|
||||
? html`
|
||||
<span class="badge" slot="start">
|
||||
${this._updatesCount + this._issuesCount}
|
||||
${this._configBadgeCount}
|
||||
</span>
|
||||
`
|
||||
: nothing
|
||||
@@ -497,11 +513,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
>${this.hass.localize("panel.config")}</span
|
||||
>
|
||||
${
|
||||
this._updatesCount > 0 || this._issuesCount > 0
|
||||
this._configBadgeCount > 0
|
||||
? html`
|
||||
<span class="badge" slot="end"
|
||||
>${this._updatesCount + this._issuesCount}</span
|
||||
>
|
||||
<span class="badge" slot="end">${this._configBadgeCount}</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { getCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
|
||||
export interface SystemState {
|
||||
home_assistant_restart_dismissed: boolean;
|
||||
home_assistant_restart_required: boolean;
|
||||
home_assistant_restart_sources: string[];
|
||||
host_reboot_dismissed: boolean;
|
||||
host_reboot_required: boolean;
|
||||
}
|
||||
|
||||
const subscribeSystemStateUpdates = (
|
||||
conn: Connection,
|
||||
store: Store<SystemState>
|
||||
) =>
|
||||
conn.subscribeMessage<SystemState>(
|
||||
(systemState) => store.setState(systemState, true),
|
||||
{ type: "subscribe_system_state" }
|
||||
);
|
||||
|
||||
// The subscription sends the current state right away, so there is
|
||||
// nothing to fetch first. Shared, so the sidebar and the restart bar use
|
||||
// one subscription.
|
||||
export const subscribeSystemState = (
|
||||
conn: Connection,
|
||||
onChange: (systemState: SystemState) => void
|
||||
): UnsubscribeFunc =>
|
||||
getCollection<SystemState>(
|
||||
conn,
|
||||
"_systemState",
|
||||
undefined,
|
||||
subscribeSystemStateUpdates
|
||||
).subscribe(onChange);
|
||||
|
||||
// Puts off what is pending right now, for every admin on every device,
|
||||
// until something new asks.
|
||||
export const dismissSystemState = (conn: Connection) =>
|
||||
conn.sendMessagePromise<null>({ type: "dismiss_system_state" });
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-alert";
|
||||
import "../components/ha-button";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
narrowViewportContext,
|
||||
uiContext,
|
||||
} from "../data/context";
|
||||
import { domainToName } from "../data/integration";
|
||||
import type { SystemState } from "../data/system_state";
|
||||
import { dismissSystemState, subscribeSystemState } from "../data/system_state";
|
||||
import { showRestartDialog } from "../dialogs/restart/show-dialog-restart";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantConnection,
|
||||
HomeAssistantUI,
|
||||
} from "../types";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"restart-required-bar-resized": { height: number };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-restart-required-bar")
|
||||
class HaRestartRequiredBar extends LitElement {
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, string>({
|
||||
transformer: ({ panelUrl }) => panelUrl,
|
||||
})
|
||||
private _panelUrl!: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, boolean>({
|
||||
transformer: ({ kioskMode }) => kioskMode,
|
||||
})
|
||||
private _kioskMode = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, boolean>({
|
||||
transformer: ({ user }) => Boolean(user?.is_admin),
|
||||
})
|
||||
private _isAdmin = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
@transform<HomeAssistantConnection, Connection>({
|
||||
transformer: ({ connection }) => connection,
|
||||
})
|
||||
private _connection?: Connection;
|
||||
|
||||
@state()
|
||||
@consume({ context: narrowViewportContext, subscribe: true })
|
||||
private _narrow = false;
|
||||
|
||||
@state() private _systemState?: SystemState;
|
||||
|
||||
private _resizeObserver = new ResizeObserver(() => this._reportHeight());
|
||||
|
||||
private _unsubSystemState?: UnsubscribeFunc;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._resizeObserver.observe(this);
|
||||
this._subscribe();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._resizeObserver.disconnect();
|
||||
this._unsubscribe();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const systemState = this._systemState;
|
||||
if (!systemState || !this._isVisible(systemState)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// A reboot restarts Home Assistant as well, so it takes priority.
|
||||
const reboot = systemState.host_reboot_required;
|
||||
const inSettings = this._inSettings;
|
||||
// The integrations asked for a restart, not for the reboot, so they are
|
||||
// only listed while the restart is what is shown.
|
||||
const sources = systemState.home_assistant_restart_sources.map((domain) =>
|
||||
domainToName(this._localize, domain)
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning" .narrow=${this._narrow}>
|
||||
${this._localize(
|
||||
reboot
|
||||
? "ui.restart_required.reboot_message"
|
||||
: "ui.restart_required.restart_message"
|
||||
)}
|
||||
${
|
||||
inSettings && !reboot && sources.length
|
||||
? html`<div class="sources">
|
||||
${this._localize("ui.restart_required.sources", {
|
||||
integrations: sources.join(", "),
|
||||
})}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<div class="actions" slot="action">
|
||||
${
|
||||
inSettings
|
||||
? nothing
|
||||
: html`<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
variant="warning"
|
||||
@click=${this._dismiss}
|
||||
>
|
||||
${this._localize("ui.restart_required.later")}
|
||||
</ha-button>`
|
||||
}
|
||||
<ha-button size="s" variant="warning" @click=${this._restart}>
|
||||
${this._localize(
|
||||
reboot
|
||||
? "ui.restart_required.reboot"
|
||||
: "ui.restart_required.restart"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (changedProps.has("_connection") || changedProps.has("_isAdmin")) {
|
||||
this._unsubscribe();
|
||||
this._subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
this.toggleAttribute(
|
||||
"active",
|
||||
Boolean(this._systemState && this._isVisible(this._systemState))
|
||||
);
|
||||
this._reportHeight();
|
||||
}
|
||||
|
||||
private _subscribe() {
|
||||
if (this._unsubSystemState || !this._connection || !this._isAdmin) {
|
||||
return;
|
||||
}
|
||||
this._unsubSystemState = subscribeSystemState(
|
||||
this._connection,
|
||||
(systemState) => {
|
||||
this._systemState = systemState;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _unsubscribe() {
|
||||
this._unsubSystemState?.();
|
||||
this._unsubSystemState = undefined;
|
||||
}
|
||||
|
||||
// Settings is where you take care of it, so it cannot be put off there.
|
||||
private get _inSettings(): boolean {
|
||||
return this._panelUrl === "config";
|
||||
}
|
||||
|
||||
private _isVisible(systemState: SystemState): boolean {
|
||||
if (!this._isAdmin || this._kioskMode) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!systemState.host_reboot_required &&
|
||||
!systemState.home_assistant_restart_required
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return this._inSettings || !this._isDismissed(systemState);
|
||||
}
|
||||
|
||||
// Core keeps track of what an admin put off, for everyone on every device.
|
||||
private _isDismissed(systemState: SystemState): boolean {
|
||||
return systemState.host_reboot_required
|
||||
? systemState.host_reboot_dismissed
|
||||
: systemState.home_assistant_restart_dismissed;
|
||||
}
|
||||
|
||||
private _dismiss() {
|
||||
dismissSystemState(this._connection!);
|
||||
}
|
||||
|
||||
private _restart() {
|
||||
showRestartDialog(this);
|
||||
}
|
||||
|
||||
private _reportHeight() {
|
||||
fireEvent(this, "restart-required-bar-resized", {
|
||||
height: this.offsetHeight,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: none;
|
||||
}
|
||||
:host([active]) {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
inset-inline-start: var(--ha-sidebar-width, 0px);
|
||||
width: var(--ha-top-app-bar-width, 100%);
|
||||
/* Above the fixed headers of the panels. */
|
||||
z-index: 5;
|
||||
box-sizing: border-box;
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-inline-end: var(--safe-area-inset-right);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
--ha-border-radius-sm: 0;
|
||||
}
|
||||
.sources {
|
||||
margin-top: var(--ha-space-1, 4px);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-restart-required-bar": HaRestartRequiredBar;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ContextProvider } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../common/dom/media_query";
|
||||
@@ -37,6 +38,10 @@ export class HomeAssistantMain extends LitElement {
|
||||
|
||||
@state() private _drawerOpen = false;
|
||||
|
||||
@state() private _restartRequiredBarHeight = 0;
|
||||
|
||||
private _restartRequiredBarLoaded = false;
|
||||
|
||||
private _narrowViewportProvider = new ContextProvider(this, {
|
||||
context: narrowViewportContext,
|
||||
initialValue: this.narrow,
|
||||
@@ -76,10 +81,26 @@ export class HomeAssistantMain extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.route=${this.route}
|
||||
slot="appContent"
|
||||
style=${styleMap(
|
||||
// Every panel already keeps clear of the top safe area,
|
||||
// so growing it makes room for the bar.
|
||||
this._restartRequiredBarHeight
|
||||
? {
|
||||
"--safe-area-inset-top": `${this._restartRequiredBarHeight}px`,
|
||||
}
|
||||
: {}
|
||||
)}
|
||||
></partial-panel-resolver>`
|
||||
: nothing
|
||||
}
|
||||
</ha-drawer>
|
||||
${
|
||||
isPanelReady && this.hass.user?.is_admin
|
||||
? html`<ha-restart-required-bar
|
||||
@restart-required-bar-resized=${this._restartRequiredBarResized}
|
||||
></ha-restart-required-bar>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -132,6 +153,12 @@ export class HomeAssistantMain extends LitElement {
|
||||
this._narrowViewportProvider.setValue(this.narrow);
|
||||
}
|
||||
|
||||
// The user is not always known yet on the first render.
|
||||
if (!this._restartRequiredBarLoaded && this.hass.user?.is_admin) {
|
||||
this._restartRequiredBarLoaded = true;
|
||||
import("./ha-restart-required-bar");
|
||||
}
|
||||
|
||||
if (changedProps.has("route") && this._sidebarNarrow) {
|
||||
this._drawerOpen = false;
|
||||
}
|
||||
@@ -152,6 +179,12 @@ export class HomeAssistantMain extends LitElement {
|
||||
return this.narrow || this.hass.dockedSidebar === "always_hidden";
|
||||
}
|
||||
|
||||
private _restartRequiredBarResized(
|
||||
ev: HASSDomEvent<HASSDomEvents["restart-required-bar-resized"]>
|
||||
) {
|
||||
this._restartRequiredBarHeight = ev.detail.height;
|
||||
}
|
||||
|
||||
private _drawerClosed() {
|
||||
this._drawerOpen = false;
|
||||
this._sidebarEditMode = false;
|
||||
|
||||
@@ -2679,6 +2679,14 @@
|
||||
"action": "Take survey"
|
||||
}
|
||||
},
|
||||
"restart_required": {
|
||||
"restart_message": "Home Assistant needs to be restarted to apply installed updates or changed settings.",
|
||||
"reboot_message": "The system needs to be rebooted to apply installed updates or changed settings.",
|
||||
"sources": "Requested by: {integrations}",
|
||||
"later": "Later",
|
||||
"restart": "Restart",
|
||||
"reboot": "Reboot"
|
||||
},
|
||||
"sidebar": {
|
||||
"external_app_configuration": "App settings",
|
||||
"sidebar_toggle": "Sidebar toggle",
|
||||
|
||||
@@ -65,6 +65,13 @@ const subscriptionResults: Record<string, unknown> = {
|
||||
notifications: {},
|
||||
},
|
||||
subscribe_entities: { a: {} },
|
||||
subscribe_system_state: {
|
||||
home_assistant_restart_dismissed: false,
|
||||
home_assistant_restart_required: false,
|
||||
home_assistant_restart_sources: [],
|
||||
host_reboot_dismissed: false,
|
||||
host_reboot_required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const commandResults: Record<string, unknown> = {
|
||||
|
||||
Reference in New Issue
Block a user