diff --git a/docs/content/permissions.md b/docs/content/permissions.md new file mode 100644 index 0000000..7c61400 --- /dev/null +++ b/docs/content/permissions.md @@ -0,0 +1,25 @@ +# Permission System + +Here is the design of the permission system to be implemented for use by the mist compute platform. The idea is very similar to unix groups. + +Each user will be tagged with workspaces (groups) they belong to and data storage sections that they may have read and or write access to. Beware that once this system is deployed it will need to be easy to migrate and switch between, so whatever implementation you make, make sure it easy to switch to and away from. + +Demands the system needs to satisfy: + +1. multiple people can be in multiple workspaces (for now workspace = group). +2. every job is attributed to some workspace or project, so it can be counted towards that groups resource use. +3. new workspaces need to be created easily. +4. system needs to be possible such as to allow future changes. +5. we can assume that users within a workspace trust each other and there are no significant malicious actors among the users. + +Common groups formats (that will be inferfaced by the UI to appear that way, low level implementation shouldn't concern itself with this layer of abstraction) "personal," "team," "project," etc.  + +Jobs will be tagged by the workspace they were launched from, and counted towards that workspace's resource use. + +## Implementation + +The system is most naturally represented as a bipartite graph of 3 sets: users linked to workspaces; and workspaces linked to data and jobs. + +Data and job running permissions (mostly quotas, queues, priority) are attached to groups. + +Present plans are to do everything using a very naive SQL database \ No newline at end of file diff --git a/src/app.go b/src/app.go index d74cd05..8380d4d 100644 --- a/src/app.go +++ b/src/app.go @@ -60,6 +60,8 @@ func NewKubernetesApp(executor *KubernetesExecutor, log *slog.Logger) *App { a.teams = newTeamService(a) mux.HandleFunc("/teams", a.teamEndpoint) mux.HandleFunc("/teams/", a.teamEndpoint) + mux.HandleFunc("/workspaces", a.teamEndpoint) + mux.HandleFunc("/workspaces/", a.teamEndpoint) a.httpServer.Handler = a.auth.middleware(a.teams.middleware(mux)) } else { a.httpServer.Handler = a.auth.middleware(mux) diff --git a/src/auth.go b/src/auth.go index f8d1fe1..0961514 100644 --- a/src/auth.go +++ b/src/auth.go @@ -305,6 +305,7 @@ func (s *AuthService) session(r *http.Request) (*Member, error) { WHERE s.token = ? AND s.expiresAt > ? AND COALESCE(u.banned, 0) = 0 `, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role) if err == nil { + m.IsAdmin = isAdmin(&m) return &m, nil } else if !errors.Is(err, sql.ErrNoRows) { return nil, err @@ -337,6 +338,7 @@ func (s *AuthService) LookupMember(ctx context.Context, id string) (*Member, err if err != nil { return nil, errors.New("account unavailable") } + m.IsAdmin = isAdmin(&m) return &m, nil } @@ -877,3 +879,16 @@ func (s *AuthService) middleware(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), memberKey{}, member))) }) } + +func (s *AuthService) Session(r *http.Request) (*Member, error) { + return s.session(r) +} + +func (s *AuthService) ActiveMembers(ctx context.Context) (map[string]AccountStatus, error) { + return s.activeMembers(ctx) +} + +func (s *AuthService) Middleware(next http.Handler) http.Handler { + return s.middleware(next) +} + diff --git a/src/jobs_http.go b/src/jobs_http.go index 6044ce9..73176df 100644 --- a/src/jobs_http.go +++ b/src/jobs_http.go @@ -28,7 +28,19 @@ func (a *App) kubernetesJobs(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusCreated, map[string]interface{}{"job_id": status.ID, "job": status}) case http.MethodGet: - jobs, err := a.requestExecutor(r).list(r.Context()) + var jobs []KubernetesJobStatus + var err error + if r.URL.Query().Get("cluster") == "true" || r.URL.Query().Get("all") == "true" { + var currentMember *Member + if access, _ := r.Context().Value(teamContextKey{}).(*TeamAccess); access != nil { + currentMember = access.Member + } else { + currentMember, _ = r.Context().Value(memberKey{}).(*Member) + } + jobs, err = a.executor.listCluster(r.Context(), currentMember) + } else { + jobs, err = a.requestExecutor(r).list(r.Context()) + } if err != nil { executorError(w, err) return diff --git a/src/kubernetes.go b/src/kubernetes.go index 7130dbe..d75f058 100644 --- a/src/kubernetes.go +++ b/src/kubernetes.go @@ -714,6 +714,30 @@ func (e *KubernetesExecutor) list(ctx context.Context) ([]KubernetesJobStatus, e return result, nil } +func (e *KubernetesExecutor) listCluster(ctx context.Context, currentMember *Member) ([]KubernetesJobStatus, error) { + jobs, err := e.client.BatchV1().Jobs("").List(ctx, metav1.ListOptions{LabelSelector: managedLabel + "=mist"}) + if err != nil { + return nil, err + } + result := make([]KubernetesJobStatus, 0, len(jobs.Items)) + data, err := e.loadJobState(ctx) + if err != nil { + return nil, err + } + for i := range jobs.Items { + status, err := e.statusWithData(ctx, &jobs.Items[i], data) + if err != nil { + continue + } + if currentMember != nil { + status.CanCancel = ptr(isAdmin(currentMember) || status.CreatorID == currentMember.ID) + } + result = append(result, *status) + } + sort.Slice(result, func(i, j int) bool { return result[i].Created.After(result[j].Created) }) + return result, nil +} + func (e *KubernetesExecutor) logs(ctx context.Context, id string) (string, error) { job, err := e.managedJob(ctx, id) if err != nil { @@ -758,7 +782,11 @@ func (e *KubernetesExecutor) cancel(ctx context.Context, id string) (*Kubernetes if err != nil { return nil, err } - if e.team != nil && !isAdmin(e.team.Member) && job.Annotations["mist.io/creator-id"] != e.team.Member.ID { + if e.team != nil { + if !isAdmin(e.team.Member) && job.Annotations["mist.io/creator-id"] != e.team.Member.ID { + return nil, &permissionError{errors.New("Only the job owner or an administrator can cancel this job")} + } + } else if job.Labels["mist.io/owner"] != "" && job.Labels["mist.io/owner"] != e.owner { return nil, &permissionError{errors.New("Only the job owner or an administrator can cancel this job")} } if job.Annotations[cancelAnnotation] != "" { diff --git a/src/member_auth.go b/src/member_auth.go index 76d6511..d289a64 100644 --- a/src/member_auth.go +++ b/src/member_auth.go @@ -1,8 +1,10 @@ package main import ( + "context" "crypto/sha256" "encoding/hex" + "errors" "net" "net/http" "os" @@ -17,16 +19,76 @@ func hashID(id string) string { } type Member struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + IsAdmin bool `json:"is_admin"` } type AccountStatus struct { Role string `json:"role"` } +// Authenticator provides an interface for user authentication, member lookups, +// and session middleware. This allows plugging in the incoming core auth system, +// the current Better Auth / SQLite implementation, or dummy mock implementations. +type Authenticator interface { + http.Handler + Close() error + Session(r *http.Request) (*Member, error) + LookupMember(ctx context.Context, id string) (*Member, error) + ActiveMembers(ctx context.Context) (map[string]AccountStatus, error) + Middleware(next http.Handler) http.Handler +} + +// Ensure AuthService implements Authenticator. +var _ Authenticator = (*AuthService)(nil) + +// DummyAuthenticator is a stub implementation of Authenticator for testing or headless runs. +type DummyAuthenticator struct { + Members map[string]*Member +} + +func NewDummyAuthenticator() *DummyAuthenticator { + return &DummyAuthenticator{ + Members: make(map[string]*Member), + } +} + +func (d *DummyAuthenticator) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +func (d *DummyAuthenticator) Close() error { + return nil +} + +func (d *DummyAuthenticator) Session(r *http.Request) (*Member, error) { + return nil, nil +} + +func (d *DummyAuthenticator) LookupMember(ctx context.Context, id string) (*Member, error) { + if m, ok := d.Members[id]; ok { + return m, nil + } + return nil, errors.New("account unavailable") +} + +func (d *DummyAuthenticator) ActiveMembers(ctx context.Context) (map[string]AccountStatus, error) { + res := make(map[string]AccountStatus) + for id, m := range d.Members { + res[id] = AccountStatus{Role: m.Role} + } + return res, nil +} + +func (d *DummyAuthenticator) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) +} + type AuthGateway = AuthService func newAuthGateway(raw string) (*AuthGateway, error) { diff --git a/src/shared_storage.go b/src/shared_storage.go index 5d7c095..8ef4242 100644 --- a/src/shared_storage.go +++ b/src/shared_storage.go @@ -80,7 +80,7 @@ func (s *SharedStorage) availableUploadLimit(team bool) int64 { return 0 } reserve := int64(1024 * 1024 * 1024) - return max(0, min(s.uploadLimit, int64(stats.Bavail)*stats.Bsize-reserve)) + return max(0, min(s.uploadLimit, int64(stats.Bavail)*int64(stats.Bsize)-reserve)) } func (s *SharedStorage) dataset(id, owner string) (*Dataset, error) { if !datasetIDPattern.MatchString(id) { @@ -138,7 +138,7 @@ func (s *SharedStorage) upload(r *http.Request, owner string) (*Dataset, error) return nil, err } reserve := int64(1024 * 1024 * 1024) - remaining := int64(stats.Bavail)*stats.Bsize - reserve + remaining := int64(stats.Bavail)*int64(stats.Bsize) - reserve if remaining <= 0 { return nil, fmt.Errorf("dataset storage has insufficient free space: %w", syscall.ENOSPC) } diff --git a/src/teams.go b/src/teams.go index 9af698c..7151d8c 100644 --- a/src/teams.go +++ b/src/teams.go @@ -56,15 +56,29 @@ type StorageGrant struct { TargetTeam string `json:"target_team"` Scope string `json:"scope"` } + +type WorkspaceType string + +const ( + WorkspaceTypeTeam WorkspaceType = "team" + WorkspaceTypePersonal WorkspaceType = "personal" + WorkspaceTypeCollaboration WorkspaceType = "collaboration" + WorkspaceTypeAdmin WorkspaceType = "admin" +) + type Team struct { - ID string `json:"id"` - Name string `json:"name"` - Created time.Time `json:"created"` - Disabled bool `json:"disabled"` - Policy TeamPolicy `json:"policy"` - Members []TeamMember `json:"members"` - Grants []StorageGrant `json:"grants"` + ID string `json:"id"` + Name string `json:"name"` + Type WorkspaceType `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Created time.Time `json:"created"` + Disabled bool `json:"disabled"` + Policy TeamPolicy `json:"policy"` + Members []TeamMember `json:"members"` + Grants []StorageGrant `json:"grants"` } + +type Workspace = Team type TeamService struct { app *App mu sync.Mutex // admissions and policy changes are serialized on the single controller @@ -115,7 +129,7 @@ func (s *TeamService) storageBudgetGiB() int64 { if syscall.Statfs(s.app.executor.storage.root, &fs) != nil { return 0 } - return int64(fs.Blocks) * fs.Bsize / (1024 * 1024 * 1024) + return int64(fs.Blocks) * int64(fs.Bsize) / (1024 * 1024 * 1024) } func normalizeStoragePolicy(p *TeamPolicy) { // Preserve legacy callers which supplied one advisory allowance. @@ -128,6 +142,17 @@ func validateTeam(team *Team) error { if strings.TrimSpace(team.Name) == "" || len(team.Name) > 100 { return errors.New("team name must contain 1–100 characters") } + if team.Type == "" { + team.Type = WorkspaceTypeTeam + } + switch team.Type { + case WorkspaceTypeTeam, WorkspaceTypePersonal, WorkspaceTypeCollaboration, WorkspaceTypeAdmin: + default: + return errors.New("invalid workspace type; must be team, personal, collaboration, or admin") + } + if len(team.Description) > 500 { + return errors.New("workspace description cannot exceed 500 characters") + } p := team.Policy cpu, err := resource.ParseQuantity(p.CPU) if err != nil || cpu.Sign() <= 0 || cpu.Cmp(resource.MustParse("32")) > 0 { @@ -167,6 +192,9 @@ func (s *TeamService) registry(ctx context.Context) (*corev1.ConfigMap, []Team, } for i := range teams { normalizeStoragePolicy(&teams[i].Policy) + if teams[i].Type == "" { + teams[i].Type = WorkspaceTypeTeam + } } return cm, teams, nil } @@ -249,13 +277,19 @@ func (s *TeamService) store(t *Team) (*SharedStorage, error) { } func (s *TeamService) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := r.Header.Get("X-Mist-Team") + id := r.Header.Get("X-Mist-Workspace") + if id == "" { + id = r.Header.Get("X-Mist-Team") + } + if id == "" { + id = r.URL.Query().Get("workspace_id") + } if id == "" { id = r.URL.Query().Get("team_id") } - if id == "" || strings.HasPrefix(r.URL.Path, "/teams") || r.URL.Path == "/session" || r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/auth/") { + if id == "" || strings.HasPrefix(r.URL.Path, "/teams") || strings.HasPrefix(r.URL.Path, "/workspaces") || r.URL.Path == "/session" || r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/auth/") { if id == "" && r.Method == http.MethodPost && (r.URL.Path == "/jobs" || r.URL.Path == "/datasets") { - writeJSON(w, 403, map[string]string{"error": "Select a team workspace before submitting jobs or uploading files"}) + writeJSON(w, 403, map[string]string{"error": "Select a workspace before submitting jobs or uploading files"}) return } next.ServeHTTP(w, r) @@ -311,7 +345,8 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { return } m, _ := r.Context().Value(memberKey{}).(*Member) - if r.URL.Path == "/teams" && r.Method == http.MethodGet { + isRootList := (r.URL.Path == "/teams" || r.URL.Path == "/workspaces") && r.Method == http.MethodGet + if isRootList { _, teams, err := s.registry(r.Context()) if err != nil { executorError(w, err) @@ -323,7 +358,7 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { result = append(result, t) } } - writeJSON(w, 200, map[string]any{"teams": result, "storage_budget_gib": s.storageBudgetGiB(), "storage_limits_enforced": false}) + writeJSON(w, 200, map[string]any{"teams": result, "workspaces": result, "storage_budget_gib": s.storageBudgetGiB(), "storage_limits_enforced": false}) return } if !isAdmin(m) { @@ -332,13 +367,19 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { } s.mu.Lock() defer s.mu.Unlock() - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/teams"), "/") - if r.URL.Path == "/teams" && r.Method == http.MethodPost { + cleanPath := strings.TrimPrefix(r.URL.Path, "/workspaces") + cleanPath = strings.TrimPrefix(cleanPath, "/teams") + parts := strings.Split(cleanPath, "/") + isRootCreate := (r.URL.Path == "/teams" || r.URL.Path == "/workspaces") && r.Method == http.MethodPost + if isRootCreate { var body struct { - Name string `json:"name"` - StorageGiB *int64 `json:"storage_gib"` - ModelStorageGiB *int64 `json:"model_storage_gib"` - Policy *TeamPolicy `json:"policy"` + Name string `json:"name"` + Type *WorkspaceType `json:"type"` + Description *string `json:"description"` + StorageGiB *int64 `json:"storage_gib"` + ModelStorageGiB *int64 `json:"model_storage_gib"` + Policy *TeamPolicy `json:"policy"` + UserID *string `json:"user_id"` } if err := decodeTeamJSON(w, r, &body); err != nil { executorError(w, err) @@ -349,7 +390,27 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { executorError(w, err) return } - t := Team{ID: id, Name: body.Name, Created: time.Now().UTC(), Policy: defaultTeamPolicy(), Members: []TeamMember{}, Grants: []StorageGrant{}} + wsType := WorkspaceTypeTeam + if body.Type != nil && *body.Type != "" { + wsType = *body.Type + } + desc := "" + if body.Description != nil { + desc = *body.Description + } + members := []TeamMember{} + if body.UserID != nil && *body.UserID != "" && a.auth != nil { + assigned, err := a.auth.lookupMember(r, *body.UserID) + if err == nil && assigned != nil { + members = append(members, TeamMember{ + ID: assigned.ID, + Name: assigned.Name, + Email: assigned.Email, + CommonWriter: true, + }) + } + } + t := Team{ID: id, Name: body.Name, Type: wsType, Description: desc, Created: time.Now().UTC(), Policy: defaultTeamPolicy(), Members: members, Grants: []StorageGrant{}} if body.Policy != nil { t.Policy = *body.Policy } @@ -392,9 +453,11 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { switch { case len(parts) == 2 && r.Method == http.MethodPatch: var body struct { - Name *string `json:"name"` - Policy *TeamPolicy `json:"policy"` - Disabled *bool `json:"disabled"` + Name *string `json:"name"` + Type *WorkspaceType `json:"type"` + Description *string `json:"description"` + Policy *TeamPolicy `json:"policy"` + Disabled *bool `json:"disabled"` } if err := decodeTeamJSON(w, r, &body); err != nil { executorError(w, err) @@ -408,6 +471,12 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { if body.Name != nil { t.Name = *body.Name } + if body.Type != nil { + t.Type = *body.Type + } + if body.Description != nil { + t.Description = *body.Description + } if body.Disabled != nil { t.Disabled = *body.Disabled } diff --git a/src/teams_test.go b/src/teams_test.go index b444812..bb87218 100644 --- a/src/teams_test.go +++ b/src/teams_test.go @@ -558,6 +558,109 @@ func TestDepartmentDefaultPoolsAndLegacyPolicy(t *testing.T) { } } +func TestWorkspacesAndClusterJobListing(t *testing.T) { + a, _, teams := departmentTestApp(t) + alice := teams[0] + + // 1. Non-admin cannot create a workspace + r := departmentRequest(a, "POST", "/workspaces", "alice", "", `{"name":"Personal Workspace","type":"personal"}`) + if r.Code != 403 { + t.Fatalf("expected 403 for non-admin workspace creation, got %d", r.Code) + } + + // Shrink existing teams to make budget for new workspaces + if err := a.teams.mutate(context.Background(), func(list *[]Team) error { + for i := range *list { + (*list)[i].Policy.StorageGiB = 10 + (*list)[i].Policy.ModelStorageGiB = 10 + } + return nil + }); err != nil { + t.Fatal(err) + } + + // 2. Admin creates a personal workspace for alice with type "personal" + r = departmentRequest(a, "POST", "/workspaces", "admin", "", `{"name":"Alice Personal","type":"personal","description":"Alice quota","storage_gib":20,"model_storage_gib":20,"user_id":"alice"}`) + if r.Code != 201 { + t.Fatalf("admin personal workspace creation failed: %d %s", r.Code, r.Body.String()) + } + var createdWs Workspace + if err := json.Unmarshal(r.Body.Bytes(), &createdWs); err != nil { + t.Fatal(err) + } + if createdWs.Type != WorkspaceTypePersonal { + t.Fatalf("expected type personal, got %s", createdWs.Type) + } + if createdWs.Description != "Alice quota" { + t.Fatalf("expected description 'Alice quota', got %s", createdWs.Description) + } + if len(createdWs.Members) != 1 || createdWs.Members[0].ID != "alice" { + t.Fatalf("expected alice enrolled in personal workspace, got %+v", createdWs.Members) + } + + // 3. User lists workspaces via /workspaces + r = departmentRequest(a, "GET", "/workspaces", "alice", "", "") + if r.Code != 200 { + t.Fatalf("list workspaces failed: %d %s", r.Code, r.Body.String()) + } + var wsList struct { + Workspaces []Workspace `json:"workspaces"` + } + if err := json.Unmarshal(r.Body.Bytes(), &wsList); err != nil { + t.Fatal(err) + } + // Alice should see team A and her personal workspace + hasPersonal := false + for _, ws := range wsList.Workspaces { + if ws.ID == createdWs.ID { + hasPersonal = true + } + } + if !hasPersonal { + t.Fatal("alice did not see her personal workspace") + } + + // 4. Submit a job in alice's team + r = departmentRequest(a, "POST", "/jobs", "alice", alice.ID, `{"command":["echo","hello"]}`) + if r.Code != 201 { + t.Fatalf("job submission failed: %d %s", r.Code, r.Body.String()) + } + var jobResp struct { + JobID string `json:"job_id"` + Job KubernetesJobStatus `json:"job"` + } + _ = json.Unmarshal(r.Body.Bytes(), &jobResp) + + // 5. Query cluster jobs via /jobs?cluster=true + r = departmentRequest(a, "GET", "/jobs?cluster=true", "alice", "", "") + if r.Code != 200 { + t.Fatalf("get cluster jobs failed: %d %s", r.Code, r.Body.String()) + } + var clusterJobs struct { + Jobs []KubernetesJobStatus `json:"jobs"` + } + _ = json.Unmarshal(r.Body.Bytes(), &clusterJobs) + if len(clusterJobs.Jobs) == 0 { + t.Fatal("cluster jobs list was empty") + } + + // 6. Test cancel protection: Enroll Bob in Alice's team, but Bob is not the creator or admin + r = departmentRequest(a, "POST", "/workspaces/"+alice.ID+"/members", "admin", "", `{"user_id":"bob","common_writer":false}`) + if r.Code != 200 { + t.Fatalf("failed to add bob to alice's team: %d %s", r.Code, r.Body.String()) + } + r = departmentRequest(a, "POST", "/jobs/"+jobResp.JobID+"/cancel", "bob", alice.ID, "") + if r.Code != 403 { + t.Fatalf("expected 403 when non-owner/non-admin bob cancels job, got %d %s", r.Code, r.Body.String()) + } + + // 7. Alice can cancel her own job + r = departmentRequest(a, "POST", "/jobs/"+jobResp.JobID+"/cancel", "alice", alice.ID, "") + if r.Code != 200 { + t.Fatalf("expected 200 when owner alice cancels job, got %d %s", r.Code, r.Body.String()) + } +} + func TestTeamVolumesUseEthernetFolderExportsAndRejectStaleBindings(t *testing.T) { a, client, teams := departmentTestApp(t) t.Setenv("MIST_NFS_SERVER", "192.168.240.2") diff --git a/web-interface/src/api.ts b/web-interface/src/api.ts index ddfeda9..3f0e6b7 100644 --- a/web-interface/src/api.ts +++ b/web-interface/src/api.ts @@ -102,7 +102,7 @@ async function request(path: string, options?: RequestInit): Promise { ...options, headers: { 'Content-Type': 'application/json', - ...(requestTeam ? { 'X-Mist-Team': requestTeam } : {}), + ...(requestTeam ? { 'X-Mist-Team': requestTeam, 'X-Mist-Workspace': requestTeam } : {}), ...options?.headers, }, }) @@ -112,7 +112,8 @@ async function request(path: string, options?: RequestInit): Promise { response.status === 403 && requestTeam && (!options?.method || options.method === 'GET') && - !path.startsWith('/teams') + !path.startsWith('/teams') && + !path.startsWith('/workspaces') ) window.dispatchEvent( new CustomEvent('mist:workspace-access-revoked', { detail: requestTeam }), @@ -133,8 +134,13 @@ async function request(path: string, options?: RequestInit): Promise { export const jobsAPI = { session: (signal?: AbortSignal) => request<{ enabled: boolean; user: Member | null }>('/session', { signal }), - list: (signal?: AbortSignal) => - request<{ jobs: Job[]; count: number }>('/jobs', { signal }), + list: (signal?: AbortSignal, options?: { cluster?: boolean }) => + request<{ jobs: Job[]; count: number }>( + options?.cluster ? '/jobs?cluster=true' : '/jobs', + { signal }, + ), + listCluster: (signal?: AbortSignal) => + request<{ jobs: Job[]; count: number }>('/jobs?cluster=true', { signal }), hardware: (signal?: AbortSignal) => request('/hardware', { signal }), images: (signal?: AbortSignal) => @@ -238,7 +244,10 @@ export const storageAPI = { `${base}/datasets?${new URLSearchParams({ filename: file.name, name, ...(scope ? { scope } : {}), ...(zip ? { format: 'zip' } : {}) })}`, ) xhr.timeout = 24 * 60 * 60 * 1000 - if (activeTeam) xhr.setRequestHeader('X-Mist-Team', activeTeam) + if (activeTeam) { + xhr.setRequestHeader('X-Mist-Team', activeTeam) + xhr.setRequestHeader('X-Mist-Workspace', activeTeam) + } xhr.upload.onprogress = (e) => { if (e.lengthComputable) progress(Math.round((100 * e.loaded) / e.total)) } @@ -278,14 +287,20 @@ export type TeamMember = { common_writer: boolean } export type StorageGrant = { id: string; target_team: string; scope: string } +export type WorkspaceType = 'team' | 'personal' | 'collaboration' | 'admin' + export type Team = { id: string name: string + type?: WorkspaceType + description?: string disabled: boolean policy: TeamPolicy members: TeamMember[] grants: StorageGrant[] } +export type Workspace = Team + export type StorageFolder = { team_id: string team_name: string @@ -296,36 +311,62 @@ export type StorageFolder = { } export const teamsAPI = { list: (signal?: AbortSignal) => - request<{ teams: Team[]; storage_budget_gib: number }>('/teams', { + request<{ teams: Team[]; workspaces?: Workspace[]; storage_budget_gib: number }>('/workspaces', { signal, - }), - create: (name: string, storage_gib = 200, model_storage_gib = 500) => - request('/teams', { + }).catch(() => + request<{ teams: Team[]; storage_budget_gib: number }>('/teams', { signal }), + ), + create: ( + name: string, + type: WorkspaceType = 'team', + description = '', + storage_gib = 200, + model_storage_gib = 500, + policy?: TeamPolicy, + user_id?: string, + ) => + request('/workspaces', { method: 'POST', - body: JSON.stringify({ name, storage_gib, model_storage_gib }), + body: JSON.stringify({ + name, + type, + description, + storage_gib, + model_storage_gib, + policy, + user_id, + }), }), update: ( id: string, - change: { name?: string; disabled?: boolean; policy?: TeamPolicy }, + change: { + name?: string + type?: WorkspaceType + description?: string + disabled?: boolean + policy?: TeamPolicy + }, ) => - request(`/teams/${id}`, { + request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(change), }), enroll: (id: string, user_id: string, common_writer: boolean) => - request(`/teams/${id}/members`, { + request(`/workspaces/${id}/members`, { method: 'POST', body: JSON.stringify({ user_id, common_writer }), }), removeMember: (id: string, user: string) => - request(`/teams/${id}/members/${encodeURIComponent(user)}`, { + request(`/workspaces/${id}/members/${encodeURIComponent(user)}`, { method: 'DELETE', }), grant: (id: string, target_team: string, scope: string) => - request(`/teams/${id}/grants`, { + request(`/workspaces/${id}/grants`, { method: 'POST', body: JSON.stringify({ target_team, scope }), }), revoke: (id: string, grant: string) => - request(`/teams/${id}/grants/${grant}`, { method: 'DELETE' }), + request(`/workspaces/${id}/grants/${grant}`, { method: 'DELETE' }), } + +export const workspacesAPI = teamsAPI diff --git a/web-interface/src/auth.tsx b/web-interface/src/auth.tsx index 546b417..6292827 100644 --- a/web-interface/src/auth.tsx +++ b/web-interface/src/auth.tsx @@ -15,12 +15,21 @@ export type Member = { name: string email: string role: string + is_admin?: boolean } + +export function isUserAdmin(user: Member | null): boolean { + if (!user) return false + if (user.is_admin === true) return true + return Boolean(user.role.split(',').includes('admin')) +} + const AccountContext = createContext<{ user: Member | null + isAdmin: boolean enabled: boolean refresh: () => void -}>({ user: null, enabled: false, refresh: () => {} }) +}>({ user: null, isAdmin: false, enabled: false, refresh: () => {} }) export const useAccount = () => useContext(AccountContext) function Login({ refresh }: { refresh: () => void }) { @@ -120,6 +129,7 @@ export function AccountGate({ children }: { children: ReactNode }) { { + const actual = await vi.importActual('@tanstack/react-router') + return { + ...actual, + Link: ({ children, to, className }: any) => ( + + {children} + + ), + } +}) + +vi.mock('../api', () => ({ + jobsAPI: { + listCluster: vi.fn(), + hardware: vi.fn(), + logs: vi.fn(), + }, +})) + +vi.mock('../auth.tsx', () => ({ + useAccount: () => ({ + user: { id: 'alice', name: 'Alice', email: 'alice@mist.local', role: 'user' }, + isAdmin: false, + enabled: true, + }), + isUserAdmin: (u: { role?: string } | null) => Boolean(u?.role?.includes('admin')), +})) + +vi.mock('../teams.tsx', () => ({ + useWorkspace: () => ({ + workspace: { id: 'team-nlp', name: 'UTMIST NLP', type: 'team' }, + workspaces: [], + }), + useTeam: () => ({ + workspace: { id: 'team-nlp', name: 'UTMIST NLP', type: 'team' }, + workspaces: [], + }), +})) + +let jobs: Job[] +beforeEach(() => { + vi.resetAllMocks() + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute('open', '') + } + HTMLDialogElement.prototype.close = function () { + this.removeAttribute('open') + } + + jobs = [ + { + id: 'job-bob', + name: 'Bobs training', + created: '2026-10-20T10:00:00Z', + job_state: 'InProgress', + accelerator: 'nvidia', + device_count: 1, + cpu: '4', + memory: '8Gi', + image: 'mist/nvidia', + checkpoint_directory: '/outputs', + creator_id: 'bob', + creator_name: 'Bob', + node: 'node-gpu-1', + }, + { + id: 'job-alice', + name: 'Alices fine-tuning', + created: '2026-10-20T10:30:00Z', + job_state: 'Scheduled', + accelerator: 'tenstorrent', + device_count: 2, + cpu: '8', + memory: '16Gi', + image: 'mist/tt', + checkpoint_directory: '/outputs', + creator_id: 'alice', + creator_name: 'Alice', + message: 'Waiting in cluster fair queue', + }, + ] + + vi.mocked(jobsAPI.listCluster).mockResolvedValue({ + jobs, + count: jobs.length, + }) + + vi.mocked(jobsAPI.hardware).mockResolvedValue({ + observed_at: '2026-10-20T12:00:00Z', + machines: [ + { + name: 'node-gpu-1', + address: '10.0.0.1', + ready: true, + schedulable: true, + cpu_allocatable: '16', + memory_allocatable: '64Gi', + }, + { + name: 'node-tt-1', + address: '10.0.0.2', + ready: true, + schedulable: true, + cpu_allocatable: '32', + memory_allocatable: '128Gi', + }, + ], + pools: [ + { + accelerator: 'nvidia', + node: 'node-gpu-1', + unit: 'GPU', + total: 2, + allocated: 1, + available: 1, + ready: true, + }, + { + accelerator: 'tenstorrent', + node: 'node-tt-1', + unit: 'board', + total: 4, + allocated: 2, + available: 2, + ready: true, + }, + ], + }) + + vi.mocked(jobsAPI.logs).mockResolvedValue({ + logs: 'Epoch 1/10 complete. Loss: 0.123', + }) +}) + +afterEach(() => { + cleanup() +}) + +test('overview displays machines and load without write buttons', async () => { + render() + + // Technical overview header + await screen.findByText('System Overview') + expect(screen.queryByText(/Your next experiment starts here/i)).toBeNull() + expect(screen.queryByText(/Bring your code/i)).toBeNull() + + // Machine metrics + expect(screen.getByText('2 / 2')).toBeTruthy() + expect(screen.getByText('Node node-gpu-1')).toBeTruthy() + + // Hardware allocation + expect(screen.getAllByText('node-gpu-1').length).toBeGreaterThan(0) + expect(screen.getAllByText('node-tt-1').length).toBeGreaterThan(0) + + // Check that NO write operations exist on overview + expect(screen.queryByRole('button', { name: /cancel/i })).toBeNull() + expect(screen.queryByRole('button', { name: /new job/i })).toBeNull() + expect(screen.queryByRole('button', { name: /add dataset/i })).toBeNull() +}) + +test('orders logged-in user jobs first, then everyone elses jobs', async () => { + render() + + // Alice's job should be in the "Your Active Experiments" section + await screen.findByText('Alices fine-tuning') + expect(screen.getByText('Your Active Experiments (1)')).toBeTruthy() + expect(screen.getByText('Your Job')).toBeTruthy() + expect(screen.getByText('ℹ️ Waiting in cluster fair queue')).toBeTruthy() + + // Bob's job should be in the "Other Cluster Workloads" section + expect(screen.getByText('Other Cluster Workloads (1)')).toBeTruthy() + expect(screen.getByText('Bobs training')).toBeTruthy() + + // Inspecting Alice's job opens read-only details & logs modal + const inspectButtons = screen.getAllByRole('button', { name: /Details & Logs/i }) + fireEvent.click(inspectButtons[0]) + + await screen.findByText(/Job Details: Alices fine-tuning/i) + await screen.findByText(/Console Logs \(Read-Only\)/i) + expect(screen.getByText(/Epoch 1\/10 complete/i)).toBeTruthy() + + // Still NO cancel button inside modal + expect(screen.queryByRole('button', { name: /cancel/i })).toBeNull() +}) diff --git a/web-interface/src/components/DashboardPage.tsx b/web-interface/src/components/DashboardPage.tsx new file mode 100644 index 0000000..12f3710 --- /dev/null +++ b/web-interface/src/components/DashboardPage.tsx @@ -0,0 +1,491 @@ +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import { jobsAPI } from '#/api.ts' +import type { Job } from '#/api.ts' +import { useAccount } from '#/auth.tsx' +import { useWorkspace } from '#/teams.tsx' +import { usePolling } from '#/hooks/usePolling.ts' +import { HardwarePanel, computeNames } from '#/components/HardwarePanel.tsx' +import { Modal } from '#/components/Modal.tsx' +import { + Server, + Activity, + Layers, + Clock3, + Cpu, + User, + ExternalLink, + Terminal, + Info, +} from 'lucide-react' + +const statusNames: Record = { + Scheduled: 'Queued / Waiting', + InProgress: 'Running', + Success: 'Completed', + Failure: 'Failed', + Cancelled: 'Cancelled', +} + +function OverviewJobRow({ + job, + isOwn, + onInspect, +}: { + job: Job + isOwn: boolean + onInspect: () => void +}) { + const isRunning = job.job_state === 'InProgress' + const isQueued = job.job_state === 'Scheduled' + + return ( +
+
+
+ + {isRunning && ( + + )} + {statusNames[job.job_state] ?? job.job_state} + + {job.name} + {job.id} +
+
+ {isOwn && ( + + Your Job + + )} + +
+
+ +
+
+ Machine: + + {job.node ? `Node ${job.node}` : 'Waiting for placement'} + +
+
+ Compute: + + {computeNames[job.accelerator]} + {job.device_count ? ` × ${job.device_count}` : ''} + +
+
+ Attribution / User: + + {job.creator_name || 'Anonymous'} + {job.team_id ? ` · ${job.team_id}` : ''} + +
+
+ Submitted: + + {new Date(job.created).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + +
+
+ + {job.message && isQueued && ( +
+ ℹ️ {job.message} +
+ )} +
+ ) +} + +export function DashboardPage() { + const { user } = useAccount() + const { workspace } = useWorkspace() + const hardware = usePolling(jobsAPI.hardware, 5000) + const jobs = usePolling(jobsAPI.listCluster, 5000) + + const [inspectingJob, setInspectingJob] = useState(null) + const [logs, setLogs] = useState(null) + const [loadingLogs, setLoadingLogs] = useState(false) + + const allJobs = jobs.data?.jobs ?? [] + const activeJobs = allJobs.filter( + (j) => j.job_state === 'InProgress' || j.job_state === 'Scheduled', + ) + const completedJobs = allJobs.filter( + (j) => j.job_state !== 'InProgress' && j.job_state !== 'Scheduled', + ) + + const myActiveJobs = activeJobs.filter((j) => j.creator_id === user?.id) + const otherActiveJobs = activeJobs.filter((j) => j.creator_id !== user?.id) + const myCompletedJobs = completedJobs.filter((j) => j.creator_id === user?.id) + + const totalNvidia = + hardware.data?.pools + .filter((p) => p.accelerator === 'nvidia') + .reduce((acc, p) => acc + p.total, 0) ?? 0 + const allocatedNvidia = + hardware.data?.pools + .filter((p) => p.accelerator === 'nvidia') + .reduce((acc, p) => acc + p.allocated, 0) ?? 0 + + const totalTT = + hardware.data?.pools + .filter((p) => p.accelerator === 'tenstorrent') + .reduce((acc, p) => acc + p.total, 0) ?? 0 + const allocatedTT = + hardware.data?.pools + .filter((p) => p.accelerator === 'tenstorrent') + .reduce((acc, p) => acc + p.allocated, 0) ?? 0 + + const readyMachines = + hardware.data?.machines.filter((m) => m.ready).length ?? 0 + const totalMachines = hardware.data?.machines.length ?? 0 + + async function openInspect(job: Job) { + setInspectingJob(job) + setLogs(null) + setLoadingLogs(true) + try { + const resp = await jobsAPI.logs(job.id) + setLogs(resp.logs || '(No log output generated yet)') + } catch { + setLogs('(Failed to retrieve logs)') + } finally { + setLoadingLogs(false) + } + } + + return ( +
+ {/* Technical Header */} +
+
+
+

+ {workspace ? `${workspace.name} (${workspace.type ?? 'team'})` : 'Cluster Overview'} +

+
+

System Overview

+

+ Live compute availability, hardware load, and active workload queue. +

+
+
+ + + Cluster live + + + Manage Jobs + +
+
+ + {/* Cluster Hardware Load Summary */} +
+

+ Cluster load metrics +

+
+
+
+ Connected Nodes + +
+
+ {readyMachines} / {totalMachines} +
+

Ready for scheduling

+
+ +
+
+ NVIDIA GPUs + +
+
+ {allocatedNvidia} / {totalNvidia}{' '} + + ({totalNvidia > 0 ? Math.round((allocatedNvidia / totalNvidia) * 100) : 0}%) + +
+
+
0 ? Math.min(100, (allocatedNvidia / totalNvidia) * 100) : 0}%`, + }} + /> +
+
+ +
+
+ Tenstorrent Boards + +
+
+ {allocatedTT} / {totalTT}{' '} + + ({totalTT > 0 ? Math.round((allocatedTT / totalTT) * 100) : 0}%) + +
+
+
0 ? Math.min(100, (allocatedTT / totalTT) * 100) : 0}%`, + }} + /> +
+
+ +
+
+ Active Workloads + +
+
+ {activeJobs.length} +
+

