The sixth plugin out, immediately after photos. A sidecar, a proxy router and 29 web files; no db/ directory, because there are no tables. ── The one thing that is not self-contained ── The connection lives in `service_connections`, a CORE table, reached through getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin declares no schema. Deliberate: that table is the app store's shared credential store, keyed on (user_id, service), already encrypted at rest and already read by app-store/effects.ts. A private copy of the same three columns would be a second place the same fact could live, and the runbook is explicit that a move plus a data-format change gives any failure two causes. The cost, stated rather than hidden: this plugin depends on a core facility it does not own, and would break if that table changed shape. Photos, by contrast, owns photos_config outright. ── 38 palette classes paid down, mapped by meaning ── blue downloading / download speed -> info cyan checking (verifying) -> busy emerald seeding / complete -> success amber queued / stalled / warnings -> warning Collapsing blue and cyan into one tone would have lost a real distinction in the status dot, so `checking` went to `busy` — in-progress work, which is what that tone is for. ── A near-miss worth recording ── The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/ — ten components. tsgo caught it as ten "cannot find module" errors. Worth stating because it is quiet in the other direction: had those been imported lazily, the typecheck would have passed and the panels would have broken at runtime. Check the directory tree, not the file glob. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
4.8 KiB
TypeScript
108 lines
4.8 KiB
TypeScript
import type { Torrent } from '../shared';
|
|
import { CircleAlert, CircleCheck, Pencil, RefreshCw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { formatDate } from '../format';
|
|
import { useTorrentMutations } from '../useTransmissionData';
|
|
|
|
// One card per tracker, grouped by tier. The point of this tab is diagnosing a torrent that isn't finding
|
|
// peers, so the failure text gets as much room as the success numbers.
|
|
|
|
type TrackersTabProps = { torrent: Torrent; onEdit: () => void };
|
|
|
|
export const TrackersTab = ({ torrent, onEdit }: TrackersTabProps) => {
|
|
const { act } = useTorrentMutations();
|
|
const trackers = torrent.trackerStats ?? [];
|
|
|
|
const tiers = new Map<number, typeof trackers>();
|
|
for (const tracker of trackers) {
|
|
tiers.set(tracker.tier, [...(tiers.get(tracker.tier) ?? []), tracker]);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-auto">
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
<span className="flex-1 text-xs text-muted-foreground">
|
|
{trackers.length} tracker{trackers.length === 1 ? '' : 's'} in {tiers.size} tier
|
|
{tiers.size === 1 ? '' : 's'}
|
|
</span>
|
|
<Button size="sm" variant="ghost" onClick={() => act.mutate({ ids: [torrent.id], action: 'reannounce' })}>
|
|
<RefreshCw className="mr-1.5 h-3 w-3" />
|
|
Reannounce
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={onEdit}>
|
|
<Pencil className="mr-1.5 h-3 w-3" />
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
|
|
{trackers.length === 0 && (
|
|
<p className="p-6 text-center text-sm text-muted-foreground">
|
|
No trackers. This torrent relies on DHT and peer exchange.
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-4 p-3">
|
|
{[...tiers.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([tier, list]) => (
|
|
<section key={tier}>
|
|
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Tier {tier + 1}
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{list.map((tracker) => (
|
|
<div key={tracker.id} className="rounded-lg border border-border p-3 text-xs">
|
|
<div className="flex items-start gap-2">
|
|
{tracker.lastAnnounceSucceeded ? (
|
|
<CircleCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" />
|
|
) : (
|
|
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="break-all font-mono">{tracker.announce}</div>
|
|
{tracker.isBackup && (
|
|
<div className="mt-0.5 text-muted-foreground">
|
|
Backup — only tried if the tier above fails entirely.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="shrink-0 text-right tabular-nums text-muted-foreground">
|
|
{tracker.seederCount >= 0 ? `${tracker.seederCount} seeds` : 'seeds unknown'}
|
|
<br />
|
|
{tracker.leecherCount >= 0 ? `${tracker.leecherCount} peers` : 'peers unknown'}
|
|
</div>
|
|
</div>
|
|
|
|
<dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2">
|
|
<Row label="Last announce" value={formatDate(tracker.lastAnnounceTime)} />
|
|
<Row
|
|
label="Result"
|
|
value={tracker.lastAnnounceResult || '—'}
|
|
tone={tracker.lastAnnounceSucceeded ? undefined : 'text-warning'}
|
|
/>
|
|
<Row label="Next announce" value={formatDate(tracker.nextAnnounceTime)} />
|
|
<Row label="Peers returned" value={String(tracker.lastAnnouncePeerCount)} />
|
|
<Row label="Last scrape" value={formatDate(tracker.lastScrapeTime)} />
|
|
<Row
|
|
label="Scrape result"
|
|
value={tracker.lastScrapeResult || '—'}
|
|
tone={tracker.lastScrapeSucceeded ? undefined : 'text-warning'}
|
|
/>
|
|
</dl>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Row = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
|
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
<dt className="w-28 shrink-0 text-muted-foreground">{label}</dt>
|
|
<dd className={`min-w-0 flex-1 break-words ${tone ?? ''}`}>{value}</dd>
|
|
</div>
|
|
);
|