⛅ Sync 2026-10-11 13:16:00
No files matched your search
@@ -0,0 +1,336 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require device-manager.model as model';
|
||||
|
||||
function statusLabel(status) {
|
||||
return status === 'online' ? _('Online') : status === 'offline' ? _('Offline') : _('Unknown');
|
||||
}
|
||||
|
||||
function closeButton() {
|
||||
return E('button', { 'type': 'button', 'class': 'dm-dialog-close',
|
||||
'aria-label': _('Close'), 'title': _('Close'), 'click': ui.hideModal
|
||||
}, [ '×' ]);
|
||||
}
|
||||
|
||||
function deviceHero(dev) {
|
||||
const typeInfo = model.getTypeInfo(dev.type);
|
||||
return E('div', { 'class': 'dm-dialog-hero' }, [
|
||||
E('span', { 'class': 'dm-dialog-icon' }, [ E('img', {
|
||||
'src': L.resource('device-manager/device-icons/' + typeInfo.icon), 'alt': ''
|
||||
}) ]),
|
||||
E('div', { 'class': 'dm-dialog-heading' }, [
|
||||
E('strong', {}, [ dev.customName || dev.hostname || _('Unknown device') ]),
|
||||
E('span', { 'class': 'dm-dialog-muted' }, [ model.getTypeLabel(dev.type) ])
|
||||
]),
|
||||
E('span', { 'class': 'dm-dialog-status dm-dialog-status-' + dev.status }, [ statusLabel(dev.status) ])
|
||||
]);
|
||||
}
|
||||
|
||||
function infoGrid(fields) {
|
||||
return E('div', { 'class': 'dm-info-grid' }, fields.map(field => E('div', {
|
||||
'class': 'dm-info-item' + (field[2] ? ' dm-info-wide' : '')
|
||||
}, [
|
||||
E('span', { 'class': 'dm-info-label' }, [ field[0] ]),
|
||||
E('div', { 'class': 'dm-info-value' }, [ field[1] || '—' ])
|
||||
])));
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
showEditModal: function(dev) {
|
||||
if (this.readonly) return;
|
||||
const self = this;
|
||||
const isEdit = (dev !== null);
|
||||
|
||||
const curMac = isEdit ? dev.mac : '';
|
||||
const curHostname = isEdit ? (dev.hostname || _('Not detected')) : '';
|
||||
const curIp = isEdit ? (dev.ipv4 || dev.ipv6 || _('Unavailable')) : '';
|
||||
const curName = isEdit ? (dev.customName || '') : '';
|
||||
const curRemark = isEdit ? (dev.remark || '') : '';
|
||||
const curGroup = isEdit ? (dev.group || 'ungrouped') : 'ungrouped';
|
||||
|
||||
const modalTitle = isEdit
|
||||
? _('Edit device')
|
||||
: _('Add device');
|
||||
|
||||
let macInput = null;
|
||||
let macRow = null;
|
||||
|
||||
if (!isEdit) {
|
||||
macInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': _('e.g. AA:BB:CC:11:22:33'),
|
||||
'maxlength': 17,
|
||||
'style': 'width:100%;'
|
||||
});
|
||||
macRow = E('div', { 'class': 'cbi-value dm-form-wide' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [
|
||||
_('MAC address'),
|
||||
E('span', { 'style': 'color:red;' }, [ ' *' ])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
macInput,
|
||||
E('div', { 'class': 'cbi-value-description' }, [ _('Hardware address; colon or hyphen separators are accepted') ])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
// Group dropdown options
|
||||
const groupSelect = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }, [
|
||||
E('option', { 'value': 'ungrouped' }, [ _('Ungrouped') ])
|
||||
]);
|
||||
for (let i = 0; i < this.groups.length; i++) {
|
||||
const g = this.groups[i];
|
||||
const opt = E('option', { 'value': g.id }, [ g.name ]);
|
||||
if (curGroup === g.id) opt.selected = true;
|
||||
groupSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
const curType = isEdit ? (dev.customType || 'auto') : 'auto';
|
||||
const typeSelect = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }, [
|
||||
E('option', { 'value': 'auto' }, [ _('Auto (detect by MAC)') ])
|
||||
]);
|
||||
for (const [ typeKey, typeObj ] of Object.entries(model.DEVICE_TYPES)) {
|
||||
const opt = E('option', { 'value': typeKey }, [ typeObj.label() ]);
|
||||
if (curType === typeKey) opt.selected = true;
|
||||
typeSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
const nameInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'value': curName,
|
||||
'placeholder': _('e.g. Living room TV, work computer'),
|
||||
'maxlength': 64,
|
||||
'style': 'width:100%;'
|
||||
});
|
||||
|
||||
const remarkTextarea = E('textarea', {
|
||||
'class': 'cbi-input-textarea',
|
||||
'rows': 3,
|
||||
'placeholder': _('e.g. Living room TV'),
|
||||
'maxlength': 256,
|
||||
'style': 'width:100%;'
|
||||
}, [ curRemark ]);
|
||||
|
||||
const errorDiv = E('div', {
|
||||
'class': 'alert-message danger',
|
||||
'style': 'display:none; margin-bottom:15px;'
|
||||
});
|
||||
|
||||
const formFields = isEdit ? [] : [ macRow ];
|
||||
|
||||
formFields.push(
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ _('Device type') ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
typeSelect,
|
||||
E('div', { 'class': 'cbi-value-description' }, [
|
||||
_('Automatically detected from names and MAC address, or customize manually')
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ _('Device group') ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
groupSelect,
|
||||
E('div', { 'class': 'cbi-value-description' }, [ _('Organize this device by purpose or location') ])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ _('Custom name') ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
nameInput,
|
||||
E('div', { 'class': 'cbi-value-description' }, [ _('Leave empty to display the default hostname') ])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ _('Device remarks') ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
remarkTextarea,
|
||||
E('div', { 'class': 'cbi-value-description' }, [ _('Record the location, purpose or owner of this device') ])
|
||||
])
|
||||
])
|
||||
);
|
||||
|
||||
const btnCancel = E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, [ _('Cancel') ]);
|
||||
|
||||
const btnSave = E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-action important dm-primary-button',
|
||||
'click': function(ev) {
|
||||
const button = ev.currentTarget;
|
||||
const targetMac = isEdit ? curMac : (macInput ? macInput.value : '');
|
||||
const normMac = model.normalizeMac(targetMac);
|
||||
|
||||
if (!normMac) {
|
||||
errorDiv.textContent = _('Enter a valid MAC address (e.g. AA:BB:CC:11:22:33)');
|
||||
errorDiv.style.display = 'block';
|
||||
if (macInput) macInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
errorDiv.style.display = 'none';
|
||||
const newName = model.sanitizeInput(nameInput.value);
|
||||
const newRemark = model.sanitizeInput(remarkTextarea.value);
|
||||
const newGroup = groupSelect.value || 'ungrouped';
|
||||
const newType = typeSelect.value || 'auto';
|
||||
|
||||
button.classList.add('spinning');
|
||||
button.disabled = true;
|
||||
|
||||
return self.handleSaveDevice(normMac, newName, newRemark, newGroup, dev, newType)
|
||||
.then(function() {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', [ _('Device "%s" saved.').format(newName || normMac) ]), 'info');
|
||||
return self.refresh();
|
||||
})
|
||||
.catch(function(err) {
|
||||
errorDiv.textContent = _('Save failed: %s').format(err.message || err);
|
||||
errorDiv.style.display = 'block';
|
||||
})
|
||||
.finally(function() {
|
||||
button.classList.remove('spinning');
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
}, [ _('Save') ]);
|
||||
|
||||
const buttonRow = [];
|
||||
|
||||
if (isEdit && dev.isSaved) {
|
||||
const btnDelete = E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-remove dm-clear-record',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
self.confirmDelete(dev);
|
||||
}
|
||||
}, [ _('Clear record') ]);
|
||||
buttonRow.push(btnDelete);
|
||||
}
|
||||
buttonRow.push(E('div', { 'class': 'right dm-dialog-buttons' }, [ btnCancel, btnSave ]));
|
||||
|
||||
const content = [ closeButton(), errorDiv ];
|
||||
if (isEdit) content.push(deviceHero(dev), infoGrid([
|
||||
[ _('MAC address'), curMac ], [ _('System hostname'), curHostname ],
|
||||
[ _('Current IP address'), curIp ], [ _('Current status'), statusLabel(dev.status) ]
|
||||
]));
|
||||
content.push(E('div', { 'class': 'dm-edit-fields' }, formFields),
|
||||
E('div', { 'class': 'dm-dialog-footer' }, buttonRow));
|
||||
ui.showModal(modalTitle, [ E('div', { 'class': 'dm-dialog' }, content) ], 'dm-device-modal');
|
||||
},
|
||||
|
||||
confirmDelete: function(dev) {
|
||||
if (this.readonly) return;
|
||||
const self = this;
|
||||
const name = dev.customName || dev.hostname || dev.mac;
|
||||
|
||||
ui.showModal(_('Clear record'), [ E('div', { 'class': 'dm-dialog' }, [
|
||||
closeButton(),
|
||||
deviceHero(dev),
|
||||
E('p', { 'class': 'dm-clear-description' }, [ _('Clear the saved record for "%s" (%s)?').format(name, dev.mac) ]),
|
||||
E('p', { 'class': 'dm-dialog-muted' }, [ _('Clears the custom name, remarks, group and device type. The device remains discoverable on the network.') ]),
|
||||
E('div', { 'class': 'right dm-dialog-footer dm-dialog-buttons' }, [
|
||||
E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
|
||||
' ',
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-remove',
|
||||
'click': function(ev) {
|
||||
const button = ev.currentTarget;
|
||||
button.classList.add('spinning');
|
||||
button.disabled = true;
|
||||
|
||||
return self.handleDeleteDevice(dev)
|
||||
.then(function() {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', [ _('Saved record for "%s" cleared.').format(name) ]), 'info');
|
||||
return self.refresh();
|
||||
})
|
||||
.catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', [ _('Clear failed: %s').format(err.message || err) ]), 'danger');
|
||||
}).finally(function() {
|
||||
button.classList.remove('spinning');
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
}, [ _('Confirm clear') ])
|
||||
])
|
||||
]) ], 'dm-device-modal');
|
||||
},
|
||||
|
||||
showDetailModal: function(dev) {
|
||||
const self = this;
|
||||
const addresses = key => (dev[key + 'Addresses'] || []).length
|
||||
? dev[key + 'Addresses'].join('\n') : dev[key];
|
||||
const pingStatus = E('strong', { 'class': 'dm-ping-status', 'role': 'status', 'aria-live': 'polite' }, [ _('Probing…') ]);
|
||||
const pingDetail = E('p', { 'class': 'dm-dialog-muted' });
|
||||
const pingOutput = E('pre', { 'class': 'dm-ping-output' });
|
||||
const pingLog = E('details', { 'class': 'dm-ping-log', 'style': 'display:none;' }, [
|
||||
E('summary', {}, [ _('Ping output') ]), pingOutput
|
||||
]);
|
||||
let pending = null;
|
||||
const runPing = () => {
|
||||
if (pending) return pending;
|
||||
pingButton.disabled = true;
|
||||
pingButton.classList.add('spinning');
|
||||
pingStatus.classList.remove('dm-ping-success');
|
||||
pingStatus.classList.remove('dm-ping-warning');
|
||||
pingStatus.textContent = _('Probing…');
|
||||
pingDetail.textContent = _('Sending one Ping from the router to this device.');
|
||||
pingLog.style.display = 'none';
|
||||
pending = Promise.resolve().then(() => self.handlePingDevice(dev)).then(reply => {
|
||||
pingStatus.textContent = reply.reachable ? _('Online · Ping replied') : _('No Ping reply');
|
||||
pingStatus.classList.add(reply.reachable ? 'dm-ping-success' : 'dm-ping-warning');
|
||||
pingDetail.textContent = _('Probe address: %s').format(reply.ip + (reply.interface ? ' (' + reply.interface + ')' : '')) +
|
||||
(reply.reachable ? '' : '\n' + _('No ICMP reply was received. The device may block Ping; this does not confirm it is offline.'));
|
||||
pingOutput.textContent = reply.output;
|
||||
pingLog.style.display = reply.output ? '' : 'none';
|
||||
}).catch(error => {
|
||||
pingStatus.textContent = _('Probe unavailable');
|
||||
pingStatus.classList.add('dm-ping-warning');
|
||||
pingDetail.textContent = error.message || String(error);
|
||||
pingOutput.textContent = error.output || '';
|
||||
pingLog.style.display = error.output ? '' : 'none';
|
||||
}).finally(() => {
|
||||
pending = null;
|
||||
pingButton.disabled = false;
|
||||
pingButton.classList.remove('spinning');
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
const pingButton = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'click': runPing }, [ _('Ping again') ]);
|
||||
const sourceLabels = {
|
||||
'DHCP': _('DHCP'), 'Host hints': _('Host hints'), 'Wi-Fi': _('Wi-Fi'),
|
||||
'ARP': _('ARP'), 'Neighbor table': _('Neighbor table')
|
||||
};
|
||||
const neighbors = (dev.neighbors || []).map(neighbor => [ neighbor.ip, neighbor.dev, neighbor.state ].filter(Boolean).join(' · ')).join('\n');
|
||||
ui.showModal(_('Device details'), [ E('div', { 'class': 'dm-dialog' }, [
|
||||
closeButton(),
|
||||
deviceHero(dev),
|
||||
E('section', { 'class': 'dm-ping-card' }, [
|
||||
E('div', { 'class': 'dm-ping-heading' }, [ pingStatus, pingButton ]), pingDetail, pingLog
|
||||
]),
|
||||
infoGrid([
|
||||
[ _('MAC address'), dev.mac ], [ _('System hostname'), dev.hostname ],
|
||||
[ _('Custom name'), dev.customName ], [ _('Device group'), self.getGroupName(dev.group) ],
|
||||
[ _('IPv4 addresses'), addresses('ipv4') ], [ _('IPv6 addresses'), addresses('ipv6') ],
|
||||
[ _('Network interfaces'), (dev.interfaces || []).join(', ') ],
|
||||
[ _('Type detection'), dev.customType && dev.customType !== 'auto' ? _('Manual') : _('Automatic') ],
|
||||
[ _('Saved record'), dev.isSaved ? _('Yes') : _('No') ],
|
||||
[ _('Discovered on network'), dev.isDiscovered ? _('Yes') : _('No') ],
|
||||
[ _('Discovery sources'), (dev.discoverySources || []).map(source => sourceLabels[source] || source).join(', '), true ],
|
||||
[ _('Status evidence'), dev.statusDetail, true ],
|
||||
[ _('Neighbor records'), neighbors, true ], [ _('Device remarks'), dev.remark, true ]
|
||||
]),
|
||||
E('div', { 'class': 'right dm-dialog-footer dm-dialog-buttons' }, [
|
||||
E('button', { 'type': 'button', 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
|
||||
])
|
||||
]) ], 'dm-device-modal');
|
||||
return runPing();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,12 @@
|
||||
Material Design Icons
|
||||
https://github.com/Templarian/MaterialDesign
|
||||
By the Pictogrammers icon group and Material Design Icons contributors.
|
||||
|
||||
The SVG path geometry in this directory was verified against upstream revision
|
||||
2424e748e0cc63ab7b9c095a099b9fe239b737c0. Original icon names and source URLs are
|
||||
listed in sources.json. The Apache License, Version 2.0 and upstream license
|
||||
summary are included as LICENSE and UPSTREAM-LICENSE.
|
||||
|
||||
Local modifications: filenames map to device types; width and height are set
|
||||
to 24 pixels; CSS selects foreground colours for light and dark browser themes.
|
||||
SPDX license comments were added. No SVG path geometry was changed.
|
||||
@@ -0,0 +1,20 @@
|
||||
Pictogrammers Free License
|
||||
--------------------------
|
||||
|
||||
This icon collection is released as free, open source, and GPL friendly by
|
||||
the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
|
||||
for commercial projects, open source projects, or anything really.
|
||||
|
||||
# Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
Some of the icons are redistributed under the Apache 2.0 license. All other
|
||||
icons are either redistributed under their respective licenses or are
|
||||
distributed under the Apache 2.0 license.
|
||||
|
||||
# Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
All web and desktop fonts are distributed under the Apache 2.0 license. Web
|
||||
and desktop fonts contain some icons that are redistributed under the Apache
|
||||
2.0 license. All other icons are either redistributed under their respective
|
||||
licenses or are distributed under the Apache 2.0 license.
|
||||
|
||||
# Code: MIT (https://opensource.org/licenses/MIT)
|
||||
The MIT license applies to all non-font and non-icon files.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-air-conditioner"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M6.59,0.66C8.93,-1.15 11.47,1.06 12.04,4.5C12.47,4.5 12.89,4.62 13.27,4.84C13.79,4.24 14.25,3.42 14.07,2.5C13.65,0.35 16.06,-1.39 18.35,1.58C20.16,3.92 17.95,6.46 14.5,7.03C14.5,7.46 14.39,7.89 14.16,8.27C14.76,8.78 15.58,9.24 16.5,9.06C18.63,8.64 20.38,11.04 17.41,13.34C15.07,15.15 12.53,12.94 11.96,9.5C11.53,9.5 11.11,9.37 10.74,9.15C10.22,9.75 9.75,10.58 9.93,11.5C10.35,13.64 7.94,15.39 5.65,12.42C3.83,10.07 6.05,7.53 9.5,6.97C9.5,6.54 9.63,6.12 9.85,5.74C9.25,5.23 8.43,4.76 7.5,4.94C5.37,5.36 3.62,2.96 6.59,0.66M5,16H7A2,2 0 0,1 9,18V24H7V22H5V24H3V18A2,2 0 0,1 5,16M5,18V20H7V18H5M12.93,16H15L12.07,24H10L12.93,16M18,16H21V18H18V22H21V24H18A2,2 0 0,1 16,22V18A2,2 0 0,1 18,16Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 999 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-air-purifier"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M11,9A4,4 0 0,1 15,13A4,4 0 0,1 11,17A4,4 0 0,1 7,13A4,4 0 0,1 11,9M11,11A2,2 0 0,0 9,13A2,2 0 0,0 11,15A2,2 0 0,0 13,13A2,2 0 0,0 11,11M7,4H14A4,4 0 0,1 18,8V9H16V8A2,2 0 0,0 14,6H7A2,2 0 0,0 5,8V20H16V18H18V22H3V8A4,4 0 0,1 7,4M16,11C18.5,11 18.5,9 21,9V11C18.5,11 18.5,13 16,13V11M16,15C18.5,15 18.5,13 21,13V15C18.5,15 18.5,17 16,17V15Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-access-point"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M4.93,4.93C3.12,6.74 2,9.24 2,12C2,14.76 3.12,17.26 4.93,19.07L6.34,17.66C4.89,16.22 4,14.22 4,12C4,9.79 4.89,7.78 6.34,6.34L4.93,4.93M19.07,4.93L17.66,6.34C19.11,7.78 20,9.79 20,12C20,14.22 19.11,16.22 17.66,17.66L19.07,19.07C20.88,17.26 22,14.76 22,12C22,9.24 20.88,6.74 19.07,4.93M7.76,7.76C6.67,8.85 6,10.35 6,12C6,13.65 6.67,15.15 7.76,16.24L9.17,14.83C8.45,14.11 8,13.11 8,12C8,10.89 8.45,9.89 9.17,9.17L7.76,7.76M16.24,7.76L14.83,9.17C15.55,9.89 16,10.89 16,12C16,13.11 15.55,14.11 14.83,14.83L16.24,16.24C17.33,15.15 18,13.65 18,12C18,10.35 17.33,8.85 16.24,7.76M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 950 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-cctv"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M6.03 12.03L8.03 15.5L5.5 18.68L2 12.62L6.03 12.03M17 18V15.29C17.88 14.9 18.5 14.03 18.5 13C18.5 12.43 18.3 11.9 17.97 11.5L19.94 10.35C20.95 9.76 21.3 8.47 20.71 7.46L19.33 5.06C18.74 4.05 17.45 3.7 16.44 4.28L8.31 9C7.36 9.53 7.03 10.75 7.58 11.71L9.08 14.31C9.63 15.26 10.86 15.59 11.81 15.04L13.69 13.96C13.94 14.55 14.41 15.03 15 15.29V18C15 19.1 15.9 20 17 20H22V18H17Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-car"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M5,11L6.5,6.5H17.5L19,11M17.5,16A1.5,1.5 0 0,1 16,14.5A1.5,1.5 0 0,1 17.5,13A1.5,1.5 0 0,1 19,14.5A1.5,1.5 0 0,1 17.5,16M6.5,16A1.5,1.5 0 0,1 5,14.5A1.5,1.5 0 0,1 6.5,13A1.5,1.5 0 0,1 8,14.5A1.5,1.5 0 0,1 6.5,16M18.92,6C18.72,5.42 18.16,5 17.5,5H6.5C5.84,5 5.28,5.42 5.08,6L3,12V20A1,1 0 0,0 4,21H5A1,1 0 0,0 6,20V19H18V20A1,1 0 0,0 19,21H20A1,1 0 0,0 21,20V12L18.92,6Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 669 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-monitor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M21,16H3V4H21M21,2H3C1.89,2 1,2.89 1,4V16A2,2 0 0,0 3,18H10V20H8V22H16V20H14V18H21A2,2 0 0,0 23,16V4C23,2.89 22.1,2 21,2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-fridge"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M7,2H17A2,2 0 0,1 19,4V9H5V4A2,2 0 0,1 7,2M19,19A2,2 0 0,1 17,21V22H15V21H9V22H7V21A2,2 0 0,1 5,19V10H19V19M8,5V7H10V5H8M8,12V15H10V12H8Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-controller"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M7.97,16L5,19C4.67,19.3 4.23,19.5 3.75,19.5A1.75,1.75 0 0,1 2,17.75V17.5L3,10.12C3.21,7.81 5.14,6 7.5,6H16.5C18.86,6 20.79,7.81 21,10.12L22,17.5V17.75A1.75,1.75 0 0,1 20.25,19.5C19.77,19.5 19.33,19.3 19,19L16.03,16H7.97M7,8V10H5V11H7V13H8V11H10V10H8V8H7M16.5,8A0.75,0.75 0 0,0 15.75,8.75A0.75,0.75 0 0,0 16.5,9.5A0.75,0.75 0 0,0 17.25,8.75A0.75,0.75 0 0,0 16.5,8M14.75,9.75A0.75,0.75 0 0,0 14,10.5A0.75,0.75 0 0,0 14.75,11.25A0.75,0.75 0 0,0 15.5,10.5A0.75,0.75 0 0,0 14.75,9.75M18.25,9.75A0.75,0.75 0 0,0 17.5,10.5A0.75,0.75 0 0,0 18.25,11.25A0.75,0.75 0 0,0 19,10.5A0.75,0.75 0 0,0 18.25,9.75M16.5,11.5A0.75,0.75 0 0,0 15.75,12.25A0.75,0.75 0 0,0 16.5,13A0.75,0.75 0 0,0 17.25,12.25A0.75,0.75 0 0,0 16.5,11.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1018 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-home-automation"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M12,3L2,12H5V20H19V12H22L12,3M12,8.5C14.34,8.5 16.46,9.43 18,10.94L16.8,12.12C15.58,10.91 13.88,10.17 12,10.17C10.12,10.17 8.42,10.91 7.2,12.12L6,10.94C7.54,9.43 9.66,8.5 12,8.5M12,11.83C13.4,11.83 14.67,12.39 15.6,13.3L14.4,14.47C13.79,13.87 12.94,13.5 12,13.5C11.06,13.5 10.21,13.87 9.6,14.47L8.4,13.3C9.33,12.39 10.6,11.83 12,11.83M12,15.17C12.94,15.17 13.7,15.91 13.7,16.83C13.7,17.75 12.94,18.5 12,18.5C11.06,18.5 10.3,17.75 10.3,16.83C10.3,15.91 11.06,15.17 12,15.17Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-laptop"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-lightbulb"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M12,2A7,7 0 0,0 5,9C5,11.38 6.19,13.47 8,14.74V17A1,1 0 0,0 9,18H15A1,1 0 0,0 16,17V14.74C17.81,13.47 19,11.38 19,9A7,7 0 0,0 12,2M9,21A1,1 0 0,0 10,22H14A1,1 0 0,0 15,21V20H9V21Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-nas"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M4,5C2.89,5 2,5.89 2,7V17C2,18.11 2.89,19 4,19H20C21.11,19 22,18.11 22,17V7C22,5.89 21.11,5 20,5H4M4.5,7A1,1 0 0,1 5.5,8A1,1 0 0,1 4.5,9A1,1 0 0,1 3.5,8A1,1 0 0,1 4.5,7M7,7H20V17H7V7M8,8V16H11V8H8M12,8V16H15V8H12M16,8V16H19V8H16M9,9H10V10H9V9M13,9H14V10H13V9M17,9H18V10H17V9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 575 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-lan"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M10,2C8.89,2 8,2.89 8,4V7C8,8.11 8.89,9 10,9H11V11H2V13H6V15H5C3.89,15 3,15.89 3,17V20C3,21.11 3.89,22 5,22H9C10.11,22 11,21.11 11,20V17C11,15.89 10.11,15 9,15H8V13H16V15H15C13.89,15 13,15.89 13,17V20C13,21.11 13.89,22 15,22H19C20.11,22 21,21.11 21,20V17C21,15.89 20.11,15 19,15H18V13H22V11H13V9H14C15.11,9 16,8.11 16,7V4C16,2.89 15.11,2 14,2H10M10,4H14V7H10V4M5,17H9V20H5V17M15,17H19V20H15V17Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 694 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-cellphone"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M17,19H7V5H17M17,1H7C5.89,1 5,1.89 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3C19,1.89 18.1,1 17,1Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-power-plug"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M16,7V3H14V7H10V3H8V7H8C7,7 6,8 6,9V14.5L9.5,18V21H14.5V18L18,14.5V9C18,8 17,7 16,7Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-printer"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M18,3H6V7H18M19,12A1,1 0 0,1 18,11A1,1 0 0,1 19,10A1,1 0 0,1 20,11A1,1 0 0,1 19,12M16,19H8V14H16M19,8H5A3,3 0 0,0 2,11V17H6V21H18V17H22V11A3,3 0 0,0 19,8Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-router-wireless"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 707 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-gauge"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12C20,14.4 19,16.5 17.3,18C15.9,16.7 14,16 12,16C10,16 8.2,16.7 6.7,18C5,16.5 4,14.4 4,12A8,8 0 0,1 12,4M14,5.89C13.62,5.9 13.26,6.15 13.1,6.54L11.81,9.77L11.71,10C11,10.13 10.41,10.6 10.14,11.26C9.73,12.29 10.23,13.45 11.26,13.86C12.29,14.27 13.45,13.77 13.86,12.74C14.12,12.08 14,11.32 13.57,10.76L13.67,10.5L14.96,7.29L14.97,7.26C15.17,6.75 14.92,6.17 14.41,5.96C14.28,5.91 14.15,5.89 14,5.89M10,6A1,1 0 0,0 9,7A1,1 0 0,0 10,8A1,1 0 0,0 11,7A1,1 0 0,0 10,6M7,9A1,1 0 0,0 6,10A1,1 0 0,0 7,11A1,1 0 0,0 8,10A1,1 0 0,0 7,9M17,9A1,1 0 0,0 16,10A1,1 0 0,0 17,11A1,1 0 0,0 18,10A1,1 0 0,0 17,9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 987 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-server"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 616 B |
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"revision": "2424e748e0cc63ab7b9c095a099b9fe239b737c0",
|
||||
"icons": {
|
||||
"sensor.svg": {
|
||||
"name": "gauge",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/gauge.svg"
|
||||
},
|
||||
"light.svg": {
|
||||
"name": "lightbulb",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/lightbulb.svg"
|
||||
},
|
||||
"computer.svg": {
|
||||
"name": "monitor",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/monitor.svg"
|
||||
},
|
||||
"fridge.svg": {
|
||||
"name": "fridge",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/fridge.svg"
|
||||
},
|
||||
"tablet.svg": {
|
||||
"name": "tablet",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/tablet.svg"
|
||||
},
|
||||
"airpurifier.svg": {
|
||||
"name": "air-purifier",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/air-purifier.svg"
|
||||
},
|
||||
"washer.svg": {
|
||||
"name": "washing-machine",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/washing-machine.svg"
|
||||
},
|
||||
"camera.svg": {
|
||||
"name": "cctv",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/cctv.svg"
|
||||
},
|
||||
"laptop.svg": {
|
||||
"name": "laptop",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/laptop.svg"
|
||||
},
|
||||
"speaker.svg": {
|
||||
"name": "speaker",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/speaker.svg"
|
||||
},
|
||||
"tv.svg": {
|
||||
"name": "television",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/television.svg"
|
||||
},
|
||||
"phone.svg": {
|
||||
"name": "cellphone",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/cellphone.svg"
|
||||
},
|
||||
"waterpurifier.svg": {
|
||||
"name": "water-check",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/water-check.svg"
|
||||
},
|
||||
"tvbox.svg": {
|
||||
"name": "set-top-box",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/set-top-box.svg"
|
||||
},
|
||||
"network.svg": {
|
||||
"name": "lan",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/lan.svg"
|
||||
},
|
||||
"airconditioner.svg": {
|
||||
"name": "air-conditioner",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/air-conditioner.svg"
|
||||
},
|
||||
"watch.svg": {
|
||||
"name": "watch-variant",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/watch-variant.svg"
|
||||
},
|
||||
"vr.svg": {
|
||||
"name": "virtual-reality",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/virtual-reality.svg"
|
||||
},
|
||||
"nas.svg": {
|
||||
"name": "nas",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/nas.svg"
|
||||
},
|
||||
"plug.svg": {
|
||||
"name": "power-plug",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/power-plug.svg"
|
||||
},
|
||||
"router.svg": {
|
||||
"name": "router-wireless",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/router-wireless.svg"
|
||||
},
|
||||
"home.svg": {
|
||||
"name": "home-automation",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/home-automation.svg"
|
||||
},
|
||||
"car.svg": {
|
||||
"name": "car",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/car.svg"
|
||||
},
|
||||
"server.svg": {
|
||||
"name": "server",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/server.svg"
|
||||
},
|
||||
"ap.svg": {
|
||||
"name": "access-point",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/access-point.svg"
|
||||
},
|
||||
"unknown.svg": {
|
||||
"name": "help-circle-outline",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/help-circle-outline.svg"
|
||||
},
|
||||
"printer.svg": {
|
||||
"name": "printer",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/printer.svg"
|
||||
},
|
||||
"game.svg": {
|
||||
"name": "controller",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/controller.svg"
|
||||
},
|
||||
"switch.svg": {
|
||||
"name": "ethernet",
|
||||
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/ethernet.svg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-speaker"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M12,12A3,3 0 0,0 9,15A3,3 0 0,0 12,18A3,3 0 0,0 15,15A3,3 0 0,0 12,12M12,20A5,5 0 0,1 7,15A5,5 0 0,1 12,10A5,5 0 0,1 17,15A5,5 0 0,1 12,20M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8C10.89,8 10,7.1 10,6C10,4.89 10.89,4 12,4M17,2H7C5.89,2 5,2.89 5,4V20A2,2 0 0,0 7,22H17A2,2 0 0,0 19,20V4C19,2.89 18.1,2 17,2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-ethernet"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M7,15H9V18H11V15H13V18H15V15H17V18H19V9H15V6H9V9H5V18H7V15M4.38,3H19.63C20.94,3 22,4.06 22,5.38V19.63A2.37,2.37 0 0,1 19.63,22H4.38C3.06,22 2,20.94 2,19.63V5.38C2,4.06 3.06,3 4.38,3Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-tablet"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M19,18H5V6H19M21,4H3C1.89,4 1,4.89 1,6V18A2,2 0 0,0 3,20H21A2,2 0 0,0 23,18V6C23,4.89 22.1,4 21,4Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-television"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M21,17H3V5H21M21,3H3A2,2 0 0,0 1,5V17A2,2 0 0,0 3,19H8V21H16V19H21A2,2 0 0,0 23,17V5A2,2 0 0,0 21,3Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-set-top-box"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M5,15.5A0.5,0.5 0 0,1 4.5,16H3.5A0.5,0.5 0 0,1 3,15.5V15H2A1,1 0 0,1 1,14V11A1,1 0 0,1 2,10H22A1,1 0 0,1 23,11V14A1,1 0 0,1 22,15H21V15.5A0.5,0.5 0 0,1 20.5,16H19.5A0.5,0.5 0 0,1 19,15.5V15H5V15.5M3,12V13H5V12H3M6,12V13H8V12H6M20.5,11.5A1,1 0 0,0 19.5,12.5A1,1 0 0,0 20.5,13.5A1,1 0 0,0 21.5,12.5A1,1 0 0,0 20.5,11.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-help-circle-outline"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-virtual-reality"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M5,3C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3H5M6,9H7.5L8.5,12.43L9.5,9H11L9.25,15H7.75L6,9M13,9H16.5C17.35,9 18,9.65 18,10.5V11.5C18,12.1 17.6,12.65 17.1,12.9L18,15H16.5L15.65,13H14.5V15H13V9M14.5,10.5V11.5H16.5V10.5H14.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-washing-machine"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M14.83,11.17C16.39,12.73 16.39,15.27 14.83,16.83C13.27,18.39 10.73,18.39 9.17,16.83L14.83,11.17M6,2H18A2,2 0 0,1 20,4V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2M7,4A1,1 0 0,0 6,5A1,1 0 0,0 7,6A1,1 0 0,0 8,5A1,1 0 0,0 7,4M10,4A1,1 0 0,0 9,5A1,1 0 0,0 10,6A1,1 0 0,0 11,5A1,1 0 0,0 10,4M12,8A6,6 0 0,0 6,14A6,6 0 0,0 12,20A6,6 0 0,0 18,14A6,6 0 0,0 12,8Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 673 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-watch-variant"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M8,0L7.17,5H7A2,2 0 0,0 5,7V17C5,18.11 5.9,19 7,19H7.17L8,24H16L16.83,19H17A2,2 0 0,0 19,17V7C19,5.89 18.1,5 17,5H16.83L16,0H8M7,7H17V17H7V7Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,12 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
<svg
|
||||
class="mdi mdi-water-check"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
|
||||
<path d="M21.75 16.25L17 21L14.25 18L15.41 16.84L17 18.43L20.59 14.84L21.75 16.25M17.62 12C16.31 8.1 12 3.25 12 3.25S6 10 6 14C6 17.31 8.69 20 12 20H12.34C12.12 19.36 12 18.7 12 18C12 14.82 14.5 12.22 17.62 12Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
'require device-manager.model as model';
|
||||
|
||||
return baseclass.extend({
|
||||
showGroupModal: function() {
|
||||
if (this.readonly) return;
|
||||
const self = this;
|
||||
|
||||
// Calculate count of devices per group
|
||||
const groupCounts = new Map();
|
||||
for (let i = 0; i < this.devices.length; i++) {
|
||||
const g = this.devices[i].group;
|
||||
if (g && g !== 'ungrouped') {
|
||||
groupCounts.set(g, (groupCounts.get(g) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const groupRows = this.groups.map(function(g) {
|
||||
const count = groupCounts.get(g.id) || 0;
|
||||
|
||||
const btnRename = E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-neutral',
|
||||
'style': 'margin-right:6px;',
|
||||
'click': function() { self.promptRenameGroup(g); }
|
||||
}, [ _('Rename') ]);
|
||||
|
||||
const btnDelete = E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-remove',
|
||||
'click': function() { self.confirmDeleteGroup(g, count); }
|
||||
}, [ _('Delete') ]);
|
||||
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td', 'style': 'font-weight:bold;' }, [ g.name ]),
|
||||
E('td', { 'class': 'td' }, E('code', {}, [ g.id ])),
|
||||
E('td', { 'class': 'td' }, [ _('%d devices').format(count) ]),
|
||||
E('td', { 'class': 'td', 'style': 'text-align:right;' }, [ btnRename, btnDelete ])
|
||||
]);
|
||||
});
|
||||
|
||||
const newGroupInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': _('e.g. Media devices, office devices'),
|
||||
'maxlength': 32,
|
||||
'style': 'width:240px; margin-right:8px;'
|
||||
});
|
||||
|
||||
const errorDiv = E('div', {
|
||||
'class': 'alert-message danger',
|
||||
'style': 'display:none; margin-bottom:12px;'
|
||||
});
|
||||
|
||||
const btnAddGroup = E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-positive',
|
||||
'click': function(ev) {
|
||||
const button = ev.currentTarget;
|
||||
const name = model.sanitizeInput(newGroupInput.value);
|
||||
if (!name) {
|
||||
errorDiv.textContent = _('Enter a valid group name');
|
||||
errorDiv.style.display = 'block';
|
||||
newGroupInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check duplicate name
|
||||
if (self.groups.some(g => g.name === name)) {
|
||||
errorDiv.textContent = _('A group with this name already exists');
|
||||
errorDiv.style.display = 'block';
|
||||
newGroupInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.add('spinning');
|
||||
button.disabled = true;
|
||||
|
||||
return self.handleAddGroup(name)
|
||||
.then(function() {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', [ _('Group "%s" created.').format(name) ]), 'info');
|
||||
return self.refresh().then(function() {
|
||||
self.showGroupModal();
|
||||
});
|
||||
})
|
||||
.catch(function(err) {
|
||||
errorDiv.textContent = _('Could not create group: %s').format(err.message || err);
|
||||
errorDiv.style.display = 'block';
|
||||
})
|
||||
.finally(function() {
|
||||
button.classList.remove('spinning');
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
}, [ _('Add group') ]);
|
||||
|
||||
ui.showModal(_('Device groups'), [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
errorDiv,
|
||||
E('table', { 'class': 'table', 'style': 'width:100%; margin-bottom:20px;' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, [ _('Group name') ]),
|
||||
E('th', { 'class': 'th' }, [ _('Group ID') ]),
|
||||
E('th', { 'class': 'th' }, [ _('Device count') ]),
|
||||
E('th', { 'class': 'th', 'style': 'text-align:right;' }, [ _('Actions') ])
|
||||
]),
|
||||
E('tbody', {}, groupRows)
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value', 'style': 'padding-top:10px; border-top:1px solid #eee;' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ _('New group') ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
newGroupInput,
|
||||
btnAddGroup,
|
||||
E('div', { 'class': 'cbi-value-description' }, [ _('Group names support Unicode text and common symbols') ])
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, [ _('Close') ])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
promptRenameGroup: function(group) {
|
||||
if (this.readonly) return;
|
||||
const self = this;
|
||||
const nameInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'value': group.name,
|
||||
'maxlength': 32,
|
||||
'style': 'width:100%;'
|
||||
});
|
||||
|
||||
const errorDiv = E('div', {
|
||||
'class': 'alert-message danger',
|
||||
'style': 'display:none; margin-bottom:12px;'
|
||||
});
|
||||
|
||||
ui.showModal(_('Rename group'), [
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
errorDiv,
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ _('Current group name') ]),
|
||||
E('div', { 'class': 'cbi-value-field', 'style': 'padding-top:6px;' }, [ group.name ])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, [ _('New group name') ]),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
nameInput,
|
||||
E('div', { 'class': 'cbi-value-description' }, [ _('Renaming preserves the group ID and all device assignments') ])
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [
|
||||
E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': function() { self.showGroupModal(); } }, [ _('Cancel') ]),
|
||||
' ',
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-positive',
|
||||
'click': function(ev) {
|
||||
const button = ev.currentTarget;
|
||||
const newName = model.sanitizeInput(nameInput.value);
|
||||
if (!newName) {
|
||||
errorDiv.textContent = _('Enter a valid group name');
|
||||
errorDiv.style.display = 'block';
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName === group.name) {
|
||||
self.showGroupModal();
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.add('spinning');
|
||||
button.disabled = true;
|
||||
|
||||
return self.handleRenameGroup(group.id, newName)
|
||||
.then(function() {
|
||||
ui.addNotification(null, E('p', [ _('Group renamed to "%s".').format(newName) ]), 'info');
|
||||
return self.refresh().then(function() {
|
||||
self.showGroupModal();
|
||||
});
|
||||
})
|
||||
.catch(function(err) {
|
||||
errorDiv.textContent = _('Rename failed: %s').format(err.message || err);
|
||||
errorDiv.style.display = 'block';
|
||||
})
|
||||
.finally(function() {
|
||||
button.classList.remove('spinning');
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
}, [ _('Save changes') ])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
confirmDeleteGroup: function(group, deviceCount) {
|
||||
if (this.readonly) return;
|
||||
const self = this;
|
||||
|
||||
ui.showModal(_('Delete group'), [
|
||||
E('p', {}, [ _('Delete group "%s"?').format(group.name) ]),
|
||||
E('p', { 'class': 'cbi-value-description' }, [ _('The %d devices in this group will become ungrouped. Their names, MAC addresses and remarks will be preserved.').format(deviceCount) ]),
|
||||
E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [
|
||||
E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': function() { self.showGroupModal(); } }, [ _('Cancel') ]),
|
||||
' ',
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'btn cbi-button cbi-button-remove',
|
||||
'click': function(ev) {
|
||||
const button = ev.currentTarget;
|
||||
button.classList.add('spinning');
|
||||
button.disabled = true;
|
||||
|
||||
return self.handleDeleteGroup(group.id)
|
||||
.then(function() {
|
||||
ui.addNotification(null, E('p', [ _('Group "%s" deleted. Its devices are now ungrouped.').format(group.name) ]), 'info');
|
||||
return self.refresh().then(function() {
|
||||
self.showGroupModal();
|
||||
});
|
||||
})
|
||||
.catch(function(err) {
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', [ _('Could not delete group: %s').format(err.message || err) ]), 'danger');
|
||||
}).finally(function() {
|
||||
button.classList.remove('spinning');
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
}, [ _('Confirm delete') ])
|
||||
])
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
const ACTIVE_STATES = [ 'REACHABLE', 'DELAY', 'PROBE' ];
|
||||
const STATE_PRIORITY = { REACHABLE: 6, DELAY: 5, PROBE: 4, STALE: 3, PERMANENT: 2, NOARP: 2, INCOMPLETE: 1, FAILED: 0 };
|
||||
|
||||
// Localize untouched defaults; user-defined names remain literal configuration data.
|
||||
const DEFAULT_GROUPS = {
|
||||
smart_home: { stored: [ '智能家居', 'Smart home' ], label: () => _('Smart home') },
|
||||
phone: { stored: [ '手机设备', 'Phones' ], label: () => _('Phones') },
|
||||
computer: { stored: [ '电脑设备', 'Computers' ], label: () => _('Computers') },
|
||||
network: { stored: [ '网络设备', 'Network devices' ], label: () => _('Network devices') },
|
||||
other: { stored: [ '其他设备', 'Other devices' ], label: () => _('Other devices') }
|
||||
};
|
||||
|
||||
const DEVICE_TYPES = {
|
||||
computer: { id: 'computer', icon: 'computer.svg', label: () => _('Computer') },
|
||||
laptop: { id: 'laptop', icon: 'laptop.svg', label: () => _('Laptop') },
|
||||
phone: { id: 'phone', icon: 'phone.svg', label: () => _('Phone') },
|
||||
tablet: { id: 'tablet', icon: 'tablet.svg', label: () => _('Tablet') },
|
||||
tv: { id: 'tv', icon: 'tv.svg', label: () => _('TV') },
|
||||
tvbox: { id: 'tvbox', icon: 'tvbox.svg', label: () => _('TV box') },
|
||||
nas: { id: 'nas', icon: 'nas.svg', label: () => _('NAS') },
|
||||
printer: { id: 'printer', icon: 'printer.svg', label: () => _('Printer') },
|
||||
camera: { id: 'camera', icon: 'camera.svg', label: () => _('Camera') },
|
||||
speaker: { id: 'speaker', icon: 'speaker.svg', label: () => _('Speaker') },
|
||||
game: { id: 'game', icon: 'game.svg', label: () => _('Game console') },
|
||||
router: { id: 'router', icon: 'router.svg', label: () => _('Router') },
|
||||
switch: { id: 'switch', icon: 'switch.svg', label: () => _('Switch') },
|
||||
ap: { id: 'ap', icon: 'ap.svg', label: () => _('Access point') },
|
||||
server: { id: 'server', icon: 'server.svg', label: () => _('Server') },
|
||||
plug: { id: 'plug', icon: 'plug.svg', label: () => _('Smart plug') },
|
||||
light: { id: 'light', icon: 'light.svg', label: () => _('Smart light') },
|
||||
sensor: { id: 'sensor', icon: 'sensor.svg', label: () => _('Sensor') },
|
||||
home: { id: 'home', icon: 'home.svg', label: () => _('Smart home') },
|
||||
watch: { id: 'watch', icon: 'watch.svg', label: () => _('Watch') },
|
||||
car: { id: 'car', icon: 'car.svg', label: () => _('Vehicle') },
|
||||
vr: { id: 'vr', icon: 'vr.svg', label: () => _('VR headset') },
|
||||
network: { id: 'network', icon: 'network.svg', label: () => _('Network device') },
|
||||
airconditioner: { id: 'airconditioner', icon: 'airconditioner.svg', label: () => _('Air conditioner') },
|
||||
washer: { id: 'washer', icon: 'washer.svg', label: () => _('Washing machine') },
|
||||
fridge: { id: 'fridge', icon: 'fridge.svg', label: () => _('Refrigerator') },
|
||||
waterpurifier: { id: 'waterpurifier', icon: 'waterpurifier.svg', label: () => _('Water purifier') },
|
||||
airpurifier: { id: 'airpurifier', icon: 'airpurifier.svg', label: () => _('Air purifier') },
|
||||
unknown: { id: 'unknown', icon: 'unknown.svg', label: () => _('Unknown') }
|
||||
};
|
||||
|
||||
// Small, documented vendor hints, not a fingerprint database. See docs/device-identification.md.
|
||||
// Broad vendors such as Apple, Google and HP do not imply one device type.
|
||||
const OUI_MAP = {
|
||||
'001132': 'nas', '4CFCAA': 'car', '2C2617': 'vr',
|
||||
'1012FB': 'camera', '0009BF': 'game', '000E58': 'speaker',
|
||||
'001788': 'light', '18FE34': 'home', '54EF44': 'home',
|
||||
'00156D': 'network', '000FE2': 'network', '000C42': 'router',
|
||||
'B827EB': 'computer'
|
||||
};
|
||||
|
||||
function getTypeInfo(type) {
|
||||
return DEVICE_TYPES[type] || DEVICE_TYPES.unknown;
|
||||
}
|
||||
|
||||
function getTypeLabel(type) {
|
||||
const info = getTypeInfo(type);
|
||||
return info.label();
|
||||
}
|
||||
|
||||
function detectDeviceType(mac, hostname, customName, group) {
|
||||
const normalized = normalizeMac(mac);
|
||||
if (!normalized) return 'unknown';
|
||||
const text = (String(customName || '') + ' ' + String(hostname || '')).toLowerCase();
|
||||
// Specific device names precede broad vendor hints. Short tokens require
|
||||
// boundaries: "Cambridge-PC" is not "cam"; a network switch is not a console.
|
||||
const clues = [
|
||||
[ 'car', /(^|[^a-z0-9])(tesla|model[-_ ]*[3ysx]|byd|nio|xpeng|li-auto|polestar|rivian|zeekr)(?=$|[^a-z0-9])/ ],
|
||||
[ 'vr', /(^|[^a-z0-9])(quest|oculus|pico|vive|vision[-_ ]?pro|vr)(?=$|[^a-z0-9])/ ],
|
||||
[ 'nas', /(^|[^a-z0-9])(synology|diskstation|qnap|truenas|freenas|unraid|asustor|terramaster|openmediavault|omv|nas)(?=$|[^a-z0-9])/ ],
|
||||
[ 'printer', /(^|[^a-z0-9])(printer|laserjet|deskjet|epson|xerox|print)(?=$|[^a-z0-9])/ ],
|
||||
[ 'camera', /(^|[^a-z0-9])(camera|cctv|ipc|cam|webcam|hikvision|dahua|reolink|uniview|ezviz|wyze|imou)(?=$|[^a-z0-9])/ ],
|
||||
[ 'game', /(^|[^a-z0-9])(playstation|ps[345]|xbox|nintendo|steamdeck|gamepad)(?=$|[^a-z0-9])/ ],
|
||||
[ 'speaker', /(^|[^a-z0-9])(homepod|echo|alexa|sonos|soundbar|speaker|xiaoai)(?=$|[^a-z0-9])/ ],
|
||||
[ 'tvbox', /(^|[^a-z0-9])(appletv|apple-tv|chromecast|mibox|mi-box|firetv|fire-tv|firestick|roku|shield-tv|tvbox|tv-box|stb)(?=$|[^a-z0-9])/ ],
|
||||
[ 'tv', /(^|[^a-z0-9])(smarttv|smart-tv|mitv|mi-tv|bravia|tizen|webos|tv)(?=$|[^a-z0-9])/ ],
|
||||
[ 'watch', /(^|[^a-z0-9])(applewatch|apple-watch|iwatch|galaxy-watch|smartwatch|fitbit|garmin|watch|band)(?=$|[^a-z0-9])/ ],
|
||||
[ 'tablet', /(^|[^a-z0-9])(ipad|tablet|tab|mediapad|pad)(?=$|[^a-z0-9])/ ],
|
||||
[ 'laptop', /(^|[^a-z0-9])(macbook|laptop|thinkpad|notebook|zenbook|ideapad|matebook|surface-laptop|latitude|inspiron|xps)(?=$|[^a-z0-9])/ ],
|
||||
[ 'server', /(^|[^a-z0-9])(server|pve|proxmox|esxi|vmware|docker|kubernetes|k8s)(?=$|[^a-z0-9])/ ],
|
||||
[ 'computer', /(^|[^a-z0-9])(desktop|imac|macmini|macstudio|macpro|pc|tower|workstation|optiplex|windows)(?=$|[^a-z0-9])/ ],
|
||||
[ 'airconditioner', /(^|[^a-z0-9])(airconditioner|air-conditioner|aircon|air_cond|hvac|ac-unit|ac_unit)(?=$|[^a-z0-9])|空调/ ],
|
||||
[ 'washer', /(^|[^a-z0-9])(washer|washing-machine|washingmachine|dryer|laundry)(?=$|[^a-z0-9])|洗衣机|烘干机/ ],
|
||||
[ 'fridge', /(^|[^a-z0-9])(fridge|refrigerator|freezer)(?=$|[^a-z0-9])|冰箱|冷柜/ ],
|
||||
[ 'waterpurifier', /(^|[^a-z0-9])(waterpurifier|water-purifier)(?=$|[^a-z0-9])|净水器|直饮机|净水机/ ],
|
||||
[ 'airpurifier', /(^|[^a-z0-9])(airpurifier|air-purifier)(?=$|[^a-z0-9])|空净|空气净化/ ],
|
||||
[ 'phone', /(^|[^a-z0-9])(iphone|galaxy|redmi|xiaomi|huawei|honor|pixel|oneplus|oppo|vivo|xperia|realme|meizu|phone|mobile)(?=$|[^a-z0-9])/ ],
|
||||
[ 'plug', /(^|[^a-z0-9])(smartplug|smart-plug|socket|outlet|plug)(?=$|[^a-z0-9])/ ],
|
||||
[ 'light', /(^|[^a-z0-9])(smartlight|smart-light|light|bulb|lamp|yeelight|hue|strip)(?=$|[^a-z0-9])/ ],
|
||||
[ 'sensor', /(^|[^a-z0-9])(sensor|temp|humidity|motion|door|window|detector)(?=$|[^a-z0-9])/ ],
|
||||
[ 'home', /(^|[^a-z0-9])(homeassistant|hass|homebridge|aqara|hub|smart-home|smarthome)(?=$|[^a-z0-9])/ ],
|
||||
[ 'ap', /(^|[^a-z0-9])(accesspoint|unifi|uap|eap|ap)(?=$|[^a-z0-9])/ ],
|
||||
[ 'switch', /(^|[^a-z0-9])(switch|usw|tl-sg)(?=$|[^a-z0-9])|交换机/ ],
|
||||
[ 'router', /(^|[^a-z0-9])(router|openwrt|gateway|ax[0-9]{4}|ac[0-9]{4}|archer|asuswrt)(?=$|[^a-z0-9])/ ]
|
||||
];
|
||||
for (const [ type, pattern ] of clues) if (pattern.test(text)) return type;
|
||||
// Locally administered/randomized MACs have no reliable vendor prefix.
|
||||
if (!(parseInt(normalized.slice(0, 2), 16) & 2)) {
|
||||
const oui = normalized.replace(/:/g, '').slice(0, 6);
|
||||
if (OUI_MAP[oui]) return OUI_MAP[oui];
|
||||
}
|
||||
const groupTypes = { smart_home: 'home', phone: 'phone', computer: 'computer', network: 'network' };
|
||||
return groupTypes[group] || 'unknown';
|
||||
}
|
||||
|
||||
function normalizeMac(mac) {
|
||||
if (typeof mac !== 'string') return null;
|
||||
const value = mac.trim().toUpperCase().replace(/[:-]/g, '');
|
||||
if (!/^[0-9A-F]{12}$/.test(value) || /^(0{12}|F{12})$/.test(value)) return null;
|
||||
return value.match(/.{2}/g).join(':');
|
||||
}
|
||||
|
||||
function sanitizeInput(value) {
|
||||
return value == null ? '' : String(value).replace(/[\x00-\x1f\x7f]/g, ' ').trim();
|
||||
}
|
||||
|
||||
function sectionMac(section) {
|
||||
return normalizeMac(section.mac) || normalizeMac((section['.name'] || '').replace(/^dev_/, ''));
|
||||
}
|
||||
|
||||
function groupName(groups, id) {
|
||||
const group = groups.find(g => g.id === id);
|
||||
return group ? group.name : _('Ungrouped');
|
||||
}
|
||||
|
||||
function matchesDevice(device, groups, group, query) {
|
||||
if (group !== 'all' && device.group !== group) return false;
|
||||
if (!query) return true;
|
||||
const typeInfo = getTypeInfo(device.type);
|
||||
return [ device.customName, device.hostname, device.ipv4, device.ipv6, device.mac,
|
||||
device.remark, groupName(groups, device.group),
|
||||
typeInfo.label(), device.type ].some(value => String(value || '').toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function parseDevices(data, groups) {
|
||||
const devices = new Map();
|
||||
const addresses = new Map();
|
||||
const addressInterfaces = new Map();
|
||||
const neighborStates = new Map();
|
||||
const wifi = new Set();
|
||||
const arp = new Set();
|
||||
const getEntry = mac => {
|
||||
const normalized = normalizeMac(mac);
|
||||
if (!normalized) return null;
|
||||
if (!devices.has(normalized)) devices.set(normalized, {
|
||||
mac: normalized, hostname: '', ipv4: '', ipv6: '', customName: '', remark: '',
|
||||
group: 'ungrouped', sid: null, isSaved: false, isDiscovered: false,
|
||||
status: 'unknown', statusDetail: '', type: 'unknown',
|
||||
ipv4Addresses: [], ipv6Addresses: [], interfaces: [], discoverySources: [], neighbors: []
|
||||
});
|
||||
return devices.get(normalized);
|
||||
};
|
||||
const addAddress = (device, ip, preferred, iface) => {
|
||||
if (!device || typeof ip !== 'string' || !ip) return;
|
||||
// DHCPv6 may include a prefix length. Keep address matching consistent.
|
||||
const address = ip.split('/')[0].toLowerCase();
|
||||
if (!addresses.has(address)) addresses.set(address, new Set());
|
||||
addresses.get(address).add(device.mac);
|
||||
if (iface) {
|
||||
if (!addressInterfaces.has(address)) addressInterfaces.set(address, new Set());
|
||||
addressInterfaces.get(address).add(iface);
|
||||
}
|
||||
const field = address.includes(':') ? 'ipv6' : 'ipv4';
|
||||
if (!device[field + 'Addresses'].includes(address)) device[field + 'Addresses'].push(address);
|
||||
if (iface && !device.interfaces.includes(iface)) device.interfaces.push(iface);
|
||||
if (!device[field] || preferred) device[field] = address;
|
||||
};
|
||||
const addSource = (device, source) => {
|
||||
if (!device.discoverySources.includes(source)) device.discoverySources.push(source);
|
||||
};
|
||||
const toArray = value => Array.isArray(value) ? value : value == null ? [] : [ value ];
|
||||
const leases = data.leases || {};
|
||||
for (const lease of [ ...toArray(leases.dhcp_leases), ...toArray(leases.dhcp6_leases) ]) {
|
||||
const device = getEntry(lease.macaddr);
|
||||
if (!device) continue;
|
||||
device.isDiscovered = true;
|
||||
addSource(device, 'DHCP');
|
||||
device.hostname = sanitizeInput(lease.hostname || device.hostname);
|
||||
for (const ip of [ ...toArray(lease.ipaddr), ...toArray(lease.ip6addrs || lease.ip6addr) ]) addAddress(device, ip);
|
||||
}
|
||||
for (const [ mac, hint ] of Object.entries(data.hints || {})) {
|
||||
const device = getEntry(mac);
|
||||
if (!device || !hint || typeof hint !== 'object') continue;
|
||||
device.isDiscovered = true;
|
||||
addSource(device, 'Host hints');
|
||||
if (!device.hostname) device.hostname = sanitizeInput(hint.name);
|
||||
for (const ip of [ ...toArray(hint.ipaddrs || hint.ipv4), ...toArray(hint.ip6addrs || hint.ipv6) ]) addAddress(device, ip);
|
||||
}
|
||||
for (const section of data.devices || []) {
|
||||
const device = getEntry(sectionMac(section));
|
||||
if (!device) continue;
|
||||
device.isSaved = true;
|
||||
device.sid = section['.name'];
|
||||
device.customName = sanitizeInput(section.name);
|
||||
device.remark = sanitizeInput(section.remark);
|
||||
device.group = groups.some(g => g.id === section.group) ? section.group : 'ungrouped';
|
||||
if (section.type) device.customType = sanitizeInput(section.type);
|
||||
}
|
||||
for (const station of data.wifi || []) {
|
||||
const device = getEntry(typeof station === 'string' ? station : station.mac);
|
||||
if (!device) continue;
|
||||
device.isDiscovered = true;
|
||||
addSource(device, 'Wi-Fi');
|
||||
if (station.dev && !device.interfaces.includes(station.dev)) device.interfaces.push(station.dev);
|
||||
wifi.add(device.mac);
|
||||
}
|
||||
for (const entry of data.arp || []) {
|
||||
const device = getEntry(entry.mac);
|
||||
if (!device) continue;
|
||||
device.isDiscovered = true;
|
||||
addSource(device, 'ARP');
|
||||
addAddress(device, entry.ip, false, entry.dev);
|
||||
if (Number(entry.flags) === 2) arp.add(device.mac);
|
||||
}
|
||||
const neighbors = (data.neighbors || {}).neighbors || [];
|
||||
// Index all resolved neighbors first, so FAILED association is order independent.
|
||||
for (const neighbor of neighbors) {
|
||||
const device = getEntry(neighbor.mac);
|
||||
if (!device) continue;
|
||||
device.isDiscovered = true;
|
||||
addAddress(device, neighbor.ip, ACTIVE_STATES.includes(neighbor.state), neighbor.dev);
|
||||
}
|
||||
for (const neighbor of neighbors) {
|
||||
let mac = normalizeMac(neighbor.mac);
|
||||
if (!mac && typeof neighbor.ip === 'string') {
|
||||
const address = neighbor.ip.split('/')[0].toLowerCase();
|
||||
const interfaces = addressInterfaces.get(address);
|
||||
if (neighbor.dev && interfaces && !interfaces.has(neighbor.dev)) continue;
|
||||
const owners = addresses.get(address);
|
||||
// Never attach a failure to an ambiguous or reassigned address.
|
||||
if (owners && owners.size === 1) mac = owners.values().next().value;
|
||||
}
|
||||
if (!mac || !devices.has(mac)) continue;
|
||||
const device = devices.get(mac);
|
||||
addSource(device, 'Neighbor table');
|
||||
device.neighbors.push({ ip: neighbor.ip || '', dev: neighbor.dev || '', state: neighbor.state || '' });
|
||||
if (neighbor.dev && !device.interfaces.includes(neighbor.dev)) device.interfaces.push(neighbor.dev);
|
||||
const state = String(neighbor.state || '').toUpperCase();
|
||||
const previous = neighborStates.get(mac);
|
||||
if (previous == null || (STATE_PRIORITY[state] ?? -1) > (STATE_PRIORITY[previous] ?? -1)) neighborStates.set(mac, state);
|
||||
}
|
||||
const complete = data.discoveryComplete === true;
|
||||
for (const device of devices.values()) {
|
||||
device.type = (device.customType && device.customType !== 'auto')
|
||||
? device.customType
|
||||
: detectDeviceType(device.mac, device.hostname, device.customName, device.group);
|
||||
const state = neighborStates.get(device.mac);
|
||||
if (wifi.has(device.mac)) {
|
||||
device.status = 'online';
|
||||
device.statusDetail = _('Active Wi-Fi association');
|
||||
} else if (ACTIVE_STATES.includes(state)) {
|
||||
device.status = 'online';
|
||||
device.statusDetail = _('Active network neighbor (%s)').format(state);
|
||||
} else if (state === 'FAILED') {
|
||||
device.status = 'offline';
|
||||
device.statusDetail = _('Network neighbor probe failed (FAILED)');
|
||||
} else if (!complete) {
|
||||
device.statusDetail = _('Some discovery sources are unavailable');
|
||||
} else if (!device.isDiscovered && !state && device.isSaved) {
|
||||
device.status = 'offline';
|
||||
device.statusDetail = _('Saved device not found in the current network');
|
||||
} else if (state) {
|
||||
device.statusDetail = _('No recent activity confirmed (%s)').format(state);
|
||||
} else if (arp.has(device.mac)) {
|
||||
device.statusDetail = _('ARP record exists without recent activity evidence');
|
||||
} else {
|
||||
device.statusDetail = _('Known device without recent activity evidence');
|
||||
}
|
||||
}
|
||||
const statusOrder = { online: 0, unknown: 1, offline: 2 };
|
||||
return Array.from(devices.values()).sort((a, b) => {
|
||||
if (a.status !== b.status) return statusOrder[a.status] - statusOrder[b.status];
|
||||
if (!!a.ipv4 !== !!b.ipv4) return a.ipv4 ? -1 : 1;
|
||||
if (a.ipv4 && b.ipv4) {
|
||||
const left = a.ipv4.split('.').map(Number), right = b.ipv4.split('.').map(Number);
|
||||
for (let i = 0; i < 4; i++) if (left[i] !== right[i]) return left[i] - right[i];
|
||||
}
|
||||
return a.mac.localeCompare(b.mac);
|
||||
});
|
||||
}
|
||||
|
||||
function parseIpv4(ip) {
|
||||
if (!ip || typeof ip !== 'string') return null;
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some(n => isNaN(n) || n < 0 || n > 255)) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
function sortDevices(devices, sortKey, sortDir, getGroupName) {
|
||||
if (!sortKey || !Array.isArray(devices)) return devices || [];
|
||||
const dir = (sortDir === 'desc') ? -1 : 1;
|
||||
return devices.slice().sort((a, b) => {
|
||||
if (sortKey === 'ip') {
|
||||
const pa = parseIpv4(a.ipv4);
|
||||
const pb = parseIpv4(b.ipv4);
|
||||
// Devices with valid IPv4 always appear before devices without IPv4
|
||||
if (Boolean(pa) !== Boolean(pb)) {
|
||||
return pa ? -1 : 1;
|
||||
}
|
||||
if (pa && pb) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (pa[i] !== pb[i]) return (pa[i] - pb[i]) * dir;
|
||||
}
|
||||
}
|
||||
// Both lack IPv4 (or identical IPv4): check IPv6
|
||||
const v6a = a.ipv6 || '';
|
||||
const v6b = b.ipv6 || '';
|
||||
if (Boolean(v6a) !== Boolean(v6b)) {
|
||||
return v6a ? -1 : 1;
|
||||
}
|
||||
if (v6a && v6b) {
|
||||
const cmpV6 = v6a.localeCompare(v6b);
|
||||
if (cmpV6 !== 0) return cmpV6 * dir;
|
||||
}
|
||||
// Tie-breaker: MAC
|
||||
return (a.mac || '').localeCompare(b.mac || '') * dir;
|
||||
}
|
||||
|
||||
if (sortKey === 'name') {
|
||||
const nameA = a.customName || a.hostname || '';
|
||||
const nameB = b.customName || b.hostname || '';
|
||||
if (Boolean(nameA) !== Boolean(nameB)) return nameA ? -1 : 1;
|
||||
if (nameA && nameB) {
|
||||
const cmp = nameA.localeCompare(nameB);
|
||||
if (cmp !== 0) return cmp * dir;
|
||||
}
|
||||
return (a.mac || '').localeCompare(b.mac || '') * dir;
|
||||
}
|
||||
|
||||
if (sortKey === 'mac') {
|
||||
return (a.mac || '').localeCompare(b.mac || '') * dir;
|
||||
}
|
||||
|
||||
if (sortKey === 'group') {
|
||||
const gA = (typeof getGroupName === 'function' ? getGroupName(a.group) : a.group) || '';
|
||||
const gB = (typeof getGroupName === 'function' ? getGroupName(b.group) : b.group) || '';
|
||||
if (Boolean(gA) !== Boolean(gB)) return gA ? -1 : 1;
|
||||
if (gA && gB) {
|
||||
const cmp = gA.localeCompare(gB);
|
||||
if (cmp !== 0) return cmp * dir;
|
||||
}
|
||||
return (a.mac || '').localeCompare(b.mac || '') * dir;
|
||||
}
|
||||
|
||||
if (sortKey === 'type') {
|
||||
const tA = a.type || '';
|
||||
const tB = b.type || '';
|
||||
if (tA !== tB) return tA.localeCompare(tB) * dir;
|
||||
return (a.mac || '').localeCompare(b.mac || '') * dir;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
DEVICE_TYPES: DEVICE_TYPES,
|
||||
detectDeviceType: detectDeviceType,
|
||||
getTypeInfo: getTypeInfo,
|
||||
getTypeLabel: getTypeLabel,
|
||||
normalizeMac: normalizeMac,
|
||||
sanitizeInput: sanitizeInput,
|
||||
sectionMac: sectionMac,
|
||||
getSectionId: mac => 'dev_' + normalizeMac(mac).replace(/:/g, '').toLowerCase(),
|
||||
groupName: groupName,
|
||||
matchesDevice: matchesDevice,
|
||||
parseDevices: parseDevices,
|
||||
sortDevices: sortDevices,
|
||||
parseGroups: sections => (sections || []).map(section => {
|
||||
const id = section['.name'], name = sanitizeInput(section.name) || id;
|
||||
const defaults = DEFAULT_GROUPS[id];
|
||||
return { id: id, name: defaults && defaults.stored.includes(name) ? defaults.label() : name };
|
||||
}),
|
||||
parseArp: content => String(content || '').trim().split('\n').slice(1).map(line => {
|
||||
const fields = line.trim().split(/\s+/);
|
||||
return { ip: fields[0], flags: fields[2], mac: fields[3], dev: fields[5] };
|
||||
}).filter(entry => entry.dev && normalizeMac(entry.mac))
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
const STORAGE_KEY = 'luci-device-manager-view';
|
||||
const DEFAULT_STATE = { tab: 'online', group: 'all' };
|
||||
|
||||
// Storage access and JSON parsing can throw synchronously; isolate them in promises.
|
||||
return baseclass.extend({
|
||||
load: function() {
|
||||
return Promise.resolve().then(() => window.localStorage.getItem(STORAGE_KEY)).then(raw => {
|
||||
const state = raw ? JSON.parse(raw) : null;
|
||||
if (!state || typeof state !== 'object') return Object.assign({}, DEFAULT_STATE);
|
||||
const result = {
|
||||
tab: typeof state.tab === 'string' ? state.tab : 'online',
|
||||
group: typeof state.group === 'string' ? state.group : 'all'
|
||||
};
|
||||
if (typeof state.maskInfo === 'boolean') result.maskInfo = state.maskInfo;
|
||||
return result;
|
||||
}).catch(() => Object.assign({}, DEFAULT_STATE));
|
||||
},
|
||||
save: function(tab, group, maskInfo) {
|
||||
const payload = { tab: tab || 'online', group: group || 'all' };
|
||||
if (typeof maskInfo === 'boolean') payload.maskInfo = maskInfo;
|
||||
return Promise.resolve().then(() => window.localStorage.setItem(STORAGE_KEY,
|
||||
JSON.stringify(payload))).catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require uci';
|
||||
'require fs';
|
||||
'require device-manager.model as model';
|
||||
|
||||
const CONFIG = 'device_manager';
|
||||
const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', reject: true });
|
||||
const callLeases = rpc.declare({ object: 'luci-rpc', method: 'getDHCPLeases', reject: true });
|
||||
const callNeighbors = rpc.declare({ object: 'luci.device-manager', method: 'get_online_status', reject: true });
|
||||
const callScan = rpc.declare({ object: 'luci.device-manager', method: 'scan_devices', reject: true });
|
||||
const callPing = rpc.declare({ object: 'luci.device-manager', method: 'ping_device', params: [ 'mac' ], reject: true });
|
||||
const callWireless = rpc.declare({ object: 'luci-rpc', method: 'getWirelessDevices', reject: true });
|
||||
const callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], reject: true });
|
||||
const callCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
|
||||
const callRevert = rpc.declare({ object: 'uci', method: 'revert', params: [ 'config' ], reject: true });
|
||||
|
||||
function requireObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(_('Invalid discovery response'));
|
||||
return value;
|
||||
}
|
||||
|
||||
function readSource(name, read) {
|
||||
return Promise.resolve().then(read).then(value => ({ name: name, ok: true, value: value }),
|
||||
error => ({ name: name, ok: false, error: error.message || String(error) }));
|
||||
}
|
||||
|
||||
function wifiStations() {
|
||||
return callWireless().then(requireObject).then(radios => {
|
||||
const interfaces = new Set();
|
||||
for (const radio of Object.values(radios)) {
|
||||
for (const iface of radio.interfaces || []) {
|
||||
const mode = (iface.config || {}).mode;
|
||||
if (mode && mode !== 'ap' && mode !== 'ap-wds') continue;
|
||||
if (iface.ifname) interfaces.add(iface.ifname);
|
||||
for (const vlan of iface.vlans || []) if (vlan.ifname) interfaces.add(vlan.ifname);
|
||||
}
|
||||
}
|
||||
return Promise.all(Array.from(interfaces).map(device => readSource(device, () => callAssoc(device).then(reply => {
|
||||
if (!Array.isArray(requireObject(reply).results)) throw new Error(_('Invalid discovery response'));
|
||||
return reply.results.map(station => ({ mac: station.mac, dev: device }));
|
||||
})))).then(results => ({
|
||||
stations: results.filter(result => result.ok).flatMap(result => result.value),
|
||||
complete: results.every(result => result.ok),
|
||||
errors: results.filter(result => !result.ok).map(result => result.name + ': ' + result.error)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function reloadConfig() {
|
||||
uci.unload(CONFIG);
|
||||
return uci.load(CONFIG);
|
||||
}
|
||||
|
||||
function validateText(value, limit) {
|
||||
const text = model.sanitizeInput(value);
|
||||
if (text.length > limit) throw new Error(_('Input is too long'));
|
||||
return text;
|
||||
}
|
||||
|
||||
function validateGroupName(name, exceptId) {
|
||||
const text = validateText(name, 32);
|
||||
if (!text) throw new Error(_('Enter a valid group name'));
|
||||
if (uci.sections(CONFIG, 'group').some(group => group['.name'] !== exceptId && model.sanitizeInput(group.name) === text))
|
||||
throw new Error(_('A group with this name already exists'));
|
||||
return text;
|
||||
}
|
||||
|
||||
function requireGroup(id) {
|
||||
if (!uci.sections(CONFIG, 'group').some(group => group['.name'] === id)) throw new Error(_('This group no longer exists'));
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
scanDevices: function() {
|
||||
if (L.hasViewPermission() !== true) return Promise.reject(new Error(_('Read-only access')));
|
||||
return this.queue(() => callScan().then(reply => {
|
||||
if (requireObject(reply).ok !== true)
|
||||
throw new Error(_('LAN scanning is unavailable. Check the selected interfaces and probe dependencies.'));
|
||||
}));
|
||||
},
|
||||
pingDevice: function(mac) {
|
||||
const normalized = model.normalizeMac(mac);
|
||||
if (!normalized) return Promise.reject(new Error(_('Enter a valid MAC address')));
|
||||
return callPing(normalized).then(reply => {
|
||||
const errors = {
|
||||
'No known IP address for this device': _('No known IP address for this device'),
|
||||
'Another device is being probed. Please retry.': _('Another device is being probed. Please retry.'),
|
||||
'Ping is unavailable': _('Ping is unavailable'),
|
||||
'Enter a valid MAC address': _('Enter a valid MAC address'),
|
||||
'Unable to read the kernel neighbor table': _('Unable to read the kernel neighbor table')
|
||||
};
|
||||
if (requireObject(reply).ok !== true) {
|
||||
const error = new Error(errors[reply.error] || _('Ping could not be completed'));
|
||||
error.output = typeof reply.output === 'string' ? reply.output : '';
|
||||
throw error;
|
||||
}
|
||||
if (typeof reply.reachable !== 'boolean' || typeof reply.ip !== 'string' || typeof reply.output !== 'string')
|
||||
throw new Error(_('Invalid discovery response'));
|
||||
return reply;
|
||||
});
|
||||
},
|
||||
// Reads and writes share the queue so a refresh cannot discard an in-flight edit.
|
||||
queue: function(task) {
|
||||
const result = (this.pending || Promise.resolve()).then(task);
|
||||
this.pending = result.catch(() => {});
|
||||
return result;
|
||||
},
|
||||
load: function() {
|
||||
return this.queue(() => this.recoverPending().then(() => {
|
||||
const config = reloadConfig().then(() => ({
|
||||
groups: uci.sections(CONFIG, 'group'), devices: uci.sections(CONFIG, 'device')
|
||||
}));
|
||||
const sources = Promise.all([
|
||||
readSource('hints', () => callHints().then(requireObject)),
|
||||
readSource('leases', () => callLeases().then(requireObject)),
|
||||
readSource('neighbors', () => callNeighbors().then(reply => {
|
||||
if (requireObject(reply).ok !== true || !Array.isArray(reply.neighbors)) throw new Error(reply.error || _('Invalid discovery response'));
|
||||
return reply;
|
||||
})),
|
||||
readSource('wifi', wifiStations),
|
||||
readSource('arp', () => fs.read('/proc/net/arp').then(model.parseArp))
|
||||
]);
|
||||
return Promise.all([ config, sources ]).then(results => {
|
||||
const data = Object.assign({ errors: [], discoveryComplete: true }, results[0]);
|
||||
for (const result of results[1]) {
|
||||
if (!result.ok) {
|
||||
data.errors.push(result.name + ': ' + result.error);
|
||||
data.discoveryComplete = false;
|
||||
continue;
|
||||
}
|
||||
if (result.name === 'wifi') {
|
||||
data.wifi = result.value.stations;
|
||||
data.discoveryComplete = data.discoveryComplete && result.value.complete;
|
||||
data.errors.push(...result.value.errors);
|
||||
} else data[result.name] = result.value;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}));
|
||||
},
|
||||
recoverPending: function() {
|
||||
if (!this.rollbackPending) return Promise.resolve();
|
||||
return callRevert(CONFIG).then(() => { this.rollbackPending = false; });
|
||||
},
|
||||
mutate: function(change) {
|
||||
return this.queue(() => {
|
||||
if (L.hasViewPermission() !== true) throw new Error(_('Read-only access'));
|
||||
let staged = false;
|
||||
return this.recoverPending().then(reloadConfig).then(change).then(() => {
|
||||
staged = true;
|
||||
return uci.save();
|
||||
}).then(() => callCommit(CONFIG)).catch(error => {
|
||||
if (!staged) throw error;
|
||||
this.rollbackPending = true;
|
||||
return this.recoverPending().then(() => { throw error; }, rollbackError => {
|
||||
throw new Error(_('%s; failed to discard pending changes: %s').format(error.message, rollbackError.message));
|
||||
});
|
||||
}).finally(() => uci.unload(CONFIG));
|
||||
});
|
||||
},
|
||||
saveDevice: function(mac, name, remark, group, type) {
|
||||
return this.mutate(() => {
|
||||
const normalized = model.normalizeMac(mac);
|
||||
if (!normalized) throw new Error(_('Enter a valid MAC address'));
|
||||
const deviceName = validateText(name, 64), deviceRemark = validateText(remark, 256);
|
||||
if (group && group !== 'ungrouped') requireGroup(group);
|
||||
const matches = uci.sections(CONFIG, 'device').filter(section => model.sectionMac(section) === normalized);
|
||||
const sid = matches.length ? matches[matches.length - 1]['.name'] : model.getSectionId(normalized);
|
||||
if (!matches.length) {
|
||||
if (uci.get(CONFIG, sid)) throw new Error(_('A conflicting configuration record exists'));
|
||||
uci.add(CONFIG, 'device', sid);
|
||||
}
|
||||
for (const section of matches) if (section['.name'] !== sid) uci.remove(CONFIG, section['.name']);
|
||||
uci.set(CONFIG, sid, 'mac', normalized);
|
||||
for (const [ key, value ] of Object.entries({ name: deviceName, remark: deviceRemark,
|
||||
group: group && group !== 'ungrouped' ? group : '',
|
||||
type: type && type !== 'auto' ? type : '' })) {
|
||||
if (value) uci.set(CONFIG, sid, key, value);
|
||||
else uci.unset(CONFIG, sid, key);
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteDevice: function(device) {
|
||||
return this.mutate(() => {
|
||||
for (const section of uci.sections(CONFIG, 'device')) {
|
||||
if (model.sectionMac(section) === device.mac) uci.remove(CONFIG, section['.name']);
|
||||
}
|
||||
});
|
||||
},
|
||||
addGroup: function(name) {
|
||||
return this.mutate(() => {
|
||||
const text = validateGroupName(name);
|
||||
let id;
|
||||
do { id = 'grp_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); } while (uci.get(CONFIG, id));
|
||||
uci.add(CONFIG, 'group', id);
|
||||
uci.set(CONFIG, id, 'name', text);
|
||||
});
|
||||
},
|
||||
renameGroup: function(id, name) {
|
||||
return this.mutate(() => {
|
||||
requireGroup(id);
|
||||
uci.set(CONFIG, id, 'name', validateGroupName(name, id));
|
||||
});
|
||||
},
|
||||
deleteGroup: function(id) {
|
||||
return this.mutate(() => {
|
||||
requireGroup(id);
|
||||
uci.remove(CONFIG, id);
|
||||
for (const section of uci.sections(CONFIG, 'device')) {
|
||||
if (section.group === id) uci.unset(CONFIG, section['.name'], 'group');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/* SPDX-License-Identifier: MIT */
|
||||
.dm-status-tabs { display:flex; flex-wrap:wrap; margin:0 0 16px 0; padding:0; border-bottom:2px solid #e0e0e0; list-style:none; gap:6px; }
|
||||
.dm-status-tab { border:0; font-family:inherit; cursor:pointer; padding:8px 16px; border-radius:6px 6px 0 0; font-weight:500; font-size:14px; color:inherit; background:rgba(0,0,0,0.03); transition:all 0.15s ease; border-bottom:3px solid transparent; }
|
||||
.dm-status-tab:hover { background:rgba(0,0,0,0.06); color:inherit; }
|
||||
.dm-status-tab.active { background:transparent; color:#1a73e8; font-weight:bold; border-bottom:3px solid #1a73e8; }
|
||||
.dm-tab-count { font-size:12px; margin-left:4px; padding:2px 7px; border-radius:10px; background:rgba(0,0,0,0.06); color:inherit; font-weight:normal; }
|
||||
.dm-status-tab.active .dm-tab-count { background:#e8f0fe; color:#1a73e8; font-weight:bold; }
|
||||
.dm-toolbar { display:flex; flex-wrap:wrap; gap:10px; justify-content:space-between; align-items:center; margin-bottom:15px; }
|
||||
.dm-controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; flex:1; min-width:260px; }
|
||||
.dm-btn-group { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
|
||||
.dm-table-wrapper { overflow-x:auto; width:100%; -webkit-overflow-scrolling:touch; }
|
||||
.dm-device-title-row { display:inline-flex; align-items:center; max-width:100%; vertical-align:middle; }
|
||||
.dm-device-title { font-weight:bold; font-size:14px; display:inline-block; }
|
||||
.dm-device-subtitle { font-size:12px; color:#777; margin-top:2px; }
|
||||
.dm-status-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:5px; flex-shrink:0; vertical-align:middle; transition:transform 0.15s ease, box-shadow 0.15s ease; cursor:help; }
|
||||
.dm-status-dot:hover { transform:scale(1.3); }
|
||||
.dm-status-dot-online { background-color:#34a853; box-shadow:0 0 0 1.5px rgba(52,168,83,0.25); }
|
||||
.dm-status-dot-offline { background-color:#ea4335; box-shadow:0 0 0 1.5px rgba(234,67,53,0.2); }
|
||||
.dm-status-dot-unknown { background-color:#fbbc04; box-shadow:0 0 0 1.5px rgba(251,188,4,0.25); }
|
||||
[data-theme="dark"] .dm-status-dot-online,
|
||||
body.dark-mode .dm-status-dot-online { background-color:#81c995; box-shadow:0 0 0 1.5px rgba(129,201,149,0.3); }
|
||||
[data-theme="dark"] .dm-status-dot-offline,
|
||||
body.dark-mode .dm-status-dot-offline { background-color:#f28b82; box-shadow:0 0 0 1.5px rgba(242,139,130,0.25); }
|
||||
[data-theme="dark"] .dm-status-dot-unknown,
|
||||
body.dark-mode .dm-status-dot-unknown { background-color:#fdd663; box-shadow:0 0 0 1.5px rgba(253,214,99,0.3); }
|
||||
.dm-group-badge { display:inline-block; font-size:12px; padding:3px 8px; border-radius:4px; background:#f1f3f4; color:#3c4043; border:1px solid #dadce0; font-weight:500; }
|
||||
.dm-group-ungrouped { color:#888; font-style:italic; }
|
||||
.dm-mac-code { font-family:monospace; font-size:13px; font-weight:600; color:#1a73e8; }
|
||||
.dm-actions { white-space:nowrap; }
|
||||
.dm-table .dm-actions-column { width:64px; min-width:64px; text-align:center; }
|
||||
.dm-table tbody .td { vertical-align:middle; }
|
||||
.dm-action-links { display:flex; flex-direction:column; align-items:center; gap:4px; }
|
||||
.dm-table .dm-action-link { display:inline-block; padding:0 2px; border:0; background:none; box-shadow:none; color:var(--dm-sort-color); font-size:12px; line-height:20px; text-decoration:none; cursor:pointer; }
|
||||
.dm-table .dm-action-link:hover { text-decoration:underline; }
|
||||
.dm-table .dm-action-link:focus-visible { outline:2px solid currentColor; outline-offset:2px; border-radius:2px; }
|
||||
|
||||
.dm-status-tab:focus-visible { outline:2px solid #1a73e8; outline-offset:2px; }
|
||||
.dm-source-warning { overflow-wrap:anywhere; }
|
||||
|
||||
.dm-type-th { width: 56px; text-align: center; }
|
||||
.dm-type-cell { text-align: center; vertical-align: middle; padding: 6px 8px; }
|
||||
.dm-device-icon-wrap { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: rgba(0, 0, 0, 0.04); vertical-align: middle; transition: all 0.15s ease; }
|
||||
.dm-device-icon-wrap:hover { background: rgba(26, 115, 232, 0.12); transform: scale(1.06); }
|
||||
.dm-device-icon { width: 20px; height: 20px; display: block; }
|
||||
[data-theme="dark"] .dm-device-icon-wrap,
|
||||
body.dark-mode .dm-device-icon-wrap { background: rgba(255, 255, 255, 0.08); }
|
||||
|
||||
.dm-table { min-width:900px; --dm-sort-color:#0069d6; --dm-header-hover:rgba(0,105,214,0.06); }
|
||||
.dm-table thead .tr.table-titles > .th {
|
||||
height:44px;
|
||||
/* Override theme padding so all header contents share the same height. */
|
||||
padding:0;
|
||||
background:rgba(148,163,184,0.08);
|
||||
color:var(--text-color-high, #64748b);
|
||||
font-size:12px;
|
||||
font-weight:500;
|
||||
line-height:20px;
|
||||
text-align:left;
|
||||
white-space:nowrap;
|
||||
vertical-align:middle;
|
||||
}
|
||||
.dm-table .dm-index-column {
|
||||
width:40px;
|
||||
min-width:40px;
|
||||
max-width:40px;
|
||||
box-sizing:border-box;
|
||||
padding:8px 6px;
|
||||
text-align:center;
|
||||
font-size:12px;
|
||||
font-variant-numeric:tabular-nums;
|
||||
color:var(--text-color-medium, #94a3b8);
|
||||
}
|
||||
.dm-table .dm-header-content {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:flex-start;
|
||||
gap:6px;
|
||||
width:100%;
|
||||
height:44px;
|
||||
box-sizing:border-box;
|
||||
margin:0;
|
||||
padding:0 12px;
|
||||
color:inherit;
|
||||
font:inherit;
|
||||
line-height:20px;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.dm-table .dm-sort-button {
|
||||
border:0;
|
||||
border-radius:0;
|
||||
background:transparent;
|
||||
box-shadow:none;
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
transition:background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.dm-table .dm-type-th .dm-header-content,
|
||||
.dm-table .dm-index-column .dm-header-content,
|
||||
.dm-table .dm-actions-column .dm-header-content { justify-content:center; padding:0 6px; }
|
||||
.dm-table .dm-sort-button:hover { background:var(--dm-header-hover); color:var(--dm-sort-color); }
|
||||
.dm-sort-button:focus-visible { outline:2px solid var(--dm-sort-color); outline-offset:-2px; }
|
||||
.dm-sort-icon { position:relative; display:block; flex:0 0 8px; width:8px; height:14px; color:inherit; }
|
||||
.dm-sort-icon::before,
|
||||
.dm-sort-icon::after {
|
||||
content:'';
|
||||
position:absolute;
|
||||
left:1px;
|
||||
width:4px;
|
||||
height:4px;
|
||||
transform:rotate(45deg);
|
||||
opacity:0.25;
|
||||
transition:opacity 0.15s ease;
|
||||
}
|
||||
.dm-sort-icon::before { top:1px; border-top:1.5px solid currentColor; border-left:1.5px solid currentColor; }
|
||||
.dm-sort-icon::after { bottom:1px; border-bottom:1.5px solid currentColor; border-right:1.5px solid currentColor; }
|
||||
.dm-sort-button:hover .dm-sort-icon::before,
|
||||
.dm-sort-button:hover .dm-sort-icon::after { opacity:0.5; }
|
||||
.dm-sortable.sorted-asc .dm-sort-button,
|
||||
.dm-sortable.sorted-desc .dm-sort-button { color:var(--dm-sort-color); background:var(--dm-header-hover); }
|
||||
.dm-sortable.sorted-asc .dm-sort-icon::before,
|
||||
.dm-sortable.sorted-desc .dm-sort-icon::after { opacity:1; }
|
||||
[data-theme="dark"] .dm-table,
|
||||
[data-darkmode="true"] .dm-table,
|
||||
body.dark-mode .dm-table { --dm-sort-color:#8ab4f8; --dm-header-hover:rgba(138,180,248,0.08); }
|
||||
|
||||
.dm-btn-group .cbi-button.dm-btn-blue {
|
||||
/* Reset the theme's background image as well as its background color. */
|
||||
background: #0069d6 !important;
|
||||
border-color: #0069d6 !important;
|
||||
color: #ffffff !important;
|
||||
text-shadow: none;
|
||||
}
|
||||
.dm-btn-group .cbi-button.dm-btn-blue:hover,
|
||||
.dm-btn-group .cbi-button.dm-btn-blue:focus-visible {
|
||||
background: #0049d6 !important;
|
||||
border-color: #0049d6 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.modal.dm-device-modal { position:relative; width:680px; min-width:0; max-width:calc(100vw - 32px); max-height:calc(100vh - 48px); margin:24px auto; overflow-y:auto; padding:24px; border-radius:12px; box-sizing:border-box; }
|
||||
.dm-device-modal > h4 { margin-bottom:18px; padding-right:32px; font-size:17px; }
|
||||
.dm-dialog .dm-dialog-close { position:absolute; top:18px; right:18px; display:flex; align-items:center; justify-content:center; width:28px; height:28px; padding:0; border:0; border-radius:6px; background:none; box-shadow:none; color:var(--dm-dialog-muted); font-size:22px; line-height:1; cursor:pointer; }
|
||||
.dm-dialog .dm-dialog-close:hover { background:var(--dm-dialog-panel); color:inherit; }
|
||||
.dm-dialog .dm-dialog-close:focus-visible { outline:2px solid #0069d6; outline-offset:2px; }
|
||||
.dm-dialog { --dm-dialog-border:var(--border-color-low, #e5e7eb); --dm-dialog-muted:var(--text-color-medium, #7b8492); --dm-dialog-panel:rgba(148,163,184,0.07); --dm-dialog-warning:#9a6700; }
|
||||
.dm-dialog-hero { display:flex; align-items:center; gap:14px; padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; background:var(--dm-dialog-panel); }
|
||||
.dm-dialog-icon { display:flex; align-items:center; justify-content:center; flex:0 0 48px; width:48px; height:48px; background:rgba(0,105,214,0.08); border-radius:12px; }
|
||||
.dm-dialog-icon img { width:30px; height:30px; }
|
||||
.dm-dialog-heading { display:flex; flex-direction:column; gap:5px; flex:1; min-width:0; }
|
||||
.dm-dialog-heading strong { font-size:17px; line-height:1.4; overflow-wrap:anywhere; }
|
||||
.dm-dialog-muted { color:var(--dm-dialog-muted); font-size:12px; line-height:1.7; }
|
||||
.dm-dialog-status { flex-shrink:0; padding:4px 10px; border-radius:20px; font-size:12px; line-height:1.5; }
|
||||
.dm-dialog-status-online { color:var(--success-color-medium, #137333); background:rgba(52,168,83,0.12); }
|
||||
.dm-dialog-status-offline { color:var(--error-color-medium, #c5221f); background:rgba(234,67,53,0.1); }
|
||||
.dm-dialog-status-unknown { color:var(--dm-dialog-warning); background:rgba(251,188,4,0.12); }
|
||||
.dm-info-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:16px 24px; padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; }
|
||||
.dm-info-item { min-width:0; }
|
||||
.dm-info-wide { grid-column:1 / -1; }
|
||||
.dm-info-label { display:block; margin-bottom:5px; color:var(--dm-dialog-muted); font-size:12px; }
|
||||
.dm-info-value { font-size:13px; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.dm-edit-fields { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:16px; }
|
||||
.dm-edit-fields .cbi-value { display:block; min-width:0; padding:0; margin:0; border:0; }
|
||||
.dm-edit-fields .cbi-value:nth-last-child(-n+2), .dm-edit-fields .dm-form-wide { grid-column:1 / -1; }
|
||||
.dm-dialog .dm-edit-fields .cbi-value-title { display:block; float:none; width:auto; margin-bottom:7px; padding:0; text-align:left; font-size:13px; line-height:1.5; }
|
||||
.dm-edit-fields .cbi-value-field { margin:0; padding:0; width:100%; }
|
||||
.dm-edit-fields .cbi-value-description { margin:5px 0 0; color:var(--dm-dialog-muted); font-size:12px; line-height:1.6; }
|
||||
.dm-edit-fields .cbi-value-description::before { display:none; }
|
||||
.dm-dialog-footer { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; padding-top:16px; margin-top:20px; border-top:1px solid var(--dm-dialog-border); }
|
||||
.dm-dialog-buttons { display:flex; justify-content:flex-end; align-items:center; gap:8px; }
|
||||
.dm-dialog-footer .dm-dialog-buttons { margin-left:auto; }
|
||||
.dm-dialog-buttons .dm-primary-button { background:#0069d6; border-color:#0069d6; color:#fff; text-shadow:none; }
|
||||
.dm-dialog-buttons .dm-primary-button:hover { background:#0049d6; }
|
||||
.dm-clear-description { margin:18px 0 10px; font-size:14px; line-height:1.7; overflow-wrap:anywhere; }
|
||||
.dm-ping-card { padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; background:var(--dm-dialog-panel); }
|
||||
.dm-ping-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; }
|
||||
.dm-ping-status { font-size:14px; line-height:1.5; }
|
||||
.dm-ping-success { color:var(--success-color-medium, #137333); }
|
||||
.dm-ping-warning { color:var(--dm-dialog-warning); }
|
||||
.dm-ping-card p { margin:0; white-space:pre-line; }
|
||||
.dm-ping-log { margin-top:10px; }
|
||||
.dm-ping-log summary { color:var(--dm-dialog-muted); font-size:12px; cursor:pointer; }
|
||||
.dm-ping-output { max-height:140px; overflow:auto; margin:12px 0 0; padding:12px; border:0; border-radius:6px; background:rgba(148,163,184,0.1); color:inherit; font-size:12px; line-height:1.6; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
[data-theme="dark"] .dm-dialog,
|
||||
[data-darkmode="true"] .dm-dialog,
|
||||
body.dark-mode .dm-dialog { --dm-dialog-warning:#f4ca64; }
|
||||
@media (max-width:640px) {
|
||||
.modal.dm-device-modal { padding:18px; }
|
||||
.dm-info-grid, .dm-edit-fields { grid-template-columns:minmax(0, 1fr); }
|
||||
.dm-dialog-hero { gap:10px; padding:12px; }
|
||||
.dm-dialog-heading strong { font-size:15px; }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
'use strict';
|
||||
/* global preferences */
|
||||
'require baseclass';
|
||||
'require device-manager.model as model';
|
||||
'require dom';
|
||||
'require device-manager.preferences as preferences';
|
||||
|
||||
return baseclass.extend({
|
||||
renderTabs: function() {
|
||||
if (!this.tabMenuNode) return;
|
||||
dom.content(this.tabMenuNode, null);
|
||||
|
||||
const self = this;
|
||||
|
||||
const matching = this.devices.filter(device => model.matchesDevice(device, this.groups, this.activeGroup, this.filterText));
|
||||
const total = matching.length;
|
||||
const online = matching.filter(device => device.status === 'online').length;
|
||||
const offline = matching.filter(device => device.status === 'offline').length;
|
||||
const unknown = total - online - offline;
|
||||
|
||||
const tabs = [
|
||||
{ key: 'online', label: _('Online devices'), count: online },
|
||||
{ key: 'all', label: _('All devices'), count: total },
|
||||
{ key: 'offline', label: _('Offline devices'), count: offline },
|
||||
{ key: 'unknown', label: _('Unknown'), count: unknown }
|
||||
];
|
||||
|
||||
tabs.forEach(function(t) {
|
||||
const isActive = (self.activeTab === t.key);
|
||||
const tabItem = E('button', {
|
||||
'type': 'button',
|
||||
'role': 'tab',
|
||||
'data-tab': t.key,
|
||||
'aria-selected': String(isActive),
|
||||
'class': 'dm-status-tab' + (isActive ? ' active' : ''),
|
||||
'click': function() {
|
||||
self.activeTab = t.key;
|
||||
preferences.save(self.activeTab, self.activeGroup, self.maskInfo);
|
||||
self.updateView();
|
||||
const current = Array.from(self.tabMenuNode.children).find(node => node.getAttribute('data-tab') === t.key);
|
||||
if (current) current.focus();
|
||||
}
|
||||
}, [
|
||||
t.label,
|
||||
E('span', { 'class': 'dm-tab-count' }, [ t.count ])
|
||||
]);
|
||||
self.tabMenuNode.appendChild(tabItem);
|
||||
});
|
||||
},
|
||||
|
||||
renderGroupSelect: function() {
|
||||
if (!this.groupSelectNode) return;
|
||||
dom.content(this.groupSelectNode, null);
|
||||
|
||||
const self = this;
|
||||
const options = [
|
||||
E('option', { 'value': 'all' }, [ _('All groups') ])
|
||||
];
|
||||
|
||||
// Count devices per group
|
||||
const groupCounts = new Map();
|
||||
let ungroupedCount = 0;
|
||||
|
||||
for (let i = 0; i < this.devices.length; i++) {
|
||||
const g = this.devices[i].group;
|
||||
if (!g || g === 'ungrouped') {
|
||||
ungroupedCount++;
|
||||
} else {
|
||||
groupCounts.set(g, (groupCounts.get(g) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.groups.length; i++) {
|
||||
const g = this.groups[i];
|
||||
const count = groupCounts.get(g.id) || 0;
|
||||
const opt = E('option', { 'value': g.id }, [ '%s (%d)'.format(g.name, count) ]);
|
||||
if (self.activeGroup === g.id) opt.selected = true;
|
||||
options.push(opt);
|
||||
}
|
||||
|
||||
const ungroupedOpt = E('option', { 'value': 'ungrouped' }, [ '%s (%d)'.format(_('Ungrouped'), ungroupedCount) ]);
|
||||
if (self.activeGroup === 'ungrouped') ungroupedOpt.selected = true;
|
||||
options.push(ungroupedOpt);
|
||||
|
||||
dom.content(this.groupSelectNode, options);
|
||||
},
|
||||
|
||||
handleSort: function(key) {
|
||||
if (this.sortKey === key) {
|
||||
this.sortDir = (this.sortDir === 'asc') ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortKey = key;
|
||||
this.sortDir = 'asc';
|
||||
}
|
||||
this.updateSortHeaders();
|
||||
this.renderTable();
|
||||
},
|
||||
|
||||
updateSortHeaders: function() {
|
||||
if (!this.tableHeadNode) return;
|
||||
const ths = this.tableHeadNode.querySelectorAll('th');
|
||||
for (let i = 0; i < ths.length; i++) {
|
||||
const th = ths[i];
|
||||
const col = th.getAttribute('data-sort');
|
||||
if (!col) continue;
|
||||
const isCurrent = (col === this.sortKey);
|
||||
th.classList.remove('sorted-asc');
|
||||
th.classList.remove('sorted-desc');
|
||||
if (isCurrent) {
|
||||
th.classList.add(this.sortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
||||
if (typeof th.setAttribute === 'function') {
|
||||
th.setAttribute('aria-sort', this.sortDir === 'asc' ? 'ascending' : 'descending');
|
||||
} else if (th.attrs) {
|
||||
th.attrs['aria-sort'] = this.sortDir === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
} else {
|
||||
if (typeof th.setAttribute === 'function') {
|
||||
th.setAttribute('aria-sort', 'none');
|
||||
} else if (th.attrs) {
|
||||
th.attrs['aria-sort'] = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderTable: function() {
|
||||
if (!this.tableBodyNode) return;
|
||||
dom.content(this.tableBodyNode, null);
|
||||
|
||||
const filtered = this.devices.filter(device =>
|
||||
(this.activeTab === 'all' || device.status === this.activeTab) &&
|
||||
model.matchesDevice(device, this.groups, this.activeGroup, this.filterText));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
let emptyMsg = _('No devices found. Refresh the list to try again.');
|
||||
if (this.filterText)
|
||||
emptyMsg = _('No devices match "%s"').format(this.filterText);
|
||||
else if (this.activeTab === 'online')
|
||||
emptyMsg = _('No online devices');
|
||||
else if (this.activeTab === 'offline')
|
||||
emptyMsg = _('No offline devices');
|
||||
else if (this.activeTab === 'unknown')
|
||||
emptyMsg = _('No devices with unknown status');
|
||||
else if (this.activeGroup !== 'all')
|
||||
emptyMsg = _('No devices in this group');
|
||||
|
||||
this.tableBodyNode.appendChild(E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td', 'colspan': 8, 'style': 'text-align:center; padding:36px 16px; color:#888;' }, [ emptyMsg ])
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = model.sortDevices(filtered, this.sortKey, this.sortDir, id => this.getGroupName(id));
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
this.tableBodyNode.appendChild(this.renderDeviceRow(sorted[i], i + 1));
|
||||
}
|
||||
},
|
||||
|
||||
renderDeviceRow: function(dev, rowNumber) {
|
||||
const self = this;
|
||||
|
||||
// 0. Device type icon column
|
||||
const typeInfo = model.getTypeInfo(dev.type);
|
||||
const typeLabel = model.getTypeLabel(dev.type);
|
||||
const iconNode = E('span', {
|
||||
'class': 'dm-device-icon-wrap',
|
||||
'title': typeLabel
|
||||
}, [
|
||||
E('img', {
|
||||
'class': 'dm-device-icon',
|
||||
'src': L.resource('device-manager/device-icons/' + typeInfo.icon),
|
||||
'alt': typeLabel
|
||||
})
|
||||
]);
|
||||
|
||||
// 1. Device name column
|
||||
const displayName = dev.customName || dev.hostname || _('Unknown device');
|
||||
|
||||
// Status dot with tooltip explanation (no text displayed)
|
||||
let dotClass = 'dm-status-dot-unknown';
|
||||
let statusLabel = _('Unknown');
|
||||
|
||||
if (dev.status === 'online') {
|
||||
dotClass = 'dm-status-dot-online';
|
||||
statusLabel = _('Online');
|
||||
} else if (dev.status === 'offline') {
|
||||
dotClass = 'dm-status-dot-offline';
|
||||
statusLabel = _('Offline');
|
||||
}
|
||||
|
||||
const statusTooltip = dev.statusDetail ? (statusLabel + ': ' + dev.statusDetail) : statusLabel;
|
||||
|
||||
const nameChildren = [
|
||||
E('div', { 'class': 'dm-device-title-row' }, [
|
||||
E('span', {
|
||||
'class': 'dm-status-dot ' + dotClass,
|
||||
'title': statusTooltip
|
||||
}),
|
||||
E('span', { 'class': 'dm-device-title' }, [ displayName ])
|
||||
])
|
||||
];
|
||||
|
||||
if (dev.customName && dev.hostname && dev.customName !== dev.hostname) {
|
||||
nameChildren.push(E('div', { 'class': 'dm-device-subtitle' }, [ dev.hostname ]));
|
||||
}
|
||||
|
||||
// 2. IP address column
|
||||
const ipChildren = [];
|
||||
if (dev.ipv4) {
|
||||
ipChildren.push(E('div', {}, [ dev.ipv4 ]));
|
||||
} else {
|
||||
ipChildren.push(E('div', { 'style': 'color:#999;' }, [ '—' ]));
|
||||
}
|
||||
if (dev.ipv6) {
|
||||
const displayIpv6 = (this.maskInfo && dev.ipv6) ? dev.ipv6.replace(/[0-9a-fA-F]/g, '*') : dev.ipv6;
|
||||
ipChildren.push(E('div', {
|
||||
'class': 'dm-device-subtitle',
|
||||
'title': displayIpv6,
|
||||
'style': 'max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;'
|
||||
}, [ displayIpv6 ]));
|
||||
}
|
||||
|
||||
// 3. MAC address column
|
||||
const displayMac = (this.maskInfo && dev.mac) ? dev.mac.replace(/[0-9a-fA-F]/g, '*') : dev.mac;
|
||||
const macNode = E('span', { 'class': 'dm-mac-code' }, [ displayMac ]);
|
||||
|
||||
// 4. Group column
|
||||
const groupName = this.getGroupName(dev.group);
|
||||
const groupNode = (dev.group && dev.group !== 'ungrouped')
|
||||
? E('span', { 'class': 'dm-group-badge' }, [ groupName ])
|
||||
: null;
|
||||
|
||||
// 5. Remark column
|
||||
const remarkNode = dev.remark
|
||||
? E('span', {}, [ dev.remark ])
|
||||
: null;
|
||||
|
||||
// 6. Action column
|
||||
const actionLink = (label, className, action) => E('a', {
|
||||
'href': '#',
|
||||
'role': 'button',
|
||||
'class': 'dm-action-link ' + className,
|
||||
'click': function(ev) {
|
||||
ev.preventDefault();
|
||||
return action();
|
||||
},
|
||||
'keydown': function(ev) {
|
||||
if (ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
return action();
|
||||
}
|
||||
}
|
||||
}, [ label ]);
|
||||
const actions = [ actionLink(_('View'), 'dm-action-detail', () => self.showDetailModal(dev)) ];
|
||||
if (!self.readonly) actions.push(actionLink(_('Edit'), 'dm-action-edit', () => self.showEditModal(dev)));
|
||||
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td dm-index-column' }, [ rowNumber == null ? '' : String(rowNumber) ]),
|
||||
E('td', { 'class': 'td dm-type-cell', 'style': 'text-align:center; vertical-align:middle;' }, [ iconNode ]),
|
||||
E('td', { 'class': 'td' }, nameChildren),
|
||||
E('td', { 'class': 'td' }, ipChildren),
|
||||
E('td', { 'class': 'td' }, macNode),
|
||||
E('td', { 'class': 'td' }, groupNode),
|
||||
E('td', { 'class': 'td' }, remarkNode),
|
||||
E('td', { 'class': 'td dm-actions dm-actions-column' }, [
|
||||
E('div', { 'class': 'dm-action-links' }, actions)
|
||||
])
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
'use strict';
|
||||
/* global deviceDialog, groupDialog, preferences, service, table */
|
||||
'require view';
|
||||
'require ui';
|
||||
'require device-manager.model as model';
|
||||
'require device-manager.service as service';
|
||||
'require device-manager.preferences as preferences';
|
||||
'require device-manager.table as table';
|
||||
'require device-manager.device-dialog as deviceDialog';
|
||||
'require device-manager.group-dialog as groupDialog';
|
||||
|
||||
return view.extend({
|
||||
devices: [],
|
||||
groups: [],
|
||||
activeTab: 'online',
|
||||
activeGroup: 'all',
|
||||
maskInfo: false,
|
||||
filterText: '',
|
||||
readonly: true,
|
||||
sortKey: null,
|
||||
sortDir: 'asc',
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
load: function() {
|
||||
return Promise.all([ service.load(), preferences.load() ])
|
||||
.then(data => ({ snapshot: data[0], preferences: data[1] }));
|
||||
},
|
||||
|
||||
acceptData: function(data) {
|
||||
this.groups = model.parseGroups(data.groups);
|
||||
this.devices = model.parseDevices(data, this.groups);
|
||||
this.sourceErrors = data.errors || [];
|
||||
if (this.activeGroup !== 'all' && this.activeGroup !== 'ungrouped' && !this.getGroupById(this.activeGroup)) {
|
||||
this.activeGroup = 'all';
|
||||
preferences.save(this.activeTab, this.activeGroup, this.maskInfo);
|
||||
}
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
const self = this;
|
||||
|
||||
this.readonly = L.hasViewPermission() !== true;
|
||||
this.acceptData(data.snapshot);
|
||||
const savedView = data.preferences || {};
|
||||
this.activeTab = ['all', 'online', 'offline', 'unknown'].includes(savedView.tab) ? savedView.tab : 'online';
|
||||
this.activeGroup = (savedView.group === 'all' || savedView.group === 'ungrouped' || this.getGroupById(savedView.group)) ? savedView.group : 'all';
|
||||
this.maskInfo = Boolean(savedView.maskInfo);
|
||||
|
||||
const sortHeader = (key, label, width, centered) => E('th', {
|
||||
'class': 'th dm-sortable' + (centered ? ' dm-type-th' : ''),
|
||||
'scope': 'col',
|
||||
'data-sort': key,
|
||||
'style': 'width:' + width + ';'
|
||||
}, [ E('button', {
|
||||
'type': 'button',
|
||||
'class': 'dm-header-content dm-sort-button',
|
||||
'title': _('Click to sort by %s').format(label),
|
||||
'click': function() { self.handleSort(key); }
|
||||
}, [
|
||||
E('span', { 'class': 'dm-sort-label' }, [ label ]),
|
||||
E('span', { 'class': 'dm-sort-icon', 'aria-hidden': 'true' })
|
||||
]) ]);
|
||||
|
||||
const theadNode = E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th dm-index-column', 'scope': 'col', 'title': _('Row number') }, [
|
||||
E('span', { 'class': 'dm-header-content' }, [ _('#') ])
|
||||
]),
|
||||
sortHeader('type', _('Type'), '56px', true),
|
||||
sortHeader('name', _('Device name'), '24%'),
|
||||
sortHeader('ip', _('IP address'), '18%'),
|
||||
sortHeader('mac', _('MAC address'), '18%'),
|
||||
sortHeader('group', _('Group'), '14%'),
|
||||
E('th', { 'class': 'th', 'scope': 'col', 'style': 'width:14%;' }, [
|
||||
E('span', { 'class': 'dm-header-content' }, [ _('Remarks') ])
|
||||
]),
|
||||
E('th', { 'class': 'th dm-actions-column', 'scope': 'col' }, [
|
||||
E('span', { 'class': 'dm-header-content' }, [ _('Actions') ])
|
||||
])
|
||||
]) ]);
|
||||
|
||||
const viewNode = E('div', { 'class': 'cbi-map' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('device-manager/styles.css') }),
|
||||
|
||||
E('h2', {}, [ _('LAN Device Management') ]),
|
||||
E('div', { 'class': 'cbi-map-descr' }, [ _('Discover network devices and save custom names, remarks and groups by MAC address.') ]),
|
||||
|
||||
E('div', { 'id': 'dm-source-warning', 'class': 'alert-message warning dm-source-warning', 'style': 'display:none;' }),
|
||||
|
||||
// Row 1: Status Tabs
|
||||
E('div', { 'class': 'dm-status-tabs', 'id': 'dm-tabs-container', 'role': 'tablist', 'aria-label': _('Device status') }),
|
||||
|
||||
// Row 2: Controls Toolbar
|
||||
E('div', { 'class': 'cbi-section dm-toolbar' }, [
|
||||
E('div', { 'class': 'dm-controls' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'dm-search-input',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': _('Search names, IP, MAC, remarks or groups...'),
|
||||
'style': 'flex:1; min-width:200px; max-width:360px;',
|
||||
'value': self.filterText,
|
||||
'input': function(ev) {
|
||||
self.filterText = ev.target.value.trim().toLowerCase();
|
||||
self.updateView();
|
||||
}
|
||||
}),
|
||||
E('select', {
|
||||
'id': 'dm-group-select',
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'min-width:140px;',
|
||||
'change': function(ev) {
|
||||
self.activeGroup = ev.target.value;
|
||||
preferences.save(self.activeTab, self.activeGroup, self.maskInfo);
|
||||
self.updateView();
|
||||
}
|
||||
})
|
||||
]),
|
||||
E('div', { 'class': 'dm-btn-group' }, [
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'disabled': self.readonly || null,
|
||||
'click': function() { self.showGroupModal(); }
|
||||
}, [ _('Manage groups') ]),
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'cbi-button cbi-button-action dm-btn-blue',
|
||||
'disabled': self.readonly || null,
|
||||
'click': function() { self.showEditModal(null); }
|
||||
}, [ _('Add device') ]),
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'id': 'dm-btn-mask',
|
||||
'title': _('Hide/Show MAC and IPv6'),
|
||||
'click': function() {
|
||||
self.maskInfo = !self.maskInfo;
|
||||
preferences.save(self.activeTab, self.activeGroup, self.maskInfo);
|
||||
self.updateView();
|
||||
}
|
||||
}, [ (self.maskInfo ? _('Show info') : _('Hide info')) ]),
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'id': 'dm-btn-refresh',
|
||||
'click': function(ev) {
|
||||
const button = ev.currentTarget;
|
||||
button.classList.add('spinning');
|
||||
button.disabled = true;
|
||||
return self.refresh().finally(function() {
|
||||
button.classList.remove('spinning');
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
}, [ _('Refresh list') ]),
|
||||
E('button', { 'type': 'button',
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'id': 'dm-btn-scan',
|
||||
'disabled': self.readonly || null,
|
||||
'title': _('Probe devices on the configured LAN interfaces'),
|
||||
'click': function(ev) {
|
||||
const button = ev.currentTarget;
|
||||
button.disabled = true;
|
||||
button.classList.add('spinning');
|
||||
return self.scan().finally(() => {
|
||||
button.disabled = self.readonly;
|
||||
button.classList.remove('spinning');
|
||||
});
|
||||
}
|
||||
}, [ _('Scan LAN') ])
|
||||
])
|
||||
]),
|
||||
|
||||
// Row 3: Device Table
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'class': 'dm-table-wrapper' }, [
|
||||
E('table', { 'class': 'table dm-table', 'id': 'device_manager_table' }, [
|
||||
theadNode,
|
||||
E('tbody', { 'id': 'device_manager_tbody' })
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
this.tableHeadNode = theadNode;
|
||||
this.warningNode = viewNode.querySelector('#dm-source-warning');
|
||||
this.tabMenuNode = viewNode.querySelector('#dm-tabs-container');
|
||||
this.tableBodyNode = viewNode.querySelector('#device_manager_tbody');
|
||||
this.groupSelectNode = viewNode.querySelector('#dm-group-select');
|
||||
this.maskBtnNode = viewNode.querySelector('#dm-btn-mask');
|
||||
|
||||
this.updateView();
|
||||
return viewNode;
|
||||
},
|
||||
|
||||
getGroupById: function(id) { return this.groups.find(group => group.id === id) || null; },
|
||||
getGroupName: function(id) { return model.groupName(this.groups, id); },
|
||||
renderTabs: function() { return table.renderTabs.call(this); },
|
||||
renderGroupSelect: function() { return table.renderGroupSelect.call(this); },
|
||||
renderTable: function() { return table.renderTable.call(this); },
|
||||
renderDeviceRow: function(device, rowNumber) { return table.renderDeviceRow.call(this, device, rowNumber); },
|
||||
handleSort: function(key) { return table.handleSort.call(this, key); },
|
||||
updateSortHeaders: function() { return table.updateSortHeaders.call(this); },
|
||||
showEditModal: function(device) { return deviceDialog.showEditModal.call(this, device); },
|
||||
showDetailModal: function(device) { return deviceDialog.showDetailModal.call(this, device); },
|
||||
handlePingDevice: function(device) { return service.pingDevice(device.mac); },
|
||||
confirmDelete: function(device) { return deviceDialog.confirmDelete.call(this, device); },
|
||||
showGroupModal: function() { return groupDialog.showGroupModal.call(this); },
|
||||
promptRenameGroup: function(group) { return groupDialog.promptRenameGroup.call(this, group); },
|
||||
confirmDeleteGroup: function(group, count) { return groupDialog.confirmDeleteGroup.call(this, group, count); },
|
||||
handleSaveDevice: function(mac, name, remark, group, dev, type) { return service.saveDevice(mac, name, remark, group, type); },
|
||||
handleDeleteDevice: function(device) { return service.deleteDevice(device); },
|
||||
handleAddGroup: function(name) { return service.addGroup(name); },
|
||||
handleRenameGroup: function(id, name) { return service.renameGroup(id, name); },
|
||||
handleDeleteGroup: function(id) { return service.deleteGroup(id); },
|
||||
|
||||
updateView: function() {
|
||||
this.renderTabs();
|
||||
this.renderGroupSelect();
|
||||
this.updateSortHeaders();
|
||||
if (this.maskBtnNode) {
|
||||
this.maskBtnNode.textContent = this.maskInfo ? _('Show info') : _('Hide info');
|
||||
}
|
||||
this.renderTable();
|
||||
if (this.warningNode) {
|
||||
this.warningNode.textContent = this.sourceErrors.length
|
||||
? _('Some device data could not be read. Unconfirmed devices are shown as unknown: %s').format(this.sourceErrors.join('; ')) : '';
|
||||
this.warningNode.style.display = this.sourceErrors.length ? '' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
scan: function() {
|
||||
if (this.scanning) return this.scanning;
|
||||
this.scanning = service.scanDevices().then(() => this.refresh()).catch(error => {
|
||||
ui.addNotification(null, E('p', {}, [ _('Could not scan LAN devices: %s').format(error.message || error) ]), 'danger');
|
||||
}).finally(() => { this.scanning = null; });
|
||||
return this.scanning;
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
if (this.refreshing) return this.refreshing;
|
||||
this.refreshing = service.load().then(data => {
|
||||
this.acceptData(data);
|
||||
this.updateView();
|
||||
}).catch(error => {
|
||||
ui.addNotification(null, E('p', {}, [ _('Could not refresh the device list: %s').format(error.message || error) ]), 'danger');
|
||||
}).finally(() => { this.refreshing = null; });
|
||||
return this.refreshing;
|
||||
}
|
||||
});
|
||||