Update.svelte raw
1 <script>
2 import { onMount } from 'svelte';
3 import { userSigner, userPubkey, binariesData, isLoading, error } from '../stores.js';
4 import { fetchBinaries, updateBinaries, rollbackVersion, restartServices, restartService, fetchReleases } from '../api.js';
5
6 let version = '';
7 let releaseBaseUrl = '';
8 let architecture = 'amd64';
9 let updateResult = null;
10 let isUpdating = false;
11 let launcherUpdated = false;
12
13 // Official releases - fetched via backend proxy to avoid CORS
14 const RELEASES_BASE = 'https://git.nostrdev.com/mleku/next.orly.dev/releases/download';
15 let availableReleases = [];
16 let selectedRelease = '';
17 let loadingReleases = false;
18
19 // Category definitions with available options
20 const categoryDefs = {
21 launcher: {
22 label: 'Launcher',
23 options: [
24 { value: 'orly-launcher', label: 'orly-launcher' },
25 { value: 'custom', label: 'Custom' }
26 ],
27 required: true
28 },
29 relay: {
30 label: 'Relay',
31 options: [
32 { value: 'orly', label: 'orly' },
33 { value: 'custom', label: 'Custom' }
34 ],
35 required: true
36 },
37 database: {
38 label: 'Database',
39 options: [
40 { value: 'orly-db-badger', label: 'Badger' },
41 { value: 'orly-db-neo4j', label: 'Neo4j' },
42 { value: 'custom', label: 'Custom' }
43 ],
44 required: true
45 },
46 acl: {
47 label: 'ACL',
48 options: [
49 { value: 'none', label: 'None (disabled)' },
50 { value: 'orly-acl-follows', label: 'Follows' },
51 { value: 'orly-acl-managed', label: 'Managed' },
52 { value: 'orly-acl-curation', label: 'Curation' },
53 { value: 'custom', label: 'Custom' }
54 ],
55 required: false
56 },
57 sync: {
58 label: 'Sync',
59 options: [
60 { value: 'none', label: 'None (disabled)' },
61 { value: 'orly-sync-negentropy', label: 'Negentropy' },
62 { value: 'custom', label: 'Custom' }
63 ],
64 required: false
65 }
66 };
67
68 // Current selections for each category
69 let categories = {
70 launcher: { selected: 'orly-launcher', customUrl: '', url: '', installing: false, installed: false },
71 relay: { selected: 'orly', customUrl: '', url: '', installing: false, installed: false },
72 database: { selected: 'orly-db-badger', customUrl: '', url: '', installing: false, installed: false },
73 acl: { selected: 'none', customUrl: '', url: '', installing: false, installed: false },
74 sync: { selected: 'none', customUrl: '', url: '', installing: false, installed: false }
75 };
76
77 onMount(async () => {
78 await loadBinaries();
79 await loadAvailableReleases();
80 });
81
82 async function loadAvailableReleases() {
83 loadingReleases = true;
84 try {
85 const result = await fetchReleases($userSigner, $userPubkey);
86 if (result.releases) {
87 availableReleases = result.releases.map(r => ({
88 tag: r.tag,
89 message: r.message || ''
90 }));
91 }
92 } catch (e) {
93 console.error('Failed to fetch releases:', e);
94 } finally {
95 loadingReleases = false;
96 }
97 }
98
99 function handleReleaseSelect() {
100 if (!selectedRelease) return;
101 version = selectedRelease;
102 releaseBaseUrl = `${RELEASES_BASE}/${selectedRelease}`;
103 updateUrls();
104 }
105
106 async function loadBinaries() {
107 $isLoading = true;
108 try {
109 $binariesData = await fetchBinaries($userSigner, $userPubkey);
110 $error = '';
111 } catch (e) {
112 $error = e.message;
113 } finally {
114 $isLoading = false;
115 }
116 }
117
118 function generateUrl(binaryName) {
119 if (!releaseBaseUrl || !version) return '';
120 const verNum = version.replace(/^v/, '');
121 return `${releaseBaseUrl}/${binaryName}-${verNum}-linux-${architecture}`;
122 }
123
124 function updateUrls() {
125 for (const key of Object.keys(categories)) {
126 const cat = categories[key];
127 if (cat.selected !== 'none' && cat.selected !== 'custom') {
128 cat.url = generateUrl(cat.selected);
129 } else if (cat.selected === 'custom') {
130 cat.url = cat.customUrl;
131 } else {
132 cat.url = '';
133 }
134 }
135 categories = categories;
136 }
137
138 function handleSelectionChange(categoryKey) {
139 updateUrls();
140 }
141
142 function setReleaseUrl() {
143 let inputUrl = prompt('Enter release URL (e.g., https://git.mleku.dev/mleku/next.orly.dev/releases/tag/v0.56.1):');
144 if (!inputUrl) return;
145
146 // Normalize the URL
147 let cleanBase = inputUrl.replace(/\/$/, '');
148 if (cleanBase.includes('/releases/tag/')) {
149 cleanBase = cleanBase.replace('/releases/tag/', '/releases/download/');
150 } else if (!cleanBase.includes('/releases/download/')) {
151 const ver = version.trim() || 'v0.56.1';
152 cleanBase = cleanBase + '/releases/download/' + ver;
153 }
154
155 // Extract version from URL
156 const urlParts = cleanBase.split('/');
157 const ver = urlParts[urlParts.length - 1];
158
159 releaseBaseUrl = cleanBase;
160 if (!version) {
161 version = ver;
162 }
163
164 updateUrls();
165 }
166
167 function getBinaryName(categoryKey) {
168 const cat = categories[categoryKey];
169 if (cat.selected === 'custom') {
170 // Try to extract binary name from URL
171 const urlParts = cat.customUrl.split('/');
172 const filename = urlParts[urlParts.length - 1];
173 // Remove version suffix like -0.56.1-linux-amd64
174 return filename.replace(/-[\d.]+-linux-(amd64|arm64)$/, '') || categoryKey;
175 }
176 return cat.selected;
177 }
178
179 function getEffectiveUrl(categoryKey) {
180 const cat = categories[categoryKey];
181 if (cat.selected === 'custom') {
182 return cat.customUrl;
183 }
184 return cat.url;
185 }
186
187 async function installCategory(categoryKey) {
188 const cat = categories[categoryKey];
189 const url = getEffectiveUrl(categoryKey);
190
191 if (!url.trim()) {
192 $error = `URL is required for ${categoryDefs[categoryKey].label}`;
193 return;
194 }
195
196 if (!version.trim()) {
197 $error = 'Version is required';
198 return;
199 }
200
201 cat.installing = true;
202 categories = categories;
203 $error = '';
204
205 try {
206 const binaryName = getBinaryName(categoryKey);
207 const urls = { [binaryName]: url.trim() };
208 const result = await updateBinaries($userSigner, $userPubkey, version.trim(), urls);
209
210 if (result.success) {
211 cat.installed = true;
212
213 if (categoryKey === 'launcher') {
214 launcherUpdated = true;
215 updateResult = {
216 success: true,
217 message: `Downloaded ${binaryName}. Click 'Restart Launcher' to apply.`,
218 downloaded_files: result.downloaded_files
219 };
220 } else {
221 updateResult = {
222 success: true,
223 message: `Downloaded ${binaryName}, restarting service...`,
224 downloaded_files: result.downloaded_files
225 };
226
227 try {
228 await restartService($userSigner, $userPubkey, binaryName);
229 updateResult = {
230 success: true,
231 message: `${binaryName} installed and restart initiated`,
232 downloaded_files: result.downloaded_files
233 };
234 } catch (restartErr) {
235 updateResult = {
236 success: true,
237 message: `Downloaded ${binaryName}, but restart failed: ${restartErr.message}`,
238 downloaded_files: result.downloaded_files
239 };
240 }
241 }
242
243 await loadBinaries();
244 }
245 } catch (e) {
246 $error = `Failed to install ${categoryDefs[categoryKey].label}: ${e.message}`;
247 } finally {
248 cat.installing = false;
249 categories = categories;
250 }
251 }
252
253 async function handleInstallAll() {
254 const urls = {};
255 let hasLauncher = false;
256
257 for (const key of Object.keys(categories)) {
258 const cat = categories[key];
259 if (cat.selected !== 'none') {
260 const url = getEffectiveUrl(key);
261 if (url.trim()) {
262 const binaryName = getBinaryName(key);
263 urls[binaryName] = url.trim();
264 if (key === 'launcher') hasLauncher = true;
265 }
266 }
267 }
268
269 if (!version.trim()) {
270 $error = 'Version is required';
271 return;
272 }
273
274 if (Object.keys(urls).length === 0) {
275 $error = 'No binaries selected for installation';
276 return;
277 }
278
279 isUpdating = true;
280 updateResult = null;
281 launcherUpdated = false;
282 $error = '';
283
284 try {
285 updateResult = await updateBinaries($userSigner, $userPubkey, version.trim(), urls);
286 await loadBinaries();
287
288 if (hasLauncher && updateResult.success) {
289 launcherUpdated = true;
290 }
291 } catch (e) {
292 $error = e.message;
293 } finally {
294 isUpdating = false;
295 }
296 }
297
298 async function handleRollback() {
299 if (!confirm('Are you sure you want to rollback to the previous version?')) {
300 return;
301 }
302
303 isUpdating = true;
304 $error = '';
305
306 try {
307 const result = await rollbackVersion($userSigner, $userPubkey);
308 updateResult = {
309 success: true,
310 message: `Rolled back from ${result.previous_version} to ${result.current_version}. Restart services to apply.`
311 };
312 await loadBinaries();
313 } catch (e) {
314 $error = e.message;
315 } finally {
316 isUpdating = false;
317 }
318 }
319
320 async function handleRestartLauncher() {
321 if (!confirm('Restart the launcher? This will briefly disconnect you.')) {
322 return;
323 }
324 try {
325 await restartServices($userSigner, $userPubkey);
326 updateResult = {
327 success: true,
328 message: 'Launcher restart initiated. The page will reconnect automatically...'
329 };
330 setTimeout(() => {
331 window.location.reload();
332 }, 5000);
333 } catch (e) {
334 $error = e.message;
335 }
336 }
337
338 // React to architecture or version changes
339 $: if (architecture || version) {
340 updateUrls();
341 }
342 </script>
343
344 <div class="update-page">
345 <div class="page-header">
346 <h2>Update Binaries</h2>
347 </div>
348
349 {#if $error}
350 <div class="error-banner">{$error}</div>
351 {/if}
352
353 {#if updateResult?.success}
354 <div class="success-banner">
355 {updateResult.message}
356 {#if updateResult.downloaded_files?.length}
357 <br>Downloaded: {updateResult.downloaded_files.join(', ')}
358 {/if}
359 {#if launcherUpdated}
360 <div class="launcher-restart">
361 <strong>Launcher was updated!</strong>
362 <button class="restart-launcher-btn" on:click={handleRestartLauncher}>
363 Restart Launcher Now
364 </button>
365 </div>
366 {/if}
367 </div>
368 {/if}
369
370 <div class="current-version">
371 <h3>Current Version</h3>
372 <div class="version-info">
373 <span class="version">{$binariesData?.current_version || 'unknown'}</span>
374 <button
375 class="rollback-btn"
376 on:click={handleRollback}
377 disabled={isUpdating || ($binariesData?.available_versions?.length || 0) < 2}
378 >
379 Rollback
380 </button>
381 </div>
382 </div>
383
384 <div class="update-form">
385 <h3>Install New Version</h3>
386
387 <div class="release-settings">
388 <div class="form-row">
389 <div class="form-group">
390 <label for="release-select">Official Release</label>
391 <select
392 id="release-select"
393 bind:value={selectedRelease}
394 on:change={handleReleaseSelect}
395 disabled={isUpdating || loadingReleases}
396 >
397 <option value="">
398 {loadingReleases ? 'Loading...' : '-- Select release --'}
399 </option>
400 {#each availableReleases as release}
401 <option value={release.tag}>
402 {release.tag}{release.message ? ` - ${release.message.slice(0, 40)}` : ''}
403 </option>
404 {/each}
405 </select>
406 </div>
407 <div class="form-group">
408 <label for="arch">Architecture</label>
409 <select id="arch" bind:value={architecture} disabled={isUpdating}>
410 <option value="amd64">AMD64 (x86_64)</option>
411 <option value="arm64">ARM64 (aarch64)</option>
412 </select>
413 </div>
414 </div>
415
416 <div class="form-row custom-release-row">
417 <div class="form-group">
418 <label for="version">Or Custom Version</label>
419 <input
420 type="text"
421 id="version"
422 bind:value={version}
423 placeholder="v0.56.1"
424 disabled={isUpdating}
425 />
426 </div>
427 <div class="form-group">
428 <label> </label>
429 <button class="helper-btn fill-btn" on:click={setReleaseUrl} disabled={isUpdating}>
430 Set Custom URL
431 </button>
432 </div>
433 </div>
434
435 {#if releaseBaseUrl}
436 <div class="release-url-display">
437 <span class="release-label">Release:</span>
438 <code>{releaseBaseUrl}</code>
439 </div>
440 {/if}
441 </div>
442
443 <div class="categories">
444 {#each Object.entries(categoryDefs) as [key, def]}
445 <div class="category-row">
446 <div class="category-header">
447 <span class="category-label">{def.label}</span>
448 {#if !def.required}
449 <span class="optional-badge">optional</span>
450 {/if}
451 </div>
452 <div class="category-controls">
453 <select
454 bind:value={categories[key].selected}
455 on:change={() => handleSelectionChange(key)}
456 disabled={isUpdating || categories[key].installing}
457 >
458 {#each def.options as opt}
459 <option value={opt.value}>{opt.label}</option>
460 {/each}
461 </select>
462
463 {#if categories[key].selected === 'custom'}
464 <input
465 type="text"
466 class="custom-url"
467 bind:value={categories[key].customUrl}
468 on:input={() => { categories[key].url = categories[key].customUrl; }}
469 placeholder="https://... (custom binary URL)"
470 disabled={isUpdating || categories[key].installing}
471 />
472 {:else if categories[key].selected !== 'none'}
473 <input
474 type="text"
475 class="url-display"
476 value={categories[key].url}
477 readonly
478 placeholder="Set release URL above"
479 />
480 {/if}
481
482 {#if categories[key].selected !== 'none'}
483 <button
484 class="install-btn"
485 on:click={() => installCategory(key)}
486 disabled={isUpdating || categories[key].installing || !getEffectiveUrl(key)}
487 title="Download and install this component"
488 >
489 {#if categories[key].installing}
490 ...
491 {:else if categories[key].installed}
492 Done
493 {:else}
494 Install
495 {/if}
496 </button>
497 {/if}
498 </div>
499 </div>
500 {/each}
501 </div>
502
503 <button
504 class="update-btn"
505 on:click={handleInstallAll}
506 disabled={isUpdating}
507 >
508 {isUpdating ? 'Installing...' : 'Install All Selected'}
509 </button>
510 </div>
511
512 {#if $binariesData?.available_versions?.length}
513 <div class="versions-list">
514 <h3>Installed Versions</h3>
515 <table>
516 <thead>
517 <tr>
518 <th>Version</th>
519 <th>Installed</th>
520 <th>Binaries</th>
521 <th>Status</th>
522 </tr>
523 </thead>
524 <tbody>
525 {#each $binariesData.available_versions as ver}
526 <tr class:current={ver.is_current}>
527 <td class="version-cell">{ver.version}</td>
528 <td>{new Date(ver.installed_at).toLocaleString()}</td>
529 <td>{ver.binaries?.length || 0} files</td>
530 <td>
531 {#if ver.is_current}
532 <span class="current-badge">Current</span>
533 {/if}
534 </td>
535 </tr>
536 {/each}
537 </tbody>
538 </table>
539 </div>
540 {/if}
541 </div>
542
543 <style>
544 .update-page {
545 padding: 20px 0;
546 }
547
548 .page-header {
549 margin-bottom: 24px;
550 }
551
552 .page-header h2 {
553 font-size: 1.5rem;
554 color: var(--text-color);
555 }
556
557 .error-banner {
558 background: #ffebee;
559 color: #c62828;
560 padding: 12px 16px;
561 border-radius: 6px;
562 margin-bottom: 20px;
563 border: 1px solid #ffcdd2;
564 }
565
566 .success-banner {
567 background: #e8f5e9;
568 color: #2e7d32;
569 padding: 12px 16px;
570 border-radius: 6px;
571 margin-bottom: 20px;
572 border: 1px solid #c8e6c9;
573 }
574
575 .launcher-restart {
576 margin-top: 12px;
577 padding-top: 12px;
578 border-top: 1px solid #c8e6c9;
579 display: flex;
580 align-items: center;
581 gap: 12px;
582 }
583
584 .restart-launcher-btn {
585 padding: 8px 16px;
586 background: #1976d2;
587 border: none;
588 color: white;
589 border-radius: 4px;
590 cursor: pointer;
591 font-weight: 500;
592 }
593
594 .restart-launcher-btn:hover {
595 background: #1565c0;
596 }
597
598 .current-version,
599 .update-form,
600 .versions-list {
601 background: var(--card-bg);
602 border: 1px solid var(--border-color);
603 border-radius: 8px;
604 padding: 20px;
605 margin-bottom: 24px;
606 }
607
608 h3 {
609 font-size: 1.1rem;
610 color: var(--text-color);
611 margin-bottom: 16px;
612 }
613
614 .version-info {
615 display: flex;
616 align-items: center;
617 justify-content: space-between;
618 }
619
620 .version {
621 font-size: 1.5rem;
622 font-weight: 600;
623 font-family: monospace;
624 color: var(--text-color);
625 }
626
627 .rollback-btn {
628 padding: 8px 16px;
629 background: var(--warning);
630 border: none;
631 color: white;
632 border-radius: 4px;
633 cursor: pointer;
634 }
635
636 .rollback-btn:hover:not(:disabled) {
637 opacity: 0.9;
638 }
639
640 .rollback-btn:disabled {
641 opacity: 0.5;
642 cursor: not-allowed;
643 }
644
645 .release-settings {
646 margin-bottom: 24px;
647 padding-bottom: 20px;
648 border-bottom: 1px solid var(--border-color);
649 }
650
651 .form-row {
652 display: flex;
653 gap: 16px;
654 align-items: flex-end;
655 }
656
657 .form-group {
658 flex: 1;
659 }
660
661 .form-group label {
662 display: block;
663 font-size: 0.85rem;
664 color: var(--text-color);
665 margin-bottom: 6px;
666 font-weight: 500;
667 }
668
669 .form-group input[type="text"],
670 .form-group select {
671 width: 100%;
672 padding: 8px 12px;
673 border: 1px solid var(--border-color);
674 border-radius: 4px;
675 font-size: 0.9rem;
676 background: var(--bg-color);
677 color: var(--text-color);
678 }
679
680 .form-group input:focus,
681 .form-group select:focus {
682 outline: none;
683 border-color: var(--primary);
684 }
685
686 .helper-btn {
687 padding: 8px 16px;
688 font-size: 0.85rem;
689 background: var(--primary);
690 border: none;
691 border-radius: 4px;
692 color: white;
693 cursor: pointer;
694 white-space: nowrap;
695 }
696
697 .helper-btn:hover:not(:disabled) {
698 opacity: 0.9;
699 }
700
701 .helper-btn:disabled {
702 opacity: 0.5;
703 cursor: not-allowed;
704 }
705
706 .fill-btn {
707 width: 100%;
708 }
709
710 .custom-release-row {
711 margin-top: 12px;
712 padding-top: 12px;
713 border-top: 1px dashed var(--border-color);
714 }
715
716 .release-url-display {
717 margin-top: 12px;
718 padding: 8px 12px;
719 background: var(--bg-color);
720 border-radius: 4px;
721 font-size: 0.8rem;
722 display: flex;
723 align-items: center;
724 gap: 8px;
725 }
726
727 .release-label {
728 color: var(--muted-color);
729 }
730
731 .release-url-display code {
732 color: var(--text-color);
733 word-break: break-all;
734 }
735
736 .categories {
737 display: flex;
738 flex-direction: column;
739 gap: 12px;
740 margin-bottom: 20px;
741 }
742
743 .category-row {
744 padding: 12px;
745 background: var(--bg-color);
746 border-radius: 6px;
747 border: 1px solid var(--border-color);
748 }
749
750 .category-header {
751 display: flex;
752 align-items: center;
753 gap: 8px;
754 margin-bottom: 8px;
755 }
756
757 .category-label {
758 font-weight: 600;
759 color: var(--text-color);
760 font-size: 0.95rem;
761 }
762
763 .optional-badge {
764 font-size: 0.7rem;
765 color: var(--muted-color);
766 background: var(--border-color);
767 padding: 2px 6px;
768 border-radius: 3px;
769 }
770
771 .category-controls {
772 display: flex;
773 gap: 8px;
774 align-items: center;
775 }
776
777 .category-controls select {
778 min-width: 140px;
779 padding: 6px 10px;
780 border: 1px solid var(--border-color);
781 border-radius: 4px;
782 font-size: 0.85rem;
783 background: var(--card-bg);
784 color: var(--text-color);
785 }
786
787 .category-controls .custom-url,
788 .category-controls .url-display {
789 flex: 1;
790 padding: 6px 10px;
791 border: 1px solid var(--border-color);
792 border-radius: 4px;
793 font-size: 0.8rem;
794 font-family: monospace;
795 background: var(--card-bg);
796 color: var(--text-color);
797 }
798
799 .category-controls .url-display {
800 background: var(--bg-color);
801 color: var(--muted-color);
802 }
803
804 .install-btn {
805 padding: 6px 14px;
806 background: var(--primary);
807 border: none;
808 color: white;
809 border-radius: 4px;
810 cursor: pointer;
811 font-size: 0.8rem;
812 min-width: 70px;
813 }
814
815 .install-btn:hover:not(:disabled) {
816 opacity: 0.9;
817 }
818
819 .install-btn:disabled {
820 opacity: 0.5;
821 cursor: not-allowed;
822 }
823
824 .update-btn {
825 width: 100%;
826 padding: 12px;
827 background: var(--primary);
828 border: none;
829 color: white;
830 border-radius: 6px;
831 font-size: 1rem;
832 cursor: pointer;
833 }
834
835 .update-btn:hover:not(:disabled) {
836 background: var(--primary-hover);
837 }
838
839 .update-btn:disabled {
840 opacity: 0.5;
841 cursor: not-allowed;
842 }
843
844 table {
845 width: 100%;
846 border-collapse: collapse;
847 }
848
849 th, td {
850 padding: 10px 12px;
851 text-align: left;
852 border-bottom: 1px solid var(--border-color);
853 }
854
855 th {
856 font-size: 0.85rem;
857 color: var(--muted-color);
858 font-weight: 500;
859 }
860
861 td {
862 font-size: 0.9rem;
863 color: var(--text-color);
864 }
865
866 .version-cell {
867 font-family: monospace;
868 }
869
870 tr.current {
871 background: rgba(0, 188, 212, 0.1);
872 }
873
874 .current-badge {
875 background: var(--primary);
876 color: white;
877 padding: 2px 8px;
878 border-radius: 4px;
879 font-size: 0.75rem;
880 }
881
882 @media (max-width: 768px) {
883 .form-row {
884 flex-direction: column;
885 gap: 12px;
886 }
887
888 .category-controls {
889 flex-wrap: wrap;
890 }
891
892 .category-controls select {
893 min-width: 100%;
894 }
895
896 .category-controls .custom-url,
897 .category-controls .url-display {
898 min-width: 100%;
899 }
900 }
901 </style>
902