Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import './ConnectHome.scss';
import { Row, Col } from 'react-bootstrap';
import Header from '../../ui/Header/Header';
import ConnectList from '../ConnectList/ConnectList';
import MyJoinAttemptsCard from '../MyJoinAttemptsCard/MyJoinAttemptsCard';
import RendezvousSettings from '../RendezvousSettings/RendezvousSettings';

function ConnectHome() {
Expand All @@ -11,6 +12,7 @@ function ConnectHome() {
<Row className='px-3'>
<Col xs={12} className='cards-container'>
<ConnectList />
<MyJoinAttemptsCard />
<RendezvousSettings />
</Col>
</Row>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useCallback, useEffect, useState } from 'react';
import { Card, Table, Badge, Button, Spinner, Alert } from 'react-bootstrap';
import { FactoriesService } from '../../../services/http.service';
import logger from '../../../services/logger.service';

/**
* Task #152: surface the client's outgoing factory-join-request attempts
* + current status. Powered by the plugin's client-list-outgoing-joins RPC
* (added in PR #77). Renders on the Connect page so users can see what
* happened to invites they sent without having to grep CLN logs.
*/

type OutgoingJoin = {
instance_id: string;
lsp_node_id: string;
request_id: string;
contribution_sats: number;
sent_at_block: number;
expected_signing_block: number;
updated_at_block: number;
status: string;
status_code: number;
reason?: string;
};

const truncate = (s: string, head = 8, tail = 4) =>
!s || s.length <= head + tail + 1 ? s : `${s.slice(0, head)}…${s.slice(-tail)}`;

const statusBadgeVariant = (status: string): string => {
switch (status) {
case 'sent':
case 'queued':
case 'accepted':
return 'warning';
case 'signed':
return 'success';
case 'rejected':
case 'cancelled':
case 'timeout':
return 'danger';
case 'already_member':
return 'info';
default:
return 'secondary';
}
};

const statusLabel = (status: string): string => {
switch (status) {
case 'sent': return 'Sent';
case 'queued': return 'Queued';
case 'accepted': return 'Accepted';
case 'signed': return 'Signed';
case 'rejected': return 'Rejected';
case 'cancelled': return 'Cancelled';
case 'timeout': return 'Timed out';
case 'already_member': return 'Already in';
default: return status;
}
};

const formatSats = (n: number): string =>
typeof n === 'number' ? n.toLocaleString() : '—';

const MyJoinAttemptsCard = () => {
const [loading, setLoading] = useState(false);
const [joins, setJoins] = useState<OutgoingJoin[]>([]);
const [error, setError] = useState<string | null>(null);

const fetchJoins = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await FactoriesService.listOutgoingJoins();
setJoins(Array.isArray(result?.joins) ? result.joins : []);
} catch (err: any) {
const msg = err?.message || String(err);
// The RPC only exists post #77 deploy. Suppress the noisy "method not
// found" until the plugin upgrade rolls out.
if (/method not found|unknown.*method|client-list-outgoing-joins/i.test(msg)) {
setError('Plugin does not expose client-list-outgoing-joins yet '
+ '(deploy task #152 plugin half / PR #77 to enable).');
} else {
setError(msg);
}
logger.warn('listOutgoingJoins failed:', err);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
fetchJoins();
}, [fetchJoins]);

