Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Allow track and untrack of a project through the TrackButton
Sebastian Martinez committed 2 years ago
commit 8152cda2ac098f0a74e13ecc406a35b5295f6e84
parent 5f116cff957349e26e00043c8f3494c8cdba8ea0
5 files changed +143 -23
modified httpd-client/index.ts
@@ -6,6 +6,7 @@ import type {
  Tree,
  DiffResponse,
} from "./lib/project.js";
+
import type { SuccessResponse } from "./lib/shared.js";
import type { Comment, Embed } from "./lib/project/comment.js";
import type {
  Commit,
@@ -38,6 +39,7 @@ import { z, array, boolean, literal, number, object, string, union } from "zod";
import * as project from "./lib/project.js";
import * as session from "./lib/session.js";
import { Fetcher } from "./lib/fetcher.js";
+
import { successResponseSchema } from "./lib/shared.js";

export type {
  BaseUrl,
@@ -127,6 +129,16 @@ const nodeInfoSchema = object({
  ),
});

+
export type NodeTracking = z.infer<typeof nodeTrackingSchema>;
+

+
const nodeTrackingSchema = array(
+
  object({
+
    id: string(),
+
    scope: string(),
+
    policy: string(),
+
  }),
+
);
+

export interface NodeStats {
  projects: { count: number };
  users: { count: number };
@@ -189,6 +201,49 @@ export class HttpdClient {
    );
  }

+
  public async getTracking(options?: RequestOptions): Promise<NodeTracking> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "GET",
+
        path: "node/policies/repos",
+
        options,
+
      },
+
      nodeTrackingSchema,
+
    );
+
  }
+

