Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,26 @@ test('revokeWatcherLink DELETEs the link by id', async () => {
assert.ok(calls[0].url.endsWith('/api/api-tokens/531'));
});

test('getSubaccountGeneratedBtc GETs the per-subaccount generated-BTC list with a token', async () => {
const rows = [{ entry_day: '2026-07-08', hashrate: 98, btc_generated: 0.00001274 }];
const { fetchImpl, calls } = fakeFetch(() => jsonResponse(rows));
const client = createDmndClient({ fetchImpl, backoffMs: 0 });

const result = await client.getSubaccountGeneratedBtc('-77', 'sub-tok');

assert.equal(calls[0].init.method, 'GET');
assert.ok(calls[0].url.includes('/api/user/sub_account/-77/generated_btc'));
assert.ok(calls[0].url.includes('token=sub-tok'));
assert.deepEqual(result, rows);
});

test('getSubaccountGeneratedBtc collapses a non-array response to an empty list', async () => {
const { fetchImpl } = fakeFetch(() => jsonResponse({ error: 'nope' }));
const client = createDmndClient({ fetchImpl, backoffMs: 0 });

assert.deepEqual(await client.getSubaccountGeneratedBtc('-77', 'sub-tok'), []);
});

test('a 4xx with a server message surfaces it as an unknown error', async () => {
const { fetchImpl } = fakeFetch(
() =>
Expand Down
33 changes: 33 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
type RequestOptions,
type SignupInput,
type Subaccount,
type SubaccountHashratePoint,
type SubaccountShareStats,
type SubaccountSummary,
type Worker,
type WorkersResponse,
Expand Down Expand Up @@ -318,6 +320,26 @@ export function createDmndClient(options: DmndClientOptions = {}): DmndClient {
);
return Array.isArray(result) ? (result as HashratePoint[]) : [];
},
async getShareStats(req) {
// The account's own counterpart to a subaccount's summary.share_stats, so a
// combined rejection rate can be computed over the same 24h window for every
// account rather than mixing windows.
const result = await request<unknown>({ method: 'GET', path: '/api/user/share_stats' }, opts, req);
return result && typeof result === 'object' ? (result as SubaccountShareStats) : null;
},
async getSubaccountHashrateHistory(id, token, from, to, req) {
const params = new URLSearchParams({ token, from, to }).toString();
const result = await request<unknown>(
{
method: 'GET',
path: `/api/user/sub_account/${encodeURIComponent(id)}/hashrate/historical?${params}`,
timeoutMs: 20_000,
},
opts,
req,
);
return Array.isArray(result) ? (result as SubaccountHashratePoint[]) : [];
},
getWorkers(from, to, req) {
const query = new URLSearchParams({ from, to }).toString();
return request<WorkersResponse>({ method: 'GET', path: `/api/workers?${query}` }, opts, req);
Expand Down Expand Up @@ -371,6 +393,17 @@ export function createDmndClient(options: DmndClientOptions = {}): DmndClient {
req,
);
},
async getSubaccountGeneratedBtc(id, token, req) {
// Bare array like the main /api/generated_btc; the same non-array collapse guards
// against an error body ever reaching the page as if it were data.
const q = new URLSearchParams({ token }).toString();
const result = await request<unknown>(
{ method: 'GET', path: `/api/user/sub_account/${encodeURIComponent(id)}/generated_btc?${q}` },
opts,
req,
);
return Array.isArray(result) ? (result as GeneratedBtcEntry[]) : [];
},
getPermissions(req) {
return request<AccountPermissions>({ method: 'GET', path: '/api/user/permissions' }, opts, req);
},
Expand Down
44 changes: 42 additions & 2 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ export interface HashratePoint {
account_id?: number;
}

/** A hashrate figure as the per-subaccount endpoints report it: a value plus its unit. */
export interface HashrateMeasure {
value: number;
unit: string;
}

/**
* One point of a subaccount's historical series. The account-level endpoint returns
* bare numbers in H/s; this one wraps each figure with its unit (observed live as
* "TH/s"), so the two are only comparable once normalised to H/s.
*/
export interface SubaccountHashratePoint {
observed_at: string;
pplns_hashrate: HashrateMeasure | null;
fpps_hashrate: HashrateMeasure | null;
total_hashrate: HashrateMeasure | null;
account_id?: number;
}

/**
* A single worker row (GET /api/workers and /api/workers/all). Per the spec the
* numeric fields are nullable and `connected_at` is a unix timestamp.
Expand Down Expand Up @@ -124,8 +143,13 @@ export interface PayoutAddresses {
*/
export interface GeneratedBtcEntry {
entry_day: string;
hashrate: number;
btc_generated: number;
// Both figures are nullable on real rows: the API reports null for a day it has no
// reading for, so display and export must treat "missing" as unknown, not as zero.
hashrate: number | null;
btc_generated: number | null;
// Which account this day's entry belongs to, set only in aggregated mode where rows
// span the main account and every subaccount.
account?: string;
}

/**
Expand Down Expand Up @@ -255,6 +279,20 @@ export interface DmndClient {
* downsample before charting; a non-array response still collapses to [].
*/
getHashrateHistory(from: string, to: string, req?: RequestOptions): Promise<HashratePoint[]>;
/** The account's own 24h accepted/rejected counts (GET /api/user/share_stats). */
getShareStats(req?: RequestOptions): Promise<SubaccountShareStats | null>;
/**
* A subaccount's historical hashrate. Returns the same points as the account-level
* series but with each figure nested as `{value, unit}` rather than a bare number,
* so callers must normalise before combining the two.
*/
getSubaccountHashrateHistory(
id: string,
token: string,
from: string,
to: string,
req?: RequestOptions,
): Promise<SubaccountHashratePoint[]>;
/** One page of workers for a date range (GET /api/workers); used by the workers page. */
getWorkers(from: string, to: string, req?: RequestOptions): Promise<WorkersResponse>;
/** The full worker roster (GET /api/workers/all, following every page); home counts. */
Expand All @@ -269,6 +307,8 @@ export interface DmndClient {
getSubaccountSummary(id: string, token: string, req?: RequestOptions): Promise<SubaccountSummary>;
/** Per-subaccount worker roster; active/offline counts derive from this. */
getSubaccountWorkers(id: string, token: string, req?: RequestOptions): Promise<WorkersResponse>;
/** The subaccount's daily generated-BTC entries; a bare array, empty when none. */
getSubaccountGeneratedBtc(id: string, token: string, req?: RequestOptions): Promise<GeneratedBtcEntry[]>;
/** Capability flags gating the Create button and the page itself. */
getPermissions(req?: RequestOptions): Promise<AccountPermissions>;
/** The account's watcher links (GET /api/api-tokens); a bare array, empty when none. */
Expand Down
5 changes: 5 additions & 0 deletions src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ export interface AuthContextValue {
session: Session | null;
signOutReason: SignOutReason | null;
status: AuthStatus;
/** The subaccount being viewed via the switcher, or null for the master account. */
viewingAccountId: string | null;
signIn: (session: Session) => void;
signOut: (reason?: SignOutReason) => void;
setViewingAccount: (accountId: string | null) => void;
}

export const AuthContext = createContext<AuthContextValue | null>(null);
Expand Down Expand Up @@ -105,8 +108,10 @@ export function AuthProvider({ children, store: injectedStore }: AuthProviderPro
session: state.session,
signOutReason: state.signOutReason,
status: state.session ? 'authenticated' : 'anonymous',
viewingAccountId: state.viewingAccountId,
signIn: store.signIn,
signOut: store.signOut,
setViewingAccount: store.setViewingAccount,
}),
[state, store],
);
Expand Down
30 changes: 30 additions & 0 deletions src/auth/__tests__/authStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,33 @@ test('a store that never connected is not cleared by another tab claiming the sa
// The unconnected store ignored the claim and kept its session.
assert.equal(ghost.getSnapshot().session?.accountId, '1');
});

