groove-dev 0.27.15 → 0.27.18
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/CLAUDE.md +0 -10
- package/README.md +37 -1
- package/developerID_application.cer +0 -0
- package/node_modules/@groove-dev/daemon/src/api.js +586 -67
- package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
- package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
- package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
- package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
- package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
- package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
- package/node_modules/@groove-dev/daemon/src/index.js +172 -19
- package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
- package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
- package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
- package/node_modules/@groove-dev/daemon/src/process.js +140 -23
- package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +14 -0
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
- package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
- package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
- package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
- package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
- package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
- package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
- package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
- package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
- package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
- package/node_modules/@groove-dev/gui/dist/assets/index-Bg6_D2xK.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-D3rvwTHD.js +8607 -0
- package/node_modules/@groove-dev/gui/dist/index.html +3 -2
- package/node_modules/@groove-dev/gui/index.html +1 -0
- package/node_modules/@groove-dev/gui/src/app.css +7 -0
- package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
- package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
- package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
- package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
- package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
- package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +15 -3
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +11 -1
- package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
- package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
- package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
- package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
- package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
- package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
- package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
- package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
- package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
- package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
- package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
- package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
- package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
- package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +388 -63
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
- package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
- package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +35 -134
- package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
- package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
- package/package.json +1 -1
- package/packages/daemon/src/api.js +586 -67
- package/packages/daemon/src/classifier.js +24 -0
- package/packages/daemon/src/credentials.js +12 -2
- package/packages/daemon/src/federation/ambassador.js +204 -0
- package/packages/daemon/src/federation/connection.js +359 -0
- package/packages/daemon/src/federation/contracts.js +112 -0
- package/packages/daemon/src/federation/whitelist.js +190 -0
- package/packages/daemon/src/federation.js +166 -7
- package/packages/daemon/src/index.js +172 -19
- package/packages/daemon/src/introducer.js +52 -7
- package/packages/daemon/src/journalist.js +46 -1
- package/packages/daemon/src/memory.js +36 -16
- package/packages/daemon/src/process.js +140 -23
- package/packages/daemon/src/providers/base.js +1 -0
- package/packages/daemon/src/providers/claude-code.js +14 -0
- package/packages/daemon/src/providers/codex.js +124 -28
- package/packages/daemon/src/providers/gemini.js +104 -17
- package/packages/daemon/src/providers/index.js +17 -0
- package/packages/daemon/src/registry.js +10 -1
- package/packages/daemon/src/rotator.js +93 -30
- package/packages/daemon/src/skills.js +33 -3
- package/packages/daemon/src/terminal-pty.js +9 -1
- package/packages/daemon/src/tool-executor.js +11 -5
- package/packages/daemon/src/toys.js +69 -0
- package/packages/daemon/src/tunnel-manager.js +24 -5
- package/packages/daemon/templates/toys-catalog.json +242 -0
- package/packages/gui/dist/assets/index-Bg6_D2xK.css +1 -0
- package/packages/gui/dist/assets/index-D3rvwTHD.js +8607 -0
- package/packages/gui/dist/index.html +3 -2
- package/packages/gui/index.html +1 -0
- package/packages/gui/src/app.css +7 -0
- package/packages/gui/src/app.jsx +37 -10
- package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
- package/packages/gui/src/components/agents/agent-config.jsx +11 -6
- package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
- package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
- package/packages/gui/src/components/editor/code-editor.jsx +33 -2
- package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
- package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
- package/packages/gui/src/components/editor/goto-line.jsx +35 -0
- package/packages/gui/src/components/editor/terminal.jsx +12 -6
- package/packages/gui/src/components/layout/activity-bar.jsx +15 -3
- package/packages/gui/src/components/layout/app-shell.jsx +0 -1
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
- package/packages/gui/src/components/layout/command-palette.jsx +6 -2
- package/packages/gui/src/components/layout/status-bar.jsx +11 -1
- package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
- package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
- package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
- package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
- package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
- package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
- package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
- package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
- package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
- package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
- package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
- package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
- package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
- package/packages/gui/src/components/settings/server-detail.jsx +310 -0
- package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
- package/packages/gui/src/components/settings/server-list.jsx +59 -0
- package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
- package/packages/gui/src/components/toys/toy-card.jsx +78 -0
- package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
- package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
- package/packages/gui/src/components/ui/toast.jsx +2 -2
- package/packages/gui/src/lib/electron.js +15 -0
- package/packages/gui/src/lib/format.js +1 -0
- package/packages/gui/src/stores/groove.js +388 -63
- package/packages/gui/src/views/agents.jsx +148 -42
- package/packages/gui/src/views/editor.jsx +92 -2
- package/packages/gui/src/views/federation.jsx +37 -0
- package/packages/gui/src/views/marketplace.jsx +2 -42
- package/packages/gui/src/views/settings.jsx +35 -134
- package/packages/gui/src/views/subscription-panel.jsx +327 -0
- package/packages/gui/src/views/teams.jsx +3 -3
- package/packages/gui/src/views/toys.jsx +162 -0
- package/plans/chat-persistence-refactor.md +154 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
- package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
- package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useGrooveStore } from '../../stores/groove';
|
|
4
|
+
import { Button } from '../ui/button';
|
|
5
|
+
import { Badge } from '../ui/badge';
|
|
6
|
+
import { StatusDot } from '../ui/status-dot';
|
|
7
|
+
import { cn } from '../../lib/cn';
|
|
8
|
+
import {
|
|
9
|
+
Server, Link2, Send, Unplug, Eye, Plus, Wifi,
|
|
10
|
+
} from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
function connectionBadge(state) {
|
|
13
|
+
switch (state) {
|
|
14
|
+
case 'established': return <Badge variant="success" className="text-2xs gap-1"><StatusDot status="running" size="sm" /> Connected</Badge>;
|
|
15
|
+
case 'connecting': return <Badge variant="warning" className="text-2xs" dot="pulse">Connecting</Badge>;
|
|
16
|
+
case 'error': return <Badge variant="danger" className="text-2xs">Error</Badge>;
|
|
17
|
+
default: return <Badge variant="default" className="text-2xs">Unknown</Badge>;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function FederationPeers({ onOpenWizard }) {
|
|
22
|
+
const connections = useGrooveStore((s) => s.federation.connections);
|
|
23
|
+
const peers = useGrooveStore((s) => s.federation.peers);
|
|
24
|
+
const sendPouch = useGrooveStore((s) => s.sendPouch);
|
|
25
|
+
const [sendingTo, setSendingTo] = useState(null);
|
|
26
|
+
|
|
27
|
+
const allPeers = peers.length > 0 ? peers : connections;
|
|
28
|
+
|
|
29
|
+
async function handleSendPouch(peerId) {
|
|
30
|
+
setSendingTo(peerId);
|
|
31
|
+
try {
|
|
32
|
+
await sendPouch(peerId, { type: 'ping' });
|
|
33
|
+
} catch {}
|
|
34
|
+
setSendingTo(null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-3">
|
|
39
|
+
<div className="flex items-center justify-between">
|
|
40
|
+
<div className="flex items-center gap-2">
|
|
41
|
+
<Server size={12} className="text-accent" />
|
|
42
|
+
<span className="text-xs font-semibold text-text-1 font-sans">Connected Peers</span>
|
|
43
|
+
{allPeers.length > 0 && (
|
|
44
|
+
<Badge variant="success" className="text-2xs">{allPeers.length}</Badge>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
<Button size="sm" variant="primary" onClick={onOpenWizard} className="h-7 text-2xs gap-1.5">
|
|
48
|
+
<Plus size={11} />
|
|
49
|
+
Pair New Peer
|
|
50
|
+
</Button>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{allPeers.length === 0 ? (
|
|
54
|
+
<div className="rounded-lg border border-dashed border-border-subtle bg-surface-1/50 px-4 py-8 text-center">
|
|
55
|
+
<Link2 size={20} className="text-text-4 mx-auto mb-2" />
|
|
56
|
+
<p className="text-xs text-text-3 font-sans mb-1">No peers connected</p>
|
|
57
|
+
<p className="text-2xs text-text-4 font-sans mb-3">Pair with a remote Groove daemon to share agents and coordinate work.</p>
|
|
58
|
+
<Button size="sm" variant="outline" onClick={onOpenWizard} className="h-7 text-2xs gap-1.5">
|
|
59
|
+
<Plus size={11} />
|
|
60
|
+
Pair Your First Peer
|
|
61
|
+
</Button>
|
|
62
|
+
</div>
|
|
63
|
+
) : (
|
|
64
|
+
<div className="grid gap-2">
|
|
65
|
+
{allPeers.map((peer) => {
|
|
66
|
+
const id = peer.peerId || peer.ip || peer.id;
|
|
67
|
+
const name = peer.name || peer.peerId || 'Unknown Peer';
|
|
68
|
+
const state = peer.state || peer.status || 'unknown';
|
|
69
|
+
const ip = peer.ip || peer.address || '';
|
|
70
|
+
const latency = peer.latency;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div key={id} className="rounded-md border border-border-subtle bg-surface-1 p-3">
|
|
74
|
+
<div className="flex items-start gap-3">
|
|
75
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 flex-shrink-0 mt-0.5">
|
|
76
|
+
<Server size={14} className="text-accent" />
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex-1 min-w-0">
|
|
79
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
80
|
+
<span className="text-xs font-semibold text-text-0 font-sans truncate">{name}</span>
|
|
81
|
+
{connectionBadge(state)}
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex items-center gap-3 text-2xs text-text-3">
|
|
84
|
+
{ip && (
|
|
85
|
+
<span className="font-mono truncate">{ip}{peer.port ? `:${peer.port}` : ''}</span>
|
|
86
|
+
)}
|
|
87
|
+
{latency != null && (
|
|
88
|
+
<span className="flex items-center gap-1 font-sans">
|
|
89
|
+
<Wifi size={9} className={cn(
|
|
90
|
+
latency < 100 ? 'text-success' : latency < 300 ? 'text-warning' : 'text-danger'
|
|
91
|
+
)} />
|
|
92
|
+
{latency}ms
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div className="flex items-center gap-1.5 mt-3 pt-2.5 border-t border-border-subtle">
|
|
100
|
+
<Button size="sm" variant="ghost" className="h-6 text-2xs gap-1 text-text-2">
|
|
101
|
+
<Eye size={10} />
|
|
102
|
+
Details
|
|
103
|
+
</Button>
|
|
104
|
+
<Button
|
|
105
|
+
size="sm"
|
|
106
|
+
variant="ghost"
|
|
107
|
+
className="h-6 text-2xs gap-1 text-text-2"
|
|
108
|
+
disabled={sendingTo === id}
|
|
109
|
+
onClick={() => handleSendPouch(id)}
|
|
110
|
+
>
|
|
111
|
+
<Send size={10} />
|
|
112
|
+
Send Pouch
|
|
113
|
+
</Button>
|
|
114
|
+
<Button size="sm" variant="ghost" className="h-6 text-2xs gap-1 text-danger/70 hover:text-danger ml-auto">
|
|
115
|
+
<Unplug size={10} />
|
|
116
|
+
Disconnect
|
|
117
|
+
</Button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useGrooveStore } from '../../stores/groove';
|
|
4
|
+
import { Dialog, DialogContent } from '../ui/dialog';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
import { Input } from '../ui/input';
|
|
7
|
+
import { Badge } from '../ui/badge';
|
|
8
|
+
import { StatusDot } from '../ui/status-dot';
|
|
9
|
+
import { cn } from '../../lib/cn';
|
|
10
|
+
import {
|
|
11
|
+
Link2, Loader2, Check, Server, ArrowRight, Wifi, AlertCircle,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
const STEPS = [
|
|
15
|
+
{ label: 'Connect', icon: Link2 },
|
|
16
|
+
{ label: 'Verify', icon: Server },
|
|
17
|
+
{ label: 'Paired', icon: Check },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function StepIndicator({ current }) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center justify-center gap-2 mb-6">
|
|
23
|
+
{STEPS.map((step, i) => {
|
|
24
|
+
const done = i < current;
|
|
25
|
+
const active = i === current;
|
|
26
|
+
const Icon = step.icon;
|
|
27
|
+
return (
|
|
28
|
+
<div key={step.label} className="flex items-center gap-2">
|
|
29
|
+
{i > 0 && (
|
|
30
|
+
<div className={cn(
|
|
31
|
+
'w-8 h-px',
|
|
32
|
+
done ? 'bg-accent' : 'bg-border-subtle',
|
|
33
|
+
)} />
|
|
34
|
+
)}
|
|
35
|
+
<div className="flex items-center gap-1.5">
|
|
36
|
+
<div className={cn(
|
|
37
|
+
'w-6 h-6 rounded-full flex items-center justify-center transition-colors',
|
|
38
|
+
done ? 'bg-accent text-white' : active ? 'bg-accent/15 text-accent border border-accent/30' : 'bg-surface-3 text-text-4',
|
|
39
|
+
)}>
|
|
40
|
+
{done ? <Check size={12} /> : <Icon size={12} />}
|
|
41
|
+
</div>
|
|
42
|
+
<span className={cn(
|
|
43
|
+
'text-2xs font-semibold font-sans',
|
|
44
|
+
active ? 'text-text-0' : done ? 'text-accent' : 'text-text-4',
|
|
45
|
+
)}>
|
|
46
|
+
{step.label}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function FederationWizard({ open, onOpenChange }) {
|
|
57
|
+
const addToWhitelist = useGrooveStore((s) => s.addToWhitelist);
|
|
58
|
+
const fetchFederationStatus = useGrooveStore((s) => s.fetchFederationStatus);
|
|
59
|
+
|
|
60
|
+
const [step, setStep] = useState(0);
|
|
61
|
+
const [ip, setIp] = useState('');
|
|
62
|
+
const [port, setPort] = useState('31415');
|
|
63
|
+
const [name, setName] = useState('');
|
|
64
|
+
const [testing, setTesting] = useState(false);
|
|
65
|
+
const [testResult, setTestResult] = useState(null);
|
|
66
|
+
const [pairing, setPairing] = useState(false);
|
|
67
|
+
const [error, setError] = useState(null);
|
|
68
|
+
const [remoteInfo, setRemoteInfo] = useState(null);
|
|
69
|
+
|
|
70
|
+
function reset() {
|
|
71
|
+
setStep(0);
|
|
72
|
+
setIp('');
|
|
73
|
+
setPort('31415');
|
|
74
|
+
setName('');
|
|
75
|
+
setTesting(false);
|
|
76
|
+
setTestResult(null);
|
|
77
|
+
setPairing(false);
|
|
78
|
+
setError(null);
|
|
79
|
+
setRemoteInfo(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleOpenChange(open) {
|
|
83
|
+
if (!open) reset();
|
|
84
|
+
onOpenChange(open);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function testReachability() {
|
|
88
|
+
setTesting(true);
|
|
89
|
+
setTestResult(null);
|
|
90
|
+
setError(null);
|
|
91
|
+
try {
|
|
92
|
+
const target = `${ip.trim()}:${port || '31415'}`;
|
|
93
|
+
const res = await fetch(`http://localhost:31415/api/federation/test?target=${encodeURIComponent(target)}`);
|
|
94
|
+
if (res.ok) {
|
|
95
|
+
const data = await res.json();
|
|
96
|
+
setTestResult('reachable');
|
|
97
|
+
setRemoteInfo(data);
|
|
98
|
+
} else {
|
|
99
|
+
setTestResult('unreachable');
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
setTestResult('unreachable');
|
|
103
|
+
}
|
|
104
|
+
setTesting(false);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handlePair() {
|
|
108
|
+
setPairing(true);
|
|
109
|
+
setError(null);
|
|
110
|
+
try {
|
|
111
|
+
await addToWhitelist(ip.trim(), parseInt(port, 10) || 31415, name.trim() || undefined);
|
|
112
|
+
await fetchFederationStatus();
|
|
113
|
+
setStep(2);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
setError(err.message || 'Failed to pair with peer');
|
|
116
|
+
}
|
|
117
|
+
setPairing(false);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
122
|
+
<DialogContent title="Pair New Peer" description="Connect to a remote Groove daemon">
|
|
123
|
+
<div className="px-5 py-5">
|
|
124
|
+
<StepIndicator current={step} />
|
|
125
|
+
|
|
126
|
+
{step === 0 && (
|
|
127
|
+
<div className="space-y-4">
|
|
128
|
+
<Input
|
|
129
|
+
label="Friendly Name"
|
|
130
|
+
placeholder="e.g. Production Server"
|
|
131
|
+
value={name}
|
|
132
|
+
onChange={(e) => setName(e.target.value)}
|
|
133
|
+
/>
|
|
134
|
+
<div className="grid grid-cols-[1fr,100px] gap-2">
|
|
135
|
+
<Input
|
|
136
|
+
label="IP / Hostname"
|
|
137
|
+
placeholder="192.168.1.100"
|
|
138
|
+
value={ip}
|
|
139
|
+
onChange={(e) => setIp(e.target.value)}
|
|
140
|
+
mono
|
|
141
|
+
/>
|
|
142
|
+
<Input
|
|
143
|
+
label="Port"
|
|
144
|
+
placeholder="31415"
|
|
145
|
+
value={port}
|
|
146
|
+
onChange={(e) => setPort(e.target.value)}
|
|
147
|
+
mono
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{testResult === 'reachable' && (
|
|
152
|
+
<div className="flex items-center gap-2 rounded-md bg-success/10 border border-success/20 px-3 py-2">
|
|
153
|
+
<Wifi size={13} className="text-success" />
|
|
154
|
+
<span className="text-2xs text-success font-sans font-medium">Peer is reachable</span>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
{testResult === 'unreachable' && (
|
|
158
|
+
<div className="flex items-center gap-2 rounded-md bg-danger/10 border border-danger/20 px-3 py-2">
|
|
159
|
+
<AlertCircle size={13} className="text-danger" />
|
|
160
|
+
<span className="text-2xs text-danger font-sans font-medium">Could not reach peer — check IP and port</span>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<div className="flex items-center gap-2 pt-2">
|
|
165
|
+
<Button
|
|
166
|
+
size="sm"
|
|
167
|
+
variant="ghost"
|
|
168
|
+
disabled={!ip.trim() || testing}
|
|
169
|
+
onClick={testReachability}
|
|
170
|
+
className="h-8 text-xs gap-1.5"
|
|
171
|
+
>
|
|
172
|
+
{testing ? <Loader2 size={12} className="animate-spin" /> : <Wifi size={12} />}
|
|
173
|
+
Test Reachability
|
|
174
|
+
</Button>
|
|
175
|
+
<Button
|
|
176
|
+
size="sm"
|
|
177
|
+
variant="primary"
|
|
178
|
+
disabled={!ip.trim()}
|
|
179
|
+
onClick={() => setStep(1)}
|
|
180
|
+
className="h-8 text-xs gap-1.5 ml-auto"
|
|
181
|
+
>
|
|
182
|
+
Continue
|
|
183
|
+
<ArrowRight size={12} />
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{step === 1 && (
|
|
190
|
+
<div className="space-y-4">
|
|
191
|
+
<div className="rounded-md border border-border-subtle bg-surface-0 p-4">
|
|
192
|
+
<div className="flex items-center gap-3 mb-3">
|
|
193
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent/10">
|
|
194
|
+
<Server size={16} className="text-accent" />
|
|
195
|
+
</div>
|
|
196
|
+
<div>
|
|
197
|
+
<p className="text-sm font-semibold text-text-0 font-sans">{name || 'Remote Peer'}</p>
|
|
198
|
+
<p className="text-2xs text-text-3 font-mono">{ip}:{port || '31415'}</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{remoteInfo && (
|
|
203
|
+
<div className="space-y-1.5 pt-2 border-t border-border-subtle">
|
|
204
|
+
{remoteInfo.version && (
|
|
205
|
+
<div className="flex items-center justify-between text-2xs font-sans">
|
|
206
|
+
<span className="text-text-3">Version</span>
|
|
207
|
+
<span className="text-text-1 font-mono">{remoteInfo.version}</span>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
{remoteInfo.peerId && (
|
|
211
|
+
<div className="flex items-center justify-between text-2xs font-sans">
|
|
212
|
+
<span className="text-text-3">Peer ID</span>
|
|
213
|
+
<span className="text-text-1 font-mono truncate max-w-40">{remoteInfo.peerId}</span>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
{remoteInfo.agents != null && (
|
|
217
|
+
<div className="flex items-center justify-between text-2xs font-sans">
|
|
218
|
+
<span className="text-text-3">Active Agents</span>
|
|
219
|
+
<span className="text-text-1">{remoteInfo.agents}</span>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{error && (
|
|
227
|
+
<div className="flex items-center gap-2 rounded-md bg-danger/10 border border-danger/20 px-3 py-2">
|
|
228
|
+
<AlertCircle size={13} className="text-danger" />
|
|
229
|
+
<span className="text-2xs text-danger font-sans">{error}</span>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
<div className="flex items-center gap-2 pt-2">
|
|
234
|
+
<Button size="sm" variant="ghost" onClick={() => setStep(0)} className="h-8 text-xs">
|
|
235
|
+
Back
|
|
236
|
+
</Button>
|
|
237
|
+
<Button
|
|
238
|
+
size="sm"
|
|
239
|
+
variant="primary"
|
|
240
|
+
disabled={pairing}
|
|
241
|
+
onClick={handlePair}
|
|
242
|
+
className="h-8 text-xs gap-1.5 ml-auto"
|
|
243
|
+
>
|
|
244
|
+
{pairing ? <Loader2 size={12} className="animate-spin" /> : <Link2 size={12} />}
|
|
245
|
+
Confirm Pairing
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{step === 2 && (
|
|
252
|
+
<div className="text-center space-y-4">
|
|
253
|
+
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-success/15 mx-auto">
|
|
254
|
+
<Check size={20} className="text-success" />
|
|
255
|
+
</div>
|
|
256
|
+
<div>
|
|
257
|
+
<h3 className="text-sm font-semibold text-text-0 font-sans mb-1">Peer Paired Successfully</h3>
|
|
258
|
+
<p className="text-2xs text-text-3 font-sans">
|
|
259
|
+
{name || ip} has been added to your federation whitelist.
|
|
260
|
+
</p>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div className="rounded-md border border-border-subtle bg-surface-0 p-3 text-left">
|
|
264
|
+
<div className="flex items-center gap-3">
|
|
265
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10">
|
|
266
|
+
<Server size={14} className="text-accent" />
|
|
267
|
+
</div>
|
|
268
|
+
<div className="flex-1 min-w-0">
|
|
269
|
+
<span className="text-xs font-semibold text-text-0 font-sans block truncate">{name || 'Remote Peer'}</span>
|
|
270
|
+
<span className="text-2xs text-text-3 font-mono">{ip}:{port || '31415'}</span>
|
|
271
|
+
</div>
|
|
272
|
+
<Badge variant="success" className="text-2xs gap-1">
|
|
273
|
+
<StatusDot status="running" size="sm" />
|
|
274
|
+
Whitelisted
|
|
275
|
+
</Badge>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<Button
|
|
280
|
+
size="sm"
|
|
281
|
+
variant="primary"
|
|
282
|
+
onClick={() => handleOpenChange(false)}
|
|
283
|
+
className="h-8 text-xs"
|
|
284
|
+
>
|
|
285
|
+
Done
|
|
286
|
+
</Button>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
</DialogContent>
|
|
291
|
+
</Dialog>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -4,15 +4,19 @@ import { useGrooveStore } from '../../stores/groove';
|
|
|
4
4
|
import { cn } from '../../lib/cn';
|
|
5
5
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
6
6
|
import {
|
|
7
|
-
Server, Radio, ExternalLink, Loader2, X, Plus,
|
|
7
|
+
Server, Radio, ExternalLink, Loader2, X, Plus, ArrowLeft,
|
|
8
8
|
} from 'lucide-react';
|
|
9
9
|
import { StatusDot } from '../ui/status-dot';
|
|
10
|
+
import { Button } from '../ui/button';
|
|
11
|
+
import { SSHWizard } from './ssh-wizard';
|
|
10
12
|
|
|
11
13
|
export function QuickConnect() {
|
|
12
14
|
const open = useGrooveStore((s) => s.quickConnectOpen);
|
|
13
15
|
const toggle = useGrooveStore((s) => s.toggleQuickConnect);
|
|
14
16
|
const savedTunnels = useGrooveStore((s) => s.savedTunnels);
|
|
17
|
+
const addToast = useGrooveStore((s) => s.addToast);
|
|
15
18
|
const [connectingId, setConnectingId] = useState(null);
|
|
19
|
+
const [showWizard, setShowWizard] = useState(false);
|
|
16
20
|
|
|
17
21
|
if (!open) return null;
|
|
18
22
|
|
|
@@ -32,9 +36,14 @@ export function QuickConnect() {
|
|
|
32
36
|
toggle();
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
function handleClose() {
|
|
40
|
+
setShowWizard(false);
|
|
41
|
+
toggle();
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
return (
|
|
36
45
|
<>
|
|
37
|
-
<div className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" onClick={
|
|
46
|
+
<div className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" onClick={handleClose} />
|
|
38
47
|
|
|
39
48
|
<AnimatePresence>
|
|
40
49
|
<motion.div
|
|
@@ -42,85 +51,119 @@ export function QuickConnect() {
|
|
|
42
51
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
43
52
|
exit={{ opacity: 0, y: -10, scale: 0.98 }}
|
|
44
53
|
transition={{ duration: 0.15 }}
|
|
45
|
-
className=
|
|
54
|
+
className={cn(
|
|
55
|
+
'fixed top-[15%] left-1/2 -translate-x-1/2 z-50 bg-surface-1 border border-border rounded-lg shadow-2xl overflow-hidden',
|
|
56
|
+
showWizard ? 'w-[520px]' : 'w-[400px]',
|
|
57
|
+
)}
|
|
46
58
|
>
|
|
47
59
|
{/* Header */}
|
|
48
60
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
|
|
49
61
|
<div className="flex items-center gap-2">
|
|
62
|
+
{showWizard && (
|
|
63
|
+
<button
|
|
64
|
+
onClick={() => setShowWizard(false)}
|
|
65
|
+
className="p-1 -ml-1 text-text-4 hover:text-text-1 cursor-pointer transition-colors"
|
|
66
|
+
>
|
|
67
|
+
<ArrowLeft size={14} />
|
|
68
|
+
</button>
|
|
69
|
+
)}
|
|
50
70
|
<Radio size={15} className="text-accent" />
|
|
51
|
-
<span className="text-sm font-semibold text-text-0 font-sans">
|
|
71
|
+
<span className="text-sm font-semibold text-text-0 font-sans">
|
|
72
|
+
{showWizard ? 'Add Connection' : 'Quick Connect'}
|
|
73
|
+
</span>
|
|
52
74
|
</div>
|
|
53
|
-
<button onClick={
|
|
75
|
+
<button onClick={handleClose} className="p-1 text-text-4 hover:text-text-1 cursor-pointer transition-colors">
|
|
54
76
|
<X size={14} />
|
|
55
77
|
</button>
|
|
56
78
|
</div>
|
|
57
79
|
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
{showWizard ? (
|
|
81
|
+
<SSHWizard
|
|
82
|
+
server={null}
|
|
83
|
+
onSave={async (data) => {
|
|
84
|
+
if (data.id) {
|
|
85
|
+
await useGrooveStore.getState().updateTunnel(data.id, data);
|
|
86
|
+
} else {
|
|
87
|
+
await useGrooveStore.getState().saveTunnel(data);
|
|
88
|
+
}
|
|
89
|
+
addToast('success', data.id ? 'Server updated' : 'Server added');
|
|
90
|
+
}}
|
|
91
|
+
onTest={() => {
|
|
92
|
+
const tunnels = useGrooveStore.getState().savedTunnels;
|
|
93
|
+
const last = tunnels[tunnels.length - 1];
|
|
94
|
+
if (last?.id) return useGrooveStore.getState().testTunnel(last.id);
|
|
95
|
+
}}
|
|
96
|
+
onConnect={() => {
|
|
97
|
+
const tunnels = useGrooveStore.getState().savedTunnels;
|
|
98
|
+
const last = tunnels[tunnels.length - 1];
|
|
99
|
+
if (last?.id) return useGrooveStore.getState().connectTunnel(last.id);
|
|
100
|
+
}}
|
|
101
|
+
onCancel={() => setShowWizard(false)}
|
|
102
|
+
/>
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
{/* Server list */}
|
|
106
|
+
<div className="overflow-y-auto max-h-[320px] py-1">
|
|
107
|
+
{savedTunnels.length === 0 ? (
|
|
108
|
+
<div className="px-4 py-8 text-center">
|
|
109
|
+
<Server size={24} className="text-text-4 mx-auto mb-2" />
|
|
110
|
+
<p className="text-sm text-text-3 font-sans">No saved servers</p>
|
|
111
|
+
<p className="text-2xs text-text-4 font-sans mt-1">Add a connection to get started.</p>
|
|
112
|
+
<Button
|
|
113
|
+
variant="primary"
|
|
114
|
+
size="sm"
|
|
115
|
+
onClick={() => setShowWizard(true)}
|
|
116
|
+
className="h-8 text-xs gap-1.5 mt-3"
|
|
117
|
+
>
|
|
118
|
+
<Plus size={12} /> Add Connection
|
|
119
|
+
</Button>
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
savedTunnels.map((server) => (
|
|
123
|
+
<button
|
|
124
|
+
key={server.id}
|
|
125
|
+
onClick={() => server.active ? handleOpenRemote(server) : handleConnect(server.id)}
|
|
126
|
+
disabled={connectingId === server.id}
|
|
127
|
+
className={cn(
|
|
128
|
+
'w-full flex items-center gap-3 px-4 py-2.5 text-left cursor-pointer transition-colors',
|
|
129
|
+
'hover:bg-surface-5',
|
|
130
|
+
connectingId === server.id && 'opacity-60 pointer-events-none',
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<Server size={15} className={server.active ? 'text-success' : 'text-text-4'} />
|
|
134
|
+
<div className="flex-1 min-w-0">
|
|
135
|
+
<div className="flex items-center gap-2">
|
|
136
|
+
<span className="text-sm font-medium text-text-0 font-sans truncate">{server.name}</span>
|
|
137
|
+
{server.active && <StatusDot status="running" size="sm" />}
|
|
138
|
+
</div>
|
|
139
|
+
<span className="text-2xs text-text-4 font-mono">{server.user}@{server.host}</span>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex-shrink-0">
|
|
142
|
+
{connectingId === server.id ? (
|
|
143
|
+
<Loader2 size={14} className="text-text-3 animate-spin" />
|
|
144
|
+
) : server.active ? (
|
|
145
|
+
<span className="flex items-center gap-1 text-2xs text-success font-sans">
|
|
146
|
+
<ExternalLink size={11} /> Open
|
|
147
|
+
</span>
|
|
148
|
+
) : (
|
|
149
|
+
<span className="text-2xs text-text-3 font-sans">Connect</span>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
</button>
|
|
153
|
+
))
|
|
154
|
+
)}
|
|
74
155
|
</div>
|
|
75
|
-
|
|
76
|
-
|
|
156
|
+
|
|
157
|
+
{/* Footer with Add button */}
|
|
158
|
+
<div className="px-4 py-2.5 border-t border-border-subtle">
|
|
77
159
|
<button
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
disabled={connectingId === server.id}
|
|
81
|
-
className={cn(
|
|
82
|
-
'w-full flex items-center gap-3 px-4 py-2.5 text-left cursor-pointer transition-colors',
|
|
83
|
-
'hover:bg-surface-5',
|
|
84
|
-
connectingId === server.id && 'opacity-60 pointer-events-none',
|
|
85
|
-
)}
|
|
160
|
+
onClick={() => setShowWizard(true)}
|
|
161
|
+
className="flex items-center gap-1.5 text-2xs text-accent hover:text-accent/80 font-sans font-medium cursor-pointer transition-colors"
|
|
86
162
|
>
|
|
87
|
-
<
|
|
88
|
-
<div className="flex-1 min-w-0">
|
|
89
|
-
<div className="flex items-center gap-2">
|
|
90
|
-
<span className="text-sm font-medium text-text-0 font-sans truncate">{server.name}</span>
|
|
91
|
-
{server.active && <StatusDot status="running" size="sm" />}
|
|
92
|
-
</div>
|
|
93
|
-
<span className="text-2xs text-text-4 font-mono">{server.user}@{server.host}</span>
|
|
94
|
-
</div>
|
|
95
|
-
<div className="flex-shrink-0">
|
|
96
|
-
{connectingId === server.id ? (
|
|
97
|
-
<Loader2 size={14} className="text-text-3 animate-spin" />
|
|
98
|
-
) : server.active ? (
|
|
99
|
-
<span className="flex items-center gap-1 text-2xs text-success font-sans">
|
|
100
|
-
<ExternalLink size={11} /> Open
|
|
101
|
-
</span>
|
|
102
|
-
) : (
|
|
103
|
-
<span className="text-2xs text-text-3 font-sans">Connect</span>
|
|
104
|
-
)}
|
|
105
|
-
</div>
|
|
163
|
+
<Plus size={10} /> Add new connection
|
|
106
164
|
</button>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
</div>
|
|
110
|
-
|
|
111
|
-
{/* Footer */}
|
|
112
|
-
{savedTunnels.length > 0 && (
|
|
113
|
-
<div className="px-4 py-2 border-t border-border-subtle">
|
|
114
|
-
<button
|
|
115
|
-
onClick={() => {
|
|
116
|
-
toggle();
|
|
117
|
-
useGrooveStore.getState().setActiveView('settings');
|
|
118
|
-
}}
|
|
119
|
-
className="flex items-center gap-1.5 text-2xs text-text-4 hover:text-text-2 font-sans cursor-pointer transition-colors"
|
|
120
|
-
>
|
|
121
|
-
<Plus size={10} /> Manage servers in Settings
|
|
122
|
-
</button>
|
|
123
|
-
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</>
|
|
124
167
|
)}
|
|
125
168
|
</motion.div>
|
|
126
169
|
</AnimatePresence>
|
|
@@ -111,10 +111,10 @@ export function RemoteServerCard({ server, onEdit, onDelete, onConnect, onDiscon
|
|
|
111
111
|
{server.user}@{server.host}:{server.port || 22}
|
|
112
112
|
</div>
|
|
113
113
|
|
|
114
|
-
{/* SSH key
|
|
115
|
-
{server.
|
|
114
|
+
{/* SSH key */}
|
|
115
|
+
{server.sshKeyDisplay && (
|
|
116
116
|
<div className="text-2xs text-text-4 font-mono truncate mb-2">
|
|
117
|
-
Key: {server.
|
|
117
|
+
Key: {server.sshKeyDisplay}
|
|
118
118
|
</div>
|
|
119
119
|
)}
|
|
120
120
|
|