mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-06 13:16:31 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40048e8586 | ||
|
|
18ac8bb122 | ||
|
|
acd1430c01 | ||
|
|
48a641d656 | ||
|
|
f1a2c05126 | ||
|
|
1845e8f340 | ||
|
|
da7acb513f | ||
|
|
fcb74a55c8 | ||
|
|
01bf0ca980 |
@@ -11,6 +11,7 @@ import {
|
||||
fetchSupervisorMounts,
|
||||
SupervisorMountType,
|
||||
SupervisorMountUsage,
|
||||
supervisorMountDescription,
|
||||
} from "../data/supervisor/mounts";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
@@ -58,9 +59,7 @@ class HaMountPicker extends LitElement {
|
||||
).map((mount) => ({
|
||||
value: mount.name,
|
||||
label: mount.name,
|
||||
secondary: `${mount.server}${mount.port ? `:${mount.port}` : ""}${
|
||||
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
|
||||
}`,
|
||||
secondary: supervisorMountDescription(mount),
|
||||
iconPath:
|
||||
mount.usage === SupervisorMountUsage.MEDIA
|
||||
? mdiPlayBox
|
||||
@@ -108,10 +107,16 @@ class HaMountPicker extends LitElement {
|
||||
private _filterMounts = memoizeOne(
|
||||
(mounts: SupervisorMounts, usage: this["usage"]) => {
|
||||
let filteredMounts = mounts.mounts.filter((mount) =>
|
||||
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
|
||||
[
|
||||
SupervisorMountType.CIFS,
|
||||
SupervisorMountType.DISK,
|
||||
SupervisorMountType.NFS,
|
||||
].includes(mount.type)
|
||||
);
|
||||
if (usage) {
|
||||
filteredMounts = mounts.mounts.filter((mount) => mount.usage === usage);
|
||||
filteredMounts = filteredMounts.filter(
|
||||
(mount) => mount.usage === usage
|
||||
);
|
||||
}
|
||||
return filteredMounts.sort((mountA, mountB) => {
|
||||
if (mountA.name === mounts.default_backup_mount) {
|
||||
|
||||
@@ -42,6 +42,29 @@ export interface DatadiskList {
|
||||
disks: Datadisk[];
|
||||
}
|
||||
|
||||
export interface HostDiskPartition {
|
||||
device: string;
|
||||
uuid: string;
|
||||
label: string;
|
||||
filesystem: string;
|
||||
size: number;
|
||||
read_only: boolean;
|
||||
mountable: boolean;
|
||||
}
|
||||
|
||||
// Without a UDisks2 drive, name and id are dev_path and the drive fields are
|
||||
// empty or false.
|
||||
export interface HostDisk extends Datadisk {
|
||||
connection_bus: string;
|
||||
removable: boolean;
|
||||
ejectable: boolean;
|
||||
partitions: HostDiskPartition[];
|
||||
}
|
||||
|
||||
export interface HostDisks {
|
||||
disks: HostDisk[];
|
||||
}
|
||||
|
||||
export interface HostDisksUsage {
|
||||
total_bytes?: number;
|
||||
used_bytes: number;
|
||||
@@ -127,6 +150,15 @@ export const listDatadisks = async (
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
// Empty list without UDisks2.
|
||||
export const fetchHostDisks = async (hass: HomeAssistant): Promise<HostDisks> =>
|
||||
hass.callWS<HostDisks>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks",
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
|
||||
// leaves the depth to the Supervisor, which defaults per target — walking a
|
||||
// mount costs a round trip per directory, so mounts want no depth at all.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HostDisk, HostDiskPartition } from "../hassio/host";
|
||||
|
||||
export enum SupervisorMountType {
|
||||
BIND = "bind",
|
||||
CIFS = "cifs",
|
||||
DISK = "disk",
|
||||
NFS = "nfs",
|
||||
}
|
||||
|
||||
@@ -33,8 +35,6 @@ interface SupervisorMountBase {
|
||||
usage: SupervisorMountUsage | null;
|
||||
type: SupervisorMountType;
|
||||
read_only: boolean;
|
||||
server: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface SupervisorMountResponse extends SupervisorMountBase {
|
||||
@@ -42,18 +42,32 @@ export interface SupervisorMountResponse extends SupervisorMountBase {
|
||||
user_path: string | null;
|
||||
}
|
||||
|
||||
export interface SupervisorNFSMount extends SupervisorMountResponse {
|
||||
// Supervisor omits port when the mount uses the protocol default.
|
||||
interface SupervisorNetworkMount extends SupervisorMountResponse {
|
||||
server: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface SupervisorNFSMount extends SupervisorNetworkMount {
|
||||
type: SupervisorMountType.NFS;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SupervisorCIFSMount extends SupervisorMountResponse {
|
||||
export interface SupervisorCIFSMount extends SupervisorNetworkMount {
|
||||
type: SupervisorMountType.CIFS;
|
||||
share: string;
|
||||
version?: CIFSVersion | null;
|
||||
}
|
||||
|
||||
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
|
||||
// Supervisor resolves device to uuid; responses report uuid and filesystem.
|
||||
export interface SupervisorDiskMount extends SupervisorMountResponse {
|
||||
type: SupervisorMountType.DISK;
|
||||
uuid: string;
|
||||
filesystem?: string;
|
||||
}
|
||||
|
||||
export type SupervisorMount =
|
||||
SupervisorNFSMount | SupervisorCIFSMount | SupervisorDiskMount;
|
||||
|
||||
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
|
||||
|
||||
@@ -63,14 +77,55 @@ export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
|
||||
version?: CIFSVersion | null;
|
||||
}
|
||||
|
||||
interface SupervisorDiskMountRequestParamsBase {
|
||||
name: string;
|
||||
usage: SupervisorMountUsage;
|
||||
type: SupervisorMountType.DISK;
|
||||
read_only?: boolean;
|
||||
}
|
||||
|
||||
// At least one identifier is required. Both may be sent together, as a
|
||||
// listed partition carries both; Supervisor then resolves by uuid and checks
|
||||
// the device agrees with it.
|
||||
export type SupervisorDiskMountRequestParams =
|
||||
| (SupervisorDiskMountRequestParamsBase & { device: string; uuid?: string })
|
||||
| (SupervisorDiskMountRequestParamsBase & { uuid: string; device?: string });
|
||||
|
||||
export type SupervisorMountRequestParams =
|
||||
SupervisorNFSMountRequestParams | SupervisorCIFSMountRequestParams;
|
||||
| SupervisorNFSMountRequestParams
|
||||
| SupervisorCIFSMountRequestParams
|
||||
| SupervisorDiskMountRequestParams;
|
||||
|
||||
export interface SupervisorMounts {
|
||||
default_backup_mount: string | null;
|
||||
mounts: SupervisorMount[];
|
||||
}
|
||||
|
||||
export interface MountableDiskPartition {
|
||||
disk: HostDisk;
|
||||
partition: HostDiskPartition;
|
||||
}
|
||||
|
||||
// Supervisor may also list partitions it cannot mount.
|
||||
export const mountableDiskPartitions = (
|
||||
disks: HostDisk[]
|
||||
): MountableDiskPartition[] =>
|
||||
disks.flatMap((disk) =>
|
||||
disk.partitions
|
||||
.filter((partition) => partition.mountable === true)
|
||||
.map((partition) => ({ disk, partition }))
|
||||
);
|
||||
|
||||
// Disk mounts have no server/share/path, so describe them by filesystem and uuid.
|
||||
export const supervisorMountDescription = (mount: SupervisorMount): string => {
|
||||
if (mount.type === SupervisorMountType.DISK) {
|
||||
return [mount.filesystem, mount.uuid].filter(Boolean).join(" • ");
|
||||
}
|
||||
return `${mount.server}${mount.port ? `:${mount.port}` : ""}${
|
||||
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
|
||||
}`;
|
||||
};
|
||||
|
||||
export const fetchSupervisorMounts = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<SupervisorMounts> =>
|
||||
|
||||
@@ -14,28 +14,109 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import { extractApiErrorMessage } from "../../../data/hassio/common";
|
||||
import type { SupervisorMountRequestParams } from "../../../data/supervisor/mounts";
|
||||
import { fetchHostDisks } from "../../../data/hassio/host";
|
||||
import type {
|
||||
CIFSVersion,
|
||||
MountableDiskPartition,
|
||||
SupervisorMountRequestParams,
|
||||
} from "../../../data/supervisor/mounts";
|
||||
import {
|
||||
createSupervisorMount,
|
||||
mountableDiskPartitions,
|
||||
removeSupervisorMount,
|
||||
SupervisorMountType,
|
||||
SupervisorMountUsage,
|
||||
updateSupervisorMount,
|
||||
} from "../../../data/supervisor/mounts";
|
||||
import { bytesToString } from "../../../util/bytes-to-string";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyle, haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import type { MountViewDialogParams } from "./show-dialog-view-mount";
|
||||
|
||||
interface MountFormData {
|
||||
name: string;
|
||||
type: SupervisorMountType;
|
||||
usage?: SupervisorMountUsage | null;
|
||||
read_only?: boolean;
|
||||
server?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
share?: string;
|
||||
version?: CIFSVersion | null;
|
||||
device?: string;
|
||||
uuid?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Disk vendor/model, then partition label or device path, then size.
|
||||
const mountPartitionLabel = ({
|
||||
disk,
|
||||
partition,
|
||||
}: MountableDiskPartition): string => {
|
||||
const drive = [disk.vendor, disk.model].filter(Boolean).join(" ");
|
||||
const identity = partition.label || partition.device;
|
||||
const size = bytesToString(partition.size);
|
||||
return drive ? `${drive} — ${identity}, ${size}` : `${identity}, ${size}`;
|
||||
};
|
||||
|
||||
const mountSchema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
existing?: boolean,
|
||||
mountType?: SupervisorMountType,
|
||||
showCIFSVersion?: boolean
|
||||
) =>
|
||||
[
|
||||
showCIFSVersion?: boolean,
|
||||
showDisk?: boolean,
|
||||
partitions?: MountableDiskPartition[],
|
||||
diskIdentity?: string,
|
||||
readOnlyForced?: boolean,
|
||||
allowBackupUsage = true
|
||||
) => {
|
||||
// Supervisor rejects read-only backup mounts.
|
||||
const usageOptions: [string, string][] = allowBackupUsage
|
||||
? [
|
||||
[
|
||||
SupervisorMountUsage.BACKUP,
|
||||
localize(
|
||||
"ui.panel.config.storage.network_mounts.mount_usage.backup"
|
||||
),
|
||||
],
|
||||
]
|
||||
: [];
|
||||
usageOptions.push(
|
||||
[
|
||||
SupervisorMountUsage.MEDIA,
|
||||
localize("ui.panel.config.storage.network_mounts.mount_usage.media"),
|
||||
],
|
||||
[
|
||||
SupervisorMountUsage.SHARE,
|
||||
localize("ui.panel.config.storage.network_mounts.mount_usage.share"),
|
||||
]
|
||||
);
|
||||
|
||||
const typeOptions: [string, string][] = [
|
||||
[
|
||||
SupervisorMountType.CIFS,
|
||||
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
|
||||
],
|
||||
[
|
||||
SupervisorMountType.NFS,
|
||||
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
|
||||
],
|
||||
];
|
||||
// Offered when creating on a Supervisor with disk mounts, and kept when
|
||||
// editing one. An existing network mount cannot become a disk mount: the
|
||||
// edit form has no device picker to identify the disk with.
|
||||
if ((showDisk && !existing) || mountType === SupervisorMountType.DISK) {
|
||||
typeOptions.push([
|
||||
SupervisorMountType.DISK,
|
||||
localize("ui.panel.config.storage.network_mounts.mount_type.disk"),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: "name",
|
||||
required: true,
|
||||
@@ -46,57 +127,34 @@ const mountSchema = memoizeOne(
|
||||
name: "usage",
|
||||
required: true,
|
||||
type: "select",
|
||||
options: [
|
||||
[
|
||||
SupervisorMountUsage.BACKUP,
|
||||
localize(
|
||||
"ui.panel.config.storage.network_mounts.mount_usage.backup"
|
||||
),
|
||||
],
|
||||
[
|
||||
SupervisorMountUsage.MEDIA,
|
||||
localize(
|
||||
"ui.panel.config.storage.network_mounts.mount_usage.media"
|
||||
),
|
||||
],
|
||||
[
|
||||
SupervisorMountUsage.SHARE,
|
||||
localize(
|
||||
"ui.panel.config.storage.network_mounts.mount_usage.share"
|
||||
),
|
||||
],
|
||||
] as const,
|
||||
},
|
||||
{
|
||||
name: "server",
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
options: usageOptions,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
required: true,
|
||||
type: "select",
|
||||
options: [
|
||||
[
|
||||
SupervisorMountType.CIFS,
|
||||
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
|
||||
],
|
||||
[
|
||||
SupervisorMountType.NFS,
|
||||
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
|
||||
],
|
||||
],
|
||||
options: typeOptions,
|
||||
},
|
||||
...(mountType === "nfs"
|
||||
...(mountType === SupervisorMountType.NFS
|
||||
? ([
|
||||
{
|
||||
name: "server",
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
] as const)
|
||||
: mountType === "cifs"
|
||||
: mountType === SupervisorMountType.CIFS
|
||||
? ([
|
||||
{
|
||||
name: "server",
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
...(showCIFSVersion
|
||||
? ([
|
||||
{
|
||||
@@ -148,8 +206,43 @@ const mountSchema = memoizeOne(
|
||||
selector: { text: { type: "password" } },
|
||||
},
|
||||
] as const)
|
||||
: ([] as const)),
|
||||
] as const
|
||||
: mountType === SupervisorMountType.DISK
|
||||
? existing
|
||||
? // Mounted partitions are not listed; show what was resolved.
|
||||
([
|
||||
{
|
||||
name: "device_identity",
|
||||
type: "constant",
|
||||
value: diskIdentity,
|
||||
},
|
||||
{
|
||||
name: "read_only",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const)
|
||||
: ([
|
||||
{
|
||||
name: "device",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
options: (partitions ?? []).map((entry) => ({
|
||||
value: entry.partition.device,
|
||||
label: mountPartitionLabel(entry),
|
||||
})),
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_only",
|
||||
disabled: readOnlyForced,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const)
|
||||
: ([] as const)),
|
||||
] as const;
|
||||
}
|
||||
);
|
||||
|
||||
@customElement("dialog-mount-view")
|
||||
@@ -158,7 +251,7 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
>()(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _data?: SupervisorMountRequestParams;
|
||||
@state() private _data?: MountFormData;
|
||||
|
||||
@state() private _waiting?: boolean;
|
||||
|
||||
@@ -172,6 +265,16 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _showCIFSVersion?: boolean;
|
||||
|
||||
@state() private _partitions?: MountableDiskPartition[];
|
||||
|
||||
@state() private _diskSupported = false;
|
||||
|
||||
private _originalType?: SupervisorMountType;
|
||||
|
||||
private _disksRequest = 0;
|
||||
|
||||
@state() private _diskIdentity?: string;
|
||||
|
||||
@state() private _reloadMounts?: () => void;
|
||||
|
||||
@state() private _open = false;
|
||||
@@ -181,6 +284,7 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
): Promise<Promise<void>> {
|
||||
this._data = dialogParams.mount;
|
||||
this._existing = dialogParams.mount !== undefined;
|
||||
this._originalType = dialogParams.mount?.type;
|
||||
this._reloadMounts = dialogParams.reloadMounts;
|
||||
this._open = true;
|
||||
if (
|
||||
@@ -190,13 +294,51 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
) {
|
||||
this._showCIFSVersion = true;
|
||||
}
|
||||
this._initDirtyTracking({ type: "deep" }, this._data ?? {});
|
||||
if (dialogParams.mount?.type === SupervisorMountType.DISK) {
|
||||
this._diskIdentity = [
|
||||
dialogParams.mount.filesystem,
|
||||
dialogParams.mount.uuid,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
}
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
(this._data ?? {}) as Partial<SupervisorMountRequestParams>
|
||||
);
|
||||
// Disks only matter when picking one for a new mount.
|
||||
if (!this._existing) {
|
||||
this._loadDisks();
|
||||
}
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
// The dialog element is reused, so a slow response must not land in a
|
||||
// later session.
|
||||
private async _loadDisks(): Promise<void> {
|
||||
const request = ++this._disksRequest;
|
||||
try {
|
||||
const { disks } = await fetchHostDisks(this.hass);
|
||||
if (request !== this._disksRequest) {
|
||||
return;
|
||||
}
|
||||
this._partitions = mountableDiskPartitions(disks);
|
||||
this._diskSupported = true;
|
||||
} catch (_err: any) {
|
||||
if (request !== this._disksRequest) {
|
||||
return;
|
||||
}
|
||||
// Older Supervisors lack the endpoint, and the relayed error carries no
|
||||
// status to tell that apart. Hide the option rather than block network
|
||||
// storage with an error.
|
||||
this._partitions = [];
|
||||
this._diskSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._data = undefined;
|
||||
this._waiting = undefined;
|
||||
@@ -204,7 +346,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
this._validationError = undefined;
|
||||
this._validationWarning = undefined;
|
||||
this._existing = undefined;
|
||||
this._originalType = undefined;
|
||||
this._showCIFSVersion = undefined;
|
||||
this._disksRequest++;
|
||||
this._partitions = undefined;
|
||||
this._diskSupported = false;
|
||||
this._diskIdentity = undefined;
|
||||
this._reloadMounts = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
@@ -249,6 +396,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._showNoPartitions
|
||||
? html`<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.storage.network_mounts.no_disks"
|
||||
)}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<ha-form
|
||||
autofocus
|
||||
.data=${this._data}
|
||||
@@ -256,7 +412,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
this.hass.localize,
|
||||
this._existing,
|
||||
this._data?.type,
|
||||
this._showCIFSVersion
|
||||
this._showCIFSVersion,
|
||||
this._diskSupported,
|
||||
this._partitions,
|
||||
this._diskIdentity,
|
||||
this._readOnlyForced,
|
||||
this._allowBackupUsage
|
||||
)}
|
||||
.error=${this._validationError}
|
||||
.warning=${this._validationWarning}
|
||||
@@ -308,6 +469,32 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
`;
|
||||
}
|
||||
|
||||
// An empty partition list is only relevant while creating a mount.
|
||||
private get _showNoPartitions(): boolean {
|
||||
return (
|
||||
!this._existing &&
|
||||
this._data?.type === SupervisorMountType.DISK &&
|
||||
this._partitions?.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
private get _readOnlyForced(): boolean {
|
||||
if (this._existing || this._data?.type !== SupervisorMountType.DISK) {
|
||||
return false;
|
||||
}
|
||||
const { device } = this._data;
|
||||
return !!this._partitions?.find(
|
||||
(entry) => entry.partition.device === device
|
||||
)?.partition.read_only;
|
||||
}
|
||||
|
||||
// Backup usage is impossible for a read-only mount.
|
||||
private get _allowBackupUsage(): boolean {
|
||||
return !(
|
||||
this._data?.type === SupervisorMountType.DISK && this._data.read_only
|
||||
);
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
// @ts-ignore
|
||||
schema: SchemaUnion<ReturnType<typeof mountSchema>>
|
||||
@@ -340,6 +527,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
this._validationError = {};
|
||||
this._validationWarning = {};
|
||||
this._data = ev.detail.value;
|
||||
// Network forms have no read-only control, so a disk's read-only value
|
||||
// must not survive switching to a network type. A network mount that was
|
||||
// already read-only keeps it.
|
||||
if (
|
||||
this._data?.type !== SupervisorMountType.DISK &&
|
||||
(!this._existing || this._originalType === SupervisorMountType.DISK)
|
||||
) {
|
||||
delete (this._data as Partial<SupervisorMountRequestParams>).read_only;
|
||||
}
|
||||
if (this._data?.name && !/^\w+$/.test(this._data.name)) {
|
||||
this._validationError.name = "invalid_name";
|
||||
}
|
||||
@@ -353,7 +549,17 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
) {
|
||||
this._validationWarning.version = "not_recomeded_cifs_version";
|
||||
}
|
||||
this._updateDirtyState(this._data ?? {});
|
||||
// Host reports this device as read-only.
|
||||
if (this._readOnlyForced) {
|
||||
this._data!.read_only = true;
|
||||
}
|
||||
// Read-only plus backup is invalid; drop usage so the user picks again.
|
||||
if (!this._allowBackupUsage && this._data?.usage === "backup") {
|
||||
delete (this._data as Partial<SupervisorMountRequestParams>).usage;
|
||||
}
|
||||
this._updateDirtyState(
|
||||
(this._data ?? {}) as Partial<SupervisorMountRequestParams>
|
||||
);
|
||||
}
|
||||
|
||||
private async _connectMount(ev) {
|
||||
@@ -364,11 +570,27 @@ class ViewMountDialog extends DirtyStateProviderMixin<
|
||||
if (mountData.type === "cifs" && mountData.version === "auto") {
|
||||
mountData.version = undefined;
|
||||
}
|
||||
// Send the partition's uuid alongside its device path: Supervisor resolves
|
||||
// by uuid and rejects the request if the path now names a different disk.
|
||||
if (mountData.type === SupervisorMountType.DISK && !this._existing) {
|
||||
const partition = this._partitions?.find(
|
||||
(entry) => entry.partition.device === mountData.device
|
||||
)?.partition;
|
||||
if (partition) {
|
||||
mountData.uuid = partition.uuid;
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (this._existing) {
|
||||
await updateSupervisorMount(this.hass, mountData);
|
||||
await updateSupervisorMount(
|
||||
this.hass,
|
||||
mountData as Partial<SupervisorMountRequestParams>
|
||||
);
|
||||
} else {
|
||||
await createSupervisorMount(this.hass, mountData);
|
||||
await createSupervisorMount(
|
||||
this.hass,
|
||||
mountData as SupervisorMountRequestParams
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this._error = extractApiErrorMessage(err);
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
SupervisorMountUsage,
|
||||
fetchSupervisorMounts,
|
||||
reloadSupervisorMount,
|
||||
supervisorMountDescription,
|
||||
} from "../../../data/supervisor/mounts";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-subpage";
|
||||
@@ -90,7 +91,11 @@ class HaConfigSectionStorage extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const validMounts = this._mountsInfo?.mounts.filter((mount) =>
|
||||
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
|
||||
[
|
||||
SupervisorMountType.CIFS,
|
||||
SupervisorMountType.DISK,
|
||||
SupervisorMountType.NFS,
|
||||
].includes(mount.type)
|
||||
);
|
||||
const isHAOS = this._hostInfo?.features.includes("haos");
|
||||
return html`
|
||||
@@ -211,13 +216,7 @@ class HaConfigSectionStorage extends LitElement {
|
||||
</span>
|
||||
<span slot="secondary">
|
||||
<span class="mount-address">
|
||||
${mount.server}${
|
||||
mount.port ? `:${mount.port}` : ""
|
||||
}${
|
||||
mount.type === SupervisorMountType.NFS
|
||||
? mount.path
|
||||
: `:${mount.share}`
|
||||
}
|
||||
${supervisorMountDescription(mount)}
|
||||
</span>
|
||||
${this._renderMountUsage(mount)}
|
||||
</span>
|
||||
@@ -486,6 +485,7 @@ class HaConfigSectionStorage extends LitElement {
|
||||
.mount-state-failed {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.mount-state-inactive,
|
||||
.mount-state-unknown {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
+27
-13
@@ -3575,7 +3575,7 @@
|
||||
"agents": {
|
||||
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
|
||||
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
|
||||
"network_mount_agent_description": "Network storage",
|
||||
"network_mount_agent_description": "Additional storage",
|
||||
"unavailable_agents": "Unavailable locations",
|
||||
"no_agents": "No locations configured",
|
||||
"encryption_turned_off": "Encryption turned off",
|
||||
@@ -3860,7 +3860,7 @@
|
||||
"no_location": "No location selected",
|
||||
"no_location_description": "You have to select at least one location to create a backup.",
|
||||
"more_locations": "Explore more locations",
|
||||
"manage_network_storage": "Manage network storage",
|
||||
"manage_network_storage": "Manage storage",
|
||||
"ha_cloud_backup": "{home_assistant_cloud} backup",
|
||||
"ha_cloud_description": "Stores an encrypted backup offsite. This is ideal in case your local backup becomes inaccessible. The cloud stores one backup with a maximum size of 5 GB, and older backups are automatically deleted."
|
||||
},
|
||||
@@ -8934,7 +8934,7 @@
|
||||
"join_chat": "Chat",
|
||||
"join_blog": "Blog",
|
||||
"join_newsletter": "Newsletter",
|
||||
"media_storage": "You can add network storage to your Home Assistant instance in the {storage} panel."
|
||||
"media_storage": "You can add additional storage to your Home Assistant instance in the {storage} panel."
|
||||
},
|
||||
"analytics": {
|
||||
"caption": "Analytics",
|
||||
@@ -9209,15 +9209,16 @@
|
||||
"move": "Move"
|
||||
},
|
||||
"network_mounts": {
|
||||
"title": "Network storage",
|
||||
"add_title": "Add network storage",
|
||||
"update_title": "Update network storage",
|
||||
"no_mounts": "No connected network storage",
|
||||
"title": "Additional storage",
|
||||
"add_title": "Add storage",
|
||||
"update_title": "Update storage",
|
||||
"no_mounts": "No additional storage configured",
|
||||
"no_disks": "No suitable disks were found. Connect a disk with a supported file system, then reopen this dialog.",
|
||||
"documentation": "Documentation",
|
||||
"not_supported": {
|
||||
"title": "The operating system does not support network storage",
|
||||
"supervised": "Network storage is not supported on this host",
|
||||
"os": "To use network storage you need to run at least Home Assistant OS {version}",
|
||||
"title": "The operating system does not support additional storage",
|
||||
"supervised": "Additional storage is not supported on this host",
|
||||
"os": "To use additional storage you need to run at least Home Assistant OS {version}",
|
||||
"navigate_to_updates": "Go to updates"
|
||||
},
|
||||
"mount_usage": {
|
||||
@@ -9227,7 +9228,8 @@
|
||||
},
|
||||
"mount_type": {
|
||||
"nfs": "Network File System (NFS)",
|
||||
"cifs": "Samba/Windows (CIFS)"
|
||||
"cifs": "Samba/Windows (CIFS)",
|
||||
"disk": "Local disk"
|
||||
},
|
||||
"cifs_versions": {
|
||||
"auto": "Auto (2.1+)",
|
||||
@@ -9255,13 +9257,25 @@
|
||||
"description": "This is the path of the remote share on your storage server"
|
||||
},
|
||||
"type": {
|
||||
"title": "Protocol",
|
||||
"description": "This determines how to communicate with the storage server"
|
||||
"title": "Type",
|
||||
"description": "This determines the kind of storage you want to add"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage",
|
||||
"description": "This determines how the share is intended to be used"
|
||||
},
|
||||
"device": {
|
||||
"title": "Disk",
|
||||
"description": "This is the disk connected to your system that you want to use"
|
||||
},
|
||||
"device_identity": {
|
||||
"title": "Disk",
|
||||
"description": "This is the disk this storage was set up with, which cannot be changed"
|
||||
},
|
||||
"read_only": {
|
||||
"title": "Read-only",
|
||||
"description": "Home Assistant will not be able to write to this storage. Backup storage cannot be read-only"
|
||||
},
|
||||
"version": {
|
||||
"title": "Samba/Windows (CIFS) version",
|
||||
"description": "This chooses the version of the protocol to use"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostDisk, HostDiskPartition } from "../../src/data/hassio/host";
|
||||
import type {
|
||||
SupervisorCIFSMount,
|
||||
SupervisorDiskMount,
|
||||
SupervisorNFSMount,
|
||||
} from "../../src/data/supervisor/mounts";
|
||||
import {
|
||||
mountableDiskPartitions,
|
||||
SupervisorMountState,
|
||||
SupervisorMountType,
|
||||
SupervisorMountUsage,
|
||||
supervisorMountDescription,
|
||||
} from "../../src/data/supervisor/mounts";
|
||||
|
||||
const nfsMount = (
|
||||
overrides: Partial<SupervisorNFSMount> = {}
|
||||
): SupervisorNFSMount => ({
|
||||
name: "my_nfs",
|
||||
type: SupervisorMountType.NFS,
|
||||
usage: SupervisorMountUsage.MEDIA,
|
||||
state: SupervisorMountState.ACTIVE,
|
||||
read_only: false,
|
||||
user_path: null,
|
||||
server: "nas.local",
|
||||
path: "/export/media",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const cifsMount = (
|
||||
overrides: Partial<SupervisorCIFSMount> = {}
|
||||
): SupervisorCIFSMount => ({
|
||||
name: "my_share",
|
||||
type: SupervisorMountType.CIFS,
|
||||
usage: SupervisorMountUsage.MEDIA,
|
||||
state: SupervisorMountState.ACTIVE,
|
||||
read_only: false,
|
||||
user_path: null,
|
||||
server: "nas.local",
|
||||
share: "media",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const diskMount = (
|
||||
overrides: Partial<SupervisorDiskMount> = {}
|
||||
): SupervisorDiskMount => ({
|
||||
name: "media_disk",
|
||||
type: SupervisorMountType.DISK,
|
||||
usage: SupervisorMountUsage.MEDIA,
|
||||
state: SupervisorMountState.ACTIVE,
|
||||
read_only: false,
|
||||
user_path: null,
|
||||
uuid: "e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
|
||||
filesystem: "ext4",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("supervisorMountDescription", () => {
|
||||
it("describes an NFS mount by server and path", () => {
|
||||
expect(supervisorMountDescription(nfsMount())).toBe(
|
||||
"nas.local/export/media"
|
||||
);
|
||||
});
|
||||
|
||||
it("describes a CIFS mount by server and share", () => {
|
||||
expect(supervisorMountDescription(cifsMount())).toBe("nas.local:media");
|
||||
});
|
||||
|
||||
it("includes the port only when Supervisor reports one", () => {
|
||||
expect(supervisorMountDescription(nfsMount({ port: 2049 }))).toBe(
|
||||
"nas.local:2049/export/media"
|
||||
);
|
||||
expect(supervisorMountDescription(nfsMount({ port: undefined }))).toBe(
|
||||
"nas.local/export/media"
|
||||
);
|
||||
});
|
||||
|
||||
it("describes a disk mount by filesystem and uuid", () => {
|
||||
expect(supervisorMountDescription(diskMount())).toBe(
|
||||
"ext4 • e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the filesystem when Supervisor has not resolved one", () => {
|
||||
expect(
|
||||
supervisorMountDescription(diskMount({ filesystem: undefined }))
|
||||
).toBe("e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b");
|
||||
});
|
||||
|
||||
it("never describes a disk mount using network fields", () => {
|
||||
// A disk mount has no server, share, or path.
|
||||
expect(supervisorMountDescription(diskMount())).not.toContain("undefined");
|
||||
});
|
||||
});
|
||||
|
||||
const partition = (device: string, mountable: boolean): HostDiskPartition => ({
|
||||
device,
|
||||
uuid: `uuid-${device}`,
|
||||
label: "",
|
||||
filesystem: "ext4",
|
||||
size: 1000,
|
||||
read_only: false,
|
||||
mountable,
|
||||
});
|
||||
|
||||
const disk = (devPath: string, partitions: HostDiskPartition[]): HostDisk => ({
|
||||
name: devPath,
|
||||
vendor: "",
|
||||
model: "",
|
||||
serial: "",
|
||||
size: 2000,
|
||||
id: devPath,
|
||||
dev_path: devPath,
|
||||
connection_bus: "",
|
||||
removable: false,
|
||||
ejectable: false,
|
||||
partitions,
|
||||
});
|
||||
|
||||
describe("mountableDiskPartitions", () => {
|
||||
it("keeps only mountable partitions, each with its disk", () => {
|
||||
const sdb = disk("/dev/sdb", [partition("/dev/sdb1", false)]);
|
||||
const sdc = disk("/dev/sdc", [
|
||||
partition("/dev/sdc1", true),
|
||||
partition("/dev/sdc2", false),
|
||||
]);
|
||||
expect(mountableDiskPartitions([sdb, sdc])).toEqual([
|
||||
{ disk: sdc, partition: sdc.partitions[0] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user