shell-mirror 1.5.58 โ 1.5.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app/dashboard.css +102 -0
- package/public/app/dashboard.js +241 -21
package/package.json
CHANGED
package/public/app/dashboard.css
CHANGED
|
@@ -183,6 +183,28 @@ body {
|
|
|
183
183
|
animation: spin 1s linear infinite;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/* Cleanup Button */
|
|
187
|
+
.cleanup-btn-inline {
|
|
188
|
+
background: #ffebee;
|
|
189
|
+
border: 1px solid #f48fb1;
|
|
190
|
+
border-radius: 50%;
|
|
191
|
+
color: #d32f2f;
|
|
192
|
+
width: 32px;
|
|
193
|
+
height: 32px;
|
|
194
|
+
cursor: pointer;
|
|
195
|
+
transition: all 0.2s ease;
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
justify-content: center;
|
|
199
|
+
font-size: 0.9rem;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.cleanup-btn-inline:hover:not(:disabled) {
|
|
203
|
+
background: #f8bbd9;
|
|
204
|
+
color: #b71c1c;
|
|
205
|
+
transform: scale(1.05);
|
|
206
|
+
}
|
|
207
|
+
|
|
186
208
|
.connection-status {
|
|
187
209
|
font-size: 0.8rem;
|
|
188
210
|
font-weight: 500;
|
|
@@ -279,6 +301,12 @@ body {
|
|
|
279
301
|
gap: 12px;
|
|
280
302
|
}
|
|
281
303
|
|
|
304
|
+
.agent-actions-header {
|
|
305
|
+
display: flex;
|
|
306
|
+
align-items: center;
|
|
307
|
+
gap: 8px;
|
|
308
|
+
}
|
|
309
|
+
|
|
282
310
|
.card-header h2 {
|
|
283
311
|
font-size: 1.3rem;
|
|
284
312
|
font-weight: 600;
|
|
@@ -335,6 +363,11 @@ body {
|
|
|
335
363
|
color: #2e7d32;
|
|
336
364
|
}
|
|
337
365
|
|
|
366
|
+
.agent-status.recent {
|
|
367
|
+
background: #fff3e0;
|
|
368
|
+
color: #f57c00;
|
|
369
|
+
}
|
|
370
|
+
|
|
338
371
|
.agent-status.offline {
|
|
339
372
|
background: #ffebee;
|
|
340
373
|
color: #c62828;
|
|
@@ -393,6 +426,24 @@ body {
|
|
|
393
426
|
transform: translateY(-1px);
|
|
394
427
|
}
|
|
395
428
|
|
|
429
|
+
/* Disabled/Offline Agent Styles */
|
|
430
|
+
.agent-item.agent-offline {
|
|
431
|
+
opacity: 0.7;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.btn-disabled {
|
|
435
|
+
background: #f5f5f5 !important;
|
|
436
|
+
color: #999 !important;
|
|
437
|
+
cursor: not-allowed !important;
|
|
438
|
+
border: 1px solid #e0e0e0 !important;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.btn-disabled:hover {
|
|
442
|
+
background: #f5f5f5 !important;
|
|
443
|
+
transform: none !important;
|
|
444
|
+
color: #999 !important;
|
|
445
|
+
}
|
|
446
|
+
|
|
396
447
|
/* Quick Actions */
|
|
397
448
|
.action-buttons {
|
|
398
449
|
display: flex;
|
|
@@ -889,4 +940,55 @@ body {
|
|
|
889
940
|
|
|
890
941
|
.api-error button {
|
|
891
942
|
margin-top: 10px;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/* Connection Testing States */
|
|
946
|
+
.btn-connect.testing {
|
|
947
|
+
background: #ffc107 !important;
|
|
948
|
+
color: #333 !important;
|
|
949
|
+
cursor: wait !important;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/* Connection Error Notifications */
|
|
953
|
+
.connection-error-notification {
|
|
954
|
+
position: fixed;
|
|
955
|
+
top: 120px;
|
|
956
|
+
right: 20px;
|
|
957
|
+
background: #fff;
|
|
958
|
+
border-left: 4px solid #f44336;
|
|
959
|
+
border-radius: 8px;
|
|
960
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
961
|
+
padding: 16px 20px;
|
|
962
|
+
max-width: 350px;
|
|
963
|
+
z-index: 3000;
|
|
964
|
+
animation: slideInRight 0.3s ease-out;
|
|
965
|
+
display: flex;
|
|
966
|
+
align-items: flex-start;
|
|
967
|
+
gap: 12px;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.connection-error-notification .error-content {
|
|
971
|
+
flex: 1;
|
|
972
|
+
font-size: 0.9rem;
|
|
973
|
+
line-height: 1.4;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.connection-error-notification button {
|
|
977
|
+
background: none;
|
|
978
|
+
border: none;
|
|
979
|
+
font-size: 1.2rem;
|
|
980
|
+
cursor: pointer;
|
|
981
|
+
color: #999;
|
|
982
|
+
padding: 0;
|
|
983
|
+
width: 20px;
|
|
984
|
+
height: 20px;
|
|
985
|
+
display: flex;
|
|
986
|
+
align-items: center;
|
|
987
|
+
justify-content: center;
|
|
988
|
+
border-radius: 50%;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.connection-error-notification button:hover {
|
|
992
|
+
background: #f5f5f5;
|
|
993
|
+
color: #333;
|
|
892
994
|
}
|
package/public/app/dashboard.js
CHANGED
|
@@ -537,30 +537,62 @@ class ShellMirrorDashboard {
|
|
|
537
537
|
}
|
|
538
538
|
|
|
539
539
|
renderActiveAgents() {
|
|
540
|
-
// Filter for
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
540
|
+
// Filter for agents that should be displayed (online, recent, or offline with sessions)
|
|
541
|
+
const displayAgents = this.agents.filter(agent => {
|
|
542
|
+
// Always show online agents
|
|
543
|
+
if (agent.status === 'online') return true;
|
|
544
|
+
|
|
545
|
+
// Show recent agents (last 2 minutes)
|
|
546
|
+
if (agent.status === 'recent') return true;
|
|
547
|
+
|
|
548
|
+
// Show offline agents only if they have active sessions
|
|
549
|
+
const sessions = this.agentSessions[agent.agentId] || [];
|
|
550
|
+
const activeSessions = sessions.filter(s => s.status === 'active');
|
|
551
|
+
return activeSessions.length > 0;
|
|
544
552
|
});
|
|
553
|
+
|
|
554
|
+
// Separate truly active vs inactive agents
|
|
555
|
+
const activeAgents = displayAgents.filter(agent => agent.status === 'online' || agent.status === 'recent');
|
|
545
556
|
|
|
546
|
-
const agentCount =
|
|
547
|
-
const agentsHtml =
|
|
557
|
+
const agentCount = displayAgents.length;
|
|
558
|
+
const agentsHtml = displayAgents.map(agent => {
|
|
548
559
|
const sessions = this.agentSessions[agent.agentId] || [];
|
|
549
560
|
const sessionCount = sessions.length;
|
|
550
561
|
|
|
562
|
+
const isConnectable = agent.status === 'online' || agent.status === 'recent';
|
|
563
|
+
const statusIcon = {
|
|
564
|
+
'online': '๐ข',
|
|
565
|
+
'recent': '๐ก',
|
|
566
|
+
'offline': '๐ด'
|
|
567
|
+
}[agent.status] || 'โ';
|
|
568
|
+
|
|
569
|
+
const statusText = {
|
|
570
|
+
'online': 'Live',
|
|
571
|
+
'recent': 'Recent',
|
|
572
|
+
'offline': 'Offline'
|
|
573
|
+
}[agent.status] || agent.status;
|
|
574
|
+
|
|
575
|
+
const lastSeenText = agent.timeSinceLastSeen !== undefined
|
|
576
|
+
? this.formatPreciseLastSeen(agent.timeSinceLastSeen)
|
|
577
|
+
: this.formatLastSeen(agent.lastSeen);
|
|
578
|
+
|
|
551
579
|
return `
|
|
552
|
-
<div class="agent-item">
|
|
580
|
+
<div class="agent-item ${!isConnectable ? 'agent-offline' : ''}">
|
|
553
581
|
<div class="agent-info">
|
|
554
582
|
<div class="agent-name">${agent.machineName || agent.agentId}</div>
|
|
555
|
-
<div class="agent-status ${agent.
|
|
556
|
-
|
|
583
|
+
<div class="agent-status ${agent.status}">
|
|
584
|
+
${statusIcon} ${statusText}
|
|
585
|
+
</div>
|
|
586
|
+
<div class="agent-last-seen">Last seen: ${lastSeenText}</div>
|
|
557
587
|
${sessionCount > 0 ? `<div class="agent-sessions">${sessionCount} active session${sessionCount !== 1 ? 's' : ''}</div>` : ''}
|
|
558
588
|
</div>
|
|
559
589
|
<div class="agent-actions">
|
|
560
|
-
<button class="btn-connect
|
|
561
|
-
|
|
590
|
+
<button class="btn-connect ${!isConnectable ? 'btn-disabled' : ''}"
|
|
591
|
+
onclick="dashboard.connectToAgent('${agent.agentId}')"
|
|
592
|
+
${!isConnectable ? 'disabled' : ''}>
|
|
593
|
+
${!isConnectable ? 'Offline' : sessionCount > 0 ? 'Resume Session' : 'New Session'}
|
|
562
594
|
</button>
|
|
563
|
-
${sessionCount > 0 ? `<button class="btn-sessions" onclick="dashboard.showAgentSessions('${agent.agentId}')">
|
|
595
|
+
${sessionCount > 0 && isConnectable ? `<button class="btn-sessions" onclick="dashboard.showAgentSessions('${agent.agentId}')">
|
|
564
596
|
All Sessions
|
|
565
597
|
</button>` : ''}
|
|
566
598
|
</div>
|
|
@@ -568,6 +600,10 @@ class ShellMirrorDashboard {
|
|
|
568
600
|
`;
|
|
569
601
|
}).join('');
|
|
570
602
|
|
|
603
|
+
// Check if there are any offline agents to show cleanup option
|
|
604
|
+
const offlineAgents = this.agents.filter(agent => agent.status === 'offline');
|
|
605
|
+
const showCleanup = offlineAgents.length > 0;
|
|
606
|
+
|
|
571
607
|
return `
|
|
572
608
|
<div class="dashboard-card">
|
|
573
609
|
<div class="card-header">
|
|
@@ -575,9 +611,14 @@ class ShellMirrorDashboard {
|
|
|
575
611
|
<h2>๐ฅ๏ธ Active Agents</h2>
|
|
576
612
|
<span class="agent-count">${agentCount} agent${agentCount !== 1 ? 's' : ''}</span>
|
|
577
613
|
</div>
|
|
578
|
-
<
|
|
579
|
-
<
|
|
580
|
-
|
|
614
|
+
<div class="agent-actions-header">
|
|
615
|
+
<button id="refresh-btn" class="refresh-btn-inline" onclick="dashboard.manualRefresh()" title="Refresh agents">
|
|
616
|
+
<span class="refresh-icon">๐</span>
|
|
617
|
+
</button>
|
|
618
|
+
${showCleanup ? `<button class="cleanup-btn-inline" onclick="dashboard.cleanupOfflineAgents()" title="Remove offline agents">
|
|
619
|
+
<span>๐งน</span>
|
|
620
|
+
</button>` : ''}
|
|
621
|
+
</div>
|
|
581
622
|
</div>
|
|
582
623
|
<div class="card-content">
|
|
583
624
|
${agentCount > 0 ? agentsHtml : '<p class="no-data">No active agents. <a href="#" onclick="dashboard.showAgentInstructions()">Set up an agent</a></p>'}
|
|
@@ -749,6 +790,16 @@ class ShellMirrorDashboard {
|
|
|
749
790
|
return `${Math.floor(diff / 86400)} days ago`;
|
|
750
791
|
}
|
|
751
792
|
|
|
793
|
+
formatPreciseLastSeen(timeSinceLastSeen) {
|
|
794
|
+
if (!timeSinceLastSeen && timeSinceLastSeen !== 0) return 'Unknown';
|
|
795
|
+
|
|
796
|
+
if (timeSinceLastSeen < 10) return 'Just now';
|
|
797
|
+
if (timeSinceLastSeen < 60) return `${Math.floor(timeSinceLastSeen)} seconds ago`;
|
|
798
|
+
if (timeSinceLastSeen < 3600) return `${Math.floor(timeSinceLastSeen / 60)} minutes ago`;
|
|
799
|
+
if (timeSinceLastSeen < 86400) return `${Math.floor(timeSinceLastSeen / 3600)} hours ago`;
|
|
800
|
+
return `${Math.floor(timeSinceLastSeen / 86400)} days ago`;
|
|
801
|
+
}
|
|
802
|
+
|
|
752
803
|
formatDate(date) {
|
|
753
804
|
return new Intl.DateTimeFormat('en-US', {
|
|
754
805
|
month: 'short',
|
|
@@ -782,9 +833,109 @@ class ShellMirrorDashboard {
|
|
|
782
833
|
}
|
|
783
834
|
}
|
|
784
835
|
|
|
836
|
+
showAgentConnectionTest(agentId, status) {
|
|
837
|
+
// Find the agent item in the DOM and show loading state
|
|
838
|
+
const agentItems = document.querySelectorAll('.agent-item');
|
|
839
|
+
agentItems.forEach(item => {
|
|
840
|
+
const connectBtn = item.querySelector('.btn-connect');
|
|
841
|
+
if (connectBtn && connectBtn.onclick.toString().includes(agentId)) {
|
|
842
|
+
if (status === 'testing') {
|
|
843
|
+
connectBtn.textContent = 'Testing...';
|
|
844
|
+
connectBtn.disabled = true;
|
|
845
|
+
connectBtn.classList.add('testing');
|
|
846
|
+
} else if (status === 'done') {
|
|
847
|
+
connectBtn.disabled = false;
|
|
848
|
+
connectBtn.classList.remove('testing');
|
|
849
|
+
// Original text will be restored on next refresh
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
showConnectionError(agent, message) {
|
|
856
|
+
// Show a more user-friendly error message
|
|
857
|
+
const notification = document.createElement('div');
|
|
858
|
+
notification.className = 'connection-error-notification';
|
|
859
|
+
notification.innerHTML = `
|
|
860
|
+
<div class="error-content">
|
|
861
|
+
<strong>Connection Failed</strong><br>
|
|
862
|
+
Agent: ${agent.machineName || agent.agentId}<br>
|
|
863
|
+
${message}
|
|
864
|
+
</div>
|
|
865
|
+
<button onclick="this.parentElement.remove()">ร</button>
|
|
866
|
+
`;
|
|
867
|
+
document.body.appendChild(notification);
|
|
868
|
+
|
|
869
|
+
// Auto-remove after 10 seconds
|
|
870
|
+
setTimeout(() => {
|
|
871
|
+
if (document.body.contains(notification)) {
|
|
872
|
+
document.body.removeChild(notification);
|
|
873
|
+
}
|
|
874
|
+
}, 10000);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async testAgentConnectivity(agentId) {
|
|
878
|
+
try {
|
|
879
|
+
console.log('[DASHBOARD] ๐ Testing connectivity for agent:', agentId);
|
|
880
|
+
|
|
881
|
+
// Try to ping the agent through the API
|
|
882
|
+
const response = await fetch(`/php-backend/api/ping-agent.php?agentId=${encodeURIComponent(agentId)}`, {
|
|
883
|
+
method: 'GET',
|
|
884
|
+
credentials: 'include',
|
|
885
|
+
timeout: 5000 // 5 second timeout
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
if (response.ok) {
|
|
889
|
+
const data = await response.json();
|
|
890
|
+
console.log('[DASHBOARD] ๐ Ping response:', data);
|
|
891
|
+
return data.success && data.reachable;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
console.log('[DASHBOARD] โ ๏ธ Ping request failed:', response.status);
|
|
895
|
+
return false;
|
|
896
|
+
} catch (error) {
|
|
897
|
+
console.log('[DASHBOARD] โ ๏ธ Agent connectivity test failed:', error);
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
785
902
|
// Action handlers
|
|
786
903
|
async connectToAgent(agentId) {
|
|
787
904
|
console.log('[DASHBOARD] ๐ DEBUG: connectToAgent called with agentId:', agentId);
|
|
905
|
+
|
|
906
|
+
// First, test if agent is actually reachable
|
|
907
|
+
const agent = this.agents.find(a => a.agentId === agentId);
|
|
908
|
+
if (!agent) {
|
|
909
|
+
alert('Agent not found. Please refresh and try again.');
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Check agent status
|
|
914
|
+
if (agent.status === 'offline') {
|
|
915
|
+
alert(`Agent "${agent.machineName || agentId}" is offline. Please ensure the agent is running.`);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Test agent connectivity for recent agents with loading indicator
|
|
920
|
+
if (agent.status === 'recent') {
|
|
921
|
+
console.log('[DASHBOARD] ๐ Testing agent connectivity...');
|
|
922
|
+
|
|
923
|
+
// Show loading state on the specific agent
|
|
924
|
+
this.showAgentConnectionTest(agentId, 'testing');
|
|
925
|
+
|
|
926
|
+
const isReachable = await this.testAgentConnectivity(agentId);
|
|
927
|
+
|
|
928
|
+
// Hide loading state
|
|
929
|
+
this.showAgentConnectionTest(agentId, 'done');
|
|
930
|
+
|
|
931
|
+
if (!isReachable) {
|
|
932
|
+
this.showConnectionError(agent, 'Connection test failed - agent may be offline');
|
|
933
|
+
// Refresh agent list to get updated status
|
|
934
|
+
await this.refreshDashboardData();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
788
939
|
console.log('[DASHBOARD] ๐ DEBUG: Current agentSessions:', this.agentSessions);
|
|
789
940
|
|
|
790
941
|
// Check if there are existing sessions for this agent
|
|
@@ -860,16 +1011,15 @@ class ShellMirrorDashboard {
|
|
|
860
1011
|
}
|
|
861
1012
|
|
|
862
1013
|
startNewSession() {
|
|
863
|
-
// Get first available agent for new session
|
|
864
|
-
const activeAgents = this.agents.filter(agent =>
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
});
|
|
1014
|
+
// Get first available agent for new session (only truly active ones)
|
|
1015
|
+
const activeAgents = this.agents.filter(agent =>
|
|
1016
|
+
agent.status === 'online' || agent.status === 'recent'
|
|
1017
|
+
);
|
|
868
1018
|
|
|
869
1019
|
if (activeAgents.length > 0) {
|
|
870
1020
|
this.connectToAgent(activeAgents[0].agentId);
|
|
871
1021
|
} else {
|
|
872
|
-
alert('No active agents available. Please ensure an agent is running on your Mac.');
|
|
1022
|
+
alert('No active agents available. Please ensure an agent is running on your Mac and try refreshing.');
|
|
873
1023
|
}
|
|
874
1024
|
}
|
|
875
1025
|
|
|
@@ -990,6 +1140,76 @@ class ShellMirrorDashboard {
|
|
|
990
1140
|
}
|
|
991
1141
|
}
|
|
992
1142
|
|
|
1143
|
+
async cleanupOfflineAgents() {
|
|
1144
|
+
try {
|
|
1145
|
+
console.log('[DASHBOARD] ๐งน Cleaning up offline agents...');
|
|
1146
|
+
|
|
1147
|
+
const response = await fetch('/php-backend/api/cleanup-agents.php', {
|
|
1148
|
+
method: 'POST',
|
|
1149
|
+
credentials: 'include'
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
if (response.ok) {
|
|
1153
|
+
const data = await response.json();
|
|
1154
|
+
console.log('[DASHBOARD] โ
Cleanup response:', data);
|
|
1155
|
+
|
|
1156
|
+
if (data.success) {
|
|
1157
|
+
// Show success message
|
|
1158
|
+
this.showCleanupResult(data.data.message);
|
|
1159
|
+
|
|
1160
|
+
// Refresh the agent list
|
|
1161
|
+
await this.refreshDashboardData();
|
|
1162
|
+
} else {
|
|
1163
|
+
this.showCleanupError('Cleanup failed: ' + (data.message || 'Unknown error'));
|
|
1164
|
+
}
|
|
1165
|
+
} else {
|
|
1166
|
+
this.showCleanupError('Cleanup request failed: ' + response.status);
|
|
1167
|
+
}
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
console.error('[DASHBOARD] โ Cleanup failed:', error);
|
|
1170
|
+
this.showCleanupError('Cleanup failed: ' + error.message);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
showCleanupResult(message) {
|
|
1175
|
+
const notification = document.createElement('div');
|
|
1176
|
+
notification.className = 'cleanup-result-notification';
|
|
1177
|
+
notification.innerHTML = `
|
|
1178
|
+
<div class="result-content">
|
|
1179
|
+
<strong>โ
Cleanup Complete</strong><br>
|
|
1180
|
+
${message}
|
|
1181
|
+
</div>
|
|
1182
|
+
<button onclick="this.parentElement.remove()">ร</button>
|
|
1183
|
+
`;
|
|
1184
|
+
document.body.appendChild(notification);
|
|
1185
|
+
|
|
1186
|
+
// Auto-remove after 5 seconds
|
|
1187
|
+
setTimeout(() => {
|
|
1188
|
+
if (document.body.contains(notification)) {
|
|
1189
|
+
document.body.removeChild(notification);
|
|
1190
|
+
}
|
|
1191
|
+
}, 5000);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
showCleanupError(message) {
|
|
1195
|
+
const notification = document.createElement('div');
|
|
1196
|
+
notification.className = 'connection-error-notification';
|
|
1197
|
+
notification.innerHTML = `
|
|
1198
|
+
<div class="error-content">
|
|
1199
|
+
<strong>Cleanup Failed</strong><br>
|
|
1200
|
+
${message}
|
|
1201
|
+
</div>
|
|
1202
|
+
<button onclick="this.parentElement.remove()">ร</button>
|
|
1203
|
+
`;
|
|
1204
|
+
document.body.appendChild(notification);
|
|
1205
|
+
|
|
1206
|
+
// Auto-remove after 8 seconds
|
|
1207
|
+
setTimeout(() => {
|
|
1208
|
+
if (document.body.contains(notification)) {
|
|
1209
|
+
document.body.removeChild(notification);
|
|
1210
|
+
}
|
|
1211
|
+
}, 8000);
|
|
1212
|
+
}
|
|
993
1213
|
|
|
994
1214
|
showAgentInstructions() {
|
|
995
1215
|
// TODO: Show modal with agent setup instructions
|