refactor(navigation): use volume id in urls instead of name

This commit is contained in:
Nicolas Meienberger 2026-01-17 23:19:17 +01:00
parent e59a3eac2f
commit 23acae1121
23 changed files with 2461 additions and 109 deletions

View file

@ -1260,7 +1260,7 @@ export const getUpdatesOptions = (options?: Options<GetUpdatesData>) =>
});
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
*/
export const downloadResticPasswordMutation = (
options?: Partial<Options<DownloadResticPasswordData>>,

View file

@ -179,7 +179,7 @@ export const testConnection = <ThrowOnError extends boolean = false>(
*/
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) =>
(options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}",
url: "/api/v1/volumes/{id}",
...options,
});
@ -188,7 +188,7 @@ export const deleteVolume = <ThrowOnError extends boolean = false>(options: Opti
*/
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) =>
(options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}",
url: "/api/v1/volumes/{id}",
...options,
});
@ -197,7 +197,7 @@ export const getVolume = <ThrowOnError extends boolean = false>(options: Options
*/
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) =>
(options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}",
url: "/api/v1/volumes/{id}",
...options,
headers: {
"Content-Type": "application/json",
@ -210,7 +210,7 @@ export const updateVolume = <ThrowOnError extends boolean = false>(options: Opti
*/
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) =>
(options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/mount",
url: "/api/v1/volumes/{id}/mount",
...options,
});
@ -221,7 +221,7 @@ export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>,
) =>
(options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/unmount",
url: "/api/v1/volumes/{id}/unmount",
...options,
});
@ -232,7 +232,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
options: Options<HealthCheckVolumeData, ThrowOnError>,
) =>
(options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/health-check",
url: "/api/v1/volumes/{id}/health-check",
...options,
});
@ -241,7 +241,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) =>
(options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/files",
url: "/api/v1/volumes/{id}/files",
...options,
});
@ -708,7 +708,7 @@ export const getUpdates = <ThrowOnError extends boolean = false>(options?: Optio
});
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
*/
export const downloadResticPassword = <ThrowOnError extends boolean = false>(
options?: Options<DownloadResticPasswordData, ThrowOnError>,

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ import { useFileBrowser } from "../hooks/use-file-browser";
import { parseError } from "../lib/errors";
type VolumeFileBrowserProps = {
volumeName: string;
volumeId: string;
enabled?: boolean;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
@ -18,7 +18,7 @@ type VolumeFileBrowserProps = {
};
export const VolumeFileBrowser = ({
volumeName,
volumeId,
enabled = true,
withCheckboxes = false,
selectedPaths,
@ -31,7 +31,7 @@ export const VolumeFileBrowser = ({
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }),
...listFilesOptions({ path: { id: volumeId } }),
enabled,
});
@ -41,7 +41,7 @@ export const VolumeFileBrowser = ({
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listFilesOptions({
path: { name: volumeName },
path: { id: volumeId },
query: { path },
}),
);
@ -49,7 +49,7 @@ export const VolumeFileBrowser = ({
prefetchFolder: (path) => {
void queryClient.prefetchQuery(
listFilesOptions({
path: { name: volumeName },
path: { id: volumeId },
query: { path },
}),
);

View file

@ -372,7 +372,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<CardContent>
<VolumeFileBrowser
key={volume.id}
volumeName={volume.name}
volumeId={volume.shortId}
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
withCheckboxes={true}

View file

@ -86,7 +86,7 @@ export const ScheduleSummary = (props: Props) => {
<div>
<CardTitle>{schedule.name}</CardTitle>
<CardDescription className="mt-1">
<Link to={`/volumes/${schedule.volume.name}`} className="hover:underline">
<Link to={`/volumes/${schedule.volume.shortId}`} className="hover:underline">
<HardDrive className="inline h-4 w-4 mr-2" />
<span>{schedule.volume.name}</span>
</Link>

View file

@ -136,7 +136,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<span className="text-muted-foreground">Volume:</span>
<p>
<Link
to={`/volumes/${backupSchedule?.volume.name}`}
to={`/volumes/${backupSchedule?.volume.shortId}`}
className="text-primary hover:underline"
>
{backupSchedule?.volume.name}

View file

@ -60,7 +60,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
<OnOff
isOn={volume.autoRemount}
toggle={() =>
toggleAutoRemount.mutate({ path: { name: volume.name }, body: { autoRemount: !volume.autoRemount } })
toggleAutoRemount.mutate({ path: { id: volume.shortId }, body: { autoRemount: !volume.autoRemount } })
}
disabled={toggleAutoRemount.isPending}
enabledLabel="Enabled"
@ -74,7 +74,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
variant="outline"
className="mt-4"
loading={healthcheck.isPending}
onClick={() => healthcheck.mutate({ path: { name: volume.name } })}
onClick={() => healthcheck.mutate({ path: { id: volume.shortId } })}
>
<Activity className="h-4 w-4 mr-2" />
Run Health Check

View file

@ -33,7 +33,7 @@ export default function CreateVolume() {
...createVolumeMutation(),
onSuccess: (data) => {
toast.success("Volume created successfully");
void navigate(`/volumes/${data.name}`);
void navigate(`/volumes/${data.shortId}`);
},
});

View file

@ -41,12 +41,12 @@ const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "
};
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.id }],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - ${params.name}` },
{ title: `Zerobyte - ${params.id}` },
{
name: "description",
content: "View and manage volume details, configuration, and files.",
@ -55,19 +55,19 @@ export function meta({ params }: Route.MetaArgs) {
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const volume = await getVolume({ path: { name: params.name } });
const volume = await getVolume({ path: { id: params.id } });
if (volume.data) return volume.data;
};
export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info";
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }),
...getVolumeOptions({ path: { id: id ?? "" } }),
initialData: loaderData,
});
@ -110,10 +110,10 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteVol.mutate({ path: { name: name ?? "" } });
deleteVol.mutate({ path: { id: id ?? "" } });
};
if (!name) {
if (!id) {
return <div>Volume not found</div>;
}
@ -139,7 +139,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
</div>
<div className="flex gap-4">
<Button
onClick={() => mountVol.mutate({ path: { name } })}
onClick={() => mountVol.mutate({ path: { id } })}
loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })}
>
@ -148,7 +148,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
</Button>
<Button
variant="secondary"
onClick={() => unmountVol.mutate({ path: { name } })}
onClick={() => unmountVol.mutate({ path: { id } })}
loading={unmountVol.isPending}
className={cn({ hidden: volume.status !== "mounted" })}
>
@ -178,7 +178,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the volume <strong>{name}</strong>? This action cannot be undone.
Are you sure you want to delete the volume <strong>{volume.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">

View file

@ -159,7 +159,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
<TableRow
key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/volumes/${volume.name}`)}
onClick={() => navigate(`/volumes/${volume.shortId}`)}
>
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
<TableCell>

View file

@ -28,7 +28,7 @@ export const FilesTabContent = ({ volume }: Props) => {
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
<VolumeFileBrowser
volumeName={volume.name}
volumeId={volume.shortId}
enabled={volume.status === "mounted"}
className="overflow-auto flex-1 border rounded-md bg-card p-2"
emptyMessage="This volume is empty."

View file

@ -37,7 +37,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
setPendingValues(null);
if (data.name !== volume.name) {
void navigate(`/volumes/${data.name}`);
void navigate(`/volumes/${data.shortId}`);
}
},
onError: (error) => {
@ -58,7 +58,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const confirmUpdate = () => {
if (pendingValues) {
updateMutation.mutate({
path: { name: volume.name },
path: { id: volume.shortId },
body: { name: pendingValues.name, config: pendingValues },
});
}

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX `volumes_table_name_organization_id_unique` ON `volumes_table` (`name`,`organization_id`);

File diff suppressed because it is too large Load diff

View file

@ -295,6 +295,13 @@
"when": 1768684588784,
"tag": "0041_motionless_storm",
"breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1768687163221,
"tag": "0042_watery_liz_osborn",
"breakpoints": true
}
]
}