+
  public async seedById(
+
    id: string,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "PUT",
+
        path: `node/policies/repos/${id}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

+
  public async stopSeedingById(
+
    id: string,
+
    authToken: string,
+
    options?: RequestOptions,
+
  ): Promise<SuccessResponse> {
+
    return this.#fetcher.fetchOk(
+
      {
+
        method: "DELETE",
+
        path: `node/policies/repos/${id}`,
+
        headers: { Authorization: `Bearer ${authToken}` },
+
        options,
+
      },
+
      successResponseSchema,
+
    );
+
  }
+

  public async getNode(options?: RequestOptions): Promise<Node> {
    return this.#fetcher.fetchOk(
      {
modified src/views/projects/Header/SeedButton.svelte
@@ -5,11 +5,11 @@
  import IconSmall from "@app/components/IconSmall.svelte";
  import Popover from "@app/components/Popover.svelte";

+
  export let disabled: boolean;
  export let projectId: string;
  export let seedCount: number;
  export let seeding: boolean;
-

-
  $: buttonTitle = seeding ? "Seeding" : "Seed";
+
  export let editSeeding: (() => Promise<void>) | undefined;
</script>

<style>
@@ -30,14 +30,21 @@

<Popover popoverPositionTop="3rem" popoverPositionRight="0">
  <Button
+
    {disabled}
    slot="toggle"
    let:toggle
-
    on:click={toggle}
+
    on:click={async () => {
+
      if (editSeeding) {
+
        await editSeeding();
+
      } else {
+
        toggle();
+
      }
+
    }}
    size="large"
    variant={seeding ? "secondary-toggle-on" : "secondary-toggle-off"}>
    <IconSmall name="network" />
    <span>
-
      {buttonTitle}
+
      {seeding ? "Seeding" : "Seed"}
      <span style:font-weight="var(--font-weight-regular)">
        {seedCount}
      </span>
modified src/views/projects/Layout.svelte
@@ -4,13 +4,16 @@

  import dompurify from "dompurify";

+
  import * as modal from "@app/lib/modal";
  import capitalize from "lodash/capitalize";
  import markdown from "@app/lib/markdown";
+
  import { httpdStore, api } from "@app/lib/httpd";
  import { twemoji } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
  import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import Header from "@app/views/projects/Header.svelte";
  import Link from "@app/components/Link.svelte";
  import SeedButton from "@app/views/projects/Header/SeedButton.svelte";
@@ -20,8 +23,50 @@
  export let project: Project;
  export let seeding: boolean;

+
  let editSeedingInProgress = false;
+

+
  async function editSeeding() {
+
    if ($httpdStore.state === "authenticated") {
+
      try {
+
        editSeedingInProgress = true;
+
        if (seeding) {
+
          await api.stopSeedingById(project.id, $httpdStore.session.id);
+
        } else {
+
          await api.seedById(project.id, $httpdStore.session.id);
+
        }
+
        seeding = !seeding;
+
      } catch (error) {
+
        if (error instanceof Error) {
+
          modal.show({
+
            component: ErrorModal,
+
            props: {
+
              title: seeding
+
                ? "Stop seeding project failed"
+
                : "Seeding project failed",
+
              subtitle: [
+
                `There was an error while trying to ${
+
                  seeding ? "stop seeding" : "seed"
+
                } this project.`,
+
                "Check your radicle-httpd logs for details.",
+
              ],
+
              error: {
+
                message: error.message,
+
                stack: error.stack,
+
              },
+
            },
+
          });
+
        }
+
      } finally {
+
        editSeedingInProgress = false;
+
      }
+
    }
+
  }
+

  const render = (content: string): string =>
    dompurify.sanitize(markdown.parse(content) as string);
+

+
  $: session =
+
    $httpdStore.state === "authenticated" ? $httpdStore.session : undefined;
</script>

<style>
@@ -101,8 +146,10 @@
      style="margin-left: auto; display: flex; gap: 0.5rem;">
      <SeedButton
        {seeding}
-
        seedCount={project.seeding}
-
        projectId={project.id} />
+
        disabled={editSeedingInProgress}
+
        projectId={project.id}
+
        editSeeding={session && editSeeding}
+
        seedCount={project.seeding} />
      <CloneButton {baseUrl} id={project.id} name={project.name} />
    </div>
  </div>
modified src/views/projects/router.ts
@@ -19,11 +19,12 @@ import type {
  Tree,
} from "@httpd-client";

-
import { HttpdClient } from "@httpd-client";
import * as Syntax from "@app/lib/syntax";
-
import { isLocal, unreachable } from "@app/lib/utils";
-
import { nodePath } from "@app/views/nodes/router";
import * as httpd from "@app/lib/httpd";
+
import { HttpdClient } from "@httpd-client";
+
import { ResponseError } from "@httpd-client/lib/fetcher";
+
import { nodePath } from "@app/views/nodes/router";
+
import { unreachable } from "@app/lib/utils";

export const COMMITS_PER_PAGE = 30;
export const PATCHES_PER_PAGE = 10;
@@ -245,21 +246,17 @@ function parseRevisionToOid(
}

async function isLocalNodeSeeding(route: ProjectRoute): Promise<boolean> {
-
  if (isLocal(route.node.hostname)) {
-
    return true;
-
  } else {
-
    try {
-
      await httpd.api.project.getById(route.project);
-
      return true;
-
    } catch (error: any) {
-
      if (error.status === 404) {
-
        return false;
-
      } else {
-
        // Either `radicle-httpd` isn't running or there was some other
-
        // error.
-
        return false;
-
      }
+
  try {
+
    const tracking = await httpd.api.getTracking();
+
    return tracking.some(({ id }) => id === route.project);
+
  } catch (error) {
+
    if (error instanceof ResponseError && error.status === 404) {
+
      return false;
    }
+

+
    // Either `radicle-httpd` isn't running or there was some other
+
    // error.
+
    return false;
  }
}

modified tests/e2e/node.spec.ts
@@ -5,6 +5,7 @@ import {
  sourceBrowsingRid,
  test,
} from "@tests/support/fixtures.js";
+
import { createProject } from "@tests/support/project";

test("node metadata", async ({ page, peerManager }) => {
  const peer = await peerManager.createPeer({
@@ -51,3 +52,16 @@ test("node projects", async ({ page }) => {
    await expect(project.getByText(sourceBrowsingRid)).toBeVisible();
  }
});
+

+
test("seeding projects", async ({ page, authenticatedPeer }) => {
+
  const { rid } = await createProject(authenticatedPeer, {
+
    name: "seedProject",
+
  });
+

+
  await page.goto(authenticatedPeer.ridUrl(rid));
+
  await page.getByRole("button", { name: "Seeding" }).click();
+
  await expect(page.getByRole("button", { name: "Seed" })).toBeVisible();
+

+
  await page.getByRole("button", { name: "Seed" }).click();
+
  await expect(page.getByRole("button", { name: "Seeding" })).toBeVisible();
+
});