+ {activeJobs.filter((j) => j.job_state === 'InProgress').length} running ·{' '} + {activeJobs.filter((j) => j.job_state === 'Scheduled').length} queued +

+
+
+
+ + {/* Machine Details & Load */} +
+
+

+ + Machine Status & Hardware Availability +

+ Live placement availability +
+ +

+ CPU and RAM figures are allocatable scheduling capacity, not real-time OS utilization. + Jobs reserve whole NVIDIA GPUs or whole Tenstorrent boards. +

+
+ + {/* Current Jobs Section: First User's, Then Everyone Else's */} +
+
+
+
+

+ + Cluster Workloads & Queue +

+

+ Current execution status across machines. Your jobs are shown first to track queue progression. +

+
+ + Read-Only Monitor + +
+
+ + {jobs.error && ( +

+ Workload queue unavailable: {jobs.error} +

+ )} + + {/* 1. Logged-in User's Active Jobs */} +
+
+

+ + Your Active Experiments ({myActiveJobs.length}) +

+ {myActiveJobs.length > 0 && ( + + {myActiveJobs.some((j) => j.job_state === 'InProgress') + ? 'Active run in progress' + : 'Waiting in cluster queue'} + + )} +
+ + {myActiveJobs.length > 0 ? ( +
+ {myActiveJobs.map((job) => ( + void openInspect(job)} + /> + ))} +
+ ) : ( +
+ You have no jobs currently running or queued. + {myCompletedJobs.length > 0 && ( + + You have {myCompletedJobs.length} completed job(s) in history. Visit Jobs to view them. + + )} +
+ )} +
+ + {/* 2. Everyone Else's Active Jobs */} +
+
+