View file

@ -8,7 +8,7 @@ export default [
route("/", "./client/routes/root.tsx"),
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
route("volumes/create", "./client/modules/volumes/routes/create-volume.tsx"),
route("volumes/:name", "./client/modules/volumes/routes/volume-details.tsx"),
route("volumes/:id", "./client/modules/volumes/routes/volume-details.tsx"),
route("backups", "./client/modules/backups/routes/backups.tsx"),
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),

View file

@ -196,7 +196,7 @@ export const volumesTable = sqliteTable("volumes_table", {
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
});
}, (table) => [unique().on(table.name, table.organizationId)]);
export type Volume = typeof volumesTable.$inferSelect;
export type VolumeInsert = typeof volumesTable.$inferInsert;

View file

@ -16,7 +16,7 @@ export class VolumeAutoRemountJob extends Job {
for (const volume of volumes) {
if (volume.autoRemount) {
try {
await volumeService.mountVolume(volume.name, volume.organizationId);
await volumeService.mountVolume(volume.id, volume.organizationId);
} catch (err) {
logger.error(`Failed to auto-remount volume ${volume.name}:`, err);
}

View file

@ -14,9 +14,9 @@ export class VolumeHealthCheckJob extends Job {
});
for (const volume of volumes) {
const { status } = await volumeService.checkHealth(volume.name, volume.organizationId);
const { status } = await volumeService.checkHealth(volume.id, volume.organizationId);
if (status === "error" && volume.autoRemount) {
await volumeService.mountVolume(volume.name, volume.organizationId);
await volumeService.mountVolume(volume.id, volume.organizationId);
}
}

View file

@ -20,7 +20,7 @@ const ensureLatestConfigurationSchema = async () => {
const volumes = await db.query.volumesTable.findMany({});
for (const volume of volumes) {
await volumeService.updateVolume(volume.name, volume, volume.organizationId).catch((err) => {
await volumeService.updateVolume(volume.id, volume, volume.organizationId).catch((err) => {
logger.error(`Failed to update volume ${volume.name}: ${err}`);
});
}
@ -69,7 +69,7 @@ export const startup = async () => {
});
for (const volume of volumes) {
await volumeService.mountVolume(volume.name, volume.organizationId).catch((err) => {
await volumeService.mountVolume(volume.id, volume.organizationId).catch((err) => {
logger.error(`Error auto-remounting volume ${volume.name} on startup: ${err.message}`);
});
}

View file

@ -50,15 +50,15 @@ export const volumeController = new Hono()
return c.json(result, 200);
})
.delete("/:name", deleteVolumeDto, async (c) => {
const { name } = c.req.param();
await volumeService.deleteVolume(name, c.get("organizationId"));
.delete("/:id", deleteVolumeDto, async (c) => {
const { id } = c.req.param();
await volumeService.deleteVolume(id, c.get("organizationId"));
return c.json({ message: "Volume deleted" }, 200);
})
.get("/:name", getVolumeDto, async (c) => {
const { name } = c.req.param();
const res = await volumeService.getVolume(name, c.get("organizationId"));
.get("/:id", getVolumeDto, async (c) => {
const { id } = c.req.param();
const res = await volumeService.getVolume(id, c.get("organizationId"));
const response = {
volume: {
@ -74,10 +74,10 @@ export const volumeController = new Hono()
return c.json<GetVolumeDto>(response, 200);
})
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param();
.put("/:id", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { id } = c.req.param();
const body = c.req.valid("json");
const res = await volumeService.updateVolume(name, body, c.get("organizationId"));
const res = await volumeService.updateVolume(id, body, c.get("organizationId"));
const response = {
...res.volume,
@ -86,28 +86,28 @@ export const volumeController = new Hono()
return c.json<UpdateVolumeDto>(response, 200);
})
.post("/:name/mount", mountVolumeDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.mountVolume(name, c.get("organizationId"));
.post("/:id/mount", mountVolumeDto, async (c) => {
const { id } = c.req.param();
const { error, status } = await volumeService.mountVolume(id, c.get("organizationId"));
return c.json({ error, status }, error ? 500 : 200);
})
.post("/:name/unmount", unmountVolumeDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.unmountVolume(name, c.get("organizationId"));
.post("/:id/unmount", unmountVolumeDto, async (c) => {
const { id } = c.req.param();
const { error, status } = await volumeService.unmountVolume(id, c.get("organizationId"));
return c.json({ error, status }, error ? 500 : 200);
})
.post("/:name/health-check", healthCheckDto, async (c) => {
const { name } = c.req.param();
const { error, status } = await volumeService.checkHealth(name, c.get("organizationId"));
.post("/:id/health-check", healthCheckDto, async (c) => {
const { id } = c.req.param();
const { error, status } = await volumeService.checkHealth(id, c.get("organizationId"));
return c.json({ error, status }, 200);
})
.get("/:name/files", listFilesDto, async (c) => {
const { name } = c.req.param();
.get("/:id/files", listFilesDto, async (c) => {
const { id } = c.req.param();
const subPath = c.req.query("path");
const result = await volumeService.listFiles(name, c.get("organizationId"), subPath);
const result = await volumeService.listFiles(id, c.get("organizationId"), subPath);
const response = {
files: result.files,

View file

@ -50,6 +50,16 @@ const listVolumes = async (organizationId: string) => {
return volumes;
};
const findVolume = async (idOrShortId: string | number, organizationId: string) => {
const isNumeric = typeof idOrShortId === "number" || /^\d+$/.test(String(idOrShortId));
return await db.query.volumesTable.findFirst({
where: and(
isNumeric ? eq(volumesTable.id, Number(idOrShortId)) : eq(volumesTable.shortId, idOrShortId),
eq(volumesTable.organizationId, organizationId),
),
});
};
const createVolume = async (name: string, backendConfig: BackendConfig, organizationId: string) => {
const slug = slugify(name, { lower: true, strict: true });
@ -90,10 +100,8 @@ const createVolume = async (name: string, backendConfig: BackendConfig, organiza
return { volume: created, status: 201 };
};
const deleteVolume = async (name: string, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
const deleteVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await findVolume(idOrShortId, organizationId);
if (!volume) {
throw new NotFoundError("Volume not found");
@ -106,10 +114,8 @@ const deleteVolume = async (name: string, organizationId: string) => {
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
};
const mountVolume = async (name: string, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
const mountVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await findVolume(idOrShortId, organizationId);
if (!volume) {
throw new NotFoundError("Volume not found");
@ -124,16 +130,14 @@ const mountVolume = async (name: string, organizationId: string) => {
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
if (status === "mounted") {
serverEvents.emit("volume:mounted", { volumeName: name });
serverEvents.emit("volume:mounted", { volumeName: volume.name });
}
return { error, status };
};
const unmountVolume = async (name: string, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
const unmountVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await findVolume(idOrShortId, organizationId);
if (!volume) {
throw new NotFoundError("Volume not found");
@ -148,16 +152,14 @@ const unmountVolume = async (name: string, organizationId: string) => {
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
if (status === "unmounted") {
serverEvents.emit("volume:unmounted", { volumeName: name });
serverEvents.emit("volume:unmounted", { volumeName: volume.name });
}
return { error, status };
};
const getVolume = async (name: string, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
const getVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await findVolume(idOrShortId, organizationId);
if (!volume) {
throw new NotFoundError("Volume not found");
@ -166,7 +168,7 @@ const getVolume = async (name: string, organizationId: string) => {
let statfs: Partial<StatFs> = {};
if (volume.status === "mounted") {
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
logger.warn(`Failed to get statfs for volume ${volume.name}: ${toMessage(error)}`);
return {};
});
}
@ -174,10 +176,8 @@ const getVolume = async (name: string, organizationId: string) => {
return { volume, statfs };
};
const updateVolume = async (name: string, volumeData: UpdateVolumeBody, organizationId: string) => {
const existing = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
const updateVolume = async (idOrShortId: string | number, volumeData: UpdateVolumeBody, organizationId: string) => {
const existing = await findVolume(idOrShortId, organizationId);
if (!existing) {
throw new NotFoundError("Volume not found");
@ -277,10 +277,8 @@ const testConnection = async (backendConfig: BackendConfig) => {
};
};
const checkHealth = async (name: string, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
const checkHealth = async (idOrShortId: string | number, organizationId: string) => {
const volume = await findVolume(idOrShortId, organizationId);
if (!volume) {
throw new NotFoundError("Volume not found");
@ -290,7 +288,7 @@ const checkHealth = async (name: string, organizationId: string) => {
const { error, status } = await backend.checkHealth();
if (status !== volume.status) {
serverEvents.emit("volume:status_changed", { volumeName: name, status });
serverEvents.emit("volume:status_changed", { volumeName: volume.name, status });
}
await db
@ -301,10 +299,8 @@ const checkHealth = async (name: string, organizationId: string) => {
return { status, error };
};
const listFiles = async (name: string, organizationId: string, subPath?: string) => {
const volume = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
const listFiles = async (idOrShortId: string | number, organizationId: string, subPath?: string) => {
const volume = await findVolume(idOrShortId, organizationId);
if (!volume) {
throw new NotFoundError("Volume not found");