Add basic nodes list & node metadata to OZW config panel (#6719)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Charles Garwood 2020-09-04 14:55:40 -04:00 committed by GitHub
parent 793b9f238c
commit aa5e20df05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 652 additions and 107 deletions

View File

@ -14,6 +14,8 @@ export interface OZWDevice {
is_zwave_plus: boolean;
ozw_instance: number;
event: string;
node_manufacturer_name: string;
node_product_name: string;
}
export interface OZWDeviceMetaDataResponse {
@ -147,6 +149,15 @@ export const fetchOZWNetworkStatistics = (
ozw_instance: ozw_instance,
});
export const fetchOZWNodes = (
hass: HomeAssistant,
ozw_instance: number
): Promise<OZWDevice[]> =>
hass.callWS({
type: "ozw/get_nodes",
ozw_instance: ozw_instance,
});
export const fetchOZWNodeStatus = (
hass: HomeAssistant,
ozw_instance: number,

View File

@ -1,6 +1,8 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-fab";
import { mdiCheckCircle, mdiCircle, mdiCloseCircle, mdiZWave } from "@mdi/js";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import "@material/mwc-fab";
import {
css,
CSSResultArray,
@ -14,20 +16,18 @@ import {
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import {
fetchOZWInstances,
networkOfflineStatuses,
networkOnlineStatuses,
networkStartingStatuses,
OZWInstance,
} from "../../../../../data/ozw";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import { mdiCircle, mdiCheckCircle, mdiCloseCircle, mdiZWave } from "@mdi/js";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import "@material/mwc-button/mwc-button";
import {
OZWInstance,
fetchOZWInstances,
networkOnlineStatuses,
networkOfflineStatuses,
networkStartingStatuses,
} from "../../../../../data/ozw";
export const ozwTabs: PageNavigation[] = [];
@ -45,22 +45,8 @@ class OZWConfigDashboard extends LitElement {
@internalProperty() private _instances: OZWInstance[] = [];
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._fetchData();
}
}
private async _fetchData() {
this._instances = await fetchOZWInstances(this.hass!);
if (this._instances.length === 1) {
navigate(
this,
`/config/ozw/network/${this._instances[0].ozw_instance}`,
true
);
}
protected firstUpdated() {
this._fetchData();
}
protected render(): TemplateResult {
@ -142,12 +128,23 @@ class OZWConfigDashboard extends LitElement {
`;
})}
`
: ``}
: ""}
</ha-config-section>
</hass-tabs-subpage>
`;
}
private async _fetchData() {
this._instances = await fetchOZWInstances(this.hass!);
if (this._instances.length === 1) {
navigate(
this,
`/config/ozw/network/${this._instances[0].ozw_instance}`,
true
);
}
}
static get styles(): CSSResultArray {
return [
haStyle,

View File

@ -1,10 +1,23 @@
import { customElement, property } from "lit-element";
import memoizeOne from "memoize-one";
import {
HassRouterPage,
RouterOptions,
} from "../../../../../layouts/hass-router-page";
import { HomeAssistant } from "../../../../../types";
import { navigate } from "../../../../../common/navigate";
import { HomeAssistant, Route } from "../../../../../types";
export const computeTail = memoizeOne((route: Route) => {
const dividerPos = route.path.indexOf("/", 1);
return dividerPos === -1
? {
prefix: route.prefix + route.path,
path: "",
}
: {
prefix: route.prefix + route.path.substr(0, dividerPos),
path: route.path.substr(dividerPos),
};
});
@customElement("ozw-config-router")
class OZWConfigRouter extends HassRouterPage {
@ -30,10 +43,10 @@ class OZWConfigRouter extends HassRouterPage {
),
},
network: {
tag: "ozw-config-network",
tag: "ozw-network-router",
load: () =>
import(
/* webpackChunkName: "ozw-config-network" */ "./ozw-config-network"
/* webpackChunkName: "ozw-network-router" */ "./ozw-network-router"
),
},
},
@ -46,19 +59,9 @@ class OZWConfigRouter extends HassRouterPage {
el.narrow = this.narrow;
el.configEntryId = this._configEntry;
if (this._currentPage === "network") {
el.ozw_instance = this.routeTail.path.substr(1);
}
const searchParams = new URLSearchParams(window.location.search);
if (this._configEntry && !searchParams.has("config_entry")) {
searchParams.append("config_entry", this._configEntry);
navigate(
this,
`${this.routeTail.prefix}${
this.routeTail.path
}?${searchParams.toString()}`,
true
);
const path = this.routeTail.path.split("/");
el.ozwInstance = path[1];
el.route = computeTail(this.routeTail);
}
}
}

View File

@ -1,4 +1,6 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-fab";
import { mdiCheckCircle, mdiCircle, mdiCloseCircle } from "@mdi/js";
import {
css,
CSSResultArray,
@ -9,31 +11,28 @@ import {
property,
TemplateResult,
} from "lit-element";
import { classMap } from "lit-html/directives/class-map";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/buttons/ha-call-service-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/buttons/ha-call-service-button";
import {
fetchOZWNetworkStatistics,
fetchOZWNetworkStatus,
networkOfflineStatuses,
networkOnlineStatuses,
networkStartingStatuses,
OZWInstance,
OZWNetworkStatistics,
} from "../../../../../data/ozw";
import "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import { mdiCircle, mdiCheckCircle, mdiCloseCircle } from "@mdi/js";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import "@material/mwc-button/mwc-button";
import {
OZWInstance,
fetchOZWNetworkStatus,
fetchOZWNetworkStatistics,
networkOnlineStatuses,
networkOfflineStatuses,
networkStartingStatuses,
OZWNetworkStatistics,
} from "../../../../../data/ozw";
import { ozwNetworkTabs } from "./ozw-network-router";
export const ozwTabs: PageNavigation[] = [];
@customElement("ozw-config-network")
class OZWConfigNetwork extends LitElement {
@customElement("ozw-network-dashboard")
class OZWNetworkDashboard extends LitElement {
@property({ type: Object }) public hass!: HomeAssistant;
@property({ type: Object }) public route!: Route;
@ -44,7 +43,7 @@ class OZWConfigNetwork extends LitElement {
@property() public configEntryId?: string;
@property() public ozw_instance = 0;
@property() public ozwInstance?: number;
@internalProperty() private _network?: OZWInstance;
@ -54,54 +53,21 @@ class OZWConfigNetwork extends LitElement {
@internalProperty() private _icon = mdiCircle;
public connectedCallback(): void {
super.connectedCallback();
if (this.ozw_instance <= 0) {
protected firstUpdated() {
if (!this.ozwInstance) {
navigate(this, "/config/ozw/dashboard", true);
}
if (this.hass) {
} else if (this.hass) {
this._fetchData();
}
}
private async _fetchData() {
this._network = await fetchOZWNetworkStatus(this.hass!, this.ozw_instance);
this._statistics = await fetchOZWNetworkStatistics(
this.hass!,
this.ozw_instance
);
if (networkOnlineStatuses.includes(this._network.Status)) {
this._status = "online";
this._icon = mdiCheckCircle;
}
if (networkStartingStatuses.includes(this._network.Status)) {
this._status = "starting";
}
if (networkOfflineStatuses.includes(this._network.Status)) {
this._status = "offline";
this._icon = mdiCloseCircle;
}
}
private _generateServiceButton(service: string) {
return html`
<ha-call-service-button
.hass=${this.hass}
domain="ozw"
service="${service}"
>
${this.hass!.localize("ui.panel.config.ozw.services." + service)}
</ha-call-service-button>
`;
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${ozwTabs}
.tabs=${ozwNetworkTabs(this.ozwInstance!)}
>
<ha-config-section .narrow=${this.narrow} .isWide=${this.isWide}>
<div slot="header">
@ -118,20 +84,21 @@ class OZWConfigNetwork extends LitElement {
<div class="details">
<ha-svg-icon
.path=${this._icon}
class="network-status-icon ${this._status}"
class="network-status-icon ${classMap({
[this._status]: true,
})}"
slot="item-icon"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.ozw.common.network"
)}
${this.hass.localize(
"ui.panel.config.ozw.network_status." + this._status
`ui.panel.config.ozw.network_status.${this._status}`
)}
<br />
<small>
${this.hass.localize(
"ui.panel.config.ozw.network_status.details." +
this._network.Status.toLowerCase()
`ui.panel.config.ozw.network_status.details.${this._network.Status.toLowerCase()}`
)}
</small>
</div>
@ -171,6 +138,38 @@ class OZWConfigNetwork extends LitElement {
`;
}
private async _fetchData() {
if (!this.ozwInstance) return;
this._network = await fetchOZWNetworkStatus(this.hass!, this.ozwInstance);
this._statistics = await fetchOZWNetworkStatistics(
this.hass!,
this.ozwInstance
);
if (networkOnlineStatuses.includes(this._network!.Status)) {
this._status = "online";
this._icon = mdiCheckCircle;
}
if (networkStartingStatuses.includes(this._network!.Status)) {
this._status = "starting";
}
if (networkOfflineStatuses.includes(this._network!.Status)) {
this._status = "offline";
this._icon = mdiCloseCircle;
}
}
private _generateServiceButton(service: string) {
return html`
<ha-call-service-button
.hass=${this.hass}
domain="ozw"
.service=${service}
>
${this.hass!.localize(`ui.panel.config.ozw.services.${service}`)}
</ha-call-service-button>
`;
}
static get styles(): CSSResultArray {
return [
haStyle,
@ -248,6 +247,6 @@ class OZWConfigNetwork extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ozw-config-network": OZWConfigNetwork;
"ozw-network-dashboard": OZWNetworkDashboard;
}
}

View File

@ -0,0 +1,144 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-fab";
import { mdiAlert, mdiCheck } from "@mdi/js";
import {
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
TemplateResult,
} from "lit-element";
import memoizeOne from "memoize-one";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/buttons/ha-call-service-button";
import { HASSDomEvent } from "../../../../../common/dom/fire_event";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import { fetchOZWNodes, OZWDevice } from "../../../../../data/ozw";
import "../../../../../layouts/hass-tabs-subpage";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import { ozwNetworkTabs } from "./ozw-network-router";
export interface NodeRowData extends OZWDevice {
node?: NodeRowData;
id?: number;
}
@customElement("ozw-network-nodes")
class OZWNetworkNodes extends LitElement {
@property({ type: Object }) public hass!: HomeAssistant;
@property({ type: Object }) public route!: Route;
@property({ type: Boolean }) public narrow!: boolean;
@property({ type: Boolean }) public isWide!: boolean;
@property() public configEntryId?: string;
@property() public ozwInstance = 0;
@internalProperty() private _nodes: OZWDevice[] = [];
private _columns = memoizeOne(
(narrow: boolean): DataTableColumnContainer => {
return {
node_id: {
title: this.hass.localize("ui.panel.config.ozw.nodes_table.id"),
sortable: true,
type: "numeric",
width: "72px",
filterable: true,
direction: "asc",
},
node_product_name: {
title: this.hass.localize("ui.panel.config.ozw.nodes_table.model"),
sortable: true,
width: narrow ? "75%" : "25%",
},
node_manufacturer_name: {
title: this.hass.localize(
"ui.panel.config.ozw.nodes_table.manufacturer"
),
sortable: true,
hidden: narrow,
width: "25%",
},
node_query_stage: {
title: this.hass.localize(
"ui.panel.config.ozw.nodes_table.query_stage"
),
sortable: true,
width: narrow ? "25%" : "15%",
},
is_zwave_plus: {
title: this.hass.localize(
"ui.panel.config.ozw.nodes_table.zwave_plus"
),
hidden: narrow,
template: (value: boolean) =>
value ? html` <ha-svg-icon .path=${mdiCheck}></ha-svg-icon>` : "",
},
is_failed: {
title: this.hass.localize("ui.panel.config.ozw.nodes_table.failed"),
hidden: narrow,
template: (value: boolean) =>
value ? html` <ha-svg-icon .path=${mdiAlert}></ha-svg-icon>` : "",
},
};
}
);
protected firstUpdated() {
if (!this.ozwInstance) {
navigate(this, "/config/ozw/dashboard", true);
} else if (this.hass) {
this._fetchData();
}
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${ozwNetworkTabs(this.ozwInstance)}
.columns=${this._columns(this.narrow)}
.data=${this._nodes}
id="node_id"
@row-click=${this._handleRowClicked}
back-path="/config/ozw/network/${this.ozwInstance}/dashboard"
>
</hass-tabs-subpage-data-table>
`;
}
private async _fetchData() {
this._nodes = await fetchOZWNodes(this.hass!, this.ozwInstance!);
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const nodeId = ev.detail.id;
navigate(this, `/config/ozw/network/${this.ozwInstance}/node/${nodeId}`);
}
static get styles(): CSSResult {
return haStyle;
}
}
declare global {
interface HTMLElementTagNameMap {
"ozw-network-nodes": OZWNetworkNodes;
}
}

View File

@ -0,0 +1,83 @@
import { customElement, property } from "lit-element";
import {
HassRouterPage,
RouterOptions,
} from "../../../../../layouts/hass-router-page";
import { HomeAssistant } from "../../../../../types";
import { computeTail } from "./ozw-config-router";
import { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { mdiServerNetwork, mdiNetwork } from "@mdi/js";
export const ozwNetworkTabs = (instance: number): PageNavigation[] => {
return [
{
translationKey: "ui.panel.config.ozw.navigation.network",
path: `/config/ozw/network/${instance}/dashboard`,
iconPath: mdiServerNetwork,
},
{
translationKey: "ui.panel.config.ozw.navigation.nodes",
path: `/config/ozw/network/${instance}/nodes`,
iconPath: mdiNetwork,
},
];
};
@customElement("ozw-network-router")
class OZWNetworkRouter extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public ozwInstance!: number;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
showLoading: true,
routes: {
dashboard: {
tag: "ozw-network-dashboard",
load: () =>
import(
/* webpackChunkName: "ozw-network-dashboard" */ "./ozw-network-dashboard"
),
},
nodes: {
tag: "ozw-network-nodes",
load: () =>
import(
/* webpackChunkName: "ozw-network-nodes" */ "./ozw-network-nodes"
),
},
node: {
tag: "ozw-node-router",
load: () =>
import(/* webpackChunkName: "ozw-node-router" */ "./ozw-node-router"),
},
},
};
protected updatePageEl(el): void {
el.route = computeTail(this.routeTail);
el.hass = this.hass;
el.isWide = this.isWide;
el.narrow = this.narrow;
el.configEntryId = this._configEntry;
el.ozwInstance = this.ozwInstance;
if (this._currentPage === "node") {
el.nodeId = this.routeTail.path.split("/")[1];
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ozw-network-router": OZWNetworkRouter;
}
}

View File

@ -0,0 +1,231 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-fab";
import {
css,
CSSResultArray,
customElement,
html,
LitElement,
internalProperty,
property,
TemplateResult,
} from "lit-element";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/buttons/ha-call-service-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import {
fetchOZWNodeStatus,
fetchOZWNodeMetadata,
OZWDevice,
OZWDeviceMetaDataResponse,
} from "../../../../../data/ozw";
import { ERR_NOT_FOUND } from "../../../../../data/websocket_api";
import { showOZWRefreshNodeDialog } from "./show-dialog-ozw-refresh-node";
import { ozwNetworkTabs } from "./ozw-network-router";
@customElement("ozw-node-dashboard")
class OZWNodeDashboard extends LitElement {
@property({ type: Object }) public hass!: HomeAssistant;
@property({ type: Object }) public route!: Route;
@property({ type: Boolean }) public narrow!: boolean;
@property({ type: Boolean }) public isWide!: boolean;
@property() public configEntryId?: string;
@property() public ozwInstance?;
@property() public nodeId?;
@internalProperty() private _node?: OZWDevice;
@internalProperty() private _metadata?: OZWDeviceMetaDataResponse;
@internalProperty() private _not_found = false;
protected firstUpdated() {
if (!this.ozwInstance) {
navigate(this, "/config/ozw/dashboard", true);
} else if (!this.nodeId) {
navigate(this, `/config/ozw/network/${this.ozwInstance}/nodes`, true);
} else if (this.hass) {
this._fetchData();
}
}
protected render(): TemplateResult {
if (this._not_found) {
return html`
<hass-error-screen
.error="${this.hass.localize("ui.panel.config.ozw.node.not_found")}"
></hass-error-screen>
`;
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${ozwNetworkTabs(this.ozwInstance)}
>
<ha-config-section .narrow=${this.narrow} .isWide=${this.isWide}>
<div slot="header">
Node Management
</div>
<div slot="introduction">
View the status of a node and manage its configuration.
</div>
${this._node
? html`
<ha-card class="content">
<div class="card-content">
<b
>${this._node.node_manufacturer_name}
${this._node.node_product_name}</b
><br />
Node ID: ${this._node.node_id}<br />
Query Stage: ${this._node.node_query_stage}
${this._metadata?.metadata.ProductManualURL
? html` <a
href="${this._metadata.metadata.ProductManualURL}"
>
<p>Product Manual</p>
</a>`
: ``}
</div>
<div class="card-actions">
<mwc-button @click=${this._refreshNodeClicked}>
Refresh Node
</mwc-button>
</div>
</ha-card>
${this._metadata
? html`
<ha-card class="content" header="Description">
<div class="card-content">
${this._metadata.metadata.Description}
</div>
</ha-card>
<ha-card class="content" header="Inclusion">
<div class="card-content">
${this._metadata.metadata.InclusionHelp}
</div>
</ha-card>
<ha-card class="content" header="Exclusion">
<div class="card-content">
${this._metadata.metadata.ExclusionHelp}
</div>
</ha-card>
<ha-card class="content" header="Reset">
<div class="card-content">
${this._metadata.metadata.ResetHelp}
</div>
</ha-card>
<ha-card class="content" header="WakeUp">
<div class="card-content">
${this._metadata.metadata.WakeupHelp}
</div>
</ha-card>
`
: ``}
`
: ``}
</ha-config-section>
</hass-tabs-subpage>
`;
}
private async _fetchData() {
if (!this.ozwInstance || !this.nodeId) {
return;
}
try {
this._node = await fetchOZWNodeStatus(
this.hass!,
this.ozwInstance,
this.nodeId
);
this._metadata = await fetchOZWNodeMetadata(
this.hass!,
this.ozwInstance,
this.nodeId
);
} catch (err) {
if (err.code === ERR_NOT_FOUND) {
this._not_found = true;
return;
}
throw err;
}
}
private async _refreshNodeClicked() {
showOZWRefreshNodeDialog(this, {
node_id: this.nodeId,
ozw_instance: this.ozwInstance,
});
}
static get styles(): CSSResultArray {
return [
haStyle,
css`
.secondary {
color: var(--secondary-text-color);
}
.content {
margin-top: 24px;
}
.sectionHeader {
position: relative;
padding-right: 40px;
}
ha-card {
margin: 0 auto;
max-width: 600px;
}
.card-actions.warning ha-call-service-button {
color: var(--error-color);
}
.toggle-help-icon {
position: absolute;
top: -6px;
right: 0;
color: var(--primary-color);
}
ha-service-description {
display: block;
color: grey;
padding: 0 8px 12px;
}
[hidden] {
display: none;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ozw-node-dashboard": OZWNodeDashboard;
}
}

View File

@ -0,0 +1,66 @@
import { customElement, property } from "lit-element";
import { navigate } from "../../../../../common/navigate";
import {
HassRouterPage,
RouterOptions,
} from "../../../../../layouts/hass-router-page";
import { HomeAssistant } from "../../../../../types";
@customElement("ozw-node-router")
class OZWNodeRouter extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public ozwInstance!: number;
@property() public nodeId!: number;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
showLoading: true,
routes: {
dashboard: {
tag: "ozw-node-dashboard",
load: () =>
import(
/* webpackChunkName: "ozw-node-dashboard" */ "./ozw-node-dashboard"
),
},
},
};
protected updatePageEl(el): void {
el.route = this.routeTail;
el.hass = this.hass;
el.isWide = this.isWide;
el.narrow = this.narrow;
el.configEntryId = this._configEntry;
el.ozwInstance = this.ozwInstance;
el.nodeId = this.nodeId;
const searchParams = new URLSearchParams(window.location.search);
if (this._configEntry && !searchParams.has("config_entry")) {
searchParams.append("config_entry", this._configEntry);
navigate(
this,
`${this.routeTail.prefix}${
this.routeTail.path
}?${searchParams.toString()}`,
true
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ozw-node-router": OZWNodeRouter;
}
}

View File

@ -1804,6 +1804,17 @@
"introduction": "Manage network-wide functions.",
"node_count": "{count} nodes"
},
"nodes_table": {
"id": "ID",
"manufacturer": "Manufacturer",
"model": "Model",
"query_stage": "Query Stage",
"zwave_plus": "Z-Wave Plus",
"failed": "Failed"
},
"node": {
"not_found": "Node not found"
},
"services": {
"add_node": "Add Node",
"remove_node": "Remove Node"