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 = ( export const downloadResticPasswordMutation = (
options?: Partial<Options<DownloadResticPasswordData>>, 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>) => export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) =>
(options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}", url: "/api/v1/volumes/{id}",
...options, ...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>) => export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) =>
(options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}", url: "/api/v1/volumes/{id}",
...options, ...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>) => export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) =>
(options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({ (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}", url: "/api/v1/volumes/{id}",
...options, ...options,
headers: { headers: {
"Content-Type": "application/json", "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>) => export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) =>
(options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/mount", url: "/api/v1/volumes/{id}/mount",
...options, ...options,
}); });
@ -221,7 +221,7 @@ export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>, options: Options<UnmountVolumeData, ThrowOnError>,
) => ) =>
(options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/unmount", url: "/api/v1/volumes/{id}/unmount",
...options, ...options,
}); });
@ -232,7 +232,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
options: Options<HealthCheckVolumeData, ThrowOnError>, options: Options<HealthCheckVolumeData, ThrowOnError>,
) => ) =>
(options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/health-check", url: "/api/v1/volumes/{id}/health-check",
...options, ...options,
}); });
@ -241,7 +241,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
*/ */
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) =>
(options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/files", url: "/api/v1/volumes/{id}/files",
...options, ...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>( export const downloadResticPassword = <ThrowOnError extends boolean = false>(
options?: Options<DownloadResticPasswordData, ThrowOnError>, 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"; import { parseError } from "../lib/errors";
type VolumeFileBrowserProps = { type VolumeFileBrowserProps = {
volumeName: string; volumeId: string;
enabled?: boolean; enabled?: boolean;
withCheckboxes?: boolean; withCheckboxes?: boolean;
selectedPaths?: Set<string>; selectedPaths?: Set<string>;
@ -18,7 +18,7 @@ type VolumeFileBrowserProps = {
}; };
export const VolumeFileBrowser = ({ export const VolumeFileBrowser = ({
volumeName, volumeId,
enabled = true, enabled = true,
withCheckboxes = false, withCheckboxes = false,
selectedPaths, selectedPaths,
@ -31,7 +31,7 @@ export const VolumeFileBrowser = ({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }), ...listFilesOptions({ path: { id: volumeId } }),
enabled, enabled,
}); });
@ -41,7 +41,7 @@ export const VolumeFileBrowser = ({
fetchFolder: async (path) => { fetchFolder: async (path) => {
return await queryClient.ensureQueryData( return await queryClient.ensureQueryData(
listFilesOptions({ listFilesOptions({
path: { name: volumeName }, path: { id: volumeId },
query: { path }, query: { path },
}), }),
); );
@ -49,7 +49,7 @@ export const VolumeFileBrowser = ({
prefetchFolder: (path) => { prefetchFolder: (path) => {
void queryClient.prefetchQuery( void queryClient.prefetchQuery(
listFilesOptions({ listFilesOptions({
path: { name: volumeName }, path: { id: volumeId },
query: { path }, query: { path },
}), }),
); );

View file

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

View file

@ -86,7 +86,7 @@ export const ScheduleSummary = (props: Props) => {
<div> <div>
<CardTitle>{schedule.name}</CardTitle> <CardTitle>{schedule.name}</CardTitle>
<CardDescription className="mt-1"> <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" /> <HardDrive className="inline h-4 w-4 mr-2" />
<span>{schedule.volume.name}</span> <span>{schedule.volume.name}</span>
</Link> </Link>

View file

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

View file

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

View file

@ -33,7 +33,7 @@ export default function CreateVolume() {
...createVolumeMutation(), ...createVolumeMutation(),
onSuccess: (data) => { onSuccess: (data) => {
toast.success("Volume created successfully"); 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 = { 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) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: `Zerobyte - ${params.name}` }, { title: `Zerobyte - ${params.id}` },
{ {
name: "description", name: "description",
content: "View and manage volume details, configuration, and files.", 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) => { 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; if (volume.data) return volume.data;
}; };
export default function VolumeDetails({ loaderData }: Route.ComponentProps) { export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info"; const activeTab = searchParams.get("tab") || "info";
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({ const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }), ...getVolumeOptions({ path: { id: id ?? "" } }),
initialData: loaderData, initialData: loaderData,
}); });
@ -110,10 +110,10 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
deleteVol.mutate({ path: { name: name ?? "" } }); deleteVol.mutate({ path: { id: id ?? "" } });
}; };
if (!name) { if (!id) {
return <div>Volume not found</div>; return <div>Volume not found</div>;
} }
@ -139,7 +139,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
onClick={() => mountVol.mutate({ path: { name } })} onClick={() => mountVol.mutate({ path: { id } })}
loading={mountVol.isPending} loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })} className={cn({ hidden: volume.status === "mounted" })}
> >
@ -148,7 +148,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => unmountVol.mutate({ path: { name } })} onClick={() => unmountVol.mutate({ path: { id } })}
loading={unmountVol.isPending} loading={unmountVol.isPending}
className={cn({ hidden: volume.status !== "mounted" })} className={cn({ hidden: volume.status !== "mounted" })}
> >
@ -178,7 +178,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete volume?</AlertDialogTitle> <AlertDialogTitle>Delete volume?</AlertDialogTitle>
<AlertDialogDescription> <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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">

View file

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

View file

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

View file

@ -37,7 +37,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
setPendingValues(null); setPendingValues(null);
if (data.name !== volume.name) { if (data.name !== volume.name) {
void navigate(`/volumes/${data.name}`); void navigate(`/volumes/${data.shortId}`);
} }
}, },
onError: (error) => { onError: (error) => {
@ -58,7 +58,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const confirmUpdate = () => { const confirmUpdate = () => {
if (pendingValues) { if (pendingValues) {
updateMutation.mutate({ updateMutation.mutate({
path: { name: volume.name }, path: { id: volume.shortId },
body: { name: pendingValues.name, config: pendingValues }, 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, "when": 1768684588784,
"tag": "0041_motionless_storm", "tag": "0041_motionless_storm",
"breakpoints": true "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("/", "./client/routes/root.tsx"),
route("volumes", "./client/modules/volumes/routes/volumes.tsx"), route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
route("volumes/create", "./client/modules/volumes/routes/create-volume.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", "./client/modules/backups/routes/backups.tsx"),
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"), route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./client/modules/backups/routes/backup-details.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") organizationId: text("organization_id")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
}); }, (table) => [unique().on(table.name, table.organizationId)]);
export type Volume = typeof volumesTable.$inferSelect; export type Volume = typeof volumesTable.$inferSelect;
export type VolumeInsert = typeof volumesTable.$inferInsert; export type VolumeInsert = typeof volumesTable.$inferInsert;

View file

@ -16,7 +16,7 @@ export class VolumeAutoRemountJob extends Job {
for (const volume of volumes) { for (const volume of volumes) {
if (volume.autoRemount) { if (volume.autoRemount) {
try { try {
await volumeService.mountVolume(volume.name, volume.organizationId); await volumeService.mountVolume(volume.id, volume.organizationId);
} catch (err) { } catch (err) {
logger.error(`Failed to auto-remount volume ${volume.name}:`, 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) { 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) { 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({}); const volumes = await db.query.volumesTable.findMany({});
for (const volume of volumes) { 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}`); logger.error(`Failed to update volume ${volume.name}: ${err}`);
}); });
} }
@ -69,7 +69,7 @@ export const startup = async () => {
}); });
for (const volume of volumes) { 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}`); 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); return c.json(result, 200);
}) })
.delete("/:name", deleteVolumeDto, async (c) => { .delete("/:id", deleteVolumeDto, async (c) => {
const { name } = c.req.param(); const { id } = c.req.param();
await volumeService.deleteVolume(name, c.get("organizationId")); await volumeService.deleteVolume(id, c.get("organizationId"));
return c.json({ message: "Volume deleted" }, 200); return c.json({ message: "Volume deleted" }, 200);
}) })
.get("/:name", getVolumeDto, async (c) => { .get("/:id", getVolumeDto, async (c) => {
const { name } = c.req.param(); const { id } = c.req.param();
const res = await volumeService.getVolume(name, c.get("organizationId")); const res = await volumeService.getVolume(id, c.get("organizationId"));
const response = { const response = {
volume: { volume: {
@ -74,10 +74,10 @@ export const volumeController = new Hono()
return c.json<GetVolumeDto>(response, 200); return c.json<GetVolumeDto>(response, 200);
}) })
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => { .put("/:id", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param(); const { id } = c.req.param();
const body = c.req.valid("json"); 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 = { const response = {
...res.volume, ...res.volume,
@ -86,28 +86,28 @@ export const volumeController = new Hono()
return c.json<UpdateVolumeDto>(response, 200); return c.json<UpdateVolumeDto>(response, 200);
}) })
.post("/:name/mount", mountVolumeDto, async (c) => { .post("/:id/mount", mountVolumeDto, async (c) => {
const { name } = c.req.param(); const { id } = c.req.param();
const { error, status } = await volumeService.mountVolume(name, c.get("organizationId")); const { error, status } = await volumeService.mountVolume(id, c.get("organizationId"));
return c.json({ error, status }, error ? 500 : 200); return c.json({ error, status }, error ? 500 : 200);
}) })
.post("/:name/unmount", unmountVolumeDto, async (c) => { .post("/:id/unmount", unmountVolumeDto, async (c) => {
const { name } = c.req.param(); const { id } = c.req.param();
const { error, status } = await volumeService.unmountVolume(name, c.get("organizationId")); const { error, status } = await volumeService.unmountVolume(id, c.get("organizationId"));
return c.json({ error, status }, error ? 500 : 200); return c.json({ error, status }, error ? 500 : 200);
}) })
.post("/:name/health-check", healthCheckDto, async (c) => { .post("/:id/health-check", healthCheckDto, async (c) => {
const { name } = c.req.param(); const { id } = c.req.param();
const { error, status } = await volumeService.checkHealth(name, c.get("organizationId")); const { error, status } = await volumeService.checkHealth(id, c.get("organizationId"));
return c.json({ error, status }, 200); return c.json({ error, status }, 200);
}) })
.get("/:name/files", listFilesDto, async (c) => { .get("/:id/files", listFilesDto, async (c) => {
const { name } = c.req.param(); const { id } = c.req.param();
const subPath = c.req.query("path"); 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 = { const response = {
files: result.files, files: result.files,

View file

@ -50,6 +50,16 @@ const listVolumes = async (organizationId: string) => {
return volumes; 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 createVolume = async (name: string, backendConfig: BackendConfig, organizationId: string) => {
const slug = slugify(name, { lower: true, strict: true }); 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 }; return { volume: created, status: 201 };
}; };
const deleteVolume = async (name: string, organizationId: string) => { const deleteVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({ const volume = await findVolume(idOrShortId, organizationId);
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
if (!volume) { if (!volume) {
throw new NotFoundError("Volume not found"); 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))); .where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
}; };
const mountVolume = async (name: string, organizationId: string) => { const mountVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({ const volume = await findVolume(idOrShortId, organizationId);
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
if (!volume) { if (!volume) {
throw new NotFoundError("Volume not found"); 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))); .where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
if (status === "mounted") { if (status === "mounted") {
serverEvents.emit("volume:mounted", { volumeName: name }); serverEvents.emit("volume:mounted", { volumeName: volume.name });
} }
return { error, status }; return { error, status };
}; };
const unmountVolume = async (name: string, organizationId: string) => { const unmountVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({ const volume = await findVolume(idOrShortId, organizationId);
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
if (!volume) { if (!volume) {
throw new NotFoundError("Volume not found"); 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))); .where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
if (status === "unmounted") { if (status === "unmounted") {
serverEvents.emit("volume:unmounted", { volumeName: name }); serverEvents.emit("volume:unmounted", { volumeName: volume.name });
} }
return { error, status }; return { error, status };
}; };
const getVolume = async (name: string, organizationId: string) => { const getVolume = async (idOrShortId: string | number, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({ const volume = await findVolume(idOrShortId, organizationId);
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
if (!volume) { if (!volume) {
throw new NotFoundError("Volume not found"); throw new NotFoundError("Volume not found");
@ -166,7 +168,7 @@ const getVolume = async (name: string, organizationId: string) => {
let statfs: Partial<StatFs> = {}; let statfs: Partial<StatFs> = {};
if (volume.status === "mounted") { if (volume.status === "mounted") {
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => { 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 {}; return {};
}); });
} }
@ -174,10 +176,8 @@ const getVolume = async (name: string, organizationId: string) => {
return { volume, statfs }; return { volume, statfs };
}; };
const updateVolume = async (name: string, volumeData: UpdateVolumeBody, organizationId: string) => { const updateVolume = async (idOrShortId: string | number, volumeData: UpdateVolumeBody, organizationId: string) => {
const existing = await db.query.volumesTable.findFirst({ const existing = await findVolume(idOrShortId, organizationId);
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
if (!existing) { if (!existing) {
throw new NotFoundError("Volume not found"); throw new NotFoundError("Volume not found");
@ -277,10 +277,8 @@ const testConnection = async (backendConfig: BackendConfig) => {
}; };
}; };
const checkHealth = async (name: string, organizationId: string) => { const checkHealth = async (idOrShortId: string | number, organizationId: string) => {
const volume = await db.query.volumesTable.findFirst({ const volume = await findVolume(idOrShortId, organizationId);
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
if (!volume) { if (!volume) {
throw new NotFoundError("Volume not found"); throw new NotFoundError("Volume not found");
@ -290,7 +288,7 @@ const checkHealth = async (name: string, organizationId: string) => {
const { error, status } = await backend.checkHealth(); const { error, status } = await backend.checkHealth();
if (status !== volume.status) { if (status !== volume.status) {
serverEvents.emit("volume:status_changed", { volumeName: name, status }); serverEvents.emit("volume:status_changed", { volumeName: volume.name, status });
} }
await db await db
@ -301,10 +299,8 @@ const checkHealth = async (name: string, organizationId: string) => {
return { status, error }; return { status, error };
}; };
const listFiles = async (name: string, organizationId: string, subPath?: string) => { const listFiles = async (idOrShortId: string | number, organizationId: string, subPath?: string) => {
const volume = await db.query.volumesTable.findFirst({ const volume = await findVolume(idOrShortId, organizationId);
where: and(eq(volumesTable.name, name), eq(volumesTable.organizationId, organizationId)),
});
if (!volume) { if (!volume) {
throw new NotFoundError("Volume not found"); throw new NotFoundError("Volume not found");