Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change.
672 lines
19 KiB
TypeScript
672 lines
19 KiB
TypeScript
import { createQuery } from '@tanstack/svelte-query';
|
|
import { api } from './client';
|
|
import { qk } from './queries';
|
|
import type {
|
|
ActionResult,
|
|
AdminPlaybackError,
|
|
AdminQuarantineRow,
|
|
LidarrConfig,
|
|
LidarrMetadataProfile,
|
|
LidarrQualityProfile,
|
|
LidarrQuarantineActionRow,
|
|
LidarrRequest,
|
|
LidarrRequestStatus,
|
|
LidarrRootFolder,
|
|
LidarrTestResult,
|
|
PlaybackErrorResolution
|
|
} from './types';
|
|
|
|
// Admin Lidarr config -----------------------------------------------------
|
|
|
|
export async function getLidarrConfig(): Promise<LidarrConfig> {
|
|
return api.get<LidarrConfig>('/api/admin/lidarr/config');
|
|
}
|
|
|
|
export async function putLidarrConfig(cfg: LidarrConfig): Promise<LidarrConfig> {
|
|
return api.put<LidarrConfig>('/api/admin/lidarr/config', cfg);
|
|
}
|
|
|
|
// testLidarrConnection always returns 200 with a discriminated-union body —
|
|
// callers branch on `result.ok`. We do NOT throw on `ok:false`; the SPA wants
|
|
// to render either branch (e.g. "connected to Lidarr X.Y.Z" vs "auth failed").
|
|
export async function testLidarrConnection(
|
|
body: { base_url?: string; api_key?: string } = {}
|
|
): Promise<LidarrTestResult> {
|
|
return api.post<LidarrTestResult>('/api/admin/lidarr/test', body);
|
|
}
|
|
|
|
export async function listQualityProfiles(): Promise<LidarrQualityProfile[]> {
|
|
return api.get<LidarrQualityProfile[]>('/api/admin/lidarr/quality-profiles');
|
|
}
|
|
|
|
export async function listMetadataProfiles(): Promise<LidarrMetadataProfile[]> {
|
|
return api.get<LidarrMetadataProfile[]>('/api/admin/lidarr/metadata-profiles');
|
|
}
|
|
|
|
export async function listRootFolders(): Promise<LidarrRootFolder[]> {
|
|
return api.get<LidarrRootFolder[]>('/api/admin/lidarr/root-folders');
|
|
}
|
|
|
|
// Admin request queue -----------------------------------------------------
|
|
|
|
export async function listAdminRequests(
|
|
status?: LidarrRequestStatus,
|
|
limit?: number
|
|
): Promise<LidarrRequest[]> {
|
|
const params = new URLSearchParams();
|
|
if (status) params.set('status', status);
|
|
if (limit !== undefined) params.set('limit', String(limit));
|
|
const qs = params.toString();
|
|
return api.get<LidarrRequest[]>(
|
|
qs ? `/api/admin/requests?${qs}` : '/api/admin/requests'
|
|
);
|
|
}
|
|
|
|
export async function approveRequest(
|
|
id: string,
|
|
overrides: { quality_profile_id?: number; root_folder_path?: string } = {}
|
|
): Promise<LidarrRequest> {
|
|
return api.post<LidarrRequest>(`/api/admin/requests/${id}/approve`, overrides);
|
|
}
|
|
|
|
export async function rejectRequest(
|
|
id: string,
|
|
notes?: string
|
|
): Promise<LidarrRequest> {
|
|
const body = notes !== undefined ? { notes } : {};
|
|
return api.post<LidarrRequest>(`/api/admin/requests/${id}/reject`, body);
|
|
}
|
|
|
|
// Query factories ---------------------------------------------------------
|
|
|
|
export function createLidarrConfigQuery() {
|
|
return createQuery({
|
|
queryKey: qk.lidarrConfig(),
|
|
queryFn: getLidarrConfig,
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// `enabled` is passed in by the caller — typically derived from
|
|
// LidarrConfig.enabled — so the query only fires once Lidarr is configured.
|
|
// Keeping it as a prop (vs. reading config inside this factory) preserves
|
|
// purity and lets the caller choose its own gating logic.
|
|
export function createQualityProfilesQuery(enabled: boolean = true) {
|
|
return createQuery({
|
|
queryKey: qk.lidarrQualityProfiles(),
|
|
queryFn: listQualityProfiles,
|
|
enabled
|
|
});
|
|
}
|
|
|
|
export function createMetadataProfilesQuery(enabled: boolean = true) {
|
|
return createQuery({
|
|
queryKey: qk.lidarrMetadataProfiles(),
|
|
queryFn: listMetadataProfiles,
|
|
enabled
|
|
});
|
|
}
|
|
|
|
export function createRootFoldersQuery(enabled: boolean = true) {
|
|
return createQuery({
|
|
queryKey: qk.lidarrRootFolders(),
|
|
queryFn: listRootFolders,
|
|
enabled
|
|
});
|
|
}
|
|
|
|
export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
|
|
return createQuery({
|
|
queryKey: qk.adminRequests(status),
|
|
queryFn: () => listAdminRequests(status),
|
|
// Only the 'approved' tab needs polling — that's where in-flight
|
|
// ingests live. Other tabs (pending/rejected/completed) are static
|
|
// until the operator acts on them.
|
|
refetchInterval: (query) => {
|
|
if (status !== 'approved') return false;
|
|
const rows = query.state.data as LidarrRequest[] | undefined;
|
|
return hasInFlightRequest(rows) ? 12_000 : false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined): boolean {
|
|
return rows?.some((r) => r.status === 'approved') ?? false;
|
|
}
|
|
|
|
// Admin quarantine --------------------------------------------------------
|
|
|
|
export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> {
|
|
return api.get<AdminQuarantineRow[]>('/api/admin/quarantine');
|
|
}
|
|
|
|
export async function resolveQuarantine(trackID: string): Promise<ActionResult> {
|
|
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/resolve`, {});
|
|
}
|
|
|
|
export async function deleteQuarantineFile(trackID: string): Promise<ActionResult> {
|
|
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {});
|
|
}
|
|
|
|
export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> {
|
|
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {});
|
|
}
|
|
|
|
export async function listQuarantineActions(
|
|
limit: number = 50
|
|
): Promise<LidarrQuarantineActionRow[]> {
|
|
return api.get<LidarrQuarantineActionRow[]>(
|
|
`/api/admin/quarantine/actions?limit=${limit}`
|
|
);
|
|
}
|
|
|
|
export function createAdminQuarantineQuery() {
|
|
return createQuery({
|
|
queryKey: qk.adminQuarantine(),
|
|
queryFn: listAdminQuarantine,
|
|
staleTime: 30_000
|
|
});
|
|
}
|
|
|
|
export function createQuarantineActionsQuery(limit: number = 50) {
|
|
return createQuery({
|
|
queryKey: qk.adminQuarantineActions(limit),
|
|
queryFn: () => listQuarantineActions(limit),
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// Admin playback errors ---------------------------------------------------
|
|
|
|
export async function listAdminPlaybackErrors(
|
|
resolved: boolean = false
|
|
): Promise<AdminPlaybackError[]> {
|
|
return api.get<AdminPlaybackError[]>(
|
|
`/api/admin/playback-errors?resolved=${resolved}`
|
|
);
|
|
}
|
|
|
|
export async function resolvePlaybackError(
|
|
id: string,
|
|
resolution: PlaybackErrorResolution
|
|
): Promise<{ id: string }> {
|
|
return api.post<{ id: string }>(
|
|
`/api/admin/playback-errors/${id}/resolve`,
|
|
{ resolution }
|
|
);
|
|
}
|
|
|
|
export function createAdminPlaybackErrorsQuery(resolved: boolean = false) {
|
|
return createQuery({
|
|
queryKey: qk.adminPlaybackErrors(resolved),
|
|
queryFn: () => listAdminPlaybackErrors(resolved),
|
|
staleTime: 30_000
|
|
});
|
|
}
|
|
|
|
// Admin cover art ---------------------------------------------------------
|
|
|
|
export type RefetchAlbumCoverResponse = {
|
|
album_id: string;
|
|
cover_art_path: string | null;
|
|
cover_art_source: string | null;
|
|
};
|
|
|
|
export async function refetchAlbumCover(albumId: string): Promise<RefetchAlbumCoverResponse> {
|
|
return api.post<RefetchAlbumCoverResponse>(`/api/admin/albums/${albumId}/cover/refetch`, {});
|
|
}
|
|
|
|
export type RefetchMissingResponse = {
|
|
started: boolean;
|
|
};
|
|
|
|
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
|
|
return api.post<RefetchMissingResponse>('/api/admin/covers/refetch-missing', {});
|
|
}
|
|
|
|
// Library scan -------------------------------------------------------------
|
|
|
|
export type ScanStageLibrary = {
|
|
scanned: number;
|
|
added: number;
|
|
updated: number;
|
|
skipped: number;
|
|
errored: number;
|
|
};
|
|
|
|
export type ScanStageMbidBackfill = {
|
|
processed: number;
|
|
healed: number;
|
|
skipped: number;
|
|
duplicates?: number;
|
|
};
|
|
|
|
export type ScanStageCoverEnrich = {
|
|
processed: number;
|
|
succeeded: number;
|
|
failed: number;
|
|
};
|
|
|
|
export type ScanStageArtistArtEnrich = {
|
|
processed: number;
|
|
succeeded: number;
|
|
failed: number;
|
|
};
|
|
|
|
export type ScanStatus = {
|
|
id: string;
|
|
started_at: string;
|
|
finished_at: string | null;
|
|
library?: ScanStageLibrary;
|
|
mbid_backfill?: ScanStageMbidBackfill;
|
|
cover_enrich?: ScanStageCoverEnrich;
|
|
artist_art_enrich?: ScanStageArtistArtEnrich;
|
|
error_message?: string;
|
|
in_flight: boolean;
|
|
};
|
|
|
|
export async function getScanStatus(): Promise<ScanStatus> {
|
|
return api.get<ScanStatus>('/api/admin/scan/status');
|
|
}
|
|
|
|
export type TriggerScanResp = { id?: string };
|
|
|
|
export async function triggerScan(): Promise<TriggerScanResp> {
|
|
return api.post<TriggerScanResp>('/api/admin/scan/run', {});
|
|
}
|
|
|
|
// Polls every 3s while a scan is in flight; falls back to a 30s stale-time
|
|
// when idle so the section auto-updates as workers complete each stage.
|
|
export function createScanStatusQuery() {
|
|
return createQuery({
|
|
queryKey: qk.scanStatus(),
|
|
queryFn: getScanStatus,
|
|
staleTime: 30_000,
|
|
refetchInterval: 3_000
|
|
});
|
|
}
|
|
|
|
// Library coverage --------------------------------------------------------
|
|
|
|
export type CoverageRollup = {
|
|
total: number;
|
|
with_art: number;
|
|
pending: number;
|
|
settled: number;
|
|
pending_no_mbid: number;
|
|
};
|
|
|
|
export async function getCoverageRollup(): Promise<CoverageRollup> {
|
|
return api.get<CoverageRollup>('/api/admin/library/coverage');
|
|
}
|
|
|
|
// Same pacing as scan-status — 3s while polling, 30s stale-time. The
|
|
// query is cheap server-side (one aggregate per call) and we want
|
|
// the gauge to tick during a scan.
|
|
export function createCoverageQuery() {
|
|
return createQuery({
|
|
queryKey: qk.coverage(),
|
|
queryFn: getCoverageRollup,
|
|
staleTime: 30_000,
|
|
refetchInterval: 3_000
|
|
});
|
|
}
|
|
|
|
// Cover-art providers ------------------------------------------------------
|
|
|
|
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
|
|
|
|
export type CoverProvider = {
|
|
id: string;
|
|
display_name: string;
|
|
requires_api_key: boolean;
|
|
supports: CoverProviderCapability[];
|
|
enabled: boolean;
|
|
api_key_set: boolean;
|
|
display_order: number;
|
|
testable: boolean;
|
|
};
|
|
|
|
export type CoverProvidersResponse = {
|
|
providers: CoverProvider[];
|
|
sources_version: number;
|
|
};
|
|
|
|
export async function getCoverProviders(): Promise<CoverProvidersResponse> {
|
|
return api.get<CoverProvidersResponse>('/api/admin/cover-sources');
|
|
}
|
|
|
|
export type UpdateCoverProviderPatch = {
|
|
enabled?: boolean;
|
|
api_key?: string;
|
|
};
|
|
|
|
export type UpdateCoverProviderResponse = CoverProvider & {
|
|
version_bumped: boolean;
|
|
};
|
|
|
|
export async function updateCoverProvider(
|
|
id: string,
|
|
patch: UpdateCoverProviderPatch
|
|
): Promise<UpdateCoverProviderResponse> {
|
|
return api.patch<UpdateCoverProviderResponse>(`/api/admin/cover-sources/${id}`, patch);
|
|
}
|
|
|
|
export type TestCoverProviderResponse = {
|
|
ok: boolean;
|
|
duration_ms?: number;
|
|
error?: string;
|
|
};
|
|
|
|
export async function testCoverProvider(id: string): Promise<TestCoverProviderResponse> {
|
|
return api.post<TestCoverProviderResponse>(`/api/admin/cover-sources/${id}/test`, {});
|
|
}
|
|
|
|
// Re-search missing art --------------------------------------------------
|
|
|
|
export type ResearchMissingArtResponse = { version: number };
|
|
|
|
// Bumps cover_art_sources_meta.current_version. Every 'none' row becomes
|
|
// eligible for retry against the current provider chain on the next
|
|
// enrichment pass. Existing positively-sourced rows are not affected.
|
|
export async function researchMissingArt(): Promise<ResearchMissingArtResponse> {
|
|
return api.post<ResearchMissingArtResponse>('/api/admin/cover-sources/research', {});
|
|
}
|
|
|
|
// Settings rarely change in operator time. 60s staleTime; refetches
|
|
// on window focus + after any mutation rather than polling.
|
|
export function createCoverProvidersQuery() {
|
|
return createQuery({
|
|
queryKey: qk.coverProviders(),
|
|
queryFn: getCoverProviders,
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// Tag-enrichment providers -------------------------------------------------
|
|
// Parallel to the cover-art providers surface, over /api/admin/tag-sources.
|
|
// Independent settings so a new folksonomy source is added without touching
|
|
// art config (#1490).
|
|
|
|
export type TagProviderCapability = 'track_tags';
|
|
|
|
export type TagProvider = {
|
|
id: string;
|
|
display_name: string;
|
|
requires_api_key: boolean;
|
|
supports: TagProviderCapability[];
|
|
enabled: boolean;
|
|
api_key_set: boolean;
|
|
display_order: number;
|
|
testable: boolean;
|
|
};
|
|
|
|
export type TagProvidersResponse = {
|
|
providers: TagProvider[];
|
|
sources_version: number;
|
|
};
|
|
|
|
export async function getTagProviders(): Promise<TagProvidersResponse> {
|
|
return api.get<TagProvidersResponse>('/api/admin/tag-sources');
|
|
}
|
|
|
|
export type UpdateTagProviderPatch = {
|
|
enabled?: boolean;
|
|
api_key?: string;
|
|
};
|
|
|
|
export type UpdateTagProviderResponse = TagProvider & {
|
|
version_bumped: boolean;
|
|
};
|
|
|
|
export async function updateTagProvider(
|
|
id: string,
|
|
patch: UpdateTagProviderPatch
|
|
): Promise<UpdateTagProviderResponse> {
|
|
return api.patch<UpdateTagProviderResponse>(`/api/admin/tag-sources/${id}`, patch);
|
|
}
|
|
|
|
export type TestTagProviderResponse = {
|
|
ok: boolean;
|
|
duration_ms?: number;
|
|
error?: string;
|
|
};
|
|
|
|
export async function testTagProvider(id: string): Promise<TestTagProviderResponse> {
|
|
return api.post<TestTagProviderResponse>(`/api/admin/tag-sources/${id}/test`, {});
|
|
}
|
|
|
|
export function createTagProvidersQuery() {
|
|
return createQuery({
|
|
queryKey: qk.tagProviders(),
|
|
queryFn: getTagProviders,
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// Admin user-management ------------------------------------------------------
|
|
|
|
export type AdminUser = {
|
|
id: string;
|
|
username: string;
|
|
display_name: string | null;
|
|
is_admin: boolean;
|
|
auto_approve_requests: boolean;
|
|
debug_mode_enabled: boolean;
|
|
created_at: string;
|
|
};
|
|
|
|
export type AdminInvite = {
|
|
token: string;
|
|
invited_by: string;
|
|
note: string | null;
|
|
created_at: string;
|
|
expires_at: string;
|
|
redeemed_at: string | null;
|
|
redeemed_by: string | null;
|
|
};
|
|
|
|
export async function listUsers(): Promise<AdminUser[]> {
|
|
const res = await api.get<{ users: AdminUser[] }>('/api/admin/users');
|
|
return res.users;
|
|
}
|
|
|
|
export async function updateUserAdmin(id: string, isAdmin: boolean): Promise<AdminUser> {
|
|
return api.put<AdminUser>(`/api/admin/users/${id}/admin`, { is_admin: isAdmin });
|
|
}
|
|
|
|
export async function listInvites(): Promise<AdminInvite[]> {
|
|
const res = await api.get<{ invites: AdminInvite[] }>('/api/admin/invites');
|
|
return res.invites;
|
|
}
|
|
|
|
export async function createInvite(note?: string): Promise<AdminInvite> {
|
|
return api.post<AdminInvite>('/api/admin/invites', note ? { note } : {});
|
|
}
|
|
|
|
export async function deleteInvite(token: string): Promise<void> {
|
|
await api.del(`/api/admin/invites/${token}`);
|
|
}
|
|
|
|
// U2 user-management actions -----------------------------------------------
|
|
|
|
export type CreateUserInput = {
|
|
username: string;
|
|
password: string;
|
|
display_name?: string;
|
|
is_admin?: boolean;
|
|
};
|
|
|
|
export async function createUser(input: CreateUserInput): Promise<AdminUser> {
|
|
return api.post<AdminUser>('/api/admin/users', input);
|
|
}
|
|
|
|
export async function deleteUser(id: string): Promise<void> {
|
|
await api.del(`/api/admin/users/${id}`);
|
|
}
|
|
|
|
export async function resetUserPassword(id: string, password: string): Promise<void> {
|
|
await api.post(`/api/admin/users/${id}/reset-password`, { password });
|
|
}
|
|
|
|
export async function updateUserAutoApprove(id: string, autoApprove: boolean): Promise<AdminUser> {
|
|
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
|
|
}
|
|
|
|
// Flip an account's diagnostics/debug-reporting opt-in (M9). Admin-set;
|
|
// the client obeys it (with a local per-device OFF switch).
|
|
export async function updateUserDebugMode(id: string, enabled: boolean): Promise<AdminUser> {
|
|
return api.put<AdminUser>(`/api/admin/users/${id}/debug-mode`, { enabled });
|
|
}
|
|
|
|
export function createAdminUsersQuery() {
|
|
return createQuery({
|
|
queryKey: qk.adminUsers(),
|
|
queryFn: listUsers
|
|
});
|
|
}
|
|
|
|
export function createAdminInvitesQuery() {
|
|
return createQuery({
|
|
queryKey: qk.adminInvites(),
|
|
queryFn: listInvites
|
|
});
|
|
}
|
|
|
|
// SMTP config (U3) ----------------------------------------------------------
|
|
|
|
export type SMTPConfig = {
|
|
enabled: boolean;
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
password: string; // "***" when set, "" when unset
|
|
from_address: string;
|
|
from_name: string;
|
|
use_tls: boolean;
|
|
};
|
|
|
|
export async function getSMTPConfig(): Promise<SMTPConfig> {
|
|
return api.get<SMTPConfig>('/api/admin/smtp-config');
|
|
}
|
|
|
|
export async function updateSMTPConfig(input: SMTPConfig): Promise<void> {
|
|
await api.put('/api/admin/smtp-config', input);
|
|
}
|
|
|
|
export async function testSMTPConfig(): Promise<void> {
|
|
await api.post('/api/admin/smtp-config/test', {});
|
|
}
|
|
|
|
export function createSMTPConfigQuery() {
|
|
return createQuery({
|
|
queryKey: qk.smtpConfig(),
|
|
queryFn: getSMTPConfig,
|
|
staleTime: 60_000
|
|
});
|
|
}
|
|
|
|
// Device diagnostics (M9) ---------------------------------------------------
|
|
|
|
// Coarse event category. The finer event sub-type lives inside `payload`.
|
|
export type DiagnosticKind =
|
|
| 'connectivity'
|
|
| 'upnp_sync'
|
|
| 'power'
|
|
| 'lifecycle'
|
|
| 'heartbeat'
|
|
| 'http';
|
|
|
|
export type AdminDiagnostic = {
|
|
id: string;
|
|
user_id: string;
|
|
username: string;
|
|
client_id: string;
|
|
app_version?: string;
|
|
os_version?: string;
|
|
kind: DiagnosticKind | string;
|
|
payload: Record<string, unknown>;
|
|
occurred_at: string;
|
|
received_at: string;
|
|
};
|
|
|
|
export type AdminDiagnosticDevice = {
|
|
client_id: string;
|
|
user_id: string;
|
|
username: string;
|
|
app_version: string;
|
|
os_version: string;
|
|
last_seen: string;
|
|
event_count: number;
|
|
};
|
|
|
|
export type DiagnosticsFilter = {
|
|
userId?: string;
|
|
clientId?: string;
|
|
kind?: string;
|
|
from?: string; // RFC3339
|
|
to?: string; // RFC3339
|
|
limit?: number;
|
|
};
|
|
|
|
export async function listAdminDiagnostics(f: DiagnosticsFilter): Promise<AdminDiagnostic[]> {
|
|
const params = new URLSearchParams();
|
|
if (f.userId) params.set('user_id', f.userId);
|
|
if (f.clientId) params.set('client_id', f.clientId);
|
|
if (f.kind) params.set('kind', f.kind);
|
|
if (f.from) params.set('from', f.from);
|
|
if (f.to) params.set('to', f.to);
|
|
if (f.limit !== undefined) params.set('limit', String(f.limit));
|
|
const qs = params.toString();
|
|
return api.get<AdminDiagnostic[]>(qs ? `/api/admin/diagnostics?${qs}` : '/api/admin/diagnostics');
|
|
}
|
|
|
|
export async function listDiagnosticDevices(userId?: string): Promise<AdminDiagnosticDevice[]> {
|
|
const qs = userId ? `?user_id=${userId}` : '';
|
|
return api.get<AdminDiagnosticDevice[]>(`/api/admin/diagnostics/devices${qs}`);
|
|
}
|
|
|
|
export function createAdminDiagnosticsQuery(f: DiagnosticsFilter) {
|
|
return createQuery({
|
|
queryKey: qk.adminDiagnostics(f as Record<string, string | number | undefined>),
|
|
queryFn: () => listAdminDiagnostics(f),
|
|
// The operator enables debug then watches events stream in; a short
|
|
// poll keeps the timeline live without manual refresh.
|
|
refetchInterval: 10_000,
|
|
staleTime: 5_000
|
|
});
|
|
}
|
|
|
|
export function createDiagnosticDevicesQuery(userId?: string) {
|
|
return createQuery({
|
|
queryKey: qk.adminDiagnosticDevices(userId),
|
|
queryFn: () => listDiagnosticDevices(userId),
|
|
staleTime: 15_000
|
|
});
|
|
}
|
|
|
|
// Trusted-proxy depth (#2453) ---------------------------------------------
|
|
|
|
// detected_client_ip / forwarded_chain / remote_addr describe THIS request
|
|
// under the current setting, so the admin card can be verified rather than
|
|
// reasoned about: change the number, see what address you resolve to.
|
|
export type NetworkSettings = {
|
|
trusted_proxy_hops: number;
|
|
max_hops: number;
|
|
detected_client_ip: string;
|
|
forwarded_chain: string;
|
|
remote_addr: string;
|
|
};
|
|
|
|
export async function getNetworkSettings(): Promise<NetworkSettings> {
|
|
return api.get<NetworkSettings>('/api/admin/network-settings');
|
|
}
|
|
|
|
// Returns the payload recomputed under the new value, so the card can show
|
|
// the effect immediately instead of requiring a reload.
|
|
export async function updateNetworkSettings(hops: number): Promise<NetworkSettings> {
|
|
return api.put<NetworkSettings>('/api/admin/network-settings', {
|
|
trusted_proxy_hops: hops
|
|
});
|
|
}
|