diff --git a/create-a-container/client/package-lock.json b/create-a-container/client/package-lock.json
index d0517d00..1d0ec772 100644
--- a/create-a-container/client/package-lock.json
+++ b/create-a-container/client/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@hookform/resolvers": "^3.9.1",
- "@mieweb/ui": "*",
+ "@mieweb/ui": "latest",
"@tanstack/react-query": "^5.62.0",
"lucide-react": "^0.460.0",
"react": "^19.0.0",
diff --git a/create-a-container/client/src/components/containers/CollaboratorsManager.tsx b/create-a-container/client/src/components/containers/CollaboratorsManager.tsx
new file mode 100644
index 00000000..65d28746
--- /dev/null
+++ b/create-a-container/client/src/components/containers/CollaboratorsManager.tsx
@@ -0,0 +1,163 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Button, Input, ServiceBadge, useToast } from '@mieweb/ui';
+import { UserPlus } from 'lucide-react';
+import { ApiError } from '@/lib/api';
+import { keys, queries } from '@/lib/queries';
+
+/**
+ * Presentational list of collaborator usernames rendered as removable chips.
+ * Shared by the live manager and the create-form's collaborators field so
+ * both surfaces look identical.
+ */
+export function CollaboratorChips({
+ usernames,
+ onRemove,
+ disabled,
+ emptyText = 'Not shared with anyone yet.',
+}: {
+ usernames: string[];
+ onRemove: (username: string) => void;
+ disabled?: boolean;
+ emptyText?: string;
+}) {
+ if (usernames.length === 0) {
+ return
{emptyText}
;
+ }
+ return (
+
+ {usernames.map((username) => (
+ -
+ {
+ if (!disabled) onRemove(username);
+ }}
+ >
+ {username}
+
+
+ ))}
+
+ );
+}
+
+/**
+ * An accessible username input + add button. Surfaces a validation/error
+ * message (e.g. "user does not exist") inline beneath the field.
+ */
+export function AddCollaboratorField({
+ onAdd,
+ pending,
+ error,
+ label = 'Username',
+}: {
+ onAdd: (username: string) => void;
+ pending?: boolean;
+ error?: string | null;
+ label?: string;
+}) {
+ const [value, setValue] = useState('');
+
+ const submit = () => {
+ const username = value.trim();
+ if (!username) return;
+ onAdd(username);
+ setValue('');
+ };
+
+ return (
+
+
+ setValue(e.target.value)}
+ onKeyDown={(e) => {
+ // Submit on Enter without bubbling to an enclosing form.
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ submit();
+ }
+ }}
+ />
+
+
}
+ isLoading={pending}
+ disabled={!value.trim()}
+ onClick={submit}
+ >
+ Share
+
+
+ );
+}
+
+/**
+ * Live sharing manager for an existing container: lists current collaborators
+ * and lets the owner/admin add or remove them via the API. Used both in the
+ * list page's share dialog and the edit form's Sharing section.
+ */
+export function CollaboratorsManager({
+ siteId,
+ containerId,
+ collaborators,
+}: {
+ siteId: string;
+ containerId: number;
+ collaborators: string[];
+}) {
+ const qc = useQueryClient();
+ const toast = useToast();
+ const [addError, setAddError] = useState(null);
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) });
+ qc.invalidateQueries({ queryKey: keys.containers(siteId) });
+ };
+
+ const add = useMutation({
+ mutationFn: (username: string) => queries.shareContainer(siteId, containerId, username),
+ onSuccess: (_data, username) => {
+ setAddError(null);
+ toast.success(`Shared with ${username}`);
+ invalidate();
+ },
+ onError: (err: ApiError) => setAddError(err.message),
+ });
+
+ const remove = useMutation({
+ mutationFn: (username: string) => queries.unshareContainer(siteId, containerId, username),
+ onSuccess: (_data, username) => {
+ toast.success(`Stopped sharing with ${username}`);
+ invalidate();
+ },
+ onError: (err: ApiError) => toast.error(err.message),
+ });
+
+ return (
+
+
remove.mutate(u)}
+ disabled={remove.isPending}
+ />
+ add.mutate(u)}
+ pending={add.isPending}
+ error={addError}
+ />
+
+ );
+}
diff --git a/create-a-container/client/src/components/containers/ContainerFilters.tsx b/create-a-container/client/src/components/containers/ContainerFilters.tsx
new file mode 100644
index 00000000..4695fee8
--- /dev/null
+++ b/create-a-container/client/src/components/containers/ContainerFilters.tsx
@@ -0,0 +1,173 @@
+import { useState } from 'react';
+import { Button, Dropdown, DropdownItem, Input } from '@mieweb/ui';
+import { ChevronDown, Filter, User as UserIcon, X } from 'lucide-react';
+
+export interface FilterOption {
+ value: string;
+ label: string;
+}
+
+/**
+ * A compact multiselect built on @mieweb/ui's Dropdown (multiSelect mode).
+ * Selected values are controlled by the parent so filter state stays in the URL.
+ */
+function MultiSelect({
+ label,
+ emptyLabel,
+ icon,
+ options,
+ selected,
+ onChange,
+ searchable = false,
+ showSelectAll = false,
+}: {
+ label: string;
+ /** Trigger summary shown when nothing is selected. */
+ emptyLabel: string;
+ icon?: React.ReactElement;
+ options: FilterOption[];
+ selected: string[];
+ onChange: (next: string[]) => void;
+ searchable?: boolean;
+ showSelectAll?: boolean;
+}) {
+ const summary =
+ selected.length === 0
+ ? emptyLabel
+ : selected.length === 1
+ ? (options.find((o) => o.value === selected[0])?.label ?? selected[0])
+ : `${selected.length} selected`;
+
+ return (
+
+ {label}:
+ {summary}
+
+
+ }
+ >
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ );
+}
+
+export interface ContainerFiltersProps {
+ userOptions: FilterOption[];
+ selectedUsers: string[];
+ onUsersChange: (next: string[]) => void;
+ statusOptions: FilterOption[];
+ selectedStatuses: string[];
+ onStatusesChange: (next: string[]) => void;
+ templateOptions: FilterOption[];
+ selectedTemplates: string[];
+ onTemplatesChange: (next: string[]) => void;
+ hostname: string;
+ onHostnameChange: (next: string) => void;
+ onClearAll: () => void;
+}
+
+/**
+ * Filter bar for the containers list. "User" is always visible and drives the
+ * server query; the remaining filters (status, template, hostname) live behind
+ * a "More filters" disclosure and filter the loaded rows client-side.
+ */
+export function ContainerFilters({
+ userOptions,
+ selectedUsers,
+ onUsersChange,
+ statusOptions,
+ selectedStatuses,
+ onStatusesChange,
+ templateOptions,
+ selectedTemplates,
+ onTemplatesChange,
+ hostname,
+ onHostnameChange,
+ onClearAll,
+}: ContainerFiltersProps) {
+ const [showMore, setShowMore] = useState(false);
+ const extraCount =
+ selectedStatuses.length + selectedTemplates.length + (hostname ? 1 : 0);
+ const activeCount = selectedUsers.length + extraCount;
+
+ return (
+
+
+ }
+ options={userOptions}
+ selected={selectedUsers}
+ onChange={onUsersChange}
+ searchable
+ showSelectAll
+ />
+ }
+ aria-expanded={showMore}
+ onClick={() => setShowMore((s) => !s)}
+ >
+ More filters{extraCount > 0 ? ` (${extraCount})` : ''}
+
+ {activeCount > 0 && (
+ }
+ onClick={onClearAll}
+ className="ml-auto text-muted-foreground"
+ >
+ Clear
+
+ )}
+
+ {showMore && (
+
+
+
+ onHostnameChange(e.target.value)}
+ placeholder="Search hostname…"
+ aria-label="Search by hostname"
+ className="h-8 w-48"
+ />
+
+ )}
+
+ );
+}
diff --git a/create-a-container/client/src/components/containers/HttpLinks.tsx b/create-a-container/client/src/components/containers/HttpLinks.tsx
new file mode 100644
index 00000000..2880b409
--- /dev/null
+++ b/create-a-container/client/src/components/containers/HttpLinks.tsx
@@ -0,0 +1,31 @@
+import { ExternalLink } from 'lucide-react';
+import type { Container } from '@/lib/types';
+import { linkClass } from './shared';
+
+/** External links for a container's HTTP services, optionally capped at `limit`. */
+export function HttpLinks({ c, limit }: { c: Container; limit?: number }) {
+ if (c.httpEntries.length === 0) return —;
+ const entries = limit ? c.httpEntries.slice(0, limit) : c.httpEntries;
+ return (
+
+ {entries.map((h) =>
+ h.externalUrl ? (
+
+
+ {h.externalUrl.replace(/^https?:\/\//, '')}
+
+ ) : (
+
+ :{h.port}
+
+ ),
+ )}
+
+ );
+}
diff --git a/create-a-container/client/src/components/containers/Meta.tsx b/create-a-container/client/src/components/containers/Meta.tsx
new file mode 100644
index 00000000..be74c6bd
--- /dev/null
+++ b/create-a-container/client/src/components/containers/Meta.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react';
+
+/** Inline labelled value used for container card metadata (Node, User, HTTP, …). */
+export function Meta({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
diff --git a/create-a-container/client/src/components/containers/NodeLink.tsx b/create-a-container/client/src/components/containers/NodeLink.tsx
new file mode 100644
index 00000000..498af3b3
--- /dev/null
+++ b/create-a-container/client/src/components/containers/NodeLink.tsx
@@ -0,0 +1,18 @@
+import type { Container } from '@/lib/types';
+import { linkClass } from './shared';
+
+/** Node name linked to the node's Proxmox web UI (LXC view when provisioned). */
+export function NodeLink({ c }: { c: Container }) {
+ if (!c.nodeApiUrl) return <>{c.nodeName || '—'}>;
+ return (
+
+ {c.nodeName || c.nodeApiUrl}
+
+ );
+}
diff --git a/create-a-container/client/src/pages/containers/ResourcesSection.tsx b/create-a-container/client/src/components/containers/ResourcesSection.tsx
similarity index 100%
rename from create-a-container/client/src/pages/containers/ResourcesSection.tsx
rename to create-a-container/client/src/components/containers/ResourcesSection.tsx
diff --git a/create-a-container/client/src/components/containers/RowActions.tsx b/create-a-container/client/src/components/containers/RowActions.tsx
new file mode 100644
index 00000000..0a21add2
--- /dev/null
+++ b/create-a-container/client/src/components/containers/RowActions.tsx
@@ -0,0 +1,68 @@
+import { Link } from 'react-router';
+import { Button } from '@mieweb/ui';
+import { Pencil, Share2, Trash2 } from 'lucide-react';
+import type { Container } from '@/lib/types';
+
+/** Per-row actions for the containers list: logs, share, edit, delete. */
+export function RowActions({
+ c,
+ siteId,
+ onDelete,
+ deleting,
+ canShare,
+ onShare,
+}: {
+ c: Container;
+ siteId?: string;
+ onDelete: (id: number) => void;
+ deleting: boolean;
+ canShare: boolean;
+ onShare: (c: Container) => void;
+}) {
+ return (
+ <>
+ {c.creationJobId && (
+
+
+
+ )}
+ {canShare && (
+ }
+ onClick={() => onShare(c)}
+ >
+ Share
+
+ )}
+
+ }
+ >
+ Edit
+
+
+ }
+ onClick={() => {
+ if (confirm(`Delete container "${c.hostname}"?`)) onDelete(c.id);
+ }}
+ disabled={deleting}
+ // Danger tint on hover only; the built-in ghost transition still applies.
+ className="hover:bg-red-100 hover:text-red-700 dark:hover:bg-red-900/40 dark:hover:text-red-200"
+ >
+ Delete
+
+ >
+ );
+}
diff --git a/create-a-container/client/src/components/containers/SshLinks.tsx b/create-a-container/client/src/components/containers/SshLinks.tsx
new file mode 100644
index 00000000..9e6bae2a
--- /dev/null
+++ b/create-a-container/client/src/components/containers/SshLinks.tsx
@@ -0,0 +1,39 @@
+import { Code2, Terminal } from 'lucide-react';
+import type { Container } from '@/lib/types';
+import { linkClass } from './shared';
+
+/** A container's SSH endpoint with VS Code Remote and terminal deep links. */
+export function SshLinks({ c, sessionUser }: { c: Container; sessionUser?: string }) {
+ if (!c.sshHost || !c.sshPort) return —;
+ return (
+
+
+ {c.sshHost}:{c.sshPort}
+
+ {sessionUser && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/create-a-container/client/src/components/containers/StatusBadge.tsx b/create-a-container/client/src/components/containers/StatusBadge.tsx
new file mode 100644
index 00000000..f9ef7058
--- /dev/null
+++ b/create-a-container/client/src/components/containers/StatusBadge.tsx
@@ -0,0 +1,35 @@
+import { Badge } from '@mieweb/ui';
+import type { ContainerStatus } from '@/lib/types';
+
+// Human-readable labels for the live status values.
+export const STATUS_LABELS: Record = {
+ running: 'Running',
+ offline: 'Offline',
+ creating: 'Creating',
+ failed: 'Failed',
+ missing: 'Missing',
+ unknown: 'Unknown',
+};
+
+function statusVariant(
+ s: ContainerStatus,
+): 'default' | 'success' | 'warning' | 'danger' | 'secondary' {
+ switch (s) {
+ case 'running':
+ return 'success';
+ case 'creating':
+ return 'warning';
+ case 'failed':
+ return 'danger';
+ case 'offline':
+ case 'missing':
+ return 'secondary';
+ default:
+ return 'default';
+ }
+}
+
+/** Status badge. The status is the live value embedded in the list response. */
+export function StatusBadge({ status }: { status: ContainerStatus }) {
+ return {STATUS_LABELS[status] ?? status};
+}
diff --git a/create-a-container/client/src/components/containers/shared.ts b/create-a-container/client/src/components/containers/shared.ts
new file mode 100644
index 00000000..1ff74f6d
--- /dev/null
+++ b/create-a-container/client/src/components/containers/shared.ts
@@ -0,0 +1,8 @@
+/** Link styling shared by the container link components. */
+export const linkClass = 'text-(--color-primary,#1d4ed8) hover:underline';
+
+/** Shorten a full image ref to just its name+tag, e.g. ghcr.io/mieweb/base:latest -> base:latest */
+export function templateTitle(template: string | null): string {
+ if (!template) return '—';
+ return template.split('/').pop() || template;
+}
diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts
index c413027e..1177f3f5 100644
--- a/create-a-container/client/src/lib/queries.ts
+++ b/create-a-container/client/src/lib/queries.ts
@@ -26,7 +26,7 @@ export const keys = {
nodes: (siteId: number | string) => ['sites', String(siteId), 'nodes'] as const,
node: (siteId: number | string, id: number | string) =>
['sites', String(siteId), 'nodes', String(id)] as const,
- containers: (siteId: number | string, params?: Record) =>
+ containers: (siteId: number | string, params?: Record) =>
['sites', String(siteId), 'containers', params ?? {}] as const,
container: (siteId: number | string, id: number | string) =>
['sites', String(siteId), 'containers', String(id)] as const,
@@ -63,13 +63,13 @@ export const queries = {
// Containers
listContainers: (
siteId: number | string,
- params?: { user?: string; nodeId?: string; hostname?: string },
+ params?: { user?: string[]; nodeId?: string; hostname?: string },
) => {
const qs = new URLSearchParams();
- // `user` is sent verbatim, including '*' (all owners, admin only) and ''
- // (own containers). Only omit it when undefined so the default still maps
- // to the requesting user server-side.
- if (params?.user !== undefined) qs.set('user', params.user);
+ // `user` is sent in bracket notation (user[0]=..., user[1]=...) so the
+ // server's 'extended' query parser always receives an array. Omitted/empty
+ // returns everything the caller may see server-side.
+ params?.user?.forEach((u, i) => qs.set(`user[${i}]`, u));
if (params?.nodeId) qs.set('nodeId', params.nodeId);
if (params?.hostname) qs.set('hostname', params.hostname);
const suffix = qs.toString() ? `?${qs.toString()}` : '';
@@ -77,6 +77,17 @@ export const queries = {
},
getContainer: (siteId: number | string, id: number | string) =>
api.get(`/api/v1/sites/${siteId}/containers/${id}`),
+ // Container sharing (collaborators). Both return the updated collaborator
+ // username list.
+ shareContainer: (siteId: number | string, id: number | string, username: string) =>
+ api.post<{ collaborators: string[] }>(
+ `/api/v1/sites/${siteId}/containers/${id}/collaborators`,
+ { username },
+ ),
+ unshareContainer: (siteId: number | string, id: number | string, username: string) =>
+ api.delete<{ collaborators: string[] }>(
+ `/api/v1/sites/${siteId}/containers/${id}/collaborators/${encodeURIComponent(username)}`,
+ ),
containerBootstrap: (siteId: number | string) =>
api.get(`/api/v1/sites/${siteId}/containers/new`),
containerMetadata: (siteId: number | string, image: string) =>
diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts
index dcd62bfe..357da690 100644
--- a/create-a-container/client/src/lib/types.ts
+++ b/create-a-container/client/src/lib/types.ts
@@ -74,6 +74,8 @@ export interface Container {
containerId: number | null;
hostname: string;
owner: string;
+ /** Usernames this container is shared with (collaborators). */
+ collaborators: string[];
ipv4Address: string | null;
macAddress: string | null;
status: ContainerStatus;
diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
index 87ec7ebe..a05c5d4b 100644
--- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
+++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
@@ -25,7 +25,8 @@ import { api, ApiError } from '@/lib/api';
import { keys, queries } from '@/lib/queries';
import { FormPageHeader } from '@/components/FormPageHeader';
import { randomHostname } from '@/lib/randomHostname';
-import { ResourcesSection } from './ResourcesSection';
+import { ResourcesSection } from '@/components/containers/ResourcesSection';
+import { AddCollaboratorField, CollaboratorChips, CollaboratorsManager } from '@/components/containers/CollaboratorsManager';
import type { ContainerCreateResult, ContainerMetadata } from '@/lib/types';
function useDebouncedValue(value: T, delay = 500): T {
@@ -85,6 +86,9 @@ const schema = z.object({
restart: z.boolean().optional(),
services: z.array(serviceSchema),
environmentVars: z.array(envVarSchema),
+ // Usernames to share a new container with (collaborators). Existence is
+ // validated server-side on submit. Unused in edit mode (live manager instead).
+ collaborators: z.array(z.string()),
});
type FormData = z.infer;
@@ -121,12 +125,15 @@ export function ContainerFormPage() {
defaultValues: {
services: [],
environmentVars: [],
+ collaborators: [],
nvidiaRequested: false,
restart: false,
},
});
const services = useFieldArray({ control, name: 'services' });
const envVars = useFieldArray({ control, name: 'environmentVars' });
+ // Guards the one-time form initialization from the loaded container (edit).
+ const initializedRef = useRef(false);
// Default external domain for new HTTP services. The bootstrap endpoint
// returns domains already sorted so the site's default domains come first,
@@ -143,9 +150,15 @@ export function ContainerFormPage() {
const hostname = watch('hostname');
const debouncedHostname = useDebouncedValue(hostname || '', 500);
const customTemplate = watch('customTemplate');
+ const collaborators = watch('collaborators') || [];
useEffect(() => {
- if (container && isEdit) {
+ if (container && isEdit && !initializedRef.current) {
+ // Initialize the form from the loaded container exactly once. Re-running
+ // reset on every `container` change would wipe unsaved edits whenever the
+ // container query refetches (e.g. after the Sharing manager adds/removes a
+ // collaborator and invalidates this query).
+ initializedRef.current = true;
reset({
hostname: container.hostname,
template: container.template || '',
@@ -173,6 +186,7 @@ export function ContainerFormPage() {
key,
value,
})),
+ collaborators: [],
});
}
}, [container, isEdit, reset]);
@@ -271,6 +285,8 @@ export function ContainerFormPage() {
services: servicesObj,
environmentVars: values.environmentVars.filter((e) => e.key.trim()),
restart: values.restart,
+ // Only meaningful on create; the edit form manages sharing live.
+ collaborators: values.collaborators,
};
type UpdateResult = {
containerId: number;
@@ -666,6 +682,51 @@ export function ContainerFormPage() {
))}
+
+
+
+ Sharing
+
+
+ {isEdit && container ? (
+ <>
+
+ Share this container with other users for collaboration. They will see it in
+ their All containers tab.
+
+
+ >
+ ) : (
+ <>
+
+ Optionally add other users as collaborators. They will see this container in
+ their All containers tab once it is created.
+
+
+ setValue(
+ 'collaborators',
+ collaborators.filter((x) => x !== u),
+ )
+ }
+ />
+ {
+ if (!collaborators.includes(u)) {
+ setValue('collaborators', [...collaborators, u]);
+ }
+ }}
+ />
+ >
+ )}
+
-
- }
- onClick={() => {
- if (confirm(`Delete container "${c.hostname}"?`)) onDelete(c.id);
- }}
- disabled={deleting}
- >
- Delete
-
- >
- );
-}
-
-function Meta({ label, children }: { label: string; children: React.ReactNode }) {
- return (
-
-
- {label}
-
- {children}
-
- );
-}
-
export function ContainersListPage() {
const { siteId } = useParams<{ siteId: string }>();
const qc = useQueryClient();
@@ -218,24 +56,62 @@ export function ContainersListPage() {
const sessionUser = session?.user;
const isAdmin = !!session?.isAdmin;
const location = useLocation();
- const [searchParams] = useSearchParams();
+ const [searchParams, setSearchParams] = useSearchParams();
const nodeId = searchParams.get('nodeId') || undefined;
- // `user=*` lists every owner on the site for admins; for non-admins the
- // server scopes it back to their own containers, so the toggle is available
- // to everyone (ahead of future shareable/collaborative containers). The param
- // is the single source of truth so the view is bookmarkable and slots in
- // beside the existing hostname/nodeId filters.
- const isAll = searchParams.get('user') === '*';
- const userFilter = isAll ? '*' : undefined;
- // Only admins ever see more than one owner in the `*` view, so the owner
- // column is meaningful for them alone.
- const showOwner = isAdmin && isAll;
+ // The URL is the single source of truth for the filter bar, so views are
+ // bookmarkable and shareable. `user` is a comma-separated owner list that
+ // drives the server query; an empty list returns everything the caller may
+ // see (all owners for admins; own + shared for non-admins).
+ // Status/template/hostname refine the loaded rows client-side.
+ const parseList = (v: string | null) => (v ? v.split(',').filter(Boolean) : []);
+ const selectedUsers = parseList(searchParams.get('user'));
+ const selectedStatuses = parseList(searchParams.get('status'));
+ const selectedTemplates = parseList(searchParams.get('template'));
+ const hostnameQuery = searchParams.get('q') ?? '';
+ // A row may belong to another owner unless the filter is narrowed to just
+ // the caller, so the owner column only disappears then.
+ const showOwner = selectedUsers.length === 0 || selectedUsers.some((u) => u !== sessionUser);
const dnsWarnings = (location.state as { dnsWarnings?: string[] } | null)?.dnsWarnings;
+ const setListParam = (key: string, values: string[]) =>
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (values.length > 0) next.set(key, values.join(','));
+ else next.delete(key);
+ return next;
+ },
+ { replace: true },
+ );
+ const setTextParam = (key: string, value: string) =>
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (value) next.set(key, value);
+ else next.delete(key);
+ return next;
+ },
+ { replace: true },
+ );
+ // The unfiltered default already shows everything the caller may see;
+ // select-all in the dropdown is just an explicit form of the same thing.
+ const clearAllFilters = () =>
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ ['user', 'status', 'template', 'q'].forEach((k) => next.delete(k));
+ return next;
+ },
+ { replace: true },
+ );
+
const [view, setView] = useState(() => {
const stored = typeof window !== 'undefined' ? window.localStorage.getItem(VIEW_STORAGE_KEY) : null;
return stored === 'table' ? 'table' : 'cards';
});
+ // Container whose sharing dialog is open. Tracked by id so the dialog reflects
+ // live collaborator changes after the list query refetches.
+ const [shareTargetId, setShareTargetId] = useState(null);
const changeView = (next: ViewMode) => {
setView(next);
try {
@@ -250,12 +126,30 @@ export function ContainersListPage() {
queryFn: () => queries.getSite(siteId!),
enabled: !!siteId,
});
+ // Empty selection returns everything the caller may see server-side.
+ const userParam = selectedUsers.length > 0 ? selectedUsers : undefined;
const { data, isLoading, error } = useQuery({
- queryKey: keys.containers(siteId!, { user: userFilter, nodeId }),
- queryFn: () => queries.listContainers(siteId!, { user: userFilter, nodeId }),
+ queryKey: keys.containers(siteId!, { user: userParam, nodeId }),
+ queryFn: () => queries.listContainers(siteId!, { user: userParam, nodeId }),
enabled: !!siteId,
});
+ // Options for the "User" filter. Admins may filter by any user; non-admins
+ // may only narrow to owners who have shared a container with them, so their
+ // option list is derived from the unfiltered list (own + shared). With no
+ // owner filter selected this is the same query as the list above, so React
+ // Query dedupes it into a single fetch.
+ const { data: allUsers } = useQuery({
+ queryKey: keys.users(),
+ queryFn: queries.listUsers,
+ enabled: !!siteId && isAdmin,
+ });
+ const { data: visibleContainers } = useQuery({
+ queryKey: keys.containers(siteId!, {}),
+ queryFn: () => queries.listContainers(siteId!),
+ enabled: !!siteId && !isAdmin,
+ });
+
const del = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/containers/${id}`),
onSuccess: () => {
@@ -265,33 +159,73 @@ export function ContainersListPage() {
onError: (err: ApiError) => toast.error(err.message),
});
- const hasContainers = !!data && data.length > 0;
+ // Client-side refinement of the loaded rows by status/template/hostname.
+ const visible = useMemo(() => {
+ const q = hostnameQuery.trim().toLowerCase();
+ return (data ?? []).filter(
+ (c) =>
+ (selectedStatuses.length === 0 || selectedStatuses.includes(c.status)) &&
+ (selectedTemplates.length === 0 ||
+ (c.template != null && selectedTemplates.includes(c.template))) &&
+ (q === '' || c.hostname.toLowerCase().includes(q)),
+ );
+ }, [data, selectedStatuses, selectedTemplates, hostnameQuery]);
+
+ const userOptions = useMemo(() => {
+ const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label);
+ if (isAdmin) {
+ const opts = (allUsers ?? []).map((u) => ({
+ value: u.uid,
+ label: u.uid === sessionUser ? `${u.cn} (me)` : u.cn,
+ }));
+ return opts.sort(byLabel);
+ }
+ const owners = new Set();
+ if (sessionUser) owners.add(sessionUser);
+ (visibleContainers ?? []).forEach((c) => owners.add(c.owner));
+ const opts = [...owners].map((o) => ({
+ value: o,
+ label: o === sessionUser ? `${o} (me)` : o,
+ }));
+ return opts.sort(byLabel);
+ }, [isAdmin, allUsers, visibleContainers, sessionUser]);
+
+ const statusOptions = useMemo(
+ () =>
+ (Object.keys(STATUS_LABELS) as ContainerStatus[]).map((s) => ({
+ value: s,
+ label: STATUS_LABELS[s],
+ })),
+ [],
+ );
+
+ const templateOptions = useMemo(() => {
+ const seen = new Map();
+ (data ?? []).forEach((c) => {
+ if (c.template) seen.set(c.template, templateTitle(c.template));
+ });
+ return [...seen.entries()]
+ .map(([value, label]) => ({ value, label }))
+ .sort((a, b) => a.label.localeCompare(b.label));
+ }, [data]);
+
+ const hasContainers = visible.length > 0;
+ const serverHasContainers = !!data && data.length > 0;
+
+ // Sharing is owner/admin only. Derive the open dialog's container from the
+ // live list so its collaborator chips update after add/remove.
+ const canShareContainer = (c: Container) => isAdmin || c.owner === sessionUser;
+ const shareTarget = data?.find((c) => c.id === shareTargetId) ?? null;
return (
}
actions={
-
-
-
- Mine
-
-
-
-
- All
-
-
-
- {hasContainers && (
+ {serverHasContainers && (
+
setListParam('user', v)}
+ statusOptions={statusOptions}
+ selectedStatuses={selectedStatuses}
+ onStatusesChange={(v) => setListParam('status', v)}
+ templateOptions={templateOptions}
+ selectedTemplates={selectedTemplates}
+ onTemplatesChange={(v) => setListParam('template', v)}
+ hostname={hostnameQuery}
+ onHostnameChange={(v) => setTextParam('q', v)}
+ onClearAll={clearAllFilters}
+ />
+
{error && (
{(error as ApiError).message}
@@ -359,16 +308,22 @@ export function ContainersListPage() {
No containers
- {showOwner
- ? 'No containers exist on this site yet.'
+ {selectedUsers.length > 0
+ ? 'No containers match the selected users.'
: 'Create your first container with the button above.'}
)}
+ {serverHasContainers && !hasContainers && (
+
+ No matches
+ No containers match the current filters.
+
+ )}
{hasContainers && view === 'cards' && (
- {data.map((c: Container) => (
+ {visible.map((c: Container) => (
-
+ setShareTargetId(target.id)}
+ />
@@ -429,7 +391,7 @@ export function ContainersListPage() {
- {data.map((c: Container) => (
+ {visible.map((c: Container) => (
{c.hostname}
@@ -456,13 +418,46 @@ export function ContainersListPage() {
-
+ setShareTargetId(target.id)}
+ />
))}
)}
+
+
!open && setShareTargetId(null)}
+ size="md"
+ >
+
+
+ {shareTarget ? `Share ${shareTarget.hostname}` : 'Share container'}
+
+
+
+
+
+ Share this container with other users for collaboration. Shared users can find it
+ by filtering the containers list by your username.
+
+ {shareTarget && siteId && (
+
+ )}
+
+
);
}
diff --git a/create-a-container/migrations/20260630120000-create-container-collaborators.js b/create-a-container/migrations/20260630120000-create-container-collaborators.js
new file mode 100644
index 00000000..05bdf058
--- /dev/null
+++ b/create-a-container/migrations/20260630120000-create-container-collaborators.js
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * Container sharing: a join table between containers and the additional users
+ * (collaborators) who have been granted access to a container. Membership is
+ * keyed by `username` (the user's `uid`) to mirror how a container records its
+ * primary owner (`Containers.username`).
+ *
+ * @type {import('sequelize-cli').Migration}
+ */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('ContainerCollaborators', {
+ id: {
+ allowNull: false,
+ autoIncrement: true,
+ primaryKey: true,
+ type: Sequelize.INTEGER,
+ },
+ containerId: {
+ type: Sequelize.INTEGER,
+ allowNull: false,
+ references: { model: 'Containers', key: 'id' },
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE',
+ },
+ username: {
+ type: Sequelize.STRING(255),
+ allowNull: false,
+ },
+ createdAt: {
+ allowNull: false,
+ type: Sequelize.DATE,
+ },
+ updatedAt: {
+ allowNull: false,
+ type: Sequelize.DATE,
+ },
+ });
+
+ // A user can be a collaborator on a given container at most once.
+ await queryInterface.addConstraint('ContainerCollaborators', {
+ fields: ['containerId', 'username'],
+ type: 'unique',
+ name: 'container_collaborators_container_username_unique',
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('ContainerCollaborators');
+ },
+};
diff --git a/create-a-container/models/container-collaborator.js b/create-a-container/models/container-collaborator.js
new file mode 100644
index 00000000..5ec49f39
--- /dev/null
+++ b/create-a-container/models/container-collaborator.js
@@ -0,0 +1,44 @@
+'use strict';
+const { Model } = require('sequelize');
+
+module.exports = (sequelize, DataTypes) => {
+ /**
+ * A grant giving a user (the `username`/uid) access to a container they do
+ * not own — i.e. a shared/collaborative container. The container's primary
+ * owner is still `Container.username`; this table only records the extra
+ * collaborators.
+ */
+ class ContainerCollaborator extends Model {
+ static associate(models) {
+ ContainerCollaborator.belongsTo(models.Container, {
+ foreignKey: 'containerId',
+ as: 'container',
+ });
+ }
+ }
+ ContainerCollaborator.init(
+ {
+ containerId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ references: { model: 'Containers', key: 'id' },
+ },
+ username: {
+ type: DataTypes.STRING(255),
+ allowNull: false,
+ },
+ },
+ {
+ sequelize,
+ modelName: 'ContainerCollaborator',
+ indexes: [
+ {
+ name: 'container_collaborators_container_username_unique',
+ unique: true,
+ fields: ['containerId', 'username'],
+ },
+ ],
+ },
+ );
+ return ContainerCollaborator;
+};
diff --git a/create-a-container/models/container.js b/create-a-container/models/container.js
index b6bfc83e..2f36acc7 100644
--- a/create-a-container/models/container.js
+++ b/create-a-container/models/container.js
@@ -18,6 +18,36 @@ module.exports = (sequelize, DataTypes) => {
Container.belongsTo(models.Site, { foreignKey: 'siteId', as: 'site' });
// a container may have a creation job
Container.belongsTo(models.Job, { foreignKey: 'creationJobId', as: 'creationJob' });
+ // a container may be shared with additional users (collaborators)
+ Container.hasMany(models.ContainerCollaborator, {
+ foreignKey: 'containerId',
+ as: 'collaborators',
+ onDelete: 'CASCADE',
+ });
+ }
+
+ /**
+ * Whether a user may view/use this container: its primary owner or a
+ * collaborator it is shared with. Requires the `collaborators` association
+ * to be eager-loaded. Admin overrides live at the route layer (admin status
+ * is a session property, not derivable from a username here).
+ * @param {string} username - The candidate user's uid.
+ * @returns {boolean}
+ */
+ canView(username) {
+ if (this.username === username) return true;
+ return (this.collaborators || []).some((c) => c.username === username);
+ }
+
+ /**
+ * Whether a user may edit this container — manage its sharing (add/remove
+ * collaborators) or delete it: only its primary owner. Collaborators can
+ * use a shared container but cannot re-share or delete it.
+ * @param {string} username - The candidate user's uid.
+ * @returns {boolean}
+ */
+ canEdit(username) {
+ return this.username === username;
}
/**
diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml
index 22e57415..043dcab8 100644
--- a/create-a-container/openapi.v1.yaml
+++ b/create-a-container/openapi.v1.yaml
@@ -314,13 +314,17 @@ paths:
- { in: query, name: nodeId, schema: { type: integer }, description: Filter to a single node belonging to the site }
- in: query
name: user
- schema: { type: string }
+ schema:
+ type: array
+ items: { type: string }
description: >-
- Owner filter. Omitted/empty returns the requesting user's own
- containers (default). `*` returns every owner on the site for admins,
- or just your own containers for non-admins. A specific username
- returns that owner's containers and is admin-only (except requesting
- yourself), yielding 403 otherwise.
+ Owner filter, sent in bracket notation (`user[0]=alice&user[1]=bob`)
+ so it always parses as a list. Omitted/empty returns everything the
+ caller may see: every container on the site for admins, or their own
+ plus any shared with them for non-admins. A list of usernames
+ returns those owners' containers for admins; for non-admins the list
+ is intersected with what they may already see (own plus shared), so
+ it can only narrow visibility.
responses:
'200': { description: Array of containers }
'403': { description: 'Admin access required', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
@@ -339,6 +343,10 @@ paths:
customTemplate: { type: string }
entrypoint: { type: string }
nvidiaRequested: { type: boolean }
+ collaborators:
+ type: array
+ items: { type: string }
+ description: Usernames to share the new container with (must exist)
environmentVars:
type: array
items: { type: object, properties: { key: { type: string }, value: { type: string } } }
@@ -369,6 +377,38 @@ paths:
get: { tags: [Containers], responses: { '200': { description: Container } } }
put: { tags: [Containers], responses: { '200': { description: Updated, optional restart job } } }
delete: { tags: [Containers], responses: { '200': { description: Deleted, with DNS cleanup warnings } } }
+ /sites/{siteId}/containers/{id}/collaborators:
+ parameters:
+ - { in: path, name: siteId, required: true, schema: { type: integer } }
+ - { in: path, name: id, required: true, schema: { type: integer } }
+ get:
+ tags: [Containers]
+ summary: List users a container is shared with (owner/collaborator/admin)
+ responses: { '200': { description: '{ collaborators: string[] }' } }
+ post:
+ tags: [Containers]
+ summary: Share a container with another user (owner/admin)
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [username]
+ properties: { username: { type: string } }
+ responses:
+ '201': { description: '{ collaborators: string[] }' }
+ '404': { description: 'user_not_found when the username does not exist', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ '409': { description: 'already_shared / already_owner', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ /sites/{siteId}/containers/{id}/collaborators/{username}:
+ delete:
+ tags: [Containers]
+ summary: Stop sharing a container with a user (owner/admin)
+ parameters:
+ - { in: path, name: siteId, required: true, schema: { type: integer } }
+ - { in: path, name: id, required: true, schema: { type: integer } }
+ - { in: path, name: username, required: true, schema: { type: string } }
+ responses: { '200': { description: '{ collaborators: string[] }' } }
/sites/{siteId}/nodes:
parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js
index a80fd3fe..6084b095 100644
--- a/create-a-container/routers/api/v1/containers.js
+++ b/create-a-container/routers/api/v1/containers.js
@@ -6,6 +6,7 @@
const express = require('express');
const {
Container,
+ ContainerCollaborator,
Service,
HTTPService,
TransportService,
@@ -15,6 +16,7 @@ const {
ExternalDomain,
Job,
Setting,
+ User,
Sequelize,
sequelize,
} = require('../../../models');
@@ -85,8 +87,13 @@ function normalizeDockerRef(ref) {
return `${host}/${org}/${image}:${tag}`;
}
-async function loadSite(req) {
- const site = await Site.findByPk(parseInt(req.params.siteId, 10));
+/**
+ * Load a site by id (typically `req.params.siteId`) or 404.
+ * @param {*} siteId - Candidate site id; parsed as a base-10 integer.
+ * @returns {Promise