mirror of
				https://github.com/optim-enterprises-bv/OptimCloud-gw-ui.git
				synced 2025-10-31 02:07:45 +00:00 
			
		
		
		
	
							
								
								
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "ucentral-client", | ||||
|   "version": "2.5.18", | ||||
|   "version": "2.5.24", | ||||
|   "lockfileVersion": 2, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "ucentral-client", | ||||
|       "version": "2.5.18", | ||||
|       "version": "2.5.24", | ||||
|       "dependencies": { | ||||
|         "@coreui/coreui": "^3.4.0", | ||||
|         "@coreui/icons": "^2.0.1", | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "ucentral-client", | ||||
|   "version": "2.5.18", | ||||
|   "version": "2.5.24", | ||||
|   "dependencies": { | ||||
|     "@coreui/coreui": "^3.4.0", | ||||
|     "@coreui/icons": "^2.0.1", | ||||
|   | ||||
| @@ -47,6 +47,7 @@ | ||||
| 		"cancel": "Abbrechen", | ||||
| 		"certificate": "Zertifikat", | ||||
| 		"certificates": "Zertifikate", | ||||
| 		"claim": "Anspruch", | ||||
| 		"clear": "Löschen", | ||||
| 		"close": "Schließen", | ||||
| 		"code": "Code", | ||||
| @@ -117,6 +118,7 @@ | ||||
| 		"hours": "std", | ||||
| 		"id": "ID", | ||||
| 		"invalid_credentials": "Ungültiger Benutzername und / oder Passwort", | ||||
| 		"invalid_date_explanation": "Ungültiges Datum, bitte verwenden Sie den Kalender, auf den Sie über die Schaltfläche rechts zugreifen können", | ||||
| 		"invalid_file": "Die ausgewählte Datei war ungültig, bitte lesen Sie die Anweisungen und passen Sie Ihre Datei entsprechend an", | ||||
| 		"invalid_password": "Dieses Passwort entspricht nicht den grundlegenden Passwortregeln. Bitte besuchen Sie unsere Seite Passwortrichtlinien, um mehr zu erfahren", | ||||
| 		"invalid_pem": "Ihre PEM-Datei ist ungültig. Es sollte mit '-----BEGIN CERTIFICATE-----' ODER '-----BEGIN PRIVATE KEY-----' beginnen und mit '-----END CERTIFICATE--- enden. --' ODER '-----END PRIVATSCHLÜSSEL-----'", | ||||
| @@ -162,6 +164,7 @@ | ||||
| 		"recorded": "Verzeichnet", | ||||
| 		"refresh": "Aktualisierung", | ||||
| 		"refresh_device": "Gerät aktualisieren", | ||||
| 		"remove_claim": "Anspruch entfernen", | ||||
| 		"required": "Erforderlich", | ||||
| 		"result": "Ergebnis", | ||||
| 		"save": "Sparen", | ||||
| @@ -655,11 +658,14 @@ | ||||
| 	}, | ||||
| 	"statistics": { | ||||
| 		"data": "Daten (KB)", | ||||
| 		"data_mb": "Daten (MB)", | ||||
| 		"latest_statistics": "Neueste Statistiken", | ||||
| 		"lifetime_stats": "Lifetime-Statistik", | ||||
| 		"memory": "Erinnerung", | ||||
| 		"no_interfaces": "Keine Statistiken zur Schnittstellenlebensdauer verfügbar", | ||||
| 		"show_latest": "Letzte Statistik", | ||||
| 		"title": "Statistiken" | ||||
| 		"title": "Statistiken", | ||||
| 		"used": "Verwendeter Speicher %" | ||||
| 	}, | ||||
| 	"status": { | ||||
| 		"connection_status": "Status", | ||||
| @@ -671,17 +677,22 @@ | ||||
| 		"percentage_free": "{{percentage}}% von {{total}} kostenlos", | ||||
| 		"percentage_used": "{{percentage}}% von {{total}} verwendet", | ||||
| 		"title": "#{{serialNumber}} Status", | ||||
| 		"total_memory": "Gesamtspeicher", | ||||
| 		"uptime": "Betriebszeit", | ||||
| 		"used_total_memory": "{{used}} verwendet / {{total}} insgesamt" | ||||
| 	}, | ||||
| 	"subscriber": { | ||||
| 		"add_device_subscriber_explanation": "Um andere Geräte zu reklamieren, kannst du unsere Suchleiste verwenden oder direkt aus der Tabelle reklamieren. Wenn ein Gerät bereits von einem Benutzer beansprucht wurde, müssen Sie zu dessen Details gehen und die Zuweisung aufheben, bevor Sie es beanspruchen.", | ||||
| 		"create": "Abonnenten erstellen", | ||||
| 		"devices_one": "{{count}} Gerät", | ||||
| 		"devices_other": "{{count}} Geräte", | ||||
| 		"edit": "Abonnent bearbeiten", | ||||
| 		"error_create": "Fehler beim Erstellen des Abonnenten: {{error}}", | ||||
| 		"error_delete": "Fehler beim Löschen des Abonnenten: {{error}}", | ||||
| 		"error_fetching": "Fehler beim Abrufen von Abonnenten: {{error}}", | ||||
| 		"error_fetching_single": "Fehler beim Abrufen des Abonnenten: {{error}}", | ||||
| 		"error_update": "Fehler beim Aktualisieren des Abonnenten: {{error}}", | ||||
| 		"is_already_claimed": "wird bereits beansprucht von", | ||||
| 		"subscribers": "Abonnenten", | ||||
| 		"success_create": "Abonnent erfolgreich erstellt!", | ||||
| 		"success_delete": "Abonnent erfolgreich gelöscht!", | ||||
|   | ||||
| @@ -47,6 +47,7 @@ | ||||
| 		"cancel": "Cancel", | ||||
| 		"certificate": "Certificate", | ||||
| 		"certificates": "Certificates", | ||||
| 		"claim": "Claim", | ||||
| 		"clear": "Clear", | ||||
| 		"close": "Close", | ||||
| 		"code": "Code", | ||||
| @@ -117,6 +118,7 @@ | ||||
| 		"hours": "hours", | ||||
| 		"id": "Id", | ||||
| 		"invalid_credentials": "Invalid username and/or password", | ||||
| 		"invalid_date_explanation": "Invalid Date, please use the calendar accessible with the button on the right ", | ||||
| 		"invalid_file": "The chosen file was invalid, please read the instructions and adjust your file accordingly", | ||||
| 		"invalid_password": "This password does not confirm to basic password rules. Please visit our Password Policy page to learn more", | ||||
| 		"invalid_pem": "Your .pem file is invalid. It should start with '-----BEGIN CERTIFICATE-----' OR '-----BEGIN PRIVATE KEY-----' and it should end with '-----END CERTIFICATE-----' OR '-----END PRIVATE KEY-----'", | ||||
| @@ -162,6 +164,7 @@ | ||||
| 		"recorded": "Recorded", | ||||
| 		"refresh": "Refresh", | ||||
| 		"refresh_device": "Refresh Device", | ||||
| 		"remove_claim": "Remove Claim", | ||||
| 		"required": "Required", | ||||
| 		"result": "Result", | ||||
| 		"save": "Save", | ||||
| @@ -655,11 +658,14 @@ | ||||
| 	}, | ||||
| 	"statistics": { | ||||
| 		"data": "Data (KB)", | ||||
| 		"data_mb": "Data (MB)", | ||||
| 		"latest_statistics": "Latest Statistics", | ||||
| 		"lifetime_stats": "Lifetime Statistics", | ||||
| 		"memory": "Memory", | ||||
| 		"no_interfaces": "No interface lifetime statistics available", | ||||
| 		"show_latest": "Last Statistics", | ||||
| 		"title": "Statistics" | ||||
| 		"title": "Statistics", | ||||
| 		"used": "Used Memory %" | ||||
| 	}, | ||||
| 	"status": { | ||||
| 		"connection_status": "Status", | ||||
| @@ -671,17 +677,22 @@ | ||||
| 		"percentage_free": "{{percentage}}% of {{total}} free", | ||||
| 		"percentage_used": "{{percentage}}% of {{total}} used", | ||||
| 		"title": "#{{serialNumber}} Status", | ||||
| 		"total_memory": "Total Memory", | ||||
| 		"uptime": "Uptime", | ||||
| 		"used_total_memory": "{{used}} used / {{total}} total " | ||||
| 	}, | ||||
| 	"subscriber": { | ||||
| 		"add_device_subscriber_explanation": "To claim devices, you can use our search bar or claim directly from the table. If a device was already claimed by a user, you will need to go to to their details and unassign it before claiming it.", | ||||
| 		"create": "Create Subscriber", | ||||
| 		"devices_one": "{{count}} Device", | ||||
| 		"devices_other": "{{count}} Devices", | ||||
| 		"edit": "Edit Subscriber", | ||||
| 		"error_create": "Error creating subscriber: {{error}}", | ||||
| 		"error_delete": "Error deleting subscriber: {{error}}", | ||||
| 		"error_fetching": "Error fetching subscribers: {{error}}", | ||||
| 		"error_fetching_single": "Error fetching subscriber: {{error}}", | ||||
| 		"error_update": "Error updating subscriber: {{error}}", | ||||
| 		"is_already_claimed": "is already claimed by ", | ||||
| 		"subscribers": "Subscribers", | ||||
| 		"success_create": "Subscriber successfully created!", | ||||
| 		"success_delete": "Subscriber successfully deleted!", | ||||
|   | ||||
| @@ -47,6 +47,7 @@ | ||||
| 		"cancel": "Cancelar", | ||||
| 		"certificate": "Certificado", | ||||
| 		"certificates": "Certificados", | ||||
| 		"claim": "Reclamación", | ||||
| 		"clear": "Claro", | ||||
| 		"close": "Cerrar", | ||||
| 		"code": "Código", | ||||
| @@ -117,6 +118,7 @@ | ||||
| 		"hours": "horas", | ||||
| 		"id": "Carné de identidad", | ||||
| 		"invalid_credentials": "Nombre de usuario y / o contraseña inválido", | ||||
| 		"invalid_date_explanation": "Fecha no válida, utilice el calendario accesible con el botón de la derecha", | ||||
| 		"invalid_file": "El archivo elegido no es válido, lea las instrucciones y ajuste su archivo en consecuencia", | ||||
| 		"invalid_password": "Esta contraseña no confirma las reglas básicas de contraseña. Visite nuestra página de Política de contraseñas para obtener más información.", | ||||
| 		"invalid_pem": "Su archivo .pem no es válido. Debe comenzar con '----- BEGIN CERTIFICATE -----' O '----- BEGIN PRIVATE KEY -----' y debe terminar con '----- END CERTIFICATE --- - 'O' ----- FIN DE CLAVE PRIVADA ----- '", | ||||
| @@ -162,6 +164,7 @@ | ||||
| 		"recorded": "Grabado", | ||||
| 		"refresh": "Refrescar", | ||||
| 		"refresh_device": "Actualizar dispositivo", | ||||
| 		"remove_claim": "Quitar reclamo", | ||||
| 		"required": "Necesario", | ||||
| 		"result": "Resultado", | ||||
| 		"save": "Salvar", | ||||
| @@ -655,11 +658,14 @@ | ||||
| 	}, | ||||
| 	"statistics": { | ||||
| 		"data": "Datos (KB)", | ||||
| 		"data_mb": "Datos (MB)", | ||||
| 		"latest_statistics": "Últimas estadísticas", | ||||
| 		"lifetime_stats": "Estadísticas de por vida", | ||||
| 		"memory": "Memoria", | ||||
| 		"no_interfaces": "No hay estadísticas de vida útil de la interfaz disponibles", | ||||
| 		"show_latest": "Últimas estadísticas", | ||||
| 		"title": "estadística" | ||||
| 		"title": "estadística", | ||||
| 		"used": "Memoria usada %" | ||||
| 	}, | ||||
| 	"status": { | ||||
| 		"connection_status": "Estado", | ||||
| @@ -671,17 +677,22 @@ | ||||
| 		"percentage_free": "{{percentage}}% de {{total}} gratis", | ||||
| 		"percentage_used": "{{percentage}}% de {{total}} utilizado", | ||||
| 		"title": "#{{serialNumber}} Estado", | ||||
| 		"total_memory": "Memoria total", | ||||
| 		"uptime": "Tiempo de actividad", | ||||
| 		"used_total_memory": "{{used}} usado / {{total}} total" | ||||
| 	}, | ||||
| 	"subscriber": { | ||||
| 		"add_device_subscriber_explanation": "Para reclamar otros dispositivos, puede usar nuestra barra de búsqueda o reclamar directamente desde la tabla. Si un dispositivo ya fue reclamado por un usuario, deberá ir a sus detalles y anular la asignación antes de reclamarlo.", | ||||
| 		"create": "Crear suscriptor", | ||||
| 		"devices_one": "{{count}} dispositivo", | ||||
| 		"devices_other": "{{count}} dispositivos", | ||||
| 		"edit": "Editar suscriptor", | ||||
| 		"error_create": "Error al crear el suscriptor: {{error}}", | ||||
| 		"error_delete": "Error al eliminar el suscriptor: {{error}}", | ||||
| 		"error_fetching": "Error al obtener suscriptores: {{error}}", | ||||
| 		"error_fetching_single": "Error al obtener el suscriptor: {{error}}", | ||||
| 		"error_update": "Error al actualizar el suscriptor: {{error}}", | ||||
| 		"is_already_claimed": "ya es reclamado por", | ||||
| 		"subscribers": "Suscriptores", | ||||
| 		"success_create": "¡Suscriptor creado correctamente!", | ||||
| 		"success_delete": "¡Suscriptor eliminado correctamente!", | ||||
|   | ||||
| @@ -47,6 +47,7 @@ | ||||
| 		"cancel": "annuler", | ||||
| 		"certificate": "Certificat", | ||||
| 		"certificates": "Certificats", | ||||
| 		"claim": "Prétendre", | ||||
| 		"clear": "Clair", | ||||
| 		"close": "Fermer", | ||||
| 		"code": "Code", | ||||
| @@ -117,6 +118,7 @@ | ||||
| 		"hours": "heures", | ||||
| 		"id": "Id", | ||||
| 		"invalid_credentials": "Nom d'utilisateur et / ou mot de passe incorrect", | ||||
| 		"invalid_date_explanation": "Date invalide, merci d'utiliser le calendrier accessible avec le bouton à droite", | ||||
| 		"invalid_file": "Le fichier choisi n'était pas valide, veuillez lire les instructions et ajuster votre fichier en conséquence", | ||||
| 		"invalid_password": "Ce mot de passe ne confirme pas les règles de base des mots de passe. Veuillez visiter notre page Politique de mot de passe pour en savoir plus", | ||||
| 		"invalid_pem": "Votre fichier .pem n'est pas valide. Il doit commencer par '-----BEGIN CERTIFICATE-----' OU '-----BEGIN PRIVATE KEY-----' et il doit se terminer par '-----END CERTIFICATE--- --' OU '-----FIN CLÉ PRIVÉE-----'", | ||||
| @@ -162,6 +164,7 @@ | ||||
| 		"recorded": "Enregistré", | ||||
| 		"refresh": "Rafraîchir", | ||||
| 		"refresh_device": "Actualiser l'appareil", | ||||
| 		"remove_claim": "Supprimer la réclamation", | ||||
| 		"required": "Champs obligatoires", | ||||
| 		"result": "Résultat", | ||||
| 		"save": "Sauvegarder", | ||||
| @@ -655,11 +658,14 @@ | ||||
| 	}, | ||||
| 	"statistics": { | ||||
| 		"data": "Données (Ko)", | ||||
| 		"data_mb": "Données (Mo)", | ||||
| 		"latest_statistics": "Dernières statistiques", | ||||
| 		"lifetime_stats": "Statistiques à vie", | ||||
| 		"memory": "mémoire", | ||||
| 		"no_interfaces": "Aucune statistique de durée de vie de l'interface disponible", | ||||
| 		"show_latest": "Dernières statistiques", | ||||
| 		"title": "statistiques" | ||||
| 		"title": "statistiques", | ||||
| 		"used": "Mémoire utilisée %" | ||||
| 	}, | ||||
| 	"status": { | ||||
| 		"connection_status": "Statut", | ||||
| @@ -671,17 +677,22 @@ | ||||
| 		"percentage_free": "{{percentage}}% de {{total}} gratuit", | ||||
| 		"percentage_used": "{{percentage}}% de {{total}} utilisé", | ||||
| 		"title": "#{{serialNumber}} état", | ||||
| 		"total_memory": "Mémoire totale", | ||||
| 		"uptime": "La disponibilité", | ||||
| 		"used_total_memory": "{{used}} utilisé / {{total}} total" | ||||
| 	}, | ||||
| 	"subscriber": { | ||||
| 		"add_device_subscriber_explanation": "Pour réclamer d'autres appareils, vous pouvez utiliser notre barre de recherche ou réclamer directement à partir du tableau. Si un appareil a déjà été réclamé par un utilisateur, vous devrez accéder à ses détails et le désaffecter avant de le réclamer.", | ||||
| 		"create": "Créer un abonné", | ||||
| 		"devices_one": "{{count}} Appareil", | ||||
| 		"devices_other": "{{count}} appareils", | ||||
| 		"edit": "Modifier l'abonné", | ||||
| 		"error_create": "Erreur lors de la création de l'abonné : {{error}}", | ||||
| 		"error_delete": "Erreur lors de la suppression de l'abonné : {{error}}", | ||||
| 		"error_fetching": "Erreur lors de la récupération des abonnés : {{error}}", | ||||
| 		"error_fetching_single": "Erreur lors de la récupération de l'abonné : {{error}}", | ||||
| 		"error_update": "Erreur lors de la mise à jour de l'abonné : {{error}}", | ||||
| 		"is_already_claimed": "est déjà réclamé par", | ||||
| 		"subscribers": "Les abonnés", | ||||
| 		"success_create": "Abonné créé avec succès !", | ||||
| 		"success_delete": "Abonné supprimé avec succès !", | ||||
|   | ||||
| @@ -47,6 +47,7 @@ | ||||
| 		"cancel": "Cancelar", | ||||
| 		"certificate": "Certificado", | ||||
| 		"certificates": "Certificados", | ||||
| 		"claim": "Afirmação", | ||||
| 		"clear": "Claro", | ||||
| 		"close": "Perto", | ||||
| 		"code": "Código", | ||||
| @@ -117,6 +118,7 @@ | ||||
| 		"hours": "horas", | ||||
| 		"id": "identidade", | ||||
| 		"invalid_credentials": "Nome de usuário e / ou senha inválidos", | ||||
| 		"invalid_date_explanation": "Data inválida, use o calendário acessível com o botão à direita", | ||||
| 		"invalid_file": "O arquivo escolhido era inválido, por favor, leia as instruções e ajuste seu arquivo de acordo", | ||||
| 		"invalid_password": "Esta senha não está de acordo com as regras básicas de senha. Visite nossa página de Política de Senha para saber mais", | ||||
| 		"invalid_pem": "Seu arquivo .pem é inválido. Deve começar com '----- BEGIN CERTIFICATE -----' OU '----- BEGIN PRIVATE KEY -----' e deve terminar com '----- END CERTIFICATE --- - 'OU' ----- END PRIVATE KEY ----- '", | ||||
| @@ -162,6 +164,7 @@ | ||||
| 		"recorded": "Gravado", | ||||
| 		"refresh": "REFRESH", | ||||
| 		"refresh_device": "Atualizar dispositivo", | ||||
| 		"remove_claim": "Remover reivindicação", | ||||
| 		"required": "Requeridos", | ||||
| 		"result": "Resultado", | ||||
| 		"save": "Salve", | ||||
| @@ -655,11 +658,14 @@ | ||||
| 	}, | ||||
| 	"statistics": { | ||||
| 		"data": "Dados (KB)", | ||||
| 		"data_mb": "Dados (MB)", | ||||
| 		"latest_statistics": "Estatísticas mais recentes", | ||||
| 		"lifetime_stats": "Estatísticas de vida", | ||||
| 		"memory": "Memória", | ||||
| 		"no_interfaces": "Nenhuma estatística de tempo de vida da interface disponível", | ||||
| 		"show_latest": "Últimas estatísticas", | ||||
| 		"title": "Estatisticas" | ||||
| 		"title": "Estatisticas", | ||||
| 		"used": "Memoria usada %" | ||||
| 	}, | ||||
| 	"status": { | ||||
| 		"connection_status": "Status", | ||||
| @@ -671,17 +677,22 @@ | ||||
| 		"percentage_free": "{{percentage}}% de {{total}} grátis", | ||||
| 		"percentage_used": "{{percentage}}% de {{total}} usado", | ||||
| 		"title": "#{{serialNumber}} status", | ||||
| 		"total_memory": "Memória total", | ||||
| 		"uptime": "Tempo de atividade", | ||||
| 		"used_total_memory": "{{used}} usado / {{total}} total" | ||||
| 	}, | ||||
| 	"subscriber": { | ||||
| 		"add_device_subscriber_explanation": "Para reivindicar outros dispositivos, você pode usar nossa barra de pesquisa ou reivindicar diretamente na tabela. Se um dispositivo já foi reivindicado por um usuário, você precisará acessar os detalhes dele e cancelar a atribuição antes de reivindicá-lo.", | ||||
| 		"create": "Criar assinante", | ||||
| 		"devices_one": "{{count}} Dispositivo", | ||||
| 		"devices_other": "{{count}} dispositivos", | ||||
| 		"edit": "Editar Assinante", | ||||
| 		"error_create": "Erro ao criar assinante: {{error}}", | ||||
| 		"error_delete": "Erro ao excluir assinante: {{error}}", | ||||
| 		"error_fetching": "Erro ao buscar assinantes: {{error}}", | ||||
| 		"error_fetching_single": "Erro ao buscar assinante: {{error}}", | ||||
| 		"error_update": "Erro ao atualizar assinante: {{error}}", | ||||
| 		"is_already_claimed": "já é reivindicado por", | ||||
| 		"subscribers": "Inscritos", | ||||
| 		"success_create": "Assinante criado com sucesso!", | ||||
| 		"success_delete": "Assinante excluído com sucesso!", | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { | ||||
|   CCard, | ||||
|   CPopover, | ||||
|   CButtonToolbar, | ||||
|   CFormText, | ||||
| } from '@coreui/react'; | ||||
| import CIcon from '@coreui/icons-react'; | ||||
| import DatePicker from 'react-widgets/DatePicker'; | ||||
| @@ -41,7 +42,9 @@ const DeviceCommands = () => { | ||||
|   const [commands, setCommands] = useState([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [start, setStart] = useState(''); | ||||
|   const [startError, setStartError] = useState(false); | ||||
|   const [end, setEnd] = useState(''); | ||||
|   const [endError, setEndError] = useState(false); | ||||
|   const [commandLimit, setCommandLimit] = useState(25); | ||||
|   // Load more button related | ||||
|   const [loadingMore, setLoadingMore] = useState(false); | ||||
| @@ -65,11 +68,25 @@ const DeviceCommands = () => { | ||||
|   }; | ||||
|  | ||||
|   const modifyStart = (value) => { | ||||
|     setStart(value); | ||||
|     try { | ||||
|       new Date(value).toISOString(); | ||||
|       setStartError(false); | ||||
|       setStart(value); | ||||
|     } catch (e) { | ||||
|       setStart(''); | ||||
|       setStartError(true); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const modifyEnd = (value) => { | ||||
|     setEnd(value); | ||||
|     try { | ||||
|       new Date(value).toISOString(); | ||||
|       setEndError(false); | ||||
|       setEnd(value); | ||||
|     } catch (e) { | ||||
|       setEnd(''); | ||||
|       setEndError(true); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const deleteCommandFromList = (commandUuid) => { | ||||
| @@ -256,10 +273,16 @@ const DeviceCommands = () => { | ||||
|               <CCol> | ||||
|                 From: | ||||
|                 <DatePicker includeTime onChange={(date) => modifyStart(date)} /> | ||||
|                 <CFormText color="danger" hidden={!startError}> | ||||
|                   {t('common.invalid_date_explanation')} | ||||
|                 </CFormText> | ||||
|               </CCol> | ||||
|               <CCol> | ||||
|                 To: | ||||
|                 <DatePicker includeTime onChange={(date) => modifyEnd(date)} /> | ||||
|                 <CFormText color="danger" hidden={!endError}> | ||||
|                   {t('common.invalid_date_explanation')} | ||||
|                 </CFormText> | ||||
|               </CCol> | ||||
|             </CRow> | ||||
|             <CCard> | ||||
|   | ||||
| @@ -12,4 +12,4 @@ DeviceStatisticsChart.propTypes = { | ||||
|   chart: PropTypes.instanceOf(Object).isRequired, | ||||
| }; | ||||
|  | ||||
| export default DeviceStatisticsChart; | ||||
| export default React.memo(DeviceStatisticsChart); | ||||
|   | ||||
| @@ -1,37 +1,94 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { CSpinner } from '@coreui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { v4 as createUuid } from 'uuid'; | ||||
| import axiosInstance from 'utils/axiosInstance'; | ||||
| import { useAuth, useDevice } from 'ucentral-libs'; | ||||
| import { unixToTime, capitalizeFirstLetter } from 'utils/helper'; | ||||
| import eventBus from 'utils/eventBus'; | ||||
| import { | ||||
|   capitalizeFirstLetter, | ||||
|   datesSameDay, | ||||
|   dateToUnix, | ||||
|   prettyDate, | ||||
|   unixToTime, | ||||
| } from 'utils/helper'; | ||||
| import DeviceStatisticsChart from './DeviceStatisticsChart'; | ||||
|  | ||||
| const StatisticsChartList = () => { | ||||
| const StatisticsChartList = ({ setOptions, section, setStart, setEnd, time }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const { currentToken, endpoints } = useAuth(); | ||||
|   const { deviceSerialNumber } = useDevice(); | ||||
|   const [statOptions, setStatOptions] = useState({ | ||||
|     interfaceList: [], | ||||
|     memory: [], | ||||
|     settings: {}, | ||||
|   }); | ||||
|  | ||||
|   const transformIntoDataset = (data) => { | ||||
|     const sortedData = data.sort((a, b) => { | ||||
|     let sortedData = data.sort((a, b) => { | ||||
|       if (a.recorded > b.recorded) return 1; | ||||
|       if (b.recorded > a.recorded) return -1; | ||||
|       return 0; | ||||
|     }); | ||||
|  | ||||
|     const dataLength = sortedData.length; | ||||
|     if (dataLength > 1000 && dataLength < 3000) { | ||||
|       sortedData = sortedData.filter((dat, index) => index % 4 === 0); | ||||
|     } else if (dataLength >= 3000 && dataLength < 5000) { | ||||
|       sortedData = sortedData.filter((dat, index) => index % 8 === 0); | ||||
|     } else if (dataLength >= 5000 && dataLength < 7000) { | ||||
|       sortedData = sortedData.filter((dat, index) => index % 12 === 0); | ||||
|     } else if (dataLength > 7000) { | ||||
|       sortedData = sortedData.filter((dat, index) => index % 20 === 0); | ||||
|     } | ||||
|  | ||||
|     // Looping through data to build our memory graph data | ||||
|     const memoryUsed = [ | ||||
|       { | ||||
|         titleName: t('statistics.memory'), | ||||
|         name: 'Used', | ||||
|         backgroundColor: 'rgb(228,102,81,0.9)', | ||||
|         data: [], | ||||
|         fill: true, | ||||
|       }, | ||||
|       { | ||||
|         titleName: t('statistics.memory'), | ||||
|         name: 'Buffered', | ||||
|         backgroundColor: 'rgb(228,102,81,0.9)', | ||||
|         data: [], | ||||
|         fill: true, | ||||
|       }, | ||||
|       { | ||||
|         titleName: t('statistics.memory'), | ||||
|         name: 'Cached', | ||||
|         backgroundColor: 'rgb(228,102,81,0.9)', | ||||
|         data: [], | ||||
|         fill: true, | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
|     for (const log of sortedData) { | ||||
|       memoryUsed[0].data.push( | ||||
|         Math.floor((log.data.unit.memory.total - log.data.unit.memory.free) / 1024 / 1024), | ||||
|       ); | ||||
|       memoryUsed[1].data.push(Math.floor(log.data.unit.memory.buffered / 1024 / 1024)); | ||||
|       memoryUsed[2].data.push(Math.floor(log.data.unit.memory.cached / 1024 / 1024)); | ||||
|     } | ||||
|  | ||||
|     // This dictionary will have a key that is the interface name and a value of it's index in the final array | ||||
|     const interfaceTypes = {}; | ||||
|     const interfaceList = []; | ||||
|     const categories = []; | ||||
|     let i = 0; | ||||
|     const areSameDay = datesSameDay( | ||||
|       new Date(sortedData[0].recorded * 1000), | ||||
|       new Date(sortedData[sortedData.length - 1].recorded * 1000), | ||||
|     ); | ||||
|  | ||||
|     // Just building the array for all the interfaces | ||||
|     for (const log of sortedData) { | ||||
|       categories.push(unixToTime(log.recorded)); | ||||
|       categories.push(areSameDay ? unixToTime(log.recorded) : prettyDate(log.recorded)); | ||||
|       for (const logInterface of log.data.interfaces) { | ||||
|         if (interfaceTypes[logInterface.name] === undefined) { | ||||
|           interfaceTypes[logInterface.name] = i; | ||||
| @@ -69,10 +126,9 @@ const StatisticsChartList = () => { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const options = { | ||||
|     const interfaceOptions = { | ||||
|       chart: { | ||||
|         id: 'chart', | ||||
|         group: 'txrx', | ||||
|       }, | ||||
|       stroke: { | ||||
|         curve: 'smooth', | ||||
| @@ -85,7 +141,7 @@ const StatisticsChartList = () => { | ||||
|           }, | ||||
|         }, | ||||
|         categories, | ||||
|         tickAmount: 20, | ||||
|         tickAmount: areSameDay ? 15 : 10, | ||||
|       }, | ||||
|       yaxis: { | ||||
|         labels: { | ||||
| @@ -105,76 +161,174 @@ const StatisticsChartList = () => { | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const memoryOptions = { | ||||
|       chart: { | ||||
|         id: 'chart', | ||||
|       }, | ||||
|       stroke: { | ||||
|         curve: 'smooth', | ||||
|       }, | ||||
|       xaxis: { | ||||
|         tickAmount: areSameDay ? 15 : 10, | ||||
|         title: { | ||||
|           text: 'Time', | ||||
|           style: { | ||||
|             fontSize: '15px', | ||||
|           }, | ||||
|         }, | ||||
|         categories, | ||||
|       }, | ||||
|       yaxis: { | ||||
|         tickAmount: 5, | ||||
|         title: { | ||||
|           text: t('statistics.data_mb'), | ||||
|           style: { | ||||
|             fontSize: '15px', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       legend: { | ||||
|         position: 'top', | ||||
|         horizontalAlign: 'right', | ||||
|         float: true, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const newOptions = { | ||||
|       interfaceList, | ||||
|       settings: options, | ||||
|       memory: [memoryUsed], | ||||
|       interfaceOptions, | ||||
|       memoryOptions, | ||||
|       start: new Date(sortedData[0].recorded * 1000).toISOString(), | ||||
|       end: new Date(sortedData[sortedData.length - 1].recorded * 1000).toISOString(), | ||||
|     }; | ||||
|  | ||||
|     if (statOptions !== newOptions) { | ||||
|       setStatOptions(newOptions); | ||||
|       const sectionOptions = newOptions.interfaceList.map((opt) => ({ | ||||
|         value: opt[0].titleName, | ||||
|         label: opt[0].titleName, | ||||
|       })); | ||||
|       setOptions([...sectionOptions, { value: 'memory', label: t('statistics.memory') }]); | ||||
|       setStatOptions({ ...newOptions }); | ||||
|       if (sortedData.length > 0) { | ||||
|         setStart(new Date(sortedData[0].recorded * 1000)); | ||||
|         setEnd(new Date(sortedData[sortedData.length - 1].recorded * 1000)); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const getInterface = useCallback(() => { | ||||
|     if (statOptions.interfaceList.length === 0) return <p>N/A</p>; | ||||
|  | ||||
|     const interfaceToShow = statOptions.interfaceList.find( | ||||
|       (inter) => inter[0].titleName === section, | ||||
|     ); | ||||
|  | ||||
|     if (interfaceToShow) { | ||||
|       const options = { | ||||
|         data: interfaceToShow, | ||||
|         options: { | ||||
|           ...statOptions.interfaceOptions, | ||||
|           title: { | ||||
|             text: capitalizeFirstLetter(interfaceToShow[0].titleName), | ||||
|             align: 'left', | ||||
|             style: { | ||||
|               fontSize: '25px', | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }; | ||||
|       return ( | ||||
|         <div key={createUuid()}> | ||||
|           <DeviceStatisticsChart chart={options} /> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|     return <p>N/A</p>; | ||||
|   }, [statOptions, section]); | ||||
|  | ||||
|   const getStatistics = () => { | ||||
|     setLoading(true); | ||||
|  | ||||
|     const options = { | ||||
|       headers: { | ||||
|         Accept: 'application/json', | ||||
|         Authorization: `Bearer ${currentToken}`, | ||||
|       }, | ||||
|       params: { | ||||
|         serialNumber: '24f5a207a130', | ||||
|       }, | ||||
|       params: {}, | ||||
|     }; | ||||
|  | ||||
|     let extraParams = ''; | ||||
|     if (time.start !== null && time.end !== null) { | ||||
|       const utcStart = new Date(time.start).toISOString(); | ||||
|       const utcEnd = new Date(time.end).toISOString(); | ||||
|       options.params.startDate = dateToUnix(utcStart); | ||||
|       options.params.endDate = dateToUnix(utcEnd); | ||||
|       options.params.limit = 10000; | ||||
|     } else { | ||||
|       extraParams = '?newest=true&limit=50'; | ||||
|     } | ||||
|  | ||||
|     axiosInstance | ||||
|       .get( | ||||
|         `${endpoints.owgw}/api/v1/device/${deviceSerialNumber}/statistics?newest=true&limit=50`, | ||||
|         `${endpoints.owgw}/api/v1/device/${deviceSerialNumber}/statistics${extraParams}`, | ||||
|         options, | ||||
|       ) | ||||
|       .then((response) => { | ||||
|         transformIntoDataset(response.data.data); | ||||
|       }) | ||||
|       .catch(() => {}); | ||||
|       .catch(() => {}) | ||||
|       .finally(() => setLoading(false)); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (deviceSerialNumber) { | ||||
|       getStatistics(); | ||||
|     } | ||||
|   }, [deviceSerialNumber]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     eventBus.on('refreshInterfaceStatistics', () => getStatistics()); | ||||
|  | ||||
|     return () => { | ||||
|       eventBus.remove('refreshInterfaceStatistics'); | ||||
|     }; | ||||
|   }, []); | ||||
|   }, [deviceSerialNumber, time.refreshId]); | ||||
|  | ||||
|   if (loading) { | ||||
|     return ( | ||||
|       <div className="text-center"> | ||||
|         <CSpinner size="xl" /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|   return ( | ||||
|     <div> | ||||
|       {statOptions.interfaceList.map((data) => { | ||||
|         const options = { | ||||
|           data, | ||||
|           options: { | ||||
|             ...statOptions.settings, | ||||
|             title: { | ||||
|               text: capitalizeFirstLetter(data[0].titleName), | ||||
|               align: 'left', | ||||
|               style: { | ||||
|                 fontSize: '25px', | ||||
|       {section !== 'memory' && !loading && getInterface()} | ||||
|       {section === 'memory' && | ||||
|         !loading && | ||||
|         statOptions.memory.map((data) => { | ||||
|           const options = { | ||||
|             data, | ||||
|             options: { | ||||
|               ...statOptions.memoryOptions, | ||||
|               title: { | ||||
|                 text: capitalizeFirstLetter(data[0].titleName), | ||||
|                 align: 'left', | ||||
|                 style: { | ||||
|                   fontSize: '25px', | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|         }; | ||||
|         return ( | ||||
|           <div key={createUuid()}> | ||||
|             <DeviceStatisticsChart chart={options} /> | ||||
|           </div> | ||||
|         ); | ||||
|       })} | ||||
|           }; | ||||
|           return ( | ||||
|             <div key={createUuid()}> | ||||
|               <DeviceStatisticsChart chart={options} section={section} /> | ||||
|             </div> | ||||
|           ); | ||||
|         })} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| StatisticsChartList.propTypes = { | ||||
|   setOptions: PropTypes.func.isRequired, | ||||
|   section: PropTypes.string.isRequired, | ||||
|   time: PropTypes.instanceOf(Object).isRequired, | ||||
|   setStart: PropTypes.func.isRequired, | ||||
|   setEnd: PropTypes.func.isRequired, | ||||
| }; | ||||
|  | ||||
| export default React.memo(StatisticsChartList); | ||||
|   | ||||
| @@ -1,9 +1,18 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { CCard, CCardHeader, CCardBody, CPopover, CButton } from '@coreui/react'; | ||||
| import { v4 as createUuid } from 'uuid'; | ||||
| import { | ||||
|   CCard, | ||||
|   CCardHeader, | ||||
|   CCardBody, | ||||
|   CPopover, | ||||
|   CButton, | ||||
|   CSelect, | ||||
|   CFormText, | ||||
| } from '@coreui/react'; | ||||
| import DatePicker from 'react-widgets/DatePicker'; | ||||
| import { cilSync } from '@coreui/icons'; | ||||
| import CIcon from '@coreui/icons-react'; | ||||
| import eventBus from 'utils/eventBus'; | ||||
| import LifetimeStatsmodal from 'components/LifetimeStatsModal'; | ||||
| import StatisticsChartList from './StatisticsChartList'; | ||||
| import LatestStatisticsmodal from './LatestStatisticsModal'; | ||||
| @@ -12,6 +21,13 @@ const DeviceStatisticsCard = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [showLatestModal, setShowLatestModal] = useState(false); | ||||
|   const [showLifetimeModal, setShowLifetimeModal] = useState(false); | ||||
|   const [options, setOptions] = useState([]); | ||||
|   const [section, setSection] = useState(''); | ||||
|   const [start, setStart] = useState(null); | ||||
|   const [startError, setStartError] = useState(false); | ||||
|   const [end, setEnd] = useState(null); | ||||
|   const [endError, setEndError] = useState(false); | ||||
|   const [time, setTime] = useState({ refreshId: '0', start: null, end: null }); | ||||
|  | ||||
|   const toggleLatestModal = () => { | ||||
|     setShowLatestModal(!showLatestModal); | ||||
| @@ -21,10 +37,36 @@ const DeviceStatisticsCard = () => { | ||||
|     setShowLifetimeModal(!showLifetimeModal); | ||||
|   }; | ||||
|  | ||||
|   const refresh = () => { | ||||
|     eventBus.dispatch('refreshInterfaceStatistics', { message: 'Refresh interface statistics' }); | ||||
|   const modifyStart = (value) => { | ||||
|     try { | ||||
|       new Date(value).toISOString(); | ||||
|       setStartError(false); | ||||
|       setStart(value); | ||||
|     } catch (e) { | ||||
|       setStart(''); | ||||
|       setStartError(true); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const modifyEnd = (value) => { | ||||
|     try { | ||||
|       new Date(value).toISOString(); | ||||
|       setEndError(false); | ||||
|       setEnd(value); | ||||
|     } catch (e) { | ||||
|       setEnd(''); | ||||
|       setEndError(true); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const refresh = () => { | ||||
|     setTime({ refreshId: createUuid(), start, end }); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (section === '' && options.length > 0) setSection(options[0].value); | ||||
|   }, [options]); | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <CCard className="m-0"> | ||||
| @@ -32,12 +74,34 @@ const DeviceStatisticsCard = () => { | ||||
|           <div className="d-flex flex-row-reverse align-items-center"> | ||||
|             <div className="pl-2"> | ||||
|               <CPopover content={t('common.refresh')}> | ||||
|                 <CButton size="sm" color="info" onClick={refresh}> | ||||
|                 <CButton size="sm" color="info" onClick={refresh} disabled={startError || endError}> | ||||
|                   <CIcon content={cilSync} /> | ||||
|                 </CButton> | ||||
|               </CPopover> | ||||
|             </div> | ||||
|             <div className="pl-2"> | ||||
|               <DatePicker | ||||
|                 includeTime | ||||
|                 onChange={(date) => modifyEnd(date)} | ||||
|                 value={end ? new Date(end) : undefined} | ||||
|               /> | ||||
|               <CFormText color="danger" hidden={!endError}> | ||||
|                 {t('common.invalid_date_explanation')} | ||||
|               </CFormText> | ||||
|             </div> | ||||
|             To: | ||||
|             <div className="pl-2"> | ||||
|               <DatePicker | ||||
|                 includeTime | ||||
|                 onChange={(date) => modifyStart(date)} | ||||
|                 value={start ? new Date(start) : undefined} | ||||
|               /> | ||||
|               <CFormText color="danger" hidden={!startError}> | ||||
|                 {t('common.invalid_date_explanation')} | ||||
|               </CFormText> | ||||
|             </div> | ||||
|             From: | ||||
|             <div className="px-2"> | ||||
|               <CButton size="sm" color="info" onClick={toggleLifetimeModal}> | ||||
|                 Lifetime Statistics | ||||
|               </CButton> | ||||
| @@ -47,10 +111,30 @@ const DeviceStatisticsCard = () => { | ||||
|                 {t('statistics.show_latest')} | ||||
|               </CButton> | ||||
|             </div> | ||||
|             <div className="pl-2"> | ||||
|               <CSelect | ||||
|                 custom | ||||
|                 value={section} | ||||
|                 disabled={options.length === 0} | ||||
|                 onChange={(e) => setSection(e.target.value)} | ||||
|               > | ||||
|                 {options.map((opt) => ( | ||||
|                   <option value={opt.value} key={createUuid()}> | ||||
|                     {opt.label} | ||||
|                   </option> | ||||
|                 ))} | ||||
|               </CSelect> | ||||
|             </div> | ||||
|           </div> | ||||
|         </CCardHeader> | ||||
|         <CCardBody className="p-1"> | ||||
|           <StatisticsChartList /> | ||||
|           <StatisticsChartList | ||||
|             setOptions={setOptions} | ||||
|             section={section} | ||||
|             time={time} | ||||
|             setStart={setStart} | ||||
|             setEnd={setEnd} | ||||
|           /> | ||||
|         </CCardBody> | ||||
|       </CCard> | ||||
|       <LatestStatisticsmodal show={showLatestModal} toggle={toggleLatestModal} /> | ||||
|   | ||||
| @@ -2,13 +2,13 @@ | ||||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { | ||||
|   CAlert, | ||||
|   CCard, | ||||
|   CCardHeader, | ||||
|   CRow, | ||||
|   CCol, | ||||
|   CCardBody, | ||||
|   CBadge, | ||||
|   CAlert, | ||||
|   CPopover, | ||||
|   CButton, | ||||
|   CSpinner, | ||||
| @@ -16,8 +16,7 @@ import { | ||||
| } from '@coreui/react'; | ||||
| import CIcon from '@coreui/icons-react'; | ||||
| import { cilSync } from '@coreui/icons'; | ||||
| import { prettyDate, compactSecondsToDetailed } from 'utils/helper'; | ||||
| import MemoryBar from './MemoryBar'; | ||||
| import { prettyDate, compactSecondsToDetailed, cleanBytesString } from 'utils/helper'; | ||||
|  | ||||
| import styles from './index.module.scss'; | ||||
|  | ||||
| @@ -27,6 +26,20 @@ const errorField = (t) => ( | ||||
|   </CAlert> | ||||
| ); | ||||
|  | ||||
| const getMemoryColor = (memTotal, memFree) => { | ||||
|   let memoryUsed = 0; | ||||
|   if (memTotal > 0) memoryUsed = Math.floor(((memTotal - memFree) / memTotal) * 100); | ||||
|  | ||||
|   if (memoryUsed < 60) return 'success'; | ||||
|   if (memoryUsed <= 85) return 'warning'; | ||||
|   return 'danger'; | ||||
| }; | ||||
|  | ||||
| const getMemoryPercentage = (memTotal, memFree) => { | ||||
|   if (memTotal <= 0) return `0%`; | ||||
|   return `${Math.floor(((memTotal - memFree) / memTotal) * 100)}%`; | ||||
| }; | ||||
|  | ||||
| const DeviceStatusCard = ({ | ||||
|   t, | ||||
|   loading, | ||||
| @@ -156,6 +169,12 @@ const DeviceStatusCard = ({ | ||||
|                     </div> | ||||
|                   )} | ||||
|                 </CCol> | ||||
|                 <CCol className="mb-1" md="4" xl="4"> | ||||
|                   {t('status.total_memory')}: | ||||
|                 </CCol> | ||||
|                 <CCol className="mb-1" md="8" xl="8" style={{ paddingTop: '5px' }}> | ||||
|                   {error ? errorField(t) : cleanBytesString(lastStats?.unit?.memory?.total)} | ||||
|                 </CCol> | ||||
|                 <CCol className="mb-1" md="4" xl="4"> | ||||
|                   {t('status.memory')}: | ||||
|                 </CCol> | ||||
| @@ -163,15 +182,19 @@ const DeviceStatusCard = ({ | ||||
|                   {error ? ( | ||||
|                     errorField(t) | ||||
|                   ) : ( | ||||
|                     <MemoryBar | ||||
|                       t={t} | ||||
|                       usedBytes={ | ||||
|                         lastStats?.unit?.memory?.total && lastStats?.unit?.memory?.free | ||||
|                           ? lastStats?.unit?.memory?.total - lastStats?.unit?.memory?.free | ||||
|                           : 0 | ||||
|                       } | ||||
|                       totalBytes={lastStats?.unit?.memory?.total ?? 0} | ||||
|                     /> | ||||
|                     <CAlert | ||||
|                       style={{ width: '40px' }} | ||||
|                       className="p-0 text-center" | ||||
|                       color={getMemoryColor( | ||||
|                         lastStats?.unit?.memory?.total ?? 0, | ||||
|                         lastStats?.unit?.memory?.free ?? 0, | ||||
|                       )} | ||||
|                     > | ||||
|                       {getMemoryPercentage( | ||||
|                         lastStats?.unit?.memory?.total ?? 0, | ||||
|                         lastStats?.unit?.memory?.free ?? 0, | ||||
|                       )} | ||||
|                     </CAlert> | ||||
|                   )} | ||||
|                 </CCol> | ||||
|               </CRow> | ||||
|   | ||||
| @@ -124,3 +124,5 @@ export const testRegex = (value, regexString) => { | ||||
|   const regex = new RegExp(regexString); | ||||
|   return regex.test(value); | ||||
| }; | ||||
|  | ||||
| export const datesSameDay = (first, second) => first.getDate() === second.getDate(); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Charles
					Charles