return (
<Card className='mt-3' data-testid='my-join-attempts-card'>
<Card.Header className='d-flex justify-content-between align-items-center'>
<span className='fw-bold'>My join attempts</span>
<Button
variant='link'
size='sm'
className='p-0 text-decoration-none'
onClick={fetchJoins}
disabled={loading}
data-testid='my-join-attempts-refresh'
>
{loading ? <Spinner animation='border' size='sm' /> : 'Refresh'}
</Button>
</Card.Header>
<Card.Body className='p-0'>
{error && (
<Alert variant='warning' className='m-2 py-2 fs-8 mb-0'>
{error}
</Alert>
)}
{!error && joins.length === 0 && !loading && (
<div className='text-light fs-8 text-center py-3' data-testid='my-join-attempts-empty'>
No outgoing join attempts from this node yet.
</div>
)}
{!error && joins.length > 0 && (
<Table size='sm' className='mb-0' striped>
<thead style={{ fontSize: '0.78rem' }}>
<tr>
<th>Factory</th>
<th>LSP</th>
<th className='text-end'>Requested (sat)</th>
<th>Sent at</th>
<th>Status</th>
</tr>
</thead>
<tbody style={{ fontSize: '0.8rem' }}>
{joins.map((j, idx) => (
<tr key={`${j.lsp_node_id}-${j.request_id}-${idx}`}>
<td><code>{truncate(j.instance_id, 10, 4)}</code></td>
<td><code>{truncate(j.lsp_node_id, 8, 4)}</code></td>
<td className='text-end'>{formatSats(j.contribution_sats)}</td>
<td>{j.sent_at_block || '—'}</td>
<td>
<Badge bg={statusBadgeVariant(j.status)}>{statusLabel(j.status)}</Badge>
{j.reason && (
<div className='text-light fs-8 mt-1' style={{ maxWidth: '14rem', whiteSpace: 'normal' }}>
{j.reason}
</div>
)}
</td>
</tr>
))}
</tbody>
</Table>
)}
</Card.Body>
</Card>
);
};

export default MyJoinAttemptsCard;
66 changes: 64 additions & 2 deletions apps/frontend/src/components/factories/FactoryList/FactoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,25 @@ import { ActionSVG } from '../../../svgs/Action';
import { useSelector } from 'react-redux';
import { selectIsAuthenticated, selectNodeInfo } from '../../../store/rootSelectors';
import { selectFactories, selectFactoriesLoading, selectFactoriesError, selectRoleCounts } from '../../../store/factoriesSelectors';
import { FactoriesService } from '../../../services/http.service';
import logger from '../../../services/logger.service';
import { Factory, FactoryLifecycle, FactoryCeremony } from '../../../types/factories.type';

// Task #150: a factory qualifies for plugin-side Discard (factory-forget RPC)
// only when it has zero on-chain footprint. Mirrors the plugin's safety gate
// so the button stays disabled rather than producing a server-side reject.
const canDiscard = (f: Factory): boolean => {
if (f.lifecycle !== FactoryLifecycle.ABORTED && f.lifecycle !== FactoryLifecycle.FAILED) {
// Allow a UI Discard for non-FAILED items whose ceremony is failed, since the
// plugin's #149 work auto-transitions those to FAILED on the LSP side; the
// client-side mirror lags a bit. Keep it conservative: only when ceremony=failed.
if (f.ceremony !== FactoryCeremony.FAILED) return false;
}
if (f.n_channels && f.n_channels > 0) return false;
if (f.funding_txid && /[1-9a-f]/i.test(f.funding_txid)) return false; // any non-zero hex = funded
return true;
};

type RoleFilter = 'all' | 'lsp' | 'client';
type Bucket = 'live' | 'history' | 'incomplete';

