~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
152 lines · 5.5 KB Raw History
import { useQueryClient } from '@tanstack/react-query';
import {
  CircleAlertIcon,
  CircleCheckIcon,
  LoaderCircleIcon,
} from 'lucide-react';
import { useState } from 'react';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Panel } from '#/components/atoms/panel.jsx';
import { RowIcon } from '#/components/atoms/row-icon.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { Section } from '#/components/molecules/section.jsx';
import { post } from '#/lib/api.js';
import { bytes, num, timeAgo } from '#/lib/format.js';
import { queryKeys } from '#/lib/query.js';

const STATUS = {
  ok: { icon: CircleCheckIcon, label: 'Backed up' },
  error: { icon: CircleAlertIcon, label: 'Backup failed' },
  running: { icon: LoaderCircleIcon, label: 'Backing up' },
};

/**
 * What each backup did, newest first, with the "Back up now" trigger. A manual
 * run blocks the button until the server answers with the finished run (or an
 * error), then the whole backups query refreshes so the list shows it.
 * @param {{data: {runs: object[]}}} props
 */
export function BackupRunsCard({ data }) {
  const qc = useQueryClient();
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const [notice, setNotice] = useState('');

  const runs = data.runs || [];

  const runNow = async () => {
    setError('');
    setNotice('');
    setBusy(true);
    try {
      const run = await post('/backups/run');
      setNotice(
        `Backup finished — ${bytes(run.bytesUploaded) || '0 B'} uploaded to ${run.snapshot}.`,
      );
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setBusy(false);
      qc.invalidateQueries({ queryKey: queryKeys.backups });
    }
  };

  return (
    <Panel>
      <Section
        title="Backup history"
        description="What each backup did, newest first."
        action={
          <Button variant="outline" size="sm" disabled={busy} onClick={runNow}>
            {busy && <LoaderCircleIcon className="animate-spin" />}
            {busy ? 'Backing up…' : 'Back up now'}
          </Button>
        }
      >
        <ErrorNotice error={error} className="mb-4" />
        {notice && !error && (
          <Notice variant="success" className="mb-4">
            {notice}
          </Notice>
        )}
        <div className="flex flex-col gap-1">
          {runs.map((run) => {
            const status = STATUS[run.status] || STATUS.running;
            const Icon = status.icon;
            const facts = [
              // "Started" fits a run still going; a finished run says when it
              // wrapped up so it does not read as stuck.
              run.status === 'running'
                ? run.startedAt && `Started ${timeAgo(run.startedAt)}`
                : run.finishedAt && `Finished ${timeAgo(run.finishedAt)}`,
              // Mid-run the total is still being discovered; show only what
              // has actually been copied so far. Finished runs upload only
              // blobs the target does not already hold, so say "new" rather
              // than implying a progress fraction.
              run.status === 'running'
                ? `${num(run.blobsCopied)} blobs copied`
                : `${num(run.blobsCopied)} new blobs, ${num(run.blobsTotal)} total`,
              run.bytesUploaded != null &&
                `${bytes(run.bytesUploaded)} uploaded`,
            ].filter(Boolean);
            return (
              <ListRow
                key={run.id}
                variant="plain"
                media={
                  <RowIcon>
                    <Icon
                      className={run.status === 'running' ? 'animate-spin' : ''}
                    />
                  </RowIcon>
                }
                title={
                  <>
                    {status.label}
                    <Badge>{run.trigger}</Badge>
                    {run.keyIncluded && (
                      <Badge variant="scope">
                        {run.keyStale ? 'stale key' : 'key included'}
                      </Badge>
                    )}
                    {run.blobsFailed > 0 && (
                      <Badge>
                        {num(run.blobsFailed)}{' '}
                        {run.blobsFailed === 1 ? 'blob' : 'blobs'} not copied
                      </Badge>
                    )}
                  </>
                }
                description={
                  <>
                    <span className="block">{facts.join(' · ')}</span>
                    {run.error && (
                      <span className="block text-destructive">
                        {run.error}
                      </span>
                    )}
                    {/* A run that copied the repo but not every blob. The
                        next run tries those blobs again. */}
                    {!run.error && run.blobError && (
                      <span className="block text-warning">
                        {run.blobError}
                      </span>
                    )}
                  </>
                }
              />
            );
          })}
          {runs.length === 0 && (
            <p className="px-1 py-2 text-[13px] text-muted-foreground">
              No backups have run yet.
            </p>
          )}
        </div>
      </Section>
    </Panel>
  );
}