(null);
@@ -131,6 +140,11 @@ export function ResourcesSection({
Resource allocations for this container. Changes above the default require admin approval.
+ {!isOwner && (
+
+ Only owners can make resource requests on their containers.
+
+ )}
{pendingNote && (
{pendingNote}
@@ -219,6 +233,7 @@ export function ResourcesSection({
type="button"
size="sm"
variant="outline"
+ disabled={!isOwner}
onClick={() => {
setEditingResource(opt.key);
setEditValue(currentValue);
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..cbf71673 100644
--- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
+++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
@@ -23,9 +23,11 @@ import {
import { Container, Dices, Plus, Search, Trash2 } from 'lucide-react';
import { api, ApiError } from '@/lib/api';
import { keys, queries } from '@/lib/queries';
+import { useSession } from '@/lib/auth';
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 +87,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;
@@ -104,6 +109,7 @@ export function ContainerFormPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const toast = useToast();
+ const { data: session } = useSession();
const { data: bootstrap, isLoading: bootstrapLoading } = useQuery({
queryKey: keys.containerBootstrap(siteId!),
@@ -115,18 +121,26 @@ export function ContainerFormPage() {
queryFn: () => queries.getContainer(siteId!, id!),
enabled: isEdit,
});
+ // Collaborators get a read-only view of a shared container: the whole form is
+ // disabled and the save footer hidden. The server enforces the same rule
+ // (PUT is owner/admin only), so this is purely presentational.
+ const isReadOnly =
+ isEdit && !!container && !!session && !session.isAdmin && container.owner !== session.user;
const { register, handleSubmit, control, reset, watch, setValue, formState } = useForm({
resolver: zodResolver(schema),
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 +157,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 +193,7 @@ export function ContainerFormPage() {
key,
value,
})),
+ collaborators: [],
});
}
}, [container, isEdit, reset]);
@@ -271,6 +292,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;
@@ -344,8 +367,17 @@ export function ContainerFormPage() {
backTo={{ label: 'Back to containers', to: `/sites/${siteId}/containers` }}
/>
+ {isReadOnly && (
+
+
+ This container is shared with you. Only the owner ({container?.owner}) can make
+ changes.
+
+
+ )}
+
diff --git a/create-a-container/client/src/pages/containers/ContainersListPage.tsx b/create-a-container/client/src/pages/containers/ContainersListPage.tsx
index ffe35d0d..fc5cd04d 100644
--- a/create-a-container/client/src/pages/containers/ContainersListPage.tsx
+++ b/create-a-container/client/src/pages/containers/ContainersListPage.tsx
@@ -1,14 +1,18 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import { Link, useLocation, useParams, useSearchParams } from 'react-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Alert,
AlertDescription,
AlertTitle,
- Badge,
Button,
Card,
CardTitle,
+ Modal,
+ ModalBody,
+ ModalClose,
+ ModalHeader,
+ ModalTitle,
PageHeader,
Spinner,
Table,
@@ -20,196 +24,30 @@ import {
useToast,
} from '@mieweb/ui';
import {
- Code2,
Container as ContainerIcon,
- ExternalLink,
LayoutGrid,
- Pencil,
Plus,
Rows3,
Server,
- Terminal,
- Trash2,
User,
} from 'lucide-react';
import { api, ApiError } from '@/lib/api';
import { useSession } from '@/lib/auth';
import { keys, queries } from '@/lib/queries';
+import { HttpLinks } from '@/components/containers/HttpLinks';
+import { Meta } from '@/components/containers/Meta';
+import { NodeLink } from '@/components/containers/NodeLink';
+import { RowActions } from '@/components/containers/RowActions';
+import { STATUS_LABELS, StatusBadge } from '@/components/containers/StatusBadge';
+import { SshLinks } from '@/components/containers/SshLinks';
+import { templateTitle } from '@/components/containers/shared';
+import { CollaboratorsManager } from '@/components/containers/CollaboratorsManager';
+import { ContainerFilters, type FilterOption } from '@/components/containers/ContainerFilters';
import type { Container, ContainerStatus } from '@/lib/types';
type ViewMode = 'cards' | 'table';
const VIEW_STORAGE_KEY = 'containers:view';
-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';
- }
-}
-
-// Human-readable labels for the live status values.
-const STATUS_LABELS: Record = {
- running: 'Running',
- offline: 'Offline',
- creating: 'Creating',
- failed: 'Failed',
- missing: 'Missing',
- unknown: 'Unknown',
-};
-
-/** Status badge. The status is the live value embedded in the list response. */
-function StatusBadge({ status }: { status: ContainerStatus }) {
- return {STATUS_LABELS[status] ?? status};
-}
-
-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 */
-function templateTitle(template: string | null): string {
- if (!template) return '—';
- return template.split('/').pop() || template;
-}
-
-function NodeLink({ c }: { c: Container }) {
- if (!c.nodeApiUrl) return <>{c.nodeName || '—'}>;
- return (
-
- {c.nodeName || c.nodeApiUrl}
-
- );
-}
-
-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}
-
- ),
- )}
-
- );
-}
-
-function SshLinks({ c, sessionUser }: { c: Container; sessionUser?: string }) {
- if (!c.sshHost || !c.sshPort) return —;
- return (
-
-
- {c.sshHost}:{c.sshPort}
-
- {sessionUser && (
- <>
-
-
-
-
-
-
- >
- )}
-
- );
-}
-
-function RowActions({
- c,
- siteId,
- onDelete,
- deleting,
-}: {
- c: Container;
- siteId?: string;
- onDelete: (id: number) => void;
- deleting: boolean;
-}) {
- return (
- <>
- {c.creationJobId && (
-
-
-
- )}
-
- }>
- Edit
-
-
- }
- 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={
-
-
-
-
-
-
-
-
- {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..e127d70d 100644
--- a/create-a-container/models/container.js
+++ b/create-a-container/models/container.js
@@ -18,6 +18,24 @@ 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 edit this container — change its configuration,
+ * manage its sharing (add/remove collaborators), or delete it: only its
+ * primary owner. Collaborators can use a shared container but have a
+ * read-only view of 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..5b657654 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 } } }
@@ -367,8 +375,51 @@ paths:
- { in: path, name: siteId, required: true, schema: { type: integer } }
- { in: path, name: id, required: true, schema: { type: integer } }
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 } } }
+ put:
+ tags: [Containers]
+ responses:
+ '200': { description: Updated, optional restart job }
+ '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ delete:
+ tags: [Containers]
+ responses:
+ '200': { description: Deleted, with DNS cleanup warnings }
+ '403': { description: 'forbidden — only the owner/admin may delete (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ /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[] }' }
+ '403': { description: 'forbidden — only the owner/admin may share (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ '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[] }' }
+ '403': { description: 'forbidden — only the owner/admin may unshare (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
/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..d9572e71 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