Files
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
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>
2026-08-18 19:19:30 +00:00

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>
);