test('setViewingAccount scopes to a subaccount and back to the master account', () => {
const store = createAuthStore({ tabId: 'A', storage: memoryStorage(), channel: null });
store.signIn(createSession({ accountId: 'master', email: 'm@x.io' }));

store.setViewingAccount('sub-1');
assert.equal(store.getSnapshot().viewingAccountId, 'sub-1');

store.setViewingAccount(null);
assert.equal(store.getSnapshot().viewingAccountId, null);
});

test('an idle bump keeps the viewed subaccount, but signing in or out resets it', () => {
const store = createAuthStore({ tabId: 'A', storage: memoryStorage(), channel: null });
store.signIn(createSession({ accountId: 'master', email: 'm@x.io' }));
store.setViewingAccount('sub-1');

// An activity refresh must not kick the miner back to the master account.
store.bumpActivity();
assert.equal(store.getSnapshot().viewingAccountId, 'sub-1');

// Signing out clears the view scope.
store.signOut();
assert.equal(store.getSnapshot().viewingAccountId, null);

// A fresh sign-in starts on the master account, never a stale subaccount.
store.setViewingAccount('sub-2');
store.signIn(createSession({ accountId: 'master', email: 'm@x.io' }));
assert.equal(store.getSnapshot().viewingAccountId, null);
});
36 changes: 30 additions & 6 deletions src/auth/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export type SignOutReason = 'user' | 'expired' | 'duplicate_tab';
export interface AuthState {
session: Session | null;
signOutReason: SignOutReason | null;
// The subaccount currently being viewed via the account switcher, or null for the
// master account. Kept in memory only (never written to storage) so a reload always
// returns to the master account rather than silently staying scoped to a subaccount.
viewingAccountId: string | null;
}

