dsh-apk-vm 0.4.1 → 0.6.0

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/client.js CHANGED
@@ -189,6 +189,25 @@ window.__ModuleLoader__.load({
189
189
  'network.caPath': 'CA certificate path (.pem/.crt)',
190
190
  'network.installCa': 'Install CA',
191
191
  'network.current': 'Current: {value}',
192
+ 'network.forwards': 'Port forwards',
193
+ 'network.forwardLocal': 'Host port',
194
+ 'network.forwardRemote': 'Guest port',
195
+ 'network.addForward': 'Add forward',
196
+ 'network.remove': 'Remove',
197
+ 'network.mode': 'Mode',
198
+ 'network.isolated': 'Isolated',
199
+ 'network.shared': 'Shared (host bridge)',
200
+ 'network.dns': 'DNS servers',
201
+ 'network.applyDns': 'Apply DNS',
202
+ 'network.hosts': 'Hosts entries',
203
+ 'network.applyHosts': 'Apply hosts',
204
+ 'network.test': 'Test connectivity',
205
+ 'network.testOk': 'Connectivity OK',
206
+ 'network.testFail': 'Connectivity check failed',
207
+ 'network.pcap': 'Download pcap',
208
+ 'network.restartRequired': 'Restart the VM to apply custom DNS',
209
+ 'network.sharedHint': 'Shared forwards are reachable from other VMs through the emulator host alias.',
210
+ 'network.guestUrl': 'Guest URL: {url}',
192
211
  'flow.title': 'Flow recorder',
193
212
  'flow.start': 'Record',
194
213
  'flow.stop': 'Stop',
@@ -200,6 +219,21 @@ window.__ModuleLoader__.load({
200
219
  'flow.saved': 'Saved {name}',
201
220
  'flow.done': 'Replayed {name}',
202
221
  'flow.count': '{n} step(s)',
222
+ 'storage.title': 'Storage',
223
+ 'storage.refresh': 'Refresh',
224
+ 'storage.cleanTemp': 'Clean temp',
225
+ 'storage.cleanSnapshots': 'Clean snapshots',
226
+ 'storage.cleanOrphans': 'Clean orphan AVDs',
227
+ 'storage.confirm': 'Click again to confirm',
228
+ 'storage.freed': 'Freed {size}',
229
+ 'storage.avds': '{size} AVDs',
230
+ 'storage.snapshots': 'Snapshots',
231
+ 'storage.sdk': 'SDK',
232
+ 'storage.temp': 'Temp',
233
+ 'storage.free': 'Free disk',
234
+ 'storage.active': 'active',
235
+ 'storage.orphan': 'orphan',
236
+ 'storage.empty': 'No AVDs found',
203
237
  'apk.bundle': 'Bundle',
204
238
  'setup.title': 'Environment',
205
239
  'setup.ok': 'Ready',
@@ -387,6 +421,25 @@ window.__ModuleLoader__.load({
387
421
  'network.caPath': 'CA 证书路径(.pem/.crt)',
388
422
  'network.installCa': '安装 CA',
389
423
  'network.current': '当前:{value}',
424
+ 'network.forwards': '端口转发',
425
+ 'network.forwardLocal': '宿主机端口',
426
+ 'network.forwardRemote': 'Guest 端口',
427
+ 'network.addForward': '添加转发',
428
+ 'network.remove': '删除',
429
+ 'network.mode': '模式',
430
+ 'network.isolated': '隔离',
431
+ 'network.shared': '共享(宿主桥接)',
432
+ 'network.dns': 'DNS 服务器',
433
+ 'network.applyDns': '应用 DNS',
434
+ 'network.hosts': 'Hosts 条目',
435
+ 'network.applyHosts': '应用 Hosts',
436
+ 'network.test': '测试连通性',
437
+ 'network.testOk': '连通性正常',
438
+ 'network.testFail': '连通性检查失败',
439
+ 'network.pcap': '下载 pcap',
440
+ 'network.restartRequired': '重启虚拟机后生效自定义 DNS',
441
+ 'network.sharedHint': '共享转发可被其他 VM 通过模拟器宿主别名访问。',
442
+ 'network.guestUrl': 'Guest URL: {url}',
390
443
  'flow.title': '操作录制',
391
444
  'flow.start': '录制',
392
445
  'flow.stop': '停止',
@@ -398,6 +451,21 @@ window.__ModuleLoader__.load({
398
451
  'flow.saved': '已保存 {name}',
399
452
  'flow.done': '已回放 {name}',
400
453
  'flow.count': '{n} 步',
454
+ 'storage.title': '存储',
455
+ 'storage.refresh': '刷新',
456
+ 'storage.cleanTemp': '清理临时文件',
457
+ 'storage.cleanSnapshots': '清理快照',
458
+ 'storage.cleanOrphans': '清理孤立 AVD',
459
+ 'storage.confirm': '再点一次确认',
460
+ 'storage.freed': '已释放 {size}',
461
+ 'storage.avds': '{size} 个 AVD',
462
+ 'storage.snapshots': '快照',
463
+ 'storage.sdk': 'SDK',
464
+ 'storage.temp': '临时文件',
465
+ 'storage.free': '磁盘剩余',
466
+ 'storage.active': '运行中',
467
+ 'storage.orphan': '孤立',
468
+ 'storage.empty': '没有找到 AVD',
401
469
  'apk.bundle': '安装包',
402
470
  'setup.title': '环境',
403
471
  'setup.ok': '就绪',
@@ -1207,6 +1275,19 @@ window.__ModuleLoader__.load({
1207
1275
  var flowBusy = flowBusyState[0];
1208
1276
  var setFlowBusy = flowBusyState[1];
1209
1277
 
1278
+ var storageState = React.useState(null);
1279
+ var storage = storageState[0];
1280
+ var setStorage = storageState[1];
1281
+ var storageBusyState = React.useState('');
1282
+ var storageBusy = storageBusyState[0];
1283
+ var setStorageBusy = storageBusyState[1];
1284
+ var storageErrorState = React.useState('');
1285
+ var storageError = storageErrorState[0];
1286
+ var setStorageError = storageErrorState[1];
1287
+ var storageConfirmState = React.useState('');
1288
+ var storageConfirm = storageConfirmState[0];
1289
+ var setStorageConfirm = storageConfirmState[1];
1290
+
1210
1291
  var proxyState = React.useState({ host: '', port: '' });
1211
1292
  var proxy = proxyState[0];
1212
1293
  var setProxy = proxyState[1];
@@ -1216,6 +1297,27 @@ window.__ModuleLoader__.load({
1216
1297
  var caPathState = React.useState('');
1217
1298
  var caPath = caPathState[0];
1218
1299
  var setCaPath = caPathState[1];
1300
+ var networkState = React.useState(null);
1301
+ var network = networkState[0];
1302
+ var setNetwork = networkState[1];
1303
+ var networkBusyState = React.useState('');
1304
+ var networkBusy = networkBusyState[0];
1305
+ var setNetworkBusy = networkBusyState[1];
1306
+ var forwardLocalState = React.useState('');
1307
+ var forwardLocal = forwardLocalState[0];
1308
+ var setForwardLocal = forwardLocalState[1];
1309
+ var forwardRemoteState = React.useState('');
1310
+ var forwardRemote = forwardRemoteState[0];
1311
+ var setForwardRemote = forwardRemoteState[1];
1312
+ var forwardSharedState = React.useState(false);
1313
+ var forwardShared = forwardSharedState[0];
1314
+ var setForwardShared = forwardSharedState[1];
1315
+ var dnsTextState = React.useState('');
1316
+ var dnsText = dnsTextState[0];
1317
+ var setDnsText = dnsTextState[1];
1318
+ var hostsTextState = React.useState('');
1319
+ var hostsText = hostsTextState[0];
1320
+ var setHostsText = hostsTextState[1];
1219
1321
 
1220
1322
  var actualFpsState = React.useState(0);
1221
1323
  var actualFps = actualFpsState[0];
@@ -1392,6 +1494,8 @@ window.__ModuleLoader__.load({
1392
1494
  loadInstances();
1393
1495
  loadDevices();
1394
1496
  loadFlows();
1497
+ loadStorage();
1498
+ loadNetwork();
1395
1499
  var pollId = setInterval(function () {
1396
1500
  if (stateBusyRef.current) return;
1397
1501
  stateBusyRef.current = true;
@@ -1413,6 +1517,12 @@ window.__ModuleLoader__.load({
1413
1517
  };
1414
1518
  }, []);
1415
1519
 
1520
+ // Load network details once the VM becomes ready (the first render often
1521
+ // happens while the state is still loading).
1522
+ React.useEffect(function () {
1523
+ if (ready) loadNetwork();
1524
+ }, [ready, activeInstance]);
1525
+
1416
1526
  // Track document visibility so the frame stream pauses when hidden.
1417
1527
  React.useEffect(function () {
1418
1528
  if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
@@ -1789,6 +1899,8 @@ window.__ModuleLoader__.load({
1789
1899
  setInspectNodes([]);
1790
1900
  setInspectSelected(null);
1791
1901
  loadFlows();
1902
+ setNetwork(null);
1903
+ loadNetwork();
1792
1904
  stopLiveLogcat();
1793
1905
  frameStatsRef.current = [];
1794
1906
  loadState();
@@ -2122,6 +2234,98 @@ window.__ModuleLoader__.load({
2122
2234
  });
2123
2235
  }
2124
2236
 
2237
+ function loadNetwork() {
2238
+ if (!ready) return Promise.resolve(null);
2239
+ setNetworkBusy('load');
2240
+ return requestJson(instancePath('/network'), null, T).then(function (result) {
2241
+ if (!mountedRef.current) return result;
2242
+ setNetworkBusy('');
2243
+ if (result.ok && isObject(result.data)) {
2244
+ setNetwork(result.data);
2245
+ if (result.data.proxy && typeof result.data.proxy === 'string') {
2246
+ var colon = result.data.proxy.lastIndexOf(':');
2247
+ if (colon > 0) {
2248
+ setProxy({ host: result.data.proxy.slice(0, colon), port: result.data.proxy.slice(colon + 1) });
2249
+ }
2250
+ }
2251
+ if (result.data.dnsServers && result.data.dnsServers.length) {
2252
+ setDnsText(result.data.dnsServers.join(', '));
2253
+ }
2254
+ }
2255
+ return result;
2256
+ });
2257
+ }
2258
+
2259
+ function networkAction(action, payload) {
2260
+ if (networkBusy) return;
2261
+ setNetworkBusy(action);
2262
+ return requestJson(
2263
+ instancePath('/network'),
2264
+ { method: 'POST', body: JSON.stringify(Object.assign({ action: action }, payload || {})) },
2265
+ T
2266
+ ).then(function (result) {
2267
+ if (!mountedRef.current) return result;
2268
+ setNetworkBusy('');
2269
+ if (!result.ok) {
2270
+ pushToast('error', T('err.action') + ': ' + result.error);
2271
+ return result;
2272
+ }
2273
+ if (action === 'test') {
2274
+ pushToast(result.data && result.data.ok === true ? 'success' : 'error', T(result.data && result.data.ok ? 'network.testOk' : 'network.testFail'));
2275
+ } else if (action === 'dns' && result.data && result.data.restartRequired) {
2276
+ pushToast('success', T('network.restartRequired'));
2277
+ } else {
2278
+ pushToast('success', T('network.title'));
2279
+ }
2280
+ loadNetwork();
2281
+ return result;
2282
+ });
2283
+ }
2284
+
2285
+ function addPortForward() {
2286
+ var local = Number(forwardLocal);
2287
+ var remote = Number(forwardRemote);
2288
+ if (!Number.isInteger(local) || local < 1 || local > 65535 || !Number.isInteger(remote) || remote < 1 || remote > 65535) {
2289
+ pushToast('error', T('err.action'));
2290
+ return;
2291
+ }
2292
+ networkAction('forward-add', {
2293
+ localPort: local,
2294
+ remotePort: remote,
2295
+ shared: forwardShared === true,
2296
+ }).then(function (result) {
2297
+ if (result && result.ok) {
2298
+ setForwardLocal('');
2299
+ setForwardRemote('');
2300
+ setForwardShared(false);
2301
+ }
2302
+ });
2303
+ }
2304
+
2305
+ function removePortForward(localPort) {
2306
+ networkAction('forward-remove', { localPort: localPort });
2307
+ }
2308
+
2309
+ function applyDns() {
2310
+ var servers = String(dnsText || '')
2311
+ .split(',')
2312
+ .map(function (item) {
2313
+ return item.trim();
2314
+ })
2315
+ .filter(Boolean);
2316
+ networkAction('dns', { servers: servers });
2317
+ }
2318
+
2319
+ function applyHosts() {
2320
+ var entries = String(hostsText || '')
2321
+ .split(/\r?\n/)
2322
+ .map(function (line) {
2323
+ return line.trim();
2324
+ })
2325
+ .filter(Boolean);
2326
+ networkAction('hosts', { entries: entries });
2327
+ }
2328
+
2125
2329
  function applyProxy() {
2126
2330
  if (proxyBusy) return;
2127
2331
  setProxyBusy(true);
@@ -2137,6 +2341,7 @@ window.__ModuleLoader__.load({
2137
2341
  return;
2138
2342
  }
2139
2343
  pushToast('success', T('network.current', { value: (result.data && result.data.proxy) || 'off' }));
2344
+ loadNetwork();
2140
2345
  });
2141
2346
  }
2142
2347
 
@@ -2151,6 +2356,7 @@ window.__ModuleLoader__.load({
2151
2356
  return;
2152
2357
  }
2153
2358
  pushToast('success', T('network.clear'));
2359
+ loadNetwork();
2154
2360
  });
2155
2361
  }
2156
2362
 
@@ -2173,6 +2379,67 @@ window.__ModuleLoader__.load({
2173
2379
  });
2174
2380
  }
2175
2381
 
2382
+ function loadStorage() {
2383
+ setStorageBusy('load');
2384
+ setStorageError('');
2385
+ return requestJson('/apk-vm/storage', null, T).then(function (result) {
2386
+ if (!mountedRef.current) return result;
2387
+ setStorageBusy('');
2388
+ if (!result.ok) {
2389
+ setStorageError(result.error || T('err.action'));
2390
+ return result;
2391
+ }
2392
+ setStorage(isObject(result.data) ? result.data : null);
2393
+ return result;
2394
+ });
2395
+ }
2396
+
2397
+ function storageSnapshotAvd() {
2398
+ var list = storage && Array.isArray(storage.avds) ? storage.avds : [];
2399
+ var current = snapshot && snapshot.vm && snapshot.vm.avd ? String(snapshot.vm.avd) : '';
2400
+ var match = list.find(function (item) {
2401
+ return item.name === current;
2402
+ });
2403
+ if (match) return match.name;
2404
+ var active = list.find(function (item) {
2405
+ return item.active && item.managed;
2406
+ });
2407
+ if (active) return active.name;
2408
+ var managed = list.find(function (item) {
2409
+ return item.managed;
2410
+ });
2411
+ return managed ? managed.name : 'dsh_apk';
2412
+ }
2413
+
2414
+ function cleanStorage(action) {
2415
+ if (storageBusy) return;
2416
+ var destructive = action === 'snapshots' || action === 'orphan-avds';
2417
+ if (destructive && storageConfirm !== action) {
2418
+ setStorageConfirm(action);
2419
+ pushToast('success', T('storage.confirm'));
2420
+ return;
2421
+ }
2422
+ setStorageConfirm('');
2423
+ setStorageBusy(action);
2424
+ var payload = { action: action };
2425
+ if (action === 'snapshots') {
2426
+ payload.avdName = storageSnapshotAvd();
2427
+ payload.confirm = true;
2428
+ }
2429
+ if (action === 'orphan-avds') payload.confirm = true;
2430
+ requestJson('/apk-vm/storage', { method: 'POST', body: JSON.stringify(payload) }, T).then(function (result) {
2431
+ if (!mountedRef.current) return;
2432
+ setStorageBusy('');
2433
+ if (!result.ok) {
2434
+ pushToast('error', T('err.action') + ': ' + result.error);
2435
+ return;
2436
+ }
2437
+ var freed = result.data && Number(result.data.freedBytes) ? Number(result.data.freedBytes) : 0;
2438
+ pushToast('success', T('storage.freed', { size: formatBytes(freed) }));
2439
+ loadStorage();
2440
+ });
2441
+ }
2442
+
2176
2443
  function refreshAll() {
2177
2444
  loadState();
2178
2445
  loadApks();
@@ -3810,7 +4077,285 @@ window.__ModuleLoader__.load({
3810
4077
  onClick: installCa,
3811
4078
  })
3812
4079
  ),
3813
- h('div', { className: 'apkvm-dim' }, T('network.caPath'))
4080
+ h('div', { className: 'apkvm-dim' }, T('network.caPath')),
4081
+ h(
4082
+ 'label',
4083
+ { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
4084
+ h('span', null, T('network.mode')),
4085
+ h(
4086
+ 'select',
4087
+ {
4088
+ value: (network && network.mode) || 'isolated',
4089
+ disabled: Boolean(networkBusy) || !ready,
4090
+ onChange: function (event) {
4091
+ networkAction('mode', { mode: event && event.target ? String(event.target.value) : 'isolated' });
4092
+ },
4093
+ style: {
4094
+ font: 'inherit',
4095
+ background: 'var(--dsw-alias-bg-layer-1)',
4096
+ color: 'var(--dsw-alias-label-primary)',
4097
+ border: '1px solid var(--dsw-alias-border-l2)',
4098
+ borderRadius: '6px',
4099
+ padding: '1px 4px',
4100
+ },
4101
+ },
4102
+ h('option', { value: 'isolated' }, T('network.isolated')),
4103
+ h('option', { value: 'shared' }, T('network.shared'))
4104
+ )
4105
+ ),
4106
+ h(
4107
+ 'div',
4108
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
4109
+ h('span', { className: 'apkvm-card-title' }, T('network.forwards')),
4110
+ actionBtn(T('network.test'), {
4111
+ key: 'network-test',
4112
+ small: true,
4113
+ disabled: !ready || Boolean(networkBusy),
4114
+ onClick: function () {
4115
+ networkAction('test', { host: proxy.host || '', port: proxy.port || undefined });
4116
+ },
4117
+ })
4118
+ ),
4119
+ network && Array.isArray(network.forwards) && network.forwards.length
4120
+ ? network.forwards.map(function (rule) {
4121
+ return h(
4122
+ 'div',
4123
+ { key: 'forward-' + rule.localPort, className: 'apkvm-dim', style: { display: 'flex', alignItems: 'center', gap: '5px' } },
4124
+ h('span', { style: { flex: '1 1 auto' } }, 'tcp:' + rule.localPort + ' -> guest tcp:' + rule.remotePort),
4125
+ rule.shared ? h('span', { className: 'apkvm-tag ok' }, 'shared') : null,
4126
+ actionBtn(T('network.remove'), {
4127
+ key: 'forward-remove-' + rule.localPort,
4128
+ small: true,
4129
+ variant: 'danger',
4130
+ disabled: Boolean(networkBusy),
4131
+ onClick: function () {
4132
+ removePortForward(rule.localPort);
4133
+ },
4134
+ })
4135
+ );
4136
+ })
4137
+ : h('div', { className: 'apkvm-dim' }, T('network.forwards')),
4138
+ h(
4139
+ 'div',
4140
+ { style: { display: 'flex', alignItems: 'center', gap: '5px' } },
4141
+ h('input', {
4142
+ className: 'apkvm-input',
4143
+ type: 'text',
4144
+ value: forwardLocal,
4145
+ placeholder: T('network.forwardLocal'),
4146
+ spellCheck: false,
4147
+ disabled: Boolean(networkBusy),
4148
+ onChange: function (event) {
4149
+ setForwardLocal(event && event.target ? String(event.target.value) : '');
4150
+ },
4151
+ 'aria-label': T('network.forwardLocal'),
4152
+ }),
4153
+ h('input', {
4154
+ className: 'apkvm-input',
4155
+ type: 'text',
4156
+ value: forwardRemote,
4157
+ placeholder: T('network.forwardRemote'),
4158
+ spellCheck: false,
4159
+ disabled: Boolean(networkBusy),
4160
+ onChange: function (event) {
4161
+ setForwardRemote(event && event.target ? String(event.target.value) : '');
4162
+ },
4163
+ 'aria-label': T('network.forwardRemote'),
4164
+ }),
4165
+ actionBtn(T('network.addForward'), {
4166
+ key: 'forward-add',
4167
+ small: true,
4168
+ disabled: !ready || Boolean(networkBusy) || !forwardLocal.trim() || !forwardRemote.trim(),
4169
+ onClick: addPortForward,
4170
+ })
4171
+ ),
4172
+ h(
4173
+ 'label',
4174
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
4175
+ h('input', {
4176
+ type: 'checkbox',
4177
+ checked: forwardShared,
4178
+ disabled: Boolean(networkBusy),
4179
+ onChange: function (event) {
4180
+ setForwardShared(Boolean(event && event.target && event.target.checked));
4181
+ },
4182
+ }),
4183
+ h('span', null, T('network.shared'))
4184
+ ),
4185
+ h(
4186
+ 'div',
4187
+ { style: { display: 'flex', alignItems: 'center', gap: '5px' } },
4188
+ h('input', {
4189
+ className: 'apkvm-input',
4190
+ type: 'text',
4191
+ value: dnsText,
4192
+ placeholder: T('network.dns'),
4193
+ spellCheck: false,
4194
+ disabled: Boolean(networkBusy),
4195
+ onChange: function (event) {
4196
+ setDnsText(event && event.target ? String(event.target.value) : '');
4197
+ },
4198
+ 'aria-label': T('network.dns'),
4199
+ }),
4200
+ actionBtn(T('network.applyDns'), {
4201
+ key: 'dns-apply',
4202
+ small: true,
4203
+ disabled: !ready || Boolean(networkBusy),
4204
+ onClick: applyDns,
4205
+ })
4206
+ ),
4207
+ h('textarea', {
4208
+ className: 'apkvm-input',
4209
+ value: hostsText,
4210
+ placeholder: T('network.hosts'),
4211
+ spellCheck: false,
4212
+ disabled: Boolean(networkBusy),
4213
+ style: { minHeight: '46px', height: '46px', resize: 'vertical', padding: '4px 8px' },
4214
+ onChange: function (event) {
4215
+ setHostsText(event && event.target ? String(event.target.value) : '');
4216
+ },
4217
+ 'aria-label': T('network.hosts'),
4218
+ }),
4219
+ h(
4220
+ 'div',
4221
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
4222
+ actionBtn(T('network.applyHosts'), {
4223
+ key: 'hosts-apply',
4224
+ small: true,
4225
+ disabled: !ready || Boolean(networkBusy),
4226
+ onClick: applyHosts,
4227
+ }),
4228
+ network && network.pcap && network.pcap.enabled
4229
+ ? h(
4230
+ 'a',
4231
+ {
4232
+ href: instancePath('/network/pcap'),
4233
+ download: 'capture.pcap',
4234
+ style: { fontSize: '11px', color: 'var(--dsw-alias-brand-primary)' },
4235
+ },
4236
+ T('network.pcap')
4237
+ )
4238
+ : null
4239
+ ),
4240
+ network && network.mode === 'shared' ? h('div', { className: 'apkvm-dim' }, T('network.sharedHint')) : null
4241
+ )
4242
+ );
4243
+
4244
+ var storageCard = h(
4245
+ 'details',
4246
+ { className: 'apkvm-card apkvm-debug' },
4247
+ h(
4248
+ 'summary',
4249
+ { className: 'apkvm-debug-summary' },
4250
+ h('span', { className: 'apkvm-card-title' }, T('storage.title')),
4251
+ h(
4252
+ 'span',
4253
+ { className: 'apkvm-dim', style: { marginLeft: 'auto' } },
4254
+ storage ? formatBytes((storage.totals && storage.totals.avdBytes) || 0) : '…'
4255
+ ),
4256
+ h('span', { className: 'apkvm-chevron', 'aria-hidden': 'true' }, '▸')
4257
+ ),
4258
+ h(
4259
+ 'div',
4260
+ { className: 'apkvm-debug-body' },
4261
+ !storage
4262
+ ? h('div', { className: 'apkvm-dim' }, storageError || T('storage.empty'))
4263
+ : h(
4264
+ 'div',
4265
+ { style: { display: 'flex', flexDirection: 'column', gap: '5px', minWidth: 0 } },
4266
+ h(
4267
+ 'div',
4268
+ { className: 'apkvm-dim', style: { display: 'flex', flexWrap: 'wrap', gap: '8px' } },
4269
+ h('span', null, T('storage.avds', { size: (storage.avds || []).length }) + ': ' + formatBytes((storage.totals && storage.totals.avdBytes) || 0)),
4270
+ h('span', null, T('storage.snapshots') + ': ' + formatBytes((storage.totals && storage.totals.snapshotBytes) || 0)),
4271
+ h('span', null, T('storage.sdk') + ': ' + formatBytes((storage.totals && storage.totals.sdkBytes) || 0)),
4272
+ h('span', null, T('storage.temp') + ': ' + formatBytes((storage.totals && storage.totals.tempBytes) || 0)),
4273
+ storage.disk
4274
+ ? h('span', null, T('storage.free') + ': ' + formatBytes(storage.disk.freeBytes))
4275
+ : null
4276
+ ),
4277
+ (storage.avds || []).slice(0, 4).map(function (avd) {
4278
+ return h(
4279
+ 'div',
4280
+ {
4281
+ key: 'storage-' + avd.name,
4282
+ className: 'apkvm-apk',
4283
+ style: { padding: '5px 7px', gap: '3px' },
4284
+ },
4285
+ h(
4286
+ 'div',
4287
+ { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
4288
+ h('span', { className: 'apkvm-apk-name', title: avd.path }, avd.name),
4289
+ avd.active ? h('span', { className: 'apkvm-tag ok' }, T('storage.active')) : null,
4290
+ avd.orphan ? h('span', { className: 'apkvm-tag' }, T('storage.orphan')) : null
4291
+ ),
4292
+ h(
4293
+ 'div',
4294
+ { className: 'apkvm-dim' },
4295
+ formatBytes(avd.totalBytes || 0) +
4296
+ ' · ' +
4297
+ T('storage.snapshots') +
4298
+ ' ' +
4299
+ formatBytes(avd.snapshotBytes || 0)
4300
+ )
4301
+ );
4302
+ }),
4303
+ h(
4304
+ 'div',
4305
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
4306
+ actionBtn(storageBusy === 'load' ? '…' : T('storage.refresh'), {
4307
+ key: 'storage-refresh',
4308
+ small: true,
4309
+ disabled: Boolean(storageBusy),
4310
+ onClick: loadStorage,
4311
+ }),
4312
+ actionBtn(storageBusy === 'temp' ? '…' : T('storage.cleanTemp'), {
4313
+ key: 'storage-temp',
4314
+ small: true,
4315
+ disabled: Boolean(storageBusy),
4316
+ onClick: function () {
4317
+ cleanStorage('temp');
4318
+ },
4319
+ }),
4320
+ actionBtn(
4321
+ storageConfirm === 'snapshots'
4322
+ ? T('storage.confirm')
4323
+ : storageBusy === 'snapshots'
4324
+ ? '…'
4325
+ : T('storage.cleanSnapshots'),
4326
+ {
4327
+ key: 'storage-snapshots',
4328
+ small: true,
4329
+ disabled: Boolean(storageBusy),
4330
+ onClick: function () {
4331
+ cleanStorage('snapshots');
4332
+ },
4333
+ }
4334
+ ),
4335
+ (storage.recommendations || []).some(function (item) {
4336
+ return item.code === 'orphan-avds';
4337
+ }) || (storage.avds || []).some(function (item) {
4338
+ return item.orphan;
4339
+ })
4340
+ ? actionBtn(
4341
+ storageConfirm === 'orphan-avds'
4342
+ ? T('storage.confirm')
4343
+ : storageBusy === 'orphan-avds'
4344
+ ? '…'
4345
+ : T('storage.cleanOrphans'),
4346
+ {
4347
+ key: 'storage-orphans',
4348
+ small: true,
4349
+ disabled: Boolean(storageBusy),
4350
+ onClick: function () {
4351
+ cleanStorage('orphan-avds');
4352
+ },
4353
+ }
4354
+ )
4355
+ : null
4356
+ ),
4357
+ storageError ? h('div', { className: 'apkvm-dim' }, storageError) : null
4358
+ )
3814
4359
  )
3815
4360
  );
3816
4361
 
@@ -4149,6 +4694,7 @@ window.__ModuleLoader__.load({
4149
4694
  debugCard,
4150
4695
  flowCard,
4151
4696
  networkCard,
4697
+ storageCard,
4152
4698
  appsCard,
4153
4699
  diagCard,
4154
4700
  h('div', { className: 'apkvm-footnote', title: T('hint.boot') }, T('hint.short')),