diff --git a/src/api/ApiUtils.ts b/src/api/ApiUtils.ts index 7c34b38c..27f4a00f 100644 --- a/src/api/ApiUtils.ts +++ b/src/api/ApiUtils.ts @@ -1,5 +1,28 @@ -import { useQueries, useQuery } from '@tanstack/react-query'; -import axios, { type AxiosError } from 'axios'; +import { + useMutation, + useQueries, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import axios, { type AxiosError, type AxiosInstance } from 'axios'; +import { getAuthToken } from '../auth/token'; + +// Dedicated client for the das-gittensor API. A request interceptor attaches the +// session JWT as a Bearer token when present, so authed writes (PATCH/POST/DELETE +// /repos) work while public GETs are unaffected. Deliberately NOT a global axios +// interceptor: githubFetch / the mirror client hit other origins and must never +// receive our token. +export const apiClient: AxiosInstance = axios.create({ + baseURL: import.meta.env.VITE_REACT_APP_BASE_URL || undefined, +}); + +apiClient.interceptors.request.use((config) => { + const token = getAuthToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); export const useMirrorApiQueries = ( queryName: string, @@ -32,13 +55,10 @@ export const useApiQuery = ( queryParams?: Record, enabled?: boolean, ) => { - const baseUrl = import.meta.env.VITE_REACT_APP_BASE_URL; - return useQuery({ queryKey: [queryName, url, queryParams], queryFn: async () => { - const requestUrl = baseUrl ? `${baseUrl}${url}` : url; - const { data } = await axios.get(requestUrl, { params: queryParams }); + const { data } = await apiClient.get(url, { params: queryParams }); return data; }, retry: false, @@ -47,6 +67,25 @@ export const useApiQuery = ( }); }; +// Mutation helper for authed das-gittensor writes (PATCH/POST/DELETE). The caller +// supplies a function that performs the request via the injected `apiClient` +// (Bearer token attached automatically) and returns the response body; on success +// any `invalidateKeys` query keys are refetched so the UI reflects the write. +export const useApiMutation = ( + mutationFn: (client: AxiosInstance, vars: TVars) => Promise, + options?: { invalidateKeys?: readonly unknown[][] }, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (vars) => mutationFn(apiClient, vars), + onSuccess: () => { + options?.invalidateKeys?.forEach((queryKey) => + queryClient.invalidateQueries({ queryKey }), + ); + }, + }); +}; + // Mirror API (https://mirror.gittensor.io/api/v1) — returns raw snake_case // payloads, so callers receive the response as-is and may transform it via // `useQuery`'s `select`. Kept separate from `useApiQuery` so the camelCase diff --git a/src/api/ReposApi.ts b/src/api/ReposApi.ts index 64946147..4f4c1022 100644 --- a/src/api/ReposApi.ts +++ b/src/api/ReposApi.ts @@ -1,8 +1,27 @@ // Repository API hooks - uses /repos endpoints -import { useApiQuery } from './ApiUtils'; +import { useApiMutation, useApiQuery } from './ApiUtils'; import { type RepositoryMaintainer, type RepositoryIssue } from './models'; import { type Repository, type RepositoryMiner } from './models/Dashboard'; +/** One audit row from GET /repos/:repo/config-history (newest first). */ +export type RepositoryConfigEdit = { + id: string; + editorLogin: string | null; + editorGithubId: string; + isAdmin: boolean; + changedKeys: string[] | null; + configBefore: Record | null; + configAfter: Record | null; + note: string | null; + createdAt: string; +}; + +/** Body for a maintainer/admin hyperparameter edit (snake_case config keys). */ +export type RepositoryConfigPatch = { + config: Record; + note?: string; +}; + /** * Helper to create /repos endpoint queries */ @@ -59,3 +78,66 @@ export const useRepositoryMiners = (repo: string) => 'useRepositoryMiners', `/${encodeURIComponent(repo)}/miners`, ); + +/** + * Hyperparameter edit history for a repository (audit trail), newest first. + * @param repo - Full repository name (e.g., "opentensor/btcli") + */ +export const useRepositoryConfigHistory = (repo: string) => + useReposQuery( + 'useRepositoryConfigHistory', + `/${encodeURIComponent(repo)}/config-history`, + ); + +// --------------------------------------------------------------------------- +// Authed writes (require a GitHub session — see AuthContext). On success they +// invalidate the affected repo reads so the UI reflects the change immediately. +// --------------------------------------------------------------------------- + +/** Maintainer/admin edit of a repo's hyperparameters (PATCH /repos/:repo/config). */ +export const useUpdateRepositoryConfig = (repo: string) => + useApiMutation( + (client, body) => + client + .patch(`/repos/${encodeURIComponent(repo)}/config`, body) + .then((r) => r.data), + { + invalidateKeys: [['useRepositoryConfig'], ['useRepositoryConfigHistory']], + }, + ); + +/** Admin: set a repo's emission_share (PATCH /repos/:repo/emission-share). */ +export const useSetEmissionShare = (repo: string) => + useApiMutation<{ emissionShare: number }, Repository>( + (client, body) => + client + .patch( + `/repos/${encodeURIComponent(repo)}/emission-share`, + body, + ) + .then((r) => r.data), + { + invalidateKeys: [['useRepositoryConfig'], ['useRepositoryConfigHistory']], + }, + ); + +/** Admin: register a new repository (POST /repos). */ +export const useRegisterRepository = () => + useApiMutation< + { fullName: string; config?: Record }, + Repository + >( + (client, body) => + client.post('/repos', body).then((r) => r.data), + { invalidateKeys: [['useRepositoryConfig']] }, + ); + +/** Admin: remove a repository (DELETE /repos/:repo). */ +export const useDeleteRepository = () => + useApiMutation<{ repo: string }, void>( + (client, { repo }) => + client + .delete(`/repos/${encodeURIComponent(repo)}`) + .then((r) => r.data), + { invalidateKeys: [['useRepositoryConfig']] }, + ); diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx new file mode 100644 index 00000000..0116d6b1 --- /dev/null +++ b/src/auth/AuthContext.tsx @@ -0,0 +1,127 @@ +/** + * GitHub-OAuth session state for the das-gittensor API. + * + * Flow (matches das AuthController): + * 1. login() sends the browser to `{API}/auth/github`; das bounces through + * GitHub and redirects back to this app with `#token=` in the fragment. + * 2. On mount we capture that fragment, persist the JWT (see token.ts), and + * strip it from the URL so it isn't left in history / shared links. + * 3. With a token present we call `GET /auth/me` to hydrate the user (and learn + * whether they're an admin). A 401 (expired/invalid) clears the session. + * + * The token is attached to API requests by the apiClient interceptor in + * ApiUtils; this context only owns identity + login/logout. + */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { apiClient } from '../api/ApiUtils'; +import { clearAuthToken, getAuthToken, setAuthToken } from './token'; + +export type AuthUser = { + githubId: string; + login: string; + name: string | null; + avatarUrl: string | null; + isAdmin: boolean; +}; + +type AuthContextValue = { + user: AuthUser | null; + isAuthenticated: boolean; + isAdmin: boolean; + /** True while the initial /auth/me hydration is in flight. */ + loading: boolean; + login: () => void; + logout: () => void; +}; + +const AuthContext = createContext(undefined); + +/** Pull `#token=...` out of the post-login redirect fragment, if present. */ +function consumeTokenFromHash(): string | null { + const hash = window.location.hash; + if (!hash.startsWith('#')) return null; + const params = new URLSearchParams(hash.slice(1)); + const token = params.get('token'); + if (!token) return null; + // Remove the fragment without adding a history entry. + const { pathname, search } = window.location; + window.history.replaceState(null, '', `${pathname}${search}`); + return token; +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fromHash = consumeTokenFromHash(); + if (fromHash) { + setAuthToken(fromHash); + } + + const token = getAuthToken(); + if (!token) { + setLoading(false); + return; + } + + let cancelled = false; + apiClient + .get('/auth/me') + .then(({ data }) => { + if (!cancelled) setUser(data); + }) + .catch(() => { + // Expired or invalid session — drop it so the UI shows logged-out. + clearAuthToken(); + if (!cancelled) setUser(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const login = useCallback(() => { + const base = import.meta.env.VITE_REACT_APP_BASE_URL ?? ''; + window.location.href = `${base}/auth/github`; + }, []); + + const logout = useCallback(() => { + clearAuthToken(); + setUser(null); + }, []); + + const value = useMemo( + () => ({ + user, + isAuthenticated: user !== null, + isAdmin: user?.isAdmin ?? false, + loading, + login, + logout, + }), + [user, loading, login, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (ctx === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return ctx; +} diff --git a/src/auth/token.ts b/src/auth/token.ts new file mode 100644 index 00000000..8f195fae --- /dev/null +++ b/src/auth/token.ts @@ -0,0 +1,34 @@ +/** + * Session-token storage for the das-gittensor API. + * + * The token is an 8h JWT handed back by das after GitHub OAuth (delivered in the + * post-login redirect fragment, see AuthContext). It is read here by both the + * axios request interceptor (to attach `Authorization: Bearer`) and AuthContext. + * localStorage access is wrapped so SSR / privacy-mode failures degrade to + * "logged out" rather than throwing. + */ +const TOKEN_KEY = 'gittensor.authToken'; + +export const getAuthToken = (): string | null => { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +}; + +export const setAuthToken = (token: string): void => { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + /* storage unavailable — token stays in-memory only for this page load */ + } +}; + +export const clearAuthToken = (): void => { + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* nothing to do */ + } +}; diff --git a/src/components/repositories/AdminRegisterRepositoryForm.tsx b/src/components/repositories/AdminRegisterRepositoryForm.tsx new file mode 100644 index 00000000..6b5a6443 --- /dev/null +++ b/src/components/repositories/AdminRegisterRepositoryForm.tsx @@ -0,0 +1,163 @@ +/** + * Admin-only direct repository registration (POST /repos). Unlike the public + * request form on this page (which emails the team), this writes straight to the + * das registry and is gated on the admin session. Initial config fields are + * optional — anything left blank is omitted and can be set later via the + * hyperparameters edit form. Bounds reuse repoConfig.ts (TOP_LEVEL_FIELD_DEFS). + */ +import React, { useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { useRegisterRepository } from '../../api'; +import { useAuth } from '../../auth/AuthContext'; +import { + TOP_LEVEL_FIELD_DEFS, + boundsLabel, + validateFieldValue, +} from '../../utils/repoConfig'; + +const FULL_NAME_RE = /^[^/\s]+\/[^/\s]+$/; + +const AdminRegisterRepositoryForm: React.FC = () => { + const { isAdmin } = useAuth(); + const navigate = useNavigate(); + const mutation = useRegisterRepository(); + + const [fullName, setFullName] = useState(''); + const [values, setValues] = useState>({}); + + const fullNameError = + fullName.trim() !== '' && !FULL_NAME_RE.test(fullName.trim()) + ? 'Use the form owner/repo' + : undefined; + + // Validate only the optional config fields the admin actually filled in. + const fieldErrors = useMemo(() => { + const e: Record = {}; + for (const def of TOP_LEVEL_FIELD_DEFS) { + const raw = values[def.key] ?? ''; + if (raw.trim() === '') continue; + const err = validateFieldValue(def, raw); + if (err) e[def.key] = err; + } + return e; + }, [values]); + + const canSubmit = + FULL_NAME_RE.test(fullName.trim()) && + Object.keys(fieldErrors).length === 0 && + !mutation.isPending; + + if (!isAdmin) return null; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + const config: Record = {}; + for (const def of TOP_LEVEL_FIELD_DEFS) { + const raw = values[def.key] ?? ''; + if (raw.trim() !== '') config[def.key] = Number(raw); + } + mutation.mutate( + { fullName: fullName.trim(), config }, + { + onSuccess: () => + navigate( + `/miners/repository?name=${encodeURIComponent(fullName.trim())}&tab=hyperparameters`, + ), + }, + ); + }; + + return ( + ({ + mb: 3, + p: { xs: 2, md: 2.5 }, + borderRadius: 2, + border: `1px solid ${theme.palette.border.medium}`, + backgroundColor: theme.palette.surface.subtle, + })} + > + ({ + color: theme.palette.text.secondary, + fontSize: '0.66rem', + letterSpacing: '0.16em', + textTransform: 'uppercase', + mb: 2, + })} + > + Admin — register a repository directly + + + {mutation.isError && ( + + {(mutation.error?.response?.data as { message?: string })?.message ?? + 'Failed to register the repository.'} + + )} + + + setFullName(e.target.value)} + error={Boolean(fullNameError)} + helperText={fullNameError ?? 'e.g. entrius/gittensor'} + /> + + {TOP_LEVEL_FIELD_DEFS.map((def) => ( + + setValues((prev) => ({ ...prev, [def.key]: e.target.value })) + } + error={Boolean(fieldErrors[def.key])} + helperText={ + fieldErrors[def.key] ?? + `${def.key}${boundsLabel(def) ? ` · ${boundsLabel(def)}` : ''}` + } + fullWidth + /> + ))} + + + + + + + + ); +}; + +export default AdminRegisterRepositoryForm; diff --git a/src/components/repositories/RepositoryConfigHistoryTab.tsx b/src/components/repositories/RepositoryConfigHistoryTab.tsx new file mode 100644 index 00000000..fe6db8d0 --- /dev/null +++ b/src/components/repositories/RepositoryConfigHistoryTab.tsx @@ -0,0 +1,215 @@ +/** + * Change-history tab: the audit trail of hyperparameter edits for a repository + * (GET /repos/:repo/config-history via useRepositoryConfigHistory), newest first. + * Each entry shows who edited it (maintainer vs admin), when, an optional note, + * and a per-changed-key before -> after diff. + */ +import React from 'react'; +import { alpha, Box, Card, Chip, Skeleton, Typography } from '@mui/material'; +import HistoryIcon from '@mui/icons-material/History'; +import { useRepositoryConfigHistory } from '../../api'; +import type { RepositoryConfigEdit } from '../../api/ReposApi'; +import { formatDate } from '../../utils/format'; +import { STATUS_COLORS } from '../../theme'; + +interface Props { + repositoryFullName: string; +} + +const formatValue = (v: unknown): string => { + if (v === null || v === undefined) return '—'; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); +}; + +const formatTimestamp = (iso: string): string => { + const date = formatDate(iso); + const time = new Date(iso).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + return `${date} · ${time}`; +}; + +const ActorChip: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => ( + +); + +/** One audit entry: actor, time, note, and the before -> after diff per key. */ +const EditEntry: React.FC<{ edit: RepositoryConfigEdit }> = ({ edit }) => { + const before = edit.configBefore ?? {}; + const after = edit.configAfter ?? {}; + const keys = + edit.changedKeys && edit.changedKeys.length > 0 ? edit.changedKeys : []; + const lifecycle = + edit.configBefore === null + ? 'created' + : edit.configAfter === null + ? 'deleted' + : null; + + return ( + + + + + {edit.editorLogin ?? `github:${edit.editorGithubId}`} + + + {lifecycle && ( + + )} + + + {formatTimestamp(edit.createdAt)} + + + + {edit.note && ( + + “{edit.note}” + + )} + + {lifecycle !== 'created' && keys.length > 0 && ( + + {keys.map((key) => ( + + + {key} + + + {formatValue(before[key])} + + + → + + + {formatValue(after[key])} + + + ))} + + )} + + ); +}; + +const RepositoryConfigHistoryTab: React.FC = ({ + repositoryFullName, +}) => { + const { data, isLoading } = useRepositoryConfigHistory(repositoryFullName); + + if (isLoading) { + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ); + } + + const edits = data ?? []; + const latest = edits[0]; + + return ( + + + + + + Change history + + + {latest + ? `Last edited by ${latest.editorLogin ?? `github:${latest.editorGithubId}`} on ${formatTimestamp(latest.createdAt)}.` + : 'Audit trail of hyperparameter edits made through the API.'} + + + + + {edits.length === 0 ? ( + + No hyperparameter edits have been recorded for this repository yet. + + ) : ( + edits.map((edit) => ) + )} + + ); +}; + +export default RepositoryConfigHistoryTab; diff --git a/src/components/repositories/RepositoryHyperparametersEditForm.tsx b/src/components/repositories/RepositoryHyperparametersEditForm.tsx new file mode 100644 index 00000000..cb1758c7 --- /dev/null +++ b/src/components/repositories/RepositoryHyperparametersEditForm.tsx @@ -0,0 +1,373 @@ +/** + * Editable form for a repository's hyperparameters (PATCH /repos/:repo/config). + * + * Reuses the field defs/bounds from repoConfig.ts so the inputs, bounds hints and + * client-side validation match the das config-validation.ts contract. Only the + * fields the user actually changed are sent, so: + * - a maintainer's once-a-day cooldown isn't spent on a no-op save, and + * - fields left at their global default don't get frozen into overrides. + * + * emission_share is admin-only (read-only chip for maintainers). Nested + * time_decay is sent in full when any of its knobs change, because das merges + * config one level deep and would otherwise drop the untouched siblings. + */ +import React, { useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + Chip, + Divider, + FormControlLabel, + Switch, + TextField, + Typography, +} from '@mui/material'; +import { useUpdateRepositoryConfig } from '../../api'; +import { useAuth } from '../../auth/AuthContext'; +import type { RepositoryConfig } from '../../api/models/Dashboard'; +import { + ELIGIBILITY_FIELD_DEFS, + SCORING_FIELD_DEFS, + TIME_DECAY_FIELD_DEFS, + TOP_LEVEL_FIELD_DEFS, + boundsLabel, + validateFieldValue, + type RepoConfigFieldDef, +} from '../../utils/repoConfig'; + +interface Props { + repositoryFullName: string; + config: RepositoryConfig; + onClose: () => void; +} + +const TOP_LEVEL_ACCESSORS: Record = { + emission_share: 'emissionShare', + issue_discovery_share: 'issueDiscoveryShare', + maintainer_cut: 'maintainerCut', + default_label_multiplier: 'defaultLabelMultiplier', + fixed_base_score: 'fixedBaseScore', +}; + +const numToInput = (value: unknown): string => { + if (value === null || value === undefined || value === '') return ''; + const n = Number(value); + return Number.isFinite(n) ? String(n) : ''; +}; + +/** Initial input string for a field, reading the repo's stored value or default. */ +function initialFor( + def: RepoConfigFieldDef, + config: RepositoryConfig, + group: 'top' | 'eligibility' | 'scoring' | 'timeDecay', +): string { + if (group === 'top') { + const raw = config[TOP_LEVEL_ACCESSORS[def.key]]; + // fixed_base_score is nullable — empty means "not set". + if (def.key === 'fixed_base_score') return numToInput(raw); + return numToInput(raw ?? def.default); + } + let raw: unknown; + if (group === 'eligibility') + raw = (config.eligibility as Record | undefined)?.[ + def.key + ]; + else if (group === 'scoring') + raw = (config.scoring as Record | undefined)?.[def.key]; + else + raw = (config.scoring?.time_decay as Record | undefined)?.[ + def.key + ]; + return numToInput(raw ?? def.default); +} + +const RepositoryHyperparametersEditForm: React.FC = ({ + repositoryFullName, + config, + onClose, +}) => { + const { isAdmin } = useAuth(); + const mutation = useUpdateRepositoryConfig(repositoryFullName); + + // Flat value map keyed by `${group}.${key}` so every input is independent. + const initialValues = useMemo(() => { + const v: Record = {}; + for (const def of TOP_LEVEL_FIELD_DEFS) + v[`top.${def.key}`] = initialFor(def, config, 'top'); + for (const def of ELIGIBILITY_FIELD_DEFS) + v[`eligibility.${def.key}`] = initialFor(def, config, 'eligibility'); + for (const def of SCORING_FIELD_DEFS) + v[`scoring.${def.key}`] = initialFor(def, config, 'scoring'); + for (const def of TIME_DECAY_FIELD_DEFS) + v[`timeDecay.${def.key}`] = initialFor(def, config, 'timeDecay'); + return v; + }, [config]); + + const [values, setValues] = useState>(initialValues); + const [trusted, setTrusted] = useState( + Boolean(config.trustedLabelPipeline), + ); + const initialBranches = (config.additionalAcceptableBranches ?? []).join( + ', ', + ); + const [branches, setBranches] = useState(initialBranches); + const [note, setNote] = useState(''); + + const setValue = (id: string, val: string) => + setValues((prev) => ({ ...prev, [id]: val })); + + // Per-field validation errors (fixed_base_score may be empty = cleared). + const errors = useMemo(() => { + const e: Record = {}; + const check = (id: string, def: RepoConfigFieldDef, optional = false) => { + const raw = values[id] ?? ''; + if (optional && raw.trim() === '') return; + const err = validateFieldValue(def, raw); + if (err) e[id] = err; + }; + for (const def of TOP_LEVEL_FIELD_DEFS) { + if (def.key === 'emission_share' && !isAdmin) continue; + check(`top.${def.key}`, def, def.key === 'fixed_base_score'); + } + for (const def of ELIGIBILITY_FIELD_DEFS) + check(`eligibility.${def.key}`, def); + for (const def of SCORING_FIELD_DEFS) check(`scoring.${def.key}`, def); + for (const def of TIME_DECAY_FIELD_DEFS) check(`timeDecay.${def.key}`, def); + return e; + }, [values, isAdmin]); + + const hasErrors = Object.keys(errors).length > 0; + + // Build a snake_case patch of only the fields that actually changed. + const patch = useMemo(() => { + const out: Record = {}; + + // Top-level scalars. + for (const def of TOP_LEVEL_FIELD_DEFS) { + const id = `top.${def.key}`; + if (values[id] === initialValues[id]) continue; + if (def.key === 'emission_share' && !isAdmin) continue; + if (def.key === 'fixed_base_score') { + out[def.key] = values[id].trim() === '' ? null : Number(values[id]); + } else { + out[def.key] = Number(values[id]); + } + } + + // Eligibility (das merges this object one level deep — partial is fine). + const eligibility: Record = {}; + for (const def of ELIGIBILITY_FIELD_DEFS) { + const id = `eligibility.${def.key}`; + if (values[id] !== initialValues[id]) + eligibility[def.key] = Number(values[id]); + } + if (Object.keys(eligibility).length) out.eligibility = eligibility; + + // Scoring scalars + time_decay. time_decay is sent whole when any knob + // changes (one-level merge would drop untouched siblings otherwise). + const scoring: Record = {}; + for (const def of SCORING_FIELD_DEFS) { + const id = `scoring.${def.key}`; + if (values[id] !== initialValues[id]) + scoring[def.key] = Number(values[id]); + } + const timeDecayChanged = TIME_DECAY_FIELD_DEFS.some( + (def) => + values[`timeDecay.${def.key}`] !== + initialValues[`timeDecay.${def.key}`], + ); + if (timeDecayChanged) { + const td: Record = {}; + for (const def of TIME_DECAY_FIELD_DEFS) + td[def.key] = Number(values[`timeDecay.${def.key}`]); + scoring.time_decay = td; + } + if (Object.keys(scoring).length) out.scoring = scoring; + + // Boolean + branches. + if (trusted !== Boolean(config.trustedLabelPipeline)) + out.trusted_label_pipeline = trusted; + if (branches !== initialBranches) { + out.additional_acceptable_branches = branches + .split(',') + .map((b) => b.trim()) + .filter(Boolean); + } + return out; + }, [ + values, + initialValues, + isAdmin, + trusted, + branches, + config, + initialBranches, + ]); + + const isDirty = Object.keys(patch).length > 0; + + const handleSave = () => { + if (!isDirty || hasErrors) return; + mutation.mutate( + { config: patch, note: note.trim() || undefined }, + { onSuccess: () => onClose() }, + ); + }; + + const renderField = (def: RepoConfigFieldDef, group: string) => { + const id = `${group}.${def.key}`; + const bounds = boundsLabel(def); + return ( + setValue(id, e.target.value)} + error={Boolean(errors[id])} + helperText={errors[id] ?? `${def.key}${bounds ? ` · ${bounds}` : ''}`} + fullWidth + /> + ); + }; + + const grid = { + display: 'grid', + gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, + gap: 2, + } as const; + + return ( + + {mutation.isError && ( + + {(mutation.error?.response?.data as { message?: string })?.message ?? + 'Failed to save changes. You may lack write access or be within the edit cooldown.'} + + )} + + + + Emission & pools + + + {TOP_LEVEL_FIELD_DEFS.map((def) => { + if (def.key === 'emission_share' && !isAdmin) { + return ( + + ); + } + return renderField(def, 'top'); + })} + + setTrusted(e.target.checked)} + /> + } + label="Trusted label pipeline" + /> + setBranches(e.target.value)} + helperText="Extra branches beyond the default branch, e.g. test, develop" + fullWidth + /> + + + + + Eligibility + + + {ELIGIBILITY_FIELD_DEFS.map((def) => renderField(def, 'eligibility'))} + + + + + + Scoring + + + {SCORING_FIELD_DEFS.map((def) => renderField(def, 'scoring'))} + + + + + + Time decay + + + {TIME_DECAY_FIELD_DEFS.map((def) => renderField(def, 'timeDecay'))} + + + + + + setNote(e.target.value)} + helperText="Recorded in the change history." + fullWidth + multiline + /> + + + + + {!isDirty && ( + + )} + + + ); +}; + +export default RepositoryHyperparametersEditForm; diff --git a/src/components/repositories/RepositoryHyperparametersTab.tsx b/src/components/repositories/RepositoryHyperparametersTab.tsx index 2ac9fc76..28c00142 100644 --- a/src/components/repositories/RepositoryHyperparametersTab.tsx +++ b/src/components/repositories/RepositoryHyperparametersTab.tsx @@ -1,7 +1,8 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { alpha, Box, + Button, Card, Chip, Divider, @@ -9,8 +10,11 @@ import { Tooltip, Typography, } from '@mui/material'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useRepositoryConfig } from '../../api'; +import { useAuth } from '../../auth/AuthContext'; +import RepositoryHyperparametersEditForm from './RepositoryHyperparametersEditForm'; import { STATUS_COLORS, tooltipSlotProps } from '../../theme'; import type { RepositoryConfig } from '../../api/models/Dashboard'; import { @@ -319,6 +323,8 @@ const RepositoryHyperparametersTab: React.FC< RepositoryHyperparametersTabProps > = ({ repositoryFullName }) => { const { data, isLoading } = useRepositoryConfig(repositoryFullName); + const { isAuthenticated } = useAuth(); + const [editing, setEditing] = useState(false); const config = data?.config; const resolved = useMemo(() => resolveRepoConfig(config), [config]); @@ -352,35 +358,67 @@ const RepositoryHyperparametersTab: React.FC< return ( - - - Repository hyperparameters - - - {resolved.overrideCount > 0 ? ( - <> - - {resolved.overrideCount} of {totalKnobs} - {' '} - scoring & eligibility knobs are overridden for this repo. - - ) : ( - `All ${totalKnobs} scoring & eligibility knobs use the global defaults.` - )} - + + + + Repository hyperparameters + + + {editing ? ( + 'Edit the values below and save. Only changed fields are submitted; maintainers may edit once per day.' + ) : resolved.overrideCount > 0 ? ( + <> + + {resolved.overrideCount} of {totalKnobs} + {' '} + scoring & eligibility knobs are overridden for this repo. + + ) : ( + `All ${totalKnobs} scoring & eligibility knobs use the global defaults.` + )} + + + {isAuthenticated && !editing && ( + + )} - - - - + {editing ? ( + setEditing(false)} + /> + ) : ( + <> + + + + + + )} ); }; diff --git a/src/components/repositories/index.ts b/src/components/repositories/index.ts index be78feff..56152726 100644 --- a/src/components/repositories/index.ts +++ b/src/components/repositories/index.ts @@ -10,4 +10,6 @@ export { default as RepositoryCodeBrowser } from './RepositoryCodeBrowser'; export { default as RepositoryMaintainers } from './RepositoryMaintainers'; export { default as RepositoryCheckTab } from './RepositoryCheckTab'; export { default as RepositoryHyperparametersTab } from './RepositoryHyperparametersTab'; +export { default as RepositoryConfigHistoryTab } from './RepositoryConfigHistoryTab'; +export { default as AdminRegisterRepositoryForm } from './AdminRegisterRepositoryForm'; export { default as RepositoryMinersTab } from './RepositoryMinersTab'; diff --git a/src/main.tsx b/src/main.tsx index 570abfc1..fdb73c83 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,7 @@ import { CssBaseline } from '@mui/material'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { HelmetProvider } from 'react-helmet-async'; import ErrorBoundary from './components/ErrorBoundary'; +import { AuthProvider } from './auth/AuthContext'; import './index.css'; const queryClient = new QueryClient({ @@ -28,9 +29,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + diff --git a/src/pages/RepositoryDetailsPage.tsx b/src/pages/RepositoryDetailsPage.tsx index def5ffc4..5e6ee2ff 100644 --- a/src/pages/RepositoryDetailsPage.tsx +++ b/src/pages/RepositoryDetailsPage.tsx @@ -35,6 +35,7 @@ import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'; import FactCheckIcon from '@mui/icons-material/FactCheck'; import TuneIcon from '@mui/icons-material/Tune'; import GroupsIcon from '@mui/icons-material/Groups'; +import HistoryIcon from '@mui/icons-material/History'; import { RANK_COLORS, STATUS_COLORS } from '../theme'; import { Page } from '../components/layout'; import { useReposAndWeights, useRepoBountySummary } from '../api'; @@ -52,6 +53,7 @@ import { RepositoryMaintainers, RepositoryCheckTab, RepositoryHyperparametersTab, + RepositoryConfigHistoryTab, RepositoryMinersTab, WatchlistButton, } from '../components'; @@ -201,6 +203,7 @@ const REPO_TAB_KEYS = [ 'pull-requests', 'contributing', 'repo-check', + 'history', ] as const; function tabIndexFromSearchParam(tab: string | null): number { @@ -590,6 +593,12 @@ const RepositoryDetailsPage: React.FC = () => { label="Repo Check" disableRipple /> + } + iconPosition="start" + label="History" + disableRipple + /> @@ -642,6 +651,11 @@ const RepositoryDetailsPage: React.FC = () => { + + {/* History Tab */} + + + {/* Sidebar */} diff --git a/src/pages/RepositoryRegistrationPage.tsx b/src/pages/RepositoryRegistrationPage.tsx index fdd663f4..3ad06fe9 100644 --- a/src/pages/RepositoryRegistrationPage.tsx +++ b/src/pages/RepositoryRegistrationPage.tsx @@ -15,7 +15,7 @@ import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import { isRepoTracked } from '../api'; import { useLinkBehavior } from '../components/common/linkBehavior'; import { Page } from '../components/layout'; -import { SEO } from '../components'; +import { SEO, AdminRegisterRepositoryForm } from '../components'; import { extractRepoFullName } from '../utils'; type VerifyResult = @@ -336,6 +336,9 @@ const RepositoryRegistrationPage: React.FC = () => { + {/* Admins can register a repo directly into the registry; self-gates. */} + + ({ mb: 3, diff --git a/src/utils/repoConfig.ts b/src/utils/repoConfig.ts index 1e897da9..2f4a7ec1 100644 --- a/src/utils/repoConfig.ts +++ b/src/utils/repoConfig.ts @@ -36,6 +36,61 @@ export type RepoConfigFieldDef = { format: RepoConfigFormat; }; +// --- Top-level scalar knobs (das config-validation.ts validateConfigPatch) -- +// emission_share is admin-only; the rest are maintainer-editable. Bounds mirror +// das config-validation.ts and gittensor load_weights.py (keep in sync; the +// Phase-7 contract test asserts they match). + +export const TOP_LEVEL_FIELD_DEFS: RepoConfigFieldDef[] = [ + { + key: 'emission_share', + label: 'Emission share', + description: + "This repo's share of the combined scoring pool. Admin-only; the registry-wide sum must stay within 1.0.", + default: 0, + min: 0, + max: 1, + format: 'percent', + }, + { + key: 'issue_discovery_share', + label: 'Issue discovery share', + description: + "Share of this repo's emission allocated to the issue-discovery pool.", + default: 0, + min: 0, + max: 1, + format: 'percent', + }, + { + key: 'maintainer_cut', + label: 'Maintainer cut', + description: "Share of this repo's emission routed to the maintainer.", + default: 0, + min: 0, + max: 1, + format: 'percent', + }, + { + key: 'default_label_multiplier', + label: 'Default label multiplier', + description: 'Score multiplier applied to issues with no matching label.', + default: 1, + min: 0, + max: 20, + format: 'multiplier', + }, + { + key: 'fixed_base_score', + label: 'Fixed base score', + description: + 'Overrides the computed base score with a fixed value. Leave empty to disable.', + default: 0, + min: 0, + format: 'score', + }, +]; + // --- Scoring knobs (gittensor RepoScoringConfig) ---------------------------- export const SCORING_FIELD_DEFS: RepoConfigFieldDef[] = [ @@ -352,6 +407,36 @@ export function resolveRepoConfig( // --- Display helpers -------------------------------------------------------- +/** + * Client-side validation of a raw input against a field def's bounds. Mirrors + * das `config-validation.ts` so the UI rejects what the API would reject. + * Returns an error message, or null when valid. Empty `raw` is "Required"; the + * caller handles optional/clearable fields before calling. + */ +export function validateFieldValue( + def: RepoConfigFieldDef, + raw: string, +): string | null { + const trimmed = raw.trim(); + if (trimmed === '') return 'Required'; + const n = Number(trimmed); + if (!Number.isFinite(n)) return 'Must be a number'; + if (def.format === 'integer' && !Number.isInteger(n)) { + return 'Must be a whole number'; + } + if (def.min !== undefined) { + if (def.minExclusive ? n <= def.min : n < def.min) { + return `Must be ${def.minExclusive ? '>' : '≥'} ${def.min}`; + } + } + if (def.max !== undefined) { + if (def.maxExclusive ? n >= def.max : n > def.max) { + return `Must be ${def.maxExclusive ? '<' : '≤'} ${def.max}`; + } + } + return null; +} + /** Human-readable bound, e.g. "[1, 90]" or "(0, 1]". Empty when unbounded. */ export function boundsLabel(def: RepoConfigFieldDef): string { if (def.min === undefined && def.max === undefined) return '';