Compare commits

...
7 changed files with 372 additions and 6 deletions
+12
View File
@@ -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);
};
+20 -6
View File
@@ -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
}
+39
View File
@@ -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" });
+253
View File
@@ -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;
}
}
+33
View File
@@ -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;
+8
View File
@@ -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",
+7
View File
@@ -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> = {