Allow track and untrack of a project through the TrackButton

- Add support for checking the tracking table from `radicle-httpd` to
  get a more precise information if the project is being tracked by the
  local node.
- If a user isn't authenticated with their local httpd instance, we
  still show the Popover to explain how to do it on the CLI
This commit is contained in:
Sebastian Martinez
2023-12-19 16:47:08 +01:00
parent 5f116cff95
commit 8152cda2ac
5 changed files with 143 additions and 23 deletions
+55
View File
@@ -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(
{
+11 -4
View File
@@ -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>
+49 -2
View File
@@ -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>
+14 -17
View File
@@ -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;
}
}
+14
View File
@@ -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();
});