Expand Down Expand Up @@ -52,7 +69,9 @@ const HISTORY_LIFECYCLES = new Set<string>([
// lifecycle at INIT, so the "did not complete" bucket is also keyed off
// ceremony === FAILED until the plugin auto-terminalizes failed drafts (follow-up).
const bucketOf = (f: Factory): Bucket => {
if (f.lifecycle === FactoryLifecycle.ABORTED || f.ceremony === FactoryCeremony.FAILED) return 'incomplete';
if (f.lifecycle === FactoryLifecycle.ABORTED
|| f.lifecycle === FactoryLifecycle.FAILED
|| f.ceremony === FactoryCeremony.FAILED) return 'incomplete';
if (HISTORY_LIFECYCLES.has(f.lifecycle)) return 'history';
return 'live';
};
Expand Down Expand Up @@ -88,11 +107,13 @@ type FactoryListProps = {
onFactoryClick: (factory: Factory) => void;
};

const FactoryListItem = ({ factory, onClick, hidden, onToggleHide }: {
const FactoryListItem = ({ factory, onClick, hidden, onToggleHide, onDiscard, discarding }: {
factory: Factory;
onClick: () => void;
hidden: boolean;
onToggleHide: (instanceId: string) => void;
onDiscard: (instanceId: string) => void;
discarding: boolean;
}) => (
<li
className='list-group-item list-item-channel cursor-pointer'
Expand Down Expand Up @@ -133,6 +154,19 @@ const FactoryListItem = ({ factory, onClick, hidden, onToggleHide }: {
>
{hidden ? 'Unhide' : 'Hide'}
</Button>
{canDiscard(factory) && (
<Button
variant='link'
size='sm'
className='p-0 text-danger text-decoration-none fs-8'
title='Hard-delete this factory record (only allowed for failed drafts with no on-chain footprint)'
data-testid='factory-discard-btn'
disabled={discarding}
onClick={(e) => { e.stopPropagation(); onDiscard(factory.instance_id); }}
>
{discarding ? '…' : 'Discard'}
</Button>
)}
</div>
</div>
<Row className='text-light fs-7 mt-1'>
Expand Down Expand Up @@ -167,6 +201,9 @@ const FactoryList = (props: FactoryListProps) => {
const [showIncomplete, setShowIncomplete] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showHidden, setShowHidden] = useState(false);
// Track in-flight factory-forget RPCs so the Discard button shows a busy
// state and we don't double-fire on rapid clicks.
const [discardingIds, setDiscardingIds] = useState<Set<string>>(new Set());

// TEMP: always show the pill until nostr rendezvous lands so single-role
// nodes can still preview the Client view.
Expand Down Expand Up @@ -196,6 +233,29 @@ const FactoryList = (props: FactoryListProps) => {
});
};

// Task #150: hard-delete a factory record via the plugin's factory-forget
// RPC. Safety-gated server-side; canDiscard() mirrors the gate so the
// button stays disabled rather than producing a server-side reject.
const handleDiscard = async (instanceId: string) => {
setDiscardingIds(prev => {
const next = new Set(prev);
next.add(instanceId);
return next;
});
try {
await FactoriesService.forgetFactory(instanceId);
await FactoriesService.fetchFactoriesData();
} catch (err) {
logger.error('factory-forget failed:', err);
} finally {
setDiscardingIds(prev => {
const next = new Set(prev);
next.delete(instanceId);
return next;
});
}
};

const groups = useMemo(() => {
const base = !factories
? []
Expand Down Expand Up @@ -228,6 +288,8 @@ const FactoryList = (props: FactoryListProps) => {
factory={factory}
hidden={hidden.has(factory.instance_id)}
onToggleHide={toggleHide}
onDiscard={handleDiscard}
discarding={discardingIds.has(factory.instance_id)}
onClick={() => props.onFactoryClick(factory)}
/>
));
Expand Down
36 changes: 36 additions & 0 deletions apps/frontend/src/services/http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,42 @@ export class FactoriesService {
return HttpService.clnCall('factory-cancel-join', { request_id: requestId });
}

/**
* Task #150: hard-discard a factory record. Plugin-side gated to refuse
* unless lifecycle is FAILED or ABORTED AND there is zero on-chain
* footprint (no funding TX, no channels). Wired to the Discard button
* in FactoryList; safer than Hide because it actually removes the record.
*/
static async forgetFactory(instanceId: string): Promise<{
instance_id: string;
lifecycle: string;
previous_lifecycle: number;
}> {
return HttpService.clnCall('factory-forget', { instance_id: instanceId });
}

/**
* Task #152: list this client's outgoing join attempts (every
* factory-join-request fired + current status). Powers the "My join
* attempts" view. Plugin RPC: client-list-outgoing-joins.
*/
static async listOutgoingJoins(): Promise<{
joins: Array<{
instance_id: string;
lsp_node_id: string;
request_id: string;
contribution_sats: number;
sent_at_block: number;
expected_signing_block: number;
updated_at_block: number;
status: string;
status_code: number;
reason?: string;
}>;
}> {
return HttpService.clnCall('client-list-outgoing-joins');
}

/**
* Client-side: fetch the persisted signing preference thresholds that
* the plugin's pre-sign validator checks against. Returns canonical
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/types/factories.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export enum FactoryLifecycle {
CLOSED_BREACHED = 'closed_breached',
// Terminal "did not complete" — operator abort or a stalled/failed ceremony.
ABORTED = 'aborted',
// Task #149 plugin auto-terminalize: ceremony failed automatically
// (withdraw failure, malformed peer msg, etc.). Same bucket as ABORTED in
// the wallet (Failed / abandoned); distinct semantically so the operator
// can tell auto-vs-manual aborts apart.
FAILED = 'failed',
}

export enum FactoryCeremony {
Expand Down
Loading