export interface AuthStore {
Expand All @@ -22,6 +26,8 @@ export interface AuthStore {
connect: () => void;
signIn: (session: Session) => void;
signOut: (reason?: SignOutReason) => void;
/** Scope the dashboard to a subaccount (id) or back to the master account (null). */
setViewingAccount: (accountId: string | null) => void;
bumpActivity: (now?: number) => void;
checkExpiry: (now?: number) => void;
tabId: string;
Expand Down Expand Up @@ -78,6 +84,9 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore {
let state: AuthState = {
session: readSession(storage),
signOutReason: null,
// A restored session always starts on the master account: the view scope is never
// persisted, so a reload cannot land the miner inside a subaccount.
viewingAccountId: null,
};
// Keep the cloud client's X-Account-ID in lockstep with the session, set
// synchronously here (not in a React effect) so a restored session has it
Expand All @@ -88,9 +97,14 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore {
for (const l of listeners) l();
};

// The account the cloud client scopes calls to: the viewed subaccount when the
// switcher is active, otherwise the master session's account.
const effectiveAccountId = (s: AuthState): string | null =>
s.viewingAccountId ?? s.session?.accountId ?? null;

const setState = (next: AuthState) => {
state = next;
setDmndAccountId(next.session?.accountId ?? null);
setDmndAccountId(effectiveAccountId(next));
emit();
};

Expand All @@ -104,7 +118,7 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore {
if (!state.session) return;
if (m.accountId !== state.session.accountId) return;
clearSession(storage);
setState({ session: null, signOutReason: 'duplicate_tab' });
setState({ session: null, signOutReason: 'duplicate_tab', viewingAccountId: null });
};

// Best-effort cross-tab claim. The channel can be closed (e.g. a StrictMode
Expand Down Expand Up @@ -142,25 +156,35 @@ export function createAuthStore(options: AuthStoreOptions = {}): AuthStore {
return state;
},
signIn(session) {
// A fresh sign-in always starts on the master account, clearing any stale view
// scope from a previous session.
writeSession(session, storage);
setState({ session, signOutReason: null });
setState({ session, signOutReason: null, viewingAccountId: null });
postClaim(session.accountId);
},
signOut(reason: SignOutReason = 'user') {
clearSession(storage);
setState({ session: null, signOutReason: reason });
setState({ session: null, signOutReason: reason, viewingAccountId: null });
},
setViewingAccount(accountId: string | null) {
if (!state.session) return;
// Only re-scope the view; the master session is untouched, so switching back is
// just clearing this to null.
setState({ ...state, viewingAccountId: accountId });
},
bumpActivity(now?: number) {
if (!state.session) return;
// An idle refresh preserves the viewed account: a mere activity tick must not
// yank the miner out of a subaccount they are viewing.
const refreshed = refreshIdle(state.session, now);
writeSession(refreshed, storage);
setState({ session: refreshed, signOutReason: state.signOutReason });
setState({ ...state, session: refreshed });
},
checkExpiry(now?: number) {
if (!state.session) return;
if (isExpired(state.session, now)) {
clearSession(storage);
setState({ session: null, signOutReason: 'expired' });
setState({ session: null, signOutReason: 'expired', viewingAccountId: null });
}
},
teardown() {
Expand Down
Loading
Loading