+ + Other Cluster Workloads ({otherActiveJobs.length}) +

+ + {otherActiveJobs.filter((j) => j.job_state === 'InProgress').length} executing ·{' '} + {otherActiveJobs.filter((j) => j.job_state === 'Scheduled').length} queued + +
+ + {otherActiveJobs.length > 0 ? ( +
+ {otherActiveJobs.map((job) => ( + void openInspect(job)} + /> + ))} +
+ ) : ( +
+ No other active experiments on the cluster right now. Compute machines have available capacity. +
+ )} +
+ + {/* 3. Recent Finished Jobs (Summary) */} + {completedJobs.length > 0 && ( +
+ + Recent Completed Workloads ({completedJobs.length}) + +
+ {completedJobs.slice(0, 10).map((job) => ( + void openInspect(job)} + /> + ))} + {completedJobs.length > 10 && ( +

+ Showing 10 most recent. View all in Jobs → +

+ )} +
+
+ )} +
+ + {/* Read-Only Inspection Modal */} + {inspectingJob && ( + setInspectingJob(null)} + > +
+
+
+ Job ID: + {inspectingJob.id} +
+
+ Status: + + {statusNames[inspectingJob.job_state] ?? inspectingJob.job_state} + +
+
+ Compute: + + {computeNames[inspectingJob.accelerator]} + {inspectingJob.device_count ? ` × ${inspectingJob.device_count}` : ''} + +
+
+ Placed Node: + + {inspectingJob.node ? `Node ${inspectingJob.node}` : 'Waiting for placement'} + +
+
+ Submitted By: + {inspectingJob.creator_name || 'Anonymous'} +
+
+ Workspace: + {inspectingJob.team_id || 'Unassigned'} +
+
+ +
+
+ + + Console Logs (Read-Only) + + {loadingLogs && Loading output…} +
+
+                {loadingLogs ? 'Connecting to pod logs…' : logs}
+              
+
+ +

+ Note: The overview monitor is read-only. To submit new experiments or cancel active workloads, visit the Jobs tab. +

+
+
+ )} +
+ ) +} +export const Dashboard = DashboardPage diff --git a/web-interface/src/components/DatasetsPage.tsx b/web-interface/src/components/DatasetsPage.tsx index 2118ef0..0349ec1 100644 --- a/web-interface/src/components/DatasetsPage.tsx +++ b/web-interface/src/components/DatasetsPage.tsx @@ -136,10 +136,10 @@ export function DatasetsPage() {
-

{team?.name ?? 'Legacy workspace'}

+

{team ? `${team.name} (${team.type ?? 'team'})` : 'Workspace Datasets'}

Datasets

- A shared home for your team's data and experiment results. + Datasets connected to this workspace are mounted at /inputs for any experiment